@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/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.trim())) throw invalid("spawn.model", "a model id");
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
- return validateTrigger({ ...raw, id: a.name, kind: "trigger", enabled: true });
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 === 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 });
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, "--dir", ws, "--preview", "--json"], io);
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
- const argv = ["spawn", def.spawn.soul, "--dir", ws, "--purpose", purpose, "--json"];
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("--launch-config", def.spawn.launchConfig);
407
- if (def.spawn.harness) argv.push("--harness", def.spawn.harness);
408
- if (def.spawn.model) argv.push("--model", def.spawn.model);
409
- if (def.spawn.backend) argv.push("--backend", def.spawn.backend);
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("--task-file", taskFile, "--trigger-event", eventFile);
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 };
@@ -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.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.1",
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",
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",