@agent-compose/sdk 0.8.1 → 0.8.3
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/dist/agent/__tests__/perf-sampler.test.d.ts +10 -0
- package/dist/agent/agent-context.d.ts +1 -1
- package/dist/agent/agent-loop.d.ts +5 -1
- package/dist/agent/desktop-open.d.ts +184 -0
- package/dist/agent/perf-sampler.d.ts +99 -0
- package/dist/agent/services-manifest.d.ts +88 -0
- package/dist/agent/services-restore.d.ts +58 -0
- package/dist/client.d.ts +189 -15
- package/dist/display.d.ts +17 -0
- package/dist/index.d.ts +14 -5
- package/dist/index.js +1625 -120
- package/dist/runtimes/_cli-agent.d.ts +372 -2
- package/dist/runtimes/claude-code.d.ts +12 -0
- package/dist/runtimes/codex.buildcommand.test.d.ts +9 -0
- package/dist/runtimes/codex.d.ts +8 -0
- package/dist/runtimes/openai-desktop.js +1555 -120
- package/dist/runtimes/session-env.test.d.ts +14 -0
- package/dist/sandbox/sizes.d.ts +120 -30
- package/dist/sandbox.d.ts +1 -1
- package/dist/types/api-conversations.d.ts +476 -1
- package/dist/types/api-factory.d.ts +164 -7
- package/dist/types/api-runs.d.ts +23 -1
- package/dist/types/protocol.d.ts +32 -1
- package/dist/types/runtime.d.ts +120 -0
- package/dist/types/workflow-metadata.d.ts +6 -5
- package/package.json +1 -1
- package/src/agent/agent-context.ts +128 -28
- package/src/agent/agent-loop.ts +10 -3
- package/src/agent/desktop-open.ts +418 -0
- package/src/agent/perf-sampler.ts +202 -0
- package/src/agent/services-manifest.ts +356 -0
- package/src/agent/services-restore.ts +195 -0
- package/src/client.ts +384 -32
- package/src/display.ts +44 -1
- package/src/index.ts +74 -7
- package/src/runtimes/_cli-agent.ts +1160 -67
- package/src/runtimes/claude-code.ts +187 -12
- package/src/runtimes/codex.ts +65 -2
- package/src/sandbox/providers/e2b.ts +8 -4
- package/src/sandbox/providers/local.ts +16 -4
- package/src/sandbox/sizes.ts +127 -44
- package/src/sandbox.ts +8 -0
- package/src/types/api-conversations.ts +461 -2
- package/src/types/api-factory.ts +165 -7
- package/src/types/api-runs.ts +25 -1
- package/src/types/protocol.ts +30 -1
- package/src/types/runtime.ts +122 -0
- package/src/types/workflow-metadata.ts +6 -5
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guest-side session perf sampling — the `<promptPath>.perf` token contract.
|
|
3
|
+
*
|
|
4
|
+
* The session guest has NO push channel: every durable signal is a flat file
|
|
5
|
+
* next to the turn's prompt path, read by bounded server execs (the durable
|
|
6
|
+
* trio — see tailer.ts / _cli-agent.ts). Perf sampling rides that exact
|
|
7
|
+
* plane:
|
|
8
|
+
*
|
|
9
|
+
* - LIVE turn: the runner's 10s heartbeat subshell calls `ac_perf_tick`
|
|
10
|
+
* each beat; every 6th beat (~60s) it burst-reads /proc (stat jiffies
|
|
11
|
+
* delta, loadavg, meminfo) plus one `df -P`, and rewrites the perf
|
|
12
|
+
* token file atomically. The subshell dies with the wrapper, so
|
|
13
|
+
* sampling stops within one beat of turn end — it can never keep the
|
|
14
|
+
* setsid group alive (the 2026-08-17 spurious-wake class).
|
|
15
|
+
* - The token then rides the EXISTING durable-trio probe exec as one
|
|
16
|
+
* trailing field (`turnLivenessProbeCommand`) — no new exec, no new
|
|
17
|
+
* socket, ~70 extra bytes on a line the server already pulls.
|
|
18
|
+
* - PARKED-AWAKE: the server's 5-min compute-span checkpoint (which
|
|
19
|
+
* already holds the provider's RUNNING list — a suspended machine is
|
|
20
|
+
* structurally never probed) runs `standalonePerfProbeCommand`: the
|
|
21
|
+
* same read burst twice across a short window, one bounded exec.
|
|
22
|
+
*
|
|
23
|
+
* Token format (ONE whitespace-free field, so it can ride a space-split
|
|
24
|
+
* probe line): `v=1,cpu=12,l1=0.42,mem=37,dsk=52,vc=2,ram=4096,ts=1755…`
|
|
25
|
+
* cpu — busy % of all vcpus over the sampling window (-1 = no window yet)
|
|
26
|
+
* l1 — 1-min loadavg (-1 = unreadable)
|
|
27
|
+
* mem — used % of MemTotal, via MemAvailable (-1 = unreadable)
|
|
28
|
+
* dsk — used % of the root filesystem (-1 = unreadable)
|
|
29
|
+
* vc — vcpu count (cpuN lines in /proc/stat)
|
|
30
|
+
* ram — MemTotal in MB (the sandbox-size context the fold tags by)
|
|
31
|
+
* ts — guest clock, epoch seconds (dedupe only, never arithmetic)
|
|
32
|
+
*
|
|
33
|
+
* The guest is hostile by doctrine (registry-activities.ts house rule):
|
|
34
|
+
* `parsePerfToken` clamps every field to its closed range and nulls
|
|
35
|
+
* anything out of bounds — server folds re-use this parser, so no guest
|
|
36
|
+
* number reaches a metric or a Temporal payload unclamped.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { shellQuote } from "../runtimes/_cli-agent.js";
|
|
40
|
+
|
|
41
|
+
/** Sample every Nth heartbeat beat (10s beats → ~60s cadence). */
|
|
42
|
+
export const PERF_SAMPLE_EVERY_BEATS = 6;
|
|
43
|
+
|
|
44
|
+
/** CPU window of the standalone (parked-awake) probe, seconds. */
|
|
45
|
+
export const PERF_PROBE_WINDOW_SECONDS = 2;
|
|
46
|
+
|
|
47
|
+
/** The standalone probe's output line leads with this prefix. */
|
|
48
|
+
export const PERF_PROBE_LINE_PREFIX = "perf ";
|
|
49
|
+
|
|
50
|
+
/** Longest token the parsers accept — anything bigger is garbage. */
|
|
51
|
+
export const PERF_TOKEN_MAX_CHARS = 200;
|
|
52
|
+
|
|
53
|
+
/** One clamped guest perf sample. Null fields = unreadable/absent on the
|
|
54
|
+
* guest — never zero-filled (a zero is a claim; null is honesty). */
|
|
55
|
+
export interface GuestPerfSample {
|
|
56
|
+
/** Busy % of all vcpus over the sample window, 0–100. */
|
|
57
|
+
cpuBusyPct: number | null;
|
|
58
|
+
/** 1-minute load average. */
|
|
59
|
+
load1: number | null;
|
|
60
|
+
/** Used % of MemTotal (MemAvailable-based), 0–100. */
|
|
61
|
+
memUsedPct: number | null;
|
|
62
|
+
/** Used % of the root filesystem, 0–100. */
|
|
63
|
+
diskUsedPct: number | null;
|
|
64
|
+
/** vcpu count — the sandbox-size context tag. */
|
|
65
|
+
vcpus: number | null;
|
|
66
|
+
/** MemTotal in MB — the other half of the size context. */
|
|
67
|
+
ramMb: number | null;
|
|
68
|
+
/** Guest clock at sample time, epoch seconds. Dedupe only. */
|
|
69
|
+
sampledAtS: number | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PerfSamplerPaths {
|
|
73
|
+
/** Durable token file (`<promptPath>.perf`). */
|
|
74
|
+
perfPath: string;
|
|
75
|
+
/** Override for tests ONLY — fixture dir standing in for /proc. */
|
|
76
|
+
procRoot?: string;
|
|
77
|
+
/** Filesystem to `df` (default "/"). */
|
|
78
|
+
diskPath?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* POSIX-sh function definitions for the read burst. `ac_perf_read` reads
|
|
83
|
+
* /proc once, computes the jiffies delta against the PREVIOUS call (state
|
|
84
|
+
* lives in shell variables — the heartbeat subshell persists across beats,
|
|
85
|
+
* so no state file), and leaves the finished token in `$ac_tok`. All
|
|
86
|
+
* `ac_p*` variable names are prefixed to stay clear of the loop's own
|
|
87
|
+
* `ac_busy` / `ac_idle` vocabulary.
|
|
88
|
+
*
|
|
89
|
+
* Every read is `2>/dev/null`-guarded and every field degrades to -1 — a
|
|
90
|
+
* missing /proc entry can never wedge the heartbeat loop.
|
|
91
|
+
*/
|
|
92
|
+
function perfReadFunctionFragment(procRoot: string, diskPath: string): string {
|
|
93
|
+
const stat = shellQuote(`${procRoot}/stat`);
|
|
94
|
+
const loadavg = shellQuote(`${procRoot}/loadavg`);
|
|
95
|
+
const meminfo = shellQuote(`${procRoot}/meminfo`);
|
|
96
|
+
const disk = shellQuote(diskPath);
|
|
97
|
+
return `ac_perf_read() { `
|
|
98
|
+
// cpu line: user nice system idle iowait irq softirq steal …
|
|
99
|
+
+ `IFS=' ' read -r ac_pl ac_pu ac_pn2 ac_psy ac_pid ac_pio ac_pirq ac_psq ac_pst ac_prest 2>/dev/null < ${stat} || return 0; `
|
|
100
|
+
+ `ac_pbz=$(( \${ac_pu:-0} + \${ac_pn2:-0} + \${ac_psy:-0} + \${ac_pirq:-0} + \${ac_psq:-0} + \${ac_pst:-0} )); `
|
|
101
|
+
+ `ac_ptt=$(( ac_pbz + \${ac_pid:-0} + \${ac_pio:-0} )); `
|
|
102
|
+
+ `ac_pcpu=-1; `
|
|
103
|
+
+ `if [ "\${ac_ppt:-0}" -gt 0 ] && [ "$ac_ptt" -gt "\${ac_ppt:-0}" ]; then `
|
|
104
|
+
+ `ac_pcpu=$(( 100 * (ac_pbz - \${ac_ppb:-0}) / (ac_ptt - ac_ppt) )); `
|
|
105
|
+
+ `[ "$ac_pcpu" -lt 0 ] && ac_pcpu=0; [ "$ac_pcpu" -gt 100 ] && ac_pcpu=100; `
|
|
106
|
+
+ `fi; `
|
|
107
|
+
+ `ac_ppb=$ac_pbz; ac_ppt=$ac_ptt; `
|
|
108
|
+
+ `IFS=' ' read -r ac_pl1 ac_plr 2>/dev/null < ${loadavg} || ac_pl1=-1; `
|
|
109
|
+
+ `ac_pvc=$(grep -c '^cpu[0-9]' ${stat} 2>/dev/null); [ -n "$ac_pvc" ] || ac_pvc=0; `
|
|
110
|
+
+ `ac_pmt=$(awk '$1=="MemTotal:"{print $2}' ${meminfo} 2>/dev/null); `
|
|
111
|
+
+ `ac_pma=$(awk '$1=="MemAvailable:"{print $2}' ${meminfo} 2>/dev/null); `
|
|
112
|
+
+ `ac_pmem=-1; `
|
|
113
|
+
+ `if [ "\${ac_pmt:-0}" -gt 0 ] && [ -n "\${ac_pma:-}" ]; then ac_pmem=$(( 100 * (ac_pmt - ac_pma) / ac_pmt )); fi; `
|
|
114
|
+
+ `[ "$ac_pmem" -gt 100 ] && ac_pmem=100; [ "$ac_pmem" -lt -1 ] && ac_pmem=-1; `
|
|
115
|
+
+ `ac_pdsk=$(df -kP ${disk} 2>/dev/null | awk 'NR==2 && $2>0 {print int(100*$3/$2)}'); `
|
|
116
|
+
+ `ac_tok="v=1,cpu=$ac_pcpu,l1=\${ac_pl1:--1},mem=$ac_pmem,dsk=\${ac_pdsk:--1},vc=\${ac_pvc:-0},ram=$(( \${ac_pmt:-0} / 1024 )),ts=$(date +%s)"; `
|
|
117
|
+
+ `}; `;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The heartbeat-loop member: function definitions plus `ac_perf_tick`,
|
|
122
|
+
* which gates the burst to every `PERF_SAMPLE_EVERY_BEATS`th beat (beat 1
|
|
123
|
+
* establishes the jiffies baseline — its token carries cpu=-1; beat 7 is
|
|
124
|
+
* the first real cpu window) and rewrites the token file atomically
|
|
125
|
+
* (tmp + `mv -f`, so a concurrent probe `head` reads old-or-new, never a
|
|
126
|
+
* torn write). Place the returned fragment INSIDE the heartbeat subshell,
|
|
127
|
+
* before its `while`; call `ac_perf_tick` once per beat in the body.
|
|
128
|
+
*/
|
|
129
|
+
export function perfSamplerFunctionFragment(paths: PerfSamplerPaths): string {
|
|
130
|
+
const perf = shellQuote(paths.perfPath);
|
|
131
|
+
const perfTmp = shellQuote(`${paths.perfPath}.tmp`);
|
|
132
|
+
return perfReadFunctionFragment(paths.procRoot ?? "/proc", paths.diskPath ?? "/")
|
|
133
|
+
+ `ac_perf_tick() { `
|
|
134
|
+
+ `ac_pbeat=$(( \${ac_pbeat:-0} + 1 )); `
|
|
135
|
+
+ `[ $(( (ac_pbeat - 1) % ${PERF_SAMPLE_EVERY_BEATS} )) -eq 0 ] || return 0; `
|
|
136
|
+
+ `ac_perf_read; `
|
|
137
|
+
+ `[ -n "\${ac_tok:-}" ] || return 0; `
|
|
138
|
+
+ `printf '%s\\n' "$ac_tok" > ${perfTmp} 2>/dev/null && mv -f ${perfTmp} ${perf} 2>/dev/null; `
|
|
139
|
+
+ `}; `;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Self-contained one-exec probe for the parked-awake lane (the server's
|
|
144
|
+
* 5-min compute-span checkpoint): two read bursts across a short window
|
|
145
|
+
* give a real cpu delta, then the token prints to stdout as
|
|
146
|
+
* `perf <token>`. Bounded by the caller's exec timeout; ~window+ε runtime.
|
|
147
|
+
*/
|
|
148
|
+
export function standalonePerfProbeCommand(opts: {
|
|
149
|
+
procRoot?: string; diskPath?: string; windowSeconds?: number;
|
|
150
|
+
} = {}): string {
|
|
151
|
+
const win = opts.windowSeconds ?? PERF_PROBE_WINDOW_SECONDS;
|
|
152
|
+
return perfReadFunctionFragment(opts.procRoot ?? "/proc", opts.diskPath ?? "/")
|
|
153
|
+
+ `ac_perf_read; sleep ${win}; ac_perf_read; `
|
|
154
|
+
+ `printf '${PERF_PROBE_LINE_PREFIX}%s\\n' "\${ac_tok:--}"`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Parse + CLAMP one perf token. Null = no usable sample (absent file, a
|
|
158
|
+
* garbled write, a hostile guest). Out-of-range fields null out
|
|
159
|
+
* individually; a token with no usable utilization field at all is null. */
|
|
160
|
+
export function parsePerfToken(token: string): GuestPerfSample | null {
|
|
161
|
+
const t = token.trim();
|
|
162
|
+
if (t.length === 0 || t.length > PERF_TOKEN_MAX_CHARS) return null;
|
|
163
|
+
if (!/^v=1(?:,|$)/.test(t)) return null;
|
|
164
|
+
const kv = new Map<string, string>();
|
|
165
|
+
for (const part of t.split(",")) {
|
|
166
|
+
const i = part.indexOf("=");
|
|
167
|
+
if (i > 0) kv.set(part.slice(0, i), part.slice(i + 1));
|
|
168
|
+
}
|
|
169
|
+
const pct = (k: string): number | null => {
|
|
170
|
+
const n = Number(kv.get(k));
|
|
171
|
+
return Number.isFinite(n) && n >= 0 && n <= 100 ? Math.round(n) : null;
|
|
172
|
+
};
|
|
173
|
+
const bounded = (k: string, min: number, max: number): number | null => {
|
|
174
|
+
const n = Number(kv.get(k));
|
|
175
|
+
return Number.isInteger(n) && n >= min && n <= max ? n : null;
|
|
176
|
+
};
|
|
177
|
+
const l1raw = Number(kv.get("l1"));
|
|
178
|
+
const sample: GuestPerfSample = {
|
|
179
|
+
cpuBusyPct: pct("cpu"),
|
|
180
|
+
load1: Number.isFinite(l1raw) && l1raw >= 0 && l1raw <= 100_000 ? l1raw : null,
|
|
181
|
+
memUsedPct: pct("mem"),
|
|
182
|
+
diskUsedPct: pct("dsk"),
|
|
183
|
+
vcpus: bounded("vc", 1, 1024),
|
|
184
|
+
ramMb: bounded("ram", 1, 16 * 1024 * 1024),
|
|
185
|
+
// Bounded to [2020, 2100) in epoch seconds — dedupe-grade only.
|
|
186
|
+
sampledAtS: bounded("ts", 1_577_836_800, 4_102_444_800),
|
|
187
|
+
};
|
|
188
|
+
if (sample.cpuBusyPct === null && sample.load1 === null
|
|
189
|
+
&& sample.memUsedPct === null && sample.diskUsedPct === null) return null;
|
|
190
|
+
return sample;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Parse the standalone probe's stdout (the LAST `perf ` line wins — envd
|
|
194
|
+
* occasionally prepends shell noise). */
|
|
195
|
+
export function parsePerfProbeOutput(stdout: string): GuestPerfSample | null {
|
|
196
|
+
const lines = stdout.split("\n").filter((l) => l.startsWith(PERF_PROBE_LINE_PREFIX));
|
|
197
|
+
const last = lines[lines.length - 1];
|
|
198
|
+
if (!last) return null;
|
|
199
|
+
const token = last.slice(PERF_PROBE_LINE_PREFIX.length).trim();
|
|
200
|
+
if (token === "-") return null;
|
|
201
|
+
return parsePerfToken(token);
|
|
202
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable services manifest — `.ac/services.yml` on the session's drive
|
|
3
|
+
* branch.
|
|
4
|
+
*
|
|
5
|
+
* Sessions run on cattle machines: a VM recycle (resize, eviction, failed
|
|
6
|
+
* reconnect) discards every process and every byte off the drive. The manifest
|
|
7
|
+
* is the pet — a small, versioned description of the long-running services a
|
|
8
|
+
* session depends on (dev servers, docker compose stacks, built dashboards) so
|
|
9
|
+
* the platform can rebuild the stack on a fresh machine without being asked.
|
|
10
|
+
*
|
|
11
|
+
* This module is pure string/data work: a parser for a deliberately RESTRICTED
|
|
12
|
+
* YAML subset (no yaml dependency — the schema is small and fixed, and a
|
|
13
|
+
* hand-rolled parser matches house style, cf. parseRecorderStatus /
|
|
14
|
+
* parsePerfToken), a serializer the CLI uses for `agentc services add/remove`,
|
|
15
|
+
* and the shared types. Script generation lives in ./services-restore.ts.
|
|
16
|
+
*
|
|
17
|
+
* Supported YAML subset (anything else is a loud parse error, never a guess):
|
|
18
|
+
*
|
|
19
|
+
* version: 1
|
|
20
|
+
* services:
|
|
21
|
+
* - name: postgres
|
|
22
|
+
* command: docker compose up postgres # required, foreground shell
|
|
23
|
+
* cwd: myapp # optional, relative to drive root
|
|
24
|
+
* port: 5432 # optional, informational + docs
|
|
25
|
+
* env: reads DATABASE_URL from .env # optional free-text note
|
|
26
|
+
* setup: docker compose pull postgres # optional one-time prep per machine
|
|
27
|
+
* health: # optional, at most one of:
|
|
28
|
+
* cmd: pg_isready -h localhost # shell probe (exit 0 = healthy)
|
|
29
|
+
* http: http://localhost:5432/ # or an HTTP 2xx probe
|
|
30
|
+
* data: # optional data hooks
|
|
31
|
+
* dump: pg_dump app > .ac/seeds/dev.sql # before a DELIBERATE recycle
|
|
32
|
+
* restore: psql app < .ac/seeds/dev.sql # after setup on a fresh boot
|
|
33
|
+
*
|
|
34
|
+
* Scalars only, one nesting level (`health:` / `data:`), full-line `#` comments
|
|
35
|
+
* only (an inline ` # ...` would be ambiguous inside shell commands and is kept
|
|
36
|
+
* as part of the value), no multi-line block scalars (`|` / `>`), no anchors,
|
|
37
|
+
* no flow collections.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export const SERVICES_MANIFEST_RELPATH = ".ac/services.yml";
|
|
41
|
+
|
|
42
|
+
/** Hard cap on manifest entries — a manifest is a stack, not a fleet. */
|
|
43
|
+
export const SERVICES_MAX = 16;
|
|
44
|
+
|
|
45
|
+
export const SERVICE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,31}$/;
|
|
46
|
+
|
|
47
|
+
export interface ServiceHealth {
|
|
48
|
+
/** Shell probe; exit 0 means healthy. Mutually exclusive with `http`. */
|
|
49
|
+
cmd?: string;
|
|
50
|
+
/** HTTP probe; any 2xx means healthy. Mutually exclusive with `cmd`. */
|
|
51
|
+
http?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ServiceDataHooks {
|
|
55
|
+
/**
|
|
56
|
+
* Run before a DELIBERATE recycle (resize). Evictions and dead-VM
|
|
57
|
+
* fresh-acquires cannot run it — the machine is already gone — so dumps are
|
|
58
|
+
* a courtesy, not a guarantee; durable data belongs on the drive.
|
|
59
|
+
*/
|
|
60
|
+
dump?: string;
|
|
61
|
+
/** Run after `setup` on a fresh boot, before the service launches. */
|
|
62
|
+
restore?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ServiceEntry {
|
|
66
|
+
name: string;
|
|
67
|
+
/** Foreground shell command; launched detached with durable log + pidfile. */
|
|
68
|
+
command: string;
|
|
69
|
+
/** Working directory, relative to the drive root (absolute rejected). */
|
|
70
|
+
cwd?: string;
|
|
71
|
+
/** Informational — surfaced in `agentc services list` and the restore line. */
|
|
72
|
+
port?: number;
|
|
73
|
+
/** Free-text note about env the service expects (documentation only). */
|
|
74
|
+
env?: string;
|
|
75
|
+
/** One-time per-machine prep (e.g. `docker compose up -d`). */
|
|
76
|
+
setup?: string;
|
|
77
|
+
health?: ServiceHealth;
|
|
78
|
+
data?: ServiceDataHooks;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ServicesManifest {
|
|
82
|
+
version: 1;
|
|
83
|
+
services: ServiceEntry[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ParseServicesManifestResult {
|
|
87
|
+
/** Present iff `errors` is empty. All-or-nothing: a broken manifest restores nothing, loudly. */
|
|
88
|
+
manifest: ServicesManifest | null;
|
|
89
|
+
errors: string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const TOP_KEYS = new Set(["version", "services"]);
|
|
93
|
+
const ENTRY_KEYS = new Set(["name", "command", "cwd", "port", "env", "setup", "health", "data"]);
|
|
94
|
+
const HEALTH_KEYS = new Set(["cmd", "http"]);
|
|
95
|
+
const DATA_KEYS = new Set(["dump", "restore"]);
|
|
96
|
+
|
|
97
|
+
interface Line {
|
|
98
|
+
no: number;
|
|
99
|
+
indent: number;
|
|
100
|
+
/** True when the content starts with `- ` (a sequence item). */
|
|
101
|
+
dash: boolean;
|
|
102
|
+
/** Content with indentation (and any leading `- `) stripped. */
|
|
103
|
+
text: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function lex(text: string): Line[] {
|
|
107
|
+
const out: Line[] = [];
|
|
108
|
+
const raw = text.split(/\r?\n/);
|
|
109
|
+
for (let i = 0; i < raw.length; i++) {
|
|
110
|
+
const line = raw[i];
|
|
111
|
+
const trimmed = line.trim();
|
|
112
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
113
|
+
const indent = line.length - line.trimStart().length;
|
|
114
|
+
const dash = trimmed.startsWith("- ") || trimmed === "-";
|
|
115
|
+
out.push({
|
|
116
|
+
no: i + 1,
|
|
117
|
+
indent,
|
|
118
|
+
dash,
|
|
119
|
+
text: dash ? trimmed.slice(1).trim() : trimmed,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Split `key: value` (value may be empty for map openers). Null when the line is not a mapping. */
|
|
126
|
+
function splitKey(text: string): { key: string; value: string } | null {
|
|
127
|
+
if (text.endsWith(":") && !text.includes(": ")) {
|
|
128
|
+
const key = text.slice(0, -1).trim();
|
|
129
|
+
return /^[A-Za-z_][\w-]*$/.test(key) ? { key, value: "" } : null;
|
|
130
|
+
}
|
|
131
|
+
const idx = text.indexOf(": ");
|
|
132
|
+
if (idx === -1) return null;
|
|
133
|
+
const key = text.slice(0, idx).trim();
|
|
134
|
+
if (!/^[A-Za-z_][\w-]*$/.test(key)) return null;
|
|
135
|
+
return { key, value: text.slice(idx + 2).trim() };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Unquote a scalar. Only full single/double quoting is recognized. */
|
|
139
|
+
function scalar(value: string, no: number, errors: string[]): string {
|
|
140
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
|
141
|
+
return value.slice(1, -1).replace(/\\(["\\])/g, "$1");
|
|
142
|
+
}
|
|
143
|
+
if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
|
|
144
|
+
return value.slice(1, -1).replace(/''/g, "'");
|
|
145
|
+
}
|
|
146
|
+
if (value === "|" || value === ">" || value.startsWith("| ") || value.startsWith("> ")) {
|
|
147
|
+
errors.push(`line ${no}: block scalars (| / >) are not supported — keep commands single-line`);
|
|
148
|
+
return "";
|
|
149
|
+
}
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function parseServicesManifest(text: string): ParseServicesManifestResult {
|
|
154
|
+
const errors: string[] = [];
|
|
155
|
+
if (text.length > 64 * 1024) {
|
|
156
|
+
return { manifest: null, errors: ["manifest exceeds 64KB — this file is a service list, not a data store"] };
|
|
157
|
+
}
|
|
158
|
+
const lines = lex(text);
|
|
159
|
+
if (lines.length === 0) return { manifest: null, errors: ["manifest is empty"] };
|
|
160
|
+
|
|
161
|
+
let version: number | null = null;
|
|
162
|
+
const services: ServiceEntry[] = [];
|
|
163
|
+
let current: ServiceEntry | null = null;
|
|
164
|
+
let currentIndent = -1;
|
|
165
|
+
/** "health" | "data" while inside a nested map, else null. */
|
|
166
|
+
let nested: "health" | "data" | null = null;
|
|
167
|
+
let nestedIndent = -1;
|
|
168
|
+
let inServices = false;
|
|
169
|
+
|
|
170
|
+
const finish = (entry: ServiceEntry | null) => {
|
|
171
|
+
if (!entry) return;
|
|
172
|
+
services.push(entry);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
const kv = splitKey(line.text);
|
|
177
|
+
if (!kv) {
|
|
178
|
+
errors.push(`line ${line.no}: not a \`key: value\` mapping — flow collections and bare scalars are not supported`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Top-level keys.
|
|
183
|
+
if (line.indent === 0 && !line.dash) {
|
|
184
|
+
inServices = false;
|
|
185
|
+
nested = null;
|
|
186
|
+
finish(current);
|
|
187
|
+
current = null;
|
|
188
|
+
if (!TOP_KEYS.has(kv.key)) {
|
|
189
|
+
errors.push(`line ${line.no}: unknown top-level key \`${kv.key}\` (allowed: version, services)`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (kv.key === "version") {
|
|
193
|
+
const v = Number(scalar(kv.value, line.no, errors));
|
|
194
|
+
if (v !== 1) errors.push(`line ${line.no}: unsupported manifest version \`${kv.value}\` (this build understands version 1)`);
|
|
195
|
+
else version = 1;
|
|
196
|
+
} else {
|
|
197
|
+
if (kv.value !== "") errors.push(`line ${line.no}: \`services:\` must open a list, not carry a value`);
|
|
198
|
+
inServices = true;
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!inServices) {
|
|
204
|
+
errors.push(`line ${line.no}: unexpected indented line outside \`services:\``);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// New sequence item.
|
|
209
|
+
if (line.dash) {
|
|
210
|
+
finish(current);
|
|
211
|
+
current = { name: "", command: "" };
|
|
212
|
+
currentIndent = line.indent;
|
|
213
|
+
nested = null;
|
|
214
|
+
if (kv.key !== "name") {
|
|
215
|
+
errors.push(`line ${line.no}: each service entry must start with \`- name: <name>\``);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
current.name = scalar(kv.value, line.no, errors);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!current) {
|
|
223
|
+
errors.push(`line ${line.no}: expected \`- name: <name>\` to open a service entry`);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Nested map keys (health/data), recognized by deeper indentation.
|
|
228
|
+
if (nested && line.indent > nestedIndent) {
|
|
229
|
+
const allowed = nested === "health" ? HEALTH_KEYS : DATA_KEYS;
|
|
230
|
+
if (!allowed.has(kv.key)) {
|
|
231
|
+
errors.push(`line ${line.no}: unknown \`${nested}\` key \`${kv.key}\` (allowed: ${[...allowed].join(", ")})`);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const value = scalar(kv.value, line.no, errors);
|
|
235
|
+
if (value === "") {
|
|
236
|
+
errors.push(`line ${line.no}: \`${nested}.${kv.key}\` needs a value`);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const bucket = (current[nested] ??= {});
|
|
240
|
+
(bucket as Record<string, string>)[kv.key] = value;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
nested = null;
|
|
244
|
+
|
|
245
|
+
if (line.indent <= currentIndent) {
|
|
246
|
+
errors.push(`line ${line.no}: bad indentation — service fields must be indented under their \`- name:\` line`);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!ENTRY_KEYS.has(kv.key)) {
|
|
251
|
+
errors.push(`line ${line.no}: unknown service key \`${kv.key}\` (allowed: ${[...ENTRY_KEYS].join(", ")})`);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (kv.key === "health" || kv.key === "data") {
|
|
256
|
+
if (kv.value !== "") {
|
|
257
|
+
errors.push(`line ${line.no}: \`${kv.key}:\` opens a nested map — put cmd/http (or dump/restore) on indented lines below`);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
nested = kv.key;
|
|
261
|
+
nestedIndent = line.indent;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const value = scalar(kv.value, line.no, errors);
|
|
266
|
+
if (kv.key === "port") {
|
|
267
|
+
const port = Number(value);
|
|
268
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
269
|
+
errors.push(`line ${line.no}: port must be an integer 1-65535, got \`${kv.value}\``);
|
|
270
|
+
} else {
|
|
271
|
+
current.port = port;
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (value === "") {
|
|
276
|
+
errors.push(`line ${line.no}: \`${kv.key}\` needs a value`);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (kv.key === "cwd" && (value.startsWith("/") || value.includes(".."))) {
|
|
280
|
+
errors.push(`line ${line.no}: cwd must be a relative path under the drive root (no leading / and no ..)`);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
(current as unknown as Record<string, string>)[kv.key] = value;
|
|
284
|
+
}
|
|
285
|
+
finish(current);
|
|
286
|
+
|
|
287
|
+
// Semantic validation.
|
|
288
|
+
if (version === null) errors.push("missing `version: 1`");
|
|
289
|
+
if (services.length === 0 && errors.length === 0) errors.push("`services:` lists no entries");
|
|
290
|
+
if (services.length > SERVICES_MAX) errors.push(`too many services (${services.length}) — the cap is ${SERVICES_MAX}`);
|
|
291
|
+
const seen = new Set<string>();
|
|
292
|
+
for (const svc of services) {
|
|
293
|
+
const label = svc.name || "<unnamed>";
|
|
294
|
+
if (!SERVICE_NAME_RE.test(svc.name)) {
|
|
295
|
+
errors.push(`service \`${label}\`: name must match ${SERVICE_NAME_RE} (alphanumeric start, then [A-Za-z0-9._-], max 32)`);
|
|
296
|
+
} else if (seen.has(svc.name)) {
|
|
297
|
+
errors.push(`service \`${label}\`: duplicate name`);
|
|
298
|
+
}
|
|
299
|
+
seen.add(svc.name);
|
|
300
|
+
if (!svc.command) errors.push(`service \`${label}\`: missing \`command\``);
|
|
301
|
+
if (svc.health?.cmd && svc.health.http) {
|
|
302
|
+
errors.push(`service \`${label}\`: health takes \`cmd\` OR \`http\`, not both`);
|
|
303
|
+
}
|
|
304
|
+
if (svc.health?.http && !/^https?:\/\//.test(svc.health.http)) {
|
|
305
|
+
errors.push(`service \`${label}\`: health.http must be an http(s):// URL`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return errors.length > 0 ? { manifest: null, errors } : { manifest: { version: 1, services }, errors: [] };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Quote a scalar for emission iff the parser would otherwise mangle it. */
|
|
313
|
+
function emitScalar(value: string): string {
|
|
314
|
+
const needsQuote =
|
|
315
|
+
value !== value.trim() ||
|
|
316
|
+
value.startsWith("'") ||
|
|
317
|
+
value.startsWith('"') ||
|
|
318
|
+
value === "|" ||
|
|
319
|
+
value === ">" ||
|
|
320
|
+
value.startsWith("| ") ||
|
|
321
|
+
value.startsWith("> ");
|
|
322
|
+
if (!needsQuote) return value;
|
|
323
|
+
return `"${value.replace(/([\\"])/g, "\\$1")}"`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Serialize a manifest in the canonical shape the parser reads back.
|
|
328
|
+
* Values containing newlines are a caller bug (the CLI rejects them first).
|
|
329
|
+
*/
|
|
330
|
+
export function renderServicesManifest(manifest: ServicesManifest): string {
|
|
331
|
+
const out: string[] = [
|
|
332
|
+
"# Durable services manifest — restored automatically after a machine recycle.",
|
|
333
|
+
"# Docs: agentc services --help",
|
|
334
|
+
"version: 1",
|
|
335
|
+
"services:",
|
|
336
|
+
];
|
|
337
|
+
for (const svc of manifest.services) {
|
|
338
|
+
out.push(` - name: ${emitScalar(svc.name)}`);
|
|
339
|
+
out.push(` command: ${emitScalar(svc.command)}`);
|
|
340
|
+
if (svc.cwd) out.push(` cwd: ${emitScalar(svc.cwd)}`);
|
|
341
|
+
if (svc.port !== undefined) out.push(` port: ${svc.port}`);
|
|
342
|
+
if (svc.env) out.push(` env: ${emitScalar(svc.env)}`);
|
|
343
|
+
if (svc.setup) out.push(` setup: ${emitScalar(svc.setup)}`);
|
|
344
|
+
if (svc.health && (svc.health.cmd || svc.health.http)) {
|
|
345
|
+
out.push(" health:");
|
|
346
|
+
if (svc.health.cmd) out.push(` cmd: ${emitScalar(svc.health.cmd)}`);
|
|
347
|
+
if (svc.health.http) out.push(` http: ${emitScalar(svc.health.http)}`);
|
|
348
|
+
}
|
|
349
|
+
if (svc.data && (svc.data.dump || svc.data.restore)) {
|
|
350
|
+
out.push(" data:");
|
|
351
|
+
if (svc.data.dump) out.push(` dump: ${emitScalar(svc.data.dump)}`);
|
|
352
|
+
if (svc.data.restore) out.push(` restore: ${emitScalar(svc.data.restore)}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return out.join("\n") + "\n";
|
|
356
|
+
}
|