@awebai/oats 0.25.3 → 0.25.5
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/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 +11 -0
- package/docs/design/2026-09-20-redesign-program-board.md +1 -1
- package/docs/design/2026-09-23-workspace-module-contracts.md +12 -0
- package/docs/integrations.md +56 -7
- package/docs/rebuild-to-v2.md +18 -10
- package/docs/release-notes/v0.25.4.md +21 -0
- package/docs/release-notes/v0.25.5.md +42 -0
- package/docs/workspace-adoption.md +1 -1
- package/docs/workspaces.md +11 -8
- package/lib/core.mjs +27 -6
- package/package-catalog.json +2 -2
- package/package.json +1 -1
|
@@ -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 {
|
|
@@ -3,7 +3,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { fs, join, dirname, safePath, readJSON, save, atomic, tree, materialize, digest, hash, withLock, exec, cleanEnv, fail, relPath, overlaps, resolve } from './io.mjs';
|
|
6
|
-
import { metadata, noGit } from './config.mjs';
|
|
6
|
+
import { metadata, noGit, gitTimeoutMs } from './config.mjs';
|
|
7
7
|
const validator = fileURLToPath(new URL('../skills/okf/scripts/okf-validate.mjs', import.meta.url));
|
|
8
8
|
// Never let local replace refs reinterpret frozen OIDs, including inside Git's
|
|
9
9
|
// transport subprocesses. Override even an explicitly supplied command env.
|
|
@@ -17,11 +17,19 @@ export function validateBase(root, base) {
|
|
|
17
17
|
if(result.errors.length || result.warnings.length) fail('E_VALIDATION', [...result.errors,...result.warnings].join('; '));
|
|
18
18
|
return {files,meta,digest:digest(files)};
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
/** Write a blob straight to a file descriptor: no in-memory buffer, so object
|
|
21
|
+
* size never limits what a base may hold. The remote budget applies because a
|
|
22
|
+
* partial clone fetches a missing blob on its first read. */
|
|
23
|
+
function writeBlob(cwd,oid,target,mode) {
|
|
24
|
+
const fd=fs.openSync(target,'wx',mode);
|
|
25
|
+
let result; try { result=spawnSync('git',['--no-replace-objects','-c','core.hooksPath=/dev/null','-C',cwd,'cat-file','blob',oid],{cwd,env:gitEnv(),timeout:gitTimeoutMs(),stdio:['ignore',fd,'pipe']}); } finally { fs.closeSync(fd); }
|
|
22
26
|
if(result.error || result.status!==0) fail('E_COMMAND','Git object read failed');
|
|
23
|
-
|
|
27
|
+
fs.chmodSync(target,mode);
|
|
24
28
|
}
|
|
29
|
+
/** A tree entry the store never materializes: outside the knowledge base root.
|
|
30
|
+
* Its absence from a staging tree is by construction; its presence is a
|
|
31
|
+
* worker's doing and is judged like any other change. */
|
|
32
|
+
const outsideBase=(base,p)=>!(base.root==='.' || p===base.root || p.startsWith(base.root+'/'));
|
|
25
33
|
function materializeGitObjects(base,dest,head) {
|
|
26
34
|
immutableCommit(dest,head);
|
|
27
35
|
const entries=gitTreeEntries(dest,head);
|
|
@@ -35,20 +43,32 @@ function materializeGitObjects(base,dest,head) {
|
|
|
35
43
|
// Index/HEAD updates do not apply content filters. They preserve the normal
|
|
36
44
|
// Git worktree needed by existing diff/publication guards without checkout.
|
|
37
45
|
git(dest,['read-tree',head]);git(dest,['update-ref','--no-deref','HEAD',head]);
|
|
46
|
+
// Only the knowledge base is materialized: the index carries the whole tree
|
|
47
|
+
// for publication, but bytes outside the base root are never read, so the
|
|
48
|
+
// size of the rest of the repository does not matter. The scope checks know
|
|
49
|
+
// that an entry outside the root is expected to be absent (see outsideBase).
|
|
38
50
|
for(const [p,entry] of entries) {
|
|
51
|
+
if(outsideBase(base,p)) continue;
|
|
39
52
|
const target=safePath(join(dest,p));fs.mkdirSync(dirname(target),{recursive:true});
|
|
40
|
-
|
|
41
|
-
const bytes=rawBlob(dest,entry.oid);
|
|
42
|
-
if(entry.mode==='120000') fs.symlinkSync(bytes.toString('utf8'),target);
|
|
43
|
-
else {fs.writeFileSync(target,bytes,{flag:'wx',mode:entry.mode==='100755'?0o755:0o644});fs.chmodSync(target,entry.mode==='100755'?0o755:0o644);}
|
|
53
|
+
writeBlob(dest,entry.oid,target,entry.mode==='100755'?0o755:0o644);
|
|
44
54
|
}
|
|
45
55
|
}
|
|
46
56
|
function clone(base, dest, selectedHead) {
|
|
47
57
|
safePath(dest);
|
|
48
58
|
if(fs.existsSync(dest)) fail('E_PATH',`staging destination exists: ${dest}`);
|
|
49
59
|
fs.mkdirSync(dirname(dest),{recursive:true});
|
|
50
|
-
|
|
51
|
-
|
|
60
|
+
// Fetch only what the store reads: the accepted branch, trees now and blobs
|
|
61
|
+
// on demand. Only a remote that does not offer object filtering gets a plain
|
|
62
|
+
// single-branch clone instead; any other failure is the failure it is. The
|
|
63
|
+
// ancestry the publication checks walk is present either way.
|
|
64
|
+
const cloneArgs=['clone','--no-hardlinks','--no-checkout','--single-branch','--branch',base.acceptedBranch];
|
|
65
|
+
try { git(dirname(dest),[...cloneArgs,'--filter=blob:none','--',base.repository,dest],{timeout:gitTimeoutMs()}); }
|
|
66
|
+
catch(e) {
|
|
67
|
+
if(e.code!=='E_COMMAND' || !/filter/i.test(e.message)) throw e;
|
|
68
|
+
fs.rmSync(dest,{recursive:true,force:true});
|
|
69
|
+
git(dirname(dest),[...cloneArgs,'--',base.repository,dest],{timeout:gitTimeoutMs()});
|
|
70
|
+
}
|
|
71
|
+
git(dest,['fetch','origin',`refs/heads/${base.acceptedBranch}`],{timeout:gitTimeoutMs()});
|
|
52
72
|
const head=selectedHead ?? git(dest,['rev-parse','FETCH_HEAD']);
|
|
53
73
|
verifyRemote(base,dest);
|
|
54
74
|
materializeGitObjects(base,dest,head);
|
|
@@ -142,7 +162,9 @@ function withVerificationIndex(cwd,fn) {
|
|
|
142
162
|
}
|
|
143
163
|
export function verifyGitScope(base, dest, baseline, { checkModes = true } = {}) {
|
|
144
164
|
return withVerificationIndex(dest,read=>{
|
|
145
|
-
|
|
165
|
+
// Entries outside the root are never materialized, so their absence is
|
|
166
|
+
// not a deletion; a present outside file that differs still is a change.
|
|
167
|
+
const names=read(['diff','--name-only','-z',baseline,'--']).split('\0').filter(Boolean).filter(p=>!(outsideBase(base,p) && !fs.existsSync(join(dest,p))));
|
|
146
168
|
// A restored working file can conceal a staged, unauthorized index entry.
|
|
147
169
|
names.push(...read(['diff','--cached','--name-only','-z',baseline,'--']).split('\0').filter(Boolean));
|
|
148
170
|
names.push(...read(['ls-files','--others','--exclude-standard','-z']).split('\0').filter(Boolean));
|
|
@@ -303,7 +325,7 @@ export function gitPublish(base, stage, proposal, receipt, persist, {beforePubli
|
|
|
303
325
|
const baseline=immutableCommit(cwd,stage.head);
|
|
304
326
|
verifyPublicationTree(base,cwd,baseline.tree,baseline.tree,proposal.before);
|
|
305
327
|
// Baseline must still be accepted. Never rebase model output without rejudging it.
|
|
306
|
-
git(cwd,['fetch','origin',`refs/heads/${base.acceptedBranch}`]);
|
|
328
|
+
git(cwd,['fetch','origin',`refs/heads/${base.acceptedBranch}`],{timeout:gitTimeoutMs()});
|
|
307
329
|
const accepted=git(cwd,['rev-parse','FETCH_HEAD']);
|
|
308
330
|
if(accepted!==stage.head) {
|
|
309
331
|
// A known or uncertain previously created PR may be reconciled, but a
|
|
@@ -350,13 +372,13 @@ export function gitPublish(base, stage, proposal, receipt, persist, {beforePubli
|
|
|
350
372
|
const publication=immutableCommit(cwd,receipt.commit);
|
|
351
373
|
if(publication.parents.length!==1 || publication.parents[0]!==stage.head) fail('E_CONFIRM','publication commit must have exactly the frozen baseline as its parent');
|
|
352
374
|
verifyPublicationTree(base,cwd,baseline.tree,publication.tree,proposal.after,proposal.before);
|
|
353
|
-
const remoteTip=()=>git(cwd,['ls-remote','--heads','origin',`refs/heads/${branch}`]).split(/\s/)[0] || null;
|
|
375
|
+
const remoteTip=()=>git(cwd,['ls-remote','--heads','origin',`refs/heads/${branch}`],{timeout:gitTimeoutMs()}).split(/\s/)[0] || null;
|
|
354
376
|
let tip=remoteTip();
|
|
355
377
|
if(tip && tip!==receipt.commit) fail('E_PR','publication branch has unexpected commit; never force push');
|
|
356
378
|
if(tip!==receipt.commit) {
|
|
357
379
|
beforePublish();
|
|
358
380
|
receipt.status='push-intent'; persist();
|
|
359
|
-
try { verifyRemote(base,cwd);git(cwd,['push','--no-follow-tags','--recurse-submodules=no','origin',`${receipt.commit}:refs/heads/${branch}`]); }
|
|
381
|
+
try { verifyRemote(base,cwd);git(cwd,['push','--no-follow-tags','--recurse-submodules=no','origin',`${receipt.commit}:refs/heads/${branch}`],{timeout:gitTimeoutMs()}); }
|
|
360
382
|
catch(e) { receipt.status='push-unknown'; receipt.error=e.message; persist(); throw e; }
|
|
361
383
|
tip=remoteTip(); if(tip!==receipt.commit) {receipt.status='push-unknown';persist();fail('E_CONFIRM','pushed head not confirmed');}
|
|
362
384
|
}
|
|
@@ -375,7 +397,7 @@ export function gitPublish(base, stage, proposal, receipt, persist, {beforePubli
|
|
|
375
397
|
const pr=matching[0];receipt.pr=pr;receipt.status='delivered';receipt.deliveredAt ||= new Date().toISOString();persist();
|
|
376
398
|
if(pr.state==='CLOSED' && !pr.mergedAt) {receipt.status='rejected';persist();fail('E_PR','PR closed without merge; retained proposal requires operator review');}
|
|
377
399
|
if(pr.mergedAt) {
|
|
378
|
-
git(cwd,['fetch','origin',`refs/heads/${base.acceptedBranch}`]);
|
|
400
|
+
git(cwd,['fetch','origin',`refs/heads/${base.acceptedBranch}`],{timeout:gitTimeoutMs()});
|
|
379
401
|
const merge=pr.mergeCommit?.oid;
|
|
380
402
|
if(!merge) fail('E_CONFIRM','merged PR lacks merge commit');
|
|
381
403
|
git(cwd,['merge-base','--is-ancestor',merge,'FETCH_HEAD']);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"capability": "oats.okf",
|
|
3
3
|
"command": "okf",
|
|
4
|
-
"version": "2.1.
|
|
4
|
+
"version": "2.1.4",
|
|
5
5
|
"compatibility": {
|
|
6
6
|
"oats": ">=0.24.4"
|
|
7
7
|
},
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
},
|
|
27
27
|
"state-dir": {
|
|
28
28
|
"description": "Absolute host-owned durable state directory for portable bindings. It is never derived from an instance home."
|
|
29
|
+
},
|
|
30
|
+
"git-timeout": {
|
|
31
|
+
"description": "Seconds allowed for each Git operation that talks to a remote (clone, fetch, push, ls-remote); default 600. Local object reads keep a short fixed limit. Raise it for large repositories behind slow links."
|
|
29
32
|
}
|
|
30
33
|
},
|
|
31
34
|
"agents": [
|
|
@@ -121,8 +121,9 @@ captured ProviderBinding as execution authority:
|
|
|
121
121
|
`payload.bindings`. Workspace, adoption, and operator values remain separate
|
|
122
122
|
inputs to the shared resolver; OKF does not select their precedence.
|
|
123
123
|
- Durable placement is explicit selected settings: physical absolute
|
|
124
|
-
`bindings-file` and `state-dir`, plus selected `harvest-runtime
|
|
125
|
-
`harvest-model
|
|
124
|
+
`bindings-file` and `state-dir`, plus selected `harvest-runtime`, optional
|
|
125
|
+
`harvest-model` and optional `git-timeout` (seconds for remote Git
|
|
126
|
+
operations, default 600). Never derive state from an instance home.
|
|
126
127
|
- Provider codecs run only after exact retained executable approval. Their
|
|
127
128
|
populated binding is not proof of readiness, enrollment, credentials, privacy
|
|
128
129
|
or publication authority. Respect typed non-ready results.
|
package/docs/capabilities.md
CHANGED
|
@@ -262,6 +262,17 @@ return `meta`, `brief`, `warning`, or runtime-specific `launch` arguments. A
|
|
|
262
262
|
**spawn hook only** may also return an `env` object for the launched process;
|
|
263
263
|
returning `env` from retire or soul-scaffold is an explicit contract error.
|
|
264
264
|
|
|
265
|
+
A **launch hook** runs at every start and restart of a home for each provider
|
|
266
|
+
captured at spawn (under its captured settings). Its `launch` arguments and
|
|
267
|
+
`env` replace that provider's previous contribution whole. Its `meta`, when
|
|
268
|
+
returned, replaces that provider's entry in `instance.json.capabilityMeta`
|
|
269
|
+
after the start succeeds — the same record the spawn hook wrote and the retire
|
|
270
|
+
hook later reads as `OATS_META` — so a provider that re-issues a credential at
|
|
271
|
+
start (a renewed session grant, for example) leaves the CURRENT one on record. A
|
|
272
|
+
launch hook that answers without `meta` keeps its previous entry; a start whose
|
|
273
|
+
preparation fails changes nothing. (Kernel ≥ 0.25.5; earlier kernels collected
|
|
274
|
+
launch `meta` and discarded it.)
|
|
275
|
+
|
|
265
276
|
Hook environment values are strings, at most 8192 UTF-8 bytes, with no NUL or
|
|
266
277
|
newlines. Names use the portable environment grammar and must belong to an
|
|
267
278
|
unambiguous vendor namespace. Only a dotted capability ID participates: its
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Purpose:** the one accurate view of every work stream in the redesign, what is on main, what is in flight, who owns it, and what blocks it. Lead: `oats-expert` (redesign lead). Updated whenever anything merges, is returned, or reality changes. Older per-lane boards are superseded by this file.
|
|
4
4
|
|
|
5
|
-
**Last update:** 2026-09-23 20:00Z · **0.25.0
|
|
5
|
+
**Last update:** 2026-09-23 20:00Z · **0.25.0–0.25.4 PUBLISHED** (workspace model A–C; team-review fixes; operator-rebuild round; OATS_SOUL_ID; quarantine-retry fix) · **OKF 2.1.4 content complete on oats-okf main, tag pending human GO** · **oats.aweb 1.12.0 (PR #107) in rehearsal round 4; decision 27 (per-spawn identity) proposed to the human** · Phase D plan next · **Desktop engineer paused by the human** (10B-0 uncommitted foundation preserved; resume is the human's call; 0.24.14 to be cut from a maintenance branch off `e5cdaf95` when its PR lands) · parity pipeline ⏸.
|
|
6
6
|
|
|
7
7
|
Legend: ✅ on main/published · 🔄 in flight (PR/branch) · 🟡 preserved, not adopted · ⬜ not started · ⛔ blocked
|
|
8
8
|
|
|
@@ -565,6 +565,18 @@ is a member; a soul that lives in it may need a work clone). Under an explicit
|
|
|
565
565
|
`oats-local.yaml` `standalone:` header the next steps say the view is standalone
|
|
566
566
|
and list only that repo.
|
|
567
567
|
|
|
568
|
+
### 0.25.5 — launch-hook `meta` is persisted
|
|
569
|
+
|
|
570
|
+
`runLifecycleHooks("launch")` collected each capability's `meta` and the
|
|
571
|
+
start/restart path discarded it (only `contributions` and `env` were consumed).
|
|
572
|
+
From 0.25.5 a successful start merges `res.meta` per capability into
|
|
573
|
+
`instance.json.capabilityMeta` — the record spawn writes and retire reads as
|
|
574
|
+
`OATS_META`. A hook answering without `meta` keeps its prior entry; a failed
|
|
575
|
+
launch preparation writes nothing. No new field, flag or hook event; this is
|
|
576
|
+
the documented hook return finally honoured (decision 27, K3′). Driver: a
|
|
577
|
+
provider renewing a session grant at every start would otherwise leave the
|
|
578
|
+
original grant id on record and retire would revoke the wrong grant.
|
|
579
|
+
|
|
568
580
|
### 0.25.3 — `OATS_SOUL_ID` (stable soul identity for providers)
|
|
569
581
|
|
|
570
582
|
The per-commit soul cache (0.25.1, M1) made `realpath(<home>/soul)` change with every
|
package/docs/integrations.md
CHANGED
|
@@ -85,10 +85,11 @@ can spawn. `owns`/`reads` are responsibility/context, not ACLs. See
|
|
|
85
85
|
[knowledge](knowledge.md) for the **prepared** version scope, provisioning and
|
|
86
86
|
commands, and [migration](knowledge-migration.md) before updating v1.
|
|
87
87
|
|
|
88
|
-
**`oats.aweb`** fills `messaging`: mints an instance identity at spawn
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
**`oats.aweb`** fills `messaging`: mints an instance identity at spawn (local
|
|
89
|
+
mode) or grants an instance an expiring session as a resident global identity
|
|
90
|
+
(global mode), removes or revokes it at retire, contributes the aweb messaging
|
|
91
|
+
and team skills, wires the channel plugin so sessions are woken by mail, and
|
|
92
|
+
exposes `oats aweb roster` and `oats aweb setup`. Requires the `aw` CLI.
|
|
92
93
|
|
|
93
94
|
**`oats.jira`** fills `tasks`: the `jira-tasks` protocol and an advisory
|
|
94
95
|
spawn hook. Requires `acli`; settings commonly include `site` and `project`.
|
|
@@ -135,6 +136,9 @@ remove it on `retire`; supply the roster; teach send, reply, chat, and "read
|
|
|
135
136
|
the event first" in the inject and skill; contribute launch arguments so the
|
|
136
137
|
session is woken; enforce the soul type's `reach` on both sides; state
|
|
137
138
|
whether the address outlives the instance; and keep task coordination out.
|
|
139
|
+
Any messaging provider emits `identity: { mode, alias, team, address|null,
|
|
140
|
+
resident|null, grant?: { id, expiresAt, scopes } }` in its spawn meta;
|
|
141
|
+
`oats.aweb` is the reference implementation.
|
|
138
142
|
|
|
139
143
|
**Tasks.** Teach claim, update, block, hand off, and complete; identify the
|
|
140
144
|
instance to the tracker in a way that survives it; keep conversation out.
|
|
@@ -197,12 +201,57 @@ warning naming the fresh-purpose remedy. On an older `aw` the pre-1.36.1
|
|
|
197
201
|
report stands (`aliasReusable: false`, warning naming aweb-abim), because
|
|
198
202
|
that CLI cannot revoke the certificate.
|
|
199
203
|
|
|
200
|
-
## oats.aweb settings (1.
|
|
204
|
+
## oats.aweb settings (1.12.0)
|
|
201
205
|
|
|
202
206
|
Set in `oats-local.yaml` under `settings.oats.aweb.<key>` (host-owned), in the
|
|
203
207
|
soul's `messaging:` payload (true of every instance), or per spawn with
|
|
204
|
-
`oats spawn … --provider oats.aweb <key>=<value>`.
|
|
205
|
-
|
|
208
|
+
`oats spawn … --provider oats.aweb <key>=<value>`. The effective payload is
|
|
209
|
+
merged in order: workspace messaging, `byTeam[team]`, soul messaging,
|
|
210
|
+
`oats-local.yaml` `settings.oats.aweb`, then per-spawn `--provider` values.
|
|
211
|
+
`residents` is host-file-only: put custody paths only in `oats-local.yaml`,
|
|
212
|
+
never in a committed workspace or soul file (current kernels document this rule
|
|
213
|
+
but do not yet enforce provenance in the hook payload).
|
|
214
|
+
|
|
215
|
+
- `team: <team id>`. The payload team wins over `OATS_TEAM_ID`/
|
|
216
|
+
`OATS_TEAM_NAME`; if both are set and differ, the hook warns and uses the
|
|
217
|
+
payload. Workspace v2 spawns can have an empty `OATS_TEAM_ID`, so set this in
|
|
218
|
+
the payload for global grants.
|
|
219
|
+
- `identity.mode: local | global` (default `local`). Any other value is fatal.
|
|
220
|
+
Local mode is the historical behavior: a spawned team identity is minted for
|
|
221
|
+
the instance, or `identity.source` uses the existing retained-seat flow below.
|
|
222
|
+
Its spawn meta includes `identity: { mode: "local", alias, team, address:
|
|
223
|
+
null, resident: null }` beside the existing top-level `alias`, `team`, and
|
|
224
|
+
`delivery` keys.
|
|
225
|
+
- `identity.mode: global` makes the instance act as a resident global identity
|
|
226
|
+
through an aweb session grant; it never mints a new global identity and never
|
|
227
|
+
copies root keys into the instance home. `identity.resident` is required and
|
|
228
|
+
resolves through `residents.<name>` to an absolute custody directory whose
|
|
229
|
+
`.aw/identity.yaml` already exists. Missing or unresolved residents fail with
|
|
230
|
+
the `oats-local.yaml settings.oats.aweb.residents.<name>` key to set. Optional
|
|
231
|
+
`identity.scopes` defaults to exactly `[mail.read, mail.send, chat.read,
|
|
232
|
+
chat.send]`; optional `identity.ttl` defaults to `8h` (aw accepts `60s` to
|
|
233
|
+
`720h`). Spawn runs `aw id grant mint --scope <comma-list> --ttl <ttl>
|
|
234
|
+
--label oats:<instance> --out <home>/.aweb-identity --json` from the custody
|
|
235
|
+
directory with `AWEB_IDENTITY_HOME` removed from the child environment: in aw
|
|
236
|
+
1.36.1, grant commands are not identity-home-aware and intentionally refuse
|
|
237
|
+
both `--identity-home` and external `AWEB_IDENTITY_HOME`, so cwd selects the
|
|
238
|
+
custody identity. The hook parses the whole JSON document because aw `--json`
|
|
239
|
+
output is indented across lines, with a fallback to the first brace-prefixed
|
|
240
|
+
block when progress lines precede it; it then verifies the minted grant's
|
|
241
|
+
`team_id` and returns
|
|
242
|
+
`env.AWEB_IDENTITY_HOME=<home>/.aweb-identity`. If the minted team differs,
|
|
243
|
+
the hook revokes the grant and keeps nothing. As of aw 1.36.1, receiving,
|
|
244
|
+
wake registration and `aw whoami` work through a grant, but sending mail or
|
|
245
|
+
chat through a grant is rejected by the server with 422 (`from_did must match
|
|
246
|
+
the authenticated sender`) because the aw client signs with the grant-key DID
|
|
247
|
+
where the server expects the resident's. Retire revokes
|
|
248
|
+
`meta.identity.grant.id` through the custody directory; with no grant id it
|
|
249
|
+
reports `nothing-to-revoke`. A failed revoke exits nonzero and reports the TTL
|
|
250
|
+
expiry.
|
|
251
|
+
- `residents: { <name>: /abs/custody/dir }` is the host-owned map for global
|
|
252
|
+
mode. Each custody directory's `.aw` holds the resident identity root keys and
|
|
253
|
+
team certificate. Do not put this map in committed source; the hook cannot
|
|
254
|
+
distinguish payload provenance.
|
|
206
255
|
- `delivery: channel | session` (default `channel`). `session` hands
|
|
207
256
|
notification delivery to the host wake broker: `AWEB_DELIVERY=session` in
|
|
208
257
|
the launch environment (declared by the manifest), no Claude channel flag,
|
package/docs/rebuild-to-v2.md
CHANGED
|
@@ -48,8 +48,9 @@ capabilities plus `oats.core`), so a public soul stays usable.
|
|
|
48
48
|
Two teams that need two different messaging identities (an open-source team
|
|
49
49
|
and a hosted-operations team, say) stay in ONE workspace: `team:` is a label,
|
|
50
50
|
and the provider payload is addressed by label under `messaging.byTeam` (§2).
|
|
51
|
-
Read §8b before relying on it:
|
|
52
|
-
|
|
51
|
+
Read §8b before relying on it: oats.aweb 1.12.0 mints into the `team` the
|
|
52
|
+
payload names, but the `.aw` root it mints FROM is still found by search and
|
|
53
|
+
must hold that team's membership (1.11.2 ignored `team` altogether).
|
|
53
54
|
|
|
54
55
|
## 2. Write `oats-workspace.yaml` v2 in the host repo
|
|
55
56
|
|
|
@@ -76,8 +77,8 @@ members:
|
|
|
76
77
|
- git:github.com/acme/platform
|
|
77
78
|
packages:
|
|
78
79
|
oats.framework: v1.1.3
|
|
79
|
-
oats.okf: v2.1.
|
|
80
|
-
oats.aweb: v1.
|
|
80
|
+
oats.okf: v2.1.4
|
|
81
|
+
oats.aweb: v1.12.0
|
|
81
82
|
teams:
|
|
82
83
|
global: { description: Org-wide }
|
|
83
84
|
engineering: { description: Platform }
|
|
@@ -136,7 +137,7 @@ they enumerate `souls/*/soul.yaml`. A soul left under `agents/` is invisible to
|
|
|
136
137
|
| 0.24 | v2 |
|
|
137
138
|
|---|---|
|
|
138
139
|
| `schemaVersion: 1` | `schemaVersion: 2` |
|
|
139
|
-
| `requires.knowledge: { capability: oats.okf, source: git:…@v2.1.
|
|
140
|
+
| `requires.knowledge: { capability: oats.okf, source: git:…@v2.1.4#oats-package }` | `capabilities: { oats.okf: { from: package } }` — or nothing, if the workspace default already says so |
|
|
140
141
|
| `requires.capabilities.<cap>: { source: git:… }` | `<cap>: { from: package }` (published) or `<cap>: { from: here }` / `{ from: <repo key> }` (a member capability) |
|
|
141
142
|
| `source: repo:…` / `path:` | `{ from: here }` |
|
|
142
143
|
| `defaults.capabilities` | fold into `capabilities:`; use `off` to remove a workspace default |
|
|
@@ -217,8 +218,8 @@ messaging identity per team (§1), a soul that loses its label silently lands
|
|
|
217
218
|
outside every team-addressed payload; nothing refuses it. Label the membership
|
|
218
219
|
when a whole repo belongs to one team, and the soul when it does not.
|
|
219
220
|
|
|
220
|
-
**Per-soul memory-harvest opt-out:** not available in OKF 2.1.3
|
|
221
|
-
item. Neither `okf.json` (`version`, `owner`, `owns`, `reads`) nor the settings
|
|
221
|
+
**Per-soul memory-harvest opt-out:** not available in OKF 2.1.3 or 2.1.4 — a
|
|
222
|
+
later OKF item. Neither `okf.json` (`version`, `owner`, `owns`, `reads`) nor the settings
|
|
222
223
|
payload (`bindings-file`, `state-dir`, `harvest-runtime`, `harvest-model`) has a
|
|
223
224
|
key that keeps a soul registered for reads while excluding it from harvest. A
|
|
224
225
|
soul that must not be harvested today says `knowledge: none` (no OKF at all for
|
|
@@ -327,7 +328,7 @@ Member capabilities need no approval: membership is the trust.
|
|
|
327
328
|
**Non-interactive approval (CI, scripted rebuilds):**
|
|
328
329
|
|
|
329
330
|
```bash
|
|
330
|
-
oats sync --approve oats.okf@v2.1.
|
|
331
|
+
oats sync --approve oats.okf@v2.1.4 --approve oats.aweb@v1.12.0
|
|
331
332
|
```
|
|
332
333
|
|
|
333
334
|
`--approve <id>@<version>` is repeatable and approves **exactly** the entry the
|
|
@@ -337,6 +338,13 @@ type a digest. An `--approve` that names an id or version the resolution does
|
|
|
337
338
|
not contain is an error, not a silent skip; an entry the flags do not cover
|
|
338
339
|
stays unapproved (exit `2`, as above).
|
|
339
340
|
|
|
341
|
+
`<version>` is the value `sync --json` reports as `approvalNeeded[].version`,
|
|
342
|
+
which is what the lock records as the package's `version`. For a **catalog**
|
|
343
|
+
package that is the published version (`oats.okf@2.1.4`). For a **git** source
|
|
344
|
+
pinned by commit (`git:github.com/awebai/oats-okf@<oid>`) it is the **full
|
|
345
|
+
commit OID**, not the `git:` reference and not a tag name — copy it from the
|
|
346
|
+
`approvalNeeded` line rather than from your workspace file.
|
|
347
|
+
|
|
340
348
|
## 7b. OKF 2: start a FRESH `state-dir` — do not re-point the old one
|
|
341
349
|
|
|
342
350
|
OKF 2 pins each knowledge **owner** to a soul by path: at source registration
|
|
@@ -398,11 +406,11 @@ machine-level setting would give the seat to EVERY instance of every messaging
|
|
|
398
406
|
soul on that machine, and a seat can be held once. The Desktop's
|
|
399
407
|
confirmed apply carries the same map.
|
|
400
408
|
|
|
401
|
-
## 8b. Where the team `.aw` lives now
|
|
409
|
+
## 8b. Where the team `.aw` lives now, and what `byTeam` does today
|
|
402
410
|
|
|
403
411
|
A freshly minted identity (every spawn without `identity.source`) needs an
|
|
404
412
|
**initialised aweb root**: a directory holding `.aw` with a team membership to
|
|
405
|
-
mint into. oats.aweb 1.11.2
|
|
413
|
+
mint into. oats.aweb's spawn hook (1.11.2 and 1.12.0 alike) looks for `.aw` among these, first hit
|
|
406
414
|
wins: the declared team scope (`OATS_TEAM_SCOPE`, from the removed
|
|
407
415
|
`oats-config.yaml` `team:` block — **empty under v2**), the instance home, the
|
|
408
416
|
git repo containing the home, the resolution context (the soul's work repo) and
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# OATS 0.25.4
|
|
2
|
+
|
|
3
|
+
Patch release: one kernel fix found by an operator rehearsal on 0.25.3.
|
|
4
|
+
|
|
5
|
+
## Fixed
|
|
6
|
+
|
|
7
|
+
- **Quarantine retry never re-ran the owed retire hook on a workspace-mode
|
|
8
|
+
home.** When a required spawn hook fails and its compensation is incomplete,
|
|
9
|
+
the kernel retains the home with a cleanup descriptor
|
|
10
|
+
(`.oats-rollback-incomplete.json`). Module materialization had already
|
|
11
|
+
written a pre-hook `instance.json` stub (`{ modules, providers,
|
|
12
|
+
resolutionRevision }`), and `oats retire` trusted that stub over the
|
|
13
|
+
descriptor — so `meta.repo` was undefined, the retry reported *"retire hook
|
|
14
|
+
<cap>: did not run on this retry"* and *"cleanup descriptor lost its context
|
|
15
|
+
repo"* on every attempt, and only `--force` could remove the home (leaving
|
|
16
|
+
the provider's external state to be cleaned by hand). The retry now reads
|
|
17
|
+
the cleanup descriptor whenever the live record is not a complete spawn
|
|
18
|
+
record; the owed hook runs, the home stays while it fails, and is removed
|
|
19
|
+
once it succeeds. Regression test in `test/spawn-workspace.test.mjs`.
|
|
20
|
+
|
|
21
|
+
No contract changes. `oats version --json` is unchanged apart from the version.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# OATS 0.25.5
|
|
2
|
+
|
|
3
|
+
Patch release: one lifecycle-contract defect fixed; two provider releases
|
|
4
|
+
pinned in the official catalog.
|
|
5
|
+
|
|
6
|
+
## Fixed
|
|
7
|
+
|
|
8
|
+
- **Launch-hook `meta` was collected and discarded.** A capability's `launch`
|
|
9
|
+
hook may return `meta` like the spawn hook does, but the start/restart path
|
|
10
|
+
consumed only its `launch` arguments and `env`, so `instance.json.capabilityMeta`
|
|
11
|
+
kept the spawn-time value forever. A provider that re-issues a credential at
|
|
12
|
+
every start (e.g. a renewed messaging session grant) would have left the
|
|
13
|
+
ORIGINAL id on record, and retire — which reads that record as `OATS_META` —
|
|
14
|
+
would have revoked the wrong one while the live one ran to its TTL. After a
|
|
15
|
+
successful start the kernel now merges each capability's launch `meta` over
|
|
16
|
+
its entry; a hook answering without `meta` keeps its prior entry; a failed
|
|
17
|
+
launch preparation changes nothing. Regression test in
|
|
18
|
+
`test/session-restart.test.mjs`; contract paragraph in `docs/capabilities.md`.
|
|
19
|
+
|
|
20
|
+
No new flags, fields or hook events. `oats version --json` unchanged apart from
|
|
21
|
+
the version.
|
|
22
|
+
|
|
23
|
+
## Catalog
|
|
24
|
+
|
|
25
|
+
- **oats.okf → v2.1.4** (mirror in `capabilities/oats-okf` synced, published
|
|
26
|
+
tag `2af47ad3`): sparse staging to `base.root` from a single-branch partial
|
|
27
|
+
clone with streamed object reads (large repositories and large files outside
|
|
28
|
+
the knowledge base no longer matter), `git-timeout` binding key (600 s for
|
|
29
|
+
remote operations), migration record hygiene + `oats okf migrate --forget`,
|
|
30
|
+
owners keyed by `OATS_SOUL_ID` with a one-time rewrite of same-name path pins
|
|
31
|
+
(closes the `E_OWNER`-after-member-commit regression; needs kernel ≥ 0.25.3),
|
|
32
|
+
and retire removes a drained source's `okf-<id>` scheduler job.
|
|
33
|
+
- **oats.aweb → v1.12.0** (byte-identical to `capabilities/oats-aweb`):
|
|
34
|
+
resident identity session grants — `identity.mode: local | global`, `team`
|
|
35
|
+
read from the payload (so `messaging.byTeam.<label>.team` is now the per-label
|
|
36
|
+
identity), host-only `residents` map, and the messaging-layer meta key
|
|
37
|
+
`identity` on every spawn. Known limitation: sending mail/chat through a
|
|
38
|
+
grant is rejected by the aweb server as of aw 1.36.1 (client bug; receive,
|
|
39
|
+
wake and whoami work). Guide truths in `docs/workspaces.md` and
|
|
40
|
+
`docs/rebuild-to-v2.md` updated from "1.11.2 ignores `team`" to what 1.12.0
|
|
41
|
+
does.
|
|
42
|
+
|
|
@@ -29,7 +29,7 @@ two roles never collapse: `oats-okf`, `oats-aweb`, `oats-jira`, `oats-linear`,
|
|
|
29
29
|
their `oats-package/` is consumed only as a package: `from: package`, pinned
|
|
30
30
|
in the workspace's `packages:`, locked and approved per version. The framework's
|
|
31
31
|
own souls therefore say `oats.okf: { from: package }` even though `oats-okf` is
|
|
32
|
-
a member. A bare version in `packages:` (`oats.okf: v2.1.
|
|
32
|
+
a member. A bare version in `packages:` (`oats.okf: v2.1.4`) resolves through
|
|
33
33
|
the catalog; a package outside it is written `git:<repo>@<ref>`.
|
|
34
34
|
|
|
35
35
|
## Join it on your machine
|
package/docs/workspaces.md
CHANGED
|
@@ -61,7 +61,7 @@ members: # repo refs, NO @revision (E_WORKSPAC
|
|
|
61
61
|
|
|
62
62
|
packages: # the ONLY versioned things
|
|
63
63
|
oats.framework: v1.1.3 # bare version → resolves through the official catalog
|
|
64
|
-
oats.okf: v2.1.
|
|
64
|
+
oats.okf: v2.1.4
|
|
65
65
|
acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
|
|
66
66
|
|
|
67
67
|
teams: # labels, declared once so they cannot drift
|
|
@@ -383,13 +383,16 @@ a label under `byTeam` that is not declared in `teams:` is `E_WORKSPACE_SCHEMA`.
|
|
|
383
383
|
**`byTeam` is kernel-merged; whether a provider honours what arrives is the
|
|
384
384
|
provider's.** `spawn --preview` shows the merged `settings.<cap>` so the
|
|
385
385
|
delivery is verifiable, and `instance.json.providers.<cap>` records it — but
|
|
386
|
-
oats.aweb
|
|
387
|
-
team
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
386
|
+
**oats.aweb 1.12.0 reads `team` from its payload** and mints into exactly that
|
|
387
|
+
team (`--team-id`), warning when the payload disagrees with an `OATS_TEAM_*`
|
|
388
|
+
value — so `byTeam.<label>.team` IS the per-label identity. What the payload
|
|
389
|
+
does not change is **where the `.aw` root is found**: the hook still searches
|
|
390
|
+
the bounded candidates in
|
|
391
|
+
[rebuild-to-v2.md §8b](rebuild-to-v2.md#8b-where-the-team-aw-lives-now-and-what-byteam-does-today)
|
|
392
|
+
and that root must hold a membership of the named team (the deployment's `.aw`
|
|
393
|
+
joined to every team its labels name is the simple layout). On oats.aweb
|
|
394
|
+
1.11.2 `team` was ignored (the root's active team won), so `byTeam` there is
|
|
395
|
+
a recorded intent only.
|
|
393
396
|
A store (`stores: { <name>: <repo ref> }`) names a repository; where a
|
|
394
397
|
knowledge base lives inside it is the knowledge provider's own concern — for
|
|
395
398
|
OKF 2.1.3 that is the **bindings file** (`bases.<alias>.repository` + `root`,
|
package/lib/core.mjs
CHANGED
|
@@ -6097,7 +6097,7 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
|
|
|
6097
6097
|
const inst = instance || meta?.instance || basename(home);
|
|
6098
6098
|
const command = renderLaunchRecipe(recipe, { home, instance: inst });
|
|
6099
6099
|
const selectionSource = frozen ? (config?.frozen || (!config && !selection.launchConfig && !selection.runtime) ? "frozen" : "config") : "config";
|
|
6100
|
-
return { recipe, command, runtime, model: model || undefined, modelSource, yolo, config, executable, preflight: problems, ok: problems.every((c) => c.ok), selectionSource, frozen };
|
|
6100
|
+
return { recipe, command, runtime, model: model || undefined, modelSource, yolo, config, executable, preflight: problems, ok: problems.every((c) => c.ok), selectionSource, frozen, ...(hooks.meta ? { hookMeta: hooks.meta } : {}) };
|
|
6101
6101
|
}
|
|
6102
6102
|
/** The environment a planned launch runs under: the host's base, the
|
|
6103
6103
|
* capabilities' validated env, the configuration's literals and its
|
|
@@ -8296,6 +8296,7 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
|
|
|
8296
8296
|
// prepares the target runtime under its CAPTURED settings.
|
|
8297
8297
|
const ctx = contextDir || meta.repo;
|
|
8298
8298
|
const withLaunchHook = [];
|
|
8299
|
+
let hookMeta;
|
|
8299
8300
|
for (const p of capturedProviders(meta, frozen)) {
|
|
8300
8301
|
const manifest = ctx ? capabilityManifest(p.id, ctx) : undefined;
|
|
8301
8302
|
if (!manifest) throw oatsError("E_LAUNCH_PREPARATION", `${p.id} was part of this home's launch at spawn but is no longer installed in the scope; reinstall it (oats install) or respawn the instance; nothing was stopped`);
|
|
@@ -8328,6 +8329,12 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
|
|
|
8328
8329
|
refreshed.push(c.capability);
|
|
8329
8330
|
}
|
|
8330
8331
|
Object.assign(env, res.env || {});
|
|
8332
|
+
// A launch hook's `meta` is part of its documented return (the same shape
|
|
8333
|
+
// spawn persists as capabilityMeta). It was collected and then dropped
|
|
8334
|
+
// here, so a provider that re-issues a credential at every start — a
|
|
8335
|
+
// renewed session grant, for instance — left the ORIGINAL id on record and
|
|
8336
|
+
// retire undid the wrong one. Carry it to the caller; the start records it.
|
|
8337
|
+
hookMeta = res.meta && Object.keys(res.meta).length ? res.meta : undefined;
|
|
8331
8338
|
}
|
|
8332
8339
|
const unprepared = contributions.filter((c) => c.capability !== null && !refreshed.includes(c.capability) && c.launch && c.launch[frozen.runtime] !== undefined && c.launch[runtime] === undefined).map((c) => c.capability);
|
|
8333
8340
|
const legacyArgs = contributions.filter((c) => c.capability === null && c.launch && Object.keys(c.launch).length);
|
|
@@ -8335,7 +8342,7 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
|
|
|
8335
8342
|
if (unprepared.length) throw oatsError("E_LAUNCH_PREPARATION", `${unprepared.join(", ")} contributed ${frozen.runtime} launch arguments at spawn and none for ${runtime}; change that capability's setting (for example its delivery mode), or the provider must declare a launch hook; nothing was stopped`);
|
|
8336
8343
|
const launch = {};
|
|
8337
8344
|
for (const c of contributions) { if (c.capability === null && refreshed.length) continue; for (const [rt, args] of Object.entries(c.launch || {})) if (args) launch[rt] = `${launch[rt] ? `${launch[rt]} ` : ""}${args}`; }
|
|
8338
|
-
return { launch, env, contributions: contributions.filter((c) => !(c.capability === null && refreshed.length)), refreshed };
|
|
8345
|
+
return { launch, env, contributions: contributions.filter((c) => !(c.capability === null && refreshed.length)), refreshed, ...(hookMeta ? { meta: hookMeta } : {}) };
|
|
8339
8346
|
}
|
|
8340
8347
|
|
|
8341
8348
|
/** Start a stopped instance again in its existing home: no new home, no
|
|
@@ -8639,7 +8646,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
8639
8646
|
// The independent receipt first (retire and session consult it), then the
|
|
8640
8647
|
// mutable metadata; both tmp+rename. A failure between them is what the
|
|
8641
8648
|
// pending receipt exists for.
|
|
8642
|
-
const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop, nativeRecordId }, clearPending = true) => {
|
|
8649
|
+
const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop, nativeRecordId, hookMeta }, clearPending = true) => {
|
|
8643
8650
|
checkRoots();
|
|
8644
8651
|
const baselinePath = retirementBaselinePath(realHome);
|
|
8645
8652
|
let baseline;
|
|
@@ -8654,7 +8661,10 @@ export function startInstanceSession(home, o = {}) {
|
|
|
8654
8661
|
const restarts = (Array.isArray(meta.restarts) ? meta.restarts : []).slice(recorded ? -20 : -19);
|
|
8655
8662
|
if (!recorded) restarts.push({ startedAt, model: model ?? null, reused });
|
|
8656
8663
|
const next = { ...meta, model, command, launched: true, startId: id, restarts, restartCount: (meta.restartCount || 0) + (recorded ? 0 : 1),
|
|
8657
|
-
...(launch ? { launch } : {}), ...(newRuntime ? { runtime: newRuntime } : {}), ...(newYolo !== undefined ? { yolo: newYolo } : {})
|
|
8664
|
+
...(launch ? { launch } : {}), ...(newRuntime ? { runtime: newRuntime } : {}), ...(newYolo !== undefined ? { yolo: newYolo } : {}),
|
|
8665
|
+
// Launch-hook meta lands per capability over the spawn's record; a hook
|
|
8666
|
+
// that answered without meta keeps its previous entry (retire reads it).
|
|
8667
|
+
...(hookMeta ? { capabilityMeta: { ...(meta.capabilityMeta || {}), ...hookMeta } } : {}) };
|
|
8658
8668
|
if (backend === "herdr") { next.sessionTarget = target; delete next.tmux; }
|
|
8659
8669
|
else { next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) }; delete next.sessionTarget; }
|
|
8660
8670
|
if (capturedStart) atomicWriteFileSync(metaPath, canonicalJson(next), { assertRoots: checkRoots });
|
|
@@ -8753,7 +8763,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
8753
8763
|
const resolvedCfg = resolveOatsConfig(context, meta.agent);
|
|
8754
8764
|
let agent; try { agent = findAgent(dirname(dirname(dirname(realHome))), meta.agent); } catch { agent = undefined; }
|
|
8755
8765
|
const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { runtime: meta.runtime, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model, yolo: o.yolo }, resolvedCfg, env: o.env || process.env, assertRoots: checkRoots });
|
|
8756
|
-
launchPlan = { recipe: plan.recipe, command: plan.command, runtime: plan.runtime, model: plan.model, yolo: plan.yolo };
|
|
8766
|
+
launchPlan = { recipe: plan.recipe, command: plan.command, runtime: plan.runtime, model: plan.model, yolo: plan.yolo, ...(plan.hookMeta ? { hookMeta: plan.hookMeta } : {}) };
|
|
8757
8767
|
command = launchPlan.command; model = launchPlan.model;
|
|
8758
8768
|
} else if (o.model !== undefined && o.model !== null && String(o.model).trim() !== "") {
|
|
8759
8769
|
const resolved = resolveModelPreference(String(o.model), runtime);
|
|
@@ -8770,6 +8780,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
8770
8780
|
const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
|
|
8771
8781
|
checkRoots(); // launch hooks/preparation have run; no backend has been observed
|
|
8772
8782
|
const planExtra = launchPlan ? { launch: launchPlan.recipe, runtime: launchPlan.runtime, yolo: launchPlan.yolo,
|
|
8783
|
+
...(launchPlan.hookMeta ? { hookMeta: launchPlan.hookMeta } : {}),
|
|
8773
8784
|
...(capturedStart ? { capturedIntent: capturedStart.intent } : {}) } : {};
|
|
8774
8785
|
let target = receipt.target;
|
|
8775
8786
|
let state = { present: false, state: "not-launched" };
|
|
@@ -9301,7 +9312,17 @@ export function retireInstance(root, name, o = {}) {
|
|
|
9301
9312
|
} catch { markerUnusable = true; }
|
|
9302
9313
|
}
|
|
9303
9314
|
const liveMeta = existsSync(metaPath) ? JSON.parse(readFileSync(metaPath, "utf8")) : undefined;
|
|
9304
|
-
|
|
9315
|
+
// A quarantined home may carry an instance.json that is NOT the spawn record:
|
|
9316
|
+
// module materialization writes a pre-hook stub ({ modules, providers,
|
|
9317
|
+
// resolutionRevision }) before the spawn hooks run, and a required-hook
|
|
9318
|
+
// failure retains the home with that stub in place. Trusting it over the
|
|
9319
|
+
// cleanup descriptor left meta.repo undefined, so the retry never re-ran the
|
|
9320
|
+
// owed retire hook and reported "cleanup descriptor lost its context repo"
|
|
9321
|
+
// on every attempt — only --force could clear the home (found by an operator
|
|
9322
|
+
// rehearsal on 0.25.3). The descriptor is written FOR this retry; a live
|
|
9323
|
+
// record only wins when it is a complete spawn record (repo + work present).
|
|
9324
|
+
const liveIsSpawnRecord = !!liveMeta && nonEmptyString(liveMeta.repo) && WORK_MODES.includes(liveMeta.work);
|
|
9325
|
+
const meta = liveIsSpawnRecord || !quarantine ? (liveMeta || quarantine?.cleanup || {}) : { ...liveMeta, ...quarantine.cleanup };
|
|
9305
9326
|
// Compensation metadata is the SPAWN's, from whichever record survived — never
|
|
9306
9327
|
// a failed retry's report, which says nothing about what still needs undoing.
|
|
9307
9328
|
if (!liveMeta?.capabilityMeta && quarantine?.cleanup?.capabilityMeta) meta.capabilityMeta = quarantine.cleanup.capabilityMeta;
|
package/package-catalog.json
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
"packages": {
|
|
4
4
|
"oats.okf": {
|
|
5
5
|
"url": "https://github.com/awebai/oats-okf.git",
|
|
6
|
-
"ref": "v2.1.
|
|
6
|
+
"ref": "v2.1.4",
|
|
7
7
|
"path": "oats-package"
|
|
8
8
|
},
|
|
9
9
|
"oats.aweb": {
|
|
10
10
|
"url": "https://github.com/awebai/oats-aweb.git",
|
|
11
|
-
"ref": "v1.
|
|
11
|
+
"ref": "v1.12.0",
|
|
12
12
|
"path": "oats-package"
|
|
13
13
|
},
|
|
14
14
|
"oats.jira": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.5",
|
|
4
4
|
"description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|