@intentius/chant-lexicon-e2b 0.0.0-stage → 0.124.33
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 +147 -2
- package/dist/catalog.d.ts +19 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +58 -0
- package/dist/codegen/docs-cli.d.ts +3 -0
- package/dist/codegen/docs-cli.d.ts.map +1 -0
- package/dist/codegen/docs-cli.js +6 -0
- package/dist/codegen/docs.d.ts +15 -0
- package/dist/codegen/docs.d.ts.map +1 -0
- package/dist/codegen/docs.js +123 -0
- package/dist/codegen/generate-cli.d.ts +3 -0
- package/dist/codegen/generate-cli.d.ts.map +1 -0
- package/dist/codegen/generate-cli.js +4 -0
- package/dist/codegen/generate.d.ts +11 -0
- package/dist/codegen/generate.d.ts.map +1 -0
- package/dist/codegen/generate.js +20 -0
- package/dist/codegen/package.d.ts +10 -0
- package/dist/codegen/package.d.ts.map +1 -0
- package/dist/codegen/package.js +34 -0
- package/dist/config.d.ts +113 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +82 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/integrity.json +15 -0
- package/dist/lint/audit-catalog.d.ts +11 -0
- package/dist/lint/audit-catalog.d.ts.map +1 -0
- package/dist/lint/audit-catalog.js +15 -0
- package/dist/lint/post-synth/e2b011-template-name.d.ts +12 -0
- package/dist/lint/post-synth/e2b011-template-name.d.ts.map +1 -0
- package/dist/lint/post-synth/e2b011-template-name.js +48 -0
- package/dist/lint/post-synth/e2b012-steward-tools.d.ts +20 -0
- package/dist/lint/post-synth/e2b012-steward-tools.d.ts.map +1 -0
- package/dist/lint/post-synth/e2b012-steward-tools.js +53 -0
- package/dist/lint/post-synth/index.d.ts +3 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -0
- package/dist/lint/post-synth/index.js +6 -0
- package/dist/lint/rules/index.d.ts +6 -0
- package/dist/lint/rules/index.d.ts.map +1 -0
- package/dist/lint/rules/index.js +6 -0
- package/dist/lint/rules/pinned-image.d.ts +14 -0
- package/dist/lint/rules/pinned-image.d.ts.map +1 -0
- package/dist/lint/rules/pinned-image.js +36 -0
- package/dist/lint/rules/template-literal.d.ts +11 -0
- package/dist/lint/rules/template-literal.d.ts.map +1 -0
- package/dist/lint/rules/template-literal.js +39 -0
- package/dist/lint/rules/template-size.d.ts +10 -0
- package/dist/lint/rules/template-size.d.ts.map +1 -0
- package/dist/lint/rules/template-size.js +31 -0
- package/dist/lsp/completions.d.ts +7 -0
- package/dist/lsp/completions.d.ts.map +1 -0
- package/dist/lsp/completions.js +10 -0
- package/dist/lsp/hover.d.ts +7 -0
- package/dist/lsp/hover.d.ts.map +1 -0
- package/dist/lsp/hover.js +30 -0
- package/dist/manifest.json +6 -0
- package/dist/meta.json +45 -0
- package/dist/okf/index.md +14 -0
- package/dist/okf/rules/E2B001.md +15 -0
- package/dist/okf/rules/E2B002.md +15 -0
- package/dist/okf/rules/E2B011.md +15 -0
- package/dist/okf/rules/E2B012.md +15 -0
- package/dist/okf/types/Template.md +16 -0
- package/dist/op/runtime.d.ts +119 -0
- package/dist/op/runtime.d.ts.map +1 -0
- package/dist/op/runtime.js +522 -0
- package/dist/op/sandbox.d.ts +67 -0
- package/dist/op/sandbox.d.ts.map +1 -0
- package/dist/op/sandbox.js +87 -0
- package/dist/package-cli.d.ts +3 -0
- package/dist/package-cli.d.ts.map +1 -0
- package/dist/package-cli.js +14 -0
- package/dist/plugin.d.ts +12 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +66 -0
- package/dist/resources.d.ts +38 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +21 -0
- package/dist/rules/e2b011-template-name.ts +51 -0
- package/dist/rules/e2b012-steward-tools.ts +58 -0
- package/dist/rules/pinned-image.ts +41 -0
- package/dist/rules/template-literal.ts +42 -0
- package/dist/rules/template-size.ts +36 -0
- package/dist/serializer.d.ts +18 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +70 -0
- package/dist/skills/chant-e2b-ops.md +69 -0
- package/dist/types/index.d.ts +1 -0
- package/package.json +67 -4
- package/src/catalog.ts +72 -0
- package/src/codegen/docs-cli.ts +7 -0
- package/src/codegen/docs.ts +130 -0
- package/src/codegen/generate-cli.ts +5 -0
- package/src/codegen/generate.ts +22 -0
- package/src/codegen/package.ts +39 -0
- package/src/config.ts +102 -0
- package/src/index.ts +6 -0
- package/src/lint/audit-catalog.ts +29 -0
- package/src/lint/post-synth/e2b011-template-name.ts +51 -0
- package/src/lint/post-synth/e2b012-steward-tools.ts +58 -0
- package/src/lint/post-synth/index.ts +9 -0
- package/src/lint/post-synth/post-synth.test.ts +59 -0
- package/src/lint/rules/index.ts +9 -0
- package/src/lint/rules/pinned-image.ts +41 -0
- package/src/lint/rules/rules.test.ts +46 -0
- package/src/lint/rules/template-literal.ts +42 -0
- package/src/lint/rules/template-size.ts +36 -0
- package/src/lsp/completions.test.ts +23 -0
- package/src/lsp/completions.ts +13 -0
- package/src/lsp/hover.test.ts +29 -0
- package/src/lsp/hover.ts +35 -0
- package/src/op/ledger-sync.test.ts +136 -0
- package/src/op/runtime.test.ts +394 -0
- package/src/op/runtime.ts +573 -0
- package/src/op/sandbox.ts +138 -0
- package/src/package-cli.ts +16 -0
- package/src/plugin.test.ts +39 -0
- package/src/plugin.ts +79 -0
- package/src/resources.ts +50 -0
- package/src/serializer.test.ts +88 -0
- package/src/serializer.ts +73 -0
- package/src/skills/chant-e2b-ops.md +69 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The e2b `opRuntime` provider: `chant run <op> --on e2b`.
|
|
3
|
+
*
|
|
4
|
+
* A steward is one sandbox that outlives every run: it keeps a clone of the
|
|
5
|
+
* project and its installed tools on its disk, runs one op at a time, and
|
|
6
|
+
* sleeps paused between runs, which costs nothing and survives anything that
|
|
7
|
+
* doesn't delete it. Any API that speaks E2B's hosts one: hosted E2B, or a
|
|
8
|
+
* platform of your own that serves the same API (loam does).
|
|
9
|
+
*
|
|
10
|
+
* A run here is the run a person would have typed in that clone:
|
|
11
|
+
*
|
|
12
|
+
* 1. Find the steward's sandbox by its `chant/steward` metadata, or make it.
|
|
13
|
+
* A sandbox already running an op is busy, and the run is refused rather
|
|
14
|
+
* than queued, as fountain refuses a busy steward conversation.
|
|
15
|
+
* 2. Bring the clone to the commit this checkout is at (fetched from the
|
|
16
|
+
* repository, so the commit must be pushed) and install the project's
|
|
17
|
+
* dependencies when its lockfile changed.
|
|
18
|
+
* 3. Run `chant run <op> --on local --json --progress-json` there, streaming
|
|
19
|
+
* each settled step back through `progress` and its stderr to ours. Gates,
|
|
20
|
+
* leases and the run ledger are the local runtime's, unchanged: they live on
|
|
21
|
+
* the repository's `chant/lifecycle` branch, which chant in the sandbox
|
|
22
|
+
* fetches and pushes with the git token the profile names, so `chant
|
|
23
|
+
* approve` on any checkout answers a gate a steward reached.
|
|
24
|
+
* 4. Pause the sandbox.
|
|
25
|
+
*
|
|
26
|
+
* `status`, `log` and `list` read the run ledger in the steward's clone, and
|
|
27
|
+
* report a run still in progress from the sandbox's process list. They wake
|
|
28
|
+
* the sandbox to do it and leave it to pause itself a few minutes later.
|
|
29
|
+
*
|
|
30
|
+
* Nothing here remembers anything between processes: the sandbox's metadata
|
|
31
|
+
* finds it, the ledger is the history, and the process list is what is running.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { loadChantConfig, type ChantConfig } from "@intentius/chant/config";
|
|
35
|
+
import type { OpConfig, OpRunHandle, OpRunRecord, OpRunStartOptions, OpRunStatus, OpRuntimeProvider, StepRecord } from "@intentius/chant/op";
|
|
36
|
+
import { runStateOf } from "@intentius/chant/op";
|
|
37
|
+
import { resolveProfile, type E2bProfile } from "../config";
|
|
38
|
+
import {
|
|
39
|
+
e2bSandboxes,
|
|
40
|
+
RUN_ID_ENV,
|
|
41
|
+
RUN_OP_ENV,
|
|
42
|
+
RUN_STARTED_ENV,
|
|
43
|
+
type CommandResult,
|
|
44
|
+
type StewardSandbox,
|
|
45
|
+
type StewardSandboxes,
|
|
46
|
+
} from "./sandbox";
|
|
47
|
+
|
|
48
|
+
/** How long a sandbox is held awake at a time while a run is going. Renewed at a third of it. */
|
|
49
|
+
export const RUN_AWAKE_MS = 30 * 60_000;
|
|
50
|
+
|
|
51
|
+
/** How long a read (`status`, `log`, `list`) leaves a woken sandbox awake before it pauses itself. */
|
|
52
|
+
export const READ_AWAKE_MS = 5 * 60_000;
|
|
53
|
+
|
|
54
|
+
/** The template a steward is made from when the profile names none. */
|
|
55
|
+
export const DEFAULT_TEMPLATE = "base";
|
|
56
|
+
|
|
57
|
+
/** Where the steward keeps its clone, under the sandbox user's home. */
|
|
58
|
+
export const CLONE_DIR = "chant-steward";
|
|
59
|
+
|
|
60
|
+
export interface E2bOpRuntimeOptions {
|
|
61
|
+
/** The project directory. Default `process.cwd()`. */
|
|
62
|
+
cwd?: string;
|
|
63
|
+
/** The project's chant config, when the caller already has it. */
|
|
64
|
+
config?: ChantConfig;
|
|
65
|
+
/** The sandbox provider. Default: the `e2b` SDK, connected per the profile. */
|
|
66
|
+
sandboxes?: StewardSandboxes;
|
|
67
|
+
/** Read the local checkout. Injectable so a test needs no repository. */
|
|
68
|
+
git?: (args: string[]) => Promise<string>;
|
|
69
|
+
/** Where the sandbox's stderr goes. Default `process.stderr`. */
|
|
70
|
+
stderr?: (chunk: string) => void;
|
|
71
|
+
env?: NodeJS.ProcessEnv;
|
|
72
|
+
now?: () => Date;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What a run needs to know about the profile, resolved once per process. */
|
|
76
|
+
interface Resolved {
|
|
77
|
+
sandboxes: StewardSandboxes;
|
|
78
|
+
steward: string;
|
|
79
|
+
template: string;
|
|
80
|
+
repo: string;
|
|
81
|
+
gitToken?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** An ssh remote as the https URL a sandbox can fetch with a token. Anything else is returned as it is. */
|
|
85
|
+
export function httpsRemote(url: string): string {
|
|
86
|
+
const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url);
|
|
87
|
+
if (scp) return `https://${scp[1]}/${scp[2]}`;
|
|
88
|
+
const ssh = /^ssh:\/\/(?:[\w.-]+@)?([\w.-]+)(?::\d+)?\/(.+)$/.exec(url);
|
|
89
|
+
if (ssh) return `https://${ssh[1]}/${ssh[2]}`;
|
|
90
|
+
return url;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The repository's name, from its URL: the steward's default name. */
|
|
94
|
+
export function repoName(url: string): string {
|
|
95
|
+
const last = url.replace(/\/+$/, "").split(/[/:]/).pop() ?? "";
|
|
96
|
+
return last.replace(/\.git$/, "") || "steward";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A word as one shell argument. */
|
|
100
|
+
export function shellQuote(value: string): string {
|
|
101
|
+
return /^[A-Za-z0-9._:@+=/,-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A bash function, `ledger_sync`, that leaves the clone's `chant/lifecycle`
|
|
106
|
+
* branch and the repository's the same, run in the clone after a fetch.
|
|
107
|
+
*
|
|
108
|
+
* chant pushes the facts other checkouts have to see (a gate's pending fact,
|
|
109
|
+
* an approval, a lease) as it writes them, but keeps each run record on the
|
|
110
|
+
* local branch only. A checkout that lives on, as a steward's does, therefore
|
|
111
|
+
* holds commits the repository lacks, and the first approval pushed from
|
|
112
|
+
* anywhere else leaves the two branches diverged; chant then refuses to read
|
|
113
|
+
* the ledger at all. So the steward reconciles before and after every run:
|
|
114
|
+
*
|
|
115
|
+
* - behind, or the same: nothing to do (chant fetches it itself);
|
|
116
|
+
* - ahead: push;
|
|
117
|
+
* - diverged: merge, then push. Every ledger file is append-only, so the
|
|
118
|
+
* merge keeps the repository's tree and adds, for each `.jsonl` file, the
|
|
119
|
+
* local lines it lacks after its own, and any file only the clone has. The
|
|
120
|
+
* merge commit has both tips as parents, so neither side loses a commit.
|
|
121
|
+
*
|
|
122
|
+
* A push that fails (the repository moved again in between, or there is no
|
|
123
|
+
* token) is reported and left for the next run.
|
|
124
|
+
*/
|
|
125
|
+
export const LEDGER_SYNC_FN = [
|
|
126
|
+
"ledger_sync() {",
|
|
127
|
+
" local L=refs/heads/chant/lifecycle R=refs/remotes/origin/chant/lifecycle",
|
|
128
|
+
" git rev-parse -q --verify \"$L\" >/dev/null || return 0",
|
|
129
|
+
" if git rev-parse -q --verify \"$R\" >/dev/null; then",
|
|
130
|
+
" git merge-base --is-ancestor \"$L\" \"$R\" && return 0",
|
|
131
|
+
" if ! git merge-base --is-ancestor \"$R\" \"$L\"; then",
|
|
132
|
+
" local idx; idx=\"$(git rev-parse --git-dir)/chant-e2b-merge-index\"",
|
|
133
|
+
" GIT_INDEX_FILE=\"$idx\" git read-tree \"$R\"",
|
|
134
|
+
" local p lb rb blob",
|
|
135
|
+
" while IFS= read -r p; do",
|
|
136
|
+
" lb=$(git rev-parse -q --verify \"$L:$p\") || continue",
|
|
137
|
+
" rb=$(git rev-parse -q --verify \"$R:$p\") || rb=",
|
|
138
|
+
" if [ -z \"$rb\" ]; then blob=$lb",
|
|
139
|
+
" elif [[ \"$p\" == *.jsonl ]]; then",
|
|
140
|
+
" blob=$( { git cat-file blob \"$rb\"; git cat-file blob \"$lb\" | grep -vxF -f <(git cat-file blob \"$rb\") || true; } | git hash-object -w --stdin )",
|
|
141
|
+
" else continue; fi",
|
|
142
|
+
" GIT_INDEX_FILE=\"$idx\" git update-index --add --cacheinfo \"100644,$blob,$p\"",
|
|
143
|
+
" done < <(git diff --name-only \"$R\" \"$L\")",
|
|
144
|
+
" local tree merged",
|
|
145
|
+
" tree=$(GIT_INDEX_FILE=\"$idx\" git write-tree); rm -f \"$idx\"",
|
|
146
|
+
" merged=$(git -c user.name=chant -c user.email=chant@localhost commit-tree \"$tree\" -p \"$R\" -p \"$L\" -m 'e2b steward: merge local run records with the repository ledger')",
|
|
147
|
+
" git update-ref \"$L\" \"$merged\"",
|
|
148
|
+
" echo 'e2b: merged the steward ledger with the repository one' >&2",
|
|
149
|
+
" fi",
|
|
150
|
+
" fi",
|
|
151
|
+
" local out; out=$(git push --quiet origin \"$L:$L\" 2>&1) || printf '%s\\ne2b: pushing the steward ledger failed; the next run tries again\\n' \"$out\" >&2",
|
|
152
|
+
"}",
|
|
153
|
+
].join("\n");
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The script that brings the steward's clone to `commit`: clone it the first
|
|
157
|
+
* time, fetch every branch after that, reconcile the ledger branch
|
|
158
|
+
* ({@link LEDGER_SYNC_FN}), check the commit out over whatever is there, and
|
|
159
|
+
* install dependencies when the lockfile changed. Ignored files
|
|
160
|
+
* (`node_modules`, tool caches) are kept, which is the point of a steward.
|
|
161
|
+
* The token, when there is one, reaches git through a credential helper that
|
|
162
|
+
* reads it from the environment, so it is never written to the sandbox's disk.
|
|
163
|
+
*/
|
|
164
|
+
export function prepareScript(repo: string, commit: string): string {
|
|
165
|
+
const dir = `"$HOME/${CLONE_DIR}"`;
|
|
166
|
+
return [
|
|
167
|
+
"set -euo pipefail",
|
|
168
|
+
LEDGER_SYNC_FN,
|
|
169
|
+
"command -v git >/dev/null || { echo 'e2b runtime: the sandbox has no git; use a template that has git and Node' >&2; exit 2; }",
|
|
170
|
+
"command -v node >/dev/null || { echo 'e2b runtime: the sandbox has no Node; use a template that has git and Node' >&2; exit 2; }",
|
|
171
|
+
`git config --global credential.helper '!f() { test "$1" = get && test -n "\${CHANT_GIT_TOKEN:-}" && echo username=x-access-token && echo "password=$CHANT_GIT_TOKEN"; }; f'`,
|
|
172
|
+
`if [ ! -d ${dir}/.git ]; then git clone --quiet --no-checkout ${shellQuote(repo)} ${dir}; fi`,
|
|
173
|
+
`cd ${dir}`,
|
|
174
|
+
`git remote set-url origin ${shellQuote(repo)}`,
|
|
175
|
+
"git fetch --quiet --prune origin '+refs/heads/*:refs/remotes/origin/*'",
|
|
176
|
+
"ledger_sync",
|
|
177
|
+
`git cat-file -e ${commit}^{commit} 2>/dev/null || { echo "e2b runtime: commit ${commit} is not on ${repo}; push it first" >&2; exit 2; }`,
|
|
178
|
+
`git checkout --quiet --force --detach ${commit}`,
|
|
179
|
+
"git clean -fdq",
|
|
180
|
+
"if [ -f package-lock.json ]; then",
|
|
181
|
+
" sum=$(sha256sum package-lock.json | cut -d' ' -f1)",
|
|
182
|
+
" if [ \"$(cat .git/chant-e2b-lock 2>/dev/null)\" != \"$sum\" ] || [ ! -d node_modules ]; then",
|
|
183
|
+
" npm ci --no-audit --no-fund --loglevel=error >&2 && echo \"$sum\" > .git/chant-e2b-lock",
|
|
184
|
+
" fi",
|
|
185
|
+
"elif [ -f package.json ] && [ ! -d node_modules ]; then",
|
|
186
|
+
" npm install --no-audit --no-fund --loglevel=error >&2",
|
|
187
|
+
"fi",
|
|
188
|
+
"test -x node_modules/.bin/chant || { echo 'e2b runtime: the project does not install chant (add @intentius/chant to its dependencies)' >&2; exit 2; }",
|
|
189
|
+
].join("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Seconds a stopped run is given to end on SIGTERM before it is killed. */
|
|
193
|
+
export const STOP_GRACE_S = 10;
|
|
194
|
+
|
|
195
|
+
/** SIGTERM `pid`, wait for it to go, and exit non-zero if it is still there after the grace period. */
|
|
196
|
+
export function stopScript(pid: number): string {
|
|
197
|
+
const p = Math.trunc(pid);
|
|
198
|
+
return `kill -TERM ${p} 2>/dev/null || exit 0; for i in $(seq 1 ${STOP_GRACE_S * 2}); do kill -0 ${p} 2>/dev/null || exit 0; sleep 0.5; done; exit 1`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** After a run: fetch and reconcile the ledger branch, so the run's record reaches the repository. */
|
|
202
|
+
export function finishScript(): string {
|
|
203
|
+
return [
|
|
204
|
+
"set -uo pipefail",
|
|
205
|
+
LEDGER_SYNC_FN,
|
|
206
|
+
`cd "$HOME/${CLONE_DIR}" || exit 0`,
|
|
207
|
+
"git fetch --quiet origin '+refs/heads/chant/lifecycle:refs/remotes/origin/chant/lifecycle' 2>/dev/null || true",
|
|
208
|
+
"ledger_sync",
|
|
209
|
+
].join("\n");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The command line one run is, in the clone. `exec` so the process the sandbox lists is chant itself. */
|
|
213
|
+
export function runScript(op: string, opts: Pick<OpRunStartOptions, "env" | "params" | "work">): string {
|
|
214
|
+
const words = ["chant", "run", op];
|
|
215
|
+
if (opts.env) words.push("--env", opts.env);
|
|
216
|
+
for (const [name, value] of Object.entries(opts.params ?? {})) {
|
|
217
|
+
words.push("--param", `${name}=${typeof value === "string" ? value : JSON.stringify(value)}`);
|
|
218
|
+
}
|
|
219
|
+
if (opts.work?.item) words.push("--work", opts.work.item);
|
|
220
|
+
if (opts.work?.holder) words.push("--holder", opts.work.holder);
|
|
221
|
+
words.push("--on", "local", "--json", "--progress-json");
|
|
222
|
+
return `cd "$HOME/${CLONE_DIR}" && exec node_modules/.bin/${words.map(shellQuote).join(" ")}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The script that prints every run record the clone's ledger holds for `ops`
|
|
227
|
+
* (all of them when empty), one JSON line each, from every environment
|
|
228
|
+
* directory on `chant/lifecycle`. Root-level ledgers only: a workspace
|
|
229
|
+
* member's runs (`_members/<m>/...`) are not read.
|
|
230
|
+
*/
|
|
231
|
+
export function ledgerScript(ops: string[]): string {
|
|
232
|
+
const pattern = ops.length === 0 ? "runs__*.jsonl" : ops.map((op) => `runs__${op}.jsonl`).join(" ");
|
|
233
|
+
return [
|
|
234
|
+
`cd "$HOME/${CLONE_DIR}" 2>/dev/null || exit 0`,
|
|
235
|
+
"git rev-parse --verify --quiet chant/lifecycle >/dev/null || exit 0",
|
|
236
|
+
"git ls-tree -r --name-only chant/lifecycle | while read -r path; do",
|
|
237
|
+
" case \"$path\" in _*) continue;; */*/*) continue;; esac",
|
|
238
|
+
" base=${path##*/}",
|
|
239
|
+
` for p in ${pattern}; do case "$base" in $p) git show "chant/lifecycle:$path"; echo;; esac; done`,
|
|
240
|
+
"done",
|
|
241
|
+
].join("\n");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
245
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A ledger line, or a run's final `--json` line, as an {@link OpRunRecord}; anything else `undefined`. */
|
|
249
|
+
export function asRunRecord(v: unknown): OpRunRecord | undefined {
|
|
250
|
+
if (!isRecord(v)) return undefined;
|
|
251
|
+
if (v.version !== 1 || typeof v.id !== "string" || typeof v.op !== "string" || typeof v.status !== "string") return undefined;
|
|
252
|
+
return v as unknown as OpRunRecord;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A `--progress-json` line as a {@link StepRecord}; anything else `undefined`. */
|
|
256
|
+
export function asStepRecord(v: unknown): StepRecord | undefined {
|
|
257
|
+
if (!isRecord(v)) return undefined;
|
|
258
|
+
if (typeof v.phase !== "string" || typeof v.fn !== "string" || typeof v.status !== "string") return undefined;
|
|
259
|
+
return v as unknown as StepRecord;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Every record in `text`, one JSON object per line, newest first. */
|
|
263
|
+
export function parseLedger(text: string): OpRunRecord[] {
|
|
264
|
+
const records: OpRunRecord[] = [];
|
|
265
|
+
for (const line of text.split("\n")) {
|
|
266
|
+
if (!line.trim()) continue;
|
|
267
|
+
try {
|
|
268
|
+
const record = asRunRecord(JSON.parse(line));
|
|
269
|
+
if (record) records.push(record);
|
|
270
|
+
} catch {
|
|
271
|
+
// A malformed line is skipped, as the local ledger reader skips it.
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return records.sort((a, b) => b.started.localeCompare(a.started));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Splits a byte stream into lines, handing each whole line to `line`. `flush`
|
|
279
|
+
* hands over what is left.
|
|
280
|
+
*/
|
|
281
|
+
function lineSplitter(line: (text: string) => void): { push(chunk: string): void; flush(): void } {
|
|
282
|
+
let buffer = "";
|
|
283
|
+
return {
|
|
284
|
+
push(chunk) {
|
|
285
|
+
buffer += chunk;
|
|
286
|
+
let at: number;
|
|
287
|
+
while ((at = buffer.indexOf("\n")) >= 0) {
|
|
288
|
+
line(buffer.slice(0, at));
|
|
289
|
+
buffer = buffer.slice(at + 1);
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
flush() {
|
|
293
|
+
if (buffer) line(buffer);
|
|
294
|
+
buffer = "";
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function statusFromRecord(record: OpRunRecord): OpRunStatus {
|
|
300
|
+
return {
|
|
301
|
+
op: record.op,
|
|
302
|
+
runId: record.id,
|
|
303
|
+
state: runStateOf(record.status),
|
|
304
|
+
startedAt: record.started,
|
|
305
|
+
endedAt: record.ended,
|
|
306
|
+
...(record.gate ? { gate: record.gate } : {}),
|
|
307
|
+
...(record.point ? { point: record.point } : {}),
|
|
308
|
+
...(record.status === "fail" ? { error: failureOf(record) } : {}),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function failureOf(record: OpRunRecord): string | undefined {
|
|
313
|
+
for (const phase of record.phases) {
|
|
314
|
+
for (const step of phase.steps) if (step.status === "fail" && step.error) return `${phase.name}: ${step.error}`;
|
|
315
|
+
}
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The last few lines of a failed command's output, for an error message. */
|
|
320
|
+
function tail(result: CommandResult, lines = 15): string {
|
|
321
|
+
const text = `${result.stderr}\n${result.stdout}`.trim().split("\n");
|
|
322
|
+
return text.slice(-lines).join("\n");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function createE2bOpRuntime(opts: E2bOpRuntimeOptions = {}): OpRuntimeProvider {
|
|
326
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
327
|
+
const env = opts.env ?? process.env;
|
|
328
|
+
const now = opts.now ?? (() => new Date());
|
|
329
|
+
const writeStderr = opts.stderr ?? ((chunk: string) => void process.stderr.write(chunk));
|
|
330
|
+
|
|
331
|
+
const git = opts.git ?? (async (args: string[]) => {
|
|
332
|
+
const { execFile } = await import("node:child_process");
|
|
333
|
+
return new Promise<string>((resolve, reject) => {
|
|
334
|
+
execFile("git", args, { cwd }, (err, stdout, stderr) => {
|
|
335
|
+
if (err) reject(new Error(`git ${args.join(" ")}: ${stderr.trim() || err.message}`));
|
|
336
|
+
else resolve(stdout.trim());
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
let activeProfile: string | undefined;
|
|
342
|
+
let resolved: Promise<Resolved> | undefined;
|
|
343
|
+
|
|
344
|
+
const resolve = (): Promise<Resolved> => {
|
|
345
|
+
resolved ??= (async () => {
|
|
346
|
+
let config = opts.config;
|
|
347
|
+
if (!config) {
|
|
348
|
+
try {
|
|
349
|
+
config = (await loadChantConfig(cwd)).config;
|
|
350
|
+
} catch {
|
|
351
|
+
config = {} as ChantConfig;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const profile: E2bProfile = resolveProfile(config, activeProfile) ?? {};
|
|
355
|
+
|
|
356
|
+
let sandboxes = opts.sandboxes;
|
|
357
|
+
if (!sandboxes) {
|
|
358
|
+
const keyVar = profile.apiKey?.env ?? "E2B_API_KEY";
|
|
359
|
+
const apiKey = env[keyVar];
|
|
360
|
+
if (!apiKey) {
|
|
361
|
+
throw new Error(`e2b runtime: ${keyVar} is not set. Set it, or name another variable in e2b.profiles.<name>.apiKey.`);
|
|
362
|
+
}
|
|
363
|
+
const domain = profile.domain ?? env.E2B_DOMAIN;
|
|
364
|
+
sandboxes = e2bSandboxes({ apiKey, ...(domain ? { domain } : {}) });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const repo = profile.repo ?? httpsRemote(await git(["remote", "get-url", "origin"]).catch(() => {
|
|
368
|
+
throw new Error("e2b runtime: this checkout has no `origin` remote for the sandbox to clone. Add one, or set e2b.profiles.<name>.repo.");
|
|
369
|
+
}));
|
|
370
|
+
const tokenVar = profile.gitToken?.env ?? "CHANT_GIT_TOKEN";
|
|
371
|
+
const gitToken = env[tokenVar];
|
|
372
|
+
if (profile.gitToken && !gitToken) {
|
|
373
|
+
throw new Error(`e2b runtime: the profile's git token variable ${tokenVar} is not set.`);
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
sandboxes,
|
|
377
|
+
steward: profile.steward ?? repoName(repo),
|
|
378
|
+
template: profile.template ?? DEFAULT_TEMPLATE,
|
|
379
|
+
repo,
|
|
380
|
+
...(gitToken ? { gitToken } : {}),
|
|
381
|
+
};
|
|
382
|
+
})();
|
|
383
|
+
return resolved;
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const selectProfile = (name: string | undefined): void => {
|
|
387
|
+
if (name === undefined || name === activeProfile) return;
|
|
388
|
+
activeProfile = name;
|
|
389
|
+
resolved = undefined;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
/** The steward's sandbox, awake, or `undefined` when it has none and `create` is false. */
|
|
393
|
+
const reach = async (r: Resolved, awakeMs: number, create: boolean): Promise<StewardSandbox | undefined> => {
|
|
394
|
+
const found = await r.sandboxes.find(r.steward);
|
|
395
|
+
if (found) return r.sandboxes.connect(found.id, { awakeMs });
|
|
396
|
+
if (!create) return undefined;
|
|
397
|
+
writeStderr(`e2b: making steward "${r.steward}" from template "${r.template}"\n`);
|
|
398
|
+
return r.sandboxes.create(r.steward, { template: r.template, awakeMs });
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/** The chant run in progress in the sandbox, if any. */
|
|
402
|
+
const liveRun = async (sb: StewardSandbox) => (await sb.processes()).find((p) => p.envs[RUN_OP_ENV] !== undefined);
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Stop a run the way Ctrl-C stops a local one: SIGTERM, which chant turns
|
|
406
|
+
* into an abort that kills the op's children and records the run, then
|
|
407
|
+
* SIGKILL if it is still there after {@link STOP_GRACE_S} seconds.
|
|
408
|
+
*/
|
|
409
|
+
const stop = async (sb: StewardSandbox, pid: number): Promise<void> => {
|
|
410
|
+
const result = await sb.run(stopScript(pid)).catch(() => undefined);
|
|
411
|
+
if (!result || result.exitCode !== 0) await sb.kill(pid).catch(() => {});
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const readLedger = async (sb: StewardSandbox, ops: string[]): Promise<OpRunRecord[]> => {
|
|
415
|
+
const result = await sb.run(ledgerScript(ops));
|
|
416
|
+
if (result.exitCode !== 0) throw new Error(`e2b runtime: reading the steward's run ledger failed:\n${tail(result)}`);
|
|
417
|
+
return parseLedger(result.stdout);
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
name: "e2b",
|
|
422
|
+
carriesWork: true,
|
|
423
|
+
|
|
424
|
+
async start(op: OpConfig, startOpts: OpRunStartOptions): Promise<OpRunHandle> {
|
|
425
|
+
selectProfile(startOpts.profile);
|
|
426
|
+
const r = await resolve();
|
|
427
|
+
|
|
428
|
+
const commit = await git(["rev-parse", "HEAD"]);
|
|
429
|
+
if ((await git(["status", "--porcelain", "--untracked-files=no"]).catch(() => "")) !== "") {
|
|
430
|
+
writeStderr(`e2b: this checkout has uncommitted changes; the steward runs commit ${commit.slice(0, 12)} without them\n`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const sb = (await reach(r, RUN_AWAKE_MS, true))!;
|
|
434
|
+
const busy = await liveRun(sb);
|
|
435
|
+
if (busy) {
|
|
436
|
+
throw new Error(
|
|
437
|
+
`e2b runtime: steward "${r.steward}" (${sb.id}) is running op "${busy.envs[RUN_OP_ENV]}" (run ${busy.envs[RUN_ID_ENV] ?? "?"}). ` +
|
|
438
|
+
`One run at a time: wait for it, or \`chant run cancel ${busy.envs[RUN_OP_ENV]} --on e2b\`.`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const runId = startOpts.runId ?? crypto.randomUUID();
|
|
443
|
+
const startedAt = now().toISOString();
|
|
444
|
+
const secretEnv: Record<string, string> = r.gitToken ? { CHANT_GIT_TOKEN: r.gitToken } : {};
|
|
445
|
+
|
|
446
|
+
// Renew the awake window while the run goes; a client that dies leaves
|
|
447
|
+
// the sandbox to finish and then pause itself when the window runs out.
|
|
448
|
+
const renew = setInterval(() => void sb.keepAwake(RUN_AWAKE_MS).catch(() => {}), RUN_AWAKE_MS / 3);
|
|
449
|
+
renew.unref?.();
|
|
450
|
+
|
|
451
|
+
let pid: number | undefined;
|
|
452
|
+
const onAbort = () => {
|
|
453
|
+
if (pid !== undefined) void stop(sb, pid);
|
|
454
|
+
};
|
|
455
|
+
startOpts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
456
|
+
|
|
457
|
+
const settle = async (): Promise<OpRunStatus> => {
|
|
458
|
+
try {
|
|
459
|
+
const prepared = await sb.run(prepareScript(r.repo, commit), {
|
|
460
|
+
envs: secretEnv,
|
|
461
|
+
onStderr: writeStderr,
|
|
462
|
+
});
|
|
463
|
+
if (prepared.exitCode !== 0) {
|
|
464
|
+
return { op: op.name, runId, state: "failed", startedAt, endedAt: now().toISOString(), error: tail(prepared) };
|
|
465
|
+
}
|
|
466
|
+
if (startOpts.signal?.aborted) {
|
|
467
|
+
return { op: op.name, runId, state: "cancelled", startedAt, endedAt: now().toISOString() };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
let record: OpRunRecord | undefined;
|
|
471
|
+
const lines = lineSplitter((text) => {
|
|
472
|
+
if (!text.trim().startsWith("{")) return;
|
|
473
|
+
let parsed: unknown;
|
|
474
|
+
try {
|
|
475
|
+
parsed = JSON.parse(text);
|
|
476
|
+
} catch {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const step = asStepRecord(parsed);
|
|
480
|
+
if (step) {
|
|
481
|
+
startOpts.progress?.(step);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
record = asRunRecord(parsed) ?? record;
|
|
485
|
+
});
|
|
486
|
+
const ran = await sb.run(runScript(op.name, startOpts), {
|
|
487
|
+
envs: { ...secretEnv, [RUN_OP_ENV]: op.name, [RUN_ID_ENV]: runId, [RUN_STARTED_ENV]: startedAt },
|
|
488
|
+
onStdout: (chunk) => lines.push(chunk),
|
|
489
|
+
onStderr: writeStderr,
|
|
490
|
+
onStart: (p) => {
|
|
491
|
+
pid = p;
|
|
492
|
+
if (startOpts.signal?.aborted) onAbort();
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
lines.flush();
|
|
496
|
+
await sb.run(finishScript(), { envs: secretEnv, onStderr: writeStderr }).catch((err) => {
|
|
497
|
+
writeStderr(`e2b: syncing the steward ledger failed (${err instanceof Error ? err.message : String(err)}); the next run tries again\n`);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
if (record) return statusFromRecord(record);
|
|
501
|
+
// Killed with no record: Ctrl-C here, or `chant run cancel` from another checkout.
|
|
502
|
+
if (startOpts.signal?.aborted || ran.exitCode < 0) {
|
|
503
|
+
return { op: op.name, runId, state: "cancelled", startedAt, endedAt: now().toISOString() };
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
op: op.name,
|
|
507
|
+
runId,
|
|
508
|
+
state: "failed",
|
|
509
|
+
startedAt,
|
|
510
|
+
endedAt: now().toISOString(),
|
|
511
|
+
error: `chant exited ${ran.exitCode} in steward "${r.steward}" without a run record:\n${tail(ran)}`,
|
|
512
|
+
};
|
|
513
|
+
} finally {
|
|
514
|
+
clearInterval(renew);
|
|
515
|
+
startOpts.signal?.removeEventListener("abort", onAbort);
|
|
516
|
+
await sb.pause().catch((err) => {
|
|
517
|
+
writeStderr(`e2b: pausing steward "${r.steward}" failed (${err instanceof Error ? err.message : String(err)}); it pauses itself when its awake window runs out\n`);
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const result = settle();
|
|
523
|
+
return { op: op.name, runId, result: () => result };
|
|
524
|
+
},
|
|
525
|
+
|
|
526
|
+
async status(op: string): Promise<OpRunStatus | undefined> {
|
|
527
|
+
const r = await resolve();
|
|
528
|
+
const sb = await reach(r, READ_AWAKE_MS, false);
|
|
529
|
+
if (!sb) return undefined;
|
|
530
|
+
const live = await liveRun(sb);
|
|
531
|
+
if (live && live.envs[RUN_OP_ENV] === op) {
|
|
532
|
+
return { op, runId: live.envs[RUN_ID_ENV] ?? "", state: "running", startedAt: live.envs[RUN_STARTED_ENV] ?? "" };
|
|
533
|
+
}
|
|
534
|
+
const [latest] = await readLedger(sb, [op]);
|
|
535
|
+
return latest ? statusFromRecord(latest) : undefined;
|
|
536
|
+
},
|
|
537
|
+
|
|
538
|
+
async log(op: string, logOpts?: { limit?: number }): Promise<OpRunRecord[]> {
|
|
539
|
+
const r = await resolve();
|
|
540
|
+
const sb = await reach(r, READ_AWAKE_MS, false);
|
|
541
|
+
if (!sb) return [];
|
|
542
|
+
const records = await readLedger(sb, [op]);
|
|
543
|
+
return logOpts?.limit !== undefined ? records.slice(0, logOpts.limit) : records;
|
|
544
|
+
},
|
|
545
|
+
|
|
546
|
+
async list(ops: OpConfig[]): Promise<Map<string, OpRunStatus | undefined>> {
|
|
547
|
+
const states = new Map<string, OpRunStatus | undefined>(ops.map((o) => [o.name, undefined]));
|
|
548
|
+
if (ops.length === 0) return states;
|
|
549
|
+
const r = await resolve();
|
|
550
|
+
const sb = await reach(r, READ_AWAKE_MS, false);
|
|
551
|
+
if (!sb) return states;
|
|
552
|
+
for (const record of (await readLedger(sb, ops.map((o) => o.name))).reverse()) {
|
|
553
|
+
states.set(record.op, statusFromRecord(record));
|
|
554
|
+
}
|
|
555
|
+
const live = await liveRun(sb);
|
|
556
|
+
const liveOp = live?.envs[RUN_OP_ENV];
|
|
557
|
+
if (live && liveOp && states.has(liveOp)) {
|
|
558
|
+
states.set(liveOp, { op: liveOp, runId: live.envs[RUN_ID_ENV] ?? "", state: "running", startedAt: live.envs[RUN_STARTED_ENV] ?? "" });
|
|
559
|
+
}
|
|
560
|
+
return states;
|
|
561
|
+
},
|
|
562
|
+
|
|
563
|
+
async cancel(op: string): Promise<void> {
|
|
564
|
+
const r = await resolve();
|
|
565
|
+
const sb = await reach(r, READ_AWAKE_MS, false);
|
|
566
|
+
const live = sb ? await liveRun(sb) : undefined;
|
|
567
|
+
if (!sb || !live || live.envs[RUN_OP_ENV] !== op) {
|
|
568
|
+
throw new Error(`e2b runtime: steward "${r.steward}" is not running op "${op}".`);
|
|
569
|
+
}
|
|
570
|
+
await stop(sb, live.pid);
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
}
|