@awebai/oats 0.30.3 → 0.32.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/resolve.mjs CHANGED
@@ -60,6 +60,7 @@ import * as defaultRemote from "./remote.mjs";
60
60
  import { BY_TEAM_REMOVED, reportRows, soulKeyOf, soulTeams, teamModel } from "./teams.mjs";
61
61
  import { bindRemote, packageProviding, readPackageManifests, validateLock } from "./packages.mjs";
62
62
  import { settingValueProblems } from "./capability-contract.mjs";
63
+ import { memberRowByKey } from "./workspace.mjs";
63
64
 
64
65
  export const RESOLUTION_API = 1;
65
66
  export const SLOTS = Object.freeze(["knowledge", "messaging", "tasks"]);
@@ -367,14 +368,26 @@ export function composeCapabilities(workspace, soulDefinition, { offs = null } =
367
368
 
368
369
  /* ───────────────────────────── lookups ────────────────────────────────── */
369
370
 
370
- function memberRow(discovery, repoKey) {
371
- return (discovery?.members || []).find((m) => m.key === repoKey) || null;
372
- }
371
+ /** A discovery's member row by key (the first with that key; lib/workspace.mjs memberRowByKey). */
372
+ const memberRow = (discovery, repoKey) => memberRowByKey(discovery?.members, repoKey);
373
373
 
374
- /** The ref the workspace lists a member under (keeps the operator's spelling: ssh vs https); else from the key. */
374
+ /** The ref the workspace lists a member under (keeps the operator's spelling: ssh vs https); else from the key.
375
+ * The key → first listed ref index is built once per members array and parseRepoRef. */
376
+ const memberRefIndex = new WeakMap();
375
377
  export function memberRef(discovery, remote, repoKey) {
376
- for (const ref of discovery?.workspace?.members || []) {
377
- try { if (remote.parseRepoRef(ref).key === repoKey) return ref; } catch { /* validated upstream */ }
378
+ const members = discovery?.workspace?.members;
379
+ if (Array.isArray(members)) {
380
+ let byParser = memberRefIndex.get(members);
381
+ if (!byParser) memberRefIndex.set(members, byParser = new Map());
382
+ let index = byParser.get(remote.parseRepoRef);
383
+ if (!index) {
384
+ index = new Map();
385
+ for (const ref of members) {
386
+ try { const key = remote.parseRepoRef(ref).key; if (!index.has(key)) index.set(key, ref); } catch { /* validated upstream */ }
387
+ }
388
+ byParser.set(remote.parseRepoRef, index);
389
+ }
390
+ if (index.has(repoKey)) return index.get(repoKey);
378
391
  }
379
392
  return refForKey(repoKey);
380
393
  }
@@ -496,7 +509,10 @@ async function enumerateSkills({ remote, ref, commit, dir, manifest, listing, mo
496
509
  // Discovery already listed the capability directory (relative to it): project onto this entry.
497
510
  entries = listing.filter((e) => e.path === declared || e.path.startsWith(`${declared}/`)).map((e) => ({ ...e, path: e.path === declared ? "" : e.path.slice(declared.length + 1) })).filter((e) => e.path);
498
511
  } else {
499
- entries = await remote.listRemoteTree(ref, commit, posix.join(dir, declared), { depth: 2 });
512
+ // A listing at a commit never changes: the parsed cache keeps it (lib/remote.mjs memoAtCommit).
513
+ const at = posix.join(dir, declared);
514
+ const list = () => remote.listRemoteTree(ref, commit, at, { depth: 2 });
515
+ entries = typeof remote.memoAtCommit === "function" ? await remote.memoAtCommit(ref, commit, `list\0${at}\0${2}`, list) : await list();
500
516
  }
501
517
  const found = skillsInListing(declared, entries);
502
518
  if (found.length === 0) throw missing(raw, "skill-missing", `declares skills entry ${show(raw)} but no SKILL.md is there (neither ${declared}/SKILL.md nor ${declared}/*/SKILL.md)`);
package/lib/schedule.mjs CHANGED
@@ -29,6 +29,7 @@ import { fileURLToPath } from "node:url";
29
29
  import { Cron } from "croner";
30
30
  import { loadLocal } from "./workspace.mjs";
31
31
  import { noteRuntimeName } from "./deprecation.mjs";
32
+ import { herdrSettingRemoved } from "./errors.mjs";
32
33
  import { tickTriggers } from "./triggers.mjs";
33
34
  import { resolveMemberClone } from "./instance-resolution.mjs";
34
35
  import { AUTOMATION_ID_RE, MODEL_RE, automationContext, automationError, baseRow, localEntry, localId, soulOriginOf, splitId } from "./automations.mjs";
@@ -38,7 +39,6 @@ export const SCHEDULE_FILE = "oats-schedules.json";
38
39
  export const SCHEDULE_API = 2;
39
40
  const ID_RE = /^[a-z0-9-]{1,40}$/;
40
41
  const HARNESSES = new Set(["pi", "claude", "codex"]);
41
- const BACKENDS = new Set(["tmux", "herdr"]);
42
42
  const MESSAGE_MAX = 256 * 1024;
43
43
  const OATS_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "oats.mjs");
44
44
  const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
@@ -295,8 +295,17 @@ function validateMessage(message, field) {
295
295
  if (typeof message !== "string" || !message.trim() || message.includes("\0") || Buffer.byteLength(message) > MESSAGE_MAX) throw scheduleError("E_SCHEDULE_INVALID", `${field}: non-empty text without NUL, at most 256 KiB`, { field });
296
296
  }
297
297
 
298
- /** Validate and normalize one definition against its scope. */
299
- export function validateDefinition(ws, def, { checkAgent = true } = {}) {
298
+ /** The refusal of a spawn job's `backend: herdr` (Herdr was removed in 0.31.0): `where` names the
299
+ * file and key that set it. */
300
+ export function herdrBackendRefusal(where) { return Object.assign(herdrSettingRemoved(`${where}: herdr`), { field: "backend" }); }
301
+ /** A stored local spawn job that selects Herdr: its refusal, naming this scope's definitions file. */
302
+ function storedHerdrBackend(ws, id, def) {
303
+ return def?.kind === "spawn" && !def.workspace && def.backend === "herdr" ? herdrBackendRefusal(`${definitionsPath(ws)} sets jobs.${id}.backend`) : null;
304
+ }
305
+
306
+ /** Validate and normalize one definition against its scope. `backendSource` names the file and key
307
+ * a definition read from elsewhere sets its backend in (default: this scope's definitions file). */
308
+ export function validateDefinition(ws, def, { checkAgent = true, backendSource } = {}) {
300
309
  if (!def || typeof def !== "object" || Array.isArray(def)) throw scheduleError("E_SCHEDULE_INVALID", "definition must be an object", { field: "definition" });
301
310
  if (def.kind === "trigger") throw scheduleError("E_SCHEDULE_INVALID", "kind: a trigger is added with `oats trigger add`, not `oats schedule add`", { field: "kind" });
302
311
  const id = def.id;
@@ -314,7 +323,8 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
314
323
  out.agentsRoot = resolve(def.agentsRoot);
315
324
  }
316
325
  if (def.repo !== undefined) { if (typeof def.repo !== "string" || !def.repo.trim() || def.repo.startsWith("-")) throw scheduleError("E_SCHEDULE_INVALID", "repo: the work repository path, as oats spawn --repo", { field: "repo" }); out.repo = def.repo; }
317
- if (def.backend !== undefined) { if (!BACKENDS.has(def.backend)) throw scheduleError("E_SCHEDULE_INVALID", "backend: tmux or herdr", { field: "backend" }); out.backend = def.backend; }
326
+ if (def.backend === "herdr") throw herdrBackendRefusal(backendSource ?? `${definitionsPath(ws)} sets jobs.${id}.backend`);
327
+ if (def.backend !== undefined) { if (def.backend !== "tmux") throw scheduleError("E_SCHEDULE_INVALID", "backend: tmux", { field: "backend" }); out.backend = def.backend; }
318
328
  if (def.purpose !== undefined) { if (typeof def.purpose !== "string" || !/^[a-z0-9-]{1,40}$/.test(def.purpose)) throw scheduleError("E_SCHEDULE_INVALID", "purpose: lowercase letters, digits and dashes", { field: "purpose" }); out.purpose = def.purpose; }
319
329
  // A run spawns <agent>-<purpose|id>-<YYYYMMDDHHMM>; instance names are at most
320
330
  // MAX_INSTANCE_NAME characters, so refuse here what would fail at every firing.
@@ -413,7 +423,7 @@ export function validateWorkspaceSchedule(dep, a) {
413
423
  const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
414
424
  if (!inside(real(dep), real(spec.cwd))) throw scheduleError("E_SCHEDULE_INVALID", `cwd: ${JSON.stringify(d.cwd)} leaves the deployment; a workspace command's cwd is a directory inside it`, { field: "cwd" });
415
425
  }
416
- const out = validateDefinition(dep, spec, { checkAgent: false });
426
+ const out = validateDefinition(dep, spec, { checkAgent: false, backendSource: `${a.origin?.path ?? a.name} sets backend` });
417
427
  delete out.enabled;
418
428
  return out;
419
429
  }
@@ -756,11 +766,13 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
756
766
  const def = defs.jobs[id];
757
767
  const js = stateOf(id);
758
768
  let due, cronError;
759
- const shape = shapeError(def);
769
+ const invalidShape = shapeError(def), herdr = invalidShape ? null : storedHerdrBackend(ws, id, def);
770
+ const shape = invalidShape ?? herdr?.message;
760
771
  if (!shape) validateActiveExecution(js, jobLockInfo(ws, id)); // Also covers host pre-observed candidates.
761
772
  if (shape) { cronError = shape; due = false; }
762
773
  else { try { due = only ? true : dueAt(def, minute); } catch (e) { cronError = e.message; due = false; } }
763
- if (cronError) { if (!dryRun) js.lastRun = { ...(js.lastRun || {}), outcome: "invalid", error: `definition cannot be evaluated: ${cronError}` }; record(id, "invalid", { error: cronError }); continue; }
774
+ const errorCode = herdr ? { errorCode: herdr.code } : {};
775
+ if (cronError) { if (!dryRun) js.lastRun = { ...(js.lastRun || {}), outcome: "invalid", error: `definition cannot be evaluated: ${cronError}`, ...errorCode }; record(id, "invalid", { error: cronError, ...errorCode }); continue; }
764
776
  if (def.kind === "wake" && js.pendingWake && def.enabled && !dryRun && !only) {
765
777
  const dueNow = due && js.lastAttemptedMinute !== key;
766
778
  const held = !!jobLockInfo(ws, id);
@@ -1066,7 +1078,8 @@ export function describe(ws, qid, io, { defs, st, now = new Date(), ctx = null }
1066
1078
  const { history, recentRuns } = projectHistory(js);
1067
1079
  const { workspace: _w, ...stored } = def; void _w;
1068
1080
  const base = { ...stored, id, scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun ? { ...js.lastRun, runId: runIdOf(js.lastRun), session: sessionProvenanceOf(js.lastRun) } : null, history, recentRuns, running: !!lock, ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
1069
- return scheduleRow(ws, entry ?? localEntry("schedule", { ...def, id: name }, { dep: ws }), base, ctx);
1081
+ const herdr = entry ? null : storedHerdrBackend(ws, id, def);
1082
+ return scheduleRow(ws, entry ?? localEntry("schedule", { ...def, id: name }, { dep: ws, ...(herdr ? { invalid: { code: herdr.code, message: herdr.message, field: herdr.field } } : {}) }), base, ctx);
1070
1083
  }
1071
1084
  /** A schedule's list row: its stored facts, the fields every trigger and schedule row share
1072
1085
  * (lib/automations.mjs baseRow) and the schedule's own: `kind` is its run (spawn, command, wake,