@awebai/oats 0.36.1 → 0.38.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/bin/oats.mjs +71 -65
- package/docs/capabilities.md +64 -9
- package/docs/capability-manifest.schema.json +4 -0
- package/docs/configuration.md +16 -21
- package/docs/design/2026-09-27-team-model-v2.md +1 -1
- package/docs/design/2026-10-02-team-model-3.md +146 -0
- package/docs/design/README.md +5 -1
- package/docs/desktop-cli-api.md +159 -121
- package/docs/desktop.md +33 -0
- package/docs/first-team.md +14 -7
- package/docs/integrations.md +1 -1
- package/docs/oats-local.schema.json +2 -14
- package/docs/oats-workspace.schema.json +4 -4
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +10 -9
- package/docs/release-notes/v0.37.0.md +111 -0
- package/docs/release-notes/v0.38.0.md +128 -0
- package/docs/souls-and-instances.md +4 -3
- package/docs/workspaces.md +122 -77
- package/lib/core.mjs +123 -32
- package/lib/instance-inspect.mjs +18 -15
- package/lib/instance-resolution.mjs +9 -6
- package/lib/resolve.mjs +5 -4
- package/lib/schedule.mjs +1 -1
- package/lib/teams-verbs.mjs +44 -70
- package/lib/teams.mjs +151 -112
- package/lib/workspace.mjs +15 -5
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/skills/oats-getting-started/SKILL.md +20 -9
package/lib/instance-inspect.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { capabilityManifests, sessionDefaults, homeLaunchLayers, instanceSoulDir
|
|
|
18
18
|
import { agentDirOf, discoverOrStandalone, ensureWorkspaceSoul, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
|
|
19
19
|
import { declaredSettings } from "./capability-contract.mjs";
|
|
20
20
|
import { kernelCompatibility } from "./resolve.mjs";
|
|
21
|
-
import { recordedTeams, reportRows, soulKeyOf, soulTeams, teamModel, teamProblems } from "./teams.mjs";
|
|
21
|
+
import { discoveredTeamKeys, isRemovedTeamKeys, isTeamRefusal, recordedTeams, reportRows, soulKeyOf, teamKeyOf, soulTeams, teamModel, teamProblems } from "./teams.mjs";
|
|
22
22
|
import { launchLayers, launchReport } from "./launch-preference.mjs";
|
|
23
23
|
import { loadLocal } from "./workspace.mjs";
|
|
24
24
|
import { ensureModuleTree } from "./operator-dispatch.mjs";
|
|
@@ -35,10 +35,10 @@ const obj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
|
35
35
|
const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
36
36
|
|
|
37
37
|
/** A resolution refusal that is a readiness FACT (the soul cannot resolve as the
|
|
38
|
-
* lock stands), not a failure to answer: reported as a failing `installed` item
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
38
|
+
* lock stands), not a failure to answer: reported as a failing `installed` item —
|
|
39
|
+
* or, for a refusal of the soul's teams (isTeamRefusal), as a team item in `configured`. */
|
|
40
|
+
const RESOLUTION_FACTS = new Set(["E_PACKAGE_MISSING", "E_PACKAGE_INTEGRITY", "E_CAPABILITY_MISSING", "E_LOCK_SCHEMA", "E_REQUIREMENT_INACTIVE"]);
|
|
41
|
+
const isResolutionFact = (e) => RESOLUTION_FACTS.has(e?.code) || isTeamRefusal(e);
|
|
42
42
|
|
|
43
43
|
/** Is `name` an executable on PATH? (manifest `requires`, no shell). */
|
|
44
44
|
function onPath(name) {
|
|
@@ -89,6 +89,9 @@ export async function homeTarget(home, meta, { remoteOptions, discover = true, l
|
|
|
89
89
|
const modules = obj(meta.modules) ? meta.modules : {};
|
|
90
90
|
const agentsRoot = dirname(dirname(dirname(realHome)));
|
|
91
91
|
const deployment = dirname(agentsRoot);
|
|
92
|
+
// The deployment's removed team keys (team model 3) refuse every reader of its oats-local.yaml, before
|
|
93
|
+
// anything below falls back past an unreadable local file (live teams, launch, session defaults).
|
|
94
|
+
try { loadLocal(deployment); } catch (e) { if (isRemovedTeamKeys(e)) throw e; }
|
|
92
95
|
const ws = obj(meta.workspace) ? meta.workspace : {};
|
|
93
96
|
const soulDir = instanceSoulDir(realHome, meta) ?? null;
|
|
94
97
|
const { definition, problems } = readSoulYaml(soulDir);
|
|
@@ -111,13 +114,13 @@ export async function homeTarget(home, meta, { remoteOptions, discover = true, l
|
|
|
111
114
|
if (external === null && (discovery.members || []).some((m) => m.key === ws.soul?.repoKey)) external = false;
|
|
112
115
|
}
|
|
113
116
|
const slots = Object.fromEntries(LAYERS.map((l) => [l, Object.keys(modules).find((n) => manifests[n]?.layer === l) ?? null]));
|
|
114
|
-
// The home's soul key
|
|
115
|
-
const teamKey = ws.soul?.package
|
|
117
|
+
// The home's soul key in the workspace's souls: (teamKeyOf): <package>/<soul>, or <member>/<soul>.
|
|
118
|
+
const teamKey = ws.soul?.package ? teamKeyOf({ ...ws.soul, package: ws.soul.package.id }) : teamKeyOf({ name: meta.agent, repoKey: ws.soul?.repoKey ?? "" });
|
|
116
119
|
let live, model = null;
|
|
117
120
|
if (discovery) {
|
|
118
121
|
model = teamModel(discovery.standalone === true ? null : discovery.workspace, local, { workspaceKey: discovery.key ?? null });
|
|
119
122
|
try { const t = soulTeams(model, teamKey); live = { teams: reportRows(t.teams), defaultTeam: t.defaultTeam, source: "live" }; }
|
|
120
|
-
catch (e) { if (!
|
|
123
|
+
catch (e) { if (!isTeamRefusal(e)) throw e; live = { ...recordedTeams(meta), source: "recorded" }; }
|
|
121
124
|
} else if (readLive) live = await liveTeams(realHome, meta, { remoteOptions });
|
|
122
125
|
else live = { ...recordedTeams(meta), source: "recorded" };
|
|
123
126
|
// Feature launch-preference: the recorded launch (frozen), and what --reselect-launch would choose now —
|
|
@@ -147,7 +150,7 @@ export async function soulTarget(contextDir, soul, { remoteOptions } = {}) {
|
|
|
147
150
|
let prepared = null, resolutionError = null;
|
|
148
151
|
try { prepared = await prepareInstance(contextDir, soul, { remoteOptions }); }
|
|
149
152
|
catch (e) {
|
|
150
|
-
if (!
|
|
153
|
+
if (!isResolutionFact(e)) throw e;
|
|
151
154
|
resolutionError = { code: e.code, message: e.message, details: e.details ?? null };
|
|
152
155
|
}
|
|
153
156
|
const found = loadLocal(contextDir);
|
|
@@ -160,11 +163,11 @@ export async function soulTarget(contextDir, soul, { remoteOptions } = {}) {
|
|
|
160
163
|
}
|
|
161
164
|
const res = prepared?.resolution;
|
|
162
165
|
// A refused resolution still names the soul's teams when they resolve (a package refusal, …); a
|
|
163
|
-
// refusal of the teams themselves (
|
|
166
|
+
// refusal of the teams themselves (isTeamRefusal) names none.
|
|
164
167
|
const model = teamModel(discovery?.standalone === true ? null : discovery?.workspace ?? null, prepared?.local ?? found.local, { workspaceKey: discovery?.key ?? null });
|
|
165
|
-
const teamKey =
|
|
168
|
+
const teamKey = teamKeyOf(soulEntry);
|
|
166
169
|
let teams = res?.teams ?? null, defaultTeam = res ? res.defaultTeam ?? null : null;
|
|
167
|
-
if (!res) { try { const t = soulTeams(model, teamKey); teams = reportRows(t.teams); defaultTeam = t.defaultTeam; } catch (e) { if (!
|
|
170
|
+
if (!res) { try { const t = soulTeams(model, teamKey); teams = reportRows(t.teams); defaultTeam = t.defaultTeam; } catch (e) { if (!isTeamRefusal(e)) throw e; } }
|
|
168
171
|
// The soul being asked about is materialised as a spawn would (ensureWorkspaceSoul: idempotent per
|
|
169
172
|
// commit, never touching a directory an instance links), so its checks always get OATS_SOUL; only a
|
|
170
173
|
// spawn wrote the per-commit copy before, and a provider asked about a soul not yet spawned at this
|
|
@@ -181,7 +184,7 @@ export async function soulTarget(contextDir, soul, { remoteOptions } = {}) {
|
|
|
181
184
|
// Feature launch-preference: what a spawn with no flags would launch here.
|
|
182
185
|
let launchConfigs = {};
|
|
183
186
|
try { launchConfigs = launchConfigsAt(deployment); } catch { launchConfigs = {}; }
|
|
184
|
-
const launch = launchReportFor({ layers: prepared?.launch ?? launchLayers({ definition: soulEntry.definition, entry: soulEntry, key:
|
|
187
|
+
const launch = launchReportFor({ layers: prepared?.launch ?? launchLayers({ definition: soulEntry.definition, entry: soulEntry, key: soulKeyOf(soulEntry), local: found.local }), launchConfigs, contextDir: deployment });
|
|
185
188
|
return {
|
|
186
189
|
kind: "soul", home: null, meta: null, deployment, agentsRoot: join(deployment, "agents"), teams, defaultTeam, teamsSource: "live", teamModel: model, teamKey, launch, session: newSessionDefaults(deployment),
|
|
187
190
|
subject: { kind: "soul", soul: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null },
|
|
@@ -281,7 +284,7 @@ export function inspectDocument(t, { kernel }) {
|
|
|
281
284
|
* `required: false`. A home also compares its spawn-time default with the live one. */
|
|
282
285
|
function teamItems(t) {
|
|
283
286
|
if (!t.teamModel) return [];
|
|
284
|
-
const problems = teamProblems(t.teamModel, { key: t.teamKey, messaging: !!t.slots?.messaging });
|
|
287
|
+
const problems = teamProblems(t.teamModel, { key: t.teamKey, messaging: !!t.slots?.messaging, soulKeys: t.discovery ? discoveredTeamKeys(t.discovery) : null });
|
|
285
288
|
const same = (a, b) => (a?.label ?? null) === (b?.label ?? null) && (a?.team ?? null) === (b?.team ?? null);
|
|
286
289
|
if (t.home && t.teamsSource === "live" && t.meta?.defaultTeam !== undefined && !same(t.recordedDefaultTeam, t.defaultTeam)) {
|
|
287
290
|
problems.push({ code: "default-team-changed", severity: "warning", recorded: t.recordedDefaultTeam, current: t.defaultTeam,
|
|
@@ -408,7 +411,7 @@ export const PROVIDER_CHECK_BUDGET_MS = 60_000;
|
|
|
408
411
|
export async function readinessDocument(t, { selector = null, remoteOptions, catalog = null, budgetMs = PROVIDER_CHECK_BUDGET_MS } = {}) {
|
|
409
412
|
const installed = [], configured = [], member = [], providers = [];
|
|
410
413
|
const cap = (id) => ({ capability: { id } });
|
|
411
|
-
if (t.resolutionError && !
|
|
414
|
+
if (t.resolutionError && !isTeamRefusal(t.resolutionError)) {
|
|
412
415
|
installed.push(item(t.soul.name, "fail", { producer: "workspace resolution", code: t.resolutionError.code, reason: t.resolutionError.message, evidence: t.resolutionError.details, remedy: "oats sync (the lock must provide every package capability the soul resolves)" }));
|
|
413
416
|
}
|
|
414
417
|
if (t.soul.copyError) {
|
|
@@ -21,7 +21,7 @@ import { join, resolve as resolvePath, dirname, relative, isAbsolute, sep } from
|
|
|
21
21
|
import { oatsError } from "./errors.mjs";
|
|
22
22
|
import { loadLocal, discoverWorkspace, discoverRepo, standaloneRepo, observeWorkspace } from "./workspace.mjs";
|
|
23
23
|
import { resolveSoul, kernelCompatibility } from "./resolve.mjs";
|
|
24
|
-
import { BY_TEAM_REMOVED, recordedTeams, reportRows, soulKeyOf, soulTeams, teamModel } from "./teams.mjs";
|
|
24
|
+
import { BY_TEAM_REMOVED, isRemovedTeamKeys, isTeamRefusal, memberNameOf, recordedTeams, reportRows, soulKeyOf, soulTeams, teamKeyOf, teamModel } from "./teams.mjs";
|
|
25
25
|
import { launchLayers } from "./launch-preference.mjs";
|
|
26
26
|
import { declaredSettings } from "./capability-contract.mjs";
|
|
27
27
|
import { materialize, moduleSkills, MODULES_DIR, SKILLS_DIR } from "./materialize.mjs";
|
|
@@ -159,6 +159,8 @@ export function disabledEntry(local, soulEntry) {
|
|
|
159
159
|
* host; a standalone deployment has local teams only and reads none.
|
|
160
160
|
* → { teams, defaultTeam, source: "live" } — or, when that cannot answer (the host is unreachable, the
|
|
161
161
|
* deployment or its teams are unreadable), the spawn-time record: { teams, defaultTeam, source: "recorded", reason }.
|
|
162
|
+
* An oats-local.yaml still carrying the removed team keys (isRemovedTeamKeys) is thrown, never answered
|
|
163
|
+
* from the record: the deployment must migrate before its homes act on teams again.
|
|
162
164
|
* `source` travels as OATS_TEAMS_SOURCE: a provider leaves a joined team only on a `live` answer.
|
|
163
165
|
*/
|
|
164
166
|
export async function liveTeams(home, meta, { remoteOptions, remote } = {}) {
|
|
@@ -166,7 +168,7 @@ export async function liveTeams(home, meta, { remoteOptions, remote } = {}) {
|
|
|
166
168
|
const soul = meta?.workspace?.soul;
|
|
167
169
|
// A home without a workspace soul record has nothing to resolve.
|
|
168
170
|
if (!soul || typeof meta.agent !== "string") return recorded("no-workspace-soul");
|
|
169
|
-
const key = soul.package
|
|
171
|
+
const key = soul.package ? teamKeyOf({ ...soul, package: soul.package.id }) : teamKeyOf({ name: meta.agent, repoKey: soul.repoKey ?? "" });
|
|
170
172
|
const deployment = dirname(dirname(dirname(dirname(resolvePath(home)))));
|
|
171
173
|
try {
|
|
172
174
|
const found = loadLocal(deployment);
|
|
@@ -175,7 +177,10 @@ export async function liveTeams(home, meta, { remoteOptions, remote } = {}) {
|
|
|
175
177
|
const wsObs = standalone ? null : await observeWorkspace(found.local.workspace, { remoteOptions, remote });
|
|
176
178
|
const t = soulTeams(teamModel(wsObs?.workspace ?? null, found.local, { workspaceKey: wsObs?.key ?? null }), key);
|
|
177
179
|
return { teams: reportRows(t.teams), defaultTeam: t.defaultTeam, source: "live" };
|
|
178
|
-
} catch (e) {
|
|
180
|
+
} catch (e) {
|
|
181
|
+
if (isRemovedTeamKeys(e)) throw e;
|
|
182
|
+
return recorded(isTeamRefusal(e) ? "invalid-teams" : "unreadable", { code: e?.code || "E_REMOTE_UNREADABLE", message: e?.message ?? String(e) });
|
|
183
|
+
}
|
|
179
184
|
}
|
|
180
185
|
|
|
181
186
|
/** The per-commit soul cache under an agent directory: `<agentDir>/souls/<commit12>/`.
|
|
@@ -314,9 +319,7 @@ export async function ensureWorkspaceSoul(prepared, agentsRoot) {
|
|
|
314
319
|
/** The taught member name of a repo key: the last path segment without `.git`
|
|
315
320
|
* (`github.com/northwind/platform` → `platform`). The same rule `oats onboard`
|
|
316
321
|
* and `oats sync` print (memberLabel). */
|
|
317
|
-
export
|
|
318
|
-
return String(key).split("/").filter(Boolean).pop()?.replace(/\.git$/i, "") || String(key);
|
|
319
|
-
}
|
|
322
|
+
export { memberNameOf };
|
|
320
323
|
|
|
321
324
|
/** The convention path of a member's clone: `<deployment>/<member name>` — except a
|
|
322
325
|
* member called `agents`, which is cloned as `agents-repo/` because `<deployment>/agents/`
|
package/lib/resolve.mjs
CHANGED
|
@@ -57,7 +57,7 @@ import { readFileSync } from "node:fs";
|
|
|
57
57
|
import { posix } from "node:path";
|
|
58
58
|
import { oatsError as baseOatsError } from "./errors.mjs";
|
|
59
59
|
import * as defaultRemote from "./remote.mjs";
|
|
60
|
-
import { BY_TEAM_REMOVED, reportRows,
|
|
60
|
+
import { BY_TEAM_REMOVED, reportRows, soulTeams, teamKeyOf, teamModel } from "./teams.mjs";
|
|
61
61
|
import { bindRemote, packageProviding, readPackageManifests, validateLock } from "./packages.mjs";
|
|
62
62
|
import { settingValueProblems } from "./capability-contract.mjs";
|
|
63
63
|
import { memberRowByKey } from "./workspace.mjs";
|
|
@@ -617,9 +617,10 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
|
|
|
617
617
|
assertSoulDiscovered(discovery, soulEntry);
|
|
618
618
|
const definition = isObject(soulEntry.definition) ? soulEntry.definition : {};
|
|
619
619
|
const soul = { name: soulEntry.name, repoKey: soulEntry.repoKey, commit: soulEntry.commit ?? null, path: soulEntry.path ?? null };
|
|
620
|
-
// Team model
|
|
621
|
-
//
|
|
622
|
-
|
|
620
|
+
// Team model 3: the soul's teams HERE (the workspace's teams, defaultTeam and souls:, and the local teams
|
|
621
|
+
// it allows). An undeclared local default (E_TEAM_UNKNOWN) or local teams the workspace does not allow
|
|
622
|
+
// (E_WORKSPACE_SCHEMA local-teams-closed) refuse the soul.
|
|
623
|
+
const soulTeamsHere = soulTeams(teamModel(discovery?.standalone === true ? null : workspace, local, { workspaceKey: discovery?.key ?? null }), teamKeyOf(soulEntry));
|
|
623
624
|
|
|
624
625
|
// Standalone: the soul's own repo only, workspace defaults unknown (decision 10).
|
|
625
626
|
// What the soul turned off (feature desktop-facts): its own `off` over a lower layer, and a `<slot>: none`
|
package/lib/schedule.mjs
CHANGED
|
@@ -516,7 +516,7 @@ export function childEnv() {
|
|
|
516
516
|
for (const key of [...RESERVED_LAUNCH_ENV,
|
|
517
517
|
"OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
|
|
518
518
|
"OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
|
|
519
|
-
"OATS_HARNESS", "OATS_PREVIOUS_HARNESS", "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
|
|
519
|
+
"OATS_HARNESS", "OATS_PREVIOUS_HARNESS", "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_LAUNCH_PREVIEW", "OATS_RETIRE_INTENT",
|
|
520
520
|
"OATS_TEAM_NAME", "OATS_TEAM_SCOPE", "OATS_TEAM_ID", "OATS_TEAM_LABEL", "OATS_TEAM_LABELS", "OATS_DEFAULT_TEAM", "OATS_DEFAULT_TEAM_ID", "OATS_DEFAULT_TEAM_FROM", "OATS_TEAMS", "OATS_TEAMS_SOURCE", "OATS_TRIGGER_EVENT_FILE",
|
|
521
521
|
]) delete env[key];
|
|
522
522
|
return env;
|
package/lib/teams-verbs.mjs
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The team model
|
|
3
|
-
* never call a provider. Each reads the deployment's oats-local.yaml and the committed
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The team model 3 verbs (docs/desktop-cli-api.md, `oats teams`, `oats soul teams`): CONFIG ONLY — they
|
|
3
|
+
* never call a provider. Each reads the deployment's oats-local.yaml and the committed workspace file. The
|
|
4
|
+
* mutations (`oats teams add|remove|default`) edit the deployment's LOCAL teams and default, so they run only
|
|
5
|
+
* where the workspace allows local teams (`localTeams: true`, or the standalone view); they rewrite
|
|
6
|
+
* oats-local.yaml in place (comments and every other key kept), after validating the result: the local
|
|
7
|
+
* schema, and no label reference the write would leave unknown. A soul's teams are the workspace's
|
|
8
|
+
* `souls:`, edited by a PR to that file: `oats soul teams` only reads them.
|
|
6
9
|
*/
|
|
7
10
|
import { readFileSync } from "node:fs";
|
|
8
11
|
import YAML from "yaml";
|
|
9
12
|
import { parseConfigData } from "./config-data.mjs";
|
|
10
13
|
import { oatsError } from "./errors.mjs";
|
|
11
14
|
import { validateLocal } from "./workspace.mjs";
|
|
12
|
-
import {
|
|
15
|
+
import { TEAM_ID_RE, localTeamsClosed, soulTeams, teamModel, teamProblems, teamReferences } from "./teams.mjs";
|
|
13
16
|
import { writeFileAtomic } from "./packages.mjs";
|
|
14
17
|
|
|
15
|
-
export const TEAMS_API =
|
|
18
|
+
export const TEAMS_API = 2, SOUL_TEAMS_API = 2;
|
|
16
19
|
const LABEL_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
17
20
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
18
21
|
const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
@@ -21,44 +24,46 @@ function fail(code, message, details) { const e = oatsError(code, message, detai
|
|
|
21
24
|
/** Whether a messaging layer is active in this deployment: the workspace fills the messaging slot. */
|
|
22
25
|
const messagingActive = (workspace) => isObject(workspace?.defaults?.messaging);
|
|
23
26
|
|
|
24
|
-
/**
|
|
27
|
+
/** The default a soul without a `souls:` default gets here: the local one (where local teams are allowed),
|
|
28
|
+
* else the workspace's → DefaultTeam | null. */
|
|
29
|
+
function deploymentDefault(model) {
|
|
30
|
+
const [label, from] = model.localDefault !== null && model.labels.has(model.localDefault) ? [model.localDefault, "deployment"]
|
|
31
|
+
: model.workspaceDefault !== null ? [model.workspaceDefault, "workspace"] : [null, null];
|
|
32
|
+
return label === null || !model.labels.has(label) ? null : { label, team: model.labels.get(label).team, from };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `oats teams --json`. `ctx`: { deployment, local, workspace (the committed file, or null: standalone), workspaceKey,
|
|
36
|
+
* soulKeys (the discovered souls' keys, for the `team-soul-unknown` typo guard; null when not known) }. */
|
|
25
37
|
export function teamsDocument(ctx) {
|
|
26
38
|
const model = teamModel(ctx.workspace, ctx.local, { workspaceKey: ctx.workspaceKey });
|
|
39
|
+
const def = deploymentDefault(model);
|
|
27
40
|
const teams = [...model.labels.values()].sort((a, b) => byCodepoint(a.label, b.label))
|
|
28
|
-
.map((d) => ({ label: d.label, team: d.team, description: d.description, from: d.from, default: d.label ===
|
|
41
|
+
.map((d) => ({ label: d.label, team: d.team, description: d.description, from: d.from, default: d.label === def?.label, at: d.at }));
|
|
29
42
|
return {
|
|
30
|
-
teamsApi: TEAMS_API, deployment: ctx.deployment,
|
|
31
|
-
souls:
|
|
32
|
-
problems: teamProblems(model, { messaging: messagingActive(ctx.workspace) }),
|
|
43
|
+
teamsApi: TEAMS_API, deployment: ctx.deployment, localTeams: model.localTeams, defaultTeam: def, teams,
|
|
44
|
+
souls: structuredClone(model.souls),
|
|
45
|
+
problems: teamProblems(model, { messaging: messagingActive(ctx.workspace), soulKeys: ctx.soulKeys ?? null }),
|
|
33
46
|
};
|
|
34
47
|
}
|
|
35
48
|
|
|
36
|
-
/** `oats soul teams <soul>|'*' --json`: `key` is the soul's key ("*" for
|
|
49
|
+
/** `oats soul teams <soul>|'*' --json`: `key` is the soul's key (teamKeyOf; "*" for the "*" pattern), `soul` its name. */
|
|
37
50
|
export function soulTeamsDocument(ctx, { soul, key }) {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
const list = (v) => (Array.isArray(v) ? [...v] : []);
|
|
41
|
-
return {
|
|
42
|
-
soulTeamsApi: SOUL_TEAMS_API, soul, key, defaultTeam: t.defaultTeam, teams: t.teams,
|
|
43
|
-
local: { teams: list(model.souls.teams[key]), default: key === "*" ? null : model.souls.default[key] ?? null },
|
|
44
|
-
all: list(model.souls.teams["*"]),
|
|
45
|
-
};
|
|
51
|
+
const t = soulTeams(teamModel(ctx.workspace, ctx.local, { workspaceKey: ctx.workspaceKey }), key);
|
|
52
|
+
return { soulTeamsApi: SOUL_TEAMS_API, soul, key, match: t.match, defaultMatch: t.defaultMatch, defaultTeam: t.defaultTeam, teams: t.teams };
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
/* ───────────────────────────── writing oats-local.yaml ─────────────────── */
|
|
49
56
|
|
|
50
57
|
const clone = (v) => (v === undefined ? undefined : structuredClone(v));
|
|
51
|
-
/** The
|
|
58
|
+
/** The two team keys of a local value, as plain data. */
|
|
52
59
|
const teamState = (local) => ({
|
|
53
60
|
teams: clone(isObject(local?.teams) ? local.teams : {}),
|
|
54
61
|
defaultTeam: typeof local?.defaultTeam === "string" ? local.defaultTeam : null,
|
|
55
|
-
soulsTeams: clone(isObject(local?.souls?.teams) ? local.souls.teams : {}),
|
|
56
|
-
soulsDefault: clone(isObject(local?.souls?.default) ? local.souls.default : {}),
|
|
57
62
|
});
|
|
58
63
|
const modelOf = (ctx, local) => teamModel(ctx.workspace, local, { workspaceKey: ctx.workspaceKey });
|
|
59
|
-
/** The refusals a state carries (unknown
|
|
64
|
+
/** The refusals a state carries (unknown references), keyed so a write can tell new ones. */
|
|
60
65
|
const refusals = (ctx, local) => new Map(teamProblems(modelOf(ctx, local))
|
|
61
|
-
.filter((p) => p.code === "E_TEAM_UNKNOWN"
|
|
66
|
+
.filter((p) => p.code === "E_TEAM_UNKNOWN").map((p) => [`${p.code} ${p.at}`, p]));
|
|
62
67
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
63
68
|
const readLocal = (ctx) => {
|
|
64
69
|
const text = readFileSync(ctx.localPath, "utf8");
|
|
@@ -69,8 +74,7 @@ const readLocal = (ctx) => {
|
|
|
69
74
|
|
|
70
75
|
/**
|
|
71
76
|
* Edit `doc` from `before` to `next` SURGICALLY: only the entries that change are touched, so comments and
|
|
72
|
-
* styles on everything else (inline comments on sibling entries
|
|
73
|
-
* left empty is removed, and so is an empty `souls`.
|
|
77
|
+
* styles on everything else (inline comments on sibling entries) are kept. A map left empty is removed.
|
|
74
78
|
*/
|
|
75
79
|
function applyState(doc, before, next) {
|
|
76
80
|
const map = (path, from, to, entry) => {
|
|
@@ -86,16 +90,6 @@ function applyState(doc, before, next) {
|
|
|
86
90
|
for (const [k, v] of Object.entries(now)) if (!same(was[k], v)) doc.setIn([...path, k], v);
|
|
87
91
|
});
|
|
88
92
|
if (next.defaultTeam !== before.defaultTeam) { if (next.defaultTeam === null) doc.deleteIn(["defaultTeam"]); else doc.setIn(["defaultTeam"], next.defaultTeam); }
|
|
89
|
-
// A soul's list, item by item (its style — flow or block — and the other items' comments stay).
|
|
90
|
-
map(["souls", "teams"], before.soulsTeams, next.soulsTeams, (path, was, now) => {
|
|
91
|
-
const seq = doc.getIn(path, true);
|
|
92
|
-
if (!Array.isArray(was) || !YAML.isSeq(seq)) { doc.setIn(path, now); return; }
|
|
93
|
-
seq.items = seq.items.filter((item) => now.includes(YAML.isScalar(item) ? item.value : item));
|
|
94
|
-
for (const label of now) if (!was.includes(label)) seq.items.push(doc.createNode(label));
|
|
95
|
-
});
|
|
96
|
-
map(["souls", "default"], before.soulsDefault, next.soulsDefault, (path, _was, now) => doc.setIn(path, now));
|
|
97
|
-
const souls = doc.get("souls");
|
|
98
|
-
if (YAML.isMap(souls) && souls.items.length === 0) doc.delete("souls");
|
|
99
93
|
}
|
|
100
94
|
|
|
101
95
|
/**
|
|
@@ -103,14 +97,18 @@ function applyState(doc, before, next) {
|
|
|
103
97
|
* refusal; both come from the file AS IT IS NOW (re-read here, never the command's startup snapshot), so
|
|
104
98
|
* two verbs running together cannot drop each other's change: the write is compare-and-swap — when the
|
|
105
99
|
* file changed after this read, the edit is redone on the new content (a few times, then E_LOCAL_CHANGED).
|
|
106
|
-
* `
|
|
100
|
+
* `verb` names the command and `writes` the keys it adds, for the refusal where the workspace does not
|
|
101
|
+
* allow local teams (local-teams-closed); a verb that only takes local teams away (`writes: null`) runs
|
|
102
|
+
* there too, since it moves the file toward what the workspace allows. → { changed, local } (the value now).
|
|
107
103
|
*/
|
|
108
|
-
function editLocal(ctx,
|
|
104
|
+
function editLocal(ctx, { verb, writes }, mutate) {
|
|
109
105
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
110
106
|
const { text, doc, local: current } = readLocal(ctx);
|
|
107
|
+
const model = modelOf(ctx, current);
|
|
108
|
+
if (model.localTeams === false && writes !== null) throw localTeamsClosed(writes, { verb });
|
|
111
109
|
const before = teamState(current);
|
|
112
110
|
const next = teamState(current);
|
|
113
|
-
mutate(next,
|
|
111
|
+
mutate(next, model);
|
|
114
112
|
if (same(next, before)) return { changed: false, local: current };
|
|
115
113
|
applyState(doc, before, next);
|
|
116
114
|
const out = doc.toString();
|
|
@@ -119,7 +117,6 @@ function editLocal(ctx, mutate, { check } = {}) {
|
|
|
119
117
|
if (problems.length) throw fail("E_WORKSPACE_SCHEMA", `the rewritten oats-local.yaml would be invalid (${problems.map((p) => `${p.path || "/"}: ${p.message}`).join("; ")}); nothing was written`, { problems });
|
|
120
118
|
const had = refusals(ctx, current);
|
|
121
119
|
for (const [k, p] of refusals(ctx, local)) if (!had.has(k)) throw fail(p.code, `${p.message}; nothing was written`, Object.fromEntries(Object.entries(p).filter(([key]) => !["code", "severity", "message", "fix"].includes(key))));
|
|
122
|
-
if (check) check(local);
|
|
123
120
|
if (readFileSync(ctx.localPath, "utf8") !== text) continue; // changed under us: redo on the new content
|
|
124
121
|
writeFileAtomic(ctx.localPath, out);
|
|
125
122
|
return { changed: true, local };
|
|
@@ -136,7 +133,7 @@ export function teamsAdd(ctx, label, { team, description } = {}) {
|
|
|
136
133
|
assertLabel(label);
|
|
137
134
|
if (typeof team !== "string" || !team.trim()) throw fail("E_BAD_ARGS", "oats teams add needs --team <provider team id>", { label });
|
|
138
135
|
if (!TEAM_ID_RE.test(team.trim())) throw fail("E_BAD_ARGS", `${JSON.stringify(team)} is not a team id: it must match ${TEAM_ID_RE.source} (a letter or digit first; no whitespace or control characters; at most 256 characters)`, { label, team });
|
|
139
|
-
return editLocal(ctx, (s, model) => {
|
|
136
|
+
return editLocal(ctx, { verb: "oats teams add", writes: ["teams"] }, (s, model) => {
|
|
140
137
|
if (model.labels.has(label)) {
|
|
141
138
|
const from = model.labels.get(label).from;
|
|
142
139
|
throw fail("E_TEAM_EXISTS", `team ${label} is already declared (${from === "shared" ? "shared, in oats-workspace.yaml" : "local, in oats-local.yaml"})${from === "local" ? ": remove it first (`oats teams remove`) to redefine it" : ""}`, { label, from });
|
|
@@ -147,9 +144,10 @@ export function teamsAdd(ctx, label, { team, description } = {}) {
|
|
|
147
144
|
});
|
|
148
145
|
}
|
|
149
146
|
|
|
150
|
-
/** `oats teams remove <label>`: a LOCAL team nothing references (no cascade).
|
|
147
|
+
/** `oats teams remove <label>`: a LOCAL team nothing references (no cascade). Allowed where the workspace
|
|
148
|
+
* does not allow local teams: removing them is the last step of committing them there. */
|
|
151
149
|
export function teamsRemove(ctx, label) {
|
|
152
|
-
return editLocal(ctx, (s, model) => {
|
|
150
|
+
return editLocal(ctx, { verb: "oats teams remove", writes: null }, (s, model) => {
|
|
153
151
|
if (!model.local.has(label)) {
|
|
154
152
|
if (model.shared.has(label)) throw fail("E_TEAM_SHARED", `team ${label} is shared (declared in oats-workspace.yaml): it is edited by a PR to that file, never removed here`, { label, at: model.shared.get(label).at });
|
|
155
153
|
throw fail("E_TEAM_UNKNOWN", `team ${JSON.stringify(label)} is not declared`, { label });
|
|
@@ -157,39 +155,15 @@ export function teamsRemove(ctx, label) {
|
|
|
157
155
|
// A label also declared in the committed file still resolves after the local copy goes (the
|
|
158
156
|
// collision's fix): its references stay valid, so only a purely local label must be unreferenced.
|
|
159
157
|
const usedBy = model.shared.has(label) ? [] : teamReferences(model, label);
|
|
160
|
-
if (usedBy.length) throw fail("E_TEAM_IN_USE", `team ${label} is still referenced (${usedBy.join(", ")}):
|
|
158
|
+
if (usedBy.length) throw fail("E_TEAM_IN_USE", `team ${label} is still referenced (${usedBy.join(", ")}): choose another default first (\`oats teams default\`)`, { label, usedBy });
|
|
161
159
|
delete s.teams[label];
|
|
162
160
|
});
|
|
163
161
|
}
|
|
164
162
|
|
|
165
163
|
/** `oats teams default <label>`: a label of either file. */
|
|
166
164
|
export function teamsDefault(ctx, label) {
|
|
167
|
-
return editLocal(ctx, (s, model) => {
|
|
165
|
+
return editLocal(ctx, { verb: "oats teams default", writes: ["defaultTeam"] }, (s, model) => {
|
|
168
166
|
if (!model.labels.has(label)) throw fail("E_TEAM_UNKNOWN", `team ${JSON.stringify(label)} is not declared: \`oats teams add\` it first`, { label });
|
|
169
167
|
s.defaultTeam = label;
|
|
170
168
|
});
|
|
171
169
|
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* `oats soul teams <soul>|'*' [--add a,b] [--remove a,b] [--default <l> | --clear-default]`.
|
|
175
|
-
* `key` is the soul's key ("*" for every soul). → { changed, local }.
|
|
176
|
-
*/
|
|
177
|
-
export function soulTeamsEdit(ctx, key, { add = [], remove = [], setDefault = null, clearDefault = false } = {}) {
|
|
178
|
-
if (key === "*" && (setDefault !== null || clearDefault)) throw fail("E_BAD_ARGS", "a default is per soul: --default / --clear-default need a soul, not '*' (the deployment's default is `oats teams default`)", { soul: key });
|
|
179
|
-
if (setDefault !== null && clearDefault) throw fail("E_BAD_ARGS", "choose --default <label> or --clear-default, not both");
|
|
180
|
-
// --default must name one of the soul's teams after the write.
|
|
181
|
-
const check = setDefault === null ? undefined : (local) => {
|
|
182
|
-
try { soulTeams(modelOf(ctx, local), key); }
|
|
183
|
-
catch (e) { if (e.code === "E_TEAM_NOT_ELIGIBLE") throw fail("E_TEAM_NOT_ELIGIBLE", `${e.message}; nothing was written`, { soul: key, label: setDefault, at: `${LOCAL_FILE}#/souls/default/${pointerKey(key)}` }); throw e; }
|
|
184
|
-
};
|
|
185
|
-
return editLocal(ctx, (s, model) => {
|
|
186
|
-
for (const label of [...add, ...remove, ...(setDefault !== null ? [setDefault] : [])]) {
|
|
187
|
-
if (!model.labels.has(label)) throw fail("E_TEAM_UNKNOWN", `team ${JSON.stringify(label)} is not declared: \`oats teams add\` it first`, { label });
|
|
188
|
-
}
|
|
189
|
-
const list = Array.isArray(s.soulsTeams[key]) ? s.soulsTeams[key] : [];
|
|
190
|
-
const next = [...list.filter((l) => !remove.includes(l)), ...add.filter((l) => !list.includes(l) && !remove.includes(l))].filter((l, i, a) => a.indexOf(l) === i);
|
|
191
|
-
if (next.length) s.soulsTeams[key] = next; else delete s.soulsTeams[key];
|
|
192
|
-
if (clearDefault) delete s.soulsDefault[key];
|
|
193
|
-
if (setDefault !== null) s.soulsDefault[key] = setDefault;
|
|
194
|
-
}, { check });
|
|
195
|
-
}
|