@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.
- package/README.md +8 -4
- package/dist/daytona/runtime.d.ts +117 -0
- package/dist/daytona/runtime.d.ts.map +1 -0
- package/dist/daytona/runtime.js +765 -0
- package/dist/daytona/runtime.js.map +1 -0
- package/dist/e2b/runtime.d.ts +167 -0
- package/dist/e2b/runtime.d.ts.map +1 -0
- package/dist/e2b/runtime.js +362 -0
- package/dist/e2b/runtime.js.map +1 -0
- package/dist/index.d.ts +26 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -3
- package/dist/index.js.map +1 -1
- package/dist/local/runtime.d.ts +63 -0
- package/dist/local/runtime.d.ts.map +1 -0
- package/dist/local/runtime.js +315 -0
- package/dist/local/runtime.js.map +1 -0
- package/dist/mount-script.d.ts +162 -0
- package/dist/mount-script.d.ts.map +1 -0
- package/dist/mount-script.js +461 -0
- package/dist/mount-script.js.map +1 -0
- package/dist/orchestrator.d.ts +110 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +553 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/port.d.ts +126 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/port.js +35 -0
- package/dist/port.js.map +1 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +16 -1
|
@@ -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":"AAAA,OAAO,EAUL,KAAK,2BAA2B,EAChC,KAAK,0BAA0B,EAChC,MAAM,mBAAmB,CAAC;AAY3B,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,kBAAkB,EAAE,CAAC;IAC7B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,MAAM,IAAI;IAC/C,SAAS,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,uBAAuB,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACnE,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,iBAAiB,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtF,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,uBAAuB,KAAK,OAAO,CAAC,oBAAoB,CAAC,CAAC;IAC/F,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9C,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,8EAA8E;IAC9E,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,+CAA+C;IAC/C,yBAAyB,CAAC,EAAE,MAAM,CAAC;IACnC,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG,iBAAiB,CAAC;AAEjD,qBAAa,mBAAmB,CAAC,MAAM;IACzB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,0BAA0B,CAAC,MAAM,CAAC;IAElE,SAAS,CAAC,OAAO,CAAC,EAAE,uBAAuB,GAAG,OAAO,CAAC,MAAM,CAAC;IAO7D,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,iBAAiB,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAOhF,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,uBAAuB,GAAG,OAAO,CAAC,qBAAqB,CAAC;IAOjG,aAAa,CACX,MAAM,EAAE,oBAAoB,EAC5B,SAAS,SAA2B,EACpC,OAAO,SAAwB,GAC9B,qBAAqB;IAmBlB,UAAU,CACd,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,2BAA2B,EACnC,OAAO,GAAE,iBAAsB,GAC9B,OAAO,CAAC,oBAAoB,CAAC;IAkH1B,UAAU,CACd,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,0BAA0B,EAClC,OAAO,GAAE,iBAAsB,GAC9B,OAAO,CAAC,IAAI,CAAC;IAWV,SAAS,CACb,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,oBAAoB,EAC3B,MAAM,EAAE,0BAA0B,EAClC,OAAO,GAAE,gBAAqB,GAC7B,OAAO,CAAC,IAAI,CAAC;IAaV,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAM9C;AAED,MAAM,MAAM,mCAAmC,GAAG;IAChD,KAAK,EAAE,CAAC,IAAI,CAAC,0BAA0B,EAAE,UAAU,CAAC,GAAG;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,GAAG,IAAI,CAAC;IACvG,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,6BAA6B,CAAC,EAAE,MAAM,CAAC;IACvC,4BAA4B,CAAC,EAAE,OAAO,CAAC;IACvC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;OAMG;IACH,oBAAoB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC,YAAY,CAAC,EAAE;QACb,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE,MAAM,CAAC;QAClB,KAAK,EAAE,MAAM,CAAC;QACd,KAAK,EAAE,MAAM,CAAC;KACf,CAAC;CACH,CAAC;AAEF,wBAAgB,iCAAiC,CAC/C,OAAO,EAAE,mCAAmC,GAC3C,MAAM,CAsHR;AAuRD,wBAAgB,yCAAyC,CACvD,KAAK,EAAE,OAAO,GAAG,IAAI,GACpB,MAAM,CAOR"}
|
|
@@ -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
|