@awebai/oats 0.29.3 → 0.29.4
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 +9 -4
- package/capabilities/oats-okf/bin/oats-okf.mjs +9 -6
- package/capabilities/oats-okf/lib/config.mjs +2 -1
- package/capabilities/oats-okf/lib/consult.mjs +26 -4
- package/capabilities/oats-okf/lib/harvest-switch.mjs +16 -3
- package/capabilities/oats-okf/lib/io.mjs +9 -1
- package/capabilities/oats-okf/lib/sources.mjs +18 -1
- package/capabilities/oats-okf/lib/stores.mjs +8 -6
- package/capabilities/oats-okf/lib/worker.mjs +2 -1
- package/capabilities/oats-okf/oats.json +1 -1
- package/capabilities/oats-okf-harvest/oats.json +1 -1
- package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +35 -14
- package/capabilities/oats-okf-maintenance/lib/provenance.mjs +6 -1
- package/capabilities/oats-okf-maintenance/oats.json +1 -1
- package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +16 -1
- package/docs/capability-manifest.schema.json +3 -1
- package/docs/design/2026-09-27-team-model-v2.md +136 -0
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +4 -4
- package/docs/release-notes/v0.29.4.md +90 -0
- package/docs/schedules.md +18 -4
- package/docs/workspaces.md +2 -2
- package/lib/automations.mjs +7 -0
- package/lib/packages.mjs +2 -5
- package/lib/schedule.mjs +31 -17
- package/lib/triggers.mjs +51 -15
- package/package-catalog.json +2 -2
- package/package.json +1 -1
package/lib/triggers.mjs
CHANGED
|
@@ -28,8 +28,8 @@ import { spawnSync } from "node:child_process";
|
|
|
28
28
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
29
29
|
import { dirname, join } from "node:path";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
|
-
import { readDefinitions, writeDefinitions, stateDir, withScopeLock, childEnv, parseEnvelopeText, schedulerStatus } from "./schedule.mjs";
|
|
32
|
-
import { automationError, baseRow, localEntry, localId, qualifiedId, soulOriginOf } from "./automations.mjs";
|
|
31
|
+
import { readDefinitions, writeDefinitions, stateDir, withScopeLock, childEnv, parseEnvelopeText, schedulerStatus, readRegistry } from "./schedule.mjs";
|
|
32
|
+
import { MODEL_RE, automationError, baseRow, localEntry, localId, parseOwner, qualifiedId, soulOriginOf } from "./automations.mjs";
|
|
33
33
|
import { lockedPackageRef } from "./packages.mjs";
|
|
34
34
|
|
|
35
35
|
export const TRIGGER_API = 1;
|
|
@@ -121,7 +121,7 @@ export function validateTrigger(def) {
|
|
|
121
121
|
if (!Array.isArray(teams) || teams.some((t) => typeof t !== "string" || !LABEL_RE.test(t)) || new Set(teams).size !== teams.length) throw invalid("spawn.teams", "a list of distinct team labels");
|
|
122
122
|
if (sp.launchConfig !== undefined && (typeof sp.launchConfig !== "string" || !LAUNCH_CONFIG_RE.test(sp.launchConfig))) throw invalid("spawn.launchConfig", "the name of a launch configuration on the running host (oats-local.yaml launch-configs)");
|
|
123
123
|
if (sp.harness !== undefined && !HARNESSES.has(sp.harness)) throw invalid("spawn.harness", "pi, claude or codex");
|
|
124
|
-
if (sp.model !== undefined && (typeof sp.model !== "string" || !sp.model
|
|
124
|
+
if (sp.model !== undefined && (typeof sp.model !== "string" || !MODEL_RE.test(sp.model))) throw invalid("spawn.model", "a model id: a letter or digit, then letters, digits and . _ : / @ + - [ ] (at most 128 characters)");
|
|
125
125
|
if (sp.yolo !== undefined && typeof sp.yolo !== "boolean") throw invalid("spawn.yolo", "boolean");
|
|
126
126
|
if (sp.backend !== undefined && !BACKENDS.has(sp.backend)) throw invalid("spawn.backend", "tmux or herdr");
|
|
127
127
|
const cc = def.concurrency === undefined ? {} : def.concurrency;
|
|
@@ -207,7 +207,12 @@ export function triggerKind({ lock = null, remote = null } = {}) {
|
|
|
207
207
|
parseBody: parseTriggerBody,
|
|
208
208
|
expand: async (a) => {
|
|
209
209
|
const raw = a.source.from ? await packageTriggerTemplate(lock, a.source.from, a.source.set, { id: a.name, remote }) : { ...a.source.definition };
|
|
210
|
-
|
|
210
|
+
const def = validateTrigger({ ...raw, id: a.name, kind: "trigger", enabled: true });
|
|
211
|
+
// The owner check asks gh on the OWNER's host, and the poll runs on the REPO's host: they must be
|
|
212
|
+
// one GitHub host, or polling would act as whoever is logged in there (re-review B #7).
|
|
213
|
+
const owner = parseOwner(a.owner), repo = parseRepo(def.on.repo);
|
|
214
|
+
if (owner && repo && owner.host.toLowerCase() !== repo.host) throw Object.assign(triggerError("E_TRIGGER_INVALID", `owner ${a.owner} is on ${owner.host}, but on.repo ${repo.key} is on ${repo.host}: a trigger acts as its owner on the repository's own GitHub host`), { field: "owner" });
|
|
215
|
+
return def;
|
|
211
216
|
},
|
|
212
217
|
};
|
|
213
218
|
}
|
|
@@ -265,8 +270,28 @@ export function readTriggerState(ws) {
|
|
|
265
270
|
let doc;
|
|
266
271
|
try { doc = JSON.parse(readFileSync(file, "utf8")); } catch { doc = null; }
|
|
267
272
|
if (!isObject(doc) || !isObject(doc.triggers)) return { version: 1, triggers: {} };
|
|
273
|
+
return carryUnqualifiedState(doc);
|
|
274
|
+
}
|
|
275
|
+
/** 0.28 kept a local trigger's state under its bare id, with event keys `<id>:…`; 0.29 qualifies
|
|
276
|
+
* both as `local/<id>`. Carried on read (re-review B #2): without it the first 0.29 tick saw no
|
|
277
|
+
* PR state, so every open matching PR emitted `opened` and spawned again. A bare id already
|
|
278
|
+
* present in qualified form keeps the qualified state. */
|
|
279
|
+
function carryUnqualifiedState(doc) {
|
|
280
|
+
for (const id of Object.keys(doc.triggers)) {
|
|
281
|
+
if (id.includes("/")) continue;
|
|
282
|
+
const qid = localId(id), old = doc.triggers[id];
|
|
283
|
+
delete doc.triggers[id];
|
|
284
|
+
if (doc.triggers[qid] || !isObject(old)) continue;
|
|
285
|
+
const rekey = (map) => Object.fromEntries(Object.entries(isObject(map) ? map : {}).map(([k, v]) => {
|
|
286
|
+
const key = k.startsWith(`${id}:`) ? `${qid}${k.slice(id.length)}` : k;
|
|
287
|
+
return [key, isObject(v) && "key" in v ? { ...v, key, ...(v.trigger === id ? { trigger: qid } : {}) } : v];
|
|
288
|
+
}));
|
|
289
|
+
doc.triggers[qid] = { ...old, prs: isObject(old.prs) ? old.prs : {}, fired: rekey(old.fired), pending: rekey(old.pending) };
|
|
290
|
+
}
|
|
268
291
|
return doc;
|
|
269
292
|
}
|
|
293
|
+
/** Whether an instance's recorded trigger id is `id`: a 0.28 home records a local trigger's bare id. */
|
|
294
|
+
const sameTrigger = (recorded, id) => recorded === id || (typeof recorded === "string" && !recorded.includes("/") && id === localId(recorded));
|
|
270
295
|
function writeTriggerState(ws, st) {
|
|
271
296
|
const file = statePath(ws);
|
|
272
297
|
mkdirSync(dirname(file), { recursive: true });
|
|
@@ -290,7 +315,7 @@ export function liveTriggerInstances(ws, id) {
|
|
|
290
315
|
if (!e.isDirectory()) continue;
|
|
291
316
|
const home = join(instances, e.name);
|
|
292
317
|
let meta; try { meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); } catch { continue; }
|
|
293
|
-
if (isObject(meta?.trigger) && meta.trigger.id
|
|
318
|
+
if (isObject(meta?.trigger) && (id === null || sameTrigger(meta.trigger.id, id))) out.push({ instance: meta.instance ?? e.name, home, repo: meta.trigger.repo, number: meta.trigger.number, event: meta.trigger.event, key: meta.trigger.key ?? null });
|
|
294
319
|
}
|
|
295
320
|
}
|
|
296
321
|
return out;
|
|
@@ -359,6 +384,9 @@ export function foldPoll(def, ts, poll, now) {
|
|
|
359
384
|
for (const e of eventsFor(def, ts.prs[pr.number], pr)) {
|
|
360
385
|
const key = eventKey(def, pr, e);
|
|
361
386
|
if (ts.fired[key] || ts.pending[key]) continue;
|
|
387
|
+
// A newer push supersedes a pending `synchronize` for an older head of the same PR: only
|
|
388
|
+
// the newest head is reviewed (re-review B #6).
|
|
389
|
+
if (e.event === "synchronize") for (const [k, p] of Object.entries(ts.pending)) if (p.number === pr.number && p.event === "synchronize") delete ts.pending[k];
|
|
362
390
|
ts.pending[key] = { trigger: def.qid ?? def.id, source: def.on.source, repo: def.on.repo, number: pr.number, url: pr.url, event: e.event, headSha: pr.headSha, labels: pr.labels, observedAt: now.toISOString(), key };
|
|
363
391
|
added.push(key);
|
|
364
392
|
}
|
|
@@ -384,7 +412,7 @@ function runOats(ws, argv, io) {
|
|
|
384
412
|
/** What `oats spawn <soul> --preview` says about the soul: its qualified resolution and the
|
|
385
413
|
* capability filling its messaging slot. → { ok, result | error } */
|
|
386
414
|
export function previewSoul(ws, def, io) {
|
|
387
|
-
const r = runOats(ws, ["spawn", def.spawn.soul,
|
|
415
|
+
const r = runOats(ws, ["spawn", def.spawn.soul, `--dir=${ws}`, "--preview", "--json"], io);
|
|
388
416
|
if (!r.envelope || typeof r.envelope.ok !== "boolean") return { ok: false, error: { code: "E_SPAWN_FAILED", message: r.timedOut ? "spawn --preview timed out" : `spawn --preview answered no envelope${r.stderr ? `: ${r.stderr.trim().split("\n").pop()}` : ""}` } };
|
|
389
417
|
if (!r.envelope.ok) return { ok: false, error: { code: r.envelope.error?.code ?? "E_SPAWN_FAILED", message: r.envelope.error?.message ?? "spawn --preview failed" } };
|
|
390
418
|
const modules = Array.isArray(r.envelope.result?.modules) ? r.envelope.result.modules : [];
|
|
@@ -396,17 +424,18 @@ export function previewSoul(ws, def, io) {
|
|
|
396
424
|
export function spawnForEvent(ws, def, ev, io) {
|
|
397
425
|
const fields = { repo: ev.repo, number: ev.number, url: ev.url, event: ev.event, headSha: ev.headSha, trigger: def.id };
|
|
398
426
|
const purpose = renderTemplate(def.spawn.purpose, fields);
|
|
399
|
-
|
|
427
|
+
// Every value travels as ONE `--flag=value` token, so no value can be read as a flag of its own.
|
|
428
|
+
const argv = ["spawn", def.spawn.soul, `--dir=${ws}`, `--purpose=${purpose}`, "--json"];
|
|
400
429
|
if (def.spawn.teams.length) {
|
|
401
430
|
const pv = previewSoul(ws, def, io);
|
|
402
431
|
if (!pv.ok) throw triggerError(pv.error.code, pv.error.message);
|
|
403
432
|
if (!pv.messaging) throw triggerError("E_TRIGGER_TEAMS", `trigger ${def.id}: spawn.teams [${def.spawn.teams.join(", ")}] needs a messaging capability, and soul ${def.spawn.soul} resolves none`, { details: { soul: def.spawn.soul, teams: def.spawn.teams } });
|
|
404
433
|
argv.push("--provider", pv.messaging, `join=${def.spawn.teams.join(",")}`);
|
|
405
434
|
}
|
|
406
|
-
if (def.spawn.launchConfig) argv.push(
|
|
407
|
-
if (def.spawn.harness) argv.push(
|
|
408
|
-
if (def.spawn.model) argv.push(
|
|
409
|
-
if (def.spawn.backend) argv.push(
|
|
435
|
+
if (def.spawn.launchConfig) argv.push(`--launch-config=${def.spawn.launchConfig}`);
|
|
436
|
+
if (def.spawn.harness) argv.push(`--harness=${def.spawn.harness}`);
|
|
437
|
+
if (def.spawn.model) argv.push(`--model=${def.spawn.model}`);
|
|
438
|
+
if (def.spawn.backend) argv.push(`--backend=${def.spawn.backend}`);
|
|
410
439
|
if (def.spawn.yolo === true) argv.push("--yolo"); else if (def.spawn.yolo === false) argv.push("--no-yolo");
|
|
411
440
|
if (io?.noLaunch === true) argv.push("--no-launch");
|
|
412
441
|
// The task and the event travel as private files, never in argv.
|
|
@@ -416,7 +445,7 @@ export function spawnForEvent(ws, def, ev, io) {
|
|
|
416
445
|
const taskFile = `${stem}.md`, eventFile = `${stem}.event.json`;
|
|
417
446
|
writeFileSync(eventFile, JSON.stringify(ev, null, 2) + "\n", { mode: 0o600 });
|
|
418
447
|
writeFileSync(taskFile, renderTemplate(def.spawn.task, fields).trimEnd() + triggerBlock(def, ev, "<home>/.oats/trigger-event.json"), { mode: 0o600 });
|
|
419
|
-
argv.push(
|
|
448
|
+
argv.push(`--task-file=${taskFile}`, `--trigger-event=${eventFile}`);
|
|
420
449
|
let r;
|
|
421
450
|
try { r = runOats(ws, argv, io); }
|
|
422
451
|
finally { for (const f of [taskFile, eventFile]) { try { rmSync(f, { force: true }); } catch { /* best effort */ } } }
|
|
@@ -438,10 +467,16 @@ function heldBy(def, ev, live) {
|
|
|
438
467
|
* whose poll is not due is not polled; pending events (a spawn that failed, or was held by
|
|
439
468
|
* concurrency) are retried whenever the trigger polls. `dryRun` reads only.
|
|
440
469
|
* → considered rows { workspace, trigger, action, … } */
|
|
441
|
-
export function tickTriggers(ws, { now = new Date(), io, dryRun = false, ctx = null } = {}) {
|
|
470
|
+
export function tickTriggers(ws, { now = new Date(), io, dryRun = false, ctx = null, reg = null, wsList = null } = {}) {
|
|
442
471
|
let entries;
|
|
443
472
|
try { entries = triggerEntries(ws, ctx); } catch (e) { return [{ workspace: ws, action: "error", error: e.message }]; }
|
|
444
473
|
if (!entries.length) return [];
|
|
474
|
+
// The host cap (registry triggersMaxConcurrent; absent = unbounded) counts every trigger-spawned
|
|
475
|
+
// live home in the host's scopes, and nothing a schedule runs (re-review B #5).
|
|
476
|
+
let hostCap;
|
|
477
|
+
try { hostCap = (reg || readRegistry()).triggersMaxConcurrent; } catch (e) { return [{ workspace: ws, action: "error", error: e.message }]; }
|
|
478
|
+
const scopes = [...new Set([...(wsList || []), ws])];
|
|
479
|
+
let hostLive = hostCap ? scopes.reduce((n, w) => n + liveTriggerInstances(w, null).length, 0) : 0;
|
|
445
480
|
const st = readTriggerState(ws);
|
|
446
481
|
const considered = [];
|
|
447
482
|
const rec = (id, action, extra = {}) => considered.push({ workspace: ws, trigger: id, action, ...extra });
|
|
@@ -471,15 +506,16 @@ export function tickTriggers(ws, { now = new Date(), io, dryRun = false, ctx = n
|
|
|
471
506
|
const queue = Object.values(work.pending).sort((a, b) => a.observedAt.localeCompare(b.observedAt) || a.key.localeCompare(b.key));
|
|
472
507
|
if (!queue.length) rec(id, "polled", { prs: work.lastPoll.prs, matching: work.lastPoll.matching });
|
|
473
508
|
for (const ev of queue) {
|
|
474
|
-
const held = heldBy(def, ev, live);
|
|
509
|
+
const held = heldBy(def, ev, live) ?? (hostCap && hostLive >= hostCap ? `host triggersMaxConcurrent ${hostCap} reached (${hostLive} live)` : null);
|
|
475
510
|
if (held) { rec(id, "held", { key: ev.key, reason: held }); continue; }
|
|
476
|
-
if (dryRun) { rec(id, "would-fire", { key: ev.key, event: ev.event, number: ev.number, url: ev.url }); live.push({ repo: ev.repo, number: ev.number }); continue; }
|
|
511
|
+
if (dryRun) { rec(id, "would-fire", { key: ev.key, event: ev.event, number: ev.number, url: ev.url }); live.push({ repo: ev.repo, number: ev.number }); hostLive++; continue; }
|
|
477
512
|
try {
|
|
478
513
|
const run = spawnForEvent(ws, def, ev, io);
|
|
479
514
|
work.fired[ev.key] = { at: now.toISOString(), instance: run.instance ?? null, home: run.home ?? null, event: ev.event, number: ev.number };
|
|
480
515
|
delete work.pending[ev.key];
|
|
481
516
|
work.lastFiredAt = now.toISOString();
|
|
482
517
|
live.push({ instance: run.instance, home: run.home, repo: ev.repo, number: ev.number });
|
|
518
|
+
hostLive++;
|
|
483
519
|
rec(id, "fired", { key: ev.key, instance: run.instance ?? null, home: run.home ?? null });
|
|
484
520
|
} catch (e) {
|
|
485
521
|
work.lastError = { at: now.toISOString(), code: e.code || "E_SPAWN_FAILED", message: e.message, key: ev.key };
|
package/package-catalog.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"packages": {
|
|
4
4
|
"oats.okf": {
|
|
5
5
|
"url": "https://github.com/awebai/oats-okf.git",
|
|
6
|
-
"ref": "v4.0.
|
|
6
|
+
"ref": "v4.0.1",
|
|
7
7
|
"path": "oats-package"
|
|
8
8
|
},
|
|
9
9
|
"oats.aweb": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"oats.framework": {
|
|
35
35
|
"url": "https://github.com/awebai/oats.git",
|
|
36
|
-
"ref": "oats-framework/v1.3.
|
|
36
|
+
"ref": "oats-framework/v1.3.2",
|
|
37
37
|
"path": "oats-package"
|
|
38
38
|
}
|
|
39
39
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.4",
|
|
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",
|