@awebai/oats 0.40.1 → 0.41.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/bin/oats.mjs +45 -18
- package/docs/capabilities.md +44 -3
- package/docs/desktop-cli-api.md +147 -23
- package/docs/desktop.md +5 -4
- package/docs/execution-targets.md +342 -38
- package/docs/implementation.md +85 -11
- package/docs/oats-local.schema.json +2 -2
- package/docs/official-catalog.md +1 -1
- package/docs/packages.md +5 -5
- package/docs/release-lane.md +8 -4
- package/docs/release-notes/v0.40.2.md +120 -0
- package/docs/release-notes/v0.41.0.md +238 -0
- package/docs/schedules.md +40 -11
- package/docs/servers.md +7 -2
- package/docs/souls-and-instances.md +32 -6
- package/docs/workspaces.md +1 -1
- package/lib/core.mjs +579 -177
- package/lib/dir-lock.mjs +7 -4
- package/lib/instance-events.mjs +130 -46
- package/lib/instance-git.mjs +113 -4
- package/lib/instance-lifecycle.mjs +3 -2
- package/lib/packages.mjs +1 -1
- package/lib/resolve.mjs +1 -1
- package/lib/schedule-command-child.mjs +39 -9
- package/lib/schedule.mjs +57 -28
- package/lib/servers.mjs +23 -9
- package/lib/session-input.mjs +42 -47
- package/package-catalog.json +1 -1
- package/package.json +1 -1
- package/skills/oats-getting-started/SKILL.md +1 -1
package/lib/dir-lock.mjs
CHANGED
|
@@ -10,9 +10,9 @@ function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch (e) {
|
|
|
10
10
|
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
11
11
|
|
|
12
12
|
/** A mkdir lock that is never reclaimed by another process: an existing
|
|
13
|
-
* lock whose owner is unreadable or dead is refused
|
|
14
|
-
* remove, because the gap between mkdir and owner.json
|
|
15
|
-
* acquirer and a dead-owner reclaim races every other acquirer. The
|
|
13
|
+
* lock whose owner is unreadable or dead is refused after bounded waiting,
|
|
14
|
+
* with the directory to remove, because the gap between mkdir and owner.json
|
|
15
|
+
* belongs to a live acquirer and a dead-owner reclaim races every other acquirer. The
|
|
16
16
|
* holder removes its own lock in finally and on SIGINT/SIGTERM. A lock still
|
|
17
17
|
* held after `retryMs` is refused with `busy(why)`, the caller's own error. */
|
|
18
18
|
export function withDirLock(dir, what, fn, { retryMs = 0, busy } = {}) {
|
|
@@ -23,7 +23,10 @@ export function withDirLock(dir, what, fn, { retryMs = 0, busy } = {}) {
|
|
|
23
23
|
catch (e) {
|
|
24
24
|
if (e.code !== "EEXIST") throw e;
|
|
25
25
|
let owner; try { owner = JSON.parse(readFileSync(join(dir, "owner.json"), "utf8")); } catch { owner = undefined; }
|
|
26
|
-
|
|
26
|
+
// Missing/unreadable owners also occur between mkdir and publication,
|
|
27
|
+
// and while the holder removes its directory. Wait without touching it.
|
|
28
|
+
const remaining = deadline - Date.now();
|
|
29
|
+
if (remaining > 0) { pause(Math.min(50, remaining)); continue; }
|
|
27
30
|
throw busy(!owner ? "its owner is not readable yet or the file is missing" : pidAlive(owner.pid) ? `pid ${owner.pid} holds it` : `its owner pid ${owner.pid} is gone`);
|
|
28
31
|
}
|
|
29
32
|
}
|
package/lib/instance-events.mjs
CHANGED
|
@@ -26,9 +26,20 @@
|
|
|
26
26
|
* Producers write claims as `waiting` rows through `setWaiting` (the CLI's
|
|
27
27
|
* `oats instance waiting` and `oats instance attention`): data
|
|
28
28
|
* `{waitingOnYou, reason?, message?}`, appended only on a change. A claim is
|
|
29
|
-
* evidence for display, never authority: nothing in the kernel acts on it.
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
* evidence for display, never authority: nothing in the kernel acts on it.
|
|
30
|
+
*
|
|
31
|
+
* ONE ADDRESS PER HOME (awebai/oats#583). A home has several spellings when its
|
|
32
|
+
* deployment is reached through a symlink or its agents root is one: status
|
|
33
|
+
* addresses it lexically, a session carries the real path. Rows are keyed by
|
|
34
|
+
* the home string, so every exported function here resolves the home it is
|
|
35
|
+
* given to its REAL path first (`addressOf`): rows record it, readers compare
|
|
36
|
+
* against it, and both logs are found from any spelling. `admit` stays strict.
|
|
37
|
+
* That is storage and matching. An ANSWER (readEvents, setWaiting) names the
|
|
38
|
+
* home the way the caller did: `admit` has proved every returned row is this
|
|
39
|
+
* one home's, so its `home` is the address that was asked about, and a consumer
|
|
40
|
+
* that checks "the rows' home is the home I asked for" keeps holding. */
|
|
41
|
+
import { appendFileSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync } from "node:fs";
|
|
42
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
32
43
|
|
|
33
44
|
export const EVENTS_API = 2;
|
|
34
45
|
const HOME_LOG = ".oats-events.jsonl";
|
|
@@ -61,36 +72,104 @@ export function validWaitingMessage(m) {
|
|
|
61
72
|
// closed set; anything else reads as null, and the claim still counts.
|
|
62
73
|
/** A stored reason outside the closed set (a hand-edited or foreign row) reads as null. */
|
|
63
74
|
const readReason = (r) => (WAITING_REASONS.includes(r) ? r : null);
|
|
75
|
+
/** An event time: a string that parses as a date. The writer refuses anything else. */
|
|
76
|
+
const validTime = (at) => typeof at === "string" && Number.isFinite(Date.parse(at));
|
|
64
77
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
/** The READ rule for one claim `{since, producer, reason, message}`, the same for a
|
|
79
|
+
* row of this machine's log and for a claim another kernel reports (a remote roster
|
|
80
|
+
* row): `since` is a valid date and `producer` is `kernel` or a producer id, or there
|
|
81
|
+
* is no claim (null); a reason outside the closed set and a message that fails
|
|
82
|
+
* validWaitingMessage read as null, and the claim still counts. Anything that is not
|
|
83
|
+
* such an object is null. */
|
|
84
|
+
export function readWaitingClaim(claim) {
|
|
85
|
+
if (!claim || typeof claim !== "object" || Array.isArray(claim)) return null;
|
|
86
|
+
const { since, producer } = claim;
|
|
87
|
+
if (!validTime(since)) return null;
|
|
88
|
+
// A producer the writer would refuse (a hand-edited log) is never shown as one.
|
|
89
|
+
if (producer !== "kernel" && !(typeof producer === "string" && WAITING_PRODUCER_RE.test(producer))) return null;
|
|
90
|
+
return { since, producer, reason: readReason(claim.reason), message: validWaitingMessage(claim.message) ? claim.message : null };
|
|
71
91
|
}
|
|
72
92
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
93
|
+
/** realpath of `p`, or, when `p` is gone, the realpath of its nearest existing
|
|
94
|
+
* ancestor with the rest re-appended (retirement writes its rows after the home
|
|
95
|
+
* is removed). */
|
|
96
|
+
function realOrNearest(p) {
|
|
97
|
+
try { return realpathSync(p); } catch { /* absent: resolve what exists */ }
|
|
98
|
+
let d = resolve(p); const tail = [];
|
|
99
|
+
while (!existsSync(d) && dirname(d) !== d) { tail.unshift(basename(d)); d = dirname(d); }
|
|
100
|
+
try { return join(realpathSync(d), ...tail); } catch { return resolve(p); }
|
|
101
|
+
}
|
|
102
|
+
function metaOf(home) {
|
|
103
|
+
try { const m = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); return m && typeof m === "object" ? m : null; } catch { return null; }
|
|
104
|
+
}
|
|
105
|
+
const incarnationIn = (meta) => (typeof meta?.createdAt === "string" ? meta.createdAt : null);
|
|
106
|
+
|
|
107
|
+
/** <deployment>/.agents/events/<agent>--<instance>.jsonl: deployment-private state
|
|
108
|
+
* beside installed capabilities and schedules; survives the home's removal.
|
|
109
|
+
*
|
|
110
|
+
* The DEPLOYMENT decides where it is, not the real home string: under a symlinked
|
|
111
|
+
* agents root the real home's ancestors are outside the deployment. The rule:
|
|
112
|
+
* 1. the deployment the spawn recorded (instance.json `workspace.deployment`), used
|
|
113
|
+
* ONLY when it verifies: the real path of its agents/<agent>/instances/<instance>
|
|
114
|
+
* is this home, with agent and instance taken from the home's path, never from
|
|
115
|
+
* instance.json;
|
|
116
|
+
* 2. otherwise the fourth ancestor of the home AS THE CALLER SPELLED IT, which is how
|
|
117
|
+
* the kernel addresses a home with no record, or one already removed (retirement).
|
|
118
|
+
* The result is resolved, so every spelling of the home gives one path.
|
|
119
|
+
*
|
|
120
|
+
* Two limits. A home with no recorded deployment (or already removed) that is named
|
|
121
|
+
* by its REAL path under a symlinked agents root still derives a directory outside
|
|
122
|
+
* the deployment; no kernel caller does that. And instance.json is home content: a
|
|
123
|
+
* record edited to name another directory that links back to this home moves the log
|
|
124
|
+
* there. That is the class the home log is already in (a path the agent can write,
|
|
125
|
+
* which the kernel appends to under the same uid), so nothing stricter is checked. */
|
|
126
|
+
function workspaceLogPath(given, home, meta) {
|
|
127
|
+
const instance = basename(home), agent = basename(dirname(dirname(home))); // <root>/<agent>/instances/<instance>
|
|
128
|
+
const recorded = meta?.workspace?.deployment;
|
|
129
|
+
const verified = typeof recorded === "string" && isAbsolute(recorded) && realOrNearest(join(recorded, "agents", agent, "instances", instance)) === home;
|
|
130
|
+
const deployment = verified ? recorded : dirname(dirname(dirname(dirname(resolve(given)))));
|
|
131
|
+
return join(realOrNearest(deployment), ".agents", "events", `${agent}--${instance}.jsonl`);
|
|
76
132
|
}
|
|
77
133
|
|
|
134
|
+
/** The one address of the home a caller named, in any spelling: the real home, its
|
|
135
|
+
* instance name and incarnation, its two logs, and `asked`, the caller's spelling. */
|
|
136
|
+
function addressOf(given) {
|
|
137
|
+
const home = realOrNearest(given);
|
|
138
|
+
const meta = metaOf(home);
|
|
139
|
+
let workspaceLog;
|
|
140
|
+
return {
|
|
141
|
+
home, asked: given, instance: basename(home), incarnation: incarnationIn(meta), homeLog: join(home, HOME_LOG),
|
|
142
|
+
get workspaceLog() { return (workspaceLog ??= workspaceLogPath(given, home, meta)); },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** THE OUTPUT EDGE, and the only one: canonical inside, the caller's spelling outside.
|
|
147
|
+
* Whatever this module ANSWERS about an address (an events read, each row in it, a
|
|
148
|
+
* waiting answer) passes through here, which names the home as the caller spelled
|
|
149
|
+
* it. Rows on disk and every comparison keep the real path. A new answer that
|
|
150
|
+
* carries a `home` must go through this too, never write `a.home` out itself. */
|
|
151
|
+
const answered = (a, body) => ({ ...body, home: a.asked });
|
|
152
|
+
|
|
153
|
+
/** The incarnation of the home at `home`: its instance.json `createdAt`, or null. */
|
|
154
|
+
export function incarnationOf(home) { return incarnationIn(metaOf(home)); }
|
|
155
|
+
|
|
78
156
|
/** Append one typed event. Never throws into the caller's action: an event is
|
|
79
157
|
* evidence, not authority — a failed write is reported in the return value. */
|
|
80
|
-
export function appendEvent(home, event, {
|
|
158
|
+
export function appendEvent(home, event, options) { return append(addressOf(home), event, options); }
|
|
159
|
+
function append(a, event, { workspaceOnly = false, incarnation, at } = {}) {
|
|
81
160
|
if (!EVENT_KINDS.includes(event.kind)) return { ok: false, reason: `unknown event kind ${event.kind}` };
|
|
82
161
|
// `at`: the time the fact became true, when it is recorded later (a start
|
|
83
162
|
// boundary reconciled from its receipt); readers order rows by it.
|
|
84
|
-
if (at !== undefined &&
|
|
85
|
-
const row = { eventsApi: EVENTS_API, at: at ?? new Date().toISOString(), instance:
|
|
163
|
+
if (at !== undefined && !validTime(at)) return { ok: false, reason: `invalid event time ${at}` };
|
|
164
|
+
const row = { eventsApi: EVENTS_API, at: at ?? new Date().toISOString(), instance: a.instance, home: a.home, incarnation: incarnation === undefined ? a.incarnation : incarnation, producer: event.producer || "kernel", kind: event.kind, ...(event.data !== undefined ? { data: event.data } : {}) };
|
|
86
165
|
const line = JSON.stringify(row) + "\n";
|
|
87
166
|
const results = [];
|
|
88
167
|
// Retirement fingerprints the home against its baseline and preserves any
|
|
89
168
|
// changed bytes as "unknown work": events written DURING retirement go only
|
|
90
169
|
// to the workspace log, never into the home being inspected.
|
|
91
|
-
for (const path of [...(workspaceOnly ? [] : [
|
|
170
|
+
for (const path of [...(workspaceOnly ? [] : [a.homeLog]), a.workspaceLog]) {
|
|
92
171
|
try {
|
|
93
|
-
if (path.
|
|
172
|
+
if (path === a.homeLog && !existsSync(a.home)) { results.push({ path, ok: false, reason: "home absent" }); continue; }
|
|
94
173
|
mkdirSync(dirname(path), { recursive: true });
|
|
95
174
|
appendFileSync(path, line);
|
|
96
175
|
results.push({ path, ok: true });
|
|
@@ -174,11 +253,12 @@ function claimsOf(rows, incarnation) {
|
|
|
174
253
|
for (const r of current.slice(boundary + 1)) {
|
|
175
254
|
if (!r.data || typeof r.data.waitingOnYou !== "boolean") continue;
|
|
176
255
|
const p = r.producer ?? "kernel";
|
|
177
|
-
// A
|
|
178
|
-
|
|
256
|
+
// A row the read rule refuses (a hand-edited log: its producer, its time) says nothing.
|
|
257
|
+
const claim = readWaitingClaim({ since: r.at, producer: p, reason: r.data.reason, message: r.data.message });
|
|
258
|
+
if (!claim) continue;
|
|
179
259
|
claims.set(p, r.data.waitingOnYou
|
|
180
|
-
? { producer: p, waiting: true, since:
|
|
181
|
-
: { producer: p, waiting: false, since:
|
|
260
|
+
? { producer: p, waiting: true, since: claim.since, reason: claim.reason, message: claim.message }
|
|
261
|
+
: { producer: p, waiting: false, since: claim.since, reason: null, message: null });
|
|
182
262
|
}
|
|
183
263
|
return claims;
|
|
184
264
|
}
|
|
@@ -186,19 +266,20 @@ const positiveOf = (claims) => [...claims.values()].filter((c) => c.waiting).sor
|
|
|
186
266
|
const waitingShape = (c) => (c ? { since: c.since, producer: c.producer, reason: c.reason, message: c.message } : null);
|
|
187
267
|
|
|
188
268
|
/** Events for one instance ADDRESS, newest last, from both logs, with a bounded
|
|
189
|
-
* window; integrity is reported independently of the selected rows.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
const
|
|
269
|
+
* window; integrity is reported independently of the selected rows. The answer's
|
|
270
|
+
* `home`, and each returned row's, is the home as the caller spelled it. */
|
|
271
|
+
export function readEvents(given, { limit = 200, since = null } = {}) {
|
|
272
|
+
const address = addressOf(given);
|
|
273
|
+
const { home, instance, incarnation, homeLog, workspaceLog } = address;
|
|
274
|
+
const a = readLog(homeLog, "home"), b = readLog(workspaceLog, "workspace");
|
|
194
275
|
const { rows, foreign } = admit(home, [a, b]);
|
|
195
276
|
const claims = claimsOf(rows, incarnation);
|
|
196
277
|
const waitingClaims = [...claims.values()];
|
|
197
278
|
const positive = positiveOf(claims);
|
|
198
279
|
const filtered = since ? rows.filter((r) => r.at > since) : rows;
|
|
199
|
-
const window = filtered.slice(-limit);
|
|
280
|
+
const window = filtered.slice(-limit).map((r) => answered(address, r));
|
|
200
281
|
const last = window.at(-1) ?? null;
|
|
201
|
-
return {
|
|
282
|
+
return answered(address, {
|
|
202
283
|
eventsApi: EVENTS_API, instance, home, incarnation, count: filtered.length, returned: window.length,
|
|
203
284
|
truncated: filtered.length > window.length || a.source.status === "tail" || b.source.status === "tail",
|
|
204
285
|
integrity: { unreadableRows: a.unreadable + b.unreadable, foreignRows: foreign, sources: [a.source, b.source] },
|
|
@@ -212,7 +293,7 @@ export function readEvents(home, { limit = 200, since = null } = {}) {
|
|
|
212
293
|
"a claim older than the incarnation's latest kernel launched, restarted or stopped row belongs to an ended session and is not counted",
|
|
213
294
|
"integrity counts torn and foreign rows and names each source's status; truncated is true when the window cut rows or a source was read as a tail",
|
|
214
295
|
],
|
|
215
|
-
};
|
|
296
|
+
});
|
|
216
297
|
}
|
|
217
298
|
|
|
218
299
|
/** The instance's live waiting state, `{since, producer, reason, message}` or
|
|
@@ -221,11 +302,11 @@ export function readEvents(home, { limit = 200, since = null } = {}) {
|
|
|
221
302
|
* (the workspace log only when the home log is absent): every row a producer
|
|
222
303
|
* or a session boundary writes goes to both. */
|
|
223
304
|
export function liveWaiting(home) {
|
|
224
|
-
const
|
|
225
|
-
if (incarnation === null) return null;
|
|
226
|
-
let log = readLog(
|
|
227
|
-
if (log.source.status === "absent") log = readLog(
|
|
228
|
-
return waitingShape(positiveOf(claimsOf(admit(home, [log]).rows, incarnation)));
|
|
305
|
+
const a = addressOf(home);
|
|
306
|
+
if (a.incarnation === null) return null;
|
|
307
|
+
let log = readLog(a.homeLog, "home");
|
|
308
|
+
if (log.source.status === "absent") log = readLog(a.workspaceLog, "workspace");
|
|
309
|
+
return waitingShape(positiveOf(claimsOf(admit(a.home, [log]).rows, a.incarnation)));
|
|
229
310
|
}
|
|
230
311
|
|
|
231
312
|
const waitingError = (code, message) => Object.assign(new Error(message), { code });
|
|
@@ -238,8 +319,9 @@ const waitingError = (code, message) => Object.assign(new Error(message), { code
|
|
|
238
319
|
* other log; success means both logs took the row. Validates its input
|
|
239
320
|
* (E_BAD_ARGS); a home without a readable instance.json is
|
|
240
321
|
* E_SESSION_UNKNOWN; a write either log refused is E_EVENTS_FAILED (a retry
|
|
241
|
-
* repairs it).
|
|
242
|
-
|
|
322
|
+
* repairs it). The answer's `home` is the home as the caller spelled it; the row
|
|
323
|
+
* records the real path. Evidence, not authority: nothing else is touched. */
|
|
324
|
+
export function setWaiting(given, { producer, waiting, reason, message } = {}) {
|
|
243
325
|
if (typeof producer !== "string" || !WAITING_PRODUCER_RE.test(producer)) throw waitingError("E_BAD_ARGS", `--producer must match ${WAITING_PRODUCER_RE.source}`);
|
|
244
326
|
if (producer === "kernel") throw waitingError("E_BAD_ARGS", "--producer kernel is reserved for the kernel's own events");
|
|
245
327
|
if (typeof waiting !== "boolean") throw waitingError("E_BAD_ARGS", "waiting must be set or clear");
|
|
@@ -250,15 +332,16 @@ export function setWaiting(home, { producer, waiting, reason, message } = {}) {
|
|
|
250
332
|
if (reason !== undefined) throw waitingError("E_BAD_ARGS", "--reason is for set, not clear");
|
|
251
333
|
if (message !== undefined) throw waitingError("E_BAD_ARGS", "--message is for set, not clear");
|
|
252
334
|
}
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
335
|
+
const a = addressOf(given);
|
|
336
|
+
const { home, incarnation } = a;
|
|
337
|
+
if (incarnation === null) throw waitingError("E_SESSION_UNKNOWN", `${given} is not an instance home (no readable instance.json)`);
|
|
338
|
+
const live = [readLog(a.homeLog, "home"), readLog(a.workspaceLog, "workspace")]
|
|
256
339
|
.map((log) => { const c = claimsOf(admit(home, [log]).rows, incarnation).get(producer); return c?.waiting ? c : null; });
|
|
257
340
|
const agrees = (c) => (waiting ? !!c && c.reason === reason && c.message === (message ?? null) : !c);
|
|
258
|
-
const answer = (changed, claim) => ({ eventsApi: EVENTS_API, instance:
|
|
341
|
+
const answer = (changed, claim) => answered(a, { eventsApi: EVENTS_API, instance: a.instance, home, producer, changed, waitingOnYou: waitingShape(claim) });
|
|
259
342
|
if (live.every(agrees)) return answer(false, waiting ? live[0] : null);
|
|
260
343
|
const data = waiting ? { waitingOnYou: true, reason, ...(message !== undefined ? { message } : {}) } : { waitingOnYou: false };
|
|
261
|
-
const res =
|
|
344
|
+
const res = append(a, { producer, kind: "waiting", data }, { incarnation });
|
|
262
345
|
const failed = (res.results || []).filter((r) => !r.ok);
|
|
263
346
|
if (!res.ok || failed.length) throw waitingError("E_EVENTS_FAILED", `could not record the waiting claim in ${failed.map((r) => `${r.path} (${r.reason})`).join(", ") || res.reason}; retry to complete it`);
|
|
264
347
|
return answer(true, waiting ? { producer, since: res.row.at, reason, message: message ?? null } : null);
|
|
@@ -272,22 +355,23 @@ export function setWaiting(home, { producer, waiting, reason, message } = {}) {
|
|
|
272
355
|
* same data), and only when neither has it is the row made, still at the
|
|
273
356
|
* launch time, so a claim the new session made since is kept. `ok` is true
|
|
274
357
|
* only when every log holds the row; evidence, never authority. */
|
|
275
|
-
export function recordStartBoundary(
|
|
276
|
-
const
|
|
277
|
-
const instance =
|
|
358
|
+
export function recordStartBoundary(given, { startId, startedAt, ...data }) {
|
|
359
|
+
const a = addressOf(given);
|
|
360
|
+
const { home, instance } = a;
|
|
361
|
+
const paths = [a.homeLog, a.workspaceLog];
|
|
278
362
|
const isIt = (r) => r.instance === instance && r.home === home && (r.producer ?? "kernel") === "kernel" && r.kind === "launched" && r.data?.startId === startId;
|
|
279
363
|
const found = paths.map((path) => readLog(path, "log").rows.find(isIt) ?? null);
|
|
280
364
|
if (found.every(Boolean)) return { ok: true, existed: true };
|
|
281
365
|
const existing = found.find(Boolean);
|
|
282
366
|
if (!existing) {
|
|
283
|
-
const res =
|
|
367
|
+
const res = append(a, { kind: "launched", data: { ...data, startId } }, { at: startedAt });
|
|
284
368
|
return { ...res, ok: res.ok && (res.results || []).every((r) => r.ok) };
|
|
285
369
|
}
|
|
286
370
|
// The row exactly as the other log holds it: same time, incarnation and data.
|
|
287
371
|
const line = JSON.stringify(existing) + "\n";
|
|
288
372
|
const results = paths.filter((_, i) => !found[i]).map((path) => {
|
|
289
373
|
try {
|
|
290
|
-
if (path.
|
|
374
|
+
if (path === a.homeLog && !existsSync(home)) return { path, ok: false, reason: "home absent" };
|
|
291
375
|
mkdirSync(dirname(path), { recursive: true });
|
|
292
376
|
appendFileSync(path, line);
|
|
293
377
|
return { path, ok: true };
|
package/lib/instance-git.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* repository's default branch) with "no upstream" ≠ 0/0. Diffs are bounded and
|
|
7
7
|
* addressed by an opaque file id minted with an observation revision; a diff
|
|
8
8
|
* against a tree that has since moved is refused, never served. */
|
|
9
|
-
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
@@ -44,6 +44,78 @@ function git(cwd, argv, { allowFail = false, input, diffExit = false } = {}) {
|
|
|
44
44
|
}
|
|
45
45
|
const trim = (s) => (s === null ? null : s.trim());
|
|
46
46
|
|
|
47
|
+
const BRANCH_REFS = Buffer.from("refs/heads/");
|
|
48
|
+
/** The two reads of HEAD, as bytes. They run like the module's other reads:
|
|
49
|
+
* read-only, helper-free, and with the module's own environment (gitEnv),
|
|
50
|
+
* which keeps only the caller's PATH and HOME: GIT_DIR, GIT_WORK_TREE,
|
|
51
|
+
* GIT_COMMON_DIR, GIT_INDEX_FILE and every other variable of the caller are
|
|
52
|
+
* not passed, and no global or system configuration is read, so nothing but
|
|
53
|
+
* the work tree named by `-C` decides what is read. `-C` comes first. */
|
|
54
|
+
const readHead = (work, args) => spawnSync("git", ["-C", work, ...READ_ONLY_GIT, ...args], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, shell: false, timeout: 30_000, env: gitEnv() });
|
|
55
|
+
/** What a failed read of HEAD says, without a line of the output that was not an error. */
|
|
56
|
+
const headReadFailure = (r, command) => String(r.error?.message ?? r.stderr ?? "").trim() || `git ${command} ${r.signal ? `was ended by ${r.signal}` : `exited with ${r.status}`}`;
|
|
57
|
+
|
|
58
|
+
/** What a work tree's HEAD names → { branch, detached, ref }. The one reader
|
|
59
|
+
* of that name: the retire plan and the retire both use it.
|
|
60
|
+
*
|
|
61
|
+
* `ref` is the ref HEAD points at, as the bytes `git symbolic-ref --quiet HEAD`
|
|
62
|
+
* prints, without the one line feed that ends them; null when HEAD is
|
|
63
|
+
* detached. It is evidence for a comparison and is never serialized. Through a
|
|
64
|
+
* chain of symbolic refs it is the last one: the branch the commits go to.
|
|
65
|
+
*
|
|
66
|
+
* `branch` is the name OATS carries for it: the part after `refs/heads/`, when
|
|
67
|
+
* that part is valid UTF-8 (it decodes and encodes back to the same bytes).
|
|
68
|
+
* Nothing of it is trimmed or replaced: a name may begin or end with a
|
|
69
|
+
* character that reads as white space. It is null when HEAD is detached, and
|
|
70
|
+
* also for a ref OATS carries no name for: one whose name is not valid UTF-8,
|
|
71
|
+
* or one outside `refs/heads/`. So `branch: null, detached: false` means "a
|
|
72
|
+
* name OATS does not carry", and two such HEADs are never taken for the same
|
|
73
|
+
* one by their missing names: compare `ref`.
|
|
74
|
+
*
|
|
75
|
+
* A read that fails throws E_WORK_INSPECTION_FAILED. Only Git's own quiet
|
|
76
|
+
* answer (exit 1, nothing printed) is "detached". */
|
|
77
|
+
export function headName(work) {
|
|
78
|
+
const r = readHead(work, ["symbolic-ref", "--quiet", "HEAD"]);
|
|
79
|
+
if (!r.error && r.status === 0 && r.stdout.length > 1) {
|
|
80
|
+
const ref = Buffer.from(r.stdout[r.stdout.length - 1] === 0x0a ? r.stdout.subarray(0, -1) : r.stdout);
|
|
81
|
+
let branch = null;
|
|
82
|
+
if (ref.length > BRANCH_REFS.length && ref.subarray(0, BRANCH_REFS.length).equals(BRANCH_REFS)) {
|
|
83
|
+
const name = ref.subarray(BRANCH_REFS.length);
|
|
84
|
+
try {
|
|
85
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(name);
|
|
86
|
+
if (Buffer.from(text, "utf8").equals(name)) branch = text;
|
|
87
|
+
} catch { /* not valid UTF-8: a name OATS does not carry */ }
|
|
88
|
+
}
|
|
89
|
+
return { branch, detached: false, ref };
|
|
90
|
+
}
|
|
91
|
+
if (!r.error && r.status === 1 && !r.stdout.length && !r.stderr.length) return { branch: null, detached: true, ref: null };
|
|
92
|
+
throw oatsError("E_WORK_INSPECTION_FAILED", `could not read what the worktree at ${work} has checked out: ${headReadFailure(r, "symbolic-ref")}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A worktree's HEAD for a retire → { commit, branch, detached, ref }: what
|
|
96
|
+
* headName gives, and the commit HEAD is at.
|
|
97
|
+
*
|
|
98
|
+
* A HEAD that has no commit (a branch that is not born yet) is a failed read
|
|
99
|
+
* here: the retire proves its copy against this commit, and removes a
|
|
100
|
+
* worktree only after it has asked which refs reach it, so there is nothing
|
|
101
|
+
* it could do without one. That differs from the plan on purpose: the plan
|
|
102
|
+
* (observeInstanceGit) reads the commit for itself, and an unborn work tree is
|
|
103
|
+
* a value on its wire (`revision: "unborn"`).
|
|
104
|
+
*
|
|
105
|
+
* The name and the commit are two reads, and nothing binds them but what is
|
|
106
|
+
* done with them: a copy is proven against `commit`, so a name read at
|
|
107
|
+
* another moment than the commit gives a copy that fails its proof. Nothing
|
|
108
|
+
* destructive rests on the name. */
|
|
109
|
+
export function worktreeHead(work) {
|
|
110
|
+
const name = headName(work);
|
|
111
|
+
const r = readHead(work, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
|
112
|
+
const commit = !r.error && r.status === 0 ? r.stdout.toString("latin1").replace(/\n$/, "") : "";
|
|
113
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(commit)) {
|
|
114
|
+
throw oatsError("E_WORK_INSPECTION_FAILED", `the HEAD of the worktree at ${work} has no commit that could be read${r.error || r.stderr?.length ? `: ${headReadFailure(r, "rev-parse")}` : ""}`);
|
|
115
|
+
}
|
|
116
|
+
return { commit, ...name };
|
|
117
|
+
}
|
|
118
|
+
|
|
47
119
|
/** The instance's work tree, from its home. The home's instance.json names
|
|
48
120
|
* the work mode; the tree is `<home>/work` (a directory or a symlink to the
|
|
49
121
|
* shared checkout). Missing/retired → attributed refusal, never a crash. */
|
|
@@ -169,12 +241,49 @@ function fileId(revision, indexOid, entry) {
|
|
|
169
241
|
return createHash("sha256").update(`${revision}\0${indexOid}\0${entry.kind}\0${entry.path}\0${entry.origPath ?? ""}`).digest("hex").slice(0, 24);
|
|
170
242
|
}
|
|
171
243
|
|
|
244
|
+
/** The HEAD of a worktree a retire is about to remove, and whether a ref of
|
|
245
|
+
* its repository reaches that commit → { head, unreached }. A ref outlives
|
|
246
|
+
* the worktree; HEAD, and refs only the worktree has, do not.
|
|
247
|
+
*
|
|
248
|
+
* One read in the repository that carries no ref name, so a ref whose name is
|
|
249
|
+
* not valid UTF-8, or very many refs, cannot make it refuse:
|
|
250
|
+
* `git for-each-ref --contains <commit> --count=1 --format=%(objectname)`.
|
|
251
|
+
* Exit 0 with a line printed: some ref reaches the commit. Exit 0 with
|
|
252
|
+
* nothing printed: none does, and the commit is something to preserve. Any
|
|
253
|
+
* other exit throws E_WORK_INSPECTION_FAILED. Standard error is not a failure
|
|
254
|
+
* here: a damaged ref prints a warning there with exit 0, and reaches
|
|
255
|
+
* nothing. A commit that only another worktree's HEAD reaches counts as not
|
|
256
|
+
* reached, which can only add a copy. */
|
|
257
|
+
export function worktreeCommitUnreached(repo, work) {
|
|
258
|
+
const head = worktreeHead(work);
|
|
259
|
+
const r = spawnSync("git", ["-C", repo, ...READ_ONLY_GIT, "for-each-ref", "--contains", head.commit, "--count=1", "--format=%(objectname)"], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, shell: false, timeout: 30_000, env: gitEnv() });
|
|
260
|
+
if (r.error || r.status !== 0) throw oatsError("E_WORK_INSPECTION_FAILED", `could not read which refs of ${repo} reach the commit the worktree at ${work} has checked out: ${headReadFailure(r, "for-each-ref")}`);
|
|
261
|
+
return { head, unreached: r.stdout.length === 0 };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The check made immediately before a retire removes a worktree: HEAD as it
|
|
265
|
+
* is now (`now`, worktreeHead) against HEAD as the final inspection read it
|
|
266
|
+
* (`inspected`). Equal when the commits are equal and both are detached or
|
|
267
|
+
* both point at a ref with the same bytes. The branch names are not compared:
|
|
268
|
+
* for a ref OATS carries no name for they are null on both sides, and null
|
|
269
|
+
* equal to null proves nothing. Anything else throws
|
|
270
|
+
* E_WORK_PRESERVATION_FAILED, and the worktree is not removed. */
|
|
271
|
+
export function assertSameWorktreeHead(inspected, now) {
|
|
272
|
+
const same = !!inspected && !!now && inspected.commit === now.commit
|
|
273
|
+
&& (inspected.ref === null || now.ref === null ? inspected.ref === null && now.ref === null && inspected.detached === true && now.detached === true : Buffer.compare(inspected.ref, now.ref) === 0);
|
|
274
|
+
if (!same) throw oatsError("E_WORK_PRESERVATION_FAILED", "the worktree's HEAD changed after it was inspected, so the worktree was not removed. The home and the worktree are kept, and so is any recovery the retire wrote; retry the retire.");
|
|
275
|
+
}
|
|
276
|
+
|
|
172
277
|
/** One consistent observation of the tree. Every field is what git said. */
|
|
173
278
|
export function observeInstanceGit(home) {
|
|
174
279
|
const { meta, work, mode } = instanceWorkTree(home);
|
|
175
280
|
const headOid = trim(git(work, ["rev-parse", "--verify", "--quiet", "HEAD"], { allowFail: true }));
|
|
176
281
|
const raw = git(work, ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all", "--ignore-submodules=none"]);
|
|
177
282
|
const { branch, entries } = parsePorcelainV2(raw);
|
|
283
|
+
// The name comes from headName, not from the status header, which prints
|
|
284
|
+
// "(detached)" and other words where a name would be and is read as text.
|
|
285
|
+
// The header still gives the upstream and its counts.
|
|
286
|
+
const head = headName(work);
|
|
178
287
|
// The index state participates in the revision so that a stage/unstage
|
|
179
288
|
// between observation and diff is a moved tree, not a stale-but-served diff.
|
|
180
289
|
// Hashed from the index listing: no `write-tree`, so observing creates no object.
|
|
@@ -198,10 +307,10 @@ export function observeInstanceGit(home) {
|
|
|
198
307
|
return {
|
|
199
308
|
instanceGitApi: INSTANCE_GIT_API,
|
|
200
309
|
instance: meta.instance ?? null, agent: meta.agent ?? null, home, workMode: mode,
|
|
201
|
-
observation: { revision, indexRevision: indexOid, at, worktree: work, branch: branch
|
|
202
|
-
recorded: { branch: meta.branch ?? null, repo: meta.repo ?? null, drift: meta.branch !== undefined && meta.branch !== null && branch
|
|
310
|
+
observation: { revision, indexRevision: indexOid, at, worktree: work, branch: head.branch, detached: head.detached && headOid !== null, unborn: headOid === null },
|
|
311
|
+
recorded: { branch: meta.branch ?? null, repo: meta.repo ?? null, drift: meta.branch !== undefined && meta.branch !== null && head.branch !== meta.branch },
|
|
203
312
|
upstream, base: baseComparison,
|
|
204
|
-
remote: remoteOf(work, branch
|
|
313
|
+
remote: remoteOf(work, head.branch),
|
|
205
314
|
summary, files,
|
|
206
315
|
notes: [
|
|
207
316
|
...(upstream.ref === null ? ["no upstream configured: upstream ahead/behind are unknown, not zero"] : []),
|
|
@@ -167,7 +167,8 @@ function writeFileSyncAtomic(path, value) {
|
|
|
167
167
|
/** Facts for Remove (retire): what retirement would touch, with the design's
|
|
168
168
|
* defaults (retain worktree and branch; never touch a PR). `oats retire`
|
|
169
169
|
* itself applies them: plain retire re-homes the worktree; --discard-worktree
|
|
170
|
-
*
|
|
170
|
+
* is the dialog's opt-in. No retire deletes a branch: `deleteBranch` is
|
|
171
|
+
* always false. */
|
|
171
172
|
export function planRetire(ctx, root, name, { home } = {}) {
|
|
172
173
|
const me = resolveInstance(ctx, root, name, { home });
|
|
173
174
|
const kids = descendantsOf(me.root, name);
|
|
@@ -183,7 +184,7 @@ export function planRetire(ctx, root, name, { home } = {}) {
|
|
|
183
184
|
appendEvent(me.home, { kind: "retire-planned", data: { planRevision: planRevision(safety), children: kids.length, dirty: facts.work.observed ? facts.work.changed + facts.work.untracked : null } }, { workspaceOnly: true });
|
|
184
185
|
return { lifecycleApi: LIFECYCLE_API, action: "retire", instance: name, home: me.home, at: new Date().toISOString(), facts, defaults, planRevision: planRevision(safety),
|
|
185
186
|
notes: [
|
|
186
|
-
...(facts.work.observed && facts.work.drift ? [`the worktree is on ${facts.work.branch}, not the recorded ${facts.recordedBranch}
|
|
187
|
+
...(facts.work.observed && facts.work.drift ? [`the worktree is on ${facts.work.branch ?? (facts.work.detached ? "a detached HEAD" : "a ref OATS carries no branch name for")}, not the recorded ${facts.recordedBranch}`] : []),
|
|
187
188
|
...(facts.work.observed && facts.work.changed + facts.work.untracked > 0 ? [`${facts.work.changed} changed and ${facts.work.untracked} untracked file(s) would be retained with the worktree`] : []),
|
|
188
189
|
...(kids.length ? [`${kids.length} recorded child instance(s) are stopped first (bounded SIGTERM, never escalated) and retained (their homes are not removed); a child still running after the grace refuses the retirement`] : []),
|
|
189
190
|
...((kids.ambiguous || []).length ? [`${kids.ambiguous.length} instance(s) record this name as parent but the name is not unique under this root; they are listed under ambiguous and NOT acted on`] : []),
|
package/lib/packages.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* OATS packages — versions, lock v3 (workspace model v2).
|
|
3
3
|
*
|
|
4
4
|
* Contract: docs/design/2026-09-23-workspace-module-contracts.md §4.
|
|
5
|
-
* Decision: https://github.com/awebai/oats-knowledge/blob/main/knowledge/nodes/oats-
|
|
5
|
+
* Decision: https://github.com/awebai/oats-knowledge/blob/main/knowledge/nodes/oats-maintainer/decisions/workspace-model-v2.md.
|
|
6
6
|
*
|
|
7
7
|
* A package is a place to fetch from WITH a version attached. Nothing is
|
|
8
8
|
* installed: `resolvePackages` turns each `workspace.packages` entry into an
|
package/lib/resolve.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* lib/resolve.mjs — from a soul to an immutable resolution (module contract §3).
|
|
3
3
|
*
|
|
4
4
|
* Contract: docs/design/2026-09-23-workspace-module-contracts.md §3.
|
|
5
|
-
* Decision: https://github.com/awebai/oats-knowledge/blob/main/knowledge/nodes/oats-
|
|
5
|
+
* Decision: https://github.com/awebai/oats-knowledge/blob/main/knowledge/nodes/oats-maintainer/decisions/workspace-model-v2.md (6, 12, 14, 16, 19–21).
|
|
6
6
|
*
|
|
7
7
|
* `resolveSoul(discovery, soulEntry, options)` turns a discovered soul into the
|
|
8
8
|
* exact set of modules an instance will be built from: which capability comes
|
|
@@ -7,9 +7,10 @@ import { signalGroup } from "./process-group.mjs";
|
|
|
7
7
|
|
|
8
8
|
const { file, args, timeout, graceMs, maxBuffer } = JSON.parse(readFileSync(0, "utf8"));
|
|
9
9
|
const result = await new Promise((resolve) => {
|
|
10
|
-
let child, status = null, signal = null, error;
|
|
11
|
-
let exited = false, closed = false, stopping = false, hardEnd = false, settled = false;
|
|
10
|
+
let child, status = null, signal = null, error, interrupted = false;
|
|
11
|
+
let exited = false, closed = false, stopping = false, hardEnd = false, settled = false, termSent = false;
|
|
12
12
|
let deadline, escalation, probe, groupEmpty = false;
|
|
13
|
+
const signalHandlers = new Map();
|
|
13
14
|
const chunks = { stdout: [], stderr: [] }, sizes = { stdout: 0, stderr: 0 };
|
|
14
15
|
const groupAlive = () => {
|
|
15
16
|
if (groupEmpty || !child?.pid) return false;
|
|
@@ -21,6 +22,11 @@ const result = await new Promise((resolve) => {
|
|
|
21
22
|
if (process.platform === "win32") { if (!exited) child.kill(sig); }
|
|
22
23
|
else if (groupAlive()) signalGroup(child, sig);
|
|
23
24
|
};
|
|
25
|
+
const requestTerm = () => {
|
|
26
|
+
if (termSent || !child?.pid) return;
|
|
27
|
+
termSent = true;
|
|
28
|
+
send("SIGTERM");
|
|
29
|
+
};
|
|
24
30
|
const finish = () => {
|
|
25
31
|
if (settled || (!closed && !hardEnd) || (!exited && child?.pid)) return;
|
|
26
32
|
// A leader can close while a pipe-free descendant ignores TERM. Keep the
|
|
@@ -28,31 +34,55 @@ const result = await new Promise((resolve) => {
|
|
|
28
34
|
if (stopping && !hardEnd && groupAlive()) return;
|
|
29
35
|
settled = true;
|
|
30
36
|
clearTimeout(deadline); clearTimeout(escalation); clearInterval(probe);
|
|
31
|
-
|
|
37
|
+
for (const [sig, handler] of signalHandlers) process.off(sig, handler);
|
|
38
|
+
resolve({ status, signal, stdout: Buffer.concat(chunks.stdout).toString("utf8"), stderr: Buffer.concat(chunks.stderr).toString("utf8"), ...(error ? { error } : {}), ...(interrupted ? { interrupted: true } : {}) });
|
|
39
|
+
};
|
|
40
|
+
const watchGroup = () => {
|
|
41
|
+
probe ??= setInterval(() => { groupAlive(); finish(); }, Math.min(50, graceMs));
|
|
32
42
|
};
|
|
33
43
|
const stop = (cause) => {
|
|
34
44
|
if (stopping || settled) return;
|
|
35
|
-
stopping = true; error
|
|
36
|
-
|
|
45
|
+
stopping = true; error ??= cause;
|
|
46
|
+
requestTerm();
|
|
37
47
|
escalation = setTimeout(() => {
|
|
38
48
|
send("SIGKILL"); hardEnd = true;
|
|
39
49
|
// A detached descendant can escape the group yet retain inherited pipes.
|
|
40
50
|
// Its pipes must not prevent returning after the direct child's exit.
|
|
41
|
-
child
|
|
51
|
+
child?.stdout?.destroy(); child?.stderr?.destroy();
|
|
42
52
|
finish();
|
|
43
53
|
}, graceMs);
|
|
44
|
-
|
|
54
|
+
watchGroup();
|
|
45
55
|
};
|
|
56
|
+
// Catchable supervisor shutdown owns the same cleanup as timeout/overflow.
|
|
57
|
+
// Keep handlers throughout cleanup: repeated signals cannot bypass it.
|
|
58
|
+
// SIGKILL/OOM cannot run this path; a missing receipt stays unconfirmed.
|
|
59
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
60
|
+
const handler = () => {
|
|
61
|
+
// Preserve interruption even if overflow/timeout already began cleanup;
|
|
62
|
+
// the first error alone cannot describe both independent observations.
|
|
63
|
+
interrupted = true;
|
|
64
|
+
stop({ code: "EINTR", message: `schedule command supervisor interrupted by ${sig}` });
|
|
65
|
+
};
|
|
66
|
+
signalHandlers.set(sig, handler);
|
|
67
|
+
process.on(sig, handler);
|
|
68
|
+
}
|
|
46
69
|
try { child = spawn(file, args, { detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] }); }
|
|
47
|
-
catch (e) { error
|
|
70
|
+
catch (e) { error ??= { code: e.code, message: e.message }; closed = true; finish(); return; }
|
|
48
71
|
for (const stream of ["stdout", "stderr"]) child[stream].on("data", (chunk) => {
|
|
49
72
|
const room = maxBuffer - sizes[stream];
|
|
50
73
|
if (room > 0) { const kept = chunk.subarray(0, room); chunks[stream].push(kept); sizes[stream] += kept.length; }
|
|
51
74
|
if (chunk.length > room) stop({ code: "ENOBUFS", message: `schedule command ${stream} exceeded ${maxBuffer} bytes` });
|
|
52
75
|
});
|
|
53
76
|
child.once("error", (e) => { error ??= { code: e.code, message: e.message }; });
|
|
54
|
-
child.once("exit", (code, sig) => {
|
|
77
|
+
child.once("exit", (code, sig) => {
|
|
78
|
+
exited = true; status = code; signal = sig;
|
|
79
|
+
// Do not wait for pipes or the deadline: an escaped session may hold pipes
|
|
80
|
+
// after this group empties. Once observed empty it is never signalled again.
|
|
81
|
+
if (groupAlive()) watchGroup();
|
|
82
|
+
finish();
|
|
83
|
+
});
|
|
55
84
|
child.once("close", () => { closed = true; finish(); });
|
|
85
|
+
if (stopping) requestTerm(); // Shutdown may have begun while spawn returned.
|
|
56
86
|
deadline = setTimeout(() => stop({ code: "ETIMEDOUT", message: `schedule command exceeded ${timeout} ms` }), timeout);
|
|
57
87
|
});
|
|
58
88
|
process.stdout.write(JSON.stringify(result));
|