Skip to content

Commit a91318c

Browse files
committed
fix: scope implicit sessions by caller workspace
1 parent 219a12b commit a91318c

17 files changed

Lines changed: 277 additions & 61 deletions

src/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
231231
lockPolicy: binding.lockPolicy,
232232
lockPlatform: binding.defaultPlatform,
233233
cwd: process.cwd(),
234+
sessionExplicit: currentFlags.session !== undefined,
234235
debug: debugOutputEnabled,
235236
});
236237
let parsedBatchSteps: BatchStep[] | undefined;

src/client-normalizers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ export function buildMeta(options: InternalRequestOptions): DaemonRequest['meta'
333333
return stripUndefined({
334334
requestId: options.requestId,
335335
cwd: options.cwd,
336+
sessionExplicit: options.sessionExplicit ?? options.session !== undefined,
336337
debug: options.debug,
337338
lockPolicy: options.lockPolicy,
338339
lockPlatform: options.lockPlatform,

src/client-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export type AgentDeviceClientConfig = {
5757
leaseBackend?: LeaseBackend;
5858
runtime?: SessionRuntimeHints;
5959
cwd?: string;
60+
sessionExplicit?: boolean;
6061
debug?: boolean;
6162
};
6263

@@ -76,6 +77,7 @@ export type AgentDeviceRequestOverrides = Pick<
7677
| 'leaseId'
7778
| 'leaseBackend'
7879
| 'cwd'
80+
| 'sessionExplicit'
7981
| 'debug'
8082
>;
8183

src/contracts.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export type DaemonRequestMeta = {
4343
requestId?: string;
4444
debug?: boolean;
4545
cwd?: string;
46+
sessionExplicit?: boolean;
4647
tenantId?: string;
4748
runId?: string;
4849
leaseId?: string;
@@ -348,6 +349,7 @@ export const daemonCommandRequestSchema = schema<DaemonRequest>((input, path) =>
348349
requestId: optionalString(meta, 'requestId', `${path}.meta`),
349350
debug: optionalBoolean(meta, 'debug', `${path}.meta`),
350351
cwd: optionalString(meta, 'cwd', `${path}.meta`),
352+
sessionExplicit: optionalBoolean(meta, 'sessionExplicit', `${path}.meta`),
351353
tenantId: optionalString(meta, 'tenantId', `${path}.meta`),
352354
runId: optionalString(meta, 'runId', `${path}.meta`),
353355
leaseId: optionalString(meta, 'leaseId', `${path}.meta`),

src/daemon-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<D
156156
requestId,
157157
debug,
158158
cwd: req.meta?.cwd,
159+
sessionExplicit: req.meta?.sessionExplicit,
159160
tenantId: req.meta?.tenantId ?? req.flags?.tenant,
160161
runId: req.meta?.runId ?? req.flags?.runId,
161162
leaseId: req.meta?.leaseId ?? req.flags?.leaseId,

src/daemon/__tests__/request-router-replay-scope.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ test('replay runs active-session actions inside the parent request provider scop
4343
session: 'default',
4444
command: 'replay',
4545
positionals: [replayPath],
46-
meta: { cwd: root, requestId: 'replay-scope-1' },
46+
meta: { cwd: root, requestId: 'replay-scope-1', sessionExplicit: true },
4747
});
4848

4949
expect(response).toMatchObject({ ok: true });
@@ -73,7 +73,7 @@ test('replay routes session-changing actions through the full request path', asy
7373
session: 'default',
7474
command: 'replay',
7575
positionals: [replayPath],
76-
meta: { cwd: root, requestId: 'replay-scope-2' },
76+
meta: { cwd: root, requestId: 'replay-scope-2', sessionExplicit: true },
7777
});
7878

7979
expect(response).toMatchObject({ ok: true });

src/daemon/__tests__/request-router-screenshot.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ test('screenshot resolves relative positional path against request cwd', async (
9797
session: 'default',
9898
command: 'screenshot',
9999
positionals: ['evidence/test.png'],
100-
meta: { cwd: callerCwd, requestId: 'req-1' },
100+
meta: { cwd: callerCwd, requestId: 'req-1', sessionExplicit: true },
101101
});
102102

103103
expect(capturedPath).toBeTruthy();
@@ -287,7 +287,7 @@ test('screenshot keeps absolute positional path unchanged', async () => {
287287
session: 'default',
288288
command: 'screenshot',
289289
positionals: [absolutePath],
290-
meta: { cwd: '/some/other/dir', requestId: 'req-2' },
290+
meta: { cwd: '/some/other/dir', requestId: 'req-2', sessionExplicit: true },
291291
});
292292

293293
expect(capturedPath).toBe(absolutePath);
@@ -359,7 +359,7 @@ test('screenshot resolves --out flag path against request cwd', async () => {
359359
command: 'screenshot',
360360
positionals: [],
361361
flags: { out: 'evidence/test.png' },
362-
meta: { cwd: callerCwd, requestId: 'req-3' },
362+
meta: { cwd: callerCwd, requestId: 'req-3', sessionExplicit: true },
363363
});
364364

365365
expect(capturedOut).toBeTruthy();

src/daemon/__tests__/session-routing.test.ts

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import fs from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
66
import { SessionStore } from '../session-store.ts';
7-
import { resolveEffectiveSessionName } from '../session-routing.ts';
7+
import {
8+
resolveEffectiveSessionName,
9+
resolveImplicitSessionScope,
10+
sessionMatchesScope,
11+
} from '../session-routing.ts';
812
import type { SessionState } from '../types.ts';
913

1014
function makeSession(name: string): SessionState {
@@ -30,9 +34,13 @@ function makeStore(t: TestContext): SessionStore {
3034
return new SessionStore(path.join(root, 'sessions'));
3135
}
3236

33-
test('reuses lone active session for implicit default session', (t) => {
37+
test('does not reuse lone active session for implicit default session from another scope', (t) => {
3438
const store = makeStore(t);
3539
store.set('android', makeSession('android'));
40+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cwd-scope-'));
41+
t.onTestFinished(() => {
42+
fs.rmSync(cwd, { recursive: true, force: true });
43+
});
3644

3745
const resolved = resolveEffectiveSessionName(
3846
{
@@ -41,9 +49,98 @@ test('reuses lone active session for implicit default session', (t) => {
4149
command: 'open',
4250
positionals: ['com.google.android.apps.maps'],
4351
flags: {},
52+
meta: { cwd },
53+
},
54+
store,
55+
);
56+
57+
assert.match(resolved, /^cwd:[a-f0-9]{16}:default$/);
58+
assert.notEqual(resolved, 'android');
59+
});
60+
61+
test('uses git worktree root for implicit default session scope', (t) => {
62+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cwd-scope-'));
63+
const nested = path.join(root, 'packages', 'app');
64+
fs.mkdirSync(path.join(root, '.git'));
65+
fs.mkdirSync(nested, { recursive: true });
66+
t.onTestFinished(() => {
67+
fs.rmSync(root, { recursive: true, force: true });
68+
});
69+
70+
const store = makeStore(t);
71+
const fromRoot = resolveEffectiveSessionName(
72+
{
73+
token: 't',
74+
session: 'default',
75+
command: 'snapshot',
76+
positionals: [],
77+
flags: {},
78+
meta: { cwd: root },
79+
},
80+
store,
81+
);
82+
const fromNested = resolveEffectiveSessionName(
83+
{
84+
token: 't',
85+
session: 'default',
86+
command: 'snapshot',
87+
positionals: [],
88+
flags: {},
89+
meta: { cwd: nested },
4490
},
4591
store,
4692
);
4793

48-
assert.equal(resolved, 'android');
94+
assert.equal(fromNested, fromRoot);
95+
});
96+
97+
test('keeps explicitly configured default session global', (t) => {
98+
const store = makeStore(t);
99+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cwd-scope-'));
100+
t.onTestFinished(() => {
101+
fs.rmSync(cwd, { recursive: true, force: true });
102+
});
103+
104+
const resolved = resolveEffectiveSessionName(
105+
{
106+
token: 't',
107+
session: 'default',
108+
command: 'snapshot',
109+
positionals: [],
110+
flags: {},
111+
meta: { cwd, sessionExplicit: true },
112+
},
113+
store,
114+
);
115+
116+
assert.equal(resolved, 'default');
117+
});
118+
119+
test('matches sessions only within the same implicit scope', (t) => {
120+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cwd-scope-'));
121+
t.onTestFinished(() => {
122+
fs.rmSync(cwd, { recursive: true, force: true });
123+
});
124+
const req = {
125+
token: 't',
126+
session: 'default',
127+
command: 'session_list',
128+
positionals: [],
129+
flags: {},
130+
meta: { cwd },
131+
};
132+
const scope = resolveImplicitSessionScope(req);
133+
assert.ok(scope);
134+
135+
assert.equal(
136+
sessionMatchesScope({ ...makeSession('default'), sessionScope: scope }, scope),
137+
true,
138+
);
139+
assert.equal(
140+
sessionMatchesScope(
141+
{ ...makeSession('default'), sessionScope: { kind: 'cwd', id: 'other' } },
142+
scope,
143+
),
144+
false,
145+
);
49146
});

src/daemon/handlers/record-trace-recording.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS,
4141
stopIosSimulatorRecordingProcess,
4242
} from './record-trace-ios-simulator.ts';
43+
import { resolveImplicitSessionScope, resolvePublicSessionName } from '../session-routing.ts';
4344

4445
const IOS_DEVICE_RECORD_MIN_FPS = 1;
4546
const IOS_DEVICE_RECORD_MAX_FPS = 120;
@@ -531,6 +532,7 @@ function deriveClientTelemetryPath(
531532

532533
function releaseRecordOnlySession(
533534
sessionStore: SessionStore,
535+
sessionName: string,
534536
session: SessionState,
535537
options: { writeLog?: boolean } = {},
536538
): void {
@@ -540,7 +542,7 @@ function releaseRecordOnlySession(
540542
if (options.writeLog) {
541543
sessionStore.writeSessionLog(session);
542544
}
543-
sessionStore.delete(session.name);
545+
sessionStore.delete(sessionName);
544546
}
545547

546548
// --- Main command handler ---
@@ -562,7 +564,8 @@ export async function handleRecordCommand(params: {
562564
const activeSession =
563565
session ??
564566
({
565-
name: sessionName,
567+
name: resolvePublicSessionName(req),
568+
sessionScope: resolveImplicitSessionScope(req),
566569
device,
567570
createdAt: Date.now(),
568571
recordOnlySession: true,
@@ -580,7 +583,7 @@ export async function handleRecordCommand(params: {
580583

581584
const response = await stopRecording({ req, activeSession, device, logPath, deps });
582585
if (!response.ok) {
583-
releaseRecordOnlySession(sessionStore, activeSession);
586+
releaseRecordOnlySession(sessionStore, sessionName, activeSession);
584587
return response;
585588
}
586589

@@ -594,6 +597,6 @@ export async function handleRecordCommand(params: {
594597
showTouches: response.data?.showTouches,
595598
},
596599
});
597-
releaseRecordOnlySession(sessionStore, activeSession, { writeLog: true });
600+
releaseRecordOnlySession(sessionStore, sessionName, activeSession, { writeLog: true });
598601
return response;
599602
}

src/daemon/handlers/session-close.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ export async function handleCloseCommand(params: {
166166
command: 'close',
167167
positionals: req.positionals ?? [],
168168
flags: req.flags ?? {},
169-
result: { session: sessionName, ...successText(`Closed: ${sessionName}`) },
169+
result: { session: session.name, ...successText(`Closed: ${session.name}`) },
170170
});
171171
if (req.flags?.saveScript) {
172172
session.recordSession = true;
@@ -182,12 +182,12 @@ export async function handleCloseCommand(params: {
182182
return {
183183
ok: true,
184184
data: withSuccessText(
185-
{ session: sessionName, shutdown: shutdownResult },
186-
`Closed: ${sessionName}`,
185+
{ session: session.name, shutdown: shutdownResult },
186+
`Closed: ${session.name}`,
187187
),
188188
};
189189
}
190-
return { ok: true, data: { session: sessionName, ...successText(`Closed: ${sessionName}`) } };
190+
return { ok: true, data: { session: session.name, ...successText(`Closed: ${session.name}`) } };
191191
}
192192

193193
async function closeWithoutSession(req: DaemonRequest, logPath: string): Promise<DaemonResponse> {

0 commit comments

Comments
 (0)