@awebai/oats 0.40.2 → 0.41.1
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 +39 -12
- package/docs/capabilities.md +9 -1
- package/docs/desktop-cli-api.md +58 -16
- package/docs/desktop.md +5 -4
- package/docs/execution-targets.md +312 -9
- package/docs/implementation.md +37 -6
- package/docs/release-notes/v0.41.0.md +238 -0
- package/docs/release-notes/v0.41.1.md +7 -0
- package/docs/servers.md +4 -1
- package/docs/souls-and-instances.md +32 -6
- package/lib/core.mjs +552 -165
- package/lib/instance-git.mjs +113 -4
- package/lib/instance-lifecycle.mjs +3 -2
- package/lib/packages.mjs +1 -1
- package/lib/resolve.mjs +1 -1
- package/lib/servers.mjs +16 -8
- package/package.json +1 -1
package/lib/instance-git.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* repository's default branch) with "no upstream" ≠ 0/0. Diffs are bounded and
|
|
7
7
|
* addressed by an opaque file id minted with an observation revision; a diff
|
|
8
8
|
* against a tree that has since moved is refused, never served. */
|
|
9
|
-
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
@@ -44,6 +44,78 @@ function git(cwd, argv, { allowFail = false, input, diffExit = false } = {}) {
|
|
|
44
44
|
}
|
|
45
45
|
const trim = (s) => (s === null ? null : s.trim());
|
|
46
46
|
|
|
47
|
+
const BRANCH_REFS = Buffer.from("refs/heads/");
|
|
48
|
+
/** The two reads of HEAD, as bytes. They run like the module's other reads:
|
|
49
|
+
* read-only, helper-free, and with the module's own environment (gitEnv),
|
|
50
|
+
* which keeps only the caller's PATH and HOME: GIT_DIR, GIT_WORK_TREE,
|
|
51
|
+
* GIT_COMMON_DIR, GIT_INDEX_FILE and every other variable of the caller are
|
|
52
|
+
* not passed, and no global or system configuration is read, so nothing but
|
|
53
|
+
* the work tree named by `-C` decides what is read. `-C` comes first. */
|
|
54
|
+
const readHead = (work, args) => spawnSync("git", ["-C", work, ...READ_ONLY_GIT, ...args], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, shell: false, timeout: 30_000, env: gitEnv() });
|
|
55
|
+
/** What a failed read of HEAD says, without a line of the output that was not an error. */
|
|
56
|
+
const headReadFailure = (r, command) => String(r.error?.message ?? r.stderr ?? "").trim() || `git ${command} ${r.signal ? `was ended by ${r.signal}` : `exited with ${r.status}`}`;
|
|
57
|
+
|
|
58
|
+
/** What a work tree's HEAD names → { branch, detached, ref }. The one reader
|
|
59
|
+
* of that name: the retire plan and the retire both use it.
|
|
60
|
+
*
|
|
61
|
+
* `ref` is the ref HEAD points at, as the bytes `git symbolic-ref --quiet HEAD`
|
|
62
|
+
* prints, without the one line feed that ends them; null when HEAD is
|
|
63
|
+
* detached. It is evidence for a comparison and is never serialized. Through a
|
|
64
|
+
* chain of symbolic refs it is the last one: the branch the commits go to.
|
|
65
|
+
*
|
|
66
|
+
* `branch` is the name OATS carries for it: the part after `refs/heads/`, when
|
|
67
|
+
* that part is valid UTF-8 (it decodes and encodes back to the same bytes).
|
|
68
|
+
* Nothing of it is trimmed or replaced: a name may begin or end with a
|
|
69
|
+
* character that reads as white space. It is null when HEAD is detached, and
|
|
70
|
+
* also for a ref OATS carries no name for: one whose name is not valid UTF-8,
|
|
71
|
+
* or one outside `refs/heads/`. So `branch: null, detached: false` means "a
|
|
72
|
+
* name OATS does not carry", and two such HEADs are never taken for the same
|
|
73
|
+
* one by their missing names: compare `ref`.
|
|
74
|
+
*
|
|
75
|
+
* A read that fails throws E_WORK_INSPECTION_FAILED. Only Git's own quiet
|
|
76
|
+
* answer (exit 1, nothing printed) is "detached". */
|
|
77
|
+
export function headName(work) {
|
|
78
|
+
const r = readHead(work, ["symbolic-ref", "--quiet", "HEAD"]);
|
|
79
|
+
if (!r.error && r.status === 0 && r.stdout.length > 1) {
|
|
80
|
+
const ref = Buffer.from(r.stdout[r.stdout.length - 1] === 0x0a ? r.stdout.subarray(0, -1) : r.stdout);
|
|
81
|
+
let branch = null;
|
|
82
|
+
if (ref.length > BRANCH_REFS.length && ref.subarray(0, BRANCH_REFS.length).equals(BRANCH_REFS)) {
|
|
83
|
+
const name = ref.subarray(BRANCH_REFS.length);
|
|
84
|
+
try {
|
|
85
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(name);
|
|
86
|
+
if (Buffer.from(text, "utf8").equals(name)) branch = text;
|
|
87
|
+
} catch { /* not valid UTF-8: a name OATS does not carry */ }
|
|
88
|
+
}
|
|
89
|
+
return { branch, detached: false, ref };
|
|
90
|
+
}
|
|
91
|
+
if (!r.error && r.status === 1 && !r.stdout.length && !r.stderr.length) return { branch: null, detached: true, ref: null };
|
|
92
|
+
throw oatsError("E_WORK_INSPECTION_FAILED", `could not read what the worktree at ${work} has checked out: ${headReadFailure(r, "symbolic-ref")}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A worktree's HEAD for a retire → { commit, branch, detached, ref }: what
|
|
96
|
+
* headName gives, and the commit HEAD is at.
|
|
97
|
+
*
|
|
98
|
+
* A HEAD that has no commit (a branch that is not born yet) is a failed read
|
|
99
|
+
* here: the retire proves its copy against this commit, and removes a
|
|
100
|
+
* worktree only after it has asked which refs reach it, so there is nothing
|
|
101
|
+
* it could do without one. That differs from the plan on purpose: the plan
|
|
102
|
+
* (observeInstanceGit) reads the commit for itself, and an unborn work tree is
|
|
103
|
+
* a value on its wire (`revision: "unborn"`).
|
|
104
|
+
*
|
|
105
|
+
* The name and the commit are two reads, and nothing binds them but what is
|
|
106
|
+
* done with them: a copy is proven against `commit`, so a name read at
|
|
107
|
+
* another moment than the commit gives a copy that fails its proof. Nothing
|
|
108
|
+
* destructive rests on the name. */
|
|
109
|
+
export function worktreeHead(work) {
|
|
110
|
+
const name = headName(work);
|
|
111
|
+
const r = readHead(work, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
|
112
|
+
const commit = !r.error && r.status === 0 ? r.stdout.toString("latin1").replace(/\n$/, "") : "";
|
|
113
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(commit)) {
|
|
114
|
+
throw oatsError("E_WORK_INSPECTION_FAILED", `the HEAD of the worktree at ${work} has no commit that could be read${r.error || r.stderr?.length ? `: ${headReadFailure(r, "rev-parse")}` : ""}`);
|
|
115
|
+
}
|
|
116
|
+
return { commit, ...name };
|
|
117
|
+
}
|
|
118
|
+
|
|
47
119
|
/** The instance's work tree, from its home. The home's instance.json names
|
|
48
120
|
* the work mode; the tree is `<home>/work` (a directory or a symlink to the
|
|
49
121
|
* shared checkout). Missing/retired → attributed refusal, never a crash. */
|
|
@@ -169,12 +241,49 @@ function fileId(revision, indexOid, entry) {
|
|
|
169
241
|
return createHash("sha256").update(`${revision}\0${indexOid}\0${entry.kind}\0${entry.path}\0${entry.origPath ?? ""}`).digest("hex").slice(0, 24);
|
|
170
242
|
}
|
|
171
243
|
|
|
244
|
+
/** The HEAD of a worktree a retire is about to remove, and whether a ref of
|
|
245
|
+
* its repository reaches that commit → { head, unreached }. A ref outlives
|
|
246
|
+
* the worktree; HEAD, and refs only the worktree has, do not.
|
|
247
|
+
*
|
|
248
|
+
* One read in the repository that carries no ref name, so a ref whose name is
|
|
249
|
+
* not valid UTF-8, or very many refs, cannot make it refuse:
|
|
250
|
+
* `git for-each-ref --contains <commit> --count=1 --format=%(objectname)`.
|
|
251
|
+
* Exit 0 with a line printed: some ref reaches the commit. Exit 0 with
|
|
252
|
+
* nothing printed: none does, and the commit is something to preserve. Any
|
|
253
|
+
* other exit throws E_WORK_INSPECTION_FAILED. Standard error is not a failure
|
|
254
|
+
* here: a damaged ref prints a warning there with exit 0, and reaches
|
|
255
|
+
* nothing. A commit that only another worktree's HEAD reaches counts as not
|
|
256
|
+
* reached, which can only add a copy. */
|
|
257
|
+
export function worktreeCommitUnreached(repo, work) {
|
|
258
|
+
const head = worktreeHead(work);
|
|
259
|
+
const r = spawnSync("git", ["-C", repo, ...READ_ONLY_GIT, "for-each-ref", "--contains", head.commit, "--count=1", "--format=%(objectname)"], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, shell: false, timeout: 30_000, env: gitEnv() });
|
|
260
|
+
if (r.error || r.status !== 0) throw oatsError("E_WORK_INSPECTION_FAILED", `could not read which refs of ${repo} reach the commit the worktree at ${work} has checked out: ${headReadFailure(r, "for-each-ref")}`);
|
|
261
|
+
return { head, unreached: r.stdout.length === 0 };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The check made immediately before a retire removes a worktree: HEAD as it
|
|
265
|
+
* is now (`now`, worktreeHead) against HEAD as the final inspection read it
|
|
266
|
+
* (`inspected`). Equal when the commits are equal and both are detached or
|
|
267
|
+
* both point at a ref with the same bytes. The branch names are not compared:
|
|
268
|
+
* for a ref OATS carries no name for they are null on both sides, and null
|
|
269
|
+
* equal to null proves nothing. Anything else throws
|
|
270
|
+
* E_WORK_PRESERVATION_FAILED, and the worktree is not removed. */
|
|
271
|
+
export function assertSameWorktreeHead(inspected, now) {
|
|
272
|
+
const same = !!inspected && !!now && inspected.commit === now.commit
|
|
273
|
+
&& (inspected.ref === null || now.ref === null ? inspected.ref === null && now.ref === null && inspected.detached === true && now.detached === true : Buffer.compare(inspected.ref, now.ref) === 0);
|
|
274
|
+
if (!same) throw oatsError("E_WORK_PRESERVATION_FAILED", "the worktree's HEAD changed after it was inspected, so the worktree was not removed. The home and the worktree are kept, and so is any recovery the retire wrote; retry the retire.");
|
|
275
|
+
}
|
|
276
|
+
|
|
172
277
|
/** One consistent observation of the tree. Every field is what git said. */
|
|
173
278
|
export function observeInstanceGit(home) {
|
|
174
279
|
const { meta, work, mode } = instanceWorkTree(home);
|
|
175
280
|
const headOid = trim(git(work, ["rev-parse", "--verify", "--quiet", "HEAD"], { allowFail: true }));
|
|
176
281
|
const raw = git(work, ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all", "--ignore-submodules=none"]);
|
|
177
282
|
const { branch, entries } = parsePorcelainV2(raw);
|
|
283
|
+
// The name comes from headName, not from the status header, which prints
|
|
284
|
+
// "(detached)" and other words where a name would be and is read as text.
|
|
285
|
+
// The header still gives the upstream and its counts.
|
|
286
|
+
const head = headName(work);
|
|
178
287
|
// The index state participates in the revision so that a stage/unstage
|
|
179
288
|
// between observation and diff is a moved tree, not a stale-but-served diff.
|
|
180
289
|
// Hashed from the index listing: no `write-tree`, so observing creates no object.
|
|
@@ -198,10 +307,10 @@ export function observeInstanceGit(home) {
|
|
|
198
307
|
return {
|
|
199
308
|
instanceGitApi: INSTANCE_GIT_API,
|
|
200
309
|
instance: meta.instance ?? null, agent: meta.agent ?? null, home, workMode: mode,
|
|
201
|
-
observation: { revision, indexRevision: indexOid, at, worktree: work, branch: branch
|
|
202
|
-
recorded: { branch: meta.branch ?? null, repo: meta.repo ?? null, drift: meta.branch !== undefined && meta.branch !== null && branch
|
|
310
|
+
observation: { revision, indexRevision: indexOid, at, worktree: work, branch: head.branch, detached: head.detached && headOid !== null, unborn: headOid === null },
|
|
311
|
+
recorded: { branch: meta.branch ?? null, repo: meta.repo ?? null, drift: meta.branch !== undefined && meta.branch !== null && head.branch !== meta.branch },
|
|
203
312
|
upstream, base: baseComparison,
|
|
204
|
-
remote: remoteOf(work, branch
|
|
313
|
+
remote: remoteOf(work, head.branch),
|
|
205
314
|
summary, files,
|
|
206
315
|
notes: [
|
|
207
316
|
...(upstream.ref === null ? ["no upstream configured: upstream ahead/behind are unknown, not zero"] : []),
|
|
@@ -167,7 +167,8 @@ function writeFileSyncAtomic(path, value) {
|
|
|
167
167
|
/** Facts for Remove (retire): what retirement would touch, with the design's
|
|
168
168
|
* defaults (retain worktree and branch; never touch a PR). `oats retire`
|
|
169
169
|
* itself applies them: plain retire re-homes the worktree; --discard-worktree
|
|
170
|
-
*
|
|
170
|
+
* is the dialog's opt-in. No retire deletes a branch: `deleteBranch` is
|
|
171
|
+
* always false. */
|
|
171
172
|
export function planRetire(ctx, root, name, { home } = {}) {
|
|
172
173
|
const me = resolveInstance(ctx, root, name, { home });
|
|
173
174
|
const kids = descendantsOf(me.root, name);
|
|
@@ -183,7 +184,7 @@ export function planRetire(ctx, root, name, { home } = {}) {
|
|
|
183
184
|
appendEvent(me.home, { kind: "retire-planned", data: { planRevision: planRevision(safety), children: kids.length, dirty: facts.work.observed ? facts.work.changed + facts.work.untracked : null } }, { workspaceOnly: true });
|
|
184
185
|
return { lifecycleApi: LIFECYCLE_API, action: "retire", instance: name, home: me.home, at: new Date().toISOString(), facts, defaults, planRevision: planRevision(safety),
|
|
185
186
|
notes: [
|
|
186
|
-
...(facts.work.observed && facts.work.drift ? [`the worktree is on ${facts.work.branch}, not the recorded ${facts.recordedBranch}
|
|
187
|
+
...(facts.work.observed && facts.work.drift ? [`the worktree is on ${facts.work.branch ?? (facts.work.detached ? "a detached HEAD" : "a ref OATS carries no branch name for")}, not the recorded ${facts.recordedBranch}`] : []),
|
|
187
188
|
...(facts.work.observed && facts.work.changed + facts.work.untracked > 0 ? [`${facts.work.changed} changed and ${facts.work.untracked} untracked file(s) would be retained with the worktree`] : []),
|
|
188
189
|
...(kids.length ? [`${kids.length} recorded child instance(s) are stopped first (bounded SIGTERM, never escalated) and retained (their homes are not removed); a child still running after the grace refuses the retirement`] : []),
|
|
189
190
|
...((kids.ambiguous || []).length ? [`${kids.ambiguous.length} instance(s) record this name as parent but the name is not unique under this root; they are listed under ambiguous and NOT acted on`] : []),
|
package/lib/packages.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* OATS packages — versions, lock v3 (workspace model v2).
|
|
3
3
|
*
|
|
4
4
|
* Contract: docs/design/2026-09-23-workspace-module-contracts.md §4.
|
|
5
|
-
* Decision: https://github.com/awebai/oats-knowledge/blob/main/knowledge/nodes/oats-
|
|
5
|
+
* Decision: https://github.com/awebai/oats-knowledge/blob/main/knowledge/nodes/oats-maintainer/decisions/workspace-model-v2.md.
|
|
6
6
|
*
|
|
7
7
|
* A package is a place to fetch from WITH a version attached. Nothing is
|
|
8
8
|
* installed: `resolvePackages` turns each `workspace.packages` entry into an
|
package/lib/resolve.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* lib/resolve.mjs — from a soul to an immutable resolution (module contract §3).
|
|
3
3
|
*
|
|
4
4
|
* Contract: docs/design/2026-09-23-workspace-module-contracts.md §3.
|
|
5
|
-
* Decision: https://github.com/awebai/oats-knowledge/blob/main/knowledge/nodes/oats-
|
|
5
|
+
* Decision: https://github.com/awebai/oats-knowledge/blob/main/knowledge/nodes/oats-maintainer/decisions/workspace-model-v2.md (6, 12, 14, 16, 19–21).
|
|
6
6
|
*
|
|
7
7
|
* `resolveSoul(discovery, soulEntry, options)` turns a discovered soul into the
|
|
8
8
|
* exact set of modules an instance will be built from: which capability comes
|
package/lib/servers.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { parseStrictJson } from "./canonical-json.mjs";
|
|
|
25
25
|
import { noteRuntimeName } from "./deprecation.mjs";
|
|
26
26
|
import { readWaitingClaim } from "./instance-events.mjs";
|
|
27
27
|
import { withDirLock } from "./dir-lock.mjs";
|
|
28
|
+
import { shellWord } from "./core.mjs";
|
|
28
29
|
|
|
29
30
|
const OATS_HOME_DIR = () => process.env.OATS_HOME_DIR || join(homedir(), ".oats");
|
|
30
31
|
export const SERVERS_FILE = () => join(OATS_HOME_DIR(), "servers.json");
|
|
@@ -917,22 +918,29 @@ export function instanceOnHost(serverId, name, io = {}) {
|
|
|
917
918
|
function requireSessionRemote(target, io, home) {
|
|
918
919
|
const remote = checkRemote(target, io);
|
|
919
920
|
if (!remote.remote.includes("session")) {
|
|
920
|
-
throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise the \`oats session\` commands (kernels from ${SESSION_REMOTE_VERSION} do); upgrade it there, or attach with
|
|
921
|
+
throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise the \`oats session\` commands (kernels from ${SESSION_REMOTE_VERSION} do); upgrade it there, or attach with ${oldKernelAttachHint(target, io, home)}`);
|
|
921
922
|
}
|
|
922
923
|
return remote;
|
|
923
924
|
}
|
|
924
925
|
|
|
925
|
-
/**
|
|
926
|
-
*
|
|
927
|
-
*
|
|
926
|
+
/** How to attach by hand to an instance on a kernel without `oats session`: the tmux session and
|
|
927
|
+
* window its roster records for that home, else its default session (pi-agents, the default of every
|
|
928
|
+
* kernel before 0.31). When the roster row records the server's socket the hint names it, and the
|
|
929
|
+
* remote command is ONE quoted word (ssh joins its words for the remote shell, which would split a
|
|
930
|
+
* path with a space); a row without a socket is a kernel that only ever used its default server. */
|
|
928
931
|
const OLD_KERNEL_TMUX_SESSION = "pi-agents";
|
|
929
|
-
function
|
|
932
|
+
function oldKernelAttachHint(target, io, home) {
|
|
933
|
+
let row;
|
|
930
934
|
try {
|
|
931
935
|
const status = runRemote(target, ["status", "--json", "--dir", target.workspace], { ...io, input: undefined }).envelope;
|
|
932
|
-
|
|
933
|
-
if (row?.tmux?.session) return row.tmux.window ? `${row.tmux.session}:${row.tmux.window}` : row.tmux.session;
|
|
936
|
+
row = (status.ok ? status.result?.agents || [] : []).flatMap((a) => a.instances || []).find((i) => i.home && home && resolve(i.home) === resolve(home));
|
|
934
937
|
} catch { /* the hint falls back to the default session */ }
|
|
935
|
-
|
|
938
|
+
const tmux = row?.tmux?.session ? row.tmux : null;
|
|
939
|
+
const recorded = tmux ? (tmux.window ? `${tmux.session}:${tmux.window}` : tmux.session) : OLD_KERNEL_TMUX_SESSION;
|
|
940
|
+
// The remote command is ONE word for the local shell, with or without a socket: ssh joins its
|
|
941
|
+
// arguments and the remote shell reads the result again.
|
|
942
|
+
const server = typeof tmux?.socket === "string" && tmux.socket ? `-S ${shellWord(tmux.socket)} ` : "";
|
|
943
|
+
return `ssh -t ${target.sshHost} ${shellWord(`tmux ${server}attach -t ${shellWord(recorded)}`)}`;
|
|
936
944
|
}
|
|
937
945
|
|
|
938
946
|
export function inspectRemote(serverId, { instance, home } = {}, io = {}) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.1",
|
|
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",
|