@awebai/oats 0.24.7 → 0.24.9
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 +233 -20
- package/capabilities/oats-okf/bin/oats-okf.mjs +3 -3
- package/capabilities/oats-okf/lib/binding-wire.mjs +35 -8
- package/capabilities/oats-okf/lib/config.mjs +1 -0
- package/capabilities/oats-okf/lib/sources.mjs +27 -2
- package/capabilities/oats-okf/lib/worker.mjs +5 -2
- package/capabilities/oats-okf/oats.json +29 -6
- package/docs/design/2026-09-20-redesign-program-board.md +2 -2
- package/docs/design/2026-09-22-desktop-parity-seams.md +4 -4
- package/docs/desktop-cli-api.md +392 -3
- package/docs/release-notes/v0.24.7.md +2 -2
- package/docs/release-notes/v0.24.8.md +107 -0
- package/docs/release-notes/v0.24.9.md +67 -0
- package/docs/workspace-adoption.md +4 -1
- package/docs/workspaces.md +1 -1
- package/lib/core.mjs +263 -21
- package/lib/instance-events.mjs +76 -0
- package/lib/instance-git.mjs +21 -0
- package/lib/instance-lifecycle.mjs +181 -0
- package/lib/portable-soul.mjs +5 -1
- package/lib/process-group.mjs +12 -0
- package/lib/readiness.mjs +225 -0
- package/lib/schedule.mjs +22 -3
- package/package-catalog.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/** K3 — lifecycle plans for the Desktop's Stop and Remove confirmations.
|
|
2
|
+
*
|
|
3
|
+
* A plan is a read-only statement of what an action WOULD touch, with the
|
|
4
|
+
* facts a human needs to decide (session state, children, dirty work) and a
|
|
5
|
+
* `planRevision` hashed from the facts that make the action safe. Apply
|
|
6
|
+
* takes that revision back: if reality moved, apply refuses with the fresh
|
|
7
|
+
* plan instead of acting on a world the human did not see. Idempotency keys
|
|
8
|
+
* make a retried apply return the first receipt rather than act twice.
|
|
9
|
+
* Unknown is reported as unknown — never as "clean" or "no children". */
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { basename, join } from "node:path";
|
|
13
|
+
import { findInstanceHomes, inspectInstanceSession, listAgents, listInstances, retirePendingMarkerPath, stopInstanceSession, teamAgentRoots, resolveOatsConfig } from "./core.mjs";
|
|
14
|
+
import { observeInstanceGit } from "./instance-git.mjs";
|
|
15
|
+
import { appendEvent } from "./instance-events.mjs";
|
|
16
|
+
import { oatsError } from "./errors.mjs";
|
|
17
|
+
|
|
18
|
+
export const LIFECYCLE_API = 1;
|
|
19
|
+
const KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
20
|
+
const stopPendingPath = (home) => join(home, ".oats-stop-pending.json");
|
|
21
|
+
const stopReceiptPath = (home, key) => join(home, `.oats-stop-receipt.${key}.json`);
|
|
22
|
+
|
|
23
|
+
function readJson(path) { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; } }
|
|
24
|
+
function planRevision(facts) { return createHash("sha256").update(JSON.stringify(facts)).digest("hex").slice(0, 24); }
|
|
25
|
+
|
|
26
|
+
/** Resolve `<name>` under the scope's agents roots to exactly one home. */
|
|
27
|
+
export function resolveInstance(ctx, root, name, { home } = {}) {
|
|
28
|
+
let r; try { r = resolveOatsConfig(ctx); } catch { r = {}; }
|
|
29
|
+
const roots = [...new Set([root, ...(r.team ? teamAgentRoots(r.team.scope) : [])])];
|
|
30
|
+
const candidates = [];
|
|
31
|
+
for (const rt of roots) for (const hit of findInstanceHomes(rt, name)) candidates.push({ root: rt, agent: hit.agent?.name ?? null, home: hit.home });
|
|
32
|
+
if (home !== undefined) {
|
|
33
|
+
const found = candidates.find((c) => c.home === home);
|
|
34
|
+
if (!found) throw oatsError("E_HOME_MISMATCH", `--home ${home} is not a home of instance ${JSON.stringify(name)} under ${roots.join(", ")}`);
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
37
|
+
if (!candidates.length) throw oatsError("E_SESSION_UNKNOWN", `no instance ${JSON.stringify(name)} under ${roots.join(", ")}`);
|
|
38
|
+
if (candidates.length > 1) throw Object.assign(oatsError("E_AMBIGUOUS_INSTANCE", `instance ${JSON.stringify(name)} has ${candidates.length} homes; pass --home <abs>`), { candidates });
|
|
39
|
+
return candidates[0];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every instance under the roots whose recorded parent chain reaches `name`,
|
|
43
|
+
* deepest first (children are acted on before their parent). Recorded
|
|
44
|
+
* parentage is the only relation the kernel knows; it is reported as such. */
|
|
45
|
+
export function descendantsOf(root, name) {
|
|
46
|
+
const rows = [];
|
|
47
|
+
for (const a of listAgents(root)) for (const i of listInstances(root).filter((x) => x.name === a.name)) for (const inst of i.instances || []) {
|
|
48
|
+
const home = join(a._dir, "instances", inst.instance);
|
|
49
|
+
const meta = readJson(join(home, "instance.json"));
|
|
50
|
+
if (meta) rows.push({ instance: inst.instance, agent: a.name, home, parent: meta.parentInstance ?? null });
|
|
51
|
+
}
|
|
52
|
+
// Recorded parentage is a BARE name, and names are unique only per agent
|
|
53
|
+
// directory. An edge whose parent name resolves to several homes under this
|
|
54
|
+
// root is ambiguous: it is reported, never acted on (nothing is inferred).
|
|
55
|
+
const homesByName = new Map();
|
|
56
|
+
for (const r of rows) { if (!homesByName.has(r.instance)) homesByName.set(r.instance, []); homesByName.get(r.instance).push(r); }
|
|
57
|
+
const byParent = new Map();
|
|
58
|
+
const ambiguous = [];
|
|
59
|
+
for (const r of rows) {
|
|
60
|
+
if (r.parent && (homesByName.get(r.parent) || []).length > 1) { ambiguous.push({ ...r, reason: `parent name ${JSON.stringify(r.parent)} resolves to ${homesByName.get(r.parent).length} homes; edge not followed` }); continue; }
|
|
61
|
+
if (!byParent.has(r.parent)) byParent.set(r.parent, []); byParent.get(r.parent).push(r);
|
|
62
|
+
}
|
|
63
|
+
const out = [];
|
|
64
|
+
const walk = (parent, depth) => { for (const c of byParent.get(parent) || []) { if (c.instance === parent) continue; walk(c.instance, depth + 1); out.push({ ...c, depth }); } };
|
|
65
|
+
walk(name, 1);
|
|
66
|
+
out.ambiguous = ambiguous.filter((r) => r.parent === name); // edges pointing at THIS name that could not be followed
|
|
67
|
+
return out; // deepest first
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `state` is the session backend's word: "shell"/"stopped"/"not-launched" are
|
|
71
|
+
* idle; "unknown" means a NON-shell process is running whose identity tmux
|
|
72
|
+
* cannot name (the ordinary case for a launched harness). "Could not be
|
|
73
|
+
* established" is a different thing and is `present: null` with a reason. */
|
|
74
|
+
function sessionFacts(home) {
|
|
75
|
+
try { const s = inspectInstanceSession(home); return { state: s.state, present: s.present, backend: s.backend ?? null, established: true }; }
|
|
76
|
+
catch (e) { return { state: "unestablished", present: null, backend: null, established: false, reason: e.code || "E_SESSION_UNAVAILABLE" }; }
|
|
77
|
+
}
|
|
78
|
+
const running = (s) => s.established && s.present === true && !["shell", "stopped", "not-launched"].includes(s.state);
|
|
79
|
+
function workFacts(home) {
|
|
80
|
+
if (!existsSync(join(home, "work"))) return { observed: false, reason: "no-worktree" };
|
|
81
|
+
try {
|
|
82
|
+
const g = observeInstanceGit(home);
|
|
83
|
+
return { observed: true, revision: g.observation.revision, branch: g.observation.branch, detached: g.observation.detached, drift: g.recorded.drift,
|
|
84
|
+
changed: g.summary.changed + g.summary.renamed + g.summary.copied + g.summary.unmerged, untracked: g.summary.untracked,
|
|
85
|
+
upstream: g.upstream, base: { ref: g.base.ref, ahead: g.base.ahead, behind: g.base.behind }, remote: g.remote ? { host: g.remote.host, path: g.remote.path } : null };
|
|
86
|
+
} catch (e) { return { observed: false, reason: e.code || "E_GIT_FAILED" }; }
|
|
87
|
+
}
|
|
88
|
+
function targetFacts(row) {
|
|
89
|
+
const meta = readJson(join(row.home, "instance.json")) || {};
|
|
90
|
+
return { instance: row.instance, agent: row.agent, home: row.home, depth: row.depth ?? 0, workMode: meta.work ?? null, launched: meta.launched === true,
|
|
91
|
+
session: sessionFacts(row.home), work: workFacts(row.home),
|
|
92
|
+
retiring: existsSync(retirePendingMarkerPath(row.home)), stopPending: existsSync(stopPendingPath(row.home)) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Facts for Stop: the instance, its recorded descendants (deepest first),
|
|
96
|
+
* each with session state and dirty-work counts. `midTask` is REPORTED
|
|
97
|
+
* activity: dirty work or a running session; unknown says unknown. */
|
|
98
|
+
export function planStop(ctx, root, name, { home, recursive = true } = {}) {
|
|
99
|
+
const me = resolveInstance(ctx, root, name, { home });
|
|
100
|
+
const kids = recursive ? descendantsOf(me.root, name) : [];
|
|
101
|
+
const targets = [...kids.map(targetFacts), targetFacts({ ...me, instance: name, depth: 0 })];
|
|
102
|
+
// Reported activity: a running session or dirty work is mid-task; when the
|
|
103
|
+
// session could not be established and work is not observed, say unknown.
|
|
104
|
+
for (const t of targets) t.midTask = running(t.session) || (t.work.observed && t.work.changed + t.work.untracked > 0) ? true : (!t.session.established || !t.work.observed) ? "unknown" : false;
|
|
105
|
+
const safety = targets.map((t) => [t.home, t.session.state, t.session.present, t.launched, t.retiring]);
|
|
106
|
+
const ambiguousEdges = (kids.ambiguous || []).map((c) => ({ instance: c.instance, agent: c.agent, home: c.home, reason: c.reason }));
|
|
107
|
+
const plan = { lifecycleApi: LIFECYCLE_API, action: "stop", instance: name, home: me.home, recursive, at: new Date().toISOString(),
|
|
108
|
+
targets, skipped: recursive ? [] : descendantsOf(me.root, name).map((c) => ({ instance: c.instance, agent: c.agent, home: c.home, reason: "recursive=false" })),
|
|
109
|
+
ambiguous: ambiguousEdges,
|
|
110
|
+
planRevision: planRevision(safety),
|
|
111
|
+
notes: [...(ambiguousEdges.length ? [`${ambiguousEdges.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`] : []),
|
|
112
|
+
...(targets.some((t) => t.retiring) ? ["a target is being retired; apply will refuse it"] : []), ...(targets.some((t) => !t.session.established) ? ["a session state could not be established; it is reported unestablished, not idle"] : [])] };
|
|
113
|
+
return plan;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Apply a Stop plan: revalidate the revision, take the stop-pending marker
|
|
117
|
+
* per target (refusing retiring targets), stop deepest-first, record a
|
|
118
|
+
* receipt keyed by idempotency key. A retried apply with the same key
|
|
119
|
+
* returns the recorded receipt without acting again. */
|
|
120
|
+
export function applyStop(ctx, root, name, { home, recursive = true, planRevision: expected, idempotencyKey, graceMs } = {}) {
|
|
121
|
+
if (typeof expected !== "string" || !expected) throw oatsError("E_BAD_ARGS", "apply needs --plan-revision from a prior `--plan`");
|
|
122
|
+
if (typeof idempotencyKey !== "string" || !KEY.test(idempotencyKey)) throw oatsError("E_BAD_ARGS", "apply needs --idempotency-key (1-128 chars of [A-Za-z0-9._:-])");
|
|
123
|
+
const plan = planStop(ctx, root, name, { home, recursive });
|
|
124
|
+
const prior = readJson(stopReceiptPath(plan.home, idempotencyKey));
|
|
125
|
+
if (prior && prior.idempotencyKey === idempotencyKey) return { ...prior, replayed: true };
|
|
126
|
+
if (plan.planRevision !== expected) throw Object.assign(oatsError("E_PLAN_STALE", `the stop plan changed since it was shown (${expected} → ${plan.planRevision}); review the fresh plan`), { plan });
|
|
127
|
+
const retiring = plan.targets.filter((t) => t.retiring);
|
|
128
|
+
if (retiring.length) throw Object.assign(oatsError("E_INSTANCE_RETIRING", `${retiring.map((t) => t.instance).join(", ")} ${retiring.length === 1 ? "is" : "are"} being retired; nothing was stopped`), { plan });
|
|
129
|
+
const pending = plan.targets.filter((t) => t.stopPending);
|
|
130
|
+
if (pending.length) throw Object.assign(oatsError("E_LIFECYCLE_BUSY", `${pending.map((t) => t.instance).join(", ")} already ${pending.length === 1 ? "has" : "have"} a stop in progress`), { plan });
|
|
131
|
+
const results = [];
|
|
132
|
+
const marker = { action: "stop", idempotencyKey, planRevision: expected, at: new Date().toISOString() };
|
|
133
|
+
for (const t of plan.targets) { try { writeFileSyncAtomic(stopPendingPath(t.home), marker); } catch { /* best effort marker; the lock is advisory across our own apply paths */ } }
|
|
134
|
+
try {
|
|
135
|
+
for (const t of plan.targets) {
|
|
136
|
+
try {
|
|
137
|
+
const r = stopInstanceSession(t.home, { graceMs });
|
|
138
|
+
results.push({ instance: t.instance, home: t.home, ok: true, stopped: r.stopped, alreadyIdle: r.alreadyIdle, state: r.state });
|
|
139
|
+
} catch (e) {
|
|
140
|
+
results.push({ instance: t.instance, home: t.home, ok: false, code: e.code || "E_SESSION_STOP_FAILED", message: e.message, stillRunning: e.receipt?.stillRunning ?? null });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} finally { for (const t of plan.targets) rmSync(stopPendingPath(t.home), { force: true }); }
|
|
144
|
+
const receipt = { lifecycleApi: LIFECYCLE_API, action: "stop", instance: name, home: plan.home, idempotencyKey, planRevision: expected, at: new Date().toISOString(),
|
|
145
|
+
ok: results.every((r) => r.ok), results, retained: ["home", "work", "transcript", "launch"], replayed: false };
|
|
146
|
+
// One receipt file per key: a retry of ANY earlier key replays its own
|
|
147
|
+
// receipt for as long as the home exists (the replay horizon).
|
|
148
|
+
try { writeFileSyncAtomic(stopReceiptPath(plan.home, idempotencyKey), receipt); } catch { /* receipt is evidence, not authority */ }
|
|
149
|
+
return receipt;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function writeFileSyncAtomic(path, value) {
|
|
153
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
154
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2)); renameSync(tmp, path);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Facts for Remove (retire): what retirement would touch, with the design's
|
|
158
|
+
* defaults (retain worktree and branch; never touch a PR). `oats retire`
|
|
159
|
+
* itself applies them: plain retire re-homes the worktree; --discard-worktree
|
|
160
|
+
* / --delete-branch are the dialog's opt-ins. */
|
|
161
|
+
export function planRetire(ctx, root, name, { home } = {}) {
|
|
162
|
+
const me = resolveInstance(ctx, root, name, { home });
|
|
163
|
+
const kids = descendantsOf(me.root, name);
|
|
164
|
+
const target = targetFacts({ ...me, instance: name, depth: 0 });
|
|
165
|
+
const meta = readJson(join(me.home, "instance.json")) || {};
|
|
166
|
+
const facts = { session: target.session, work: target.work, workMode: meta.work ?? null, repo: meta.repo ?? null, recordedBranch: meta.branch ?? null,
|
|
167
|
+
children: kids.map((c) => ({ instance: c.instance, agent: c.agent, home: c.home, session: sessionFacts(c.home) })),
|
|
168
|
+
ambiguous: (kids.ambiguous || []).map((c) => ({ instance: c.instance, agent: c.agent, home: c.home, reason: c.reason })),
|
|
169
|
+
pullRequest: "unknown" /* forge facts are the ADE's (P1); the kernel never claims 'no PR' */ };
|
|
170
|
+
const defaults = { retainWorktree: meta.work === "worktree", deleteBranch: false, stopChildren: true, retainChildren: true };
|
|
171
|
+
const safety = [me.home, target.session.state, target.launched, facts.work.observed ? [facts.work.revision, facts.work.branch, facts.work.changed, facts.work.untracked] : null, kids.map((c) => c.home)];
|
|
172
|
+
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 });
|
|
173
|
+
return { lifecycleApi: LIFECYCLE_API, action: "retire", instance: name, home: me.home, at: new Date().toISOString(), facts, defaults, planRevision: planRevision(safety),
|
|
174
|
+
notes: [
|
|
175
|
+
...(facts.work.observed && facts.work.drift ? [`the worktree is on ${facts.work.branch}, not the recorded ${facts.recordedBranch}; branch actions use the worktree's branch`] : []),
|
|
176
|
+
...(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`] : []),
|
|
177
|
+
...(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`] : []),
|
|
178
|
+
...((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`] : []),
|
|
179
|
+
"pull request state is unknown to the kernel; the ADE reports it when a forge connection exists",
|
|
180
|
+
] };
|
|
181
|
+
}
|
package/lib/portable-soul.mjs
CHANGED
|
@@ -9,7 +9,7 @@ export { FUNDAMENTAL_SLOTS } from "./portable-policy.mjs";
|
|
|
9
9
|
const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
10
10
|
const ALIAS = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
11
11
|
const LAUNCH_CONFIG_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
12
|
-
const ROOT_FIELDS = ["schemaVersion", "name", "description", "requires", "defaults", "knowledge", "teams", "resources", "work", "runtime", "model", "yolo", "launch-config", "backend"];
|
|
12
|
+
const ROOT_FIELDS = ["schemaVersion", "name", "description", "requires", "defaults", "knowledge", "teams", "resources", "work", "runtime", "model", "yolo", "launch-config", "backend", "children"];
|
|
13
13
|
|
|
14
14
|
export function parsePortableSoul(input, { origin = null, localBase, allowLocalPaths = false, limits } = {}) {
|
|
15
15
|
const parsed = parseConfigData(input, { origin, limits });
|
|
@@ -34,5 +34,9 @@ export function parsePortableSoul(input, { origin = null, localBase, allowLocalP
|
|
|
34
34
|
if (Object.hasOwn(value, "launch-config")) stringAt(value["launch-config"], "/launch-config", { pattern: LAUNCH_CONFIG_NAME });
|
|
35
35
|
if (Object.hasOwn(value, "backend") && !["tmux", "herdr"].includes(value.backend)) invalidShape("/backend", "unsupported session backend");
|
|
36
36
|
if (Object.hasOwn(value, "yolo") && typeof value.yolo !== "boolean") invalidShape("/yolo", "expected boolean");
|
|
37
|
+
if (Object.hasOwn(value, "children")) {
|
|
38
|
+
const children = objectAt(value.children, ["spawn"], [], "/children");
|
|
39
|
+
if (Object.hasOwn(children, "spawn") && typeof children.spawn !== "boolean") invalidShape("/children/spawn", "expected boolean");
|
|
40
|
+
}
|
|
37
41
|
return { declaration: value, sources, origins: parsed.origins, integrity: parsed.integrity };
|
|
38
42
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Kill a detached child's whole process group (e.g. git + its remote helper /
|
|
2
|
+
* ssh, or a runtime probe + what it forked). ONLY for a child that was actually
|
|
3
|
+
* spawned: a failed spawn (ENOENT) reports `pid: 0`, and `process.kill(-0)` /
|
|
4
|
+
* `process.kill(0)` address the CALLER's own process group — the operator's
|
|
5
|
+
* shell, tmux session or Desktop backend. Returns whether anything was signalled. */
|
|
6
|
+
export function killGroup(child, signal = "SIGKILL") {
|
|
7
|
+
const pid = child?.pid;
|
|
8
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
9
|
+
try { process.kill(-pid, signal); } catch { /* already gone */ }
|
|
10
|
+
try { process.kill(pid, signal); } catch { /* already gone */ }
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/** K5 — readiness quartet, signature status and enforced policy, as DATA.
|
|
2
|
+
*
|
|
3
|
+
* `installed | trusted | configured | enrolled`, each `pass | fail | unknown |
|
|
4
|
+
* not-applicable` with items a human can act on: subject, whether it is
|
|
5
|
+
* required, the reason, the producer of the fact, evidence, remedy. Every fact
|
|
6
|
+
* is derived from what inspect already reports (capability inventory, health,
|
|
7
|
+
* executable approval, activation, runtime-package requirements, soul
|
|
8
|
+
* declarations) — never a second opinion. Unknown is unknown; "Ready" is the
|
|
9
|
+
* consumer's word and only when every required check passes. */
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { killGroup } from "./process-group.mjs";
|
|
12
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { parseYamlNested } from "./core.mjs";
|
|
16
|
+
|
|
17
|
+
export const READINESS_API = 1;
|
|
18
|
+
|
|
19
|
+
function roll(items, { required = (i) => i.required !== false } = {}) {
|
|
20
|
+
const req = items.filter(required);
|
|
21
|
+
if (!items.length) return "not-applicable";
|
|
22
|
+
if (req.some((i) => i.status === "fail")) return "fail";
|
|
23
|
+
if (req.some((i) => i.status === "unknown")) return "unknown";
|
|
24
|
+
if (req.every((i) => i.status === "pass" || i.status === "not-applicable")) return req.length ? "pass" : "not-applicable";
|
|
25
|
+
return "unknown";
|
|
26
|
+
}
|
|
27
|
+
const item = (subject, status, { required = true, reason = null, producer = "kernel", evidence = null, remedy = null, ...rest } = {}) =>
|
|
28
|
+
({ subject, status, required, reason, producer, evidence, remedy, ...rest });
|
|
29
|
+
|
|
30
|
+
/** Verified Git signature of a source commit, named signer or nothing.
|
|
31
|
+
* Requires network (fetch) — only when the caller asks (`verify: true`);
|
|
32
|
+
* otherwise `unknown` with the reason. Never a URL, owner or hash as signer. */
|
|
33
|
+
export const SIGNATURE_FAILURES = Object.freeze(["transport-not-allowed", "fetch-failed", "fetch-timeout", "budget-exhausted", "verifier-failed", "verifier-timeout", "cannot-check", "no-source"]);
|
|
34
|
+
const ALLOWED_TRANSPORTS = /^(https:\/\/|ssh:\/\/|git@[^/:]+:)/;
|
|
35
|
+
/** Verified Git signature of a source commit, named signer or nothing.
|
|
36
|
+
* Requires network (fetch) — only when the caller asks (`verify: true`);
|
|
37
|
+
* otherwise `unknown` with the reason. Never a URL, owner or hash as signer.
|
|
38
|
+
* Bounded custody: one total budget per call (`budgetMs`, default 60s) shared
|
|
39
|
+
* by fetch and verify; each Git child runs in its own process group and is
|
|
40
|
+
* killed with the group on timeout; the scratch repository is removed on every
|
|
41
|
+
* exit including signals; Git reads NO global/system config and cannot prompt;
|
|
42
|
+
* only https/ssh transports are fetched. Failures carry a closed `failure`
|
|
43
|
+
* code — never stderr. */
|
|
44
|
+
/** One verification budget for a whole readiness read: every signatureOf call
|
|
45
|
+
* in that read draws from it, so a target with N capabilities is bounded by the
|
|
46
|
+
* TOTAL (default 120 s), not N × per-call. */
|
|
47
|
+
export function verificationBudget(totalMs = 120000) {
|
|
48
|
+
const started = Date.now();
|
|
49
|
+
return { totalMs, remaining: () => totalMs - (Date.now() - started) };
|
|
50
|
+
}
|
|
51
|
+
const scratchDirs = new Set();
|
|
52
|
+
let signalCleanupInstalled = false;
|
|
53
|
+
function installSignalCleanup() {
|
|
54
|
+
if (signalCleanupInstalled) return; signalCleanupInstalled = true;
|
|
55
|
+
const sweep = () => { for (const d of scratchDirs) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } } scratchDirs.clear(); };
|
|
56
|
+
process.once("exit", sweep);
|
|
57
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.once(sig, () => { sweep(); process.exit(128 + (sig === "SIGINT" ? 2 : sig === "SIGTERM" ? 15 : 1)); });
|
|
58
|
+
}
|
|
59
|
+
export function signatureOf({ url, commit }, { verify = false, budgetMs = 60000, budget = null } = {}) {
|
|
60
|
+
if (!url || !commit || commit === "local") return { status: "not-applicable", signer: null, reason: commit === "local" ? "path-installed capability has no source commit" : "no source recorded", failure: null };
|
|
61
|
+
if (!verify) return { status: "unknown", signer: null, reason: "signature verification needs a network fetch; pass --verify-signatures", failure: null };
|
|
62
|
+
if (!ALLOWED_TRANSPORTS.test(url)) return { status: "unknown", signer: null, reason: "source transport is not https or ssh; not fetched", failure: { code: "transport-not-allowed" } };
|
|
63
|
+
const shared = budget ?? verificationBudget(budgetMs);
|
|
64
|
+
if (shared.remaining() <= 0) return { status: "unknown", signer: null, reason: "the verification budget was exhausted", failure: { code: "budget-exhausted" } };
|
|
65
|
+
installSignalCleanup();
|
|
66
|
+
const dir = mkdtempSync(join(tmpdir(), "oats-sig-")); scratchDirs.add(dir);
|
|
67
|
+
const cleanup = () => { scratchDirs.delete(dir); try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } };
|
|
68
|
+
const env = { PATH: process.env.PATH ?? "", HOME: dir, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_ASKPASS: "/bin/false", SSH_ASKPASS: "/bin/false", GIT_SSH_COMMAND: "ssh -o BatchMode=yes", LC_ALL: "C" };
|
|
69
|
+
const git = (args, stage) => {
|
|
70
|
+
const left = shared.remaining();
|
|
71
|
+
if (left <= 0) throw Object.assign(new Error("budget"), { failure: "budget-exhausted" });
|
|
72
|
+
const child = spawnSync("git", ["-C", dir, "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "-c", "protocol.ssh.allow=always", ...args],
|
|
73
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: left, killSignal: "SIGKILL", detached: true, env, shell: false });
|
|
74
|
+
if (child.error?.code === "ETIMEDOUT" || child.signal === "SIGKILL") { killGroup(child); throw Object.assign(new Error(stage), { failure: `${stage}-timeout` }); }
|
|
75
|
+
if (child.error || child.status !== 0) { killGroup(child); throw Object.assign(new Error(stage), { failure: `${stage}-failed` }); }
|
|
76
|
+
return child.stdout;
|
|
77
|
+
};
|
|
78
|
+
try {
|
|
79
|
+
git(["init", "-q"], "verifier");
|
|
80
|
+
git(["fetch", "-q", "--depth", "1", url, commit], "fetch");
|
|
81
|
+
// %G? : G good, B bad, U good-untrusted, X expired, Y expired key, R revoked, E cannot check, N none
|
|
82
|
+
const out = git(["log", "-1", "--format=%G?%x00%GS%x00%GK%x00%GF", commit], "verifier").trim();
|
|
83
|
+
const [code, signerName, keyId, fingerprint] = out.split("\0");
|
|
84
|
+
if (code === "N") return { status: "unsigned", signer: null, reason: "commit carries no signature", failure: null };
|
|
85
|
+
if (code === "G") return { status: "verified", signer: { id: fingerprint || keyId || null, label: signerName || null }, reason: null, failure: null };
|
|
86
|
+
if (code === "U") return { status: "verified", signer: { id: fingerprint || keyId || null, label: signerName || null }, reason: "good signature from a key not marked trusted in the local keyring", trust: "untrusted-key", failure: null };
|
|
87
|
+
if (code === "E") return { status: "unknown", signer: null, reason: "signature present but cannot be checked (missing public key)", failure: { code: "cannot-check" } };
|
|
88
|
+
return { status: "invalid", signer: null, reason: { B: "bad signature", X: "good signature that has expired", Y: "good signature made by an expired key", R: "good signature made by a revoked key" }[code] || "verifier reported an unrecognised state", failure: null };
|
|
89
|
+
} catch (e) {
|
|
90
|
+
const code = SIGNATURE_FAILURES.includes(e.failure) ? e.failure : "verifier-failed";
|
|
91
|
+
const reasons = { "fetch-failed": "the source could not be fetched", "fetch-timeout": "the fetch exceeded the verification budget", "budget-exhausted": "the verification budget was exhausted", "verifier-failed": "the local verifier failed", "verifier-timeout": "the local verifier exceeded the verification budget" };
|
|
92
|
+
return { status: "unknown", signer: null, reason: reasons[code], failure: { code } };
|
|
93
|
+
} finally { cleanup(); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sourceOfCapability(cap, catalog) {
|
|
97
|
+
// cap.source is e.g. "catalog:oats.framework" or "git:https://…@ref#path"; the
|
|
98
|
+
// installed record's package commit is what we would verify.
|
|
99
|
+
const src = String(cap.source || "");
|
|
100
|
+
if (src.startsWith("git:")) { const m = /^git:([^@#]+)(?:@([^#]+))?/.exec(src); return { url: m?.[1] ?? null, commit: cap.commit ?? m?.[2] ?? null }; }
|
|
101
|
+
if (src.startsWith("catalog:") && catalog) { const entry = catalog.packages?.[src.slice(8)]; return { url: entry?.url ?? null, commit: cap.commit ?? null }; }
|
|
102
|
+
return { url: null, commit: cap.commit ?? null };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The quartet for a scope or one soul, from an inspect result. */
|
|
106
|
+
export function readinessOf(inspect, { soul = null, verifySignatures = false, catalog = null, deploymentDir = null, memberDocument = null, selector = null, verificationBudgetMs = 120000 } = {}) {
|
|
107
|
+
const caps = inspect.capabilities || [];
|
|
108
|
+
const budget = verifySignatures ? verificationBudget(verificationBudgetMs) : null;
|
|
109
|
+
const soulEntry = soul ? (inspect.souls || []).find((s) => s.name === soul) : null;
|
|
110
|
+
const declaredRequires = soulEntry?.declarations?.requires?.capabilities ? Object.keys(soulEntry.declarations.requires.capabilities) : null;
|
|
111
|
+
const required = new Set(declaredRequires ?? caps.filter((c) => c.activation?.enabled).map((c) => c.id));
|
|
112
|
+
const declaredForSoul = (c) => !!c.activation?.declaredAt?.some((d) => (d.targets || []).includes(`soul:${soul}`));
|
|
113
|
+
const relevant = soul ? caps.filter((c) => required.has(c.id) || declaredForSoul(c)) : caps;
|
|
114
|
+
// Typed linkage for consumers (frame-level per-capability rows): WHICH
|
|
115
|
+
// capability, at which config level/scope, and WHY it is in this quartet.
|
|
116
|
+
const capabilityOf = (c) => ({ id: c.id, level: c.activation?.level ?? null, scope: c.activation?.target ?? null });
|
|
117
|
+
const originOf = (c) => declaredRequires?.includes(c.id) ? { kind: "requires", target: `soul:${soul}` }
|
|
118
|
+
: soul && declaredForSoul(c) ? { kind: "declares", target: `soul:${soul}` }
|
|
119
|
+
: c.activation?.enabled ? { kind: "default", target: c.activation?.target ?? "global" } : { kind: "inventory", target: null };
|
|
120
|
+
const typed = (c) => ({ capability: capabilityOf(c), origin: originOf(c) });
|
|
121
|
+
|
|
122
|
+
// installed — the artifact's bytes are present and locked with matching integrity
|
|
123
|
+
const installed = relevant.map((c) => {
|
|
124
|
+
const ok = c.health?.installed === true && (c.health.integrity == null || c.health.installedIntegrity == null || c.health.integrity === c.health.installedIntegrity);
|
|
125
|
+
return item(c.id, ok ? "pass" : c.health?.installed === false ? "fail" : "unknown", { required: required.has(c.id), producer: "oats list",
|
|
126
|
+
reason: ok ? null : c.health?.installed === false ? "not acquired" : c.health?.code || "integrity drift", evidence: { version: c.version ?? null, integrity: c.health?.integrity ?? null, origin: c.origin ?? null },
|
|
127
|
+
remedy: ok ? null : `oats install ${c.package || c.id}`, ...typed(c) });
|
|
128
|
+
});
|
|
129
|
+
for (const id of required) if (!caps.some((c) => c.id === id)) installed.push(item(id, "fail", { producer: "soul declaration", reason: "declared by the soul but not in the inventory", remedy: `oats install <package providing ${id}>`,
|
|
130
|
+
capability: { id, level: null, scope: null }, origin: { kind: "requires", target: `soul:${soul}` } }));
|
|
131
|
+
|
|
132
|
+
// trusted — executable approval of the exact artifact; signature separately
|
|
133
|
+
const trusted = relevant.map((c) => {
|
|
134
|
+
// The inspect row states whether the manifest has anything trust approves
|
|
135
|
+
// (commands/hooks/launch env). No surface → trust is not-applicable, however
|
|
136
|
+
// the lock records it. Older rows without the flag fall back to the old heuristic.
|
|
137
|
+
const executable = typeof c.health?.executableSurface === "boolean" ? c.health.executableSurface
|
|
138
|
+
: !!(c.operations?.length || c.health?.code === "untrusted-surface");
|
|
139
|
+
const approved = c.health?.trusted === true;
|
|
140
|
+
const sig = signatureOf(sourceOfCapability(c, catalog), { verify: verifySignatures, budget });
|
|
141
|
+
return item(c.id, !executable ? "not-applicable" : approved ? "pass" : c.health?.trusted === false ? "fail" : "unknown", { required: required.has(c.id), producer: "artifact approval",
|
|
142
|
+
reason: !executable ? "no executable surface" : approved ? null : "executable surface not approved", evidence: { integrity: c.health?.integrity ?? null }, remedy: approved || !executable ? null : `oats trust ${c.id}`,
|
|
143
|
+
signature: sig, ...typed(c) });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// configured — EFFECTIVE activation for the subject (a declaration at the
|
|
147
|
+
// soul is not activation: `enabled` is the resolved verdict for this subject,
|
|
148
|
+
// and a declared-but-disabled binding is a fail that says so) + runtime
|
|
149
|
+
// package requirements + layer readiness problems
|
|
150
|
+
const configured = [];
|
|
151
|
+
for (const c of relevant) {
|
|
152
|
+
const active = c.activation?.enabled === true;
|
|
153
|
+
const declaredOnly = !active && soul && declaredForSoul(c);
|
|
154
|
+
if (required.has(c.id)) configured.push(item(`${c.id} activation`, active ? "pass" : "fail", { producer: "oats-config.yaml",
|
|
155
|
+
reason: active ? null : declaredOnly ? `declared for soul ${soul} but disabled${c.activation?.reason ? ` (${c.activation.reason})` : ""}` : `not active for ${soul ? `soul ${soul}` : "this scope"}`,
|
|
156
|
+
evidence: { target: c.activation?.target ?? null, level: c.activation?.level ?? null, source: c.activation?.source ?? null }, remedy: active ? null : `oats use ${c.id}${soul ? ` --soul ${soul}` : ""}`, ...typed(c) }));
|
|
157
|
+
for (const miss of c.missingRequires || []) configured.push(item(`${c.id} requires ${miss.command}`, "fail", { producer: "capability manifest", reason: miss.why || "required command not on PATH", remedy: miss.install || null, ...typed(c) }));
|
|
158
|
+
}
|
|
159
|
+
for (const p of inspect.problems || []) if (/runtime package|DISABLED|extension/i.test(p.message || "")) configured.push(item(p.capability || p.code, "fail", { producer: "runtime settings", reason: p.message, remedy: null }));
|
|
160
|
+
if (soulEntry && soulEntry.readiness?.status === "undeclared") configured.push(item(`${soul} declarations`, "not-applicable", { required: false, producer: "soul.yaml", reason: "no requirements declared" }));
|
|
161
|
+
|
|
162
|
+
// enrolled — workspace member admission (decision §3): not-applicable for
|
|
163
|
+
// standalone; pass/fail when this repository declares a workspace.
|
|
164
|
+
const enrolled = [];
|
|
165
|
+
const member = memberDocument ?? readMemberDocument(deploymentDir);
|
|
166
|
+
if (!member) enrolled.push(item("workspace membership", "not-applicable", { required: false, producer: "oats.yaml", reason: "standalone deployment: no workspace declared in oats.yaml" }));
|
|
167
|
+
else if (member.unreadable) enrolled.push(item("workspace membership", "unknown", { producer: "oats.yaml", reason: "workspace member document is unreadable; membership cannot be stated", evidence: { file: member.file ?? null }, remedy: "repair oats.yaml (valid YAML) and re-run" }));
|
|
168
|
+
else if (!member.workspace?.source) enrolled.push(item("workspace membership", "not-applicable", { required: false, producer: "oats.yaml", reason: "oats.yaml declares exports but no workspace backlink" }));
|
|
169
|
+
else enrolled.push(item(`member of ${member.workspace.source}`, member.admitted === true ? "pass" : member.admitted === false ? "fail" : "unknown", { producer: "workspace discovery",
|
|
170
|
+
reason: member.admitted === true ? null : member.admitted === false ? "this repository is not admitted in the workspace's members" : "reciprocal admission not observed: this CLI reads the backlink but does not yet fetch the workspace's members (declared, not verified)",
|
|
171
|
+
evidence: { workspace: member.workspace.source, revision: member.workspace.revision ?? null }, remedy: member.admitted === true ? null : "ask the workspace maintainer to admit this repository (oats-workspace.yaml members) — enrolment is admission, not login" }));
|
|
172
|
+
|
|
173
|
+
const checks = { installed: { status: roll(installed), items: installed }, trusted: { status: roll(trusted), items: trusted }, configured: { status: roll(configured), items: configured }, enrolled: { status: roll(enrolled), items: enrolled } };
|
|
174
|
+
const requiredStatuses = Object.values(checks).flatMap((c) => c.items.filter((i) => i.required).map((i) => i.status));
|
|
175
|
+
// Per-capability grouping of the same items (no second observation): each
|
|
176
|
+
// capability's four verdicts, ready only if all its REQUIRED items pass.
|
|
177
|
+
const byCapability = [...new Set(Object.values(checks).flatMap((c) => c.items.map((i) => i.capability?.id).filter(Boolean)))].sort().map((id) => {
|
|
178
|
+
const of = (name) => checks[name].items.filter((i) => i.capability?.id === id);
|
|
179
|
+
const statuses = Object.keys(checks).flatMap((name) => of(name).filter((i) => i.required).map((i) => i.status));
|
|
180
|
+
const any = Object.keys(checks).flatMap((name) => of(name)).find(Boolean);
|
|
181
|
+
return { capability: any?.capability ?? { id, level: null, scope: null }, origin: any?.origin ?? null, required: statuses.length > 0,
|
|
182
|
+
checks: Object.fromEntries(Object.keys(checks).map((name) => [name, of(name).length ? roll(of(name)) : "not-applicable"])),
|
|
183
|
+
ownReady: statuses.length > 0 && statuses.every((s) => s === "pass" || s === "not-applicable") };
|
|
184
|
+
});
|
|
185
|
+
// Items that belong to no capability (workspace membership, soul declarations)
|
|
186
|
+
// are SUBJECT-level: a capability's own verdict never overrides them, and a
|
|
187
|
+
// row must not read "ready" while the subject is blocked by one of them.
|
|
188
|
+
const subjectBlockers = Object.entries(checks).flatMap(([name, c]) => c.items.filter((i) => i.required && !i.capability && i.status !== "pass" && i.status !== "not-applicable").map((i) => ({ check: name, subject: i.subject, status: i.status })));
|
|
189
|
+
for (const g of byCapability) g.ready = g.ownReady && subjectBlockers.length === 0;
|
|
190
|
+
return { readinessApi: READINESS_API, subject: { ...(soul ? { kind: "soul", name: soul } : { kind: "scope", context: inspect.scope?.context ?? null }), ...(selector ? { selector } : {}) }, at: new Date().toISOString(),
|
|
191
|
+
checks, summary: { ready: requiredStatuses.length > 0 && requiredStatuses.every((s) => s === "pass" || s === "not-applicable"), required: requiredStatuses.length,
|
|
192
|
+
pass: requiredStatuses.filter((s) => s === "pass").length, fail: requiredStatuses.filter((s) => s === "fail").length, unknown: requiredStatuses.filter((s) => s === "unknown").length, byCapability, subjectBlockers },
|
|
193
|
+
notes: [
|
|
194
|
+
...(verifySignatures ? [] : ["signatures are unknown until --verify-signatures (network fetch)"]),
|
|
195
|
+
"ready means every REQUIRED check passes; it is never inferred from an empty set",
|
|
196
|
+
"enrolment is workspace member admission, not native login or team registration",
|
|
197
|
+
] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function readMemberDocument(deploymentDir) {
|
|
201
|
+
if (!deploymentDir) return null;
|
|
202
|
+
const file = join(deploymentDir, "oats.yaml");
|
|
203
|
+
if (!existsSync(file)) return null;
|
|
204
|
+
try {
|
|
205
|
+
const doc = parseYamlNested(readFileSync(file, "utf8"));
|
|
206
|
+
// The lenient parser never throws: a `workspace:` that is present but not a
|
|
207
|
+
// mapping is a document we cannot read a membership from — unreadable, not absent.
|
|
208
|
+
if (doc.workspace !== undefined && (doc.workspace === null || typeof doc.workspace !== "object")) return { workspace: null, admitted: null, unreadable: true, file };
|
|
209
|
+
return { workspace: doc.workspace && typeof doc.workspace === "object" ? doc.workspace : null, admitted: null, exports: doc.exports ?? null };
|
|
210
|
+
} catch { return { workspace: null, admitted: null, unreadable: true, file }; }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Enforced policy for an instance (from its recorded metadata) or a soul
|
|
214
|
+
* (its declaration + default), with origins. Advisory/unknown stays unknown. */
|
|
215
|
+
export function policyOf({ instanceMeta = null, soul = null } = {}) {
|
|
216
|
+
const child = instanceMeta?.policy?.childSpawns
|
|
217
|
+
? { allowed: instanceMeta.policy.childSpawns.allowed === true, enforced: true, origin: instanceMeta.policy.childSpawns.origin ?? { kind: "recorded" } }
|
|
218
|
+
: instanceMeta ? { allowed: true, enforced: true, origin: { kind: "default", detail: "no recorded policy: children allowed (pre-0.24.8 instance)" } }
|
|
219
|
+
: soul?.declarations?.children && typeof soul.declarations.children.spawn === "boolean"
|
|
220
|
+
? { allowed: soul.declarations.children.spawn, enforced: false, origin: { kind: "soul", detail: "children.spawn in soul.yaml; enforced once an instance records it" } }
|
|
221
|
+
: { allowed: true, enforced: false, origin: { kind: "default", detail: "no declaration: children allowed" } };
|
|
222
|
+
const worktree = instanceMeta ? { allowed: instanceMeta.work === "worktree" || instanceMeta.work === "checkout", mode: instanceMeta.work ?? null, enforced: true, origin: { kind: "work-mode", detail: `work: ${instanceMeta.work}` } }
|
|
223
|
+
: soul ? { allowed: ["worktree", "checkout"].includes(soul.work), mode: soul.work ?? null, enforced: false, origin: { kind: "soul", detail: `work: ${soul.work}` } } : { allowed: null, mode: null, enforced: false, origin: { kind: "unknown" } };
|
|
224
|
+
return { readinessApi: READINESS_API, policy: { childSpawns: child, worktrees: worktree }, notes: ["a lifecycle-authority claim enforced by the spawn route, not an OS sandbox"] };
|
|
225
|
+
}
|
package/lib/schedule.mjs
CHANGED
|
@@ -145,7 +145,25 @@ export function readDefinitions(ws) {
|
|
|
145
145
|
}
|
|
146
146
|
export function writeDefinitions(ws, doc) { writeJson(definitionsPath(ws), doc); }
|
|
147
147
|
export function readState(ws) { const st = readJson(statePath(ws), { jobs: {} }); if (!st.jobs || typeof st.jobs !== "object") st.jobs = {}; return st; }
|
|
148
|
-
|
|
148
|
+
/** K8 — bounded run history. Every time a job's lastRun reaches a settled
|
|
149
|
+
* outcome (not "active"/"starting"), it is appended once to `recentRuns`
|
|
150
|
+
* (newest first, max 50), keyed by scheduledFor+startedAt so re-saves of the
|
|
151
|
+
* same run never duplicate it. Recording happens on save: producers keep
|
|
152
|
+
* writing lastRun exactly as they do; nothing is inferred. */
|
|
153
|
+
export const RECENT_RUNS_MAX = 50;
|
|
154
|
+
function recordRecentRuns(st) {
|
|
155
|
+
for (const js of Object.values(st.jobs || {})) {
|
|
156
|
+
const lr = js.lastRun;
|
|
157
|
+
if (!lr || typeof lr !== "object" || ["active", "starting"].includes(lr.outcome)) continue;
|
|
158
|
+
const key = `${lr.scheduledFor ?? ""}|${lr.startedAt ?? ""}|${lr.outcome ?? ""}`;
|
|
159
|
+
js.recentRuns ||= [];
|
|
160
|
+
if (js.recentRuns[0]?.key === key) { js.recentRuns[0] = { key, ...lr }; continue; } // same run, later fields
|
|
161
|
+
if (js.recentRuns.some((r) => r.key === key)) continue;
|
|
162
|
+
js.recentRuns.unshift({ key, ...lr });
|
|
163
|
+
if (js.recentRuns.length > RECENT_RUNS_MAX) js.recentRuns.length = RECENT_RUNS_MAX;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export function writeState(ws, st) { recordRecentRuns(st); writeJson(statePath(ws), st); }
|
|
149
167
|
|
|
150
168
|
// ------------------------------------------------------- host registry
|
|
151
169
|
|
|
@@ -712,7 +730,7 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
712
730
|
if (run.startedRuntime) js.lastLaunchedAt = now.toISOString();
|
|
713
731
|
if (!existsSync(def.home)) releaseJobLock(ws, id);
|
|
714
732
|
if (run.pending) js.pendingWake = { scheduledFor: minute.toISOString() }; else delete js.pendingWake;
|
|
715
|
-
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), home: def.home, ...run, outcome: run.action };
|
|
733
|
+
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), home: def.home, ...run, outcome: run.action, ...(run.instance ? { instance: run.instance } : {}) };
|
|
716
734
|
record(id, run.action, { ...(run.reason ? { reason: run.reason } : {}), pending: !!run.pending });
|
|
717
735
|
continue;
|
|
718
736
|
}
|
|
@@ -877,7 +895,8 @@ export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
|
|
|
877
895
|
let nextRun = null; try { nextRun = def.enabled ? nextRunAfter(def, now) : null; } catch { nextRun = null; }
|
|
878
896
|
const lock = jobLockInfo(ws, id);
|
|
879
897
|
const intent = js.attempt || (lock ? (js.lastRun?.execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION, execution: js.lastRun.execution } : {}) : undefined);
|
|
880
|
-
|
|
898
|
+
const recentRuns = (js.recentRuns || []).map(({ key, ...r }) => ({ ...r, ...(r.instance && r.home ? { transcript: { instance: r.instance, home: r.home, kind: "session" } } : {}) }));
|
|
899
|
+
return { ...def, scheduleApi: 2, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun || null, recentRuns, running: !!lock, ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
|
|
881
900
|
}
|
|
882
901
|
export function listSchedules(ws, io, { now = new Date() } = {}) {
|
|
883
902
|
const defs = readDefinitions(ws), st = readState(ws);
|
package/package-catalog.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.9",
|
|
4
4
|
"description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|