@awebai/oats 0.25.9 → 0.27.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/README.md +8 -6
- package/bin/oats.mjs +648 -1755
- package/capabilities/oats-authoring/oats-package.json +2 -2
- package/capabilities/oats-authoring/oats.json +2 -2
- package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +46 -25
- package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +13 -6
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +279 -93
- package/capabilities/oats-aweb/injects/aweb.md +7 -2
- package/capabilities/oats-aweb/lib/binding-wire.mjs +89 -13
- package/capabilities/oats-aweb/lib/captured-native.mjs +1 -1
- package/capabilities/oats-aweb/lib/grant-custody.mjs +38 -0
- package/capabilities/oats-aweb/oats.json +8 -4
- package/capabilities/oats-jira/bin/oats-jira.mjs +4 -4
- package/capabilities/oats-jira/oats.json +2 -2
- package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +6 -3
- package/capabilities/oats-linear/bin/oats-linear-hook.mjs +6 -4
- package/capabilities/oats-linear/oats.json +2 -2
- package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +6 -0
- package/capabilities/oats-review/oats.json +3 -2
- package/docs/capabilities.md +229 -58
- package/docs/capability-manifest.schema.json +29 -9
- package/docs/configuration.md +17 -5
- package/docs/conventions.md +18 -28
- package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +1 -1
- package/docs/design/2026-09-13-knowledge-and-memory-direction.md +3 -3
- package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +2 -2
- package/docs/design/2026-09-15-portable-souls-handoff.md +2 -2
- package/docs/design/2026-09-15-portable-souls-implementation.md +1 -1
- package/docs/design/2026-09-20-redesign-program-board.md +2 -2
- package/docs/design/2026-09-23-workspace-module-contracts.md +1 -1
- package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +1 -1
- package/docs/design/2026-09-24-desktop-phase-f-boundary.md +34 -1
- package/docs/design/2026-09-24-phase-d-plan.md +57 -0
- package/docs/design/2026-09-25-teams-contract.md +226 -0
- package/docs/design/README.md +3 -3
- package/docs/design/launch-configurations.md +20 -16
- package/docs/design/operations-contract.md +27 -10
- package/docs/desktop-cli-api.md +604 -271
- package/docs/desktop-instance-start.md +3 -3
- package/docs/desktop.md +7 -13
- package/docs/execution-targets.md +16 -18
- package/docs/first-team.md +15 -18
- package/docs/implementation.md +31 -62
- package/docs/integrations.md +64 -33
- package/docs/knowledge-capability-authoring.md +1 -1
- package/docs/knowledge-reference/package-craft.md +10 -8
- package/docs/knowledge-theory.md +1 -1
- package/docs/knowledge.md +10 -11
- package/docs/layers.md +16 -17
- package/docs/oats-local.schema.json +30 -1
- package/docs/oats-membership.schema.json +5 -3
- package/docs/oats-package.schema.json +2 -2
- package/docs/oats-workspace.schema.json +1 -1
- package/docs/{official-marketplace.md → official-catalog.md} +15 -16
- package/docs/packages.md +76 -53
- package/docs/release-notes/v0.22.0.md +1 -1
- package/docs/release-notes/v0.23.1.md +1 -1
- package/docs/release-notes/v0.26.0.md +670 -0
- package/docs/release-notes/v0.27.0.md +100 -0
- package/docs/schedules.md +54 -132
- package/docs/servers.md +4 -4
- package/docs/soul.schema.json +11 -4
- package/docs/souls-and-instances.md +60 -47
- package/docs/workspaces.md +80 -58
- package/injects/instance-boundary.md +2 -2
- package/injects/work-attached.md +1 -1
- package/injects/work-workspace.md +2 -2
- package/lib/{portable-files.mjs → bounded-read.mjs} +6 -6
- package/lib/{portable-values.mjs → canonical-json.mjs} +3 -12
- package/lib/capability-contract.mjs +110 -0
- package/lib/config-data.mjs +2 -2
- package/lib/core.mjs +947 -5023
- package/lib/deprecation.mjs +24 -0
- package/lib/digest.mjs +12 -0
- package/lib/instance-inspect.mjs +397 -0
- package/lib/instance-lifecycle.mjs +3 -4
- package/lib/instance-resolution.mjs +212 -26
- package/lib/instruction-composition.mjs +0 -20
- package/lib/materialize.mjs +6 -4
- package/lib/operator-dispatch.mjs +33 -13
- package/lib/packages.mjs +25 -190
- package/lib/process-group.mjs +1 -1
- package/lib/provider-binding.mjs +4 -2
- package/lib/provider-reasons.mjs +3 -68
- package/lib/remote.mjs +1 -1
- package/lib/resolve.mjs +204 -68
- package/lib/schedule.mjs +136 -292
- package/lib/servers.mjs +70 -38
- package/lib/{portable-shape.mjs → shape.mjs} +4 -3
- package/lib/tree-copy.mjs +44 -0
- package/lib/workspace.mjs +132 -20
- package/package-catalog.json +6 -6
- package/package.json +1 -1
- package/packages/record/lib/session-roots.mjs +8 -6
- package/skills/integration-authoring/SKILL.md +48 -40
- package/skills/oats-getting-started/SKILL.md +105 -110
- package/skills/oats-support/SKILL.md +2 -2
- package/skills/soul-craft/SKILL.md +13 -6
- package/bin/oats-pi-sdk-host.mjs +0 -17
- package/docs/2026-09-03-architecture-proposal.md +0 -642
- package/docs/artifact-approvals.schema.json +0 -7
- package/docs/captured-invocation-context.schema.json +0 -7
- package/docs/captured-resolution.schema.json +0 -7
- package/docs/design/package-engine-contract.md +0 -813
- package/docs/design/package-runtime-api.md +0 -588
- package/docs/desktop-succession.md +0 -57
- package/docs/execution-capsule.schema.json +0 -108
- package/docs/first-team-demo.md +0 -92
- package/docs/knowledge-migration.md +0 -147
- package/docs/migration-from-oas.md +0 -103
- package/docs/oats-config.schema.json +0 -172
- package/docs/oats-lock-v3.schema.json +0 -7
- package/docs/oats-lock.schema.json +0 -175
- package/docs/operating-team-migration.md +0 -470
- package/docs/portable.schema.json +0 -2512
- package/docs/provider-check-input.schema.json +0 -7
- package/docs/rebuild-to-v2.md +0 -511
- package/docs/workspace-adoption.md +0 -74
- package/injects/framework-workspace.md +0 -7
- package/injects/local-soul.md +0 -19
- package/injects/oats-portable.md +0 -20
- package/injects/oats.md +0 -11
- package/injects/portable-instance-boundary.md +0 -39
- package/injects/portable-work-directory.md +0 -29
- package/lib/artifact-approvals.mjs +0 -120
- package/lib/artifact-tree.mjs +0 -141
- package/lib/capability-artifacts.mjs +0 -179
- package/lib/capability-execution.mjs +0 -15
- package/lib/capability-inputs.mjs +0 -39
- package/lib/capability-provenance.mjs +0 -231
- package/lib/captured-action-shape.mjs +0 -21
- package/lib/captured-admission-shape.mjs +0 -20
- package/lib/captured-binding-file.mjs +0 -36
- package/lib/captured-dispatch.mjs +0 -66
- package/lib/captured-instance-index.mjs +0 -277
- package/lib/captured-invocation-context.mjs +0 -130
- package/lib/captured-launch-request.mjs +0 -66
- package/lib/captured-operation-process.mjs +0 -15
- package/lib/captured-pi-custody.mjs +0 -29
- package/lib/captured-pi-host.mjs +0 -167
- package/lib/captured-pi-outcome.mjs +0 -172
- package/lib/captured-resolutions.mjs +0 -275
- package/lib/captured-scaffold.mjs +0 -87
- package/lib/captured-selector.mjs +0 -28
- package/lib/captured-session-backend.mjs +0 -52
- package/lib/captured-source-receipt-file.mjs +0 -72
- package/lib/helper-injection-policy.mjs +0 -104
- package/lib/legacy-lock-codec.mjs +0 -106
- package/lib/manifest-settings.mjs +0 -84
- package/lib/package-closure.mjs +0 -48
- package/lib/package-materialization.mjs +0 -83
- package/lib/pi-sdk-host.mjs +0 -229
- package/lib/portable-artifacts.mjs +0 -115
- package/lib/portable-choices.mjs +0 -82
- package/lib/portable-composition.mjs +0 -136
- package/lib/portable-digest.mjs +0 -105
- package/lib/portable-identity.mjs +0 -40
- package/lib/portable-lock.mjs +0 -117
- package/lib/portable-onboarding-request.mjs +0 -49
- package/lib/portable-onboarding.mjs +0 -256
- package/lib/portable-package-preparation.mjs +0 -188
- package/lib/portable-policy.mjs +0 -44
- package/lib/portable-soul.mjs +0 -42
- package/lib/portable-state.mjs +0 -80
- package/lib/prepare-composition.mjs +0 -170
- package/lib/prepared-bindings.mjs +0 -92
- package/lib/prepared-resources.mjs +0 -127
- package/lib/provider-binding-broker.mjs +0 -65
- package/lib/provider-binding-wire.mjs +0 -116
- package/lib/readiness.mjs +0 -225
- package/lib/repository-observation.mjs +0 -226
- package/lib/resolution-shape.mjs +0 -393
- package/lib/schedule-capsule.mjs +0 -206
- package/lib/soul-constraints.mjs +0 -40
- package/lib/source-projection.mjs +0 -84
- package/lib/source-spec.mjs +0 -189
- package/lib/workspace-definition.mjs +0 -126
- package/lib/workspace-discovery.mjs +0 -146
- package/skills/oats/SKILL.md +0 -162
- package/skills/oats-config/SKILL.md +0 -164
- package/skills/oats-packages/SKILL.md +0 -184
- package/skills/oats-portable/SKILL.md +0 -115
- package/skills/oats-portable-artifacts/SKILL.md +0 -63
package/lib/servers.mjs
CHANGED
|
@@ -21,8 +21,8 @@ import { createHash } from "node:crypto";
|
|
|
21
21
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
22
22
|
import { homedir } from "node:os";
|
|
23
23
|
import { join, resolve } from "node:path";
|
|
24
|
-
import { parseStrictJson } from "./
|
|
25
|
-
import {
|
|
24
|
+
import { parseStrictJson } from "./canonical-json.mjs";
|
|
25
|
+
import { noteRuntimeName } from "./deprecation.mjs";
|
|
26
26
|
|
|
27
27
|
const OATS_HOME_DIR = () => process.env.OATS_HOME_DIR || join(homedir(), ".oats");
|
|
28
28
|
export const SERVERS_FILE = () => join(OATS_HOME_DIR(), "servers.json");
|
|
@@ -225,53 +225,64 @@ export function checkRemote(target, io = {}) {
|
|
|
225
225
|
}
|
|
226
226
|
// What the remote kernel advertises it can launch. A probe without these
|
|
227
227
|
// fields is a 0.22.1-class kernel: pi and claude only, no session backend
|
|
228
|
-
// choice, no launch options; nothing newer may be requested of it.
|
|
228
|
+
// choice, no launch options; nothing newer may be requested of it. The
|
|
229
|
+
// harness list is named `harnesses` since 0.27.0; an older kernel names the
|
|
230
|
+
// same list `runtimes`, and is read in its own vocabulary (lead call 6).
|
|
229
231
|
const list = (k, fallback) => (Array.isArray(probe[k]) ? probe[k].map(String) : fallback);
|
|
232
|
+
const harnessKey = Array.isArray(probe.harnesses) ? "harnesses" : "runtimes";
|
|
230
233
|
return {
|
|
231
234
|
version: probe.version, schemaVersion: 1, desktopApi: 1,
|
|
232
|
-
|
|
235
|
+
harnesses: list(harnessKey, ["pi", "claude"]),
|
|
233
236
|
sessionBackends: list("sessionBackends", []),
|
|
234
237
|
launchOptions: list("launchOptions", []),
|
|
235
238
|
remote: list("remote", []),
|
|
236
239
|
features: list("features", []),
|
|
237
|
-
operationsApi: probe.operationsApi
|
|
240
|
+
operationsApi: [1, 2].includes(probe.operationsApi) ? probe.operationsApi : null,
|
|
238
241
|
scheduleApi: [1, 2].includes(probe.scheduleApi) ? probe.scheduleApi : null,
|
|
239
|
-
advertised: Array.isArray(probe
|
|
242
|
+
advertised: Array.isArray(probe[harnessKey]),
|
|
240
243
|
};
|
|
241
244
|
}
|
|
242
245
|
|
|
243
246
|
/** Refuse, before any mutation, a spawn that asks the remote kernel for a
|
|
244
|
-
*
|
|
245
|
-
* effective
|
|
247
|
+
* harness, session backend or launch option it does not advertise. The
|
|
248
|
+
* effective harness is the --harness flag or the soul's default from the
|
|
246
249
|
* remote roster, resolved THERE, since the local roster says nothing about
|
|
247
250
|
* that host's souls. Desktop's local support check cannot prove remote
|
|
248
251
|
* support; this is the proof. */
|
|
252
|
+
/** The client speaks the host's vocabulary (0.27.0, lead call 6): a host that does not
|
|
253
|
+
* advertise the harness feature is asked with `--runtime`, the name it reads. */
|
|
254
|
+
export function hostHarnessArgs(remote, args) {
|
|
255
|
+
if ((remote?.features || []).includes("harness")) return args;
|
|
256
|
+
return args.map((a) => (a === "--harness" ? "--runtime" : a.startsWith("--harness=") ? `--runtime=${a.slice("--harness=".length)}` : a));
|
|
257
|
+
}
|
|
249
258
|
export function checkRemoteSupport(remote, target, oatsArgs, roster) {
|
|
250
259
|
const flagOf = (name) => { const i = oatsArgs.indexOf(name); return i >= 0 && oatsArgs[i + 1] && !oatsArgs[i + 1].startsWith("--") ? oatsArgs[i + 1] : undefined; };
|
|
251
260
|
const agent = oatsArgs.find((a) => !a.startsWith("--"));
|
|
252
261
|
const soul = (roster?.agents || []).find((a) => a.name === agent);
|
|
253
|
-
// The effective
|
|
254
|
-
// roster reports it. A soul the roster does not list with a
|
|
262
|
+
// The effective harness is the flag, else the soul's default as the remote
|
|
263
|
+
// roster reports it. A soul the roster does not list with a harness (a
|
|
255
264
|
// capability-defined agent, or one with no live instance) has no default
|
|
256
265
|
// this side can establish: the remote kernel validates its own default at
|
|
257
266
|
// spawn, and nothing is asserted here about it.
|
|
258
267
|
const namedConfig = oatsArgs.includes("--launch-config");
|
|
259
|
-
|
|
268
|
+
// --runtime is the pre-0.27 name of --harness; a roster row from a host before 0.27 says `runtime`.
|
|
269
|
+
const chosen = flagOf("--harness") || flagOf("--runtime");
|
|
270
|
+
const harness = chosen || (namedConfig ? undefined : soul?.harness ?? soul?.runtime);
|
|
260
271
|
// What the message may claim depends on what was established: an
|
|
261
272
|
// advertising remote said what it supports; a silent one (before 0.22.2)
|
|
262
273
|
// said nothing, and only pi and claude are assumed of it.
|
|
263
274
|
const supports = remote.advertised
|
|
264
|
-
? `it advertises
|
|
265
|
-
: `it does not advertise
|
|
275
|
+
? `it advertises harnesses ${remote.harnesses.join(", ")}${remote.sessionBackends.length ? `, session backends ${remote.sessionBackends.join(", ")}` : ", no session backend choice"}${remote.launchOptions.length ? `, launch options ${remote.launchOptions.join(", ")}` : ", no launch options"}`
|
|
276
|
+
: `it does not advertise its harnesses (kernels before 0.22.2 do not), so only pi and claude are assumed of it${remote.sessionBackends.length ? `, with session backends ${remote.sessionBackends.join(", ")}` : " on tmux"}${remote.launchOptions.length ? ` and launch options ${remote.launchOptions.join(", ")}` : " with no launch options"}; upgrade it there to use more`;
|
|
266
277
|
const refuse = (what) => { throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost}: ${what} was not established as supported there (${supports})`); };
|
|
267
278
|
if (namedConfig && !remote.features.includes("launch-config")) refuse("a named launch configuration (the host must advertise launch-config)");
|
|
268
|
-
if (
|
|
279
|
+
if (harness && !remote.harnesses.includes(harness)) refuse(`harness ${harness}${chosen ? "" : ` (the default of soul ${agent} there)`}`);
|
|
269
280
|
// A wake schedule at spawn is saved by the host: it must advertise schedules.
|
|
270
281
|
if (["--wake-json", "--wake-every", "--wake-cron"].some((f) => oatsArgs.includes(f)) && !remote.features.includes("schedule")) refuse("a wake schedule at spawn (the host must advertise the schedule feature)");
|
|
271
282
|
const backend = flagOf("--backend");
|
|
272
283
|
if (backend && !remote.sessionBackends.includes(backend)) refuse(`session backend ${backend}`);
|
|
273
284
|
if (oatsArgs.includes("--yolo") && !remote.launchOptions.includes("yolo")) refuse("the yolo launch option");
|
|
274
|
-
return {
|
|
285
|
+
return { harness, backend, yolo: oatsArgs.includes("--yolo") };
|
|
275
286
|
}
|
|
276
287
|
|
|
277
288
|
/** The remote kernel version that carries `oats session` (inspect, input,
|
|
@@ -405,14 +416,13 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
|
|
|
405
416
|
const ii = oatsArgs.indexOf("--instance");
|
|
406
417
|
const explicitName = ii >= 0 && oatsArgs[ii + 1] && !oatsArgs[ii + 1].startsWith("--") ? oatsArgs[ii + 1] : undefined;
|
|
407
418
|
if (explicitName && readSnapshot(serverId, explicitName)) throw serverError("E_ROUTE_EXISTS", `a saved route for ${explicitName} through server ${serverId} already exists (${readSnapshot(serverId, explicitName).home}); retire it (oats retire ${explicitName} --server ${serverId}) or drop it (oats server forget ${serverId} --instance ${explicitName}) before spawning that name again`);
|
|
408
|
-
// The remote roster: the soul's
|
|
419
|
+
// The remote roster: the soul's harness default for the support check,
|
|
409
420
|
// and the remote agents root for the snapshot (the kernel's spawn result
|
|
410
|
-
// does not carry it, and guessing it from the workspace would be wrong
|
|
411
|
-
// for local souls).
|
|
421
|
+
// does not carry it, and guessing it from the workspace would be wrong).
|
|
412
422
|
const status = runRemote(target, json(withScope(["status"])), io).envelope;
|
|
413
423
|
if (!status.ok) return { envelope: status, stderr: "" };
|
|
414
424
|
checkRemoteSupport(remote, target, oatsArgs, status.result);
|
|
415
|
-
const { envelope, stderr } = runRemote(target, json(withScope(["spawn", ...oatsArgs])), io);
|
|
425
|
+
const { envelope, stderr } = runRemote(target, json(withScope(["spawn", ...hostHarnessArgs(remote, oatsArgs)])), io);
|
|
416
426
|
if (envelope.ok && envelope.result?.instance) {
|
|
417
427
|
// A generated name can still collide with a saved route of another
|
|
418
428
|
// soul on the same host (dev --purpose foo-1 vs dev-foo --purpose 1).
|
|
@@ -486,7 +496,7 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
|
|
|
486
496
|
return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, target, snapshots: listSnapshots(serverId) } } : envelope, stderr };
|
|
487
497
|
}
|
|
488
498
|
if (OPERATIONS_COMMANDS.has(cmd)) {
|
|
489
|
-
// The operations contract (inspect, operation run
|
|
499
|
+
// The operations contract (inspect, operation run): the
|
|
490
500
|
// remote must advertise it before anything is sent. An explicit --dir is
|
|
491
501
|
// the exact member context the caller chose and travels as is; without
|
|
492
502
|
// one, a --home selection is left to the host (the home is its own
|
|
@@ -505,14 +515,14 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
|
|
|
505
515
|
if (ii >= 0) { args.splice(ii, 2); if (valueOf("--home") === undefined) args.push("--home", route.home); }
|
|
506
516
|
}
|
|
507
517
|
const remote = checkRemote(target, io);
|
|
508
|
-
if (!Array.isArray(remote.features) || !remote.features.includes("operations") || remote.operationsApi
|
|
518
|
+
if (!Array.isArray(remote.features) || !remote.features.includes("operations") || ![1, 2].includes(remote.operationsApi)) {
|
|
509
519
|
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`);
|
|
510
520
|
}
|
|
511
521
|
const scoped = args.includes("--dir") || args.includes("--home") ? args : [...args, "--dir", target.workspace];
|
|
512
522
|
const { envelope, stderr } = runRemote(target, json([cmd, ...scoped]), io);
|
|
513
523
|
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 };
|
|
514
524
|
}
|
|
515
|
-
throw serverError("E_USAGE", `--server routes spawn, retire, status, session, okf harvest, schedule, inspect
|
|
525
|
+
throw serverError("E_USAGE", `--server routes spawn, retire, status, session, okf harvest, schedule, inspect and operation only (not ${cmd})`);
|
|
516
526
|
}
|
|
517
527
|
|
|
518
528
|
// ------------------------------------------------------------------- roster
|
|
@@ -578,7 +588,7 @@ export function rosterGroups({ server, io = {} } = {}) {
|
|
|
578
588
|
g.probe = { ok: true };
|
|
579
589
|
g.agentsRoot = status.result.root;
|
|
580
590
|
for (const a of status.result.agents || []) {
|
|
581
|
-
g.souls.push({ name: a.name,
|
|
591
|
+
g.souls.push({ name: a.name, harness: a.harness ?? a.runtime, work: a.work, backend: a.backend, description: a.description, agentsRoot: status.result.root });
|
|
582
592
|
// A failed deferred self-retirement needs an operator: it rides with
|
|
583
593
|
// the group, named by agent and instance, as `oats status` prints it.
|
|
584
594
|
for (const f of a.retireFailures || []) g.retireFailures.push({ agent: a.name, ...f });
|
|
@@ -586,7 +596,7 @@ export function rosterGroups({ server, io = {} } = {}) {
|
|
|
586
596
|
const snap = routeOf(i);
|
|
587
597
|
g.instances.push({
|
|
588
598
|
server: g.server, instance: i.instance, agent: a.name, home: i.home || snap?.home, agentsRoot: status.result.root,
|
|
589
|
-
|
|
599
|
+
harness: i.harness || i.runtime || null, backend: i.sessionTarget?.backend || (i.tmux ? "tmux" : null),
|
|
590
600
|
...(i.sessionTarget ? { sessionTarget: i.sessionTarget } : {}), ...(i.tmux ? { tmux: i.tmux } : {}),
|
|
591
601
|
running: typeof i.running === "boolean" ? i.running : null, ...(i.runtimeError ? { runtimeError: i.runtimeError } : {}),
|
|
592
602
|
retirePending: !!i.retirePending, rollbackIncomplete: !!i.rollbackIncomplete, savedRoute: !!snap, missingRemotely: false,
|
|
@@ -599,7 +609,7 @@ export function rosterGroups({ server, io = {} } = {}) {
|
|
|
599
609
|
// instance is gone there (retired, or its home removed) and the route is
|
|
600
610
|
// stale; with a failed probe nothing is known. Same keys as remote rows.
|
|
601
611
|
for (const snap of bySnapshot.values()) {
|
|
602
|
-
g.instances.push({ server: g.server, instance: snap.instance, agent: snap.agent, home: snap.home, agentsRoot: snap.agentsRoot,
|
|
612
|
+
g.instances.push({ server: g.server, instance: snap.instance, agent: snap.agent, home: snap.home, agentsRoot: snap.agentsRoot, harness: snap.harness || snap.runtime || null, backend: null, running: null, retirePending: false, rollbackIncomplete: false, savedRoute: true, missingRemotely: g.probe?.ok === true });
|
|
603
613
|
}
|
|
604
614
|
delete g._snapshots;
|
|
605
615
|
}
|
|
@@ -656,7 +666,7 @@ export function inspectRemote(serverId, { instance, home } = {}, io = {}) {
|
|
|
656
666
|
export const SESSION_START_REMOTE_VERSION = "0.22.9";
|
|
657
667
|
/** The kernel version whose probe first advertises the operations contract. */
|
|
658
668
|
export const OPERATIONS_REMOTE_VERSION = "0.22.16";
|
|
659
|
-
const OPERATIONS_COMMANDS = new Set(["inspect", "operation"
|
|
669
|
+
const OPERATIONS_COMMANDS = new Set(["inspect", "operation"]);
|
|
660
670
|
|
|
661
671
|
/** `session start` on the execution host for a remote instance: the same
|
|
662
672
|
* route resolution as inspect, refused before any mutation when the remote
|
|
@@ -669,15 +679,15 @@ export function restartRemote(serverId, choices = {}, io = {}) {
|
|
|
669
679
|
return launchRemoteSession(serverId, "restart", choices, io);
|
|
670
680
|
}
|
|
671
681
|
|
|
672
|
-
function remoteLaunchArgs({ launchConfig,
|
|
682
|
+
function remoteLaunchArgs({ launchConfig, harness, model, yolo } = {}) {
|
|
673
683
|
const args = [];
|
|
674
684
|
if (launchConfig !== undefined) {
|
|
675
685
|
if (typeof launchConfig !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(launchConfig)) throw serverError("E_BAD_ARGS", "invalid launch configuration name");
|
|
676
686
|
args.push("--launch-config", launchConfig);
|
|
677
687
|
}
|
|
678
|
-
if (
|
|
679
|
-
if (!["pi", "claude", "codex"].includes(
|
|
680
|
-
args.push("--
|
|
688
|
+
if (harness !== undefined) {
|
|
689
|
+
if (!["pi", "claude", "codex"].includes(harness)) throw serverError("E_BAD_ARGS", "harness must be pi, claude or codex");
|
|
690
|
+
args.push("--harness", harness);
|
|
681
691
|
}
|
|
682
692
|
if (model !== undefined && model !== null && model !== "") {
|
|
683
693
|
if (typeof model !== "string" || model.startsWith("-") || model.includes("\0")) throw serverError("E_BAD_ARGS", "invalid model name");
|
|
@@ -695,7 +705,7 @@ function requireRemoteFeature(remote, target, feature) {
|
|
|
695
705
|
}
|
|
696
706
|
|
|
697
707
|
function launchRemoteSession(serverId, action, choices, io) {
|
|
698
|
-
const { instance, home, launchConfig,
|
|
708
|
+
const { instance, home, launchConfig, harness, yolo } = choices;
|
|
699
709
|
const choiceArgs = remoteLaunchArgs(choices);
|
|
700
710
|
const route = resolveRoute(serverId, { instance, home }, `session ${action}`);
|
|
701
711
|
const remote = requireSessionRemote(route.target, io);
|
|
@@ -703,8 +713,8 @@ function launchRemoteSession(serverId, action, choices, io) {
|
|
|
703
713
|
throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${route.target.sshHost} does not advertise session-start (kernels from ${SESSION_START_REMOTE_VERSION} do); upgrade it there, or start the instance on that host`);
|
|
704
714
|
}
|
|
705
715
|
if (action === "restart") requireRemoteFeature(remote, route.target, "session-restart");
|
|
706
|
-
if (launchConfig !== undefined ||
|
|
707
|
-
const args = ["session", action, "--home", route.home, ...choiceArgs, "--json"];
|
|
716
|
+
if (launchConfig !== undefined || harness !== undefined || yolo !== undefined) requireRemoteFeature(remote, route.target, "launch-config");
|
|
717
|
+
const args = ["session", action, "--home", route.home, ...hostHarnessArgs(remote, choiceArgs), "--json"];
|
|
708
718
|
const { envelope, stderr } = runRemote(route.target, args, io);
|
|
709
719
|
return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, instance: instance || route.snapshot?.instance || envelope.result.instance } } : envelope, stderr, route };
|
|
710
720
|
}
|
|
@@ -713,6 +723,15 @@ function launchRemoteSession(serverId, action, choices, io) {
|
|
|
713
723
|
* its saved route. Definitions travel on stdin, never as remote file paths or
|
|
714
724
|
* shell arguments. The host resolves environment references and validates the
|
|
715
725
|
* definition; this client never substitutes its own environment. */
|
|
726
|
+
/** A launch definition in the host's vocabulary: `harness` for a host with the harness feature,
|
|
727
|
+
* `runtime` for one before 0.27.0. Either spelling is read here; both, disagreeing, are refused. */
|
|
728
|
+
function hostLaunchDefinition(remote, definition) {
|
|
729
|
+
const { harness, runtime, ...rest } = definition; // a disagreeing pair was refused before the probe
|
|
730
|
+
if (runtime !== undefined) noteRuntimeName("runtime in the launch configuration definition (sent as the host's harness key)");
|
|
731
|
+
const value = harness ?? runtime;
|
|
732
|
+
if (value === undefined) return rest;
|
|
733
|
+
return (remote?.features || []).includes("harness") ? { ...rest, harness: value } : { ...rest, runtime: value };
|
|
734
|
+
}
|
|
716
735
|
export function launchConfigRemote(serverId, options = {}, io = {}) {
|
|
717
736
|
const { action, name, context, instance, home, soul, agentsRoot, definition, keepEnv } = options;
|
|
718
737
|
if (!["list", "set", "remove", "preview"].includes(action)) throw serverError("E_BAD_ARGS", "unknown launch configuration action");
|
|
@@ -731,6 +750,7 @@ export function launchConfigRemote(serverId, options = {}, io = {}) {
|
|
|
731
750
|
if (!definition || typeof definition !== "object" || Array.isArray(definition)) throw serverError("E_BAD_ARGS", "specify a launch configuration object");
|
|
732
751
|
if (keepEnv !== undefined && typeof keepEnv !== "boolean") throw serverError("E_BAD_ARGS", "keepEnv must be true or false");
|
|
733
752
|
if (keepEnv && Object.hasOwn(definition, "env")) throw serverError("E_BAD_ARGS", "omit env when preserving the saved environment");
|
|
753
|
+
if (definition.harness !== undefined && definition.runtime !== undefined && definition.harness !== definition.runtime) throw serverError("E_BAD_ARGS", "the definition names harness and runtime (its pre-0.27 name) with different values; nothing was sent");
|
|
734
754
|
input = Buffer.from(JSON.stringify(definition));
|
|
735
755
|
args.push("--file", "-");
|
|
736
756
|
if (keepEnv) args.push("--keep-env");
|
|
@@ -751,10 +771,14 @@ export function launchConfigRemote(serverId, options = {}, io = {}) {
|
|
|
751
771
|
// stdin belongs only to the set operation, never its preceding version probe.
|
|
752
772
|
const remote = checkRemote(target, { ...io, input: undefined });
|
|
753
773
|
requireRemoteFeature(remote, target, "launch-config");
|
|
754
|
-
|
|
774
|
+
// The host's vocabulary: its harness flag, and the definition's key (read either here).
|
|
775
|
+
if (input !== undefined) input = Buffer.from(JSON.stringify(hostLaunchDefinition(remote, definition)));
|
|
776
|
+
const { envelope, stderr } = runRemote(target, [...hostHarnessArgs(remote, args), "--json"], { ...io, input });
|
|
755
777
|
return { envelope, stderr, target, ...(route ? { route } : {}) };
|
|
756
778
|
}
|
|
757
779
|
|
|
780
|
+
/** The definition keys of a captured (versioned) schedule, removed in 0.26 (as lib/schedule.mjs refuses them). */
|
|
781
|
+
const CAPTURED_SCHEDULE_KEYS = ["definitionVersion", "recurrencePolicy", "execution", "preparation"];
|
|
758
782
|
/** `oats schedule ...` on the execution host: schedules are host-owned, so
|
|
759
783
|
* every subcommand runs in the server's registered workspace; refused
|
|
760
784
|
* before any remote mutation when the remote kernel does not advertise
|
|
@@ -767,14 +791,22 @@ export function scheduleRemote(serverId, oatsArgs, io = {}) {
|
|
|
767
791
|
if (["add", "update"].includes(oatsArgs[0])) {
|
|
768
792
|
const indexes = oatsArgs.flatMap((arg, index) => arg === "--spec-json" ? [index] : []);
|
|
769
793
|
if (indexes.length > 1) throw serverError("E_BAD_ARGS", "remote schedule spec must be unambiguous");
|
|
770
|
-
let versioned = oatsArgs.includes("--file"); // Cannot classify remote file bytes here.
|
|
771
794
|
if (indexes.length) {
|
|
772
795
|
const spec = parseStrictJson(oatsArgs[indexes[0] + 1]);
|
|
773
796
|
if (!spec || typeof spec !== "object" || Array.isArray(spec)) throw serverError("E_BAD_ARGS", "remote schedule spec must be an object");
|
|
774
|
-
versioned
|
|
775
|
-
|
|
797
|
+
// Captured (versioned) schedules were removed in 0.26: refused here, as a local add
|
|
798
|
+
// refuses them, never forwarded to a remote kernel that might still accept one.
|
|
799
|
+
const captured = CAPTURED_SCHEDULE_KEYS.filter((key) => Object.hasOwn(spec, key));
|
|
800
|
+
if (captured.length) throw serverError("E_SCHEDULE_INVALID", `${captured.join(", ")}: captured schedules are refused (the captured/portable path was removed in 0.26); no request was forwarded`);
|
|
801
|
+
// The host's vocabulary for the spawn job's harness (read either here).
|
|
802
|
+
if (Object.hasOwn(spec, "harness") || Object.hasOwn(spec, "runtime")) {
|
|
803
|
+
const { harness, runtime, ...rest } = spec;
|
|
804
|
+
if (harness !== undefined && runtime !== undefined && harness !== runtime) throw serverError("E_SCHEDULE_INVALID", "the schedule names harness and runtime (its pre-0.27 name) with different values; no request was forwarded");
|
|
805
|
+
const value = harness ?? runtime;
|
|
806
|
+
if (runtime !== undefined) noteRuntimeName("runtime in the schedule spec (sent as the host's harness key)");
|
|
807
|
+
oatsArgs = oatsArgs.map((a, i) => (i === indexes[0] + 1 ? JSON.stringify((remote.features || []).includes("harness") ? { ...rest, harness: value } : { ...rest, runtime: value }) : a));
|
|
808
|
+
}
|
|
776
809
|
}
|
|
777
|
-
if (versioned && remote.scheduleApi !== 2) throw serverError("E_REMOTE_INCOMPATIBLE", "captured schedule mutation requires advertised scheduleApi 2; no request was forwarded");
|
|
778
810
|
}
|
|
779
811
|
const args = ["schedule", ...oatsArgs.filter((a) => a !== "--json"), "--dir", target.workspace, "--json"];
|
|
780
812
|
const { envelope, stderr } = runRemote(target, args, io);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
/** Small shape checks
|
|
2
|
-
* decoded or canonical-data-validated values; these
|
|
1
|
+
/** Small shape checks for decoded documents (capability manifests' binding
|
|
2
|
+
* declarations). Call only on decoded or canonical-data-validated values; these
|
|
3
|
+
* helpers do not resolve policy. */
|
|
3
4
|
import { oatsError } from "./errors.mjs";
|
|
4
|
-
import { scalarString } from "./
|
|
5
|
+
import { scalarString } from "./canonical-json.mjs";
|
|
5
6
|
|
|
6
7
|
export const pointerKey = (key) => key.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
7
8
|
export function invalidShape(pointer, message, code = "invalid-declaration") {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Tree mechanics only: the kernel's catchable recursive copy (spawn, retire and
|
|
2
|
+
* work recovery). No acquisition, selection, trust, lock or lifecycle policy. */
|
|
3
|
+
import {
|
|
4
|
+
chmodSync, copyFileSync, lstatSync, mkdirSync, readdirSync, readlinkSync, symlinkSync,
|
|
5
|
+
} from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { oatsError } from "./errors.mjs";
|
|
8
|
+
|
|
9
|
+
/** Recursively copy a tree the way `cpSync(..., { recursive: true })` would —
|
|
10
|
+
* except catchably.
|
|
11
|
+
*
|
|
12
|
+
* Node 22's recursive `cpSync` performs its recursion in native code, and on
|
|
13
|
+
* macOS an unreadable directory inside the tree surfaces as an uncaught libc++
|
|
14
|
+
* `filesystem_error` that TERMINATES THE PROCESS. No JS `catch` or `finally`
|
|
15
|
+
* runs, so a transaction using it can never clean up staging or roll back the
|
|
16
|
+
* store, the lock and the ignore file. Every package-, capability- and
|
|
17
|
+
* user-shaped tree in the engine therefore goes through this hand-walk instead,
|
|
18
|
+
* where an EACCES is an ordinary throwable error.
|
|
19
|
+
*
|
|
20
|
+
* Semantics chosen to be safe rather than maximally faithful:
|
|
21
|
+
* - deterministic traversal (sorted entries), so two copies of one tree hash
|
|
22
|
+
* identically;
|
|
23
|
+
* - symlinks are recreated VERBATIM — never followed, never rewritten — because
|
|
24
|
+
* the bytes about to be hashed must be the bytes the author wrote;
|
|
25
|
+
* - FIFOs, sockets and device nodes are rejected fail-closed: they are not
|
|
26
|
+
* distributable content, and copying them has no defined meaning here;
|
|
27
|
+
* - directory modes are applied AFTER their children, so a read-only source
|
|
28
|
+
* directory cannot block writing its own contents. */
|
|
29
|
+
export function copyTreeSafe(src, dest) {
|
|
30
|
+
const st = lstatSync(src);
|
|
31
|
+
if (st.isSymbolicLink()) { symlinkSync(readlinkSync(src), dest); return; }
|
|
32
|
+
if (st.isFile()) { copyFileSync(src, dest); chmodSync(dest, st.mode & 0o7777); return; }
|
|
33
|
+
if (!st.isDirectory()) {
|
|
34
|
+
throw oatsError("invalid-source", `${src} is not a regular file, directory or symlink (${st.isFIFO() ? "FIFO" : st.isSocket() ? "socket" : st.isBlockDevice() || st.isCharacterDevice() ? "device node" : "unsupported file type"}) — package and capability trees carry distributable content only`);
|
|
35
|
+
}
|
|
36
|
+
mkdirSync(dest, { recursive: true });
|
|
37
|
+
for (const e of readdirSync(src, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
38
|
+
copyTreeSafe(join(src, e.name), join(dest, e.name));
|
|
39
|
+
}
|
|
40
|
+
chmodSync(dest, st.mode & 0o7777);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
package/lib/workspace.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { parseConfigData } from "./config-data.mjs";
|
|
|
16
16
|
import { oatsError } from "./errors.mjs";
|
|
17
17
|
import * as defaultRemote from "./remote.mjs";
|
|
18
18
|
import { bindRemote, classifyPackageValue } from "./packages.mjs";
|
|
19
|
+
import { manifestContractProblems } from "./capability-contract.mjs";
|
|
19
20
|
|
|
20
21
|
/* ───────────────────────────── errors ─────────────────────────────────── */
|
|
21
22
|
|
|
@@ -293,6 +294,13 @@ function fromProblems(remote, capabilities, path, problems, { here }) {
|
|
|
293
294
|
export function validateLocal(value) {
|
|
294
295
|
const problems = validateAgainst(schemaFor("local"), value);
|
|
295
296
|
if (isObject(value) && isObject(value.settings)) for (const [cap, payload] of Object.entries(value.settings)) reservedKeyProblems(payload, `/settings/${pointerKey(cap)}`, problems);
|
|
297
|
+
// runtime → harness (0.27.0, lead call 6): `runtime`, the pre-0.27 name, is still read; both,
|
|
298
|
+
// disagreeing, are refused.
|
|
299
|
+
if (isObject(value) && isObject(value["launch-configs"])) for (const [name, entry] of Object.entries(value["launch-configs"])) {
|
|
300
|
+
if (isObject(entry) && Object.hasOwn(entry, "runtime") && Object.hasOwn(entry, "harness") && entry.runtime !== entry.harness) {
|
|
301
|
+
problems.push({ path: `/launch-configs/${pointerKey(name)}/runtime`, reason: "harness-conflict", message: `harness ${show(entry.harness)} and runtime ${show(entry.runtime)} disagree: \`runtime\` is the pre-0.27 name of \`harness\` — keep one` });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
296
304
|
return problems;
|
|
297
305
|
}
|
|
298
306
|
|
|
@@ -335,14 +343,24 @@ function schemaError(kind, origin, problems, value) {
|
|
|
335
343
|
|
|
336
344
|
/* ───────────────────────────── public API ─────────────────────────────── */
|
|
337
345
|
|
|
338
|
-
/**
|
|
346
|
+
/** What a 0.25 `oats-config.yaml` held, and where each piece lives now (0.26.0 removed the file). */
|
|
347
|
+
export const LEGACY_CONFIG_MIGRATION = "oats-config.yaml is no longer read (removed in 0.26.0): launch-configs moved to the deployment's oats-local.yaml; "
|
|
348
|
+
+ "capability bindings, layers and team moved to oats-workspace.yaml (defaults, teams) and each soul's soul.yaml in its member repository; "
|
|
349
|
+
+ "agents-md-injection, skill-overrides, work-modes and yolo are removed (yolo is --yolo or a launch configuration's yolo). Move what you still need, then delete the file";
|
|
350
|
+
|
|
351
|
+
/** Walk up from dir to find oats-local.yaml → { path, local } | E_LOCAL_MISSING.
|
|
352
|
+
* A 0.25 `oats-config.yaml` between `dir` and the deployment (inclusive) is refused
|
|
353
|
+
* (E_CONFIG_BROKEN, naming the migration): nothing reads it, so it must not look
|
|
354
|
+
* like configuration. Above the deployment is not this deployment's business. */
|
|
339
355
|
export function loadLocal(dir) {
|
|
340
356
|
let current = resolve(dir);
|
|
341
|
-
const visited = [];
|
|
357
|
+
const visited = [], legacy = [];
|
|
342
358
|
for (;;) {
|
|
343
359
|
const candidate = join(current, "oats-local.yaml");
|
|
344
360
|
visited.push(candidate);
|
|
361
|
+
if (existsSync(join(current, "oats-config.yaml"))) legacy.push(join(current, "oats-config.yaml"));
|
|
345
362
|
if (existsSync(candidate)) {
|
|
363
|
+
if (legacy.length) throw fail("E_CONFIG_BROKEN", `${legacy.join(", ")}: ${LEGACY_CONFIG_MIGRATION}`, { dir: resolve(dir), deployment: current, files: legacy, reason: "legacy-config" });
|
|
346
364
|
const origin = { kind: "local", path: candidate };
|
|
347
365
|
const read = readDeclaration("local", readFileSync(candidate), origin);
|
|
348
366
|
if (read.problems) throw schemaError("local", origin, read.problems, read.value);
|
|
@@ -352,7 +370,7 @@ export function loadLocal(dir) {
|
|
|
352
370
|
if (parent === current) break;
|
|
353
371
|
current = parent;
|
|
354
372
|
}
|
|
355
|
-
throw fail("E_LOCAL_MISSING", `no oats-local.yaml found walking up from ${resolve(dir)}`, { dir: resolve(dir), searched: visited });
|
|
373
|
+
throw fail("E_LOCAL_MISSING", `no oats-local.yaml found walking up from ${resolve(dir)}${legacy.length ? ` (${legacy[0]} is a 0.25 deployment's configuration, which 0.26.0 no longer reads: retire its instances with 0.25, then \`oats onboard\` it)` : ""}`, { dir: resolve(dir), searched: visited, ...(legacy.length ? { legacy } : {}) });
|
|
356
374
|
}
|
|
357
375
|
|
|
358
376
|
/** Observe the workspace host: → { key, url, commit, workspace, observedAt }. */
|
|
@@ -374,6 +392,47 @@ export async function observeWorkspace(ref, { at, remote: injected, remoteOption
|
|
|
374
392
|
return { key: obs.key, url: obs.url, ref, commit: obs.commit, workspace: read.value, observedAt: obs.observedAt };
|
|
375
393
|
}
|
|
376
394
|
|
|
395
|
+
/**
|
|
396
|
+
* A soul's team labels as its repository declares them NOW, without a workspace discovery (teams
|
|
397
|
+
* contract decision 6, the live teams of an existing home): the workspace host has already been read
|
|
398
|
+
* (`wsObs`, observeWorkspace); this reads the soul's own repo once — a member at its current commit
|
|
399
|
+
* (`souls/<name>/soul.yaml`, and `oats-membership.yaml` only when the soul declares no `team`), or an
|
|
400
|
+
* `external[]` soul at its pinned commit (its entry's `team` wins, as in discovery).
|
|
401
|
+
* → labels (primary first), or null when the workspace no longer lists the soul's repo.
|
|
402
|
+
* Membership is not re-confirmed here: the home's soul was confirmed at spawn; this reads labels only.
|
|
403
|
+
*/
|
|
404
|
+
export async function observeSoulLabels(wsObs, { name, repoKey }, { remote: injected, remoteOptions } = {}) {
|
|
405
|
+
const remote = remoteOf({ remote: injected, remoteOptions });
|
|
406
|
+
const workspace = wsObs.workspace;
|
|
407
|
+
const readSoul = async (ref, commit, path) => {
|
|
408
|
+
const file = `${path.replace(/\/+$/, "")}/soul.yaml`;
|
|
409
|
+
const { bytes } = await remote.readRemoteFile(ref, commit, file);
|
|
410
|
+
const read = readDeclaration("soul", bytes, { kind: "soul", repoKey, commit, path: file });
|
|
411
|
+
if (read.problems) throw schemaError("soul", { kind: "soul", repoKey, commit, path: file }, read.problems, read.value);
|
|
412
|
+
return read.value;
|
|
413
|
+
};
|
|
414
|
+
for (const entry of workspace.external || []) {
|
|
415
|
+
const pin = entry.source.lastIndexOf("@");
|
|
416
|
+
let key; try { key = refKey(remote, entry.source.slice(0, pin)); } catch { continue; }
|
|
417
|
+
if (key !== repoKey) continue;
|
|
418
|
+
if (typeof entry.team === "string") return [entry.team];
|
|
419
|
+
const soul = await readSoul(entry.source.slice(0, pin), entry.source.slice(pin + 1), entry.soul);
|
|
420
|
+
if (soul.name !== name) continue;
|
|
421
|
+
return labelList(soul.team) ?? [];
|
|
422
|
+
}
|
|
423
|
+
const memberRef = (workspace.members || []).find((ref) => { try { return refKey(remote, ref) === repoKey; } catch { return false; } });
|
|
424
|
+
if (!memberRef) return null;
|
|
425
|
+
const obs = await remote.observeRemote(memberRef);
|
|
426
|
+
const soul = await readSoul(memberRef, obs.commit, `souls/${name}`);
|
|
427
|
+
const own = labelList(soul.team);
|
|
428
|
+
if (own) return own;
|
|
429
|
+
try {
|
|
430
|
+
const { bytes } = await remote.readRemoteFile(memberRef, obs.commit, "oats-membership.yaml");
|
|
431
|
+
const read = readDeclaration("membership", bytes, { kind: "membership", repoKey, commit: obs.commit, path: "oats-membership.yaml" });
|
|
432
|
+
return read.problems ? [] : labelList(read.value.team) ?? [];
|
|
433
|
+
} catch (e) { if (e?.code === "E_REMOTE_PATH_MISSING") return []; throw e; }
|
|
434
|
+
}
|
|
435
|
+
|
|
377
436
|
const unconfirmed = (key, reason, detail, extra = {}) => ({ key, confirmed: false, reason, detail, ...extra });
|
|
378
437
|
|
|
379
438
|
/**
|
|
@@ -422,21 +481,28 @@ export async function confirmMembership(workspaceObs, memberRef, { remote: injec
|
|
|
422
481
|
const caseOnly = backlinkKey.toLowerCase() === workspaceObs.key.toLowerCase();
|
|
423
482
|
return unconfirmed(key, "backlink-elsewhere", `${key}@${obs.commit.slice(0, 12)} names workspace ${backlinkKey}, not ${workspaceObs.key}${caseOnly ? " (the keys differ only by letter case: repo paths are case-sensitive identities; spell the workspace ref exactly as the workspace lists itself)" : ""}`, { commit: obs.commit, backlink: backlinkKey, ...(caseOnly ? { caseOnly: true } : {}) });
|
|
424
483
|
}
|
|
425
|
-
|
|
484
|
+
const labels = labelList(read.value.team) ?? [];
|
|
485
|
+
return { key, commit: obs.commit, confirmed: true, team: labels[0] ?? null, labels };
|
|
426
486
|
}
|
|
427
487
|
|
|
428
488
|
/* ───────────────────────────── enumeration ────────────────────────────── */
|
|
429
489
|
|
|
430
490
|
const SOUL_FILE = /^([^/]+)\/soul\.yaml$/;
|
|
431
491
|
const CAP_FILE = /^([^/]+)\/oats\.json$/;
|
|
432
|
-
|
|
492
|
+
/** A declared `team` as a label list (teams contract 2026-09-25: a label or a non-empty list of distinct
|
|
493
|
+
* labels, the first the primary); null when the item declares none. */
|
|
494
|
+
const labelList = (team) => (typeof team === "string" ? [team] : Array.isArray(team) ? team.filter((l) => typeof l === "string") : null);
|
|
495
|
+
/** The item's labels, else the repo default's (oats-membership.yaml). */
|
|
496
|
+
const labelsOf = (item, fallback) => labelList(item?.team) ?? [...(fallback || [])];
|
|
497
|
+
/** Each label with the path it is declared at: `/team` for one label, `/team/<i>` in a list. */
|
|
498
|
+
const labelPaths = (item, labels, file) => labels.map((label, i) => [label, Array.isArray(item?.team) ? `${file}#/team/${i}` : `${file}#/team`]);
|
|
433
499
|
|
|
434
500
|
/**
|
|
435
501
|
* Enumerate souls/*\/soul.yaml and capabilities/*\/oats.json of one repo at one commit.
|
|
436
502
|
* Validates each item; collects problems instead of aborting.
|
|
437
503
|
* → { souls: [SoulEntry], capabilities: [CapEntry], problems: [{ code, path, message, repoKey }] }
|
|
438
504
|
*/
|
|
439
|
-
async function enumerateRepo(remote, ref, key, commit, {
|
|
505
|
+
async function enumerateRepo(remote, ref, key, commit, { defaultLabels = [], teams = null } = {}) {
|
|
440
506
|
const souls = [];
|
|
441
507
|
const capabilities = [];
|
|
442
508
|
const problems = [];
|
|
@@ -467,9 +533,10 @@ async function enumerateRepo(remote, ref, key, commit, { defaultTeam = null, tea
|
|
|
467
533
|
if (definition.name !== m[1]) problem("E_WORKSPACE_SCHEMA", `${file}#/name`, `soul name ${show(definition.name)} does not match its directory ${show(m[1])}`);
|
|
468
534
|
if (soulNames.has(definition.name)) { problem("E_WORKSPACE_SCHEMA", `${file}#/name`, `soul name ${show(definition.name)} is already declared by ${soulNames.get(definition.name)}; the second declaration is not listed`); continue; }
|
|
469
535
|
soulNames.set(definition.name, file);
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
536
|
+
const labels = labelsOf(definition, defaultLabels);
|
|
537
|
+
for (const [label, at] of labelPaths(definition, labels, file)) checkTeam(label, at);
|
|
538
|
+
// Souls have no private mode since 0.26.0: `private` is accepted, warned (soul-private-ignored) and never hides a soul.
|
|
539
|
+
souls.push({ name: definition.name, path, repoKey: key, commit, team: labels[0] ?? null, labels, private: false, definition });
|
|
473
540
|
}
|
|
474
541
|
const capNames = new Map();
|
|
475
542
|
for (const entry of await list("capabilities")) {
|
|
@@ -497,9 +564,13 @@ async function enumerateRepo(remote, ref, key, commit, { defaultTeam = null, tea
|
|
|
497
564
|
},
|
|
498
565
|
}, manifest);
|
|
499
566
|
if (shape.length) { for (const p of shape) problem("E_WORKSPACE_SCHEMA", `${file}#${p.path}`, p.message); continue; }
|
|
567
|
+
// The kernel contract (launch environment, hooks): a manifest the kernel could not run is not listed.
|
|
568
|
+
const contract = manifestContractProblems(manifest);
|
|
569
|
+
if (contract.length) { for (const p of contract) problem("E_WORKSPACE_SCHEMA", `${file}#${p.pointer}`, p.message); continue; }
|
|
500
570
|
if (capNames.has(manifest.capability)) { problem("E_WORKSPACE_SCHEMA", `${file}#/capability`, `capability ${show(manifest.capability)} is already declared by ${capNames.get(manifest.capability)}; the second declaration is not listed`); continue; }
|
|
501
571
|
capNames.set(manifest.capability, file);
|
|
502
|
-
|
|
572
|
+
// A capability is LISTED under one team (its own `team:`, else the repo default's primary); it joins none.
|
|
573
|
+
const team = typeof manifest.team === "string" ? manifest.team : defaultLabels[0] ?? null;
|
|
503
574
|
checkTeam(team, `${file}#/team`);
|
|
504
575
|
capabilities.push({ name: manifest.capability, path, repoKey: key, commit, team, private: manifest.private === true, manifest });
|
|
505
576
|
}
|
|
@@ -537,7 +608,7 @@ export async function discoverRepo(ref, { at, remote: injected, remoteOptions }
|
|
|
537
608
|
} catch (e) {
|
|
538
609
|
if (e?.code !== "E_REMOTE_PATH_MISSING") throw e;
|
|
539
610
|
}
|
|
540
|
-
const items = await enumerateRepo(remote, ref, obs.key, obs.commit, {
|
|
611
|
+
const items = await enumerateRepo(remote, ref, obs.key, obs.commit, { defaultLabels: labelList(membership?.team) ?? [], teams: null });
|
|
541
612
|
return { key: obs.key, commit: obs.commit, membership, ...items, problems: [...problems, ...items.problems] };
|
|
542
613
|
}
|
|
543
614
|
|
|
@@ -557,7 +628,7 @@ export async function discoverWorkspace(ref, { at, local, remote: injected, remo
|
|
|
557
628
|
const members = [];
|
|
558
629
|
for (const memberRef of workspace.members || []) {
|
|
559
630
|
const confirmation = await confirmMembership(wsObs, memberRef, { remote });
|
|
560
|
-
const row = { key: confirmation.key, commit: confirmation.commit ?? null, confirmed: confirmation.confirmed, team: confirmation.team ?? null, souls: [], capabilities: [], publishes: null };
|
|
631
|
+
const row = { key: confirmation.key, commit: confirmation.commit ?? null, confirmed: confirmation.confirmed, team: confirmation.team ?? null, labels: confirmation.labels ?? [], souls: [], capabilities: [], publishes: null };
|
|
561
632
|
if (!confirmation.confirmed) {
|
|
562
633
|
// Contract: an unconfirmed member contributes nothing but its row.
|
|
563
634
|
row.reason = confirmation.reason;
|
|
@@ -565,8 +636,10 @@ export async function discoverWorkspace(ref, { at, local, remote: injected, remo
|
|
|
565
636
|
members.push(row);
|
|
566
637
|
continue;
|
|
567
638
|
}
|
|
568
|
-
|
|
569
|
-
|
|
639
|
+
for (const [i, label] of row.labels.entries()) {
|
|
640
|
+
if (!teams.includes(label)) problems.push({ code: "E_TEAM_UNKNOWN", repoKey: row.key, path: row.labels.length > 1 ? `oats-membership.yaml#/team/${i}` : "oats-membership.yaml#/team", message: `team ${show(label)} is not declared in the workspace's teams (${teams.join(", ") || "none"})` });
|
|
641
|
+
}
|
|
642
|
+
const items = await enumerateRepo(remote, memberRef, row.key, row.commit, { defaultLabels: row.labels, teams });
|
|
570
643
|
row.souls = items.souls;
|
|
571
644
|
row.capabilities = items.capabilities;
|
|
572
645
|
row.publishes = items.publishes;
|
|
@@ -593,11 +666,50 @@ export async function discoverWorkspace(ref, { at, local, remote: injected, remo
|
|
|
593
666
|
}
|
|
594
667
|
const read = readDeclaration("soul", bytes, { kind: "soul", repoKey: key, commit, path: file });
|
|
595
668
|
if (read.problems) { for (const p of read.problems) pushProblem("E_WORKSPACE_SCHEMA", `${file}#${p.path}`, `${p.message}${schemaHint("soul", read.value)}`); continue; }
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
669
|
+
// The workspace's `external[].team` (one label) overrides the soul's own labels.
|
|
670
|
+
const labels = labelList(entry.team) ?? labelList(read.value.team) ?? [];
|
|
671
|
+
for (const label of labels) if (!teams.includes(label)) pushProblem("E_TEAM_UNKNOWN", `${file}#/team`, `team ${show(label)} is not declared in the workspace's teams`);
|
|
672
|
+
externalRows.push({ source: entry.source, key, commit, soul: { name: read.value.name, path: entry.soul, repoKey: key, commit, team: labels[0] ?? null, labels, private: false, definition: read.value } });
|
|
599
673
|
}
|
|
600
|
-
return { workspace, key: wsObs.key, url: wsObs.url, commit: wsObs.commit, observedAt: wsObs.observedAt, local: local ?? null, members, external: externalRows, problems };
|
|
674
|
+
return { workspace, key: wsObs.key, url: wsObs.url, commit: wsObs.commit, observedAt: wsObs.observedAt, local: local ?? null, members, external: externalRows, problems, warnings: [...unmappedLabelWarnings(workspace, members, externalRows, teams), ...privateSoulWarnings([...members.filter((m) => m.confirmed).flatMap((m) => m.souls), ...externalRows.map((e) => e.soul)])] };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Teams contract decision 5: a soul label the workspace declares in `teams:` but does not map in
|
|
678
|
+
* `messaging.byTeam` is a WARNING, not a problem — the label stays eligible-but-unmapped and the
|
|
679
|
+
* messaging provider falls back to the personal team for it. (An undeclared label is E_TEAM_UNKNOWN.)
|
|
680
|
+
* ONE warning per unmapped label, naming its souls (a personal-only workspace would otherwise print
|
|
681
|
+
* a line per soul): { code, label, souls, paths, message }, sorted by label. */
|
|
682
|
+
const byCodepointOrder = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
683
|
+
function unmappedLabelWarnings(workspace, members, externalRows, teams) {
|
|
684
|
+
const byTeam = isObject(workspace?.messaging) && isObject(workspace.messaging.byTeam) ? workspace.messaging.byTeam : {};
|
|
685
|
+
const byLabel = new Map();
|
|
686
|
+
const souls = [...members.filter((m) => m.confirmed).flatMap((m) => m.souls), ...externalRows.map((e) => e.soul)];
|
|
687
|
+
for (const soul of souls) {
|
|
688
|
+
for (const label of soul.labels || []) {
|
|
689
|
+
if (!teams.includes(label) || Object.hasOwn(byTeam, label)) continue;
|
|
690
|
+
const row = byLabel.get(label) || { souls: [], paths: [] };
|
|
691
|
+
row.souls.push(soul.name); row.paths.push(`${soul.repoKey}:${soul.path}/soul.yaml#/team`);
|
|
692
|
+
byLabel.set(label, row);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return [...byLabel.keys()].sort(byCodepointOrder).map((label) => {
|
|
696
|
+
const { souls: names, paths } = byLabel.get(label);
|
|
697
|
+
const order = names.map((n, i) => i).sort((x, y) => byCodepointOrder(names[x], names[y]) || byCodepointOrder(paths[x], paths[y]));
|
|
698
|
+
const sorted = order.map((i) => names[i]);
|
|
699
|
+
return { code: "unmapped-team-label", label, souls: sorted, paths: order.map((i) => paths[i]),
|
|
700
|
+
message: `team ${show(label)} has no messaging.byTeam entry; its souls (${sorted.join(", ")}) fall back to the personal team for it` };
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** `private:` in a soul.yaml has no effect since 0.26.0 (human decision 2026-09-25): every soul of a
|
|
705
|
+
* confirmed member, and every external soul, is listed and spawnable. The field is still ACCEPTED
|
|
706
|
+
* (existing files keep validating) and named by ONE warning per soul that carries it:
|
|
707
|
+
* { code: "soul-private-ignored", soul, repoKey, path, message }, sorted by soul name, then path. */
|
|
708
|
+
function privateSoulWarnings(souls) {
|
|
709
|
+
return souls.filter((s) => s.definition && Object.hasOwn(s.definition, "private"))
|
|
710
|
+
.map((s) => ({ code: "soul-private-ignored", soul: s.name, repoKey: s.repoKey, path: `${s.repoKey}:${s.path}/soul.yaml#/private`,
|
|
711
|
+
message: `\`private\` has no effect on a soul since 0.26.0; remove it from ${s.path}/soul.yaml` }))
|
|
712
|
+
.sort((a, b) => byCodepointOrder(a.soul, b.soul) || byCodepointOrder(a.path, b.path));
|
|
601
713
|
}
|
|
602
714
|
|
|
603
715
|
/**
|
|
@@ -648,7 +760,7 @@ export function standaloneRepo(ref, commit, discovery, { remote: injected } = {}
|
|
|
648
760
|
});
|
|
649
761
|
return {
|
|
650
762
|
standalone: true, key, commit: source.commit ?? commit ?? null, workspace: null,
|
|
651
|
-
members: [{ key, commit: source.commit ?? commit ?? null, confirmed: false, reason: "cannot-read", detail: `workspace of ${key} cannot be read; standalone view`, team: source.team ?? source.membership?.team ?? null, souls, capabilities, publishes: source.publishes ?? null }],
|
|
652
|
-
external: [], problems,
|
|
763
|
+
members: [{ key, commit: source.commit ?? commit ?? null, confirmed: false, reason: "cannot-read", detail: `workspace of ${key} cannot be read; standalone view`, team: source.team ?? labelList(source.membership?.team)?.[0] ?? null, souls, capabilities, publishes: source.publishes ?? null }],
|
|
764
|
+
external: [], problems, warnings: privateSoulWarnings(souls),
|
|
653
765
|
};
|
|
654
766
|
}
|