@sealant/sdk 0.0.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/LICENSE +202 -0
- package/README.md +61 -0
- package/dist/client.d.ts +20 -0
- package/dist/client.js +107 -0
- package/dist/effect/api-client.d.ts +1762 -0
- package/dist/effect/api-client.js +29 -0
- package/dist/effect/operations.d.ts +236 -0
- package/dist/effect/operations.js +18 -0
- package/dist/effect/run-harness.d.ts +9 -0
- package/dist/effect/run-harness.js +63 -0
- package/dist/effect/runtime.d.ts +22 -0
- package/dist/effect/runtime.js +63 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.js +47 -0
- package/dist/facade/context.d.ts +9 -0
- package/dist/facade/context.js +9 -0
- package/dist/facade/record.d.ts +20 -0
- package/dist/facade/record.js +222 -0
- package/dist/facade/run.d.ts +30 -0
- package/dist/facade/run.js +57 -0
- package/dist/facade/sandbox.d.ts +23 -0
- package/dist/facade/sandbox.js +89 -0
- package/dist/harness.d.ts +34 -0
- package/dist/harness.js +37 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +19 -0
- package/dist/internal/blueprint.d.ts +30 -0
- package/dist/internal/blueprint.js +69 -0
- package/dist/internal/config.d.ts +24 -0
- package/dist/internal/config.js +17 -0
- package/dist/internal/credentials.d.ts +22 -0
- package/dist/internal/credentials.js +26 -0
- package/dist/internal/map-error.d.ts +8 -0
- package/dist/internal/map-error.js +44 -0
- package/dist/types.d.ts +291 -0
- package/dist/types.js +17 -0
- package/package.json +41 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { getRunLossOp, getRunOp, getRunScrollbackOp, getRunTimelineOp, } from "../effect/operations.js";
|
|
2
|
+
import { SealantNotImplementedError } from "../errors.js";
|
|
3
|
+
// Live `stream()` is poll-backed for now: it tails the timeline endpoint until the run is terminal.
|
|
4
|
+
// The transport swaps to SSE over Postgres LISTEN/NOTIFY in Stage 5 behind this same signature.
|
|
5
|
+
const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
|
6
|
+
const STREAM_POLL_INTERVAL_MS = 500;
|
|
7
|
+
const STREAM_TIMEOUT_MS = 30 * 60 * 1_000;
|
|
8
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
|
+
const toTimelineEntry = (wire) => ({
|
|
10
|
+
sequence: BigInt(wire.sequence),
|
|
11
|
+
kind: wire.kind,
|
|
12
|
+
occurredAt: wire.occurredAt,
|
|
13
|
+
data: { summary: wire.summary, ...(wire.ref === undefined ? {} : { ref: wire.ref }) },
|
|
14
|
+
});
|
|
15
|
+
const toLossReport = (wire) => ({
|
|
16
|
+
complete: !wire.earlyClose && wire.droppedEventCount === "0" && wire.sequenceGapCount === 0,
|
|
17
|
+
// Pass boundaries through only when present — do not fabricate a {0, 0} span.
|
|
18
|
+
spans: wire.spans.map((span) => ({
|
|
19
|
+
...(span.fromSequence === undefined ? {} : { fromSequence: BigInt(span.fromSequence) }),
|
|
20
|
+
...(span.toSequence === undefined ? {} : { toSequence: BigInt(span.toSequence) }),
|
|
21
|
+
})),
|
|
22
|
+
});
|
|
23
|
+
// ---------------------------------------------------------------------------------------------
|
|
24
|
+
// Transcript reconstruction — turn the raw timeline into the terminal commands a human cares about.
|
|
25
|
+
// ---------------------------------------------------------------------------------------------
|
|
26
|
+
const STDERR_STREAM = 3; // StreamKind.STDERR (stdout is 2)
|
|
27
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
28
|
+
const SHELL_SAFE = /^[A-Za-z0-9_/.:=@%+-]+$/;
|
|
29
|
+
const quoteArg = (arg) => arg.length > 0 && SHELL_SAFE.test(arg) ? arg : `"${arg.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
30
|
+
const formatCommandLine = (executable, args) => [executable, ...args.map(quoteArg)].join(" ");
|
|
31
|
+
const humanBytes = (n) => {
|
|
32
|
+
if (n < 1024)
|
|
33
|
+
return `${n} B`;
|
|
34
|
+
if (n < 1024 * 1024)
|
|
35
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
36
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
37
|
+
};
|
|
38
|
+
const humanDuration = (ms) => ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)} s`;
|
|
39
|
+
/**
|
|
40
|
+
* Folds the timeline into the ordered list of terminal commands. Process boundaries are
|
|
41
|
+
* `processStarted`/`processExited`; `ioChunk` byte counts accrue to the current command. Daemon noise
|
|
42
|
+
* (`runtimeStateChanged`, and the boot foreground we never `processStarted`) is skipped naturally.
|
|
43
|
+
*/
|
|
44
|
+
export const reconstructCommands = (entries) => {
|
|
45
|
+
const commands = [];
|
|
46
|
+
let current;
|
|
47
|
+
const flush = (exit) => {
|
|
48
|
+
if (current === undefined) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
commands.push({
|
|
52
|
+
executable: current.executable,
|
|
53
|
+
args: current.args,
|
|
54
|
+
command: formatCommandLine(current.executable, current.args),
|
|
55
|
+
...(current.cwd === undefined ? {} : { cwd: current.cwd }),
|
|
56
|
+
...(exit?.exitCode === undefined ? {} : { exitCode: exit.exitCode }),
|
|
57
|
+
...(exit?.signal === undefined ? {} : { signal: exit.signal }),
|
|
58
|
+
...(exit?.durationMs === undefined ? {} : { durationMs: exit.durationMs }),
|
|
59
|
+
stdoutBytes: current.stdoutBytes,
|
|
60
|
+
stderrBytes: current.stderrBytes,
|
|
61
|
+
});
|
|
62
|
+
current = undefined;
|
|
63
|
+
};
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
const ref = isRecord(entry.ref) ? entry.ref : {};
|
|
66
|
+
if (entry.kind === "processStarted") {
|
|
67
|
+
flush();
|
|
68
|
+
const args = ref["args"];
|
|
69
|
+
current = {
|
|
70
|
+
executable: typeof ref["executable"] === "string" ? ref["executable"] : "?",
|
|
71
|
+
args: Array.isArray(args) ? args.map((a) => String(a)) : [],
|
|
72
|
+
...(typeof ref["cwd"] === "string" ? { cwd: ref["cwd"] } : {}),
|
|
73
|
+
stdoutBytes: 0,
|
|
74
|
+
stderrBytes: 0,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
else if (entry.kind === "ioChunk" && current !== undefined) {
|
|
78
|
+
const bytes = typeof ref["byteCount"] === "string" ? Number(ref["byteCount"]) : 0;
|
|
79
|
+
if (ref["stream"] === STDERR_STREAM) {
|
|
80
|
+
current.stderrBytes += bytes;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
current.stdoutBytes += bytes;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else if (entry.kind === "processExited") {
|
|
87
|
+
flush({
|
|
88
|
+
...(typeof ref["exitCode"] === "number" ? { exitCode: ref["exitCode"] } : {}),
|
|
89
|
+
...(typeof ref["signal"] === "number" ? { signal: ref["signal"] } : {}),
|
|
90
|
+
...(typeof ref["durationMicros"] === "string"
|
|
91
|
+
? { durationMs: Math.round(Number(ref["durationMicros"]) / 1000) }
|
|
92
|
+
: {}),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
flush();
|
|
97
|
+
return commands;
|
|
98
|
+
};
|
|
99
|
+
export const renderTranscript = (commands) => {
|
|
100
|
+
if (commands.length === 0) {
|
|
101
|
+
return "(no commands recorded)\n";
|
|
102
|
+
}
|
|
103
|
+
const blocks = commands.map((command) => {
|
|
104
|
+
const io = [];
|
|
105
|
+
if (command.stdoutBytes > 0) {
|
|
106
|
+
io.push(`stdout ${humanBytes(command.stdoutBytes)}`);
|
|
107
|
+
}
|
|
108
|
+
if (command.stderrBytes > 0) {
|
|
109
|
+
io.push(`stderr ${humanBytes(command.stderrBytes)}`);
|
|
110
|
+
}
|
|
111
|
+
const outcome = command.signal !== undefined
|
|
112
|
+
? `terminated (signal ${command.signal})`
|
|
113
|
+
: command.exitCode === 0
|
|
114
|
+
? "completed (exit 0)"
|
|
115
|
+
: command.exitCode !== undefined
|
|
116
|
+
? `failed (exit ${command.exitCode})`
|
|
117
|
+
: "ended";
|
|
118
|
+
const duration = command.durationMs === undefined ? "" : ` · ${humanDuration(command.durationMs)}`;
|
|
119
|
+
return ` $ ${command.command}\n ↳ ${[...io, outcome].join(" · ")}${duration}`;
|
|
120
|
+
});
|
|
121
|
+
return `${blocks.join("\n\n")}\n`;
|
|
122
|
+
};
|
|
123
|
+
export const makeRunRecord = (ctx, runId) => {
|
|
124
|
+
const fetchTimeline = (from) => ctx.runtime.run(getRunTimelineOp(runId, from === undefined ? {} : { fromSequence: from.toString() }));
|
|
125
|
+
return {
|
|
126
|
+
runId,
|
|
127
|
+
replay: async (options) => {
|
|
128
|
+
const wire = await fetchTimeline();
|
|
129
|
+
const entries = wire.map(toTimelineEntry);
|
|
130
|
+
if (options?.onEntry !== undefined) {
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
options.onEntry(entry);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const replay = {
|
|
136
|
+
entries,
|
|
137
|
+
at: (sequence) => {
|
|
138
|
+
let found;
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
if (entry.sequence <= sequence) {
|
|
141
|
+
found = entry;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return found;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
return replay;
|
|
151
|
+
},
|
|
152
|
+
commands: async () => reconstructCommands(await fetchTimeline()),
|
|
153
|
+
transcript: async () => renderTranscript(reconstructCommands(await fetchTimeline())),
|
|
154
|
+
stream: (options) => {
|
|
155
|
+
const ctxRun = ctx.runtime;
|
|
156
|
+
async function* iterate() {
|
|
157
|
+
let from = options?.from;
|
|
158
|
+
const deadline = Date.now() + STREAM_TIMEOUT_MS;
|
|
159
|
+
for (;;) {
|
|
160
|
+
const wire = await ctxRun.run(getRunTimelineOp(runId, from === undefined ? {} : { fromSequence: from.toString() }));
|
|
161
|
+
for (const entry of wire) {
|
|
162
|
+
const mapped = toTimelineEntry(entry);
|
|
163
|
+
yield mapped;
|
|
164
|
+
from = mapped.sequence + 1n;
|
|
165
|
+
}
|
|
166
|
+
// Stop once the run is terminal — with one final drain to catch entries written between the
|
|
167
|
+
// last timeline fetch and the status check.
|
|
168
|
+
const run = await ctxRun.run(getRunOp(runId));
|
|
169
|
+
if (TERMINAL_RUN_STATUSES.has(run.status)) {
|
|
170
|
+
const tail = await ctxRun.run(getRunTimelineOp(runId, from === undefined ? {} : { fromSequence: from.toString() }));
|
|
171
|
+
for (const entry of tail) {
|
|
172
|
+
yield toTimelineEntry(entry);
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (Date.now() > deadline) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
await delay(STREAM_POLL_INTERVAL_MS);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return iterate();
|
|
183
|
+
},
|
|
184
|
+
timeline: (options) => {
|
|
185
|
+
const run = ctx.runtime;
|
|
186
|
+
async function* iterate() {
|
|
187
|
+
const wire = await run.run(getRunTimelineOp(runId, options?.from === undefined ? {} : { fromSequence: options.from.toString() }));
|
|
188
|
+
for (const entry of wire) {
|
|
189
|
+
yield toTimelineEntry(entry);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return iterate();
|
|
193
|
+
},
|
|
194
|
+
scrollback: (processId, stream) => {
|
|
195
|
+
const run = ctx.runtime;
|
|
196
|
+
async function* iterate() {
|
|
197
|
+
const response = await run.run(getRunScrollbackOp(runId, { processId, stream }));
|
|
198
|
+
const bytes = Buffer.from(response.contentBase64, "base64");
|
|
199
|
+
if (bytes.byteLength > 0) {
|
|
200
|
+
yield new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return iterate();
|
|
204
|
+
},
|
|
205
|
+
loss: async () => toLossReport(await ctx.runtime.run(getRunLossOp(runId))),
|
|
206
|
+
summary: async () => {
|
|
207
|
+
const run = await ctx.runtime.run(getRunOp(runId));
|
|
208
|
+
const timeline = await fetchTimeline();
|
|
209
|
+
const durationMs = run.startedAt !== undefined && run.finishedAt !== undefined
|
|
210
|
+
? new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()
|
|
211
|
+
: undefined;
|
|
212
|
+
return {
|
|
213
|
+
runId,
|
|
214
|
+
outcome: run.status === "completed" ? "completed" : "failed",
|
|
215
|
+
entries: timeline.length,
|
|
216
|
+
...(durationMs === undefined ? {} : { durationMs }),
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
fileTreeAt: () => Promise.reject(new SealantNotImplementedError("record.fileTreeAt (file-tree fold arrives in Phase 1)")),
|
|
220
|
+
processTreeAt: () => Promise.reject(new SealantNotImplementedError("record.processTreeAt (process-tree fold arrives in Phase 1)")),
|
|
221
|
+
};
|
|
222
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Run` facade — one harness execution as the SDK exposes it. Built from a `runs.get` lookup
|
|
3
|
+
* (read a past run, record outlives the sandbox), from `harness.run()` (which also captures the file
|
|
4
|
+
* changes inline), or from `harness.start()` (a live handle; `wait()` settles it). `result` is
|
|
5
|
+
* derived from the wire status; `changes` is captured inline by `run()` and fetched by `wait()` once
|
|
6
|
+
* the run is terminal (the event-sourced fileChange projection that backs reads arrives in Phase 1);
|
|
7
|
+
* `artifacts` is empty until the artifact store is exposed; `record` reads via the run/record
|
|
8
|
+
* endpoints.
|
|
9
|
+
*/
|
|
10
|
+
import type { Run as WireRun } from "@sealant/api-contracts";
|
|
11
|
+
import type { Run, RunFileChange } from "../types.js";
|
|
12
|
+
import type { SdkContext } from "./context.js";
|
|
13
|
+
export interface RunChangesData {
|
|
14
|
+
readonly files: readonly RunFileChange[];
|
|
15
|
+
readonly diff: string;
|
|
16
|
+
}
|
|
17
|
+
export interface RunInit {
|
|
18
|
+
readonly wire: WireRun;
|
|
19
|
+
readonly changes?: RunChangesData;
|
|
20
|
+
}
|
|
21
|
+
/** Maps the wire changes shape to the facade data (shared with the run-harness execution path). */
|
|
22
|
+
export declare const toRunChangesData: (wire: {
|
|
23
|
+
readonly files: readonly {
|
|
24
|
+
readonly path: string;
|
|
25
|
+
readonly change: "added" | "deleted" | "modified" | "renamed";
|
|
26
|
+
readonly oldPath?: string | undefined;
|
|
27
|
+
}[];
|
|
28
|
+
readonly diff: string;
|
|
29
|
+
}) => RunChangesData;
|
|
30
|
+
export declare const makeRun: (ctx: SdkContext, init: RunInit) => Run;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { getRunChangesOp, getRunOp } from "../effect/operations.js";
|
|
2
|
+
import { SealantError, SealantNotImplementedError } from "../errors.js";
|
|
3
|
+
import { makeRunRecord } from "./record.js";
|
|
4
|
+
/** Maps the wire changes shape to the facade data (shared with the run-harness execution path). */
|
|
5
|
+
export const toRunChangesData = (wire) => ({
|
|
6
|
+
files: wire.files.map((file) => ({
|
|
7
|
+
path: file.path,
|
|
8
|
+
change: file.change,
|
|
9
|
+
...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),
|
|
10
|
+
})),
|
|
11
|
+
diff: wire.diff,
|
|
12
|
+
});
|
|
13
|
+
const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
|
14
|
+
const WAIT_POLL_INTERVAL_MS = 500;
|
|
15
|
+
const WAIT_TIMEOUT_MS = 30 * 60 * 1_000;
|
|
16
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
const toResult = (wire) => ({
|
|
18
|
+
status: wire.status,
|
|
19
|
+
outcome: wire.status === "completed" ? "completed" : "failed",
|
|
20
|
+
exitCode: wire.exitCode ?? -1,
|
|
21
|
+
});
|
|
22
|
+
const emptyArtifacts = {
|
|
23
|
+
list: () => Promise.resolve([]),
|
|
24
|
+
get: () => Promise.reject(new SealantNotImplementedError("artifacts.get (artifact store wiring arrives in Phase 1)")),
|
|
25
|
+
};
|
|
26
|
+
export const makeRun = (ctx, init) => {
|
|
27
|
+
const runId = init.wire.runId;
|
|
28
|
+
const changesData = init.changes;
|
|
29
|
+
const changes = {
|
|
30
|
+
files: changesData?.files ?? [],
|
|
31
|
+
diff: () => Promise.resolve(changesData?.diff ?? ""),
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
id: runId,
|
|
35
|
+
result: toResult(init.wire),
|
|
36
|
+
changes,
|
|
37
|
+
artifacts: emptyArtifacts,
|
|
38
|
+
record: makeRunRecord(ctx, runId),
|
|
39
|
+
wait: async () => {
|
|
40
|
+
let current = init.wire;
|
|
41
|
+
const deadline = Date.now() + WAIT_TIMEOUT_MS;
|
|
42
|
+
while (!TERMINAL_STATUSES.has(current.status)) {
|
|
43
|
+
if (Date.now() > deadline) {
|
|
44
|
+
throw new SealantError(`Timed out waiting for run ${runId} to reach a terminal status.`, {
|
|
45
|
+
code: "run_wait_timeout",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
await delay(WAIT_POLL_INTERVAL_MS);
|
|
49
|
+
current = await ctx.runtime.run(getRunOp(runId));
|
|
50
|
+
}
|
|
51
|
+
// Settle the changes: a handle from `harness.start()` or `runs.get()` has none captured yet,
|
|
52
|
+
// so read the server-side capture now that the run is terminal.
|
|
53
|
+
const settledChanges = changesData ?? toRunChangesData(await ctx.runtime.run(getRunChangesOp(runId)));
|
|
54
|
+
return makeRun(ctx, { wire: current, changes: settledChanges });
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Harness, Sandbox, SandboxStatus } from "../types.js";
|
|
2
|
+
import type { SdkContext } from "./context.js";
|
|
3
|
+
export interface SandboxInit {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly status: SandboxStatus;
|
|
7
|
+
/** Present when the handle came from `create()` (needed by `harness.run()`). */
|
|
8
|
+
readonly harness?: Harness;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The `harness.run()`/`harness.start()` implementations are injected by the run-execution module to
|
|
12
|
+
* avoid a static dependency cycle (sandbox <-> run execution). Until they are registered, both
|
|
13
|
+
* report that the feature is not wired in this build.
|
|
14
|
+
*/
|
|
15
|
+
export type RunHarnessFn = (ctx: SdkContext, init: SandboxInit, prompt: string, options?: import("../types.js").RunOptions) => Promise<import("../types.js").Run>;
|
|
16
|
+
export interface HarnessExecutors {
|
|
17
|
+
/** BLOCKING `harness.run()`: resolves once the run is terminal. */
|
|
18
|
+
readonly run: RunHarnessFn;
|
|
19
|
+
/** NON-BLOCKING `harness.start()`: returns the live handle immediately. */
|
|
20
|
+
readonly start: RunHarnessFn;
|
|
21
|
+
}
|
|
22
|
+
export declare const registerHarnessExecutors: (executors: HarnessExecutors) => void;
|
|
23
|
+
export declare const makeSandbox: (ctx: SdkContext, init: SandboxInit) => Sandbox;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { getSandboxOp } from "../effect/operations.js";
|
|
2
|
+
import { SealantError, SealantNotImplementedError } from "../errors.js";
|
|
3
|
+
const FAILED_STATUSES = new Set(["failed", "cancelled"]);
|
|
4
|
+
const READY_POLL_INTERVAL_MS = 2_000;
|
|
5
|
+
const READY_TIMEOUT_MS = 10 * 60 * 1_000;
|
|
6
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
let harnessExecutors;
|
|
8
|
+
export const registerHarnessExecutors = (executors) => {
|
|
9
|
+
harnessExecutors = executors;
|
|
10
|
+
};
|
|
11
|
+
export const makeSandbox = (ctx, init) => {
|
|
12
|
+
const harness = {
|
|
13
|
+
run: (prompt, options) => {
|
|
14
|
+
if (harnessExecutors === undefined) {
|
|
15
|
+
return Promise.reject(new SealantNotImplementedError("harness.run (run execution not wired in this build)"));
|
|
16
|
+
}
|
|
17
|
+
return harnessExecutors.run(ctx, init, prompt, options);
|
|
18
|
+
},
|
|
19
|
+
start: (prompt, options) => {
|
|
20
|
+
if (harnessExecutors === undefined) {
|
|
21
|
+
return Promise.reject(new SealantNotImplementedError("harness.start (run execution not wired in this build)"));
|
|
22
|
+
}
|
|
23
|
+
return harnessExecutors.start(ctx, init, prompt, options);
|
|
24
|
+
},
|
|
25
|
+
session: () => Promise.reject(new SealantNotImplementedError("harness.session (interactive, Phase 3)")),
|
|
26
|
+
};
|
|
27
|
+
const sandbox = {
|
|
28
|
+
id: init.id,
|
|
29
|
+
name: init.name,
|
|
30
|
+
status: async () => {
|
|
31
|
+
const details = await ctx.runtime.run(getSandboxOp(init.id));
|
|
32
|
+
return details.status;
|
|
33
|
+
},
|
|
34
|
+
ready: async () => {
|
|
35
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
36
|
+
for (;;) {
|
|
37
|
+
const details = await ctx.runtime.run(getSandboxOp(init.id));
|
|
38
|
+
// Gate on the coarse "ready" status, which the control plane now emits ONLY after the
|
|
39
|
+
// in-sandbox daemon's control socket is accepting (readiness probe in the launch path).
|
|
40
|
+
// This is honest: when ready() resolves, harness.run() can connect without racing the socket.
|
|
41
|
+
if (details.status === "ready") {
|
|
42
|
+
return sandbox;
|
|
43
|
+
}
|
|
44
|
+
if (FAILED_STATUSES.has(details.status)) {
|
|
45
|
+
throw new SealantError(`Sandbox ${init.id} reached terminal status "${details.status}" before becoming ready.`, { code: "sandbox_not_ready" });
|
|
46
|
+
}
|
|
47
|
+
if (Date.now() > deadline) {
|
|
48
|
+
throw new SealantError(`Timed out waiting for sandbox ${init.id} to become ready.`, {
|
|
49
|
+
code: "sandbox_ready_timeout",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
await delay(READY_POLL_INTERVAL_MS);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
harness,
|
|
56
|
+
// Poll-backed lifecycle stream: emit a coarse event on each status transition until the sandbox
|
|
57
|
+
// reaches a terminal/ready state. Swaps to SSE over Postgres LISTEN/NOTIFY in Stage 5 (same shape).
|
|
58
|
+
events: () => {
|
|
59
|
+
const ctxRun = ctx.runtime;
|
|
60
|
+
async function* iterate() {
|
|
61
|
+
let lastStatus;
|
|
62
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
63
|
+
for (;;) {
|
|
64
|
+
const details = await ctxRun.run(getSandboxOp(init.id));
|
|
65
|
+
if (details.status !== lastStatus) {
|
|
66
|
+
lastStatus = details.status;
|
|
67
|
+
yield {
|
|
68
|
+
type: `status.${details.status}`,
|
|
69
|
+
occurredAt: new Date().toISOString(),
|
|
70
|
+
message: `Sandbox status: ${details.status}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (details.status === "ready" || FAILED_STATUSES.has(details.status)) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (Date.now() > deadline) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
await delay(READY_POLL_INTERVAL_MS);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return iterate();
|
|
83
|
+
},
|
|
84
|
+
stop: () => Promise.reject(new SealantNotImplementedError("sandbox.stop (lifecycle, Phase 3)")),
|
|
85
|
+
restart: () => Promise.reject(new SealantNotImplementedError("sandbox.restart (lifecycle, Phase 3)")),
|
|
86
|
+
expire: () => Promise.reject(new SealantNotImplementedError("sandbox.expire (lifecycle, Phase 3)")),
|
|
87
|
+
};
|
|
88
|
+
return sandbox;
|
|
89
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness factories. Each returns a thin `Harness` value: an id plus how to invoke it one-shot
|
|
3
|
+
* against a prompt.
|
|
4
|
+
*
|
|
5
|
+
* NOTE — the one-shot invocation forms below (`opencode run <prompt>`, `codex exec <prompt>`,
|
|
6
|
+
* `claude -p <prompt>`) are the expected headless shapes but are PENDING live verification against
|
|
7
|
+
* the baked sandbox image (see the SDK plan's task #2, "verify harness one-shot CLI semantics").
|
|
8
|
+
* Until that is confirmed, only `opencode()` is exercised end-to-end; the others are provided for the
|
|
9
|
+
* typed surface and adjusted once verified.
|
|
10
|
+
*/
|
|
11
|
+
import type { Harness } from "./types.js";
|
|
12
|
+
/** OpenCode — the harness used in the canonical hero example. */
|
|
13
|
+
export declare const opencode: () => Harness;
|
|
14
|
+
/** OpenAI Codex CLI. */
|
|
15
|
+
export declare const codex: () => Harness;
|
|
16
|
+
/** Anthropic Claude Code. */
|
|
17
|
+
export declare const claudeCode: (options?: {
|
|
18
|
+
readonly profile?: string;
|
|
19
|
+
} | undefined) => Harness;
|
|
20
|
+
/**
|
|
21
|
+
* A bring-your-own harness. The caller supplies how to invoke it one-shot (`invoke`) and, optionally,
|
|
22
|
+
* how to install and launch it. This is the harness-neutral escape hatch: any agent loop, CI worker,
|
|
23
|
+
* or custom binary.
|
|
24
|
+
*/
|
|
25
|
+
export declare const customHarness: (options: {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly invoke: (prompt: string) => readonly string[];
|
|
28
|
+
readonly executable?: string;
|
|
29
|
+
readonly install?: {
|
|
30
|
+
readonly packages?: readonly string[];
|
|
31
|
+
readonly command?: string;
|
|
32
|
+
};
|
|
33
|
+
readonly launchCommand?: string;
|
|
34
|
+
}) => Harness;
|
package/dist/harness.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** OpenCode — the harness used in the canonical hero example. */
|
|
2
|
+
export const opencode = () => ({
|
|
3
|
+
id: "opencode",
|
|
4
|
+
buildRunCommand: (prompt) => ({ executable: "opencode", args: ["run", prompt] }),
|
|
5
|
+
launchCommand: "opencode",
|
|
6
|
+
});
|
|
7
|
+
/** OpenAI Codex CLI. */
|
|
8
|
+
export const codex = () => ({
|
|
9
|
+
id: "codex",
|
|
10
|
+
buildRunCommand: (prompt) => ({ executable: "codex", args: ["exec", prompt] }),
|
|
11
|
+
launchCommand: "codex",
|
|
12
|
+
});
|
|
13
|
+
/** Anthropic Claude Code. */
|
|
14
|
+
export const claudeCode = (options) => ({
|
|
15
|
+
id: "claude-code",
|
|
16
|
+
buildRunCommand: (prompt) => ({
|
|
17
|
+
executable: "claude",
|
|
18
|
+
args: options?.profile === undefined
|
|
19
|
+
? ["-p", prompt]
|
|
20
|
+
: ["--profile", options.profile, "-p", prompt],
|
|
21
|
+
}),
|
|
22
|
+
launchCommand: "claude",
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* A bring-your-own harness. The caller supplies how to invoke it one-shot (`invoke`) and, optionally,
|
|
26
|
+
* how to install and launch it. This is the harness-neutral escape hatch: any agent loop, CI worker,
|
|
27
|
+
* or custom binary.
|
|
28
|
+
*/
|
|
29
|
+
export const customHarness = (options) => ({
|
|
30
|
+
id: options.id,
|
|
31
|
+
buildRunCommand: (prompt) => ({
|
|
32
|
+
executable: options.executable ?? options.id,
|
|
33
|
+
args: options.invoke(prompt),
|
|
34
|
+
}),
|
|
35
|
+
...(options.install === undefined ? {} : { install: options.install }),
|
|
36
|
+
...(options.launchCommand === undefined ? {} : { launchCommand: options.launchCommand }),
|
|
37
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sealant/sdk — the fluent public SDK for Sealant.
|
|
3
|
+
*
|
|
4
|
+
* Create a live sandbox around a real repository, run the harness you already use, stream progress
|
|
5
|
+
* while it works, and keep the replayable execution record after the sandbox is gone:
|
|
6
|
+
*
|
|
7
|
+
* import { Sealant, opencode } from "@sealant/sdk"
|
|
8
|
+
*
|
|
9
|
+
* const sealant = new Sealant({ baseUrl: "http://localhost:8080" })
|
|
10
|
+
* const sandbox = await sealant.sandboxes.create({
|
|
11
|
+
* repository: "github.com/acme/billing-service",
|
|
12
|
+
* harness: opencode(),
|
|
13
|
+
* })
|
|
14
|
+
* const run = await sandbox.harness.run("Round invoice totals once, after applying the discount.")
|
|
15
|
+
* await run.record.replay()
|
|
16
|
+
*/
|
|
17
|
+
export { Sealant } from "./client.js";
|
|
18
|
+
export { claudeCode, codex, customHarness, opencode } from "./harness.js";
|
|
19
|
+
export { SealantApiError, SealantError, SealantNotImplementedError, SealantRuntimeError, } from "./errors.js";
|
|
20
|
+
export type * from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sealant/sdk — the fluent public SDK for Sealant.
|
|
3
|
+
*
|
|
4
|
+
* Create a live sandbox around a real repository, run the harness you already use, stream progress
|
|
5
|
+
* while it works, and keep the replayable execution record after the sandbox is gone:
|
|
6
|
+
*
|
|
7
|
+
* import { Sealant, opencode } from "@sealant/sdk"
|
|
8
|
+
*
|
|
9
|
+
* const sealant = new Sealant({ baseUrl: "http://localhost:8080" })
|
|
10
|
+
* const sandbox = await sealant.sandboxes.create({
|
|
11
|
+
* repository: "github.com/acme/billing-service",
|
|
12
|
+
* harness: opencode(),
|
|
13
|
+
* })
|
|
14
|
+
* const run = await sandbox.harness.run("Round invoice totals once, after applying the discount.")
|
|
15
|
+
* await run.record.replay()
|
|
16
|
+
*/
|
|
17
|
+
export { Sealant } from "./client.js";
|
|
18
|
+
export { claudeCode, codex, customHarness, opencode } from "./harness.js";
|
|
19
|
+
export { SealantApiError, SealantError, SealantNotImplementedError, SealantRuntimeError, } from "./errors.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CreateOptions } from "../types.js";
|
|
2
|
+
import type { SealantInternalConfig } from "./config.js";
|
|
3
|
+
export declare const buildCreateSandboxRequest: (options: CreateOptions, config: SealantInternalConfig) => {
|
|
4
|
+
readonly payload: {
|
|
5
|
+
readonly ownerUserId: string;
|
|
6
|
+
readonly registryId: string;
|
|
7
|
+
readonly repository: string;
|
|
8
|
+
readonly tag: string;
|
|
9
|
+
readonly name?: string | undefined;
|
|
10
|
+
readonly sourceSelection?: {
|
|
11
|
+
readonly provider: "github";
|
|
12
|
+
readonly installationId: string;
|
|
13
|
+
readonly installationRepositoryId: string;
|
|
14
|
+
readonly ref?: string | undefined;
|
|
15
|
+
} | undefined;
|
|
16
|
+
readonly dotfilesSelection?: {
|
|
17
|
+
readonly provider: "github";
|
|
18
|
+
readonly installationId: string;
|
|
19
|
+
readonly installationRepositoryId: string;
|
|
20
|
+
readonly ref?: string | undefined;
|
|
21
|
+
} | undefined;
|
|
22
|
+
readonly credentials?: {
|
|
23
|
+
readonly profileId?: string | undefined;
|
|
24
|
+
readonly claude?: string | undefined;
|
|
25
|
+
readonly codex?: string | undefined;
|
|
26
|
+
readonly github?: string | undefined;
|
|
27
|
+
} | undefined;
|
|
28
|
+
readonly spec: unknown;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lowers the fluent `create({ repository, harness })` options into the existing
|
|
3
|
+
* `createSandboxRequestSchema` the control plane accepts — entirely client-side, so the slice needs
|
|
4
|
+
* no contract change. The public `repository` is the SOURCE git repo (it becomes
|
|
5
|
+
* `spec.sources.sandbox.url`); the contract's `repository`/`tag` are the OCI push coordinates, which
|
|
6
|
+
* we derive. `customization.enableSealantd` is forced on (it bakes + launches the daemon the run path
|
|
7
|
+
* connects to), the runtime target is pinned to docker (the only bridgeable adapter today), and the
|
|
8
|
+
* foreground is a keepalive so the sandbox idles with the daemon up and the harness is exec'd on
|
|
9
|
+
* demand by `run()` rather than launched at boot. `options.credentials`, if present, is lowered via
|
|
10
|
+
* `mapSandboxCredentials` (see `./credentials.js`) and folded into `spec.credentials`; the control
|
|
11
|
+
* plane resolves those account references server-side (never secret material over this path).
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { mapSandboxCredentials } from "./credentials.js";
|
|
15
|
+
const sanitizeRepoSlug = (value) => {
|
|
16
|
+
const slug = value
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
19
|
+
.replace(/^-+|-+$/g, "")
|
|
20
|
+
.slice(0, 48);
|
|
21
|
+
return slug.length > 0 ? slug : "repo";
|
|
22
|
+
};
|
|
23
|
+
const toGitUrl = (repository) => {
|
|
24
|
+
if (/^(https?:\/\/|git@|ssh:\/\/)/.test(repository)) {
|
|
25
|
+
return repository;
|
|
26
|
+
}
|
|
27
|
+
return `https://${repository}.git`;
|
|
28
|
+
};
|
|
29
|
+
export const buildCreateSandboxRequest = (options, config) => {
|
|
30
|
+
const tail = options.repository
|
|
31
|
+
.split("/")
|
|
32
|
+
.filter((s) => s.length > 0)
|
|
33
|
+
.pop() ?? options.repository;
|
|
34
|
+
const credentials = mapSandboxCredentials(options.credentials);
|
|
35
|
+
const spec = {
|
|
36
|
+
version: "1",
|
|
37
|
+
sources: {
|
|
38
|
+
sandbox: {
|
|
39
|
+
kind: "git",
|
|
40
|
+
provider: "generic",
|
|
41
|
+
url: toGitUrl(options.repository),
|
|
42
|
+
ref: options.ref ?? "main",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
harness: { id: options.harness.id },
|
|
46
|
+
customization: { enableSealantd: true },
|
|
47
|
+
target: {
|
|
48
|
+
os: { family: options.os ?? "fedora", mode: "prefer" },
|
|
49
|
+
runtime: { family: "docker", mode: "require" },
|
|
50
|
+
},
|
|
51
|
+
lifecycle: {
|
|
52
|
+
startup: { foreground: { kind: "command", run: "sleep infinity", shell: "bash" } },
|
|
53
|
+
},
|
|
54
|
+
...(options.packages === undefined || options.packages.length === 0
|
|
55
|
+
? {}
|
|
56
|
+
: { tooling: { packages: options.packages.map((id) => ({ id })) } }),
|
|
57
|
+
...(credentials === undefined ? {} : { credentials }),
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
payload: {
|
|
61
|
+
ownerUserId: config.hostLocal.ownerUserId,
|
|
62
|
+
registryId: config.hostLocal.registryId,
|
|
63
|
+
repository: sanitizeRepoSlug(tail),
|
|
64
|
+
tag: `sdk-${randomUUID().slice(0, 8)}`,
|
|
65
|
+
...(options.name === undefined ? {} : { name: options.name }),
|
|
66
|
+
spec,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal SDK configuration.
|
|
3
|
+
*
|
|
4
|
+
* The PUBLIC surface (`SealantConfig` in `../types.ts`) is intentionally minimal: `{ baseUrl, apiKey }`.
|
|
5
|
+
* The SDK is now a thin HTTP client (run execution + telemetry moved server-side), so the only
|
|
6
|
+
* host-local concerns left are a pre-auth owner principal and the registry id used on create/run
|
|
7
|
+
* payloads. These live HERE — resolved from the environment with docker-compose defaults — so they
|
|
8
|
+
* never leak into the published `SealantConfig`, and they disappear entirely once auth lands.
|
|
9
|
+
*/
|
|
10
|
+
import type { SealantConfig } from "../types.js";
|
|
11
|
+
export interface SealantHostLocalConfig {
|
|
12
|
+
/** Owner principal the control plane attributes sandboxes/runs to (pre-auth). */
|
|
13
|
+
readonly ownerUserId: string;
|
|
14
|
+
/** Registry the sandbox image is published to and launched from. */
|
|
15
|
+
readonly registryId: string;
|
|
16
|
+
}
|
|
17
|
+
export interface SealantInternalConfig {
|
|
18
|
+
readonly baseUrl: string;
|
|
19
|
+
readonly apiKey: string | undefined;
|
|
20
|
+
readonly fetch: typeof fetch | undefined;
|
|
21
|
+
readonly hostLocal: SealantHostLocalConfig;
|
|
22
|
+
}
|
|
23
|
+
/** Resolves the public config plus host-local needs (from env, with docker-compose defaults). */
|
|
24
|
+
export declare const resolveInternalConfig: (config: SealantConfig) => SealantInternalConfig;
|