@agent-relay/sandbox 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,110 @@
1
+ import { type RelayfileMountDaemonOptions, type RelayfileMountShellOptions } from "./mount-script.js";
2
+ export type SandboxCommandResult = {
3
+ output: string;
4
+ exitCode: number | null;
5
+ cmdId?: string;
6
+ };
7
+ export type SandboxOutputChunk = {
8
+ stream: "combined";
9
+ text: string;
10
+ };
11
+ export type SandboxCapturedOutput = {
12
+ output: string;
13
+ chunks: SandboxOutputChunk[];
14
+ exitCode: number | null;
15
+ cmdId?: string;
16
+ startedAt: string;
17
+ endedAt: string;
18
+ durationMs: number;
19
+ };
20
+ export type SandboxRunScriptOptions = {
21
+ command: string;
22
+ cwd?: string;
23
+ env?: Record<string, string>;
24
+ timeoutMs?: number;
25
+ sessionId?: string;
26
+ };
27
+ export type SandboxProvisionOptions = {
28
+ label?: string;
29
+ name?: string;
30
+ workdir?: string;
31
+ env?: Record<string, string>;
32
+ labels?: Record<string, string>;
33
+ createTimeoutSeconds?: number;
34
+ };
35
+ export type SandboxBundleFile = {
36
+ source: string | Buffer;
37
+ destination: string;
38
+ };
39
+ export type SandboxOrchestratorRuntime<Handle> = {
40
+ provision?: (options?: SandboxProvisionOptions) => Promise<Handle>;
41
+ uploadBundle?: (handle: Handle, files: readonly SandboxBundleFile[]) => Promise<void>;
42
+ runScript: (handle: Handle, options: SandboxRunScriptOptions) => Promise<SandboxCommandResult>;
43
+ teardown?: (handle: Handle) => Promise<void>;
44
+ };
45
+ export type RelayfileMountHandle = {
46
+ pid?: string;
47
+ };
48
+ export type StartMountOptions = {
49
+ cwd?: string;
50
+ /** @deprecated Use initialSyncIdleTimeoutMs; kept for existing call sites. */
51
+ initialSyncTimeoutMs?: number;
52
+ initialSyncIdleTimeoutMs?: number;
53
+ /** @deprecated No longer used: the initial sync is polled, not one exec. */
54
+ timeoutMs?: number;
55
+ /**
56
+ * Overall wall-clock budget for the polled initial sync. Unlike the idle
57
+ * timeout (which cancels a *stalled* sync in-sandbox), this bounds a sync
58
+ * that keeps progressing — pick it to fit the caller's step/lease budget.
59
+ */
60
+ initialSyncDeadlineMs?: number;
61
+ /** Cadence of the short status-probe execs. */
62
+ initialSyncPollIntervalMs?: number;
63
+ killExisting?: boolean;
64
+ };
65
+ export type FlushMountOptions = {
66
+ cwd?: string;
67
+ timeoutMs?: number;
68
+ };
69
+ export type StopMountOptions = FlushMountOptions;
70
+ export declare class SandboxOrchestrator<Handle> {
71
+ private readonly runtime;
72
+ constructor(runtime: SandboxOrchestratorRuntime<Handle>);
73
+ provision(options?: SandboxProvisionOptions): Promise<Handle>;
74
+ uploadBundle(handle: Handle, files: readonly SandboxBundleFile[]): Promise<void>;
75
+ runScript(handle: Handle, options: SandboxRunScriptOptions): Promise<SandboxCapturedOutput>;
76
+ captureOutput(result: SandboxCommandResult, startedAt?: string, started?: number): SandboxCapturedOutput;
77
+ startMount(handle: Handle, config: RelayfileMountDaemonOptions, options?: StartMountOptions): Promise<RelayfileMountHandle>;
78
+ flushMount(handle: Handle, config: RelayfileMountShellOptions, options?: FlushMountOptions): Promise<void>;
79
+ stopMount(handle: Handle, mount: RelayfileMountHandle, config: RelayfileMountShellOptions, options?: StopMountOptions): Promise<void>;
80
+ teardown(handle: Handle): Promise<void>;
81
+ }
82
+ export type RelayfileMountLifecycleShellOptions = {
83
+ mount: (Omit<RelayfileMountShellOptions, "localDir"> & {
84
+ interval?: string;
85
+ logPath?: string;
86
+ }) | null;
87
+ localDir: string;
88
+ initialSyncPaths?: readonly string[];
89
+ flushTimeoutSeconds?: number;
90
+ initialSyncIdleTimeoutSeconds?: number;
91
+ continueOnInitialSyncFailure?: boolean;
92
+ cleanupStatusMessage?: string;
93
+ /**
94
+ * Local mount directories that hold writeback command drafts (e.g. the Slack
95
+ * `.../messages` + `.../threads/<id>/replies` roots). The cleanup probes
96
+ * these for files written during THIS run so a dropped writeback can be
97
+ * surfaced as a loud, command-specific failure rather than a swallowed
98
+ * teardown warning. Empty/omitted → no command-draft probe.
99
+ */
100
+ commandRootLocalDirs?: readonly string[];
101
+ mountLogTail?: {
102
+ startMarker: string;
103
+ endMarker: string;
104
+ bytes: number;
105
+ lines: number;
106
+ };
107
+ };
108
+ export declare function buildRelayfileMountLifecycleShell(options: RelayfileMountLifecycleShellOptions): string;
109
+ export declare function buildRelayfileMountCleanupInvocationShell(mount: unknown | null): string;
110
+ //# sourceMappingURL=orchestrator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"orchestrator.d.ts","sourceRoot":"","sources":["../src/orchestrator.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,553 @@
1
+ import { buildRelayfileMountCleanupFlushShell, buildRelayfileMountFlushShell, buildRelayfileMountInitialSyncBackgroundShell, buildRelayfileMountInitialSyncKillShell, buildRelayfileMountInitialSyncLogTailShell, buildRelayfileMountInitialSyncShell, buildRelayfileMountInitialSyncStatusShell, buildRelayfileMountStartShell, parseRelayfileMountInitialSyncStatus, } from "./mount-script.js";
2
+ function sleepMs(ms) {
3
+ return ms <= 0
4
+ ? Promise.resolve()
5
+ : new Promise((resolve) => setTimeout(resolve, ms));
6
+ }
7
+ function relayfileInitialSyncRunId() {
8
+ return `${Date.now()}-${Math.random().toString(36).slice(2) || "0"}`;
9
+ }
10
+ export class SandboxOrchestrator {
11
+ runtime;
12
+ constructor(runtime) {
13
+ this.runtime = runtime;
14
+ }
15
+ async provision(options) {
16
+ if (!this.runtime.provision) {
17
+ throw new Error("SandboxOrchestrator runtime does not support provision");
18
+ }
19
+ return this.runtime.provision(options);
20
+ }
21
+ async uploadBundle(handle, files) {
22
+ if (!this.runtime.uploadBundle) {
23
+ throw new Error("SandboxOrchestrator runtime does not support uploadBundle");
24
+ }
25
+ await this.runtime.uploadBundle(handle, files);
26
+ }
27
+ async runScript(handle, options) {
28
+ const started = Date.now();
29
+ const startedAt = new Date(started).toISOString();
30
+ const result = await this.runtime.runScript(handle, options);
31
+ return this.captureOutput(result, startedAt, started);
32
+ }
33
+ captureOutput(result, startedAt = new Date().toISOString(), started = Date.parse(startedAt)) {
34
+ const ended = Date.now();
35
+ if (typeof result.output !== "string") {
36
+ throw new Error("SandboxOrchestrator runtime adapter must return merged output; split stdout/stderr results are not accepted");
37
+ }
38
+ const output = result.output;
39
+ return {
40
+ output,
41
+ chunks: output ? [{ stream: "combined", text: output }] : [],
42
+ exitCode: result.exitCode,
43
+ ...(result.cmdId !== undefined ? { cmdId: result.cmdId } : {}),
44
+ startedAt,
45
+ endedAt: new Date(ended).toISOString(),
46
+ durationMs: Number.isFinite(started) ? Math.max(0, ended - started) : 0,
47
+ };
48
+ }
49
+ async startMount(handle, config, options = {}) {
50
+ const cwd = options.cwd;
51
+ const mkdir = await this.runtime.runScript(handle, {
52
+ command: `mkdir -p ${shellQuote(config.localDir)}`,
53
+ cwd,
54
+ });
55
+ if (mkdir.exitCode !== 0) {
56
+ throw new Error(`Failed to create relayfile mount path: ${mkdir.output}`);
57
+ }
58
+ if (options.killExisting) {
59
+ await this.runtime.runScript(handle, {
60
+ command: "pkill -f '(^|/)relayfile-mount( |$)' 2>/dev/null || true",
61
+ cwd,
62
+ });
63
+ }
64
+ const idleTimeoutMs = options.initialSyncIdleTimeoutMs ?? options.initialSyncTimeoutMs ?? 60_000;
65
+ const initialSyncIdleTimeoutSeconds = relayfileBootstrapIdleTimeoutSeconds(idleTimeoutMs / 1000);
66
+ const start = await this.runtime.runScript(handle, {
67
+ command: withRelayfileBootstrapIdleTimeout(buildRelayfileMountStartShell(config), initialSyncIdleTimeoutSeconds),
68
+ cwd,
69
+ });
70
+ if (start.exitCode !== 0) {
71
+ throw new Error(`Failed to start relayfile mount: ${start.output}`);
72
+ }
73
+ // The initial sync can outlive any single exec (Daytona's proxy read
74
+ // timeout is ~120s and callers wrap execs in client-side fail-fasts), so
75
+ // it runs detached in the sandbox — keeping the in-sandbox idle watchdog
76
+ // — while we poll its exit sentinel with short, idempotent execs.
77
+ const initialSyncRun = { runId: relayfileInitialSyncRunId() };
78
+ const launch = await this.runtime.runScript(handle, {
79
+ command: withRelayfileBootstrapIdleTimeout(buildRelayfileMountInitialSyncBackgroundShell({
80
+ ...config,
81
+ idleTimeoutSeconds: initialSyncIdleTimeoutSeconds,
82
+ }, initialSyncRun), initialSyncIdleTimeoutSeconds),
83
+ cwd,
84
+ });
85
+ if (launch.exitCode !== 0) {
86
+ throw new Error(`Failed to launch relayfile initial sync: ${launch.output}`);
87
+ }
88
+ const deadlineMs = options.initialSyncDeadlineMs ?? 240_000;
89
+ const pollIntervalMs = options.initialSyncPollIntervalMs ?? 2_000;
90
+ const deadline = Date.now() + deadlineMs;
91
+ // The probe prints exactly one of two markers. Output with neither means
92
+ // the exec channel is not actually reaching our probe (a broken runtime
93
+ // adapter, a proxy interposing its own body) — fail fast after a couple
94
+ // of confirmations instead of polling garbage until the deadline.
95
+ let unknownStatusCount = 0;
96
+ for (;;) {
97
+ const status = await this.runtime.runScript(handle, {
98
+ command: buildRelayfileMountInitialSyncStatusShell(initialSyncRun),
99
+ cwd,
100
+ });
101
+ if (status.exitCode !== 0) {
102
+ throw new Error(`Failed to check relayfile initial sync status: ${status.output}`);
103
+ }
104
+ const parsed = parseRelayfileMountInitialSyncStatus(status.output);
105
+ if (parsed.state === "unknown") {
106
+ unknownStatusCount += 1;
107
+ if (unknownStatusCount >= 3) {
108
+ throw new Error(`Relayfile initial sync status probe returned unrecognized output: ${status.output.trim().slice(0, 200)}`);
109
+ }
110
+ await sleepMs(pollIntervalMs);
111
+ continue;
112
+ }
113
+ unknownStatusCount = 0;
114
+ if (parsed.state === "exited") {
115
+ if (parsed.exitCode === 0)
116
+ break;
117
+ const logTail = await this.runtime
118
+ .runScript(handle, {
119
+ command: buildRelayfileMountInitialSyncLogTailShell(40, initialSyncRun),
120
+ cwd,
121
+ })
122
+ .catch(() => null);
123
+ const detail = logTail?.output?.trim();
124
+ throw new Error(`Failed initial relayfile sync: exit ${parsed.exitCode}${detail ? `: ${detail}` : ""}`);
125
+ }
126
+ if (Date.now() >= deadline) {
127
+ await this.runtime
128
+ .runScript(handle, {
129
+ command: buildRelayfileMountInitialSyncKillShell(initialSyncRun),
130
+ cwd,
131
+ })
132
+ .catch(() => undefined);
133
+ throw new Error(`Relayfile initial sync did not finish within ${Math.ceil(deadlineMs / 1000)}s`);
134
+ }
135
+ await sleepMs(pollIntervalMs);
136
+ }
137
+ const pid = start.output.trim().split(/\s+/).at(-1);
138
+ return pid ? { pid } : {};
139
+ }
140
+ async flushMount(handle, config, options = {}) {
141
+ const result = await this.runtime.runScript(handle, {
142
+ command: buildRelayfileMountFlushShell(config),
143
+ cwd: options.cwd,
144
+ timeoutMs: options.timeoutMs ?? 120_000,
145
+ });
146
+ if (result.exitCode !== 0) {
147
+ throw new Error(`Failed to flush relayfile mount: ${result.output}`);
148
+ }
149
+ }
150
+ async stopMount(handle, mount, config, options = {}) {
151
+ try {
152
+ await this.flushMount(handle, config, options);
153
+ }
154
+ finally {
155
+ if (mount.pid) {
156
+ await this.runtime.runScript(handle, {
157
+ command: `kill ${shellQuote(mount.pid)} 2>/dev/null || true`,
158
+ cwd: options.cwd,
159
+ });
160
+ }
161
+ }
162
+ }
163
+ async teardown(handle) {
164
+ if (!this.runtime.teardown) {
165
+ throw new Error("SandboxOrchestrator runtime does not support teardown");
166
+ }
167
+ await this.runtime.teardown(handle);
168
+ }
169
+ }
170
+ export function buildRelayfileMountLifecycleShell(options) {
171
+ const mount = options.mount;
172
+ if (!mount)
173
+ return "";
174
+ const config = { ...mount, localDir: options.localDir };
175
+ const start = buildRelayfileMountStartShell(config);
176
+ // The post-handler cleanup flush uses `--flush-outbox-once` (O(outbox), no
177
+ // full-tree reconcile — the durable cure for cleanup flushes that time out
178
+ // on large mirrors) when the mount binary supports it, falling back to
179
+ // `--once` otherwise. The outer
180
+ // teardown timeout must exceed relayfile-mount's outbox flush deadline
181
+ // (RELAYFILE_OUTBOX_TIMEOUT, default 60s) or cleanup can SIGKILL a slow but
182
+ // healthy writeback before the independent outbox drain finishes.
183
+ const sync = buildRelayfileMountCleanupFlushShell(config);
184
+ const flushTimeoutSeconds = options.flushTimeoutSeconds ?? 75;
185
+ const initialSyncIdleTimeoutSeconds = relayfileBootstrapIdleTimeoutSeconds(options.initialSyncIdleTimeoutSeconds ?? 90);
186
+ const initialSync = (options.initialSyncPaths?.length ?? 0) > 0
187
+ ? buildRelayfileMountInitialSyncShell({
188
+ ...config,
189
+ paths: options.initialSyncPaths,
190
+ idleTimeoutSeconds: initialSyncIdleTimeoutSeconds,
191
+ })
192
+ : "";
193
+ return [
194
+ // Raise the relayfile-mount daemon's INTERNAL bootstrap no-progress
195
+ // watchdog (`RELAYFILE_BOOTSTRAP_IDLE_TIMEOUT`, a Go duration, default
196
+ // 90s) to MATCH the outer `buildIdleWatchedCommand` wrapper's idle
197
+ // timeout. They are a matched pair: the daemon's atomic full export does
198
+ // not report progress until the body fully returns, so on a
199
+ // slow-but-progressing export whichever watchdog is lower cancels the
200
+ // bootstrap mid-flight -> "non-empty without completed bootstrap -> force
201
+ // full reconcile" sticky loop. Value is a Go duration string ("<n>s");
202
+ // RELAYFILE_BOOTSTRAP_TIMEOUT is left UNSET (0 = unbounded while making
203
+ // progress) - a hard total cap could kill a legitimately long resumable
204
+ // pull. The `$(${start})` subshell and the initial-sync block below both
205
+ // inherit this export.
206
+ ...relayfileBootstrapIdleTimeoutEnvShell(initialSyncIdleTimeoutSeconds),
207
+ `if ! RELAYFILE_MOUNT_PID=$(${start}); then`,
208
+ " echo '[relayfile-mount] failed to start daemon' >&2",
209
+ " exit 1",
210
+ "fi",
211
+ // Probe ONCE for `--flush-outbox-once` support. The cleanup flush then
212
+ // runs `relayfile-mount "$relayfile_mount_flush_mode" ...` — O(outbox) on
213
+ // daemons that support it, `--once` on older binaries (inert / no
214
+ // regression). Probed here (not in the trap) so it runs once.
215
+ "relayfile_mount_flush_mode=--once",
216
+ "if relayfile-mount --help 2>&1 | grep -q -- 'flush-outbox-once'; then relayfile_mount_flush_mode=--flush-outbox-once; fi",
217
+ // Probe ONCE for `--push-local-once` — the teardown drain that
218
+ // ingests local drafts the running daemon never picked up (one pushLocal pass,
219
+ // no pullRemote/digest). Used below ONLY when pending local writes are detected;
220
+ // the outbox-only `--flush-outbox-once` stays the no-pending-writes fast path.
221
+ "relayfile_mount_push_local_supported=false",
222
+ "if relayfile-mount --help 2>&1 | grep -q -- 'push-local-once'; then relayfile_mount_push_local_supported=true; fi",
223
+ "relayfile_mount_cleanup() {",
224
+ " relayfile_mount_status=$?",
225
+ // Harness/backward safety: default the mode if the probe did not run in this
226
+ // shell context.
227
+ ' : "${relayfile_mount_flush_mode:=--once}"',
228
+ " relayfile_mount_has_pending_writes=false",
229
+ " relayfile_mount_kill_attempted=false",
230
+ " relayfile_mount_kill_status=0",
231
+ " relayfile_mount_pending_writeback=0",
232
+ " relayfile_mount_has_pending_writeback=false",
233
+ " relayfile_mount_outbox_needs_attention=false",
234
+ " relayfile_mount_command_draft=false",
235
+ // Empty = null = "not computed" (mount without receipt support, node
236
+ // absent, or precondition violated) → the TS gate feature-detects this and
237
+ // falls back to the outbox-pending signals above. A number is the positive
238
+ // adapter-dispatch-receipt count.
239
+ " relayfile_mount_command_drafts_undeliverable=",
240
+ ` if [ -n "\${RELAYFILE_MOUNT_FLUSH_MARKER:-}" ] && [ -d ${shellQuote(options.localDir)} ]; then`,
241
+ ` relayfile_mount_pending_path=$(find ${shellQuote(options.localDir)} \\( -name '.git' -o -name 'node_modules' -o -name '.agent-relay' \\) -prune -o \\( -type f -o -type d \\) -newer "$RELAYFILE_MOUNT_FLUSH_MARKER" ! -name '.relayfile-mount-state.json' ! -name '..relayfile-mount-state.json.tmp-*' -print -quit 2>/dev/null || true)`,
242
+ ' if [ -n "$relayfile_mount_pending_path" ]; then',
243
+ " relayfile_mount_has_pending_writes=true",
244
+ // A draft written after the daemon's last sync cycle (e.g. a final
245
+ // fire-and-forget reply right before teardown) is on disk but not yet in the
246
+ // outbox, so the outbox-only flush would drop it. When such pending writes
247
+ // exist and the binary supports it, upgrade the cleanup to push-local-once so
248
+ // the on-disk mirror is scanned and the draft is ingested before the flush.
249
+ ' if [ "$relayfile_mount_push_local_supported" = true ]; then',
250
+ " relayfile_mount_flush_mode=--push-local-once",
251
+ " fi",
252
+ " fi",
253
+ " fi",
254
+ " if command -v timeout >/dev/null 2>&1; then",
255
+ ` timeout ${Math.ceil(flushTimeoutSeconds)}s ${sync} >> /tmp/relayfile-mount.log 2>&1 || relayfile_mount_status=$?`,
256
+ " else",
257
+ ` ${sync} >> /tmp/relayfile-mount.log 2>&1 || relayfile_mount_status=$?`,
258
+ " fi",
259
+ ' if [ "$relayfile_mount_status" -eq 124 ] && [ "$relayfile_mount_has_pending_writes" = false ]; then',
260
+ " echo '[relayfile-mount] cleanup sync timed out with no pending local writes; treating as clean' >&2",
261
+ " relayfile_mount_status=0",
262
+ " fi",
263
+ ' if [ -n "${RELAYFILE_MOUNT_PID:-}" ]; then',
264
+ " relayfile_mount_kill_attempted=true",
265
+ ' kill "$RELAYFILE_MOUNT_PID" 2>/dev/null || relayfile_mount_kill_status=$?',
266
+ " fi",
267
+ writebackUndeliveredSignalShell(options),
268
+ cleanupStatusShell(options.cleanupStatusMessage),
269
+ mountLogTailShell(options.mountLogTail),
270
+ ' if [ -n "${RELAYFILE_MOUNT_FLUSH_MARKER:-}" ]; then',
271
+ ' rm -f "$RELAYFILE_MOUNT_FLUSH_MARKER"',
272
+ " fi",
273
+ ' return "$relayfile_mount_status"',
274
+ "}",
275
+ "trap relayfile_mount_cleanup EXIT",
276
+ "trap 'relayfile_mount_cleanup; exit $?' INT TERM",
277
+ initialSync
278
+ ? buildInitialSyncBlock(initialSync, options.continueOnInitialSyncFailure ?? true)
279
+ : "",
280
+ "RELAYFILE_MOUNT_FLUSH_MARKER=$(mktemp /tmp/relayfile-mount-flush-baseline.XXXXXX) || RELAYFILE_MOUNT_FLUSH_MARKER=",
281
+ 'if [ -n "$RELAYFILE_MOUNT_FLUSH_MARKER" ]; then',
282
+ ' touch "$RELAYFILE_MOUNT_FLUSH_MARKER"',
283
+ "fi",
284
+ ].join("\n");
285
+ }
286
+ function relayfileBootstrapIdleTimeoutSeconds(value) {
287
+ if (!Number.isFinite(value)) {
288
+ return undefined;
289
+ }
290
+ const seconds = Math.ceil(value);
291
+ return seconds > 0 ? seconds : undefined;
292
+ }
293
+ function relayfileBootstrapIdleTimeoutEnvShell(idleTimeoutSeconds) {
294
+ return idleTimeoutSeconds === undefined
295
+ ? []
296
+ : [`export RELAYFILE_BOOTSTRAP_IDLE_TIMEOUT=${idleTimeoutSeconds}s`];
297
+ }
298
+ function withRelayfileBootstrapIdleTimeout(command, idleTimeoutSeconds) {
299
+ return [
300
+ ...relayfileBootstrapIdleTimeoutEnvShell(idleTimeoutSeconds),
301
+ command,
302
+ ].join("\n");
303
+ }
304
+ function cleanupStatusShell(message) {
305
+ if (!message)
306
+ return "";
307
+ return ` printf '{"message":"${message}","flushExitCode":%s,"killAttempted":%s,"killExitCode":%s,"pendingWriteback":%s,"hasPendingWriteback":%s,"outboxNeedsAttention":%s,"commandDraftWrittenThisRun":%s,"commandDraftsUndeliverable":%s}\\n' "$relayfile_mount_status" "$relayfile_mount_kill_attempted" "$relayfile_mount_kill_status" "$relayfile_mount_pending_writeback" "$relayfile_mount_has_pending_writeback" "$relayfile_mount_outbox_needs_attention" "$relayfile_mount_command_draft" "\${relayfile_mount_command_drafts_undeliverable:-null}" >&2`;
308
+ }
309
+ /**
310
+ * The positive adapter-dispatch-receipt classifier, run in the sandbox at
311
+ * teardown via `node` (NOT sed/grep: it needs same-record multi-field
312
+ * correlation — remotePath ∧ dispatchStatus ∧ opId ∧ needsAttention from ONE
313
+ * durable-outbox record — which cross-record grep cannot do safely). Reads
314
+ * ONLY `<localDir>/.relay/outbox/{acked,pending}` (O(outbox), no mirror walk)
315
+ * + the command roots, so it stays off the full-tree reconcile path that can
316
+ * time out on large mirrors. Prints a single integer (undeliverable count) to stdout on
317
+ * success; prints NOTHING and exits non-zero on ANY error / precondition
318
+ * violation, so the caller leaves the signal empty → the TS gate reads it as
319
+ * null and falls back to the outbox-pending signals (feature-detect; can never
320
+ * false-fire from this path).
321
+ *
322
+ * Undeliverable = a THIS-run command draft (newer than the flush marker) whose
323
+ * derived remotePath has NO acked-succeeded receipt AND is either (a) in
324
+ * `pending/` with `needsAttention:true` (failed/dead-lettered), (b) in
325
+ * `pending/` with an empty/missing `opId` (never uploaded — sandbox-local
326
+ * risk), or (c) has NO outbox record at all (never enqueued: --flush-outbox-once
327
+ * does not scan command roots, so a just-written draft can race ahead of
328
+ * enqueue). A draft with `opId` + `dispatchStatus` pending/running/queued is
329
+ * BENIGN in-flight — once an opId is committed, the server owns delivery and
330
+ * sandbox teardown cannot orphan it, so it does NOT count.
331
+ *
332
+ * remotePath derivation assumes this module's invariant: `--local-dir` is the
333
+ * UNSCOPED workspace root, so a draft sits at its full provider-rooted path
334
+ * under localDir and `remotePath == "/" + rel(localDir, draftPath)` (the bare
335
+ * strip equals relayfile's `normalizeRemotePath(remoteRoot + "/" + rel(...))`
336
+ * because remoteRoot is "/" relative to the unscoped root). If a draft is NOT
337
+ * under localDir (someone scoped the mount later), the invariant is broken and
338
+ * the program bails to null rather than emit wrong paths that would false-fire.
339
+ */
340
+ const WRITEBACK_RECEIPT_SCAN_PROGRAM = `"use strict";
341
+ const fs = require("fs");
342
+ const path = require("path");
343
+ function normalizeRemotePath(p) {
344
+ let s = String(p).replace(/\\/+/g, "/");
345
+ if (s.charAt(0) !== "/") s = "/" + s;
346
+ if (s.length > 1) s = s.replace(/\\/+$/g, "");
347
+ return s;
348
+ }
349
+ function statMtime(p) {
350
+ try { return fs.statSync(p).mtimeMs; } catch (e) { return null; }
351
+ }
352
+ function walkFiles(dir, out) {
353
+ let entries;
354
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
355
+ for (const ent of entries) {
356
+ const full = path.join(dir, ent.name);
357
+ if (ent.isDirectory()) walkFiles(full, out);
358
+ else if (ent.isFile()) out.push(full);
359
+ }
360
+ }
361
+ function isDraftFile(name) {
362
+ return /^draft.*\\.json$/.test(name) || name === "create.json";
363
+ }
364
+ function readRecords(dir) {
365
+ const out = [];
366
+ let names;
367
+ try { names = fs.readdirSync(dir); } catch (e) { return out; }
368
+ for (const n of names) {
369
+ if (n.slice(-5) !== ".json") continue;
370
+ try {
371
+ const rec = JSON.parse(fs.readFileSync(path.join(dir, n), "utf8"));
372
+ if (rec && typeof rec === "object") out.push(rec);
373
+ } catch (e) { /* skip malformed */ }
374
+ }
375
+ return out;
376
+ }
377
+ try {
378
+ const argv = process.argv.slice(2);
379
+ const localDir = (argv[0] || "").replace(/\\/+$/g, "");
380
+ const marker = argv[1] || "";
381
+ const roots = argv.slice(2);
382
+ if (!localDir || !marker || roots.length === 0) process.exit(1);
383
+ const markerMtime = statMtime(marker);
384
+ if (markerMtime === null) process.exit(1);
385
+ const drafts = [];
386
+ for (const root of roots) {
387
+ const files = [];
388
+ walkFiles(root, files);
389
+ for (const f of files) {
390
+ if (!isDraftFile(path.basename(f))) continue;
391
+ const m = statMtime(f);
392
+ if (m === null || m <= markerMtime) continue;
393
+ drafts.push(f);
394
+ }
395
+ }
396
+ const prefix = localDir + "/";
397
+ const draftRemotePaths = [];
398
+ for (const f of drafts) {
399
+ if (f.indexOf(prefix) !== 0) process.exit(1);
400
+ draftRemotePaths.push(normalizeRemotePath(f.slice(localDir.length)));
401
+ }
402
+ const outbox = path.join(localDir, ".relay", "outbox");
403
+ // RECEIPT CAPABILITY DETECT (load-bearing — without it this gate false-fires
404
+ // on every older daemon). The positive gate is valid ONLY on a mount whose
405
+ // outbox emits adapter-dispatch receipts. Daemons that support receipts
406
+ // write a capability marker — .relay/outbox/capabilities.json
407
+ // {"dispatchReceipts": true} — on EVERY run, even with an empty pending set.
408
+ // Older daemons (no outbox at all, or a durable outbox predating receipts) do
409
+ // NOT write it. So classify ONLY when the marker confirms receipts are
410
+ // active; else bail → empty stdout → the TS gate reads null and falls back to
411
+ // the pending gate, staying truly inert on every older daemon. Keying on the
412
+ // marker rather than on opId-record presence closes the empty-outbox blind
413
+ // spot: a receipt-capable run whose only draft never enqueued still carries
414
+ // the marker → classified correctly.
415
+ // Contract: .relay/outbox/capabilities.json =
416
+ // {"schemaVersion":2,"dispatchReceipts":true}, written by the daemon's
417
+ // outbox-dir setup (which --flush-outbox-once calls even with empty pending).
418
+ // Require BOTH dispatchReceipts===true AND schemaVersion>=2 (the version guard
419
+ // is forward-safe). Absent / parse-fail / not-enabled → treated as absent.
420
+ let dispatchReceiptsActive = false;
421
+ try {
422
+ const cap = JSON.parse(fs.readFileSync(path.join(outbox, "capabilities.json"), "utf8"));
423
+ dispatchReceiptsActive = !!(
424
+ cap &&
425
+ cap.dispatchReceipts === true &&
426
+ typeof cap.schemaVersion === "number" &&
427
+ cap.schemaVersion >= 2
428
+ );
429
+ } catch (e) {
430
+ dispatchReceiptsActive = false;
431
+ }
432
+ if (!dispatchReceiptsActive) process.exit(1);
433
+ const acked = readRecords(path.join(outbox, "acked"));
434
+ const pending = readRecords(path.join(outbox, "pending"));
435
+ const ackedByRemote = new Map();
436
+ for (const r of acked) {
437
+ if (!r || !r.remotePath) continue;
438
+ const opId = typeof r.opId === "string" ? r.opId.trim() : "";
439
+ if (opId && r.dispatchStatus === "succeeded") ackedByRemote.set(normalizeRemotePath(r.remotePath), true);
440
+ }
441
+ const pendingByRemote = new Map();
442
+ for (const r of pending) {
443
+ if (!r || !r.remotePath) continue;
444
+ const key = normalizeRemotePath(r.remotePath);
445
+ if (!pendingByRemote.has(key)) pendingByRemote.set(key, r);
446
+ }
447
+ let undeliverable = 0;
448
+ for (const rp of draftRemotePaths) {
449
+ if (ackedByRemote.get(rp) === true) continue;
450
+ const rec = pendingByRemote.get(rp);
451
+ if (!rec) { undeliverable += 1; continue; }
452
+ const opId = typeof rec.opId === "string" ? rec.opId.trim() : "";
453
+ if (rec.needsAttention === true) { undeliverable += 1; continue; }
454
+ if (!opId) { undeliverable += 1; continue; }
455
+ }
456
+ process.stdout.write(String(undeliverable));
457
+ } catch (e) {
458
+ process.exit(1);
459
+ }
460
+ `;
461
+ /**
462
+ * Compute the writeback-delivery signals into shell vars the cleanup-status
463
+ * printf emits:
464
+ *
465
+ * - `relayfile_mount_pending_writeback`: the canonical undelivered count from
466
+ * `<localDir>/.relay/state.json` (the mount/outbox public status file — the
467
+ * public state lives under localDir, NOT `--state-dir`). Parsed with `sed`
468
+ * (no `jq` dependency); absent/unparsable → 0. A stamped `revision` is NOT
469
+ * read here — it is not proof of delivery.
470
+ * - `relayfile_mount_has_pending_writeback` / `relayfile_mount_outbox_needs_attention`:
471
+ * the unified pending + needs-attention flags from `states` in the same
472
+ * `.relay/state.json`. `states.hasPendingWriteback` is set by the daemon for
473
+ * LOCAL pending and, on daemons with a durable outbox, for outbox pending —
474
+ * so it subsumes the nested `outbox.pending` count without fragile nested
475
+ * parsing. `states.outboxNeedsAttention` is `omitempty` (absent → false on
476
+ * daemons predating the durable outbox). Matched with `grep -Eq`
477
+ * (whitespace-tolerant); both keys live ONLY under top-level `states`, never
478
+ * per-file under `files`, so the context-blind grep can't false-positive.
479
+ * Backward-safe: absent → false.
480
+ * - `relayfile_mount_command_draft`: whether THIS run wrote a writeback command
481
+ * FILE (`draft*.json` / `create.json`, the agent-authored convention) under a
482
+ * configured command root, newer than the run's flush marker. The glob is
483
+ * deliberately narrow: command roots are MIRROR dirs, so an INBOUND message
484
+ * mirrored down mid-run (timestamp-named `<ts>.json`) is also `-newer` — a
485
+ * broad `*.json` probe would flag a read-only run that merely RECEIVED a
486
+ * message and, with a backlog present, falsely fail it — a real regression
487
+ * this narrowness exists to prevent. Only agent-authored draft/create files
488
+ * count.
489
+ * The conjunction of these two is what makes the failure loud yet free of
490
+ * read-only false alarms.
491
+ *
492
+ * This is pure observability — it never changes `relayfile_mount_status`, so it
493
+ * does not perturb the teardown exit code. The TS layer folds these into run
494
+ * status.
495
+ */
496
+ function writebackUndeliveredSignalShell(options) {
497
+ const stateJson = `${options.localDir.replace(/\/+$/u, "")}/.relay/state.json`;
498
+ const lines = [
499
+ ` if [ -f ${shellQuote(stateJson)} ]; then`,
500
+ ` relayfile_mount_pending_writeback=$(sed -n 's/.*"pendingWriteback":[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p' ${shellQuote(stateJson)} 2>/dev/null | head -n 1)`,
501
+ ' if [ -z "$relayfile_mount_pending_writeback" ]; then relayfile_mount_pending_writeback=0; fi',
502
+ ` if grep -Eq '"hasPendingWriteback":[[:space:]]*true' ${shellQuote(stateJson)} 2>/dev/null; then relayfile_mount_has_pending_writeback=true; fi`,
503
+ ` if grep -Eq '"outboxNeedsAttention":[[:space:]]*true' ${shellQuote(stateJson)} 2>/dev/null; then relayfile_mount_outbox_needs_attention=true; fi`,
504
+ " fi",
505
+ ];
506
+ const commandRoots = (options.commandRootLocalDirs ?? []).filter((dir) => dir.trim().length > 0);
507
+ if (commandRoots.length > 0) {
508
+ const quoted = commandRoots.map((dir) => shellQuote(dir)).join(" ");
509
+ lines.push(' if [ -n "${RELAYFILE_MOUNT_FLUSH_MARKER:-}" ]; then', ` for relayfile_mount_cmd_root in ${quoted}; do`, ' if [ -d "$relayfile_mount_cmd_root" ]; then', ' relayfile_mount_cmd_hit=$(find "$relayfile_mount_cmd_root" -type f -newer "$RELAYFILE_MOUNT_FLUSH_MARKER" \\( -name \'draft*.json\' -o -name \'create.json\' \\) -print -quit 2>/dev/null || true)', ' if [ -n "$relayfile_mount_cmd_hit" ]; then relayfile_mount_command_draft=true; break; fi', " fi", " done", " fi");
510
+ // Positive adapter-dispatch-receipt count. Materialize the
511
+ // classifier to a temp file (no extension → node runs it as CommonJS; the
512
+ // program uses `require`) and run it over the durable outbox. Self-
513
+ // protecting: node-absent / mktemp-fail / any program error → the var stays
514
+ // empty → the TS gate reads null → falls back to the outbox-pending signals.
515
+ // The `|| true` + 2>/dev/null guarantee it never perturbs the flush exit code.
516
+ lines.push(' if [ -n "${RELAYFILE_MOUNT_FLUSH_MARKER:-}" ] && command -v node >/dev/null 2>&1; then', " relayfile_mount_receipt_scan=$(mktemp /tmp/relayfile-receipt-scan.XXXXXX 2>/dev/null || true)", ' if [ -n "$relayfile_mount_receipt_scan" ]; then', ` cat > "$relayfile_mount_receipt_scan" <<'RELAYFILE_RECEIPT_SCAN_EOF'`, WRITEBACK_RECEIPT_SCAN_PROGRAM, "RELAYFILE_RECEIPT_SCAN_EOF", ` relayfile_mount_command_drafts_undeliverable=$(node "$relayfile_mount_receipt_scan" ${shellQuote(options.localDir)} "$RELAYFILE_MOUNT_FLUSH_MARKER" ${quoted} 2>/dev/null || true)`, ' rm -f "$relayfile_mount_receipt_scan" 2>/dev/null || true', " fi", " fi");
517
+ }
518
+ return lines.join("\n");
519
+ }
520
+ function mountLogTailShell(options) {
521
+ if (!options)
522
+ return "";
523
+ return [
524
+ " if [ -f /tmp/relayfile-mount.log ]; then",
525
+ ` echo '${options.startMarker}' >&2`,
526
+ ` tail -c ${Math.max(0, Math.ceil(options.bytes))} /tmp/relayfile-mount.log 2>/dev/null | tail -n ${Math.max(0, Math.ceil(options.lines))} >&2 || true`,
527
+ ` echo '${options.endMarker}' >&2`,
528
+ " fi",
529
+ ].join("\n");
530
+ }
531
+ export function buildRelayfileMountCleanupInvocationShell(mount) {
532
+ if (!mount)
533
+ return "";
534
+ return [
535
+ "trap - EXIT INT TERM",
536
+ "MOUNT_EXIT=0",
537
+ "relayfile_mount_cleanup || MOUNT_EXIT=$?",
538
+ ].join("\n");
539
+ }
540
+ function buildInitialSyncBlock(initialSync, continueOnFailure) {
541
+ if (!continueOnFailure) {
542
+ return initialSync;
543
+ }
544
+ return [
545
+ `if ! ${initialSync} >> /tmp/relayfile-mount.log 2>&1; then`,
546
+ " echo '[relayfile-mount] scoped initial sync failed; continuing without preloaded reads' >&2",
547
+ "fi",
548
+ ].join("\n");
549
+ }
550
+ function shellQuote(value) {
551
+ return `'${value.replaceAll("'", "'\\''")}'`;
552
+ }
553
+ //# sourceMappingURL=orchestrator.js.map