@awebai/oats 0.25.4 → 0.25.6
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 +7 -3
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +156 -9
- package/capabilities/oats-aweb/injects/aweb.md +7 -0
- package/capabilities/oats-aweb/oats.json +15 -2
- package/capabilities/oats-okf/bin/oats-okf.mjs +3 -1
- package/capabilities/oats-okf/lib/config.mjs +5 -1
- package/capabilities/oats-okf/lib/io.mjs +1 -1
- package/capabilities/oats-okf/lib/migration.mjs +24 -3
- package/capabilities/oats-okf/lib/sources.mjs +52 -16
- package/capabilities/oats-okf/lib/stores.mjs +37 -15
- package/capabilities/oats-okf/oats.json +4 -1
- package/capabilities/oats-okf/skills/okf/SKILL.md +3 -2
- package/docs/capabilities.md +19 -0
- package/docs/capability-manifest.schema.json +4 -0
- package/docs/design/2026-09-20-redesign-program-board.md +1 -1
- package/docs/design/2026-09-23-workspace-module-contracts.md +27 -0
- package/docs/design/2026-09-24-desktop-phase-f-boundary.md +180 -0
- package/docs/design/2026-09-24-phase-d-plan.md +129 -0
- package/docs/desktop-cli-api.md +35 -7
- package/docs/integrations.md +73 -7
- package/docs/rebuild-to-v2.md +11 -10
- package/docs/release-notes/v0.25.5.md +42 -0
- package/docs/release-notes/v0.25.6.md +37 -0
- package/docs/workspace-adoption.md +1 -1
- package/docs/workspaces.md +11 -8
- package/lib/core.mjs +45 -7
- package/lib/resolve.mjs +28 -5
- package/package-catalog.json +2 -2
- package/package.json +1 -1
package/bin/oats.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
packageIntegrity, capabilityArtifactIntegrity, verifyCapabilityInstallation, installedCapabilityDir,
|
|
32
32
|
resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, parseYamlNested, assertSafeConfigValue, stripInternalAnnotations, withConfigFile, teamAgentRoots,
|
|
33
33
|
findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, findInstanceHomes, listCapabilityAgents, workspaceOf, stopInstanceSession, recomposeInstanceInstructions,
|
|
34
|
-
ensureRoot, findRoot, findAgent, listAgents, listInstances, listAgentDefs, createAgent as coreCreateAgent,
|
|
34
|
+
ensureRoot, findRoot, findAgent, listAgents, listInstances, servedIdentityLine, servedIdentityOf, listAgentDefs, createAgent as coreCreateAgent,
|
|
35
35
|
spawnInstance, spawnInstanceAsync, findModuleCapabilityAgent, capabilityAgentFromDir, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, upsertLocalAgent, defaultRepo, RELATIONS, validateLaunchConfig, resolveLaunchSelection, resolveLaunchExecutable, checkLaunchExecutable, missingLaunchEnvRefs, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_RUNTIMES, LAUNCH_RECIPE_VERSION, parseLaunchCommand, resolveYolo, planLaunch, redactLaunchCommand, restartInstanceSession,
|
|
36
36
|
} from "../lib/core.mjs";
|
|
37
37
|
import {
|
|
@@ -935,7 +935,9 @@ function computeInspect({ onFail } = {}) {
|
|
|
935
935
|
const result = {
|
|
936
936
|
operationsApi: 1, kernel: OATS_VERSION,
|
|
937
937
|
scope: { context: ctx, requestedContext: requestedContext === ctx ? null : requestedContext, workspace: roots.length ? workspaceOf(roots[0]) : ctx, team: r.team || null, chain: chain.map((c) => ({ file: c._file, level: c._level, levelKind: levelOf(c._level) })), agentsRoots: roots },
|
|
938
|
-
selected: { soul: selectedSoul?.name || null, agentsRoot: selectedSoul?.agentsRoot || null, home: home || null, source: meta ? "snapshot" : "config"
|
|
938
|
+
selected: { soul: selectedSoul?.name || null, agentsRoot: selectedSoul?.agentsRoot || null, home: home || null, source: meta ? "snapshot" : "config",
|
|
939
|
+
// Decision 27 (K2): the principal this home acts as, from its messaging provider's hook meta.
|
|
940
|
+
...(meta ? { identity: servedIdentityOf(meta) } : {}) },
|
|
939
941
|
souls, sources, layers, capabilities, knowledge, snapshot, currentConfig,
|
|
940
942
|
problems: [...(lockError ? [lockError] : []), ...packagedDiagnostics.map((d) => ({ code: d.code, message: d.message, capability: d.capability })),
|
|
941
943
|
...(meta ? snapshotCaps.filter((c) => !mans[c.id]).map((c) => ({ code: "captured-capability-missing", message: `${c.id} was active when this home was composed but no manifest for it is acquired now`, capability: c.id })) : [])],
|
|
@@ -947,6 +949,7 @@ function printInspect(result) {
|
|
|
947
949
|
const { ctx, selectedSoul, home } = result._print; delete result._print;
|
|
948
950
|
const { souls, layers, capabilities } = result;
|
|
949
951
|
console.log(`oats inspect — ${shortPath(ctx)}${selectedSoul ? ` soul ${selectedSoul.name}` : ""}${home ? ` home ${shortPath(home)}` : ""}`);
|
|
952
|
+
if (result.selected?.identity) console.log(` identity: ${servedIdentityLine(result.selected.identity)}`);
|
|
950
953
|
for (const s of souls) console.log(` soul ${s.name} [${s.kind}${s.capability ? ` ${s.capability}` : ""}] runtime ${s.runtime}${s.model ? ` model ${s.model}` : ""} work ${s.work}${s.editable.fields.length ? "" : " (read-only)"}`);
|
|
951
954
|
for (const l of LAYERS) console.log(` layer ${l}: ${layers[l].id || (layers[l].disabled ? "disabled" : "none")}${layers[l].provenance ? ` (${layers[l].provenance})` : ""}`);
|
|
952
955
|
for (const c of capabilities) console.log(` ${c.id}@${c.version || "?"} ${c.health.status}${c.activation.enabled ? ` active:${c.activation.target}` : " inactive"}${c.operations.length ? ` ops: ${c.operations.map((o) => `${o.name}${o.available ? "" : "(unavailable)"}`).join(", ")}` : ""}`);
|
|
@@ -2343,6 +2346,7 @@ async function status() {
|
|
|
2343
2346
|
for (const i of a.instances) {
|
|
2344
2347
|
console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
|
|
2345
2348
|
const key = i.home ?? `${a.name}/${i.instance}`;
|
|
2349
|
+
if (i.identity) console.log(` identity: ${servedIdentityLine(i.identity)}`);
|
|
2346
2350
|
const s = ws?.soul.get(key);
|
|
2347
2351
|
if (s && (verbose || s.status !== "current")) console.log(` ${soulDriftLine(s, a.name)}`);
|
|
2348
2352
|
const rows = ws?.drift.get(key) || [];
|
|
@@ -3366,7 +3370,7 @@ function versionCmd() {
|
|
|
3366
3370
|
// Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
|
|
3367
3371
|
// runs on resolve/materialize (contract §6); a feature the binary does not implement is
|
|
3368
3372
|
// never listed.
|
|
3369
|
-
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "session-recompose", "readiness-verify", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload"], workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
|
|
3373
|
+
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "session-recompose", "readiness-verify", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity"], workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
|
|
3370
3374
|
return;
|
|
3371
3375
|
}
|
|
3372
3376
|
console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
import { execFileSync } from "node:child_process";
|
|
40
40
|
import { chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
41
41
|
import { hostname } from "node:os";
|
|
42
|
-
import { join, dirname, resolve, delimiter } from "node:path";
|
|
42
|
+
import { join, dirname, resolve, delimiter, isAbsolute } from "node:path";
|
|
43
43
|
import { loadCapturedAwebExecution, requireCapturedAwebAction } from "../lib/captured-execution.mjs";
|
|
44
44
|
import { assessCapturedSessionReadiness, querySelectedKernel } from "../lib/session-readiness.mjs";
|
|
45
45
|
import { runCapturedNative } from "../lib/captured-native.mjs";
|
|
@@ -50,9 +50,11 @@ import { parseBindingJson } from "../lib/binding-wire.mjs";
|
|
|
50
50
|
* property of one helper staying correct forever, while argv removes the class.
|
|
51
51
|
* This hook is a REQUIRED spawn hook, so it gates every spawn, which is reason
|
|
52
52
|
* enough not to rely on quoting. */
|
|
53
|
-
const run = (argv, cwd, timeout = 45000, { secrets = [], secretSafe = false } = {}) => {
|
|
53
|
+
const run = (argv, cwd, timeout = 45000, { secrets = [], secretSafe = false, env: extraEnv, unsetEnv = [] } = {}) => {
|
|
54
54
|
try {
|
|
55
|
-
|
|
55
|
+
const childEnv = extraEnv || unsetEnv.length ? { ...process.env, ...(extraEnv || {}) } : undefined;
|
|
56
|
+
for (const name of unsetEnv) if (childEnv) delete childEnv[name];
|
|
57
|
+
return execFileSync(argv[0], argv.slice(1), { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout, ...(childEnv ? { env: childEnv } : {}) }).trim();
|
|
56
58
|
} catch (e) {
|
|
57
59
|
// execFileSync puts the WHOLE ARGV in e.message ("Command failed: aw team
|
|
58
60
|
// join <token> …"). This hook's failures are reported by the kernel and land
|
|
@@ -113,6 +115,17 @@ const warn = (m) => out({ warning: `oats-aweb: ${String(m).slice(0, 300)}` });
|
|
|
113
115
|
* nonzero so the kernel rolls the spawn back. `meta` carries whatever external
|
|
114
116
|
* state already exists (e.g. a joined identity) so retire can undo it. */
|
|
115
117
|
const fatal = (m, meta) => out({ ...(meta ? { meta } : {}), warning: `oats-aweb: ${String(m).slice(0, 300)}` }, 1);
|
|
118
|
+
const parseAwJson = (text, what) => {
|
|
119
|
+
const trimmed = String(text ?? "").trim();
|
|
120
|
+
if (!trimmed) throw new Error(`${what} returned no JSON result`);
|
|
121
|
+
try { return JSON.parse(trimmed); } catch { /* may have progress before JSON */ }
|
|
122
|
+
const lines = trimmed.split(/\r?\n/);
|
|
123
|
+
for (let i = 0; i < lines.length; i++) {
|
|
124
|
+
if (!lines[i].trimStart().startsWith("{")) continue;
|
|
125
|
+
try { return JSON.parse(lines.slice(i).join("\n")); } catch { /* keep looking */ }
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`${what} returned no JSON result`);
|
|
128
|
+
};
|
|
116
129
|
|
|
117
130
|
// Any selected snapshot enters the captured consumer BEFORE legacy settings,
|
|
118
131
|
// root discovery or identity handling. Invalid-present never falls back.
|
|
@@ -150,6 +163,15 @@ const deliveryMode = (() => {
|
|
|
150
163
|
const v = settings.delivery === undefined || settings.delivery === null || settings.delivery === "" ? "channel" : String(settings.delivery);
|
|
151
164
|
return v === "session" ? "session" : "channel";
|
|
152
165
|
})();
|
|
166
|
+
const identitySettings = settings.identity && typeof settings.identity === "object" && !Array.isArray(settings.identity) ? settings.identity : {};
|
|
167
|
+
const identityMode = identitySettings.mode === undefined || identitySettings.mode === null || identitySettings.mode === "" ? "local" : String(identitySettings.mode);
|
|
168
|
+
if (!["local", "global"].includes(identityMode) && ["spawn", "retire"].includes(event)) fatal(`identity.mode must be either "local" or "global" (got ${JSON.stringify(identitySettings.mode)})`);
|
|
169
|
+
const payloadTeam = () => {
|
|
170
|
+
const fromSettings = typeof settings.team === "string" && settings.team.trim() ? settings.team.trim() : undefined;
|
|
171
|
+
const fromEnv = process.env.OATS_TEAM_ID || process.env.OATS_TEAM_NAME || undefined;
|
|
172
|
+
return { team: fromSettings || fromEnv, payload: fromSettings, env: fromEnv };
|
|
173
|
+
};
|
|
174
|
+
const identityMeta = ({ mode = "local", alias, team, address = null, resident = null, grant }) => ({ mode, alias, team, address: address || null, resident: resident || null, ...(grant ? { grant } : {}) });
|
|
153
175
|
|
|
154
176
|
/**
|
|
155
177
|
* The aweb root (minting authority). BOUNDED candidates — the deployment's team
|
|
@@ -231,6 +253,126 @@ function wakeRegister(instanceHome, identityHome) {
|
|
|
231
253
|
function wakeDeregister(instanceHome) {
|
|
232
254
|
try { run(["aw", "wake", "deregister", "--home", instanceHome], instanceHome, 60000); return true; } catch { return false; }
|
|
233
255
|
}
|
|
256
|
+
const DEFAULT_GRANT_SCOPES = ["mail.read", "mail.send", "chat.read", "chat.send"];
|
|
257
|
+
const residentKeyHint = (name) => `oats-local.yaml settings.oats.aweb.residents.${name || "<name>"}`;
|
|
258
|
+
function resolveResidentCustody(name) {
|
|
259
|
+
if (!name) fatal(`identity.mode "global" requires identity.resident; set ${residentKeyHint("<name>")} to the absolute custody directory for that resident identity`);
|
|
260
|
+
const residents = settings.residents && typeof settings.residents === "object" && !Array.isArray(settings.residents) ? settings.residents : {};
|
|
261
|
+
const custody = residents[name];
|
|
262
|
+
if (typeof custody !== "string" || !isAbsolute(custody) || !existsSync(join(custody, ".aw", "identity.yaml"))) {
|
|
263
|
+
fatal(`identity.mode "global" resident ${JSON.stringify(name)} is not resolvable; set ${residentKeyHint(name)} to an absolute custody directory whose .aw/identity.yaml exists`);
|
|
264
|
+
}
|
|
265
|
+
return custody;
|
|
266
|
+
}
|
|
267
|
+
function grantShow(custody, grantId) {
|
|
268
|
+
const raw = run(["aw", "id", "grant", "show", grantId, "--json"], custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] });
|
|
269
|
+
return parseAwJson(raw, "aw id grant show");
|
|
270
|
+
}
|
|
271
|
+
const revokeMaybeApplied = (error) => /may have applied|context deadline exceeded|timed out|timeout/i.test(String(error?.message || error));
|
|
272
|
+
function revokeGrant(custody, grantId) {
|
|
273
|
+
try {
|
|
274
|
+
const raw = run(["aw", "id", "grant", "revoke", grantId, "--json"], custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] });
|
|
275
|
+
return parseAwJson(raw, "aw id grant revoke");
|
|
276
|
+
} catch (e) {
|
|
277
|
+
if (revokeMaybeApplied(e)) {
|
|
278
|
+
try {
|
|
279
|
+
const shown = grantShow(custody, grantId);
|
|
280
|
+
const status = String(shown.status || shown.grant?.status || "").toLowerCase();
|
|
281
|
+
if (status === "revoked" || shown.revoked === true) return { grant_id: grantId, status: "revoked", verifiedByShow: true };
|
|
282
|
+
} catch { /* fall through to original revoke error */ }
|
|
283
|
+
}
|
|
284
|
+
throw e;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function recoverGrantHome(grantHome) {
|
|
288
|
+
try {
|
|
289
|
+
const text = readFileSync(join(grantHome, "grant.yaml"), "utf8");
|
|
290
|
+
const scalar = (key) => {
|
|
291
|
+
const m = text.match(new RegExp(`^\\s*${key}:\\s*["']?([^"'\\n#]+)["']?\\s*$`, "m"));
|
|
292
|
+
return m ? m[1].trim() : undefined;
|
|
293
|
+
};
|
|
294
|
+
return { grantId: scalar("grant_id"), team: scalar("team_id"), expiresAt: scalar("expires_at") };
|
|
295
|
+
} catch { return {}; }
|
|
296
|
+
}
|
|
297
|
+
function globalGrantSpawn() {
|
|
298
|
+
const { team, payload, env: envTeam } = payloadTeam();
|
|
299
|
+
if (!team) fatal("identity.mode \"global\" requires settings.oats.aweb.team (or OATS_TEAM_ID/OATS_TEAM_NAME) before minting a grant");
|
|
300
|
+
const resident = String(identitySettings.resident || "");
|
|
301
|
+
const custody = resolveResidentCustody(resident);
|
|
302
|
+
const grantHome = join(home, ".aweb-identity");
|
|
303
|
+
if (existsSync(grantHome)) fatal(`${grantHome} already exists; refusing to overwrite an existing aweb session grant home`);
|
|
304
|
+
const scopes = Array.isArray(identitySettings.scopes) && identitySettings.scopes.length ? identitySettings.scopes.map(String) : DEFAULT_GRANT_SCOPES;
|
|
305
|
+
const ttl = identitySettings.ttl === undefined || identitySettings.ttl === null || identitySettings.ttl === "" ? "8h" : String(identitySettings.ttl);
|
|
306
|
+
let meta;
|
|
307
|
+
const cleanup = () => { try { rmSync(grantHome, { recursive: true, force: true }); } catch { /* best effort */ } };
|
|
308
|
+
const failAfterMint = (message, code = 1) => { cleanup(); out({ ...(meta ? { meta } : {}), warning: `oats-aweb: ${String(message).slice(0, 300)}` }, code); };
|
|
309
|
+
try {
|
|
310
|
+
const raw = run(["aw", "id", "grant", "mint", "--scope", scopes.join(","), "--ttl", ttl, "--label", `oats:${instance}`, "--out", grantHome, "--json"], custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] });
|
|
311
|
+
let minted;
|
|
312
|
+
try { minted = parseAwJson(raw, "aw id grant mint"); }
|
|
313
|
+
catch (parseError) {
|
|
314
|
+
const recovered = recoverGrantHome(grantHome);
|
|
315
|
+
if (recovered.grantId) {
|
|
316
|
+
meta = { delivery: deliveryMode, identity: identityMeta({ mode: "global", alias: resident, team: recovered.team || team, resident, grant: { id: recovered.grantId, expiresAt: recovered.expiresAt || "unknown", scopes } }) };
|
|
317
|
+
try { revokeGrant(custody, recovered.grantId); failAfterMint(`${parseError.message}; recovered grant ${recovered.grantId} from grant.yaml, revoked it, and removed the grant home`); }
|
|
318
|
+
catch (revokeError) { failAfterMint(`${parseError.message}; recovered grant ${recovered.grantId} from grant.yaml, but revoke failed: ${revokeError.message || revokeError}`); }
|
|
319
|
+
}
|
|
320
|
+
throw parseError;
|
|
321
|
+
}
|
|
322
|
+
const grantId = typeof minted.grant_id === "string" ? minted.grant_id : undefined;
|
|
323
|
+
const expiresAt = typeof minted.expires_at === "string" ? minted.expires_at : undefined;
|
|
324
|
+
const mintedTeam = typeof minted.team_id === "string" ? minted.team_id : undefined;
|
|
325
|
+
const mintedOut = typeof minted.out === "string" ? minted.out : undefined;
|
|
326
|
+
if (!grantId || !expiresAt || !mintedTeam || !mintedOut) throw new Error("aw id grant mint JSON lacked grant_id, expires_at, team_id, or out");
|
|
327
|
+
if (resolve(mintedOut) !== resolve(grantHome)) throw new Error(`aw id grant mint wrote ${mintedOut}, not ${grantHome}`);
|
|
328
|
+
const alias = typeof minted.alias === "string" && minted.alias ? minted.alias : resident;
|
|
329
|
+
const address = typeof minted.address === "string" && minted.address ? minted.address : null;
|
|
330
|
+
meta = { delivery: deliveryMode, identity: identityMeta({ mode: "global", alias, team: mintedTeam, address, resident, grant: { id: grantId, expiresAt, scopes } }) };
|
|
331
|
+
if (mintedTeam !== team) {
|
|
332
|
+
try { revokeGrant(custody, grantId); failAfterMint(`minted grant team ${mintedTeam} differs from ${team}; the grant was revoked and nothing was kept`); }
|
|
333
|
+
catch (e) { failAfterMint(`minted grant team ${mintedTeam} differs from ${team}; revoke failed: ${e.message || e}`); }
|
|
334
|
+
}
|
|
335
|
+
const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
|
|
336
|
+
? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
|
|
337
|
+
: undefined;
|
|
338
|
+
const env = { ...(deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : {}), AWEB_IDENTITY_HOME: grantHome };
|
|
339
|
+
if (deliveryMode === "session") {
|
|
340
|
+
try { wakeRegister(home, grantHome); }
|
|
341
|
+
catch (e) {
|
|
342
|
+
try { revokeGrant(custody, grantId); failAfterMint(`session delivery registration failed for minted grant ${grantId}: ${e.message || e}; grant revoked and grant home removed`); }
|
|
343
|
+
catch (revokeError) { failAfterMint(`session delivery registration failed for minted grant ${grantId}: ${e.message || e}; revoke failed: ${revokeError.message || revokeError}`); }
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const warnings = [];
|
|
347
|
+
if (payload && envTeam && payload !== envTeam) warnings.push(`oats-aweb: settings.oats.aweb.team ${payload} differs from OATS team ${envTeam}; using payload team`);
|
|
348
|
+
const deliveryBrief = deliveryMode === "session"
|
|
349
|
+
? ` Notification delivery: external (AWEB_DELIVERY=session): the host wake broker (aw wake) is registered for this home and nudges you when mail or chat arrives; the native aweb channel is not running. If you have waited long with nothing arriving, check \`aw mail inbox\` and \`aw chat pending\` yourself at task boundaries.`
|
|
350
|
+
: "";
|
|
351
|
+
out({
|
|
352
|
+
meta,
|
|
353
|
+
env,
|
|
354
|
+
brief: `Comms: you act as resident aweb identity "${alias}" on team ${mintedTeam} through a session grant for ${resident}; scopes: ${scopes.join(", ")}; expires: ${expiresAt}. Root keys are not in this home, and identity lifecycle commands are not yours to run.${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
|
|
355
|
+
...(launch ? { launch } : {}),
|
|
356
|
+
...(warnings.length ? { warning: warnings.join(" | ") } : {}),
|
|
357
|
+
});
|
|
358
|
+
} catch (e) {
|
|
359
|
+
if (meta?.identity?.grant?.id) { try { revokeGrant(custody, meta.identity.grant.id); } catch { /* retire compensation gets meta */ } cleanup(); }
|
|
360
|
+
fatal(`identity grant minting failed: ${e.message || e}`, meta);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function globalGrantRetire(meta) {
|
|
364
|
+
if (meta.delivery === "session") { if (!wakeDeregister(home)) process.stderr.write("oats-aweb: aw wake deregister failed; the broker treats a retired home as inactive on its own\n"); }
|
|
365
|
+
const id = meta.identity?.grant?.id;
|
|
366
|
+
if (!id) out({ meta: { retired: false, reason: "nothing-to-revoke" } });
|
|
367
|
+
const resident = meta.identity?.resident;
|
|
368
|
+
const custody = resolveResidentCustody(resident);
|
|
369
|
+
try {
|
|
370
|
+
revokeGrant(custody, id);
|
|
371
|
+
out({ meta: { retired: true, identityRevoked: true, grant: id } });
|
|
372
|
+
} catch (e) {
|
|
373
|
+
out({ meta: { retired: false, reason: "grant-revoke-failed", grant: id }, warning: `oats-aweb: grant ${id} was not revoked (${e.message || e}); it still expires at ${meta.identity?.grant?.expiresAt || "its TTL"}` }, 1);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
234
376
|
const seatLockPath = (source) => join(dirname(source), ".aw-retained-seat.json");
|
|
235
377
|
/** The alias a home's .aw/workspace.yaml records under memberships (indented),
|
|
236
378
|
* or undefined. Read only when the hook has no alias of its own. */
|
|
@@ -357,7 +499,7 @@ function retainedSeatSpawn(source, takeOver) {
|
|
|
357
499
|
if (takenOver) warnings.push(`oats-aweb: took over the retained identity from ${takenOver} on identity.takeOver: true; if that runtime was still alive there are now two seats with one key — stop the old one`);
|
|
358
500
|
if (hostNote) warnings.push(`oats-aweb: seated${hostNote}`);
|
|
359
501
|
out({
|
|
360
|
-
meta: { team, alias, retained: true, source, lock: lockPath, delivery: deliveryMode, ...(takenOver ? { tookOverFrom: takenOver } : {}) },
|
|
502
|
+
meta: { team, alias, retained: true, source, lock: lockPath, delivery: deliveryMode, identity: identityMeta({ mode: "global", alias, team, address: shownAddress || expectedAddress || null }), ...(takenOver ? { tookOverFrom: takenOver } : {}) },
|
|
361
503
|
...(env ? { env } : {}),
|
|
362
504
|
brief: `Comms: you are the retained seat of the existing aweb identity "${alias}" on team ${team} (same did and address as the seat you replace; its contacts, routes and conversations are yours).${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill).`,
|
|
363
505
|
...(launch ? { launch } : {}),
|
|
@@ -370,7 +512,9 @@ function retainedSeatSpawn(source, takeOver) {
|
|
|
370
512
|
}
|
|
371
513
|
|
|
372
514
|
if (event === "spawn") {
|
|
373
|
-
if (
|
|
515
|
+
if (identityMode === "global" && identitySettings.source) fatal('identity.mode "global" cannot be combined with identity.source; use identity.mode "local" with identity.source for a retained seat, or identity.mode "global" with identity.resident for a resident grant');
|
|
516
|
+
if (identityMode === "global") globalGrantSpawn();
|
|
517
|
+
if (identityMode === "local" && settings.identity && typeof settings.identity === "object" && settings.identity.source) retainedSeatSpawn(String(settings.identity.source), settings.identity.takeOver === true);
|
|
374
518
|
let minted; // external identity, once `aw team join` succeeds
|
|
375
519
|
const root = awebRoot();
|
|
376
520
|
if (!root) fatal(`no initialized aweb root (.aw) among the bounded candidates (home, its git repo, context repo, workspace ${process.env.OATS_WORKSPACE || "?"}), so no identity could be minted and this instance would have no messaging — run \`oats aweb setup\` for guided onboarding`);
|
|
@@ -380,7 +524,9 @@ if (event === "spawn") {
|
|
|
380
524
|
// team happens to be active at mint time — and verify the joined cert matches.
|
|
381
525
|
// The instance name IS the discoverable alias (the team roster doubles as the
|
|
382
526
|
// cross-machine instance directory).
|
|
383
|
-
|
|
527
|
+
const resolvedTeam = payloadTeam();
|
|
528
|
+
let team = resolvedTeam.team;
|
|
529
|
+
const teamPayloadMismatch = resolvedTeam.payload && resolvedTeam.env && resolvedTeam.payload !== resolvedTeam.env;
|
|
384
530
|
if (!team) team = JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
|
|
385
531
|
if (!team) fatal("cannot determine target team (no config team block, no active team at root), so no identity could be minted — set a team: block in oats-config.yaml, or activate a team at the aweb root");
|
|
386
532
|
// A bare team name (no namespace) resolves against the root's memberships.
|
|
@@ -437,7 +583,7 @@ if (event === "spawn") {
|
|
|
437
583
|
run(["aw", "init", "--do-not-touch-agents-md"], home);
|
|
438
584
|
const alias = joined.alias;
|
|
439
585
|
const mismatch = joined.team_id !== team
|
|
440
|
-
? ` [WARNING: joined ${joined.team_id}, expected ${team}]` : "";
|
|
586
|
+
? ` [WARNING: joined ${joined.team_id}, expected ${team}]` : teamPayloadMismatch ? ` [WARNING: settings team ${resolvedTeam.payload} differs from OATS team ${resolvedTeam.env}; using payload team]` : "";
|
|
441
587
|
// Runtime integration: for Claude Code sessions the aweb-channel plugin
|
|
442
588
|
// carries real-time push events. This hook does NOT install it. The plugin
|
|
443
589
|
// is a DECLARED runtime requirement (oats.json), consented once at
|
|
@@ -458,11 +604,11 @@ if (event === "spawn") {
|
|
|
458
604
|
? ` Notification delivery: external (AWEB_DELIVERY=session): the host wake broker (aw wake) is registered for this home and nudges you when mail or chat arrives; the native aweb channel is not running. If you have waited long with nothing arriving, check \`aw mail inbox\` and \`aw chat pending\` yourself at task boundaries.`
|
|
459
605
|
: "";
|
|
460
606
|
out({
|
|
461
|
-
meta: { team: joined.team_id, alias, delivery: deliveryMode },
|
|
607
|
+
meta: { team: joined.team_id, alias, delivery: deliveryMode, identity: identityMeta({ mode: "local", alias, team: joined.team_id }) },
|
|
462
608
|
...(env ? { env } : {}),
|
|
463
609
|
brief: `Comms: you have an aweb identity — alias "${alias}" on team ${joined.team_id}.${mismatch}${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
|
|
464
610
|
...(launch ? { launch } : {}),
|
|
465
|
-
...(
|
|
611
|
+
...(joined.team_id !== team ? { warning: `oats-aweb: team mismatch — joined ${joined.team_id}, expected ${team}` } : teamPayloadMismatch ? { warning: `oats-aweb: settings.oats.aweb.team ${resolvedTeam.payload} differs from OATS team ${resolvedTeam.env}; using payload team` } : channelWarning ? { warning: channelWarning } : {}),
|
|
466
612
|
});
|
|
467
613
|
} catch (e) {
|
|
468
614
|
// A join may already have created a REMOTE identity before the failure.
|
|
@@ -480,6 +626,7 @@ if (event === "spawn") {
|
|
|
480
626
|
if (meta.lock) { try { rmSync(meta.lock, { force: true }); } catch { /* the lock may already be gone */ } }
|
|
481
627
|
out({ meta: { retired: true, retained: true, identityReleased: true, ...(meta.tookOverFrom ? { tookOverFrom: meta.tookOverFrom } : {}) }, warning: `oats-aweb: released the retained identity "${meta.alias}" (lock ${meta.lock || "?"} removed); the identity itself and ${meta.source || "its source"} are untouched${meta.tookOverFrom ? `; this seat had taken over from ${meta.tookOverFrom}` : ""}` });
|
|
482
628
|
}
|
|
629
|
+
if (meta.identity?.mode === "global") globalGrantRetire(meta);
|
|
483
630
|
// No alias means the spawn hook never reported an identity: nothing exists to
|
|
484
631
|
// undo, which is completion. An alias WITH no local `.aw` is the opposite —
|
|
485
632
|
// the remote record exists and its key is gone, so the self-delete cannot be
|
|
@@ -4,6 +4,13 @@ Your messaging layer is **aweb**. You have (or will be minted) a team-scoped
|
|
|
4
4
|
aweb identity — alias = your instance name — on your deployment's team (see
|
|
5
5
|
`instance.json` / your TASK.md briefing for the team).
|
|
6
6
|
|
|
7
|
+
Some instances serve a resident global identity through an expiring session
|
|
8
|
+
grant instead of holding their own root keys. In that mode, your TASK.md and
|
|
9
|
+
`instance.json` identify the resident alias, scopes, and expiry; root keys are
|
|
10
|
+
not in your home, and mint/revoke/join/identity-lifecycle commands are not yours
|
|
11
|
+
to run. If `aw mail` or `aw chat` says the grant expired or was revoked, stop
|
|
12
|
+
messaging and report the condition to your coordinator/human.
|
|
13
|
+
|
|
7
14
|
**Load the skills at the right moments — do not work from memory:**
|
|
8
15
|
|
|
9
16
|
- **Before your first `aw mail`/`aw chat` of a session**, load the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"capability": "oats.aweb",
|
|
3
3
|
"command": "aweb",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.12.0",
|
|
5
5
|
"compatibility": {
|
|
6
6
|
"oats": ">=0.24.4"
|
|
7
7
|
},
|
|
@@ -124,7 +124,8 @@
|
|
|
124
124
|
"retire": "bin/oats-aweb.mjs retire"
|
|
125
125
|
},
|
|
126
126
|
"environment": [
|
|
127
|
-
"AWEB_DELIVERY"
|
|
127
|
+
"AWEB_DELIVERY",
|
|
128
|
+
"AWEB_IDENTITY_HOME"
|
|
128
129
|
],
|
|
129
130
|
"settings": {
|
|
130
131
|
"delivery": {
|
|
@@ -134,6 +135,18 @@
|
|
|
134
135
|
"session"
|
|
135
136
|
],
|
|
136
137
|
"description": "channel: the native aweb channel packages wake the instance (default). session: delivery is external (AWEB_DELIVERY=session), no channel flag; the host wake broker registers the instance once it exists."
|
|
138
|
+
},
|
|
139
|
+
"team": {
|
|
140
|
+
"description": "Target aweb team id for identity lifecycle. In workspace v2 spawns this payload value wins over OATS_TEAM_ID/OATS_TEAM_NAME; if both are set and differ the hook warns and uses this setting."
|
|
141
|
+
},
|
|
142
|
+
"identity": {
|
|
143
|
+
"default": {
|
|
144
|
+
"mode": "local"
|
|
145
|
+
},
|
|
146
|
+
"description": "Identity selection. mode=local (default) mints an instance-local team identity or uses the existing identity.source retained-seat flow. mode=global acts as a named resident identity through a session grant; requires identity.resident and optional scopes/ttl."
|
|
147
|
+
},
|
|
148
|
+
"residents": {
|
|
149
|
+
"description": "Host-owned map for global mode: resident name to absolute custody directory whose .aw holds the resident root keys and team certificate. Put this only in oats-local.yaml settings.oats.aweb.residents; committed workspace or soul files must never carry custody paths."
|
|
137
150
|
}
|
|
138
151
|
},
|
|
139
152
|
"environmentNamespaces": [
|
|
@@ -4,7 +4,7 @@ import { fs, join, dirname, resolve, readJSON, save, safePath, cliPath, oats, fa
|
|
|
4
4
|
import { loadBindings, declaration, splitRef } from '../lib/config.mjs';
|
|
5
5
|
import { register, registerCaptured, loadInvocationSourceReceipt, homeSource, loadSource, loadStatus, saveStatus, updateStatus, capture, scheduleSource, settleRetiredSchedule, service, markerPath, views } from '../lib/sources.mjs';
|
|
6
6
|
import { runSource, complete, retry, readRun, requireQualifiedHelper } from '../lib/worker.mjs';
|
|
7
|
-
import { initBase, migrate, deliverMigration, cutoverMigration, migrateSource } from '../lib/migration.mjs';
|
|
7
|
+
import { initBase, migrate, deliverMigration, cutoverMigration, migrateSource, forgetMigration } from '../lib/migration.mjs';
|
|
8
8
|
import { inspect } from '../lib/inspection.mjs';
|
|
9
9
|
import { loadInvocationKnowledgeBinding } from '../lib/binding-wire.mjs';
|
|
10
10
|
import { loadCapturedOkfInvocation, loadOkfSourceReceiptInput, assertOkfInvocationAction, requireOkfAdmittedAction, assertOkfSourceContext, assertOkfRegisteredSourceReplay } from '../lib/invocation-context.mjs';
|
|
@@ -20,6 +20,7 @@ oats okf init --base ALIAS --nodes FILE [--output PATH | --confirm] [--json]
|
|
|
20
20
|
oats okf migrate --legacy PATH --base ALIAS --node NODE --output PATH [--json]
|
|
21
21
|
oats okf migrate --deliver FILE | --cutover FILE --soul-dir PATH [--json]
|
|
22
22
|
oats okf migrate --source-home PATH [--json]
|
|
23
|
+
oats okf migrate --forget ID [--json]
|
|
23
24
|
oats okf unlock --lock PATH --token TOKEN [--json]
|
|
24
25
|
Captured workers use oats operation run knowledge:harvest with SOURCE --deployment/--resolution/--home,
|
|
25
26
|
--arg native-request=ABS_BACKEND_ONLY_JSON and optional --arg worker-mode=prepare|launch.
|
|
@@ -167,6 +168,7 @@ else {
|
|
|
167
168
|
} else if(event==='init') result=initBase(loadBindings(),flags.base,flags.nodes,flags.output,{confirm:!!flags.confirm});
|
|
168
169
|
else if(event==='migrate') {
|
|
169
170
|
if(flags['source-home']) result=migrateSource(loadBindings(),flags['source-home']);
|
|
171
|
+
else if(flags.forget) result=forgetMigration(loadBindings(),flags.forget);
|
|
170
172
|
else if(flags.deliver) result=deliverMigration(resolve(flags.deliver));
|
|
171
173
|
else if(flags.cutover) result=cutoverMigration(resolve(flags.cutover),flags['soul-dir']);
|
|
172
174
|
else result=migrate(loadBindings(),{legacy:flags.legacy,alias:flags.base,node:flags.node,output:flags.output});
|
|
@@ -7,12 +7,16 @@ function keys(value, allowed, label, code='E_CONFIG') {
|
|
|
7
7
|
}
|
|
8
8
|
export function settings() {
|
|
9
9
|
const s = JSON.parse(process.env.OATS_SETTINGS || '{}');
|
|
10
|
-
keys(s,['bindings-file','state-dir','harvest-runtime','harvest-model'],'OATS_SETTINGS');
|
|
10
|
+
keys(s,['bindings-file','state-dir','harvest-runtime','harvest-model','git-timeout'],'OATS_SETTINGS');
|
|
11
|
+
if(s['git-timeout']!==undefined && (!Number.isInteger(s['git-timeout']) || s['git-timeout']<1)) fail('E_CONFIG','git-timeout must be a positive integer number of seconds');
|
|
11
12
|
if(s['state-dir']!==undefined && (typeof s['state-dir']!=='string' || !isAbsolute(s['state-dir']) || resolve(s['state-dir'])!==s['state-dir'])) fail('E_CONFIG','state-dir must be a normalized absolute path');
|
|
12
13
|
if(s['harvest-runtime']!==undefined && !['pi','claude','codex'].includes(s['harvest-runtime'])) fail('E_CONFIG','invalid harvest-runtime');
|
|
13
14
|
if(s['harvest-model']!==undefined && (typeof s['harvest-model']!=='string' || !s['harvest-model'].trim())) fail('E_CONFIG','harvest-model must be a nonempty string');
|
|
14
15
|
return s;
|
|
15
16
|
}
|
|
17
|
+
/** Time budget for Git operations that talk to a remote (clone, fetch, push,
|
|
18
|
+
* ls-remote). Local object reads keep the short exec default. */
|
|
19
|
+
export function gitTimeoutMs() { return (settings()['git-timeout'] ?? 600)*1000; }
|
|
16
20
|
export function noGit(path) {
|
|
17
21
|
for (let p = safePath(path); ; p = dirname(p)) {
|
|
18
22
|
if (fs.existsSync(join(p, '.git')) || (fs.existsSync(join(p, 'HEAD')) && fs.existsSync(join(p, 'objects')) && fs.existsSync(join(p, 'refs')))) fail('E_DIRECTORY_GIT', `directory store is in Git custody: ${p}; use kind git`);
|
|
@@ -70,7 +70,7 @@ export function unlock(path, token) {
|
|
|
70
70
|
try { process.kill(o.pid, 0); fail('E_LOCKED', 'lock owner is still alive'); } catch (e) { if(e.code !== 'ESRCH') throw e; }
|
|
71
71
|
fs.rmSync(path, { recursive: true }); syncDir(dirname(path)); return { unlocked: path };
|
|
72
72
|
}
|
|
73
|
-
export const identityKeys = ['OATS_INSTANCE','OATS_INSTANCE_HOME','OATS_HOME','PI_AGENT_HOME','PI_AGENT_NAME','PI_AGENT_INSTANCE','PI_AGENTS_ROOT','OATS_ROOT','OATS_SOUL','OATS_AGENT','OATS_KIND','OATS_EVENT','OATS_CONTEXT','OATS_REPO','OATS_WORK','OATS_BRANCH','OATS_META','OATS_SETTINGS','OATS_BINDING_FILE','OATS_SOURCE_RECEIPT_FILE','OATS_INVOCATION_CONTEXT_FILE','OATS_DEPLOYMENT','OATS_RESOLUTION'];
|
|
73
|
+
export const identityKeys = ['OATS_INSTANCE','OATS_INSTANCE_HOME','OATS_HOME','PI_AGENT_HOME','PI_AGENT_NAME','PI_AGENT_INSTANCE','PI_AGENTS_ROOT','OATS_ROOT','OATS_SOUL','OATS_SOUL_ID','OATS_AGENT','OATS_KIND','OATS_EVENT','OATS_CONTEXT','OATS_REPO','OATS_WORK','OATS_BRANCH','OATS_META','OATS_SETTINGS','OATS_BINDING_FILE','OATS_SOURCE_RECEIPT_FILE','OATS_INVOCATION_CONTEXT_FILE','OATS_DEPLOYMENT','OATS_RESOLUTION'];
|
|
74
74
|
export function cleanEnv(env = process.env) {
|
|
75
75
|
return Object.fromEntries(Object.entries(env).filter(([k]) => !/^(OATS_(?!HOME_DIR$|PACKAGE_CATALOG$)|PI_AGENT|GIT_)/.test(k)));
|
|
76
76
|
}
|
|
@@ -30,10 +30,16 @@ export function migrate(bindings,{legacy,alias,node,output}) {
|
|
|
30
30
|
if(paths.some(path=>overlaps(path,output))) fail('E_PATH','migration stage overlaps configured accepted base or coordination artifacts');
|
|
31
31
|
}
|
|
32
32
|
const original=tree(legacy);
|
|
33
|
+
// Stage the accepted base before any record exists: a base that cannot be
|
|
34
|
+
// read leaves nothing behind. From the record on, a failure is recorded in
|
|
35
|
+
// it rather than left as an unexplained directory.
|
|
36
|
+
const stage=stageBase(base,output);
|
|
33
37
|
fs.mkdirSync(bindings.stateDir,{recursive:true,mode:0o700});
|
|
34
38
|
const id=randomUUID();const dir=join(bindings.stateDir,'migrations',id);fs.mkdirSync(dir,{recursive:true,mode:0o700});
|
|
35
39
|
save(join(dir,'legacy.json'),original); // byte-preserving backup BEFORE any delivery
|
|
36
|
-
const
|
|
40
|
+
const recordFile=join(dir,'migration.json');
|
|
41
|
+
try {
|
|
42
|
+
const spec=stage.meta.nodes[node];if(!spec) fail('E_OWNER','migration node must be explicitly provisioned first');
|
|
37
43
|
const prefix=spec.path+'/';
|
|
38
44
|
if(Object.keys(stage.files).some(p=>p.startsWith(prefix) && ![prefix+'index.md',prefix+'log.md'].includes(p))) fail('E_MIGRATION','target node is not empty; merge migration requires human judgment');
|
|
39
45
|
const after={...stage.files};for(const p of Object.keys(after)) if(p.startsWith(prefix)) delete after[p];
|
|
@@ -48,8 +54,23 @@ export function migrate(bindings,{legacy,alias,node,output}) {
|
|
|
48
54
|
// Replace only the empty pre-provisioned node, not unrelated accepted bytes.
|
|
49
55
|
fs.rmSync(join(stage.root,spec.path),{recursive:true});materialize(stage.root,after);validateBase(stage.root,base);
|
|
50
56
|
const proposalFile=join(dir,'proposal.json');const proposal={version:1,run:id,created:new Date().toISOString(),file:proposalFile,before:stage.files,after};save(proposalFile,proposal);
|
|
51
|
-
const record={version:1,id,alias,node,base,owner:spec.owner,legacy,originalDigest:digest(original),stage:{root:stage.root,checkout:stage.checkout,head:stage.head},proposal:proposalFile,proposalHash:hash(proposal),receipt:{status:'staged'}};save(
|
|
52
|
-
return {status:'staged',migration:
|
|
57
|
+
const record={version:1,id,alias,node,base,owner:spec.owner,legacy,originalDigest:digest(original),stage:{root:stage.root,checkout:stage.checkout,head:stage.head},proposal:proposalFile,proposalHash:hash(proposal),receipt:{status:'staged'}};save(recordFile,record);
|
|
58
|
+
return {status:'staged',migration:recordFile,originalPreserved:true,stage:stage.root};
|
|
59
|
+
} catch(e) {
|
|
60
|
+
save(recordFile,{version:1,id,alias,node,base,legacy,originalDigest:digest(original),receipt:{status:'failed',error:e.message,code:e.code||'E_MIGRATION',at:new Date().toISOString()}});
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Remove a migration record that never delivered anything (staged or failed).
|
|
65
|
+
* The backup it holds is the legacy bundle, which still exists at its source;
|
|
66
|
+
* a delivered record is custody and stays. */
|
|
67
|
+
export function forgetMigration(bindings,id) {
|
|
68
|
+
identifier(id);const dir=join(bindings.stateDir,'migrations',id);const file=join(dir,'migration.json');
|
|
69
|
+
if(!fs.existsSync(dir)) fail('E_MIGRATION',`unknown migration ${id}`);
|
|
70
|
+
const status=fs.existsSync(file)?readJSON(file).receipt?.status:'incomplete';
|
|
71
|
+
if(!['failed','staged','incomplete'].includes(status)) fail('E_MIGRATION',`migration ${id} is ${status}: a delivered migration is custody and cannot be forgotten`);
|
|
72
|
+
fs.rmSync(dir,{recursive:true,force:true});
|
|
73
|
+
return {status:'forgotten',id,was:status};
|
|
53
74
|
}
|
|
54
75
|
export function deliverMigration(file) {
|
|
55
76
|
safePath(file);const m=readJSON(file);const proposal=readJSON(m.proposal);if(hash(proposal)!==m.proposalHash) fail('E_MIGRATION','migration proposal changed');
|
|
@@ -195,6 +195,7 @@ export function register(home) {
|
|
|
195
195
|
const soul=fs.realpathSync(process.env.OATS_SOUL || join(home,'soul'));
|
|
196
196
|
const work=fs.existsSync(join(home,'work'))?fs.realpathSync(join(home,'work')):join(home,'work');
|
|
197
197
|
const decl=declaration(soul);
|
|
198
|
+
const soulId=process.env.OATS_SOUL_ID || null;
|
|
198
199
|
const bindings=loadBindings(undefined,{sourceHome:home,sourceWork:work});
|
|
199
200
|
const context=fs.realpathSync(process.env.OATS_CONTEXT || meta.repo || fail('E_CONFIG','source requires durable config context'));
|
|
200
201
|
if(overlaps(home,context) && context.startsWith(home)) fail('E_PATH','config context cannot be in disposable home');
|
|
@@ -203,11 +204,7 @@ export function register(home) {
|
|
|
203
204
|
if(!agent || !instance) fail('E_SOURCE','source instance/agent required');
|
|
204
205
|
fs.mkdirSync(bindings.stateDir,{recursive:true,mode:0o700});
|
|
205
206
|
const ownersFile=join(bindings.stateDir,'owners.json');
|
|
206
|
-
|
|
207
|
-
const owners=fs.existsSync(ownersFile)?readJSON(ownersFile):{};
|
|
208
|
-
if(Object.hasOwn(owners,decl.owner) && owners[decl.owner]!==soul) fail('E_OWNER','stable owner ID already identifies a different soul in this state namespace');
|
|
209
|
-
owners[decl.owner]=soul;save(ownersFile,owners);
|
|
210
|
-
});
|
|
207
|
+
pinOwner(ownersFile,decl.owner,{id:soulId,soulName:agent,path:soul});
|
|
211
208
|
const id=randomUUID(); const dir=join(bindings.stateDir,'sources',id);
|
|
212
209
|
// Copy only the role document, never instance.json wholesale, launch recipes,
|
|
213
210
|
// environment, credentials, source worktree, or third-party message stores.
|
|
@@ -235,6 +232,24 @@ export function register(home) {
|
|
|
235
232
|
}
|
|
236
233
|
return finishRegistration({...source,file});
|
|
237
234
|
}
|
|
235
|
+
/** Pin a stable owner id to the soul it identifies. The kernel names a soul by
|
|
236
|
+
* identity (OATS_SOUL_ID: repository key plus soul name) so the pin survives
|
|
237
|
+
* the per-commit soul copies a workspace deployment materializes; a classic
|
|
238
|
+
* soul keeps the resolved path. A row written by an earlier version as a path
|
|
239
|
+
* under agents/<same soul name>/(soul|souls/<commit>) is rewritten to the
|
|
240
|
+
* identity once; any other mismatch is a different soul and is refused. */
|
|
241
|
+
export function pinOwner(ownersFile,owner,{id,soulName,path}) {
|
|
242
|
+
const value=id || path;
|
|
243
|
+
return withLock(join(dirname(ownersFile),'owners.lock'),()=>{
|
|
244
|
+
const owners=fs.existsSync(ownersFile)?readJSON(ownersFile):{};
|
|
245
|
+
if(!obj(owners)) fail('E_OWNER','invalid owner registry');
|
|
246
|
+
const prior=Object.hasOwn(owners,owner)?owners[owner]:undefined;
|
|
247
|
+
const samePath=typeof prior==='string' && new RegExp(`/agents/${soulName.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}/(soul|souls/[^/]+)$`).test(prior);
|
|
248
|
+
if(prior!==undefined && prior!==value && !(id && samePath)) fail('E_OWNER','stable owner ID already identifies a different soul in this state namespace');
|
|
249
|
+
if(prior!==value) {owners[owner]=value;save(ownersFile,owners);}
|
|
250
|
+
return value;
|
|
251
|
+
});
|
|
252
|
+
}
|
|
238
253
|
function enqueue(source,status,payload) {
|
|
239
254
|
const id=hash(payload);const path=join(dirname(source.file),'inputs',`${id}.json`);
|
|
240
255
|
if(!fs.existsSync(path)) save(path,payload);
|
|
@@ -246,19 +261,35 @@ export function input(source,id) {
|
|
|
246
261
|
const value=readJSON(join(dirname(source.file),'inputs',`${id}.json`));
|
|
247
262
|
if(hash(value)!==id) fail('E_INPUT','durable evidence hash mismatch');return value;
|
|
248
263
|
}
|
|
249
|
-
/**
|
|
250
|
-
*
|
|
264
|
+
/** Take a drained, retired source's job out of the kernel scheduler. The job is
|
|
265
|
+
* first switched off, then removed: the definition it carried is kept as
|
|
266
|
+
* evidence in this source's own schedule.json, so nothing about the run is
|
|
267
|
+
* lost, and `oats schedule list` stops accumulating dead okf-<id> rows. A job
|
|
268
|
+
* that is still running (or has unresolved effects) stays disabled and is
|
|
269
|
+
* removed on the worker's next settle. Idempotent; a scheduler failure is
|
|
270
|
+
* recorded, never thrown — the evidence is already safe. */
|
|
251
271
|
export function settleRetiredSchedule(source) {
|
|
252
272
|
const status=loadStatus(source);
|
|
253
|
-
if(status.schedule?.
|
|
273
|
+
if(status.schedule?.removed===true) return {status:'already-removed',id:status.schedule.id};
|
|
254
274
|
if(!status.retired || status.auto || status.schedule?.id===undefined) return {status:'kept'};
|
|
275
|
+
const id=status.schedule.id;
|
|
276
|
+
const mark=(patch)=>updateStatus(source,current=>{current.schedule={...current.schedule,...patch};});
|
|
277
|
+
try {
|
|
278
|
+
if(status.schedule.settled!==true) {
|
|
279
|
+
oats(['schedule','disable',id,'--dir',source.context,'--json'],source.context);
|
|
280
|
+
mark({settled:true,settledAt:new Date().toISOString(),settleError:undefined});
|
|
281
|
+
}
|
|
282
|
+
} catch(e) {
|
|
283
|
+
if(e.code!=='E_SCHEDULE_UNKNOWN') {mark({settled:false,settleError:e.message});return {status:'disable-failed',id};}
|
|
284
|
+
}
|
|
255
285
|
try {
|
|
256
|
-
oats(['schedule','
|
|
257
|
-
|
|
258
|
-
return {status:'
|
|
286
|
+
oats(['schedule','remove',id,'--dir',source.context,'--json'],source.context);
|
|
287
|
+
mark({removed:true,removedAt:new Date().toISOString(),removeError:undefined});
|
|
288
|
+
return {status:'removed',id};
|
|
259
289
|
} catch(e) {
|
|
260
|
-
|
|
261
|
-
|
|
290
|
+
if(e.code==='E_SCHEDULE_UNKNOWN') {mark({removed:true,removedAt:new Date().toISOString(),removeError:undefined});return {status:'already-removed',id};}
|
|
291
|
+
mark({removed:false,removeError:e.message});
|
|
292
|
+
return {status:e.code==='E_SCHEDULE_RUNNING'?'disabled-pending-removal':'remove-failed',id};
|
|
262
293
|
}
|
|
263
294
|
}
|
|
264
295
|
export function capture(source,{final=false,deadlineMs=85000}={}) {
|
|
@@ -336,8 +367,9 @@ export function capture(source,{final=false,deadlineMs=85000}={}) {
|
|
|
336
367
|
if(final) {
|
|
337
368
|
status.retired=true;status.retiredAt=new Date().toISOString();
|
|
338
369
|
// A retired source whose every captured input is already processed has
|
|
339
|
-
// no further work: its schedule is switched off
|
|
340
|
-
// the
|
|
370
|
+
// no further work: its schedule is switched off and removed from the
|
|
371
|
+
// kernel scheduler (settleRetiredSchedule); the definition stays as
|
|
372
|
+
// evidence in this source's schedule.json. Anything still pending keeps
|
|
341
373
|
// the job enabled until the worker drains it (see worker.mjs).
|
|
342
374
|
const drained=status.captured.inputs.every(id=>status.processed.includes(id));
|
|
343
375
|
status.auto=status.auto && !noLaunch && !drained;
|
|
@@ -349,10 +381,14 @@ export function capture(source,{final=false,deadlineMs=85000}={}) {
|
|
|
349
381
|
});
|
|
350
382
|
}
|
|
351
383
|
export function scheduleSource(source) {
|
|
384
|
+
// A retired, drained source whose job was already taken out of the scheduler
|
|
385
|
+
// has nothing left to run: do not recreate the job (retire is re-entrant).
|
|
386
|
+
const current=loadStatus(source);
|
|
387
|
+
if(current.retired && current.schedule?.removed===true) return current.schedule.result;
|
|
352
388
|
const captured=source.registration?.schemaVersion===1 && source.registration.kind==='captured';
|
|
353
389
|
const argv=captured?['oats','okf','run-source','--source',source.file,'--deployment',source.executionBinding.deployment,'--resolution',source.executionBinding.resolution.id,'--json']
|
|
354
390
|
:['oats','okf','run-source','--source',source.file,'--soul',source.agent,'--json'];
|
|
355
|
-
const spec={id:`okf-${source.id}`,kind:'command',enabled:
|
|
391
|
+
const spec={id:`okf-${source.id}`,kind:'command',enabled:current.auto,cron:source.bindings.cron,tz:source.bindings.tz,cwd:source.context,argv,
|
|
356
392
|
...(captured?{definitionVersion:2,recurrencePolicy:'capture',responsibleHuman:source.responsibleHuman}: {})};
|
|
357
393
|
const file=join(dirname(source.file),'schedule.json');
|
|
358
394
|
try {
|