@awebai/oats 0.27.1 → 0.28.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 +185 -26
- package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +8 -3
- package/capabilities/oats-okf/bin/oats-okf.mjs +33 -28
- package/capabilities/oats-okf/injects/okf.md +29 -21
- package/capabilities/oats-okf/lib/config.mjs +5 -1
- package/capabilities/oats-okf/lib/consult.mjs +500 -0
- package/capabilities/oats-okf/lib/inspection.mjs +11 -3
- package/capabilities/oats-okf/lib/io.mjs +9 -2
- package/capabilities/oats-okf/lib/sources.mjs +15 -53
- package/capabilities/oats-okf/lib/stores.mjs +10 -7
- package/capabilities/oats-okf/lib/worker.mjs +9 -1
- package/capabilities/oats-okf/oats.json +13 -4
- package/capabilities/oats-okf/skills/okf/SKILL.md +14 -6
- package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +142 -0
- package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +86 -0
- package/docs/capabilities.md +3 -1
- package/docs/design/2026-09-24-phase-d-plan.md +11 -0
- package/docs/design/2026-09-26-desktop-design-brief-architecture.md +241 -0
- package/docs/design/2026-09-26-okf-knowledge-operations.md +389 -0
- package/docs/desktop-cli-api.md +99 -7
- package/docs/oats-local.schema.json +2 -1
- package/docs/oats-package.schema.json +39 -0
- package/docs/packages.md +67 -3
- package/docs/release-notes/v0.27.2.md +51 -0
- package/docs/release-notes/v0.28.0.md +144 -0
- package/docs/schedules.md +99 -1
- package/docs/souls-and-instances.md +19 -3
- package/docs/workspaces.md +8 -2
- package/lib/core.mjs +124 -10
- package/lib/instance-inspect.mjs +4 -4
- package/lib/instance-resolution.mjs +62 -18
- package/lib/materialize.mjs +13 -0
- package/lib/packages.mjs +90 -6
- package/lib/resolve.mjs +20 -2
- package/lib/schedule.mjs +24 -11
- package/lib/triggers.mjs +545 -0
- package/lib/workspace.mjs +80 -3
- package/package-catalog.json +1 -1
- package/package.json +1 -1
|
@@ -28,7 +28,7 @@ import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync, symlinkSync,
|
|
|
28
28
|
import { tmpdir } from "node:os";
|
|
29
29
|
import { spawnSync } from "node:child_process";
|
|
30
30
|
import { randomBytes } from "node:crypto";
|
|
31
|
-
import { readLock, LOCK_FILE, readPackageManifests } from "./packages.mjs";
|
|
31
|
+
import { readLock, LOCK_FILE, readPackageManifests, SOUL_ALIAS_SYMLINK } from "./packages.mjs";
|
|
32
32
|
import * as defaultRemote from "./remote.mjs";
|
|
33
33
|
import { parseRepoRef } from "./remote.mjs";
|
|
34
34
|
|
|
@@ -77,30 +77,58 @@ export function parseProviderFlags(pairs) {
|
|
|
77
77
|
return JSON.parse(JSON.stringify(out));
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
/**
|
|
81
|
-
*
|
|
80
|
+
/** The qualified name of a discovered soul: `<package>/<soul>` for a package soul,
|
|
81
|
+
* `<member name>/<soul>` for a member's, `<source repo name>/<soul>` for an external one. */
|
|
82
|
+
export function qualifiedSoulName(entry) {
|
|
83
|
+
if (typeof entry?.package === "string") return `${entry.package}/${entry.name}`;
|
|
84
|
+
return `${memberNameOf(entry?.repoKey ?? "")}/${entry?.name}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Find the soul named `name` in a discovery: confirmed members, external souls and package
|
|
88
|
+
* souls. A bare name must be unique across all three (else E_SOUL_AMBIGUOUS naming each
|
|
89
|
+
* qualified form); `<repo>/<soul>` names a member (its key, a key suffix or its member name)
|
|
90
|
+
* or an external source, `<package>/<soul>` a package soul. */
|
|
82
91
|
export function findSoulEntry(discovery, name) {
|
|
83
92
|
const [repoPart, soulPart] = name.includes("/") && !name.startsWith("/") ? [name.slice(0, name.lastIndexOf("/")), name.slice(name.lastIndexOf("/") + 1)] : [null, name];
|
|
84
93
|
const hits = [];
|
|
85
94
|
// A standalone view's one row is the repo's own (unconfirmed by definition — the
|
|
86
95
|
// workspace could not be read); resolveSoul admits exactly that case.
|
|
87
96
|
const standaloneOwn = discovery.standalone === true ? discovery.key : null;
|
|
97
|
+
const memberMatches = (key) => !repoPart || key === repoPart || key.endsWith(repoPart) || memberNameOf(key) === repoPart;
|
|
88
98
|
for (const m of discovery.members || []) {
|
|
89
99
|
if (!m.confirmed && m.key !== standaloneOwn) continue;
|
|
90
100
|
for (const s of m.souls || []) {
|
|
91
|
-
if (s.name !== soulPart) continue;
|
|
92
|
-
if (repoPart && !m.key.endsWith(repoPart) && m.key !== repoPart) continue;
|
|
101
|
+
if (s.name !== soulPart || !memberMatches(m.key)) continue;
|
|
93
102
|
hits.push({ ...s, repoKey: m.key, memberCommit: m.commit, external: false });
|
|
94
103
|
}
|
|
95
104
|
}
|
|
96
105
|
for (const x of discovery.external || []) {
|
|
97
106
|
if (x.soul?.name === soulPart && (!repoPart || (x.source && String(x.source).includes(repoPart)))) hits.push({ ...x.soul, repoKey: x.soul.repoKey ?? parseRepoRef(x.source.replace(/@.*$/, "")).key, commit: x.commit, external: true });
|
|
98
107
|
}
|
|
99
|
-
|
|
100
|
-
|
|
108
|
+
for (const s of discovery.packageSouls || []) {
|
|
109
|
+
if (s.name === soulPart && (!repoPart || repoPart === s.package)) hits.push({ ...s, external: false });
|
|
110
|
+
}
|
|
111
|
+
if (hits.length === 0) throw err("E_SOUL_UNKNOWN", `no soul ${JSON.stringify(name)} among the confirmed members, external souls or package souls of this workspace`, { name, members: (discovery.members || []).filter((m) => m.confirmed).map((m) => m.key), packages: [...new Set((discovery.packageSouls || []).map((s) => s.package))].sort() });
|
|
112
|
+
if (hits.length > 1) {
|
|
113
|
+
const qualified = hits.map(qualifiedSoulName);
|
|
114
|
+
throw err("E_SOUL_AMBIGUOUS", `soul ${JSON.stringify(name)} is ${hits.length} souls (${qualified.join(", ")}); name one of them`, { name, repos: hits.map((h) => h.repoKey), qualified });
|
|
115
|
+
}
|
|
116
|
+
if (Array.isArray(hits[0].collides)) throw err("E_SOUL_AMBIGUOUS", `package souls ${hits[0].collides.join(" and ")} would share the agent directory agents/${hits[0].agentName}/ — keep one of the packages in the workspace's packages:`, { name, agentDir: hits[0].agentName, qualified: hits[0].collides });
|
|
101
117
|
return hits[0];
|
|
102
118
|
}
|
|
103
119
|
|
|
120
|
+
/** The agents-root directory of a discovered soul: a package soul's is `<package>--<soul>`
|
|
121
|
+
* (packageSoulAgentName), every other soul's is its name. */
|
|
122
|
+
export function agentDirOf(entry) { return typeof entry?.agentName === "string" ? entry.agentName : entry?.name; }
|
|
123
|
+
|
|
124
|
+
/** `oats-local.yaml` `souls.disabled` (not run on this machine): an entry names a soul by its
|
|
125
|
+
* bare name (every soul of that name) or its qualified name. → the matching entry or null. */
|
|
126
|
+
export function disabledEntry(local, soulEntry) {
|
|
127
|
+
const list = Array.isArray(local?.souls?.disabled) ? local.souls.disabled : [];
|
|
128
|
+
const qualified = qualifiedSoulName(soulEntry);
|
|
129
|
+
return list.find((d) => d === soulEntry.name || d === qualified) ?? null;
|
|
130
|
+
}
|
|
131
|
+
|
|
104
132
|
/**
|
|
105
133
|
* A home's LIVE eligible teams (teams contract 2026-09-25, decision 6). Teams are messaging
|
|
106
134
|
* state, not frozen composition: the soul's labels are read from its repository NOW and each
|
|
@@ -117,6 +145,8 @@ export async function liveTeams(home, meta, { remoteOptions, remote } = {}) {
|
|
|
117
145
|
const soul = meta?.workspace?.soul;
|
|
118
146
|
// A capability agent's soul is its providing module's: no team labels, nothing to re-read.
|
|
119
147
|
if (!soul || typeof soul.repoKey !== "string" || typeof meta.agent !== "string") return recorded("no-workspace-soul");
|
|
148
|
+
// A package soul's labels are pinned with the package: the spawn-time record is its answer.
|
|
149
|
+
if (soul.package && typeof soul.package === "object") return recorded("package-soul");
|
|
120
150
|
const deployment = dirname(dirname(dirname(dirname(resolvePath(home)))));
|
|
121
151
|
try {
|
|
122
152
|
const found = loadLocal(deployment);
|
|
@@ -162,9 +192,13 @@ function swapSoulPointer(soulDir, target) {
|
|
|
162
192
|
/** Fetch a prepared soul's source tree (soul.yaml + AGENTS.md, CLAUDE.md → AGENTS.md) into `dest`. */
|
|
163
193
|
async function fetchSoulSource(prepared, dest) {
|
|
164
194
|
const e = prepared.soulEntry;
|
|
165
|
-
const ref = e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
|
|
195
|
+
const ref = typeof e.ref === "string" ? e.ref : e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
|
|
166
196
|
// A soul's CLAUDE.md → AGENTS.md alias is the one symlink a soul source may carry.
|
|
167
|
-
await fetchRemoteTree(ref, e.commit, e.path, dest, { ...(prepared.remoteOptions || {}), allowSymlinks:
|
|
197
|
+
const { digest } = await fetchRemoteTree(ref, e.commit, e.path, dest, { ...(prepared.remoteOptions || {}), allowSymlinks: SOUL_ALIAS_SYMLINK });
|
|
198
|
+
// A package soul is what the lock recorded at sync: the same bytes at the locked commit.
|
|
199
|
+
if (typeof e.package === "string" && digest !== e.digest) {
|
|
200
|
+
throw err("E_PACKAGE_INTEGRITY", `package soul ${e.qualifiedName} v${e.version} @ ${String(e.commit).slice(0, 12)}: content digest ${digest} does not match the locked ${e.digest} — run \`oats sync\` (a lock that was edited: remove the entry and sync again)`, { package: e.package, soul: e.name, version: e.version, commit: e.commit, why: "soul-digest", locked: e.digest, observed: digest });
|
|
201
|
+
}
|
|
168
202
|
if (!existsSync(join(dest, "soul.yaml")) || !existsSync(join(dest, "AGENTS.md"))) throw err("E_SOUL_INCOMPLETE", `soul ${e.name} at ${e.repoKey}@${String(e.commit).slice(0, 12)} lacks soul.yaml or AGENTS.md`, { repoKey: e.repoKey, commit: e.commit, path: e.path });
|
|
169
203
|
if (!existsSync(join(dest, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(dest, "CLAUDE.md"));
|
|
170
204
|
}
|
|
@@ -176,7 +210,7 @@ async function fetchSoulSource(prepared, dest) {
|
|
|
176
210
|
* that temporary copy; `fetched` says whether the preview had to fetch. */
|
|
177
211
|
export async function previewWorkspaceSoul(prepared, agentsRoot) {
|
|
178
212
|
const e = prepared.soulEntry;
|
|
179
|
-
const commitDir = join(agentsRoot, e
|
|
213
|
+
const commitDir = join(agentsRoot, agentDirOf(e), SOULS_DIR, commit12(e.commit));
|
|
180
214
|
if (existsSync(join(commitDir, "soul.yaml")) && existsSync(join(commitDir, "AGENTS.md"))) return { soulDir: realpathSync(commitDir), fetched: false, cleanup: () => {} };
|
|
181
215
|
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "oats-preview-soul-")));
|
|
182
216
|
const cleanup = () => rmSync(tmp, { recursive: true, force: true });
|
|
@@ -202,7 +236,7 @@ export async function previewWorkspaceSoul(prepared, agentsRoot) {
|
|
|
202
236
|
* Returns the PER-COMMIT directory (what a home should link). */
|
|
203
237
|
export async function ensureWorkspaceSoul(prepared, agentsRoot) {
|
|
204
238
|
const e = prepared.soulEntry;
|
|
205
|
-
const agentDir = join(agentsRoot, e
|
|
239
|
+
const agentDir = join(agentsRoot, agentDirOf(e)); const soulDir = join(agentDir, "soul");
|
|
206
240
|
const soulsDir = join(agentDir, SOULS_DIR);
|
|
207
241
|
const stamp = join(agentDir, SOUL_SOURCE_STAMP);
|
|
208
242
|
const readStamp = () => { try { return JSON.parse(readFileSync(stamp, "utf8")); } catch { return null; } };
|
|
@@ -248,7 +282,8 @@ export async function ensureWorkspaceSoul(prepared, agentsRoot) {
|
|
|
248
282
|
if (soulPointerTarget(soulDir) !== target) swapSoulPointer(soulDir, target);
|
|
249
283
|
const cur = readStamp();
|
|
250
284
|
if (!cur || cur.repoKey !== e.repoKey || cur.commit !== e.commit || cur.path !== e.path) {
|
|
251
|
-
|
|
285
|
+
const pkg = typeof e.package === "string" ? { package: e.package, version: e.version } : {};
|
|
286
|
+
writeFileSync(stamp, JSON.stringify({ repoKey: e.repoKey, commit: e.commit, path: e.path, ...pkg, fetchedAt: new Date().toISOString() }, null, 2) + "\n");
|
|
252
287
|
}
|
|
253
288
|
return target;
|
|
254
289
|
}
|
|
@@ -389,8 +424,10 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
|
|
|
389
424
|
const local = found.local;
|
|
390
425
|
const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
|
|
391
426
|
const lock = existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
|
|
392
|
-
const discovery = discoveryOverride ?? await discoverOrStandalone(local, { remoteOptions, remote });
|
|
427
|
+
const discovery = discoveryOverride ?? await discoverOrStandalone(local, { lock, remoteOptions, remote });
|
|
393
428
|
const soulEntry = findSoulEntry(discovery, soulName);
|
|
429
|
+
const disabled = disabledEntry(local, soulEntry);
|
|
430
|
+
if (disabled !== null) throw err("E_SOUL_DISABLED", `soul ${qualifiedSoulName(soulEntry)} is disabled on this machine (oats-local.yaml souls.disabled: ${disabled}) — remove it from that list to spawn it here`, { name: soulEntry.name, qualifiedName: qualifiedSoulName(soulEntry), entry: disabled });
|
|
394
431
|
const resolution = await resolveSoul(discovery, soulEntry, { local, lock, spawn, remoteOptions, remote });
|
|
395
432
|
return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions, spawn };
|
|
396
433
|
}
|
|
@@ -402,6 +439,11 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
|
|
|
402
439
|
const ACCESS_REASONS = new Set(["auth", "not-found"]);
|
|
403
440
|
const isAccessFailure = (e) => e?.code === "E_REMOTE_UNREADABLE" && ACCESS_REASONS.has(e?.details?.reason ?? e?.provenance?.reason);
|
|
404
441
|
|
|
442
|
+
/** The deployment's lock, or null when it has none (an unreadable lock is E_LOCK_SCHEMA). */
|
|
443
|
+
export function deploymentLock(deployment) {
|
|
444
|
+
return existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
|
|
445
|
+
}
|
|
446
|
+
|
|
405
447
|
/** Decision 10: a standalone view is a MEMBER whose workspace cannot be read — the
|
|
406
448
|
* repo must declare that workspace (oats-membership.yaml). A lone repo with no
|
|
407
449
|
* backlink is not a member of anything and never becomes a capability source. */
|
|
@@ -424,21 +466,23 @@ function requireMembership(repo, ref) {
|
|
|
424
466
|
* readable in this access context (auth / not-found).
|
|
425
467
|
* A transient failure reading the host (network, timeout) is rethrown as-is.
|
|
426
468
|
*/
|
|
427
|
-
export async function discoverOrStandalone(local, { remoteOptions, remote } = {}) {
|
|
469
|
+
export async function discoverOrStandalone(local, { lock, deployment, remoteOptions, remote } = {}) {
|
|
428
470
|
const ro = { remoteOptions, remote };
|
|
471
|
+
// Package souls are listed from the deployment's lock (the `lock` given, else `<deployment>/oats-lock.json`).
|
|
472
|
+
if (lock === undefined) lock = typeof deployment === "string" ? deploymentLock(deployment) : null;
|
|
429
473
|
if (typeof local.standalone === "string" && local.standalone) {
|
|
430
474
|
const repo = await discoverRepo(local.standalone, ro);
|
|
431
475
|
requireMembership(repo, local.standalone);
|
|
432
476
|
return { ...standaloneRepo(local.standalone, repo.commit, repo, { remote }), standaloneReason: "explicit" };
|
|
433
477
|
}
|
|
434
|
-
try { return await discoverWorkspace(local.workspace, { local, ...ro }); }
|
|
478
|
+
try { return await discoverWorkspace(local.workspace, { local, lock, ...ro }); }
|
|
435
479
|
catch (e) {
|
|
436
480
|
if (e?.code !== "E_WORKSPACE_SCHEMA" || e?.details?.notAHost !== true) throw e;
|
|
437
481
|
// The ref is a repository without oats-workspace.yaml: is it a member whose
|
|
438
482
|
// workspace we cannot read? Then the standalone view is what the operator gets.
|
|
439
483
|
const repo = await discoverRepo(local.workspace, ro);
|
|
440
484
|
if (!repo.membership) throw e;
|
|
441
|
-
try { return await discoverWorkspace(repo.membership.workspace, { local, ...ro }); }
|
|
485
|
+
try { return await discoverWorkspace(repo.membership.workspace, { local, lock, ...ro }); }
|
|
442
486
|
catch (inner) {
|
|
443
487
|
// Only an ACCESS failure on the host means "standalone"; anything else
|
|
444
488
|
// (network, timeout, a broken workspace file) is the caller's to see.
|
|
@@ -499,7 +543,7 @@ export async function prepareCapabilityAgent(contextDir, agent, { discovery, pkg
|
|
|
499
543
|
const local = found.local;
|
|
500
544
|
const deployment = dirname(found.path);
|
|
501
545
|
const lock = existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
|
|
502
|
-
const view = discovery ?? await discoverOrStandalone(local, { remoteOptions });
|
|
546
|
+
const view = discovery ?? await discoverOrStandalone(local, { lock, remoteOptions });
|
|
503
547
|
const cap = agent.capability;
|
|
504
548
|
let module, payload, origins, localModules = null;
|
|
505
549
|
const anchor = pkg ? null : agent._manifestSource;
|
|
@@ -652,7 +696,7 @@ export async function resolvePackageCapabilityAgent(contextDir, name, { remoteOp
|
|
|
652
696
|
const found = loadLocal(contextDir);
|
|
653
697
|
const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
|
|
654
698
|
const remote = defaultRemote;
|
|
655
|
-
const view = discovery === undefined ? await discoverOrStandalone(found.local, { remoteOptions }) : discovery;
|
|
699
|
+
const view = discovery === undefined ? await discoverOrStandalone(found.local, { deployment, remoteOptions }) : discovery;
|
|
656
700
|
// A confirmed member's own (non-private) capability first: membership is its trust
|
|
657
701
|
// decision, exactly as for a soul's `from: <member>` module.
|
|
658
702
|
for (const row of view?.members || []) {
|
package/lib/materialize.mjs
CHANGED
|
@@ -584,12 +584,25 @@ export function driftOf(instanceJson, discovery, { lock } = {}) {
|
|
|
584
584
|
* (reason "unconfirmed" / the row's reason). A standalone view's own member row is unconfirmed
|
|
585
585
|
* by construction (reason "cannot-read") yet carries the repo's current commit — it is compared,
|
|
586
586
|
* not reported missing. Returns null for a home without a recorded workspace soul (classic).
|
|
587
|
+
* A PACKAGE soul (`workspace.soul.package`) is compared with the discovery's package souls: the
|
|
588
|
+
* pin moved → "moved"; the package no longer locked/declared → "missing" ("package-absent"); the
|
|
589
|
+
* soul no longer shipped by the package → "missing" ("soul-absent").
|
|
587
590
|
*/
|
|
588
591
|
export function soulDriftOf(instanceJson, discovery) {
|
|
589
592
|
const soul = plainObject(instanceJson?.workspace) && plainObject(instanceJson.workspace.soul) ? instanceJson.workspace.soul : null;
|
|
590
593
|
if (!soul || typeof soul.repoKey !== "string") return null;
|
|
591
594
|
const name = typeof instanceJson.agent === "string" ? instanceJson.agent : (typeof instanceJson.soul === "string" ? instanceJson.soul : null);
|
|
592
595
|
const commit = typeof soul.commit === "string" ? soul.commit : null;
|
|
596
|
+
if (plainObject(soul.package) && typeof soul.package.id === "string") {
|
|
597
|
+
// A package soul: "moved" means the package pin moved (another version/commit is locked now).
|
|
598
|
+
const pkg = { package: soul.package.id, version: soul.package.version ?? null };
|
|
599
|
+
const soulName = typeof soul.name === "string" ? soul.name : name;
|
|
600
|
+
const base = { name: soulName, repoKey: soul.repoKey, commit, team: soul.team ?? null, ...pkg };
|
|
601
|
+
const listed = Array.isArray(discovery?.packageSouls) ? discovery.packageSouls.filter((s) => s && s.package === soul.package.id) : [];
|
|
602
|
+
const now = listed.find((s) => s.name === soulName) || null;
|
|
603
|
+
if (!now) return { ...base, current: null, status: "missing", reason: listed.length ? "soul-absent" : "package-absent" };
|
|
604
|
+
return { ...base, current: { commit: now.commit, version: now.version }, status: now.commit === commit ? "current" : "moved" };
|
|
605
|
+
}
|
|
593
606
|
const members = Array.isArray(discovery?.members) ? discovery.members : [];
|
|
594
607
|
const member = members.find((m) => m && m.key === soul.repoKey) || null;
|
|
595
608
|
const standaloneOwn = discovery?.standalone === true && member && member.key === discovery.key && typeof member.commit === "string";
|
package/lib/packages.mjs
CHANGED
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
* version: "<version string, no leading v>",
|
|
29
29
|
* commit: "<full 40-hex OID>",
|
|
30
30
|
* integrity: "sha256-<hex>", // contentDigest of the package tree at <path>
|
|
31
|
-
* capabilities: ["<cap name>", …]
|
|
31
|
+
* capabilities: ["<cap name>", …], // sorted
|
|
32
|
+
* souls: [{ name, path, digest }] // package souls (0.28.0), sorted by name; omitted when none
|
|
32
33
|
* }
|
|
33
34
|
* }
|
|
34
35
|
* }
|
|
@@ -57,6 +58,11 @@ export const DEFAULT_PACKAGE_PATH = "oats-package";
|
|
|
57
58
|
|
|
58
59
|
const OID_RE = /^[0-9a-f]{40}$/;
|
|
59
60
|
const DIGEST_RE = /^sha256-[0-9a-f]{64}$/;
|
|
61
|
+
/** A soul name (docs/soul.schema.json#/$defs/slug): a package soul's name is its directory's. */
|
|
62
|
+
const SOUL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
63
|
+
/** The one symlink a soul source may carry: its CLAUDE.md → AGENTS.md alias (at the soul's root).
|
|
64
|
+
* Sync and spawn fetch a package soul with this same predicate, so their digests agree. */
|
|
65
|
+
export const SOUL_ALIAS_SYMLINK = (p) => p === "CLAUDE.md";
|
|
60
66
|
const CONTRACT_DOC = "docs/design/2026-09-23-workspace-module-contracts.md";
|
|
61
67
|
|
|
62
68
|
/** `oatsError` with details attached as BOTH `e.provenance` (today's field) and
|
|
@@ -143,6 +149,14 @@ export function validateLock(lock, { file } = {}) {
|
|
|
143
149
|
if (typeof entry.commit !== "string" || !OID_RE.test(entry.commit)) throw bad("commit", "must be a full 40-hex OID");
|
|
144
150
|
if (typeof entry.integrity !== "string" || !DIGEST_RE.test(entry.integrity)) throw bad("integrity", "must be sha256-<hex>");
|
|
145
151
|
if (!Array.isArray(entry.capabilities) || entry.capabilities.some((c) => typeof c !== "string" || !c)) throw bad("capabilities", "must be an array of capability names");
|
|
152
|
+
if (entry.souls !== undefined) {
|
|
153
|
+
if (!Array.isArray(entry.souls)) throw bad("souls", "must be an array of { name, path, digest }");
|
|
154
|
+
for (const [i, soul] of entry.souls.entries()) {
|
|
155
|
+
if (!plainObject(soul) || typeof soul.name !== "string" || !SOUL_NAME_RE.test(soul.name)) throw bad(`souls/${i}/name`, "must be a soul name");
|
|
156
|
+
if (typeof soul.path !== "string" || !soul.path || soul.path.split("/").some((p) => p === ".." || p === "") || soul.path.startsWith("/")) throw bad(`souls/${i}/path`, "must be a relative path inside the package");
|
|
157
|
+
if (typeof soul.digest !== "string" || !DIGEST_RE.test(soul.digest)) throw bad(`souls/${i}/digest`, "must be sha256-<hex>");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
146
160
|
}
|
|
147
161
|
return lock;
|
|
148
162
|
}
|
|
@@ -168,6 +182,7 @@ export function canonicalLock(lock) {
|
|
|
168
182
|
packages[id] = {
|
|
169
183
|
source: e.source, ...(typeof e.url === "string" && e.url ? { url: e.url } : {}), path: e.path, version: e.version, commit: e.commit, integrity: e.integrity,
|
|
170
184
|
capabilities: [...e.capabilities].sort(),
|
|
185
|
+
...(Array.isArray(e.souls) && e.souls.length ? { souls: [...e.souls].sort((a, b) => byCodepoint(a.name, b.name)).map((x) => ({ name: x.name, path: x.path, digest: x.digest })) } : {}),
|
|
171
186
|
};
|
|
172
187
|
}
|
|
173
188
|
return { lockfileVersion: LOCK_VERSION, packages };
|
|
@@ -319,7 +334,63 @@ export async function readPackageManifests(remote, remoteRef, commit, path, deta
|
|
|
319
334
|
capabilities.push({ name: capManifest.capability, dir, manifest: capManifest });
|
|
320
335
|
}
|
|
321
336
|
capabilities.sort((a, b) => byCodepoint(a.name, b.name));
|
|
322
|
-
|
|
337
|
+
// Package souls (0.28.0): ordinary soul directories, versioned and locked with the package. A soul's
|
|
338
|
+
// name is its directory's (as for a member's souls/<name>); soul.yaml is validated where it is
|
|
339
|
+
// discovered, AGENTS.md is required where it is locked (packageSoulDigests).
|
|
340
|
+
const souls = [];
|
|
341
|
+
if (manifest.souls !== undefined) {
|
|
342
|
+
if (!Array.isArray(manifest.souls)) throw oatsError("E_PACKAGE_MANIFEST", `package manifest at ${manifestPath}: "souls" must be a list of soul directories`, { ...details, path: manifestPath });
|
|
343
|
+
const seenSouls = new Map();
|
|
344
|
+
for (const rel of manifest.souls) {
|
|
345
|
+
assertRelPath(`${manifestPath} souls[]`, rel, { ...details, path: manifestPath });
|
|
346
|
+
const soulPath = posix.normalize(rel).replace(/\/+$/, "");
|
|
347
|
+
const name = posix.basename(soulPath);
|
|
348
|
+
if (!SOUL_NAME_RE.test(name)) throw oatsError("E_PACKAGE_MANIFEST", `${manifestPath} souls[]: ${JSON.stringify(rel)} — a soul's directory is its name, which must match ${SOUL_NAME_RE}`, { ...details, path: manifestPath, soul: rel });
|
|
349
|
+
if (seenSouls.has(name)) throw oatsError("E_PACKAGE_MANIFEST", `package ${manifest.package} declares soul ${JSON.stringify(name)} twice (${seenSouls.get(name)} and ${soulPath})`, { ...details, path: manifestPath, duplicate: name, other: seenSouls.get(name) });
|
|
350
|
+
seenSouls.set(name, soulPath);
|
|
351
|
+
souls.push({ name, path: soulPath, dir: pjoin(path, soulPath) });
|
|
352
|
+
}
|
|
353
|
+
souls.sort((a, b) => byCodepoint(a.name, b.name));
|
|
354
|
+
}
|
|
355
|
+
return { manifest, capabilities, souls };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Fetch each package soul at `commit` and return its lock record `{ name, path, digest }` — the digest
|
|
359
|
+
* is what `fetchRemoteTree` reports with SOUL_ALIAS_SYMLINK, the same fetch a spawn makes and verifies.
|
|
360
|
+
* A soul without soul.yaml or AGENTS.md is E_PACKAGE_MANIFEST. */
|
|
361
|
+
export async function packageSoulDigests(remote, remoteRef, commit, souls, details = {}) {
|
|
362
|
+
if (!souls.length) return [];
|
|
363
|
+
if (typeof remote.fetchRemoteTree !== "function") throw oatsError("E_PACKAGE_INTEGRITY", "remote must provide fetchRemoteTree() to compute a package soul's digest", { path: "/remote" });
|
|
364
|
+
const scratch = mkdtempSync(join(tmpdir(), "oats-pkg-soul-"));
|
|
365
|
+
try {
|
|
366
|
+
const out = [];
|
|
367
|
+
for (const soul of souls) {
|
|
368
|
+
const dest = join(scratch, soul.name);
|
|
369
|
+
let digest;
|
|
370
|
+
try { ({ digest } = await remote.fetchRemoteTree(remoteRef, commit, soul.dir, dest, { allowSymlinks: SOUL_ALIAS_SYMLINK })); }
|
|
371
|
+
catch (e) {
|
|
372
|
+
if (e?.code === "E_REMOTE_PATH_MISSING") throw oatsError("E_PACKAGE_MANIFEST", `package soul ${soul.path} is missing at ${String(commit).slice(0, 12)}`, { ...details, soul: soul.name, path: soul.dir });
|
|
373
|
+
throw e;
|
|
374
|
+
}
|
|
375
|
+
// A fake remote reports a digest without copying: only a real copy can be checked for its files.
|
|
376
|
+
if (existsSync(dest)) {
|
|
377
|
+
for (const file of ["soul.yaml", "AGENTS.md"]) {
|
|
378
|
+
if (!existsSync(join(dest, file))) throw oatsError("E_PACKAGE_MANIFEST", `package soul ${soul.path} lacks ${file} at ${String(commit).slice(0, 12)} — a soul directory carries soul.yaml and AGENTS.md`, { ...details, soul: soul.name, path: soul.dir, missing: file });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
out.push({ name: soul.name, path: soul.path, digest: assertDigest(`package soul ${soul.path} digest`, digest, details) });
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The repo ref a lock entry is read from without a catalog: its recorded url, else the key of a
|
|
388
|
+
* `git:<key>@<ref>` source. → string | null (a catalog entry written without a url). */
|
|
389
|
+
export function lockedPackageRef(entry) {
|
|
390
|
+
if (typeof entry?.url === "string" && entry.url) return entry.url;
|
|
391
|
+
const m = /^git:(.+)@([^@]+)$/.exec(String(entry?.source ?? ""));
|
|
392
|
+
if (!m) return null;
|
|
393
|
+
return m[1].startsWith("local/") ? m[1].slice("local/".length) : `git:${m[1]}`;
|
|
323
394
|
}
|
|
324
395
|
|
|
325
396
|
/** Content digest of the package tree at `path`: the digest `fetchRemoteTree` reports for a
|
|
@@ -419,23 +490,36 @@ export async function resolvePackages(workspace, { catalog = {}, lock = emptyLoc
|
|
|
419
490
|
// The lock's capability list must still be what the package declares at that commit —
|
|
420
491
|
// spawn refuses a mismatch (E_PACKAGE_INTEGRITY why:capabilities), so sync must too, or
|
|
421
492
|
// a bad list would pass every sync and fail every spawn.
|
|
422
|
-
const { capabilities } = await readPackageManifests(remote, req.remoteRef, obs.commit, old.path, details);
|
|
493
|
+
const { capabilities, souls: soulDirs } = await readPackageManifests(remote, req.remoteRef, obs.commit, old.path, details);
|
|
423
494
|
const listed = capabilities.map((c) => c.name).sort(), locked = [...old.capabilities].sort();
|
|
424
495
|
if (listed.length !== locked.length || listed.some((c, i) => c !== locked[i])) {
|
|
425
496
|
throw oatsError("E_PACKAGE_INTEGRITY",
|
|
426
497
|
`packages.${id} ${version} @ ${obs.commit}: the lock lists capabilities [${locked.join(", ")}] but the package declares [${listed.join(", ")}] — the lock was edited; remove this entry from oats-lock.json and run \`oats sync\` again`,
|
|
427
498
|
{ ...details, version, why: "capabilities", listed, locked });
|
|
428
499
|
}
|
|
429
|
-
|
|
430
|
-
|
|
500
|
+
// The lock's souls must be what the package ships at that commit: the same names, paths and
|
|
501
|
+
// digests. A lock written before 0.28.0 carries no `souls` — it is filled in, not refused.
|
|
502
|
+
const souls = await packageSoulDigests(remote, req.remoteRef, obs.commit, soulDirs, details);
|
|
503
|
+
if (old.souls !== undefined) {
|
|
504
|
+
const show = (list) => list.map((x) => `${x.name}@${x.path}:${x.digest.slice(7, 19)}`).sort().join(", ");
|
|
505
|
+
if (show(old.souls) !== show(souls)) {
|
|
506
|
+
throw oatsError("E_PACKAGE_INTEGRITY",
|
|
507
|
+
`packages.${id} ${version} @ ${obs.commit}: the lock's souls [${show(old.souls)}] do not match what the package ships [${show(souls)}] — the lock was edited; remove this entry from oats-lock.json and run \`oats sync\` again`,
|
|
508
|
+
{ ...details, version, why: "souls", listed: souls, locked: old.souls });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const { approved: _dropped, souls: _souls, ...kept } = clone(old); // a pre-0.26 approval record is dropped on write
|
|
512
|
+
nextPackages[id] = { ...kept, ...(souls.length ? { souls } : {}) };
|
|
431
513
|
continue;
|
|
432
514
|
}
|
|
433
515
|
|
|
434
|
-
const { capabilities } = await readPackageManifests(remote, req.remoteRef, obs.commit, req.path, details);
|
|
516
|
+
const { capabilities, souls: soulDirs } = await readPackageManifests(remote, req.remoteRef, obs.commit, req.path, details);
|
|
435
517
|
const integrity = assertDigest(`packages.${id} integrity`, await packageIntegrity(remote, req.remoteRef, obs.commit, req.path), details);
|
|
518
|
+
const souls = await packageSoulDigests(remote, req.remoteRef, obs.commit, soulDirs, details);
|
|
436
519
|
nextPackages[id] = {
|
|
437
520
|
source, url: obs.url, path: req.path, version, commit: obs.commit, integrity,
|
|
438
521
|
capabilities: capabilities.map((c) => c.name).sort(),
|
|
522
|
+
...(souls.length ? { souls } : {}),
|
|
439
523
|
};
|
|
440
524
|
changes.push({ id, from: old ? old.version : null, to: version, commit: obs.commit });
|
|
441
525
|
}
|
package/lib/resolve.mjs
CHANGED
|
@@ -472,6 +472,15 @@ function lookupPackage(lock, name, via, soul, declared) {
|
|
|
472
472
|
return providing;
|
|
473
473
|
}
|
|
474
474
|
|
|
475
|
+
/** `from: here` in a package soul: the capability must be one its OWN locked package provides. */
|
|
476
|
+
function ownPackageEntry(lock, name, via, soul, id) {
|
|
477
|
+
const where = { capability: name, from: "here", soul: soul.name, via, package: id };
|
|
478
|
+
const entry = isObject(lock?.packages) ? lock.packages[id] : null;
|
|
479
|
+
if (!isObject(entry)) throw fail("E_PACKAGE_MISSING", `${name}: from: here in package soul ${id}/${soul.name} needs ${id} in the lock — run \`oats sync\``, { ...where, reason: "no-lock" });
|
|
480
|
+
if (!entry.capabilities.includes(name)) throw fail("E_CAPABILITY_MISSING", `${name}: from: here in package soul ${id}/${soul.name}, but package ${id} v${entry.version} provides [${entry.capabilities.join(", ")}]`, { ...where, version: entry.version, listed: [...entry.capabilities] });
|
|
481
|
+
return { id, entry };
|
|
482
|
+
}
|
|
483
|
+
|
|
475
484
|
/** The repo ref a locked package is read from: the lock's recorded url, else the catalog's url for catalog ids, else the key for git refs. */
|
|
476
485
|
export function packageRef(id, entry, catalog, remote) {
|
|
477
486
|
const cat = isObject(catalog) ? (isObject(catalog.packages) && !("url" in catalog.packages) ? catalog.packages : catalog) : {};
|
|
@@ -582,8 +591,12 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
|
|
|
582
591
|
const emptied = new Set(SLOTS.filter((slot) => definition[slot] === "none"));
|
|
583
592
|
for (const { name, from, via } of declared) {
|
|
584
593
|
let module;
|
|
585
|
-
|
|
586
|
-
|
|
594
|
+
const ownPackage = from === "here" && typeof soulEntry.package === "string";
|
|
595
|
+
if (from === "package" || ownPackage) {
|
|
596
|
+
// `from: here` in a package soul is its own package, at the locked commit (§2.2).
|
|
597
|
+
const { id, entry } = ownPackage
|
|
598
|
+
? ownPackageEntry(lock, name, via, soul, soulEntry.package)
|
|
599
|
+
: lookupPackage(lock, name, via, soul, discovery?.standalone === true ? null : (isObject(workspace?.packages) ? workspace.packages : {}));
|
|
587
600
|
const ref = packageRef(id, entry, catalog, remote);
|
|
588
601
|
const details = { capability: name, id, version: entry.version, commit: entry.commit, path: entry.path };
|
|
589
602
|
const { capabilities } = await readPackageManifests(remote, ref, entry.commit, entry.path, details);
|
|
@@ -783,6 +796,11 @@ function assertSoulDiscovered(discovery, soulEntry) {
|
|
|
783
796
|
const { name, repoKey } = soulEntry;
|
|
784
797
|
const where = { soul: name, repoKey };
|
|
785
798
|
const sameSoul = (s) => isObject(s) && s.name === name && s.repoKey === repoKey && (s.commit ?? null) === (soulEntry.commit ?? null);
|
|
799
|
+
if (typeof soulEntry.package === "string") {
|
|
800
|
+
// A package soul: the discovery listed it from the lock, at this package's locked commit.
|
|
801
|
+
if ((discovery?.packageSouls || []).some((s) => sameSoul(s) && s.package === soulEntry.package)) return;
|
|
802
|
+
throw fail("E_PACKAGE_MISSING", `soul ${soulEntry.package}/${name}: the discovery does not list it at ${short(soulEntry.commit)} — the soul entry is stale; run \`oats sync\` and discover again`, { ...where, package: soulEntry.package, reason: "stale" });
|
|
803
|
+
}
|
|
786
804
|
if ((discovery?.external || []).some((e) => sameSoul(e?.soul))) return;
|
|
787
805
|
const row = memberRow(discovery, repoKey);
|
|
788
806
|
if (!row) throw fail("E_NOT_A_MEMBER", `soul ${name}: ${repoKey} is not a member of the workspace${discovery?.key ? ` ${discovery.key}` : ""} (nor an external soul) — a soul is spawned from a confirmed member or an external entry`, { ...where, reason: "not-listed" });
|
package/lib/schedule.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { fileURLToPath } from "node:url";
|
|
|
29
29
|
import { Cron } from "croner";
|
|
30
30
|
import { loadLocal } from "./workspace.mjs";
|
|
31
31
|
import { noteRuntimeName } from "./deprecation.mjs";
|
|
32
|
+
import { tickTriggers } from "./triggers.mjs";
|
|
32
33
|
import { RESERVED_LAUNCH_ENV, MAX_INSTANCE_NAME, findAgent, findInstanceHomes, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
|
|
33
34
|
|
|
34
35
|
export const SCHEDULE_FILE = "oats-schedules.json";
|
|
@@ -65,6 +66,7 @@ const CAPTURED_SCHEDULE_REMOVED = "captured schedules are refused (the captured/
|
|
|
65
66
|
export const HARNESS_CONFLICT = "harness and runtime (its pre-0.27 name) disagree: keep harness: and remove runtime:";
|
|
66
67
|
export function shapeError(def) {
|
|
67
68
|
if (!def || typeof def !== "object" || Array.isArray(def)) return "definition is not an object";
|
|
69
|
+
if (def.kind === "trigger") return "a trigger is managed by oats trigger, not oats schedule";
|
|
68
70
|
if (!["spawn", "command", "wake", "operation"].includes(def.kind)) return `kind ${JSON.stringify(def.kind)} is not spawn, command, wake or operation`;
|
|
69
71
|
if (typeof def.cron !== "string" || typeof def.tz !== "string") return "cron and tz must be strings";
|
|
70
72
|
if (def.kind === "spawn" && typeof def.agent !== "string") return "agent must be a string";
|
|
@@ -246,7 +248,7 @@ export function withHostLock(fn, { retryMs = 0 } = {}) { return withDirLock(join
|
|
|
246
248
|
/** Registry read-modify-write is serialized on its own short lock. */
|
|
247
249
|
function withRegistryLock(fn) { return withDirLock(join(hostScheduleDir(), "registry.lock"), "the host schedule registry", fn, { retryMs: 3000 }); }
|
|
248
250
|
/** Definitions and state are read-modify-write; CRUD serializes here briefly. */
|
|
249
|
-
function withScopeLock(ws, fn) { return withDirLock(join(stateDir(ws), "scope.lock"), `schedules of ${ws}`, fn, { retryMs: 3000 }); }
|
|
251
|
+
export function withScopeLock(ws, fn) { return withDirLock(join(stateDir(ws), "scope.lock"), `schedules of ${ws}`, fn, { retryMs: 3000 }); }
|
|
250
252
|
|
|
251
253
|
export function acquireJobLock(ws, id, owner) {
|
|
252
254
|
const dir = lockDir(ws, id);
|
|
@@ -288,6 +290,7 @@ function validateMessage(message, field) {
|
|
|
288
290
|
/** Validate and normalize one definition against its scope. */
|
|
289
291
|
export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
290
292
|
if (!def || typeof def !== "object" || Array.isArray(def)) throw scheduleError("E_SCHEDULE_INVALID", "definition must be an object", { field: "definition" });
|
|
293
|
+
if (def.kind === "trigger") throw scheduleError("E_SCHEDULE_INVALID", "kind: a trigger is added with `oats trigger add`, not `oats schedule add`", { field: "kind" });
|
|
291
294
|
const id = def.id;
|
|
292
295
|
if (typeof id !== "string" || !ID_RE.test(id)) throw scheduleError("E_SCHEDULE_INVALID", "id: lowercase letters, digits and dashes, 1 to 40 characters", { field: "id" });
|
|
293
296
|
const enabled = def.enabled === undefined ? true : def.enabled;
|
|
@@ -431,13 +434,13 @@ function scheduleBlock(def, minute) {
|
|
|
431
434
|
* may be invoked from inside an unrelated instance; dispatch belongs to the
|
|
432
435
|
* job's cwd and explicit selectors, never to that caller), host configuration
|
|
433
436
|
* such as OATS_HOME_DIR and credentials kept. Shared by command and spawn jobs. */
|
|
434
|
-
function childEnv() {
|
|
437
|
+
export function childEnv() {
|
|
435
438
|
const env = { ...process.env };
|
|
436
439
|
for (const key of [...RESERVED_LAUNCH_ENV,
|
|
437
440
|
"OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
|
|
438
441
|
"OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
|
|
439
442
|
"OATS_HARNESS", "OATS_PREVIOUS_HARNESS", "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
|
|
440
|
-
"OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE",
|
|
443
|
+
"OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE", "OATS_TRIGGER_EVENT_FILE",
|
|
441
444
|
]) delete env[key];
|
|
442
445
|
return env;
|
|
443
446
|
}
|
|
@@ -671,6 +674,8 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
671
674
|
// are gone), then decide launches: a job ordered earlier must not see a
|
|
672
675
|
// slot still held by a home that has already ended.
|
|
673
676
|
// `candidates` (the host tick) means the host already observed every scope.
|
|
677
|
+
// Triggers (kind "trigger") live in the same file and are evaluated by tickTriggers, not here.
|
|
678
|
+
for (const [id, def] of Object.entries(defs.jobs)) if (def && def.kind === "trigger") delete defs.jobs[id];
|
|
674
679
|
if (!candidates) for (const [id, def] of Object.entries(defs.jobs)) observePrevious(ws, id, def, stateOf(id), io, now, { mutate: !dryRun });
|
|
675
680
|
if (observeOnly) { if (!dryRun) writeState(ws, st); return considered; }
|
|
676
681
|
// Least recently LAUNCHED first: only an actual harness launch counts, a
|
|
@@ -778,7 +783,7 @@ export function tickHost({ now = new Date(), io, dryRun = false } = {}) {
|
|
|
778
783
|
try {
|
|
779
784
|
considered.push(...tickWorkspace(ws, { now, io, reg, wsList, dryRun, observeOnly: true }));
|
|
780
785
|
const defs = readDefinitions(ws), st = readState(ws);
|
|
781
|
-
for (const id of Object.
|
|
786
|
+
for (const [id, def] of Object.entries(defs.jobs)) if (def?.kind !== "trigger") jobs.push({ ws, id, key: launchOrderKey(st.jobs[id]) });
|
|
782
787
|
} catch (e) { considered.push({ workspace: ws, action: "error", error: e.message }); }
|
|
783
788
|
}
|
|
784
789
|
jobs.sort((a, b) => a.key.localeCompare(b.key) || a.ws.localeCompare(b.ws) || a.id.localeCompare(b.id));
|
|
@@ -786,6 +791,11 @@ export function tickHost({ now = new Date(), io, dryRun = false } = {}) {
|
|
|
786
791
|
try { considered.push(...tickWorkspace(j.ws, { now, io, reg, wsList, dryRun, candidates: [j.id] })); }
|
|
787
792
|
catch (e) { considered.push({ workspace: j.ws, id: j.id, action: "error", error: e.message }); }
|
|
788
793
|
}
|
|
794
|
+
// Triggers: each scope's event-driven spawns, polled at their own interval (lib/triggers.mjs).
|
|
795
|
+
for (const ws of wsList) {
|
|
796
|
+
try { considered.push(...tickTriggers(ws, { now, io, dryRun })); }
|
|
797
|
+
catch (e) { considered.push({ workspace: ws, action: "error", error: `triggers: ${e.message}` }); }
|
|
798
|
+
}
|
|
789
799
|
if (!dryRun) writeHostState({ ...readHostState(), lastTick: now.toISOString(), minute: minuteKey(minuteStart(now)) });
|
|
790
800
|
return { tickedAt: now.toISOString(), minute: minuteKey(minuteStart(now)), considered, scheduler: schedulerStatus(undefined, io) };
|
|
791
801
|
};
|
|
@@ -797,7 +807,7 @@ export function tickHost({ now = new Date(), io, dryRun = false } = {}) {
|
|
|
797
807
|
export function runNow(ws, id, { now = new Date(), io, force = false } = {}) {
|
|
798
808
|
const defs = readDefinitions(ws);
|
|
799
809
|
const def = defs.jobs[id];
|
|
800
|
-
if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
810
|
+
if (!def || def.kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
801
811
|
if (!def.enabled && !force) throw scheduleError("E_SCHEDULE_DISABLED", `schedule ${id} is disabled; enable it or pass --force`);
|
|
802
812
|
const reg = readRegistry();
|
|
803
813
|
const wsList = reg.workspaces.includes(resolve(ws)) ? reg.workspaces : [...reg.workspaces, resolve(ws)];
|
|
@@ -824,7 +834,7 @@ export function runNow(ws, id, { now = new Date(), io, force = false } = {}) {
|
|
|
824
834
|
export function reconcile(ws, id, { io, now = new Date(), clear = false } = {}) {
|
|
825
835
|
const defs = readDefinitions(ws);
|
|
826
836
|
const def = defs.jobs[id];
|
|
827
|
-
if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
837
|
+
if (!def || def.kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
828
838
|
return withHostLock(() => {
|
|
829
839
|
const st = readState(ws);
|
|
830
840
|
const js = st.jobs[id] || (st.jobs[id] = {});
|
|
@@ -898,6 +908,7 @@ export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
|
|
|
898
908
|
defs ||= readDefinitions(ws); st ||= readState(ws);
|
|
899
909
|
const def = defs.jobs[id];
|
|
900
910
|
if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
911
|
+
if (def.kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `${id} is a trigger, not a schedule — use oats trigger show ${id}`);
|
|
901
912
|
// Subject truth: the definition's own id must be the key it is stored under.
|
|
902
913
|
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 });
|
|
903
914
|
const js = st.jobs[id] || {};
|
|
@@ -910,11 +921,13 @@ export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
|
|
|
910
921
|
export function listSchedules(ws, io, { now = new Date() } = {}) {
|
|
911
922
|
const defs = readDefinitions(ws), st = readState(ws);
|
|
912
923
|
// One job's bad identity or history does not fail the others: it is reported as its own row.
|
|
913
|
-
const schedules = Object.keys(defs.jobs).sort().map((id) => {
|
|
924
|
+
const schedules = Object.keys(defs.jobs).filter((id) => defs.jobs[id]?.kind !== "trigger").sort().map((id) => {
|
|
914
925
|
try { return describe(ws, id, io, { defs, st, now }); }
|
|
915
926
|
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: [] }; }
|
|
916
927
|
});
|
|
917
|
-
|
|
928
|
+
// Triggers share the definitions file but not this listing: a pointer, so they are never invisible.
|
|
929
|
+
const triggers = Object.values(defs.jobs).filter((d) => d?.kind === "trigger").length;
|
|
930
|
+
return { scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, integrity: scheduleIntegrity(ws), schedules, triggers: { count: triggers, command: "oats trigger list" }, scheduler: schedulerStatus(ws, io) };
|
|
918
931
|
}
|
|
919
932
|
export function addSchedule(ws, spec, io) {
|
|
920
933
|
const def = validateDefinition(ws, spec);
|
|
@@ -935,7 +948,7 @@ export function updateSchedule(ws, id, spec, io) {
|
|
|
935
948
|
const def = validateDefinition(ws, { ...spec, id });
|
|
936
949
|
return withHostLock(() => withScopeLock(ws, () => {
|
|
937
950
|
const defs = readDefinitions(ws);
|
|
938
|
-
if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
951
|
+
if (!defs.jobs[id] || defs.jobs[id].kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}${defs.jobs[id] ? " (it is a trigger: use oats trigger)" : ""}`);
|
|
939
952
|
const identity = (d) => JSON.stringify([d.kind, d.agent, d.agentsRoot, d.repo, d.purpose, d.home, d.cwd, d.argv, d.operation, d.definitionVersion, d.recurrencePolicy, d.execution, d.preparation]);
|
|
940
953
|
const busy = !!jobLockInfo(ws, id) || !!readState(ws).jobs[id]?.attempt;
|
|
941
954
|
if (busy && identity(defs.jobs[id]) !== identity(def)) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is running or has an unresolved attempt; its execution identity, kind, target and captured policy cannot change until it ends (cron, tz and enabled can)`);
|
|
@@ -947,7 +960,7 @@ export function updateSchedule(ws, id, spec, io) {
|
|
|
947
960
|
export function setEnabled(ws, id, enabled, io) {
|
|
948
961
|
return withScopeLock(ws, () => {
|
|
949
962
|
const defs = readDefinitions(ws);
|
|
950
|
-
if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
963
|
+
if (!defs.jobs[id] || defs.jobs[id].kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}${defs.jobs[id] ? " (it is a trigger: use oats trigger)" : ""}`);
|
|
951
964
|
defs.jobs[id] = { ...defs.jobs[id], enabled, updatedAt: new Date().toISOString() };
|
|
952
965
|
writeDefinitions(ws, defs);
|
|
953
966
|
return describe(ws, id, io);
|
|
@@ -958,7 +971,7 @@ export function setEnabled(ws, id, enabled, io) {
|
|
|
958
971
|
export function removeSchedule(ws, id, { force = false } = {}) {
|
|
959
972
|
return withHostLock(() => withScopeLock(ws, () => {
|
|
960
973
|
const defs = readDefinitions(ws);
|
|
961
|
-
if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
974
|
+
if (!defs.jobs[id] || defs.jobs[id].kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}${defs.jobs[id] ? " (it is a trigger: use oats trigger)" : ""}`);
|
|
962
975
|
const st = readState(ws), job = st.jobs[id];
|
|
963
976
|
if ((jobLockInfo(ws, id) || job?.attempt || job?.lastRun?.outcome === "unknown") && !force) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is running or has unresolved effects; reconcile first, or --force forgets the job without stopping anything`);
|
|
964
977
|
delete defs.jobs[id];
|