@awebai/oats 0.22.12 → 0.22.16
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 +714 -25
- package/capabilities/oats-okf/bin/oats-okf.mjs +26 -2
- package/capabilities/oats-okf/oats.json +18 -3
- package/docs/capabilities.md +7 -0
- package/docs/capability-manifest.schema.json +32 -0
- package/docs/design/2026-09-07-architecture-reassessment.md +131 -0
- package/docs/design/2026-09-07-desktop-souls-capabilities.md +50 -0
- package/docs/design/operations-contract.md +123 -0
- package/docs/desktop.md +16 -0
- package/docs/execution-targets.md +21 -0
- package/docs/release-notes/v0.22.13.md +44 -0
- package/docs/release-notes/v0.22.14.md +48 -0
- package/docs/release-notes/v0.22.15.md +48 -0
- package/docs/release-notes/v0.22.16.md +53 -0
- package/docs/schedules.md +9 -0
- package/lib/attachments.mjs +164 -0
- package/lib/core.mjs +37 -2
- package/lib/schedule.mjs +32 -8
- package/lib/servers.mjs +35 -2
- package/lib/session-viewer.mjs +4 -0
- package/package-catalog.json +29 -8
- package/package.json +1 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# OATS v0.22.16
|
|
2
|
+
|
|
3
|
+
The same reviewed content as the unpublished v0.22.15 tag, whose hosted
|
|
4
|
+
run failed in frozen test data (retirement goldens still carrying the
|
|
5
|
+
removed `harvested` field, and a Desktop harvest fixture predating the
|
|
6
|
+
operations gate) before anything was published; only tests changed.
|
|
7
|
+
|
|
8
|
+
Souls and capabilities become something you inspect and manage, through
|
|
9
|
+
one kernel contract, and knowledge providers declare what they can do.
|
|
10
|
+
|
|
11
|
+
## Provider operations and inspection (kernel)
|
|
12
|
+
|
|
13
|
+
`oats inspect --json` answers everything a GUI needs in one envelope for a
|
|
14
|
+
scope, a selected soul or a running home: souls with runtime defaults,
|
|
15
|
+
editability and instructions; installed capabilities with health from the
|
|
16
|
+
package engine, separately from their effective activation, provenance and
|
|
17
|
+
settings; effective layer bindings; and the operations each provider
|
|
18
|
+
declares, with availability and the reason when unavailable. For a home the
|
|
19
|
+
answer is its captured bindings (with current manifests and trust), the live
|
|
20
|
+
config beside it, and the drift between them. Same-named souls in member
|
|
21
|
+
repositories are addressed by name plus agents root; a home selects its own
|
|
22
|
+
soul under its own root.
|
|
23
|
+
|
|
24
|
+
`oats operation run <layer>:<name>` resolves the provider that fills a layer
|
|
25
|
+
for a home or a soul, checks declaration, trust and host requirements, runs
|
|
26
|
+
the provider's own command in the right place with the right identity, and
|
|
27
|
+
relays its receipt; a view answers labeled documents. Unconfirmed outcomes
|
|
28
|
+
carry what was observed and a scheduled operation keeps its slot until
|
|
29
|
+
reconciled. Manifests declare `operations`. Schedules gain kind
|
|
30
|
+
`operation`. `oats use --json` answers receipts and `--inherit` returns a
|
|
31
|
+
level to inheritance; `oats soul set` edits an editable soul's defaults and
|
|
32
|
+
instructions in place. All four route to a registered server through the
|
|
33
|
+
saved route; the probe advertises `operations` and `operationsApi: 1`. The
|
|
34
|
+
retire receipt no longer implies a harvest. See
|
|
35
|
+
docs/design/operations-contract.md.
|
|
36
|
+
|
|
37
|
+
## oats.okf 1.6.0
|
|
38
|
+
|
|
39
|
+
The official knowledge provider declares `inspect` (a view of STATE.md,
|
|
40
|
+
log.md and pending notes) and `harvest` (the existing action); the catalog
|
|
41
|
+
pins v1.6.0. Existing scopes keep their locked 1.5.2 until their owners
|
|
42
|
+
update them; the GUI then shows no operations for those providers, which is
|
|
43
|
+
the truthful state.
|
|
44
|
+
|
|
45
|
+
## Desktop
|
|
46
|
+
|
|
47
|
+
Souls & capabilities replaces the launch-centric roster: select a soul to
|
|
48
|
+
inspect it, Launch and Schedule are explicit, defaults and instructions are
|
|
49
|
+
editable where the kernel says so, installed capabilities are shown apart
|
|
50
|
+
from activation with enable, disable and inherit at an exact scope, an
|
|
51
|
+
instance's menu opens Knowledge & capabilities, provider views render as
|
|
52
|
+
text, and schedules run declared provider operations. See
|
|
53
|
+
docs/design/2026-09-07-desktop-souls-capabilities.md.
|
package/docs/schedules.md
CHANGED
|
@@ -56,6 +56,15 @@ and no queue.
|
|
|
56
56
|
home is started again only at due minutes, never every minute, so a
|
|
57
57
|
harness that keeps exiting is not restarted in a loop. A job holds at most
|
|
58
58
|
one pending delivery: a due minute while one is pending adds nothing.
|
|
59
|
+
- **operation** `{id, enabled, cron, tz, kind: "operation", operation, home}`
|
|
60
|
+
— runs a provider operation such as `knowledge:harvest` in the instance at
|
|
61
|
+
`home` through `oats operation run <layer>:<name> --home <home>`. The
|
|
62
|
+
provider is whatever fills that layer for the home when the job runs (its
|
|
63
|
+
snapshot), not something stored in the job, so the job stays valid across
|
|
64
|
+
provider changes and a GUI can list and edit it without parsing argv.
|
|
65
|
+
Admission, tracking and reconciliation are those of a command job: a
|
|
66
|
+
launch receipt the provider answers (a harvester it spawned) is followed
|
|
67
|
+
until that home is gone; the source home is never treated as a launch.
|
|
59
68
|
Unobservable or still starting: skipped with the reason, delivery kept
|
|
60
69
|
pending. Whether a running harness is busy cannot be seen from the
|
|
61
70
|
terminal: delivery is terminal input (bracketed paste plus Enter), never an
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/** Instance attachments: bytes a viewer drops or pastes for an agent, kept as
|
|
2
|
+
* private files INSIDE the instance home so the agent can read them by path.
|
|
3
|
+
* Upload never touches the terminal; the caller pastes the returned path.
|
|
4
|
+
* Remote uploads stream through the same ssh transport as every routed
|
|
5
|
+
* command, into `oats session receive` on the execution host. */
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeSync, realpathSync } from "node:fs";
|
|
8
|
+
import { basename, extname, isAbsolute, join, resolve, sep } from "node:path";
|
|
9
|
+
import { checkRemote, resolveRoute, runRemote, serverError } from "./servers.mjs";
|
|
10
|
+
|
|
11
|
+
export const ATTACHMENTS_DIRNAME = ".oats-attachments";
|
|
12
|
+
/** Kernel bound per file; a GUI imposes its own stricter interaction bound. */
|
|
13
|
+
export const MAX_ATTACHMENT_BYTES = 64 * 1024 * 1024;
|
|
14
|
+
/** The kernel version whose probe first advertises session-upload. */
|
|
15
|
+
export const SESSION_UPLOAD_REMOTE_VERSION = "0.22.13";
|
|
16
|
+
|
|
17
|
+
const err = (code, message, extra) => Object.assign(new Error(message), { code, ...(extra || {}) });
|
|
18
|
+
|
|
19
|
+
/** A file name as it will exist in the attachments directory: one path
|
|
20
|
+
* segment, printable, never a dot name, at most 200 bytes. */
|
|
21
|
+
export function attachmentName(name) {
|
|
22
|
+
if (typeof name !== "string" || !name.trim()) throw err("E_BAD_ARGS", "attachment name must be a non-empty file name");
|
|
23
|
+
if (name.includes("/") || name.includes("\\") || name.includes("\0")) throw err("E_BAD_ARGS", "attachment name must be a single path segment without slashes or NUL");
|
|
24
|
+
if (/[\x00-\x1f\x7f]/.test(name)) throw err("E_BAD_ARGS", "attachment name must not contain control characters");
|
|
25
|
+
if (name === "." || name === "..") throw err("E_BAD_ARGS", "attachment name may not be a dot name");
|
|
26
|
+
// Deliberate: a name starting with a dash would read as an option on the
|
|
27
|
+
// remote command line; the caller renames the file (screenshots never
|
|
28
|
+
// start with one).
|
|
29
|
+
if (name.startsWith("-")) throw err("E_BAD_ARGS", `attachment name ${JSON.stringify(name)} starts with a dash; rename the file before attaching it`);
|
|
30
|
+
if (Buffer.byteLength(name) > 200) throw err("E_BAD_ARGS", "attachment name is longer than 200 bytes");
|
|
31
|
+
return name;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sha256Hex(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
|
|
35
|
+
|
|
36
|
+
/** Read a REGULAR file descriptor to its end, refusing once more than
|
|
37
|
+
* `maxBytes` arrive. Regular files never answer EAGAIN; pipes must use
|
|
38
|
+
* readStreamBounded, which waits for data instead of spinning. */
|
|
39
|
+
export function readBounded(fd, maxBytes = MAX_ATTACHMENT_BYTES) {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let total = 0;
|
|
42
|
+
const buf = Buffer.allocUnsafe(1024 * 1024);
|
|
43
|
+
for (;;) {
|
|
44
|
+
const n = readSync(fd, buf, 0, buf.length, null);
|
|
45
|
+
if (n === 0) break;
|
|
46
|
+
total += n;
|
|
47
|
+
if (total > maxBytes) throw err("E_UPLOAD_TOO_LARGE", `attachment exceeds the kernel bound of ${maxBytes} bytes`);
|
|
48
|
+
chunks.push(Buffer.from(buf.subarray(0, n)));
|
|
49
|
+
}
|
|
50
|
+
return Buffer.concat(chunks, total);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Collect a readable stream (stdin from ssh) into memory, bounded: the
|
|
54
|
+
* read is event-driven, so a slow or stalled sender costs no CPU, and the
|
|
55
|
+
* bound is checked as chunks arrive, before anything is written. The whole
|
|
56
|
+
* file is buffered on both sides (up to the bound); this is not end-to-end
|
|
57
|
+
* streaming. */
|
|
58
|
+
export async function readStreamBounded(stream, maxBytes = MAX_ATTACHMENT_BYTES) {
|
|
59
|
+
const chunks = [];
|
|
60
|
+
let total = 0;
|
|
61
|
+
for await (const chunk of stream) {
|
|
62
|
+
const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
63
|
+
total += b.length;
|
|
64
|
+
if (total > maxBytes) { stream.destroy?.(); throw err("E_UPLOAD_TOO_LARGE", `attachment exceeds the kernel bound of ${maxBytes} bytes`); }
|
|
65
|
+
chunks.push(b);
|
|
66
|
+
}
|
|
67
|
+
return Buffer.concat(chunks, total);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function instanceHomeOf(home) {
|
|
71
|
+
if (typeof home !== "string" || !isAbsolute(home)) throw err("E_BAD_ARGS", "attachments need an absolute instance home");
|
|
72
|
+
if (!existsSync(join(home, "instance.json"))) throw err("E_SESSION_UNKNOWN", `${home} is not an OATS instance home (no instance.json); nothing was written`);
|
|
73
|
+
return realpathSync(home);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The attachments directory must be a real directory inside the home: a
|
|
77
|
+
* symlink planted there (or a file) must not turn a receive into a write
|
|
78
|
+
* somewhere else. Created 0700 when absent. */
|
|
79
|
+
function attachmentsDir(realHome) {
|
|
80
|
+
const dir = join(realHome, ATTACHMENTS_DIRNAME);
|
|
81
|
+
let st;
|
|
82
|
+
try { st = lstatSync(dir); }
|
|
83
|
+
catch (e) {
|
|
84
|
+
if (e.code !== "ENOENT") throw e;
|
|
85
|
+
// Two first uploads may both see ENOENT: the loser's mkdir answers
|
|
86
|
+
// EEXIST and it validates what the winner (or a planted entry) put there.
|
|
87
|
+
try { mkdirSync(dir, { mode: 0o700 }); } catch (m) { if (m.code !== "EEXIST") throw m; }
|
|
88
|
+
st = lstatSync(dir);
|
|
89
|
+
}
|
|
90
|
+
if (st.isSymbolicLink() || !st.isDirectory()) throw err("E_UPLOAD_FAILED", `${dir} is not a real directory inside the instance home; nothing was written`);
|
|
91
|
+
const real = realpathSync(dir);
|
|
92
|
+
if (real !== dir && !real.startsWith(realHome + sep)) throw err("E_UPLOAD_FAILED", `${dir} resolves outside the instance home; nothing was written`);
|
|
93
|
+
return dir;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Store `bytes` as a private file under <home>/.oats-attachments/<name>,
|
|
97
|
+
* taking name-2, name-3 ... when the name is taken. The destination is
|
|
98
|
+
* allocated exclusively (O_CREAT|O_EXCL, 0600): two simultaneous uploads
|
|
99
|
+
* of the same name get two files and neither clobbers the other, and a
|
|
100
|
+
* symlink planted under a candidate name is skipped, never followed. The
|
|
101
|
+
* path is reported only after the bytes are written and synced. */
|
|
102
|
+
export function receiveAttachment(home, name, bytes, { maxBytes = MAX_ATTACHMENT_BYTES } = {}) {
|
|
103
|
+
const realHome = instanceHomeOf(home);
|
|
104
|
+
const safe = attachmentName(name);
|
|
105
|
+
if (!Buffer.isBuffer(bytes)) throw err("E_BAD_ARGS", "attachment bytes must be a Buffer");
|
|
106
|
+
if (bytes.length > maxBytes) throw err("E_UPLOAD_TOO_LARGE", `attachment exceeds the kernel bound of ${maxBytes} bytes`);
|
|
107
|
+
const dir = attachmentsDir(realHome);
|
|
108
|
+
const ext = extname(safe), stem = safe.slice(0, safe.length - ext.length);
|
|
109
|
+
for (let i = 1; i <= 10000; i++) {
|
|
110
|
+
const path = i === 1 ? join(dir, safe) : join(dir, `${stem}-${i}${ext}`);
|
|
111
|
+
let fd;
|
|
112
|
+
try { fd = openSync(path, "wx", 0o600); }
|
|
113
|
+
catch (e) { if (e.code === "EEXIST") continue; throw e; }
|
|
114
|
+
try {
|
|
115
|
+
let off = 0;
|
|
116
|
+
while (off < bytes.length) off += writeSync(fd, bytes, off, bytes.length - off);
|
|
117
|
+
fsyncSync(fd);
|
|
118
|
+
} catch (e) { closeSync(fd); rmSync(path, { force: true }); throw e; }
|
|
119
|
+
closeSync(fd);
|
|
120
|
+
return { path, bytes: bytes.length, sha256: sha256Hex(bytes) };
|
|
121
|
+
}
|
|
122
|
+
throw err("E_UPLOAD_FAILED", `no free name for ${safe} under ${dir}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readLocalFile(file, maxBytes) {
|
|
126
|
+
if (typeof file !== "string" || !file) throw err("E_BAD_ARGS", "--file needs a path");
|
|
127
|
+
const abs = resolve(file);
|
|
128
|
+
let st;
|
|
129
|
+
try { st = statSync(abs); } catch { throw err("E_BAD_ARGS", `no such file: ${abs}`); }
|
|
130
|
+
if (!st.isFile()) throw err("E_BAD_ARGS", `${abs} is not a regular file`);
|
|
131
|
+
if (st.size > maxBytes) throw err("E_UPLOAD_TOO_LARGE", `${abs} is ${st.size} bytes; the kernel bound is ${maxBytes}`);
|
|
132
|
+
const fd = openSync(abs, "r");
|
|
133
|
+
try { if (fstatSync(fd).size !== st.size) throw err("E_UPLOAD_FAILED", `${abs} changed while being read`); return { abs, bytes: readBounded(fd, maxBytes) }; }
|
|
134
|
+
finally { closeSync(fd); }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Upload a local file into an instance's attachments: locally by home, or
|
|
138
|
+
* on the execution host of a registered server through its saved route
|
|
139
|
+
* (the same resolution as session attach). The remote answer's sha256 and
|
|
140
|
+
* size must equal the local file's before the path is reported. */
|
|
141
|
+
export function uploadAttachment({ file, home, server, instance }, io = {}) {
|
|
142
|
+
const maxBytes = io.maxBytes || MAX_ATTACHMENT_BYTES;
|
|
143
|
+
const { abs, bytes } = readLocalFile(file, maxBytes);
|
|
144
|
+
const name = basename(abs);
|
|
145
|
+
const sha256 = sha256Hex(bytes);
|
|
146
|
+
if (!server) {
|
|
147
|
+
if (!home) throw err("E_BAD_ARGS", "session upload needs --home </absolute/instance> or --server <id> with --instance <name> or --home");
|
|
148
|
+
return { ...receiveAttachment(home, name, bytes, { maxBytes }), name, home: realpathSync(home), source: abs };
|
|
149
|
+
}
|
|
150
|
+
const route = resolveRoute(server, { instance, home }, "session upload");
|
|
151
|
+
const remote = checkRemote(route.target, io);
|
|
152
|
+
// Both lists must carry the token; a probe without the arrays is an old kernel.
|
|
153
|
+
const advertises = (list) => Array.isArray(list) && list.includes("session-upload");
|
|
154
|
+
if (!advertises(remote.remote) || !advertises(remote.features)) {
|
|
155
|
+
throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${route.target.sshHost} does not advertise session-upload (kernels from ${SESSION_UPLOAD_REMOTE_VERSION} do); upgrade it there; nothing was sent`);
|
|
156
|
+
}
|
|
157
|
+
const { envelope, stderr } = runRemote(route.target, ["session", "receive", "--home", route.home, "--name", name, "--json"], { ...io, input: bytes, timeoutMs: io.timeoutMs || 600000 });
|
|
158
|
+
if (!envelope.ok) throw serverError(envelope.error?.code || "E_UPLOAD_FAILED", `${server}: ${envelope.error?.message || "receive failed"}`);
|
|
159
|
+
const r = envelope.result || {};
|
|
160
|
+
if (r.sha256 !== sha256 || r.bytes !== bytes.length || typeof r.path !== "string" || !r.path.startsWith("/")) {
|
|
161
|
+
throw serverError("E_UPLOAD_FAILED", `${server} stored ${r.bytes ?? "?"} bytes with sha256 ${r.sha256 ?? "?"} at ${r.path || "?"}, but the local file is ${bytes.length} bytes with sha256 ${sha256}; the remote file is left for inspection`);
|
|
162
|
+
}
|
|
163
|
+
return { path: r.path, bytes: r.bytes, sha256: r.sha256, name, home: route.home, server, instance: instance || route.snapshot?.instance, source: abs, ...(stderr?.trim() ? { stderr: stderr.trim() } : {}) };
|
|
164
|
+
}
|
package/lib/core.mjs
CHANGED
|
@@ -987,9 +987,45 @@ function loadManifestAt(idir, origin) {
|
|
|
987
987
|
if (hookDeclaration(value)?.required && hook !== "spawn") throw new Error(`capability ${id} hook "${hook}" cannot be required — only the spawn hook is enforced (retire and soul-scaffold run outside a spawn transaction)`);
|
|
988
988
|
}
|
|
989
989
|
if (m.agents !== undefined && (!Array.isArray(m.agents) || m.agents.some((a) => typeof a !== "string"))) throw new Error(`capability ${id} "agents" must be an array of package-relative soul directories`);
|
|
990
|
+
validateManifestOperations(m, id);
|
|
990
991
|
return { ...m, _dir: idir, _origin: origin };
|
|
991
992
|
}
|
|
992
993
|
|
|
994
|
+
/** `operations`: what a capability offers a GUI or scheduler by name, each
|
|
995
|
+
* delegating to one of its own `commands`. kind "action" runs something;
|
|
996
|
+
* kind "view" answers { documents: [...] } for presentation (a knowledge
|
|
997
|
+
* provider's own view of an instance's working knowledge). context "home"
|
|
998
|
+
* runs in an instance home; "scope" runs in the config scope. */
|
|
999
|
+
const OPERATION_NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
1000
|
+
function validateManifestOperations(m, id) {
|
|
1001
|
+
if (m.operations === undefined) return;
|
|
1002
|
+
if (!m.operations || typeof m.operations !== "object" || Array.isArray(m.operations)) throw new Error(`capability ${id} "operations" must be an object map`);
|
|
1003
|
+
for (const [name, op] of Object.entries(m.operations)) {
|
|
1004
|
+
if (!OPERATION_NAME_RE.test(name)) throw new Error(`capability ${id} operation "${name}": name must match ${OPERATION_NAME_RE.source}`);
|
|
1005
|
+
if (!op || typeof op !== "object" || Array.isArray(op)) throw new Error(`capability ${id} operation "${name}" must be an object`);
|
|
1006
|
+
if (typeof op.command !== "string" || !Object.prototype.hasOwnProperty.call(m.commands || {}, op.command)) throw new Error(`capability ${id} operation "${name}": "command" must name one of the manifest's commands`);
|
|
1007
|
+
if (op.kind !== undefined && !["action", "view"].includes(op.kind)) throw new Error(`capability ${id} operation "${name}": "kind" must be action or view`);
|
|
1008
|
+
if (op.context !== undefined && !["home", "scope"].includes(op.context)) throw new Error(`capability ${id} operation "${name}": "context" must be home or scope`);
|
|
1009
|
+
if (op.description !== undefined && typeof op.description !== "string") throw new Error(`capability ${id} operation "${name}": "description" must be a string`);
|
|
1010
|
+
if (op.args !== undefined) {
|
|
1011
|
+
if (!Array.isArray(op.args)) throw new Error(`capability ${id} operation "${name}": "args" must be an array`);
|
|
1012
|
+
for (const a of op.args) {
|
|
1013
|
+
if (!a || typeof a !== "object" || typeof a.name !== "string" || !OPERATION_NAME_RE.test(a.name)) throw new Error(`capability ${id} operation "${name}": each arg needs a name matching ${OPERATION_NAME_RE.source}`);
|
|
1014
|
+
if (a.flag !== undefined && (typeof a.flag !== "string" || !/^--[a-z][a-z0-9-]*$/.test(a.flag))) throw new Error(`capability ${id} operation "${name}" arg "${a.name}": "flag" must look like --name`);
|
|
1015
|
+
if (a.required !== undefined && typeof a.required !== "boolean") throw new Error(`capability ${id} operation "${name}" arg "${a.name}": "required" must be a boolean`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
/** The normalized operations a manifest declares (empty when none). */
|
|
1021
|
+
export function manifestOperations(manifest) {
|
|
1022
|
+
return Object.entries(manifest?.operations || {}).map(([name, op]) => ({
|
|
1023
|
+
name, kind: op.kind || "action", command: op.command, context: op.context || "home",
|
|
1024
|
+
description: op.description || null,
|
|
1025
|
+
args: (op.args || []).map((a) => ({ name: a.name, flag: a.flag || `--${a.name}`, required: !!a.required, description: a.description || null })),
|
|
1026
|
+
}));
|
|
1027
|
+
}
|
|
1028
|
+
|
|
993
1029
|
/** Discover capability manifests. Later sources take precedence: outer scopes < inner scopes; installed < owned within one scope. Duplicates inside one source layer are errors. */
|
|
994
1030
|
export function capabilityManifests(startDir) {
|
|
995
1031
|
// Capability-id keyed — never answer for `constructor`/`toString`. The ids
|
|
@@ -7230,7 +7266,6 @@ export function retireInstance(root, name, o = {}) {
|
|
|
7230
7266
|
contextDir: meta.repo, workspaceDir: workspaceOf(root), rootDir: root, resolved, priorMeta: meta.capabilityMeta || {},
|
|
7231
7267
|
});
|
|
7232
7268
|
}
|
|
7233
|
-
const harvested = hookResults?.meta?.["oats.okf"]?.harvested || [];
|
|
7234
7269
|
|
|
7235
7270
|
// ORDINARY retirement must not delete a home whose cleanup did not finish.
|
|
7236
7271
|
// The failures were already collected above; until now they were read ONLY on
|
|
@@ -7439,7 +7474,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
7439
7474
|
}
|
|
7440
7475
|
|
|
7441
7476
|
|
|
7442
|
-
const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, worktreeRemoved: isWorktree, branchDeleted: !!(o.deleteBranch && meta.branch) || quarantineBranchDeleted, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined,
|
|
7477
|
+
const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, worktreeRemoved: isWorktree, branchDeleted: !!(o.deleteBranch && meta.branch) || quarantineBranchDeleted, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: hookResults?.warnings?.length ? hookResults.warnings : undefined };
|
|
7443
7478
|
if (self) {
|
|
7444
7479
|
// The caller is the instance: its process lives in the window we are about to
|
|
7445
7480
|
// kill. Detach the kill so this function can return and the caller can report
|
package/lib/schedule.mjs
CHANGED
|
@@ -40,11 +40,12 @@ export function scheduleError(code, message, extra) { return Object.assign(new E
|
|
|
40
40
|
* is never observed or executed, and is reported invalid on that job only. */
|
|
41
41
|
export function shapeError(def) {
|
|
42
42
|
if (!def || typeof def !== "object" || Array.isArray(def)) return "definition is not an object";
|
|
43
|
-
if (!["spawn", "command", "wake"].includes(def.kind)) return `kind ${JSON.stringify(def.kind)} is not spawn, command or
|
|
43
|
+
if (!["spawn", "command", "wake", "operation"].includes(def.kind)) return `kind ${JSON.stringify(def.kind)} is not spawn, command, wake or operation`;
|
|
44
44
|
if (typeof def.cron !== "string" || typeof def.tz !== "string") return "cron and tz must be strings";
|
|
45
45
|
if (def.kind === "spawn" && typeof def.agent !== "string") return "agent must be a string";
|
|
46
46
|
if (def.kind === "command" && (typeof def.cwd !== "string" || !Array.isArray(def.argv))) return "cwd and argv are required";
|
|
47
47
|
if (def.kind === "wake" && (typeof def.home !== "string" || typeof def.message !== "string")) return "home and message are required";
|
|
48
|
+
if (def.kind === "operation" && (typeof def.operation !== "string" || typeof def.home !== "string")) return "operation and home are required";
|
|
48
49
|
return null;
|
|
49
50
|
}
|
|
50
51
|
|
|
@@ -229,7 +230,14 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
|
229
230
|
if (typeof def.home !== "string" || !isAbsolute(def.home) || !inside(ws, def.home) || !existsSync(join(def.home, "instance.json"))) throw scheduleError("E_SCHEDULE_INVALID", "home: an existing instance home inside the scope", { field: "home" });
|
|
230
231
|
validateMessage(def.message, "message");
|
|
231
232
|
out.home = resolve(def.home); out.message = def.message;
|
|
232
|
-
} else
|
|
233
|
+
} else if (def.kind === "operation") {
|
|
234
|
+
// A provider operation in a running home, resolved when the job runs
|
|
235
|
+
// (oats operation run <layer>:<name> --home): the provider is whatever
|
|
236
|
+
// fills that layer for the home then; nothing about it is stored here.
|
|
237
|
+
if (typeof def.operation !== "string" || !/^(knowledge|messaging|tasks):[a-z][a-z0-9-]*$/.test(def.operation)) throw scheduleError("E_SCHEDULE_INVALID", "operation: <layer>:<name>, e.g. knowledge:harvest", { field: "operation" });
|
|
238
|
+
if (typeof def.home !== "string" || !isAbsolute(def.home) || !inside(ws, def.home) || !existsSync(join(def.home, "instance.json"))) throw scheduleError("E_SCHEDULE_INVALID", "home: an existing instance home inside the scope", { field: "home" });
|
|
239
|
+
out.operation = def.operation; out.home = resolve(def.home);
|
|
240
|
+
} else throw scheduleError("E_SCHEDULE_INVALID", "kind: spawn, command, wake or operation", { field: "kind" });
|
|
233
241
|
return out;
|
|
234
242
|
}
|
|
235
243
|
|
|
@@ -320,6 +328,13 @@ function launchSpawn(ws, def, minute, io) {
|
|
|
320
328
|
}
|
|
321
329
|
return run;
|
|
322
330
|
}
|
|
331
|
+
/** The command job an operation job becomes when it runs: the kernel's own
|
|
332
|
+
* operation runner in the home, which resolves the provider at that moment
|
|
333
|
+
* and reports a launch receipt (instance/home) only for what the provider
|
|
334
|
+
* launched, never for the source home. */
|
|
335
|
+
export function operationAsCommand(def) {
|
|
336
|
+
return { ...def, kind: "command", cwd: def.home, argv: ["oats", "operation", "run", def.operation, "--home", def.home] };
|
|
337
|
+
}
|
|
323
338
|
/** Parse a command's stdout as one JSON document (remote output can be
|
|
324
339
|
* multi-line); fall back to the last {...} block; null when nothing parses. */
|
|
325
340
|
export function parseEnvelopeText(text) {
|
|
@@ -355,9 +370,16 @@ function launchCommand(ws, def, io) {
|
|
|
355
370
|
if (instance && !home) { const found = findHomesInScope(ws, instance); if (found.length === 1) home = found[0]; }
|
|
356
371
|
if (envelope.ok && instance && !home) return { kind: "command", launched: true, instance, unconfirmed: true, error: `the envelope names instance ${instance} but no home for it is in this scope's roster; run oats schedule reconcile once it appears` };
|
|
357
372
|
// A valid ok:false answer that reports an incomplete rollback (a harvest
|
|
358
|
-
// spawn whose compensation could not stop or remove everything)
|
|
359
|
-
//
|
|
360
|
-
|
|
373
|
+
// spawn whose compensation could not stop or remove everything), or an
|
|
374
|
+
// operation runner's unconfirmed outcome (timeout, no valid receipt, a
|
|
375
|
+
// receipt contradicted by the exit status), is not a confirmed failure.
|
|
376
|
+
// Whatever name the provider managed to answer travels in error.details
|
|
377
|
+
// so reconcile can adopt it.
|
|
378
|
+
if (!envelope.ok && (UNCONFIRMED_ERROR_CODES.has(envelope.error?.code) || envelope.error?.details?.unconfirmed === true || reportsRetainedEffects(envelope.error?.message))) {
|
|
379
|
+
const partial = envelope.error?.details?.envelope?.result;
|
|
380
|
+
const named = instance || (partial && typeof partial.instance === "string" ? partial.instance : undefined);
|
|
381
|
+
return { kind: "command", launched: false, unconfirmed: true, ...(named ? { instance: named } : {}), error: `command outcome unconfirmed: ${envelope.error?.message}`, errorCode: envelope.error?.code };
|
|
382
|
+
}
|
|
361
383
|
return { kind: "command", launched: envelope.ok === true, ...(instance ? { instance } : {}), ...(home ? { home } : {}), ...(envelope.ok ? {} : { error: envelope.error?.message || "command failed", errorCode: envelope.error?.code }) };
|
|
362
384
|
}
|
|
363
385
|
/** One wake. `startIfStopped` false between due minutes (a harness that
|
|
@@ -367,6 +389,8 @@ function launchCommand(ws, def, io) {
|
|
|
367
389
|
* confirm (spawn compensation's "rollback INCOMPLETE", a quarantined or
|
|
368
390
|
* retained home): the launch's effects are unconfirmed, never a confirmed
|
|
369
391
|
* failure. Shared by caught spawn errors and ok:false command envelopes. */
|
|
392
|
+
/** Error codes whose meaning is "the effects are unconfirmed" by contract. */
|
|
393
|
+
const UNCONFIRMED_ERROR_CODES = new Set(["E_OPERATION_TIMEOUT", "E_OPERATION_RESULT"]);
|
|
370
394
|
export function reportsRetainedEffects(message) { return /INCOMPLETE|quarantin|retain|could not (?:be )?(?:verif|confirm)/i.test(String(message || "")); }
|
|
371
395
|
function performWake(def, io, { startIfStopped = true, canStart = true, reserve } = {}) {
|
|
372
396
|
const seen = observeHome(def.home, io);
|
|
@@ -498,7 +522,7 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
498
522
|
js.lastLaunchedAt = now.toISOString();
|
|
499
523
|
writeState(ws, st);
|
|
500
524
|
let run;
|
|
501
|
-
try { run = def.kind === "
|
|
525
|
+
try { run = def.kind === "spawn" ? launchSpawn(ws, def, minute, io) : launchCommand(ws, def.kind === "operation" ? operationAsCommand(def) : def, io); }
|
|
502
526
|
catch (e) {
|
|
503
527
|
// spawnInstance compensates its own failures, but its rollback can be
|
|
504
528
|
// INCOMPLETE (a pane it could not stop, a quarantined home it kept):
|
|
@@ -595,7 +619,7 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
|
|
|
595
619
|
const homes = findHomesInScope(ws, `${def.agent}-${purpose}`);
|
|
596
620
|
if (homes.length === 1) adopted = { instance: `${def.agent}-${purpose}`, home: homes[0] };
|
|
597
621
|
else if (homes.length > 1) ambiguous = `${homes.length} homes named ${def.agent}-${purpose}: ${homes.join(", ")}`;
|
|
598
|
-
} else if (def.kind === "command") {
|
|
622
|
+
} else if (def.kind === "command" || def.kind === "operation") {
|
|
599
623
|
const named = js.lastRun?.instance ? findHomesInScope(ws, js.lastRun.instance).map((home) => ({ instance: js.lastRun.instance, home })) : [];
|
|
600
624
|
if (named.length === 1) adopted = named[0];
|
|
601
625
|
else if (named.length > 1) ambiguous = `${named.length} homes named ${js.lastRun.instance}: ${named.map((c) => c.home).join(", ")}`;
|
|
@@ -651,7 +675,7 @@ export function updateSchedule(ws, id, spec, io) {
|
|
|
651
675
|
return withHostLock(() => withScopeLock(ws, () => {
|
|
652
676
|
const defs = readDefinitions(ws);
|
|
653
677
|
if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
654
|
-
const identity = (d) => JSON.stringify([d.kind, d.agent, d.agentsRoot, d.repo, d.purpose, d.home, d.cwd, d.argv]);
|
|
678
|
+
const identity = (d) => JSON.stringify([d.kind, d.agent, d.agentsRoot, d.repo, d.purpose, d.home, d.cwd, d.argv, d.operation]);
|
|
655
679
|
const busy = !!jobLockInfo(ws, id) || !!readState(ws).jobs[id]?.attempt;
|
|
656
680
|
if (busy && identity(defs.jobs[id]) !== identity(def)) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is running or has an unresolved attempt; its kind, agent, agentsRoot, repo, purpose, home, cwd and argv cannot change until it ends (cron, tz, task, message, runtime, model and enabled can)`);
|
|
657
681
|
defs.jobs[id] = { ...def, createdAt: defs.jobs[id].createdAt, updatedAt: new Date().toISOString() };
|
package/lib/servers.mjs
CHANGED
|
@@ -185,7 +185,9 @@ export function runRemote(target, oatsArgs, io = {}) {
|
|
|
185
185
|
let stderr = "";
|
|
186
186
|
let status = 0;
|
|
187
187
|
try {
|
|
188
|
-
|
|
188
|
+
// `io.input` (a Buffer) streams to the remote command's stdin: the only
|
|
189
|
+
// way bytes reach a host, as a quoted argument never could.
|
|
190
|
+
stdout = exec(bin, argv, { encoding: "utf8", stdio: [io.input === undefined ? "ignore" : "pipe", "pipe", "pipe"], ...(io.input === undefined ? {} : { input: io.input }), maxBuffer: 16 * 1024 * 1024, timeout: io.timeoutMs || 300000 });
|
|
189
191
|
} catch (e) {
|
|
190
192
|
stdout = String(e.stdout || "");
|
|
191
193
|
stderr = String(e.stderr || e.message || "");
|
|
@@ -230,6 +232,7 @@ export function checkRemote(target, io = {}) {
|
|
|
230
232
|
launchOptions: list("launchOptions", []),
|
|
231
233
|
remote: list("remote", []),
|
|
232
234
|
features: list("features", []),
|
|
235
|
+
operationsApi: probe.operationsApi === 1 ? 1 : null,
|
|
233
236
|
advertised: Array.isArray(probe.runtimes),
|
|
234
237
|
};
|
|
235
238
|
}
|
|
@@ -477,7 +480,34 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
|
|
|
477
480
|
const { envelope, stderr } = runRemote(target, json(withScope(["status", ...oatsArgs])), io);
|
|
478
481
|
return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, target, snapshots: listSnapshots(serverId) } } : envelope, stderr };
|
|
479
482
|
}
|
|
480
|
-
|
|
483
|
+
if (OPERATIONS_COMMANDS.has(cmd)) {
|
|
484
|
+
// The operations contract (inspect, operation run, use, soul set): the
|
|
485
|
+
// remote must advertise it before anything is sent. An explicit --dir is
|
|
486
|
+
// the exact member context the caller chose and travels as is; without
|
|
487
|
+
// one, a --home selection is left to the host (the home is its own
|
|
488
|
+
// context) and anything else runs in the registered workspace.
|
|
489
|
+
// An exact remote home (or a saved instance name) resolves through its
|
|
490
|
+
// FROZEN route exactly as session attach does: the snapshot's target,
|
|
491
|
+
// and a home/name disagreement is refused; the registration's target is
|
|
492
|
+
// used only for scope requests, which get its workspace as --dir.
|
|
493
|
+
let args = [...oatsArgs];
|
|
494
|
+
const valueOf = (name) => { const i = args.indexOf(name); return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : undefined; };
|
|
495
|
+
let route;
|
|
496
|
+
if (valueOf("--home") !== undefined || valueOf("--instance") !== undefined) {
|
|
497
|
+
route = resolveRoute(serverId, { instance: valueOf("--instance"), home: valueOf("--home") }, cmd);
|
|
498
|
+
target = route.target;
|
|
499
|
+
const ii = args.indexOf("--instance");
|
|
500
|
+
if (ii >= 0) { args.splice(ii, 2); if (valueOf("--home") === undefined) args.push("--home", route.home); }
|
|
501
|
+
}
|
|
502
|
+
const remote = checkRemote(target, io);
|
|
503
|
+
if (!Array.isArray(remote.features) || !remote.features.includes("operations") || remote.operationsApi !== 1) {
|
|
504
|
+
throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise the operations contract (kernels from ${OPERATIONS_REMOTE_VERSION} do); upgrade it there; nothing was sent`);
|
|
505
|
+
}
|
|
506
|
+
const scoped = args.includes("--dir") || args.includes("--home") ? args : [...args, "--dir", target.workspace];
|
|
507
|
+
const { envelope, stderr } = runRemote(target, json([cmd, ...scoped]), io);
|
|
508
|
+
return { envelope: envelope.result && typeof envelope.result === "object" ? { ...envelope, result: { ...envelope.result, server: serverId, ...(route ? { route: { home: route.home, instance: route.snapshot?.instance || null, frozen: !!route.snapshot } } : {}) } } : envelope, stderr };
|
|
509
|
+
}
|
|
510
|
+
throw serverError("E_USAGE", `--server routes spawn, retire, status, session, okf harvest, schedule, inspect, operation, use and soul only (not ${cmd})`);
|
|
481
511
|
}
|
|
482
512
|
|
|
483
513
|
// ------------------------------------------------------------------- roster
|
|
@@ -619,6 +649,9 @@ export function inspectRemote(serverId, { instance, home } = {}, io = {}) {
|
|
|
619
649
|
/** The kernel version whose probe first advertises the `session-start`
|
|
620
650
|
* feature; the probe's features list is the actual check. */
|
|
621
651
|
export const SESSION_START_REMOTE_VERSION = "0.22.9";
|
|
652
|
+
/** The kernel version whose probe first advertises the operations contract. */
|
|
653
|
+
export const OPERATIONS_REMOTE_VERSION = "0.22.16";
|
|
654
|
+
const OPERATIONS_COMMANDS = new Set(["inspect", "operation", "use", "soul"]);
|
|
622
655
|
|
|
623
656
|
/** `session start` on the execution host for a remote instance: the same
|
|
624
657
|
* route resolution as inspect, refused before any mutation when the remote
|
package/lib/session-viewer.mjs
CHANGED
|
@@ -26,6 +26,10 @@ export function prepareSessionViewer(target, { exec = execFileSync } = {}) {
|
|
|
26
26
|
// when this agent retires. Disable window navigation in the viewer only.
|
|
27
27
|
for (const name of ["prefix", "prefix2"]) run(["set-option", "-t", viewer, name, "None"]);
|
|
28
28
|
run(["set-option", "-t", viewer, "key-table", "oatsview-locked"]);
|
|
29
|
+
// The viewer shows one agent; its status line only repeats that name.
|
|
30
|
+
// Session-scoped on the temporary viewer: the agents' own session and
|
|
31
|
+
// the operator's tmux settings are untouched.
|
|
32
|
+
run(["set-option", "-t", viewer, "status", "off"]);
|
|
29
33
|
run(["unbind-key", "-a", "-q", "-T", "oatsview-locked"]);
|
|
30
34
|
run(["bind-key", "-T", "oatsview-locked", "WheelUpPane", "if-shell", "-F", "#{||:#{pane_in_mode},#{mouse_any_flag}}", "send-keys -M", "copy-mode -e; send-keys -M"]);
|
|
31
35
|
run(["set-option", "-t", viewer, "mouse", "on"]);
|
package/package-catalog.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"packages": {
|
|
3
3
|
"oats.okf": {
|
|
4
4
|
"url": "https://github.com/awebai/oats-okf.git",
|
|
5
|
-
"ref": "v1.
|
|
5
|
+
"ref": "v1.6.0",
|
|
6
6
|
"path": "oats-package"
|
|
7
7
|
},
|
|
8
8
|
"oats.aweb": {
|
|
@@ -33,12 +33,33 @@
|
|
|
33
33
|
},
|
|
34
34
|
"capabilities": {
|
|
35
35
|
"oats.review": "oats.dev",
|
|
36
|
-
"oas.okf": {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"oas.
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
"oas.okf": {
|
|
37
|
+
"package": "oats.okf",
|
|
38
|
+
"capability": "oats.okf"
|
|
39
|
+
},
|
|
40
|
+
"oas.aweb": {
|
|
41
|
+
"package": "oats.aweb",
|
|
42
|
+
"capability": "oats.aweb"
|
|
43
|
+
},
|
|
44
|
+
"oas.jira": {
|
|
45
|
+
"package": "oats.jira",
|
|
46
|
+
"capability": "oats.jira"
|
|
47
|
+
},
|
|
48
|
+
"oas.linear": {
|
|
49
|
+
"package": "oats.linear",
|
|
50
|
+
"capability": "oats.linear"
|
|
51
|
+
},
|
|
52
|
+
"oas.authoring": {
|
|
53
|
+
"package": "oats.authoring",
|
|
54
|
+
"capability": "oats.authoring"
|
|
55
|
+
},
|
|
56
|
+
"oas.dev": {
|
|
57
|
+
"package": "oats.dev",
|
|
58
|
+
"capability": "oats.dev"
|
|
59
|
+
},
|
|
60
|
+
"oas.review": {
|
|
61
|
+
"package": "oats.dev",
|
|
62
|
+
"capability": "oats.review"
|
|
63
|
+
}
|
|
43
64
|
}
|
|
44
65
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.16",
|
|
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",
|