@awebai/oats 0.30.3 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/triggers.mjs CHANGED
@@ -28,7 +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, readRegistry } from "./schedule.mjs";
31
+ import { readDefinitions, writeDefinitions, stateDir, withScopeLock, childEnv, parseEnvelopeText, schedulerStatus, readRegistry, definitionsPath } from "./schedule.mjs";
32
+ import { herdrSettingRemoved } from "./errors.mjs";
32
33
  import { MODEL_RE, automationError, baseRow, localEntry, localId, parseOwner, qualifiedId, soulOriginOf } from "./automations.mjs";
33
34
  import { lockedPackageRef } from "./packages.mjs";
34
35
 
@@ -43,7 +44,6 @@ const SOUL_RE = /^(?:[a-z0-9][a-z0-9._-]*\/)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
43
44
  const HARNESSES = new Set(["pi", "claude", "codex"]);
44
45
  /** A launch configuration name (oats-local.yaml launch-configs, docs/oats-local.schema.json). */
45
46
  const LAUNCH_CONFIG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
46
- const BACKENDS = new Set(["tmux", "herdr"]);
47
47
  const DEFAULT_POLL = "2m";
48
48
  const POLL_MIN_MS = 60_000;
49
49
  /** A tick runs once a minute and takes a moment: a poll due within this much is taken now. */
@@ -87,8 +87,9 @@ function checkTemplate(text, field) {
87
87
  if (bad.length) throw invalid(field, `the template may name only ${TEMPLATE_FIELDS.map((f) => `{${f}}`).join(" ")} (a PR's title and body are never templated); found ${bad.map((f) => `{${f}}`).join(" ")}`);
88
88
  }
89
89
 
90
- /** Validate and normalize one trigger definition. */
91
- export function validateTrigger(def) {
90
+ /** Validate and normalize one trigger definition. `backendSource` names the file and key that set its
91
+ * spawn.backend, for the refusal of `herdr` (Herdr was removed in 0.31.0). */
92
+ export function validateTrigger(def, { backendSource = "the trigger's spawn.backend" } = {}) {
92
93
  if (!isObject(def)) throw invalid("definition", "must be an object");
93
94
  onlyKeys(def, ["id", "enabled", "kind", "on", "spawn", "concurrency", "template", "createdAt", "updatedAt"], "");
94
95
  if (typeof def.id !== "string" || !ID_RE.test(def.id)) throw invalid("id", "lowercase letters, digits and dashes, 1 to 40 characters");
@@ -123,7 +124,8 @@ export function validateTrigger(def) {
123
124
  if (sp.harness !== undefined && !HARNESSES.has(sp.harness)) throw invalid("spawn.harness", "pi, claude or codex");
124
125
  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
126
  if (sp.yolo !== undefined && typeof sp.yolo !== "boolean") throw invalid("spawn.yolo", "boolean");
126
- if (sp.backend !== undefined && !BACKENDS.has(sp.backend)) throw invalid("spawn.backend", "tmux or herdr");
127
+ if (sp.backend === "herdr") throw Object.assign(herdrSettingRemoved(`${backendSource}: herdr`), { field: "spawn.backend", details: { field: "spawn.backend" } });
128
+ if (sp.backend !== undefined && sp.backend !== "tmux") throw invalid("spawn.backend", "tmux");
127
129
  const cc = def.concurrency === undefined ? {} : def.concurrency;
128
130
  if (!isObject(cc)) throw invalid("concurrency", "{ max?, perKey? }");
129
131
  onlyKeys(cc, ["max", "perKey"], "concurrency.");
@@ -207,7 +209,7 @@ export function triggerKind({ lock = null, remote = null } = {}) {
207
209
  parseBody: parseTriggerBody,
208
210
  expand: async (a) => {
209
211
  const raw = a.source.from ? await packageTriggerTemplate(lock, a.source.from, a.source.set, { id: a.name, remote }) : { ...a.source.definition };
210
- const def = validateTrigger({ ...raw, id: a.name, kind: "trigger", enabled: true });
212
+ const def = validateTrigger({ ...raw, id: a.name, kind: "trigger", enabled: true }, { backendSource: `${a.source.from ? `package template ${a.source.from}` : a.origin?.path ?? a.name} sets spawn.backend` });
211
213
  // The owner check asks gh on the OWNER's host, and the poll runs on the REPO's host: they must be
212
214
  // one GitHub host, or polling would act as whoever is logged in there (re-review B #7).
213
215
  const owner = parseOwner(a.owner), repo = parseRepo(def.on.repo);
@@ -239,7 +241,7 @@ export function triggerEntries(ws, ctx) {
239
241
  const out = [];
240
242
  for (const [id, raw] of Object.entries(triggerJobs(ws)).sort(([a], [b]) => a.localeCompare(b))) {
241
243
  let def = null, bad = null;
242
- try { def = validateTrigger(raw); } catch (e) { bad = { code: e.code, message: e.message, ...(e.field ? { field: e.field } : {}) }; }
244
+ try { def = validateTrigger(raw, { backendSource: `${definitionsPath(ws)} sets jobs.${id}.spawn.backend` }); } catch (e) { bad = { code: e.code, message: e.message, ...(e.field ? { field: e.field } : {}) }; }
243
245
  out.push({ ...localEntry("trigger", def ?? { ...raw, id }, { invalid: bad, dep: ws }), raw });
244
246
  }
245
247
  out.push(...(ctx?.triggers || []));
@@ -577,7 +579,7 @@ export function listTriggers(ws, ctx = null, { io } = {}) {
577
579
  };
578
580
  }
579
581
  export function addTrigger(ws, spec) {
580
- const def = validateTrigger({ ...spec, kind: spec?.kind ?? "trigger" });
582
+ const def = validateTrigger({ ...spec, kind: spec?.kind ?? "trigger" }, { backendSource: `${definitionsPath(ws)} sets jobs.${spec?.id}.spawn.backend` });
581
583
  return withScopeLock(ws, () => {
582
584
  const defs = readDefinitions(ws);
583
585
  if (defs.jobs[def.id]) throw triggerError("E_TRIGGER_EXISTS", `${defs.jobs[def.id].kind === "trigger" ? "trigger" : "a schedule"} ${def.id} already exists in ${ws}`, { details: { id: def.id } });
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "oats.aweb": {
10
10
  "url": "https://github.com/awebai/oats-aweb.git",
11
- "ref": "v1.17.1",
11
+ "ref": "v1.17.3",
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.30.3",
3
+ "version": "0.31.0",
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",
package/lib/herdr.mjs DELETED
@@ -1,143 +0,0 @@
1
- /** Herdr 0.8.x/0.9.x host-local backend (explicit protocols 20/22).
2
- * SSH belongs to the CLI router. A new session records the server's protocol once; a recorded
3
- * target is never renegotiated. */
4
- import { execFileSync, spawn } from "node:child_process";
5
- import { existsSync, mkdirSync } from "node:fs";
6
- import { homedir } from "node:os";
7
- import { dirname, join, resolve } from "node:path";
8
-
9
- // The legacy protocol literal; a new session records the protocol its server reports (ensureHerdr).
10
- export const HERDR_PROTOCOL = 20;
11
- export const HERDR_SUPPORTED_PROTOCOLS = Object.freeze([20, 22]);
12
- export const isSupportedHerdrProtocol = (value) => HERDR_SUPPORTED_PROTOCOLS.includes(value);
13
- function selectedHerdrProtocol(target) {
14
- if (!isSupportedHerdrProtocol(target?.protocol)) throw new Error("Herdr endpoint requires explicit supported protocol 20 or 22");
15
- return target.protocol;
16
- }
17
- const quote = (s) => `'${String(s).replaceAll("'", "'\\''")}'`;
18
- const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
19
-
20
- export function herdrSocket(session = "oats") {
21
- if (!/^[a-zA-Z0-9_-]+$/.test(session)) throw new Error("invalid Herdr session name");
22
- return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "herdr", "sessions", session, "herdr.sock");
23
- }
24
-
25
- /** One Herdr CLI call on the target's socket, its stdout as text. */
26
- function herdrOutput(target, args, { timeout = 10000, exec = execFileSync } = {}) {
27
- const env = { ...process.env, HERDR_SOCKET_PATH: target.socket };
28
- delete env.HERDR_SESSION;
29
- return exec(target.binary || "herdr", args, { env, encoding: "utf8", timeout, maxBuffer: 8 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
30
- }
31
-
32
- export function herdrCommand(target, args, io) {
33
- const output = herdrOutput(target, args, io);
34
- // Some successful terminal mutations have no output; reads below require
35
- // their documented result shape independently.
36
- if (!output.trim()) return {};
37
- const response = JSON.parse(output);
38
- if (response.error) throw Object.assign(new Error(response.error.message), { code: response.error.code });
39
- if (!response.result) throw new Error("Herdr returned no result");
40
- return response.result;
41
- }
42
-
43
- export function herdrSnapshot(target, io) {
44
- const protocol = selectedHerdrProtocol(target);
45
- const snapshot = herdrCommand(target, ["api", "snapshot"], io).snapshot;
46
- if (snapshot?.protocol !== protocol) throw new Error(`Herdr snapshot does not match selected protocol ${protocol}`);
47
- if (!Array.isArray(snapshot.panes)) throw new Error("Herdr snapshot has no pane inventory");
48
- return snapshot;
49
- }
50
-
51
- /** The target for a NEW session on the server at `socket`: it records the supported protocol that server
52
- * reports (Herdr 0.8 speaks 20, 0.9 speaks 22). Every later call checks the snapshot against that recorded
53
- * protocol, so a recorded target is never renegotiated. */
54
- function newSessionTarget(target, io) {
55
- const protocol = herdrCommand(target, ["api", "snapshot"], io).snapshot?.protocol;
56
- if (!isSupportedHerdrProtocol(protocol)) throw Object.assign(new Error(`Herdr server speaks protocol ${protocol ?? "unknown"}; OATS supports ${HERDR_SUPPORTED_PROTOCOLS.join(" and ")}`), { unsupportedProtocol: true });
57
- const selected = { ...target, protocol };
58
- herdrSnapshot(selected, io);
59
- return selected;
60
- }
61
-
62
- export function ensureHerdr({ binary = "herdr", socket, session = "oats", io } = {}) {
63
- const target = { backend: "herdr", binary, socket: resolve(socket || herdrSocket(session)) };
64
- try { return newSessionTarget(target, io); }
65
- catch (e) {
66
- // An explicit socket belongs to an operator-managed server. Never start a
67
- // different server when it cannot be inspected or is incompatible.
68
- if (socket || existsSync(target.socket)) throw e;
69
- }
70
- mkdirSync(dirname(target.socket), { recursive: true });
71
- const env = { ...process.env };
72
- delete env.HERDR_SESSION; delete env.HERDR_SOCKET_PATH;
73
- const child = spawn(binary, ["--session", session, "server"], { detached: true, stdio: "ignore", env });
74
- child.on("error", () => {});
75
- child.unref();
76
- let error;
77
- for (let i = 0; i < 30; i++) {
78
- pause(100);
79
- // A server that answers with an unsupported protocol is up: waiting longer cannot change its answer.
80
- try { return newSessionTarget(target, io); } catch (e) { if (e.unsupportedProtocol) throw e; error = e; }
81
- }
82
- throw new Error(`Herdr did not start: ${error?.message || "no socket"}`);
83
- }
84
-
85
- export function allocateHerdr(target, { home, instance }, io) {
86
- const protocol = selectedHerdrProtocol(target);
87
- const result = herdrCommand(target, ["workspace", "create", "--cwd", home, "--label", instance, "--no-focus"], io);
88
- const pane = result.root_pane;
89
- if (!pane?.pane_id || !pane?.terminal_id || !pane?.workspace_id) throw new Error("Herdr allocation returned no session identity");
90
- return { ...target, protocol, workspaceId: pane.workspace_id, paneId: pane.pane_id, terminalId: pane.terminal_id };
91
- }
92
-
93
- export function inspectHerdr(target, io) {
94
- const snapshot = herdrSnapshot(target, io);
95
- const matches = snapshot.panes.filter((p) => p.pane_id === target.paneId && p.terminal_id === target.terminalId);
96
- if (io?.strictHerdrTarget && (matches.length > 1 || (matches.length === 1 && matches[0].workspace_id !== target.workspaceId))) throw new Error("Herdr target workspace/pane/terminal identity is ambiguous or changed");
97
- const pane = matches[0];
98
- const agent = pane && snapshot.agents?.find((a) => a.terminal_id === target.terminalId);
99
- return { present: !!pane, pane, agent, status: agent?.agent_status || "unknown" };
100
- }
101
-
102
- /** Wait until a new pane's shell has drawn something (its prompt) before typing into it. Text typed
103
- * earlier sits in the terminal's canonical line buffer, which drops a line past 1024 bytes on macOS:
104
- * a launch command is longer than that, and arrived cut. Bounded; a Herdr that cannot read a pane
105
- * leaves the old behaviour (type at once). */
106
- function awaitShellReady(target, io) {
107
- const deadline = Date.now() + (io?.shellReadyMs ?? 10000);
108
- for (;;) {
109
- let text;
110
- // `pane read` answers the screen as plain text, not a JSON envelope.
111
- const remaining = Math.max(100, deadline - Date.now());
112
- try { text = herdrOutput(target, ["pane", "read", target.paneId, "--source", "visible"], { ...io, timeout: Math.min(2000, remaining) }); }
113
- catch { return; }
114
- if (typeof text === "string" && text.trim()) return;
115
- if (Date.now() >= deadline) return;
116
- pause(100);
117
- }
118
- }
119
-
120
- export function launchHerdr(target, command, io) {
121
- if (!inspectHerdr(target, io).present) throw new Error("Herdr launch pane disappeared");
122
- awaitShellReady(target, io);
123
- // This is the allocated shell's one launch command. Exec avoids a fallback
124
- // shell that could accidentally consume a subsequent agent notification.
125
- herdrCommand(target, ["pane", "run", target.paneId, `exec /bin/sh -c ${quote(command)}`], io);
126
- }
127
-
128
- export function stopHerdr(target, io) {
129
- if (!inspectHerdr(target, io).present) return;
130
- try { herdrCommand(target, ["pane", "close", target.paneId], io); } catch { /* inspect the effect even if the pane exited concurrently */ }
131
- if (inspectHerdr(target, io).present) throw new Error(`Herdr pane ${target.paneId} is still present`);
132
- }
133
-
134
- export function inputHerdr(target, text, io) {
135
- if (!inspectHerdr(target, io).present) throw new Error("Herdr session stopped or was replaced");
136
- herdrCommand(target, ["pane", "run", target.paneId, text], io);
137
- }
138
-
139
- export function validHerdrTarget(value) {
140
- return value?.backend === "herdr" && isSupportedHerdrProtocol(value.protocol)
141
- && [value.socket, value.binary, value.workspaceId, value.paneId, value.terminalId].every((v) => typeof v === "string" && v.length > 0)
142
- && value.socket === resolve(value.socket);
143
- }