@awebai/oats 0.24.12 → 0.25.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 +936 -2822
- package/docs/capabilities.md +136 -323
- package/docs/configuration.md +68 -533
- package/docs/design/2026-09-16-fresh-operator-walkthrough.md +19 -14
- package/docs/design/2026-09-16-portable-migration-evidence.md +2 -0
- package/docs/design/2026-09-16-portable-onboarding.md +4 -2
- package/docs/design/2026-09-20-redesign-program-board.md +1 -1
- package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +2 -0
- package/docs/design/2026-09-23-simplified-workspace-model.md +711 -0
- package/docs/design/2026-09-23-workspace-module-contracts.md +309 -0
- package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +65 -0
- package/docs/design/README.md +20 -8
- package/docs/design/operations-contract.md +1 -0
- package/docs/design/package-engine-contract.md +1 -1
- package/docs/design/package-runtime-api.md +1 -1
- package/docs/desktop-cli-api.md +386 -6
- package/docs/desktop-succession.md +3 -4
- package/docs/first-team.md +107 -224
- package/docs/implementation.md +6 -4
- package/docs/integrations.md +45 -44
- package/docs/knowledge-capability-authoring.md +10 -4
- package/docs/knowledge-migration.md +5 -4
- package/docs/knowledge-reference/package-craft.md +11 -3
- package/docs/knowledge.md +24 -8
- package/docs/layers.md +3 -3
- package/docs/oats-local.schema.json +50 -0
- package/docs/oats-membership.schema.json +23 -0
- package/docs/oats-workspace.schema.json +133 -48
- package/docs/official-marketplace.md +9 -6
- package/docs/packages.md +229 -440
- package/docs/rebuild-to-v2.md +233 -0
- package/docs/release-notes/v0.24.13.md +51 -0
- package/docs/release-notes/v0.25.0.md +99 -0
- package/docs/soul.schema.json +41 -68
- package/docs/souls-and-instances.md +175 -108
- package/docs/workspace-adoption.md +70 -345
- package/docs/workspaces.md +429 -119
- package/lib/core.mjs +419 -55
- package/lib/instance-resolution.mjs +312 -0
- package/lib/materialize.mjs +580 -0
- package/lib/packages.mjs +501 -1273
- package/lib/remote.mjs +639 -0
- package/lib/resolve.mjs +576 -0
- package/lib/schedule.mjs +194 -34
- package/lib/workspace.mjs +635 -0
- package/package.json +1 -1
- package/lib/portable-migration-artifacts.mjs +0 -135
- package/lib/portable-migration-evidence.mjs +0 -305
- package/lib/portable-migration-store.mjs +0 -199
- package/lib/portable-migration.mjs +0 -104
- package/lib/portable-onboarding-acceptance.mjs +0 -66
- package/lib/setup-expert-source.mjs +0 -100
package/lib/schedule.mjs
CHANGED
|
@@ -20,12 +20,14 @@
|
|
|
20
20
|
* succeeded. A launch whose side effects cannot be confirmed stays
|
|
21
21
|
* `unknown` with its slot held until `reconcile` proves what happened. */
|
|
22
22
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
23
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
24
25
|
import { homedir } from "node:os";
|
|
25
26
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
26
27
|
import { fileURLToPath } from "node:url";
|
|
27
28
|
import { Cron } from "croner";
|
|
28
29
|
import { portableScope } from "./portable-state.mjs";
|
|
30
|
+
import { loadLocal } from "./workspace.mjs";
|
|
29
31
|
import { RESERVED_LAUNCH_ENV, ensureRoot, findAgent, findCapabilityAgent, findInstanceHomes, teamAgentRoots, configChain, spawnInstance, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath, loadCapturedDispatch, prepareCapturedComposition } from "./core.mjs";
|
|
30
32
|
import { RECURRENCE_POLICIES, SCHEDULE_ATTEMPT_VERSION, SCHEDULE_DEFINITION_VERSION, admitExecutionTemplate, buildCommandExecutionTemplate, buildWakeExecutionTemplate, capturedDispatchAction, commandArgvWithExecutionBinding, executionContentIntegrity, validateExecutionBinding, validateExecutionCapsule, validateExecutionTemplate, validatePreparationInput } from "./schedule-capsule.mjs";
|
|
31
33
|
|
|
@@ -127,11 +129,32 @@ export function stateDir(ws) { return join(ws, ".agents", "schedules"); }
|
|
|
127
129
|
function statePath(ws) { return join(stateDir(ws), "state.json"); }
|
|
128
130
|
function lockDir(ws, id) { return join(stateDir(ws), "locks", id); }
|
|
129
131
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
export const SCHEDULE_HISTORY_API = 3;
|
|
133
|
+
export const SCHEDULE_FILE_BUDGET = 1024 * 1024;
|
|
134
|
+
/** K8b — bounded, descriptor-safe JSON read: lstat → regular file only →
|
|
135
|
+
* O_NOFOLLOW|O_NONBLOCK open → fstat dev/ino identity → whole file only if
|
|
136
|
+
* within budget (an over-budget file is REFUSED as a typed error, never
|
|
137
|
+
* truncated into fabricated JSON). Returns { value, source }. */
|
|
138
|
+
function readJsonBounded(path, fallback, label) {
|
|
139
|
+
let st;
|
|
140
|
+
try { st = lstatSync(path); }
|
|
141
|
+
catch (e) { if (e.code === "ENOENT") return { value: fallback, source: { path: label, status: "absent", bytes: 0 } }; throw scheduleError("E_SCHEDULE_INVALID", `${path} cannot be read: ${e.message}`, { field: "file", source: { path: label, status: "refused", bytes: 0 } }); }
|
|
142
|
+
if (!st.isFile()) throw scheduleError("E_SCHEDULE_INVALID", `${path} is not a regular file`, { field: "file", source: { path: label, status: "refused", bytes: 0 } });
|
|
143
|
+
if (st.size > SCHEDULE_FILE_BUDGET) throw scheduleError("E_SCHEDULE_STATE_OVERSIZE", `${path} is ${st.size} bytes; the read budget is ${SCHEDULE_FILE_BUDGET}`, { field: "file", source: { path: label, status: "oversize", bytes: st.size } });
|
|
144
|
+
let fd, text;
|
|
145
|
+
try {
|
|
146
|
+
fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
|
|
147
|
+
const fst = fstatSync(fd);
|
|
148
|
+
if (!fst.isFile() || fst.dev !== st.dev || fst.ino !== st.ino || fst.size > SCHEDULE_FILE_BUDGET) throw new Error("swapped or grew past budget");
|
|
149
|
+
const buf = Buffer.alloc(fst.size); let got = 0;
|
|
150
|
+
while (got < fst.size) { const n = readSync(fd, buf, got, fst.size - got, got); if (n <= 0) break; got += n; }
|
|
151
|
+
text = buf.toString("utf8"); st = fst;
|
|
152
|
+
} catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${path} cannot be read safely: ${e.message}`, { field: "file", source: { path: label, status: "refused", bytes: st.size } }); }
|
|
153
|
+
finally { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to recover */ } }
|
|
154
|
+
try { return { value: JSON.parse(text), source: { path: label, status: "ok", bytes: st.size } }; }
|
|
155
|
+
catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${path} is not valid JSON: ${e.message}`, { field: "file", source: { path: label, status: "corrupt", bytes: st.size } }); }
|
|
134
156
|
}
|
|
157
|
+
function readJson(path, fallback) { return readJsonBounded(path, fallback, basename(path)).value; }
|
|
135
158
|
function writeJson(path, value) {
|
|
136
159
|
mkdirSync(dirname(path), { recursive: true });
|
|
137
160
|
const tmp = `${path}.tmp-${process.pid}`;
|
|
@@ -139,27 +162,52 @@ function writeJson(path, value) {
|
|
|
139
162
|
renameSync(tmp, path);
|
|
140
163
|
}
|
|
141
164
|
export function readDefinitions(ws) {
|
|
142
|
-
const doc =
|
|
165
|
+
const doc = readJsonBounded(definitionsPath(ws), { version: 1, jobs: {} }, "definitions").value;
|
|
143
166
|
if (![1, 2].includes(doc.version) || typeof doc.jobs !== "object" || doc.jobs === null || Array.isArray(doc.jobs)) throw scheduleError("E_SCHEDULE_INVALID", `${definitionsPath(ws)} must be {version: 1|2, jobs: {}}`, { field: "file" });
|
|
144
167
|
return doc;
|
|
145
168
|
}
|
|
146
169
|
export function writeDefinitions(ws, doc) { writeJson(definitionsPath(ws), doc); }
|
|
147
|
-
export function readState(ws) { const st =
|
|
170
|
+
export function readState(ws) { const st = readJsonBounded(statePath(ws), { jobs: {} }, "state").value; if (!st.jobs || typeof st.jobs !== "object") st.jobs = {}; return st; }
|
|
171
|
+
/** Integrity facts for a scope's two files, without throwing. */
|
|
172
|
+
export function scheduleIntegrity(ws) {
|
|
173
|
+
const sources = [];
|
|
174
|
+
for (const [path, label, fallback] of [[definitionsPath(ws), "definitions", { version: 1, jobs: {} }], [statePath(ws), "state", { jobs: {} }]]) {
|
|
175
|
+
try { sources.push(readJsonBounded(path, fallback, label).source); }
|
|
176
|
+
catch (e) { sources.push(e.source ?? { path: label, status: "refused", bytes: 0 }); }
|
|
177
|
+
}
|
|
178
|
+
return { sources };
|
|
179
|
+
}
|
|
148
180
|
/** K8 — bounded run history. Every time a job's lastRun reaches a settled
|
|
149
181
|
* outcome (not "active"/"starting"), it is appended once to `recentRuns`
|
|
150
182
|
* (newest first, max 50), keyed by scheduledFor+startedAt so re-saves of the
|
|
151
183
|
* same run never duplicate it. Recording happens on save: producers keep
|
|
152
184
|
* writing lastRun exactly as they do; nothing is inferred. */
|
|
153
185
|
export const RECENT_RUNS_MAX = 50;
|
|
154
|
-
|
|
186
|
+
/** K8b — a run's identity is WHEN it was scheduled and started (plus the
|
|
187
|
+
* attempt id when one exists), never its outcome: unknown→ended is one run
|
|
188
|
+
* with two transitions, not two rows. */
|
|
189
|
+
export function runIdOf(lr) {
|
|
190
|
+
if (!lr || typeof lr !== "object" || (!lr.scheduledFor && !lr.startedAt)) return null;
|
|
191
|
+
return createHash("sha256").update(`${lr.scheduledFor ?? ""}|${lr.startedAt ?? ""}|${lr.execution?.attemptId ?? lr.attemptId ?? ""}`).digest("hex").slice(0, 24);
|
|
192
|
+
}
|
|
193
|
+
const SETTLED_OUTCOMES = new Set(["ended", "stopped", "delivered", "skipped", "blocked", "invalid", "failed", "unknown", "refused", "completed"]);
|
|
194
|
+
function recordRecentRuns(st, now = new Date()) {
|
|
155
195
|
for (const js of Object.values(st.jobs || {})) {
|
|
156
196
|
const lr = js.lastRun;
|
|
157
197
|
if (!lr || typeof lr !== "object" || ["active", "starting"].includes(lr.outcome)) continue;
|
|
158
|
-
const
|
|
198
|
+
const runId = runIdOf(lr); if (!runId) continue;
|
|
159
199
|
js.recentRuns ||= [];
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
200
|
+
const settled = SETTLED_OUTCOMES.has(lr.outcome) && lr.pending !== true;
|
|
201
|
+
const existing = js.recentRuns.find((r) => r.runId === runId);
|
|
202
|
+
if (existing) {
|
|
203
|
+
// same run, later facts: update in place, keep the outcome sequence
|
|
204
|
+
const prev = existing.outcome;
|
|
205
|
+
Object.assign(existing, lr, { runId, settled, recordedAt: now.toISOString() });
|
|
206
|
+
existing.transitions = [...(existing.transitions || [prev]), ...(prev !== lr.outcome ? [lr.outcome] : [])];
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
// legacy rows (pre-K8b, keyed by outcome) are left as they are: never merged, reported as legacy
|
|
210
|
+
js.recentRuns.unshift({ runId, ...lr, settled, recordedAt: now.toISOString(), transitions: [lr.outcome] });
|
|
163
211
|
if (js.recentRuns.length > RECENT_RUNS_MAX) js.recentRuns.length = RECENT_RUNS_MAX;
|
|
164
212
|
}
|
|
165
213
|
}
|
|
@@ -427,13 +475,97 @@ export function findHomesInScope(ws, instance) {
|
|
|
427
475
|
function scheduleBlock(def, minute) {
|
|
428
476
|
return `\n\n## Scheduled run\n\nThis instance was launched by OATS schedule "${def.id}" for ${minute.toISOString()} (cron "${def.cron}" in ${def.tz}). It is disposable: finish the task above, bring your memory files up to date, and end with \`oats retire --self\`.\n`;
|
|
429
477
|
}
|
|
430
|
-
/**
|
|
478
|
+
/** The environment a scheduler-launched oats child runs with: the caller's
|
|
479
|
+
* frozen instance identity and capability snapshot removed (a tick or run-now
|
|
480
|
+
* may be invoked from inside an unrelated instance; dispatch belongs to the
|
|
481
|
+
* job's cwd and explicit selectors, never to that caller), host configuration
|
|
482
|
+
* such as OATS_HOME_DIR and credentials kept. Shared by command and spawn jobs. */
|
|
483
|
+
function childEnv() {
|
|
484
|
+
const env = { ...process.env };
|
|
485
|
+
for (const key of [...RESERVED_LAUNCH_ENV,
|
|
486
|
+
"OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
|
|
487
|
+
"OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
|
|
488
|
+
"OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
|
|
489
|
+
"OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE",
|
|
490
|
+
]) delete env[key];
|
|
491
|
+
return env;
|
|
492
|
+
}
|
|
493
|
+
/** Is this spawn a WORKSPACE-model spawn (module contract §5, decision 7)?
|
|
494
|
+
* Two marks, either suffices: the deployment realizes a workspace
|
|
495
|
+
* (`oats-local.yaml` walking up from the agents root or the scope — the same
|
|
496
|
+
* detection `oats spawn` uses), or the soul under the agents root was copied
|
|
497
|
+
* from a member repo by ensureWorkspaceSoul (`.oats-soul-source.json` beside
|
|
498
|
+
* it). An unreadable oats-local.yaml is still a workspace deployment: the
|
|
499
|
+
* spawn must go the workspace way and report E_WORKSPACE_SCHEMA, never fall
|
|
500
|
+
* back to a classic home. Returns null for a classic (bare agents root) spawn. */
|
|
501
|
+
export function workspaceSpawnContext(ws, root, agent) {
|
|
502
|
+
for (const dir of [dirname(root), ws]) {
|
|
503
|
+
try { const found = loadLocal(dir); return { kind: "local", local: found.path, dir: dirname(found.path) }; }
|
|
504
|
+
catch (e) { if (e?.code !== "E_LOCAL_MISSING") return { kind: "local", local: null, dir, error: { code: e.code || "E_WORKSPACE_SCHEMA", message: e.message } }; }
|
|
505
|
+
}
|
|
506
|
+
const stamp = agent?._dir ? join(agent._dir, ".oats-soul-source.json") : join(root, agent?.name || "", ".oats-soul-source.json");
|
|
507
|
+
if (agent?.name && existsSync(stamp)) return { kind: "soul-source", local: null, dir: dirname(root), stamp };
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
/** A workspace spawn materializes EXACTLY like `oats spawn` — prepareInstance →
|
|
511
|
+
* ensureWorkspaceSoul → spawnInstanceAsync (resolve over the remotes, copy every
|
|
512
|
+
* module whole into the home, compose AGENTS.md from the modules' injects).
|
|
513
|
+
* That chain is asynchronous (it reads Git remotes); the tick chain is
|
|
514
|
+
* synchronous by design (one short-lived process under the host lock, no
|
|
515
|
+
* daemon). So the tick delegates to the kernel's own CLI as a child process,
|
|
516
|
+
* like a command job: `oats spawn <soul> --json …` in the deployment, and
|
|
517
|
+
* reads back the one JSON envelope. The child holds no host lock (a spawn
|
|
518
|
+
* takes only the scope lock, briefly, for a wake it saves — and the schedule
|
|
519
|
+
* passes no wake flags: the wake is saved here, from the run record).
|
|
520
|
+
* `io.oatsBin`, `io.commandTimeoutMs` and `io.noLaunch` are the test seams. */
|
|
521
|
+
function spawnViaCli(ws, root, agent, opts, context, io) {
|
|
522
|
+
if (context.error) throw scheduleError(context.error.code, `this deployment realizes a workspace (${context.dir}) but its oats-local.yaml cannot be read: ${context.error.message}`);
|
|
523
|
+
const argv = ["spawn", agent.name, "--dir", context.dir, "--agents-root", root, "--purpose", opts.purpose, "--json"];
|
|
524
|
+
if (opts.runtime) argv.push("--runtime", opts.runtime);
|
|
525
|
+
if (opts.model) argv.push("--model", opts.model);
|
|
526
|
+
if (opts.backend) argv.push("--backend", opts.backend);
|
|
527
|
+
if (opts.repo) argv.push("--repo", opts.repo);
|
|
528
|
+
if (opts.yolo === true) argv.push("--yolo"); else if (opts.yolo === false) argv.push("--no-yolo");
|
|
529
|
+
if (opts.launch === false) argv.push("--no-launch");
|
|
530
|
+
// The task is multi-line text of arbitrary size: it travels as a private file, never in argv.
|
|
531
|
+
mkdirSync(join(stateDir(ws), "tasks"), { recursive: true });
|
|
532
|
+
const taskFile = join(stateDir(ws), "tasks", `${opts.purpose}-${process.pid}.md`);
|
|
533
|
+
writeFileSync(taskFile, opts.task, { mode: 0o600 });
|
|
534
|
+
argv.push("--task-file", taskFile);
|
|
535
|
+
let r;
|
|
536
|
+
try { r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: context.dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env: childEnv() }); }
|
|
537
|
+
finally { try { rmSync(taskFile, { force: true }); } catch { /* best effort */ } }
|
|
538
|
+
const timedOut = r.error?.code === "ETIMEDOUT" || (r.status === null && r.signal === "SIGTERM");
|
|
539
|
+
const envelope = parseEnvelopeText(r.stdout);
|
|
540
|
+
// The child may have created (and launched) a home before dying: unconfirmed, never a confirmed failure.
|
|
541
|
+
// The thrown message reports RETAINED effects so the tick keeps the attempt and its slot for reconcile.
|
|
542
|
+
if (timedOut) throw scheduleError("E_SPAWN_UNCONFIRMED", "workspace spawn timed out; its effects could not be confirmed (a home may have been created) — run oats schedule reconcile");
|
|
543
|
+
if (!envelope || typeof envelope !== "object" || typeof envelope.ok !== "boolean") throw scheduleError("E_SPAWN_UNCONFIRMED", `workspace spawn answered no valid envelope (exit ${r.status ?? r.signal}); its effects could not be confirmed — run oats schedule reconcile${r.stderr ? `: ${String(r.stderr).trim().split("\n").pop()}` : ""}`);
|
|
544
|
+
if (!envelope.ok) {
|
|
545
|
+
const e = envelope.error || {};
|
|
546
|
+
// E_SPAWN_INCOMPLETE: the kernel created a home it could not finish or roll back — retained effects.
|
|
547
|
+
const retained = e.code === "E_SPAWN_INCOMPLETE" || e.details?.home;
|
|
548
|
+
throw scheduleError(e.code || "E_SPAWN_FAILED", retained ? `${e.message} (home retained; effects unconfirmed)` : String(e.message || "spawn failed"), { details: e.details });
|
|
549
|
+
}
|
|
550
|
+
const res = envelope.result || {};
|
|
551
|
+
return { instance: res.instance, home: res.home, launched: res.launched, materialized: "workspace" };
|
|
552
|
+
}
|
|
553
|
+
/** Launch one spawn job. `io.spawn(root, agent, opts)` overrides the whole
|
|
554
|
+
* launcher for tests. Otherwise a workspace deployment goes through the
|
|
555
|
+
* kernel's own `oats spawn` (workspace materialization: `.oats/modules`,
|
|
556
|
+
* module skills and injects — never a classic home); a bare agents root goes
|
|
557
|
+
* through the synchronous classic spawnInstance, whose failures throw here. */
|
|
431
558
|
function launchSpawn(ws, def, minute, io) {
|
|
432
559
|
const { root, agent } = resolveScheduledAgent(ws, def);
|
|
433
560
|
const purpose = `${def.purpose || def.id}-${purposeSuffix(minute)}`;
|
|
434
|
-
const opts = { task: def.task.trimEnd() + scheduleBlock(def, minute), purpose, runtime: def.runtime, model: def.model, yolo: def.yolo, backend: def.backend, repo: def.repo, launch: true };
|
|
435
|
-
|
|
436
|
-
|
|
561
|
+
const opts = { task: def.task.trimEnd() + scheduleBlock(def, minute), purpose, runtime: def.runtime, model: def.model, yolo: def.yolo, backend: def.backend, repo: def.repo, launch: io?.noLaunch === true ? false : true };
|
|
562
|
+
let r;
|
|
563
|
+
if (io?.spawn) r = io.spawn(root, agent, opts);
|
|
564
|
+
else {
|
|
565
|
+
const context = workspaceSpawnContext(ws, root, agent);
|
|
566
|
+
r = context ? spawnViaCli(ws, root, agent, opts, context, io) : spawnInstance(root, agent, opts);
|
|
567
|
+
}
|
|
568
|
+
const run = { instance: r.instance, home: r.home, launched: !!r.instance, kind: "spawn", ...(r.materialized ? { materialized: r.materialized } : {}) };
|
|
437
569
|
if (def.wake && r.home) {
|
|
438
570
|
try { run.wakeSchedule = saveWakeForHome(ws, { instance: r.instance, home: r.home, wake: def.wake }); }
|
|
439
571
|
catch (e) { run.wakeScheduleError = { code: e.code || "E_SCHEDULE_INVALID", message: e.message }; }
|
|
@@ -467,18 +599,7 @@ function launchCommand(ws, def, io) {
|
|
|
467
599
|
let envelope, timedOut = false, raw = "";
|
|
468
600
|
if (io?.command) envelope = io.command({ cwd: def.cwd, argv });
|
|
469
601
|
else {
|
|
470
|
-
const
|
|
471
|
-
// A schedule may be ticked/run-now from inside an unrelated instance.
|
|
472
|
-
// Dispatch belongs to the job's cwd/explicit selectors, never that
|
|
473
|
-
// caller's frozen capability snapshot (including legacy OATS_HOME).
|
|
474
|
-
// Keep host configuration such as OATS_HOME_DIR and credentials intact.
|
|
475
|
-
for (const key of [...RESERVED_LAUNCH_ENV,
|
|
476
|
-
"OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
|
|
477
|
-
"OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
|
|
478
|
-
"OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
|
|
479
|
-
"OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE",
|
|
480
|
-
]) delete env[key];
|
|
481
|
-
const r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: def.cwd, encoding: "utf8", timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env });
|
|
602
|
+
const r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: def.cwd, encoding: "utf8", timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env: childEnv() });
|
|
482
603
|
timedOut = r.error?.code === "ETIMEDOUT" || (r.status === null && r.signal === "SIGTERM");
|
|
483
604
|
raw = String(r.stdout || "");
|
|
484
605
|
envelope = parseEnvelopeText(raw);
|
|
@@ -502,7 +623,10 @@ function launchCommand(ws, def, io) {
|
|
|
502
623
|
const named = instance || (partial && typeof partial.instance === "string" ? partial.instance : undefined);
|
|
503
624
|
return { kind: "command", launched: false, unconfirmed: true, ...(named ? { instance: named } : {}), error: `command outcome unconfirmed: ${envelope.error?.message}`, errorCode: envelope.error?.code };
|
|
504
625
|
}
|
|
505
|
-
|
|
626
|
+
// Provenance the recorder has at write time: the answering server (remote
|
|
627
|
+
// spawn) and the launched home's incarnation. Never a transcript.
|
|
628
|
+
const server = typeof res.server === "string" ? res.server : undefined;
|
|
629
|
+
return { kind: "command", launched: envelope.ok === true, ...(instance ? { instance } : {}), ...(home ? { home, incarnation: incarnationOfHome(home) } : {}), ...(server ? { server } : {}), ...(envelope.ok ? {} : { error: envelope.error?.message || "command failed", errorCode: envelope.error?.code }) };
|
|
506
630
|
}
|
|
507
631
|
|
|
508
632
|
/** Verify an immutable capsule and its current exact-artifact authorization
|
|
@@ -596,12 +720,16 @@ function performWake(def, io, { startIfStopped = true, canStart = true, reserve
|
|
|
596
720
|
if (reserve && !reserve()) return { kind: "wake", action: "skipped", reason: "the job's slot is held by another process; delivery pending", pending: true };
|
|
597
721
|
try {
|
|
598
722
|
const r = (io?.start || startInstanceSession)(def.home);
|
|
599
|
-
return { kind: "wake", action: "started", instance: r.instance, target: r.target, reason: "session was stopped; the message is pending until the session is active", pending: true, startedRuntime: true };
|
|
723
|
+
return { kind: "wake", action: "started", instance: r.instance, target: r.target, home: def.home, incarnation: incarnationOfHome(def.home), reason: "session was stopped; the message is pending until the session is active", pending: true, startedRuntime: true };
|
|
600
724
|
} catch (e) {
|
|
601
725
|
return { kind: "wake", action: "skipped", reason: `start did not complete (${e.code || "error"}): ${e.message}; the slot is kept until the session is observed stopped or absent`, error: e.message, errorCode: e.code, pending: true, startedRuntime: "unconfirmed" };
|
|
602
726
|
}
|
|
603
727
|
}
|
|
604
|
-
try {
|
|
728
|
+
try {
|
|
729
|
+
const r = (io?.input || inputInstanceSession)(def.home, def.message);
|
|
730
|
+
// Delivery custody: name the instance ONLY when the input result names it; the home's current incarnation at record time.
|
|
731
|
+
return { kind: "wake", action: "delivered", pending: false, ...(typeof r?.instance === "string" ? { instance: r.instance } : {}), home: def.home, incarnation: incarnationOfHome(def.home) };
|
|
732
|
+
}
|
|
605
733
|
catch (e) { return { kind: "wake", action: "skipped", reason: `input refused: ${e.message}`, pending: true }; }
|
|
606
734
|
}
|
|
607
735
|
|
|
@@ -887,20 +1015,52 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
|
|
|
887
1015
|
|
|
888
1016
|
// -------------------------------------------------------- definitions CRUD
|
|
889
1017
|
|
|
1018
|
+
export const SCHEDULE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
1019
|
+
function incarnationOfHome(home) { try { const m = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); return typeof m?.createdAt === "string" ? m.createdAt : null; } catch { return null; } }
|
|
1020
|
+
/** K8b — session PROVENANCE of a run (facts the recorder had), never a
|
|
1021
|
+
* transcript: there is no reader behind this block. */
|
|
1022
|
+
function sessionProvenanceOf(r) {
|
|
1023
|
+
if (!r || typeof r !== "object") return null;
|
|
1024
|
+
const delivery = r.startedRuntime ? "launched" : r.action === "delivered" || r.outcome === "delivered" || r.completedPending ? "delivered-active" : r.launched === true || r.instance ? "launched" : "none";
|
|
1025
|
+
return { instance: typeof r.instance === "string" ? r.instance : null, home: typeof r.home === "string" ? r.home : null, incarnation: typeof r.incarnation === "string" ? r.incarnation : null, server: typeof r.server === "string" ? r.server : null, delivery };
|
|
1026
|
+
}
|
|
1027
|
+
/** Read-time projection of one job's stored history: bounded to
|
|
1028
|
+
* RECENT_RUNS_MAX, legacy rows marked, corruption isolated to the job. */
|
|
1029
|
+
function projectHistory(js) {
|
|
1030
|
+
const stored = js?.recentRuns;
|
|
1031
|
+
if (stored === undefined || stored === null) return { history: { status: "ok", stored: 0, truncated: false }, recentRuns: [] };
|
|
1032
|
+
if (!Array.isArray(stored)) return { history: { status: "corrupt", stored: null, truncated: false }, recentRuns: [] };
|
|
1033
|
+
const rows = [];
|
|
1034
|
+
for (const r of stored.slice(0, RECENT_RUNS_MAX)) {
|
|
1035
|
+
if (!r || typeof r !== "object") { rows.push({ runId: null, legacy: true, corrupt: true }); continue; }
|
|
1036
|
+
const { key, ...rest } = r;
|
|
1037
|
+
const legacy = typeof r.runId !== "string";
|
|
1038
|
+
rows.push({ ...rest, runId: legacy ? null : r.runId, legacy, ...(legacy ? { settled: null, transitions: null } : {}), session: sessionProvenanceOf(rest) });
|
|
1039
|
+
}
|
|
1040
|
+
return { history: { status: "ok", stored: stored.length, truncated: stored.length > RECENT_RUNS_MAX }, recentRuns: rows };
|
|
1041
|
+
}
|
|
890
1042
|
export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
|
|
1043
|
+
if (typeof id !== "string" || !SCHEDULE_ID_RE.test(id)) throw scheduleError("E_BAD_ARGS", `schedule id must match ${SCHEDULE_ID_RE} (got ${JSON.stringify(id)})`);
|
|
891
1044
|
defs ||= readDefinitions(ws); st ||= readState(ws);
|
|
892
1045
|
const def = defs.jobs[id];
|
|
893
1046
|
if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
1047
|
+
// Subject truth: the definition's own id must be the key it is stored under.
|
|
1048
|
+
if (def.id !== undefined && def.id !== id) throw scheduleError("E_SCHEDULE_IDENTITY", `schedule stored under ${JSON.stringify(id)} declares id ${JSON.stringify(def.id)}`, { key: id, declared: def.id });
|
|
894
1049
|
const js = st.jobs[id] || {};
|
|
895
1050
|
let nextRun = null; try { nextRun = def.enabled ? nextRunAfter(def, now) : null; } catch { nextRun = null; }
|
|
896
1051
|
const lock = jobLockInfo(ws, id);
|
|
897
1052
|
const intent = js.attempt || (lock ? (js.lastRun?.execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION, execution: js.lastRun.execution } : {}) : undefined);
|
|
898
|
-
const
|
|
899
|
-
return { ...def, scheduleApi: 2, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun
|
|
1053
|
+
const { history, recentRuns } = projectHistory(js);
|
|
1054
|
+
return { ...def, id, scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun ? { ...js.lastRun, runId: runIdOf(js.lastRun), session: sessionProvenanceOf(js.lastRun) } : null, history, recentRuns, running: !!lock, ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
|
|
900
1055
|
}
|
|
901
1056
|
export function listSchedules(ws, io, { now = new Date() } = {}) {
|
|
902
1057
|
const defs = readDefinitions(ws), st = readState(ws);
|
|
903
|
-
|
|
1058
|
+
// One job's bad identity or history does not fail the others: it is reported as its own row.
|
|
1059
|
+
const schedules = Object.keys(defs.jobs).sort().map((id) => {
|
|
1060
|
+
try { return describe(ws, id, io, { defs, st, now }); }
|
|
1061
|
+
catch (e) { return { id, scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, unreadable: { code: e.code || "E_SCHEDULE_INVALID", message: e.message }, history: { status: "corrupt", stored: null, truncated: false }, recentRuns: [] }; }
|
|
1062
|
+
});
|
|
1063
|
+
return { scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, integrity: scheduleIntegrity(ws), schedules, scheduler: schedulerStatus(ws, io) };
|
|
904
1064
|
}
|
|
905
1065
|
export function addSchedule(ws, spec, io) {
|
|
906
1066
|
const def = validateDefinition(ws, spec);
|