Skip to content

Commit 081d14b

Browse files
committed
fix(remote): retry downloads of published session scripts
1 parent 79ff421 commit 081d14b

6 files changed

Lines changed: 104 additions & 15 deletions

File tree

‎docs/adr/0016-active-session-script-publication.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,11 @@ An ordinary recording eligible for active-session publication has three states:
8888
A filesystem or target-collision failure leaves the recording ARMED, including its path and same-target `--force` authorization.
8989
Thus, the caller can correct the target or permissions and retry.
9090
ABORTED and PUBLISHED are terminal until the session is destroyed.
91-
In either state, `session save-script` fails with an explicit error and never writes.
91+
In either state, `session save-script` never records or publishes new script content.
92+
A PUBLISHED remote recording may return its retained immutable artifact for another caller-side download.
93+
This exception does not change the terminal recording state.
94+
A retry may choose another caller destination; overwrite permission applies only to the authorized destination.
95+
Local PUBLISHED recordings and all ABORTED recordings still refuse the command.
9296

9397
Every existing arming entry point respects terminality.
9498
On any existing session, `open --save-script` is rejected before app dispatch.

‎packages/daemon/src/core/session-script-publication-capability.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,16 @@ export function retargetActivePublication(
148148
}
149149

150150
/** Active publication succeeded: PUBLISHED is terminal, which is what stops recording. */
151-
export function markActivePublicationDone(session: SessionState, writtenPath: string): void {
152-
session.scriptPublication = markAuthoringPublished(publicationState(session), writtenPath);
151+
export function markActivePublicationDone(
152+
session: SessionState,
153+
writtenPath: string,
154+
actionCount: number,
155+
): void {
156+
session.scriptPublication = markAuthoringPublished(
157+
publicationState(session),
158+
writtenPath,
159+
actionCount,
160+
);
153161
}
154162

155163
/** Ordinary close publication succeeded: the authoring lifecycle reached `published`. */

‎packages/daemon/src/core/session-script-publication-state.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export type SessionScriptPublicationState =
4646
| Readonly<{
4747
kind: 'authoring';
4848
status: 'armed' | 'aborted' | 'published';
49+
publishedActionCount?: number;
4950
target: SessionScriptTarget;
5051
}>
5152
| Readonly<{
@@ -221,11 +222,13 @@ export function abortAuthoring(
221222
export function markAuthoringPublished(
222223
state: SessionScriptPublicationState,
223224
writtenPath: string,
225+
actionCount?: number,
224226
): SessionScriptPublicationState {
225227
if (state.kind !== 'authoring') return state;
226228
return {
227229
kind: 'authoring',
228230
status: 'published',
231+
...(actionCount !== undefined ? { publishedActionCount: actionCount } : {}),
229232
target: {
230233
kind: 'explicit',
231234
path: writtenPath,

‎packages/daemon/src/sessions/handlers/session-script-publication.ts‎

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import path from 'node:path';
2+
import fs from 'node:fs';
23
import type { DaemonResponse } from '@agent-device/kernel/contracts';
34
import { INTERNAL_COMMANDS } from '@agent-device/command-registry/catalog';
45
import { AppError, normalizeError } from '@agent-device/kernel/errors';
@@ -36,6 +37,8 @@ export function handleSessionScriptPublication(params: {
3637
);
3738
}
3839
const session = sessionStore.requireCurrent(ref);
40+
const retained = retainedPublication(req, session);
41+
if (retained) return retained;
3942
const eligibilityError = validatePublicationEligibility(session);
4043
if (eligibilityError) return failure(eligibilityError);
4144

@@ -67,30 +70,82 @@ export function handleSessionScriptPublication(params: {
6770
);
6871
}
6972

70-
markActivePublicationDone(session, result.path);
73+
markActivePublicationDone(session, result.path, result.actionCount);
74+
return publicationResponse(session, result.path, result.actionCount);
75+
}
76+
77+
function retainedPublication(
78+
req: DaemonRequest,
79+
session: SessionState,
80+
): DaemonResponse | undefined {
81+
const state = session.scriptPublication;
82+
if (
83+
state?.kind !== 'authoring' ||
84+
state.status !== 'published' ||
85+
state.target.kind !== 'explicit' ||
86+
!state.target.clientPath
87+
)
88+
return undefined;
89+
if (!fs.existsSync(state.target.path)) {
90+
return failure(
91+
new AppError(
92+
'COMMAND_FAILED',
93+
'The published script artifact has expired or is unavailable.',
94+
{
95+
reason: 'script_artifact_unavailable',
96+
hint: 'The recording is terminal. Start a fresh authoring session to record another journey.',
97+
},
98+
),
99+
);
100+
}
101+
const requested = req.meta?.clientArtifactPaths?.savedScript;
102+
if ((req.positionals?.length ?? 0) > 0 && !requested) {
103+
return failure(
104+
new AppError(
105+
'INVALID_ARGS',
106+
'A published remote script accepts only a caller-side download destination.',
107+
),
108+
);
109+
}
110+
const force =
111+
req.flags?.force === true ||
112+
((!requested || requested === state.target.clientPath) && state.target.force);
113+
return publicationResponse(session, state.target.path, state.publishedActionCount ?? 0, {
114+
path: requested ?? state.target.clientPath,
115+
force,
116+
});
117+
}
118+
119+
function publicationResponse(
120+
session: SessionState,
121+
writtenPath: string,
122+
actionCount: number,
123+
download?: { path: string; force: boolean },
124+
): DaemonResponse {
71125
const state = session.scriptPublication;
72126
const target = state?.kind === 'authoring' ? state.target : undefined;
127+
const localPath = download?.path ?? (target?.kind === 'explicit' ? target.clientPath : undefined);
73128
return {
74129
ok: true,
75130
data: {
76131
session: session.name,
77-
savedScript: result.path,
78-
...(target?.kind === 'explicit' && target.clientPath
132+
savedScript: writtenPath,
133+
actionCount,
134+
...(localPath && target?.kind === 'explicit'
79135
? {
80136
artifacts: [
81137
{
82138
field: 'savedScript',
83139
artifactType: 'session-script',
84-
path: result.path,
85-
localPath: target.clientPath,
86-
fileName: path.basename(target.clientPath),
87-
force: target.force,
140+
path: writtenPath,
141+
localPath,
142+
fileName: path.basename(localPath),
143+
force: download?.force ?? target.force,
88144
},
89145
],
90146
}
91147
: {}),
92-
actionCount: result.actionCount,
93-
...successText(`Published script: ${result.path}`),
148+
...successText(`Published script: ${writtenPath}`),
94149
},
95150
};
96151
}

‎packages/remote/src/artifact-download.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ async function materializeScriptArtifact(
245245
: 'script_download_failed',
246246
path: destination,
247247
artifactId,
248-
hint: 'The daemon already published the script. Retry downloading the retained artifact by ID; do not repeat the device journey.',
248+
hint: 'The daemon already published the script. Retry session save-script with --force to replace the caller file, or choose another caller path. The command downloads the retained script without repeating the device journey.',
249249
},
250250
error instanceof Error ? error : undefined,
251251
);

‎test/integration/provider-scenarios/remote-script-publication.test.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { createAndroidSettingsWorld } from './android-world.ts';
88
import { withProviderScenarioResource } from './harness.ts';
99
import { createProviderScenarioProxy } from './harness-proxy.ts';
1010

11-
for (const publication of ['active', 'active-force'] as const) {
11+
for (const publication of ['active', 'active-force', 'retry-existing'] as const) {
1212
test(`remote ${publication} publication downloads a reusable script through the owning proxy`, async () => {
1313
await withProviderScenarioResource(
1414
() => createAndroidSettingsWorld({ nativeTextInjection: true }),
@@ -30,10 +30,29 @@ for (const publication of ['active', 'active-force'] as const) {
3030
});
3131
try {
3232
await recordRemoteJourney(world, client, publication, destination);
33+
if (publication === 'retry-existing') {
34+
fs.mkdirSync(path.dirname(destination), { recursive: true });
35+
fs.writeFileSync(destination, 'protected');
36+
await assert.rejects(
37+
client.sessions.saveScript({ path: destination }),
38+
(error: unknown) =>
39+
(error as { details?: { reason?: string } }).details?.reason ===
40+
'script_target_exists',
41+
);
42+
assert.equal(fs.readFileSync(destination, 'utf8'), 'protected');
43+
}
3344
const published = await client.sessions.saveScript(
34-
publication === 'active-force' ? { path: destination } : {},
45+
publication === 'retry-existing'
46+
? { path: destination, force: true }
47+
: publication === 'active-force'
48+
? { path: destination }
49+
: {},
3550
);
3651
assert.equal(published.savedScript, destination);
52+
fs.unlinkSync(destination);
53+
const retry = await client.sessions.saveScript();
54+
assert.equal(retry.actionCount, published.actionCount);
55+
assert.equal(retry.savedScript, destination);
3756
const text = fs.readFileSync(destination, 'utf8');
3857
assert.match(text, /open/);
3958
assert.match(text, /agent-device:target-v1/);

0 commit comments

Comments
 (0)