team 0.0.1 → 0.1.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/LICENSE +21 -0
- package/README.md +310 -0
- package/dist/approve/approval.d.ts +38 -0
- package/dist/approve/approval.js +78 -0
- package/dist/approve/diff.d.ts +13 -0
- package/dist/approve/diff.js +47 -0
- package/dist/approve/fingerprint.d.ts +44 -0
- package/dist/approve/fingerprint.js +109 -0
- package/dist/args.d.ts +7 -0
- package/dist/args.js +22 -0
- package/dist/budgets/checks.d.ts +35 -0
- package/dist/budgets/checks.js +77 -0
- package/dist/budgets/gate.d.ts +20 -0
- package/dist/budgets/gate.js +96 -0
- package/dist/budgets/readings.d.ts +47 -0
- package/dist/budgets/readings.js +119 -0
- package/dist/budgets/run.d.ts +66 -0
- package/dist/budgets/run.js +119 -0
- package/dist/budgets/table.d.ts +24 -0
- package/dist/budgets/table.js +90 -0
- package/dist/caller.d.ts +48 -0
- package/dist/caller.js +126 -0
- package/dist/check/config.d.ts +51 -0
- package/dist/check/config.js +50 -0
- package/dist/check/git.d.ts +27 -0
- package/dist/check/git.js +97 -0
- package/dist/check/message.d.ts +27 -0
- package/dist/check/message.js +101 -0
- package/dist/check/run.d.ts +31 -0
- package/dist/check/run.js +77 -0
- package/dist/check/signature.d.ts +10 -0
- package/dist/check/signature.js +22 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +76 -0
- package/dist/clis.d.ts +7 -0
- package/dist/clis.js +9 -0
- package/dist/commands/add.d.ts +23 -0
- package/dist/commands/add.js +445 -0
- package/dist/commands/approve.d.ts +11 -0
- package/dist/commands/approve.js +146 -0
- package/dist/commands/check.d.ts +17 -0
- package/dist/commands/check.js +103 -0
- package/dist/commands/doctor.d.ts +38 -0
- package/dist/commands/doctor.js +232 -0
- package/dist/commands/down.d.ts +37 -0
- package/dist/commands/down.js +258 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +138 -0
- package/dist/commands/remove.d.ts +23 -0
- package/dist/commands/remove.js +235 -0
- package/dist/commands/status.d.ts +41 -0
- package/dist/commands/status.js +147 -0
- package/dist/commands/up.d.ts +49 -0
- package/dist/commands/up.js +361 -0
- package/dist/commands/watch.d.ts +35 -0
- package/dist/commands/watch.js +302 -0
- package/dist/commands/worktree.d.ts +12 -0
- package/dist/commands/worktree.js +388 -0
- package/dist/end/condition.d.ts +28 -0
- package/dist/end/condition.js +77 -0
- package/dist/file/current.d.ts +13 -0
- package/dist/file/current.js +53 -0
- package/dist/file/lines.d.ts +25 -0
- package/dist/file/lines.js +277 -0
- package/dist/file/load.d.ts +7 -0
- package/dist/file/load.js +90 -0
- package/dist/file/paths.d.ts +6 -0
- package/dist/file/paths.js +77 -0
- package/dist/file/secrets.d.ts +6 -0
- package/dist/file/secrets.js +54 -0
- package/dist/file/signature.d.ts +13 -0
- package/dist/file/signature.js +36 -0
- package/dist/file/types.d.ts +130 -0
- package/dist/file/types.js +1 -0
- package/dist/file/validate.d.ts +13 -0
- package/dist/file/validate.js +702 -0
- package/dist/file/write.d.ts +11 -0
- package/dist/file/write.js +13 -0
- package/dist/herdr.d.ts +38 -0
- package/dist/herdr.js +227 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/io.d.ts +10 -0
- package/dist/io.js +1 -0
- package/dist/launch/deliver.d.ts +10 -0
- package/dist/launch/deliver.js +39 -0
- package/dist/launch/execute.d.ts +46 -0
- package/dist/launch/execute.js +284 -0
- package/dist/launch/plan.d.ts +185 -0
- package/dist/launch/plan.js +280 -0
- package/dist/launch/rules.d.ts +25 -0
- package/dist/launch/rules.js +36 -0
- package/dist/log.d.ts +1 -0
- package/dist/log.js +24 -0
- package/dist/profiles/antigravity.yaml +66 -0
- package/dist/profiles/claude-code.yaml +69 -0
- package/dist/profiles/codex.yaml +77 -0
- package/dist/profiles/cursor.yaml +68 -0
- package/dist/profiles/index.d.ts +1 -0
- package/dist/profiles/index.js +1 -0
- package/dist/profiles/profile.d.ts +60 -0
- package/dist/profiles/profile.js +313 -0
- package/dist/profiles/quota.d.ts +22 -0
- package/dist/profiles/quota.js +56 -0
- package/dist/state.d.ts +59 -0
- package/dist/state.js +127 -0
- package/dist/status/compare.d.ts +26 -0
- package/dist/status/compare.js +110 -0
- package/dist/status/statusline.d.ts +9 -0
- package/dist/status/statusline.js +26 -0
- package/dist/store/store.d.ts +62 -0
- package/dist/store/store.js +116 -0
- package/dist/watch/check.d.ts +82 -0
- package/dist/watch/check.js +29 -0
- package/dist/watch/checks/approval.d.ts +2 -0
- package/dist/watch/checks/approval.js +16 -0
- package/dist/watch/checks/attention.d.ts +2 -0
- package/dist/watch/checks/attention.js +23 -0
- package/dist/watch/checks/budget.d.ts +2 -0
- package/dist/watch/checks/budget.js +141 -0
- package/dist/watch/checks/disk.d.ts +2 -0
- package/dist/watch/checks/disk.js +13 -0
- package/dist/watch/checks/extra.d.ts +2 -0
- package/dist/watch/checks/extra.js +18 -0
- package/dist/watch/checks/idle.d.ts +2 -0
- package/dist/watch/checks/idle.js +32 -0
- package/dist/watch/checks/load.d.ts +2 -0
- package/dist/watch/checks/load.js +11 -0
- package/dist/watch/checks/memory.d.ts +2 -0
- package/dist/watch/checks/memory.js +11 -0
- package/dist/watch/checks/missing.d.ts +2 -0
- package/dist/watch/checks/missing.js +12 -0
- package/dist/watch/checks/model-drift.d.ts +2 -0
- package/dist/watch/checks/model-drift.js +14 -0
- package/dist/watch/checks/swap-free.d.ts +2 -0
- package/dist/watch/checks/swap-free.js +12 -0
- package/dist/watch/checks/swap-growth.d.ts +2 -0
- package/dist/watch/checks/swap-growth.js +18 -0
- package/dist/watch/checks/team-idle.d.ts +2 -0
- package/dist/watch/checks/team-idle.js +18 -0
- package/dist/watch/checks/unsent.d.ts +2 -0
- package/dist/watch/checks/unsent.js +18 -0
- package/dist/watch/close.d.ts +19 -0
- package/dist/watch/close.js +40 -0
- package/dist/watch/dialect.d.ts +5 -0
- package/dist/watch/dialect.js +430 -0
- package/dist/watch/end.d.ts +11 -0
- package/dist/watch/end.js +17 -0
- package/dist/watch/machine.d.ts +38 -0
- package/dist/watch/machine.js +137 -0
- package/dist/watch/notify.d.ts +1 -0
- package/dist/watch/notify.js +15 -0
- package/dist/watch/pass.d.ts +30 -0
- package/dist/watch/pass.js +237 -0
- package/dist/watch/screen-core.d.ts +14 -0
- package/dist/watch/screen-core.js +320 -0
- package/dist/watch/screen-data.d.ts +65 -0
- package/dist/watch/screen-data.js +1 -0
- package/dist/watch/screen-file.d.ts +2 -0
- package/dist/watch/screen-file.js +269 -0
- package/dist/watch/screen.d.ts +20 -0
- package/dist/watch/screen.js +38 -0
- package/dist/worktree/place.d.ts +18 -0
- package/dist/worktree/place.js +126 -0
- package/dist/yaml.d.ts +30 -0
- package/dist/yaml.js +380 -0
- package/examples/checks/codex-quota +217 -0
- package/examples/team.yaml +76 -0
- package/package.json +29 -10
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { approvalDifferences, budgetsInForce } from "../approve/approval.js";
|
|
5
|
+
import { readArgs } from "../args.js";
|
|
6
|
+
import { callerOf, describeCaller, isOwner, mayChangeTeam } from "../caller.js";
|
|
7
|
+
import { blocksLaunch, doctorFindings, realSources as doctorSources } from "../commands/doctor.js";
|
|
8
|
+
import { seatBudget } from "../budgets/gate.js";
|
|
9
|
+
import { loadReadings } from "../budgets/readings.js";
|
|
10
|
+
import { rulesOf } from "../commands/up.js";
|
|
11
|
+
import { branchPresent, readMerge } from "../end/condition.js";
|
|
12
|
+
import { clearStopped, hasSeat, restoreSeat, seatIsStopped } from "../file/lines.js";
|
|
13
|
+
import { loadTeamFile, placedProblems } from "../file/load.js";
|
|
14
|
+
import { validateTeamFile } from "../file/validate.js";
|
|
15
|
+
import { writeTeamFile } from "../file/write.js";
|
|
16
|
+
import { agentList, agentRename, paneRead, paneRun, sessionRunning, sessionState, startServer, workspaceClose, workspaceCreate, workspaceList, } from "../herdr.js";
|
|
17
|
+
import { executePlan } from "../launch/execute.js";
|
|
18
|
+
import { formatPlan, upPlan } from "../launch/plan.js";
|
|
19
|
+
import { logLine } from "../log.js";
|
|
20
|
+
import { profileFor } from "../profiles/index.js";
|
|
21
|
+
import { emptySession, readState, updateState, withLock } from "../state.js";
|
|
22
|
+
import { approvedCopy, readApproval, recordLedger, storePath } from "../store/store.js";
|
|
23
|
+
import { launchLimit, readMachine } from "../watch/machine.js";
|
|
24
|
+
import { readScreen } from "../watch/screen.js";
|
|
25
|
+
import { seatStart } from "../worktree/place.js";
|
|
26
|
+
const realLaunch = {
|
|
27
|
+
sessionState,
|
|
28
|
+
startServer,
|
|
29
|
+
sessionUp: sessionRunning,
|
|
30
|
+
createWorkspace: (session, cwd, label) => workspaceCreate(cwd, label, aim(session)),
|
|
31
|
+
paneRun: (session, pane, command) => paneRun(pane, command, aim(session)),
|
|
32
|
+
renameAgent: (session, pane, name) => agentRename(pane, name, aim(session)),
|
|
33
|
+
closeWorkspace: (session, workspace) => workspaceClose(workspace, aim(session)),
|
|
34
|
+
agentPanes(session) {
|
|
35
|
+
const agents = agentList(aim(session));
|
|
36
|
+
return agents === null ? null : agents.map((agent) => agent.pane);
|
|
37
|
+
},
|
|
38
|
+
paneText: (session, pane) => paneRead(pane, 200, aim(session)),
|
|
39
|
+
sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
|
|
40
|
+
now: () => new Date(),
|
|
41
|
+
};
|
|
42
|
+
function aim(session) {
|
|
43
|
+
return session === 'default' ? undefined : session;
|
|
44
|
+
}
|
|
45
|
+
export const realSources = {
|
|
46
|
+
home: homedir(),
|
|
47
|
+
sessionState,
|
|
48
|
+
agents: (session) => agentList(aim(session)),
|
|
49
|
+
workspaces(session) {
|
|
50
|
+
const listed = workspaceList(aim(session));
|
|
51
|
+
return listed === null ? null : listed.map((workspace) => ({ id: workspace.id, label: workspace.label }));
|
|
52
|
+
},
|
|
53
|
+
doctor: doctorSources,
|
|
54
|
+
machine: readMachine,
|
|
55
|
+
now: () => new Date(),
|
|
56
|
+
launch: realLaunch,
|
|
57
|
+
};
|
|
58
|
+
export const USAGE = `Usage: team add <name> [--dry-run] [--session <name>] [--file <path>]
|
|
59
|
+
team add --temporary --like <seat> --until <result:path|merged:branch> [--worktree <task>] [--dry-run] [--session <name>] [--file <path>]
|
|
60
|
+
`;
|
|
61
|
+
export const add = (argv, io) => runAdd(argv, io, realSources);
|
|
62
|
+
export default add;
|
|
63
|
+
export async function runAdd(argv, io, sources = realSources) {
|
|
64
|
+
const args = readArgs(argv, ['like', 'until', 'worktree', 'session', 'file'], ['temporary', 'dry-run']);
|
|
65
|
+
if (args.error) {
|
|
66
|
+
io.stderr(`team add: ${args.error}\n${USAGE}`);
|
|
67
|
+
return 2;
|
|
68
|
+
}
|
|
69
|
+
const temporary = args.flags.has('temporary');
|
|
70
|
+
if (temporary ? args.rest.length > 0 : args.rest.length !== 1) {
|
|
71
|
+
io.stderr(`team add: ${temporary ? `unexpected "${args.rest[0]}"` : 'a seat name is required'}\n${USAGE}`);
|
|
72
|
+
return 2;
|
|
73
|
+
}
|
|
74
|
+
if (!temporary && (args.values.like || args.values.until || args.values.worktree)) {
|
|
75
|
+
io.stderr('team add: --like, --until and --worktree are for --temporary\n');
|
|
76
|
+
return 2;
|
|
77
|
+
}
|
|
78
|
+
const loaded = loadTeamFile(io.cwd, args.values.file ? { file: args.values.file } : {});
|
|
79
|
+
if (!loaded.ok) {
|
|
80
|
+
for (const problem of loaded.errors)
|
|
81
|
+
io.stderr(`team add: ${where(problem)}${problem.message}\n`);
|
|
82
|
+
return 2;
|
|
83
|
+
}
|
|
84
|
+
const caller = callerOf(io);
|
|
85
|
+
if (args.values.file && !isOwner(caller)) {
|
|
86
|
+
io.stderr(`team add: --file is the owner's, from a terminal outside herdr; this call is ${describeCaller(caller)}\n`);
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
if (!mayChangeTeam(caller, loaded.team)) {
|
|
90
|
+
io.stderr(`team add: only the owner, the coordinator or the operator runs it; this call is ${describeCaller(caller)}\n`);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
const { team, root, path } = loaded;
|
|
94
|
+
const session = args.values.session ?? team.session;
|
|
95
|
+
if (session === 'default') {
|
|
96
|
+
io.stderr('team add: session can\'t be "default", herdr\'s own session\n');
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
const differences = approvalDifferences(team, root, sources.home);
|
|
100
|
+
if (differences === null) {
|
|
101
|
+
io.stderr('team add: the file was never approved on this machine: run `team approve`\n');
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
if (differences.length) {
|
|
105
|
+
io.stderr(`team add: the file is not the approved one (${differences.join('; ')}): run \`team approve\`\n`);
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
const approvedText = approvedCopy(root, sources.home);
|
|
109
|
+
const approved = approvedText ? validateTeamFile(approvedText) : null;
|
|
110
|
+
if (!approvedText || !approved?.ok) {
|
|
111
|
+
io.stderr('team add: the approved copy can\'t be read: run `team approve`\n');
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
const dir = dirname(path);
|
|
115
|
+
const live = sources.sessionState(session);
|
|
116
|
+
if (live === null) {
|
|
117
|
+
io.stderr('team add: herdr doesn\'t answer\n');
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
if (live === 'stopped') {
|
|
121
|
+
io.stderr(`team add: session ${session} is stopped; clear it with \`herdr session delete ${session}\`\n`);
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
const agents = live === 'running' ? sources.agents(session) : [];
|
|
125
|
+
const workspaces = live === 'running' ? sources.workspaces(session) : [];
|
|
126
|
+
if (agents === null || workspaces === null) {
|
|
127
|
+
io.stderr(`team add: session ${session} runs, and its agents can't be read\n`);
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
const recorded = readState(dir).sessions[session] ?? emptySession();
|
|
131
|
+
const ceilings = readApproval(storePath(team.project, root, sources.home))?.approval.ceilings;
|
|
132
|
+
if (!ceilings) {
|
|
133
|
+
io.stderr('team add: the file was never approved on this machine: run `team approve`\n');
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
const original = readFileSync(path, 'utf8');
|
|
137
|
+
const built = temporary
|
|
138
|
+
? temporarySeat(args.values, original, approved.team, recorded, agents, root, team.workspace.base)
|
|
139
|
+
: declaredSeat(args.rest[0] ?? '', original, approvedText, approved.team);
|
|
140
|
+
if ('error' in built) {
|
|
141
|
+
io.stderr(`team add: ${built.error}\n`);
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
if (agents.some((agent) => agent.name === built.name)) {
|
|
145
|
+
io.stderr(`team add: ${built.name} is already running\n`);
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
if (!profileFor(built.seat.cli)) {
|
|
149
|
+
io.stderr(`team add: no launch profile for \`${built.seat.cli}\` in this version\n`);
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
const prepared = validateTeamFile(built.edited);
|
|
153
|
+
if (!prepared.ok) {
|
|
154
|
+
for (const problem of prepared.errors)
|
|
155
|
+
io.stderr(`team add: ${where(problem)}${problem.message}\n`);
|
|
156
|
+
return 2;
|
|
157
|
+
}
|
|
158
|
+
for (const problem of placedProblems(prepared.team, root)) {
|
|
159
|
+
io.stderr(`team add: ${problem.message}\n`);
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
// Where the seat waits: its own folder, the lobby, or a refusal — before the file is edited.
|
|
163
|
+
const start = seatStart(prepared.team, built.seat, root);
|
|
164
|
+
if ('problem' in start) {
|
|
165
|
+
io.stderr(`team add: ${start.problem}\n`);
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
const doctorTeam = built.temporary
|
|
169
|
+
? { ...prepared.team, seats: prepared.team.seats.map((item) => item.name === built.temporary?.like ? { ...item, stopped: false } : item) }
|
|
170
|
+
: prepared.team;
|
|
171
|
+
for (const finding of doctorFindings(doctorTeam, root, dir, session, sources.doctor, prepared.warnings)) {
|
|
172
|
+
if (blocksLaunch(finding)) {
|
|
173
|
+
io.stderr(`team add: ${finding.text}\n`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const samples = [];
|
|
178
|
+
const crossed = () => {
|
|
179
|
+
const machine = sources.machine?.(root);
|
|
180
|
+
return machine ? launchLimit(machine, team.machine, samples, sources.now().getTime()) : null;
|
|
181
|
+
};
|
|
182
|
+
const problem = crossed();
|
|
183
|
+
if (problem) {
|
|
184
|
+
io.stderr(`team add: ${problem}\n`);
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
const running = runningOf(agents, prepared.team, recorded);
|
|
188
|
+
const room = ceilingProblem(ceilings, running, built.seat, Boolean(built.temporary));
|
|
189
|
+
if (room) {
|
|
190
|
+
io.stderr(`team add: ${room}\n`);
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
const again = crossed();
|
|
194
|
+
if (again) {
|
|
195
|
+
io.stderr(`team add: ${again}\n`);
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
// The gate reads the approved budgets, never the edited file's: the seat this `add` inserts
|
|
199
|
+
// changes no section of its own, and no unapproved reserve may unblock a launch (#50).
|
|
200
|
+
const budgets = budgetsInForce(prepared.team, root, sources.home);
|
|
201
|
+
const decision = seatBudget(budgets, loadReadings(dir, session), built.seat, sources.now().getTime());
|
|
202
|
+
const stray = unnamedIn(built.seat.label, agents, workspaces);
|
|
203
|
+
const starting = seatPlan(prepared.team, built.seat, start);
|
|
204
|
+
const planned = stray
|
|
205
|
+
? { ...starting, stage: 'launched', pane: stray.pane, workspace: stray.workspace, agentLive: true }
|
|
206
|
+
: starting;
|
|
207
|
+
const wouldLaunch = planned.stage === undefined || !planned.pane || (planned.stage === 'launched' && !planned.agentLive);
|
|
208
|
+
const seatForPlan = decision.kind === 'refuse' && !wouldLaunch ? { ...planned, budget: decision } : planned;
|
|
209
|
+
const dry = args.flags.has('dry-run');
|
|
210
|
+
if (dry) {
|
|
211
|
+
if (decision.kind === 'refuse' && wouldLaunch) {
|
|
212
|
+
io.stdout(`${built.name}: would refuse: ${decision.why}\ndry run: nothing was run\n`);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
if (decision.kind === 'unknown')
|
|
216
|
+
io.stdout(`${built.name}: ${decision.text}\n`);
|
|
217
|
+
const preview = upPlan({
|
|
218
|
+
root,
|
|
219
|
+
session,
|
|
220
|
+
sessionRunning: live === 'running',
|
|
221
|
+
seats: [seatForPlan],
|
|
222
|
+
watchAlive: true,
|
|
223
|
+
});
|
|
224
|
+
io.stdout(formatPlan(preview));
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
if (decision.kind === 'refuse' && wouldLaunch) {
|
|
228
|
+
io.stderr(`team add: refused: ${decision.why}\n`);
|
|
229
|
+
return 1;
|
|
230
|
+
}
|
|
231
|
+
if (decision.kind === 'unknown')
|
|
232
|
+
io.stdout(`${built.name}: ${decision.text}\n`);
|
|
233
|
+
if (built.edited !== original) {
|
|
234
|
+
const written = withLock(dir, () => {
|
|
235
|
+
if (readFileSync(path, 'utf8') !== original)
|
|
236
|
+
return { kind: 'changed' };
|
|
237
|
+
const wrote = writeTeamFile(path, built.edited);
|
|
238
|
+
return wrote.ok ? { kind: 'ok' } : { kind: 'invalid', errors: wrote.errors };
|
|
239
|
+
});
|
|
240
|
+
if (written.kind === 'changed') {
|
|
241
|
+
io.stderr('team add: the file changed while add was checking; nothing was written\n');
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
244
|
+
if (written.kind === 'invalid') {
|
|
245
|
+
for (const problem of written.errors)
|
|
246
|
+
io.stderr(`team add: ${where(problem)}${problem.message}\n`);
|
|
247
|
+
return 2;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const plan = upPlan({ root, session, sessionRunning: live === 'running', seats: [seatForPlan], watchAlive: true });
|
|
251
|
+
const who = describeCaller(caller);
|
|
252
|
+
const host = hostOf({
|
|
253
|
+
dir, session, team: prepared.team, root, ceilings, running, seat: built.seat, temporary: built.temporary,
|
|
254
|
+
caller: who, now: sources.now, launch: sources.launch, readMachine: sources.machine, samples, limits: team.machine, io,
|
|
255
|
+
});
|
|
256
|
+
const report = await executePlan(plan, session, host);
|
|
257
|
+
const afterwards = readState(dir).sessions[session]?.seats[built.name];
|
|
258
|
+
if (afterwards?.stage === 'ready' && built.temporary) {
|
|
259
|
+
recordLedger(storePath(team.project, root, sources.home), [built.seat]);
|
|
260
|
+
}
|
|
261
|
+
const ready = afterwards?.stage === 'ready';
|
|
262
|
+
if (ready)
|
|
263
|
+
logLine(dir, 'add', who, `started ${built.name}${built.temporary ? ` like ${built.temporary.like} until ${built.temporary.until}` : ''}`, sources.now());
|
|
264
|
+
return ready && !report.serverFailed ? 0 : 1;
|
|
265
|
+
}
|
|
266
|
+
function declaredSeat(name, current, approvedText, approved) {
|
|
267
|
+
const seat = approved.seats.find((item) => item.name === name);
|
|
268
|
+
if (!seat)
|
|
269
|
+
return { error: `the approved file has no seat ${JSON.stringify(name)}` };
|
|
270
|
+
let edited = hasSeat(current, name) ? current : restoreSeat(current, approvedText, name);
|
|
271
|
+
if (seat.stopped || seatIsStopped(edited, name))
|
|
272
|
+
edited = clearStopped(edited, name);
|
|
273
|
+
const again = validateTeamFile(edited);
|
|
274
|
+
const restored = again.ok ? again.team.seats.find((item) => item.name === name) : undefined;
|
|
275
|
+
if (!restored)
|
|
276
|
+
return { error: `couldn't put ${name} back from the approved copy` };
|
|
277
|
+
return { name, seat: { ...restored, stopped: false }, edited, temporary: undefined };
|
|
278
|
+
}
|
|
279
|
+
function temporarySeat(values, current, approved, state, agents, root, base) {
|
|
280
|
+
const like = approved.seats.find((item) => item.name === values.like);
|
|
281
|
+
if (!like)
|
|
282
|
+
return { error: `the approved file has no seat ${JSON.stringify(values.like)}` };
|
|
283
|
+
const until = parseUntil(values.until ?? '');
|
|
284
|
+
if (!until)
|
|
285
|
+
return { error: '--until is result:<path> or merged:<branch>' };
|
|
286
|
+
let own;
|
|
287
|
+
if (until.kind === 'result') {
|
|
288
|
+
if (until.path.startsWith('/') || until.path.startsWith('~'))
|
|
289
|
+
return { error: 'a result path is relative to the project' };
|
|
290
|
+
if (existsSync(resolve(root, until.path)))
|
|
291
|
+
return { error: `${until.path} already exists` };
|
|
292
|
+
}
|
|
293
|
+
else if (!base) {
|
|
294
|
+
return { error: 'workspace.base is required to read a merged end' };
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
const read = readMerge(root, until.branch, base, false);
|
|
298
|
+
const here = branchPresent(root, until.branch);
|
|
299
|
+
if (read.detail.includes('the branch is gone') || here === false)
|
|
300
|
+
return { error: `branch ${until.branch} doesn't exist` };
|
|
301
|
+
if (here !== true && read.verdict === 'unproven')
|
|
302
|
+
return { error: read.detail };
|
|
303
|
+
own = read.ownNow > 0;
|
|
304
|
+
}
|
|
305
|
+
const task = values.worktree;
|
|
306
|
+
const worktree = task ? state.worktrees[task] : undefined;
|
|
307
|
+
if (task && !worktree)
|
|
308
|
+
return { error: `no worktree named ${JSON.stringify(task)} is recorded` };
|
|
309
|
+
if (worktree?.setup === 'failed')
|
|
310
|
+
return { error: `worktree ${task} has a failed setup; team worktree remove ${task}` };
|
|
311
|
+
const name = temporaryName(like.name, approved, state, agents);
|
|
312
|
+
return {
|
|
313
|
+
name,
|
|
314
|
+
edited: current,
|
|
315
|
+
seat: { ...like, name, label: name, cwd: worktree?.path ?? like.cwd, stopped: false },
|
|
316
|
+
temporary: {
|
|
317
|
+
like: like.name,
|
|
318
|
+
until: values.until ?? '',
|
|
319
|
+
...(task ? { task } : {}),
|
|
320
|
+
...(until.kind === 'merged' && !task && own !== undefined ? { own_commits: own } : {}),
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
function temporaryName(like, team, state, agents) {
|
|
325
|
+
const used = new Set([...team.seats.map((seat) => seat.name), ...Object.keys(state.seats), ...agents.map((agent) => agent.name ?? '')]);
|
|
326
|
+
let n = 1;
|
|
327
|
+
while (used.has(`${like}-tmp-${n}`))
|
|
328
|
+
n++;
|
|
329
|
+
return `${like}-tmp-${n}`;
|
|
330
|
+
}
|
|
331
|
+
function parseUntil(value) {
|
|
332
|
+
if (value.startsWith('result:') && value.length > 'result:'.length)
|
|
333
|
+
return { kind: 'result', path: value.slice('result:'.length) };
|
|
334
|
+
if (value.startsWith('merged:') && value.length > 'merged:'.length)
|
|
335
|
+
return { kind: 'merged', branch: value.slice('merged:'.length) };
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
function seatPlan(team, seat, start) {
|
|
339
|
+
return {
|
|
340
|
+
name: seat.name,
|
|
341
|
+
cli: seat.cli,
|
|
342
|
+
launch: seat.launch,
|
|
343
|
+
cwd: start.cwd,
|
|
344
|
+
label: seat.label,
|
|
345
|
+
stopped: false,
|
|
346
|
+
rules: rulesOf(team, seat),
|
|
347
|
+
...(start.lobby ? { lobby: true } : {}),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
function unnamedIn(label, agents, workspaces) {
|
|
351
|
+
const ids = new Set(workspaces.filter((workspace) => workspace.label === label).map((workspace) => workspace.id));
|
|
352
|
+
return agents.find((agent) => !agent.name && ids.has(agent.workspace));
|
|
353
|
+
}
|
|
354
|
+
function runningOf(agents, team, state) {
|
|
355
|
+
const seats = new Map(team.seats.map((seat) => [seat.name, seat]));
|
|
356
|
+
const running = [];
|
|
357
|
+
for (const agent of agents) {
|
|
358
|
+
if (!agent.name)
|
|
359
|
+
continue;
|
|
360
|
+
const seat = seats.get(agent.name);
|
|
361
|
+
const known = state.seats[agent.name];
|
|
362
|
+
if (!seat && !known?.temporary)
|
|
363
|
+
continue;
|
|
364
|
+
const like = known?.temporary ? seats.get(known.temporary.like) : undefined;
|
|
365
|
+
running.push({ name: agent.name, vendor: seat?.vendor ?? like?.vendor ?? 'unknown', temporary: Boolean(known?.temporary) });
|
|
366
|
+
}
|
|
367
|
+
return running;
|
|
368
|
+
}
|
|
369
|
+
function ceilingProblem(ceilings, running, seat, temporary) {
|
|
370
|
+
if (running.some((item) => item.name === seat.name))
|
|
371
|
+
return null;
|
|
372
|
+
if (running.length + 1 > ceilings.seats)
|
|
373
|
+
return `the approval allows ${ceilings.seats} seats; ${running.length + 1} would be running`;
|
|
374
|
+
if (temporary && running.filter((item) => item.temporary).length + 1 > ceilings.temporary) {
|
|
375
|
+
return `the approval allows ${ceilings.temporary} temporary seats; ${running.filter((item) => item.temporary).length + 1} would be running`;
|
|
376
|
+
}
|
|
377
|
+
const cap = ceilings.vendors[seat.vendor];
|
|
378
|
+
if (cap !== undefined) {
|
|
379
|
+
const vendors = running.filter((item) => item.vendor === seat.vendor).length + 1;
|
|
380
|
+
if (vendors > cap)
|
|
381
|
+
return `the approval allows ${cap} ${seat.vendor} seats; ${vendors} would be running`;
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
function where(problem) {
|
|
386
|
+
return problem.line ? `line ${problem.line}: ` : '';
|
|
387
|
+
}
|
|
388
|
+
function hostOf(input) {
|
|
389
|
+
const { dir, session, launch, seat, temporary } = input;
|
|
390
|
+
const running = [...input.running];
|
|
391
|
+
return {
|
|
392
|
+
startServer: launch.startServer,
|
|
393
|
+
sessionUp: launch.sessionUp,
|
|
394
|
+
makeDir(path) {
|
|
395
|
+
try {
|
|
396
|
+
mkdirSync(path, { recursive: true });
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
createWorkspace: launch.createWorkspace,
|
|
404
|
+
paneRun: launch.paneRun,
|
|
405
|
+
typeLine: () => false,
|
|
406
|
+
renameAgent: launch.renameAgent,
|
|
407
|
+
closeWorkspace: launch.closeWorkspace,
|
|
408
|
+
stopSession: () => false,
|
|
409
|
+
kill: () => false,
|
|
410
|
+
agentPanes: launch.agentPanes,
|
|
411
|
+
classify: (_name, pane, cli) => readScreen(cli, launch.paneText(session, pane) ?? undefined).kind,
|
|
412
|
+
sleep: launch.sleep,
|
|
413
|
+
now: () => input.now().getTime(),
|
|
414
|
+
allow(name) {
|
|
415
|
+
if (input.readMachine) {
|
|
416
|
+
const problem = launchLimit(input.readMachine(input.root), input.limits, input.samples, input.now().getTime());
|
|
417
|
+
if (problem)
|
|
418
|
+
return problem;
|
|
419
|
+
}
|
|
420
|
+
if (name !== seat.name)
|
|
421
|
+
return null;
|
|
422
|
+
return ceilingProblem(input.ceilings, running, seat, Boolean(temporary));
|
|
423
|
+
},
|
|
424
|
+
record(name, patch) {
|
|
425
|
+
updateState(dir, (file) => {
|
|
426
|
+
const current = (file.sessions[session] ??= emptySession());
|
|
427
|
+
const prior = current.seats[name] ?? { stage: patch.stage };
|
|
428
|
+
current.seats[name] = { ...prior, ...patch, ...(temporary && name === seat.name ? { temporary } : {}) };
|
|
429
|
+
});
|
|
430
|
+
},
|
|
431
|
+
running(name) {
|
|
432
|
+
if (!running.some((item) => item.name === name))
|
|
433
|
+
running.push({ name, vendor: seat.vendor, temporary: Boolean(temporary) });
|
|
434
|
+
},
|
|
435
|
+
drop(name) {
|
|
436
|
+
updateState(dir, (file) => {
|
|
437
|
+
const seats = file.sessions[session]?.seats;
|
|
438
|
+
if (seats)
|
|
439
|
+
delete seats[name];
|
|
440
|
+
});
|
|
441
|
+
},
|
|
442
|
+
say: (line) => input.io.stdout(line),
|
|
443
|
+
log: (who, what) => logLine(dir, 'add', input.caller, `${who}: ${what}`, input.now()),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Command, Io } from '../io.ts';
|
|
2
|
+
export type ApproveSources = {
|
|
3
|
+
ask(question: string): Promise<string | null>;
|
|
4
|
+
now(): Date;
|
|
5
|
+
home: string;
|
|
6
|
+
};
|
|
7
|
+
export declare const realSources: ApproveSources;
|
|
8
|
+
export declare const USAGE = "Usage: team approve [--show] [--file <path>]\n";
|
|
9
|
+
export declare const approve: Command;
|
|
10
|
+
export default approve;
|
|
11
|
+
export declare function runApprove(argv: string[], io: Io, sources: ApproveSources): Promise<number>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, resolve, sep } from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
5
|
+
import { approvedFingerprints, approvalOf, ceilingsOf } from "../approve/approval.js";
|
|
6
|
+
import { checkDrift, resolveChecks } from "../budgets/checks.js";
|
|
7
|
+
import { formatDiff } from "../approve/diff.js";
|
|
8
|
+
import { compare, describe, fingerprints } from "../approve/fingerprint.js";
|
|
9
|
+
import { readArgs } from "../args.js";
|
|
10
|
+
import { callerOf, describeCaller, isOwner } from "../caller.js";
|
|
11
|
+
import { loadTeamFile, placedProblems } from "../file/load.js";
|
|
12
|
+
import { validateTeamFile } from "../file/validate.js";
|
|
13
|
+
import { logLine } from "../log.js";
|
|
14
|
+
import { readApproval, storePath, writeApproval } from "../store/store.js";
|
|
15
|
+
export const realSources = {
|
|
16
|
+
async ask(question) {
|
|
17
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
18
|
+
try {
|
|
19
|
+
return await terminal.question(question);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
terminal.close();
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
now: () => new Date(),
|
|
29
|
+
home: homedir(),
|
|
30
|
+
};
|
|
31
|
+
export const USAGE = 'Usage: team approve [--show] [--file <path>]\n';
|
|
32
|
+
export const approve = (argv, io) => runApprove(argv, io, realSources);
|
|
33
|
+
export default approve;
|
|
34
|
+
function real(path) {
|
|
35
|
+
try {
|
|
36
|
+
return realpathSync(path);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return path;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function inside(path, folder) {
|
|
43
|
+
return path === folder || path.startsWith(folder.endsWith(sep) ? folder : folder + sep);
|
|
44
|
+
}
|
|
45
|
+
// The store must stay out of the project and of every folder a seat may work in.
|
|
46
|
+
function storeProblem(store, root, team) {
|
|
47
|
+
const place = real(dirname(store));
|
|
48
|
+
const folders = [root, ...team.trust.map((pattern) => resolve(root, pattern.replace(/\/?\*$/, '')))];
|
|
49
|
+
const holder = folders.map(real).find((folder) => inside(place, folder) || inside(store, folder));
|
|
50
|
+
return holder === undefined ? null : `the approval store ${store} is inside ${holder}, where seats work`;
|
|
51
|
+
}
|
|
52
|
+
function ceilingsLine(ceilings) {
|
|
53
|
+
const vendors = Object.entries(ceilings.vendors).map(([vendor, count]) => `${vendor} ${count}`);
|
|
54
|
+
return [`${ceilings.seats} seats at most`, `${ceilings.temporary} temporary`, ...vendors].join(', ');
|
|
55
|
+
}
|
|
56
|
+
export async function runApprove(argv, io, sources) {
|
|
57
|
+
const args = readArgs(argv, ['file'], ['show']);
|
|
58
|
+
if (args.error || args.rest.length) {
|
|
59
|
+
io.stderr(`team approve: ${args.error ?? `unexpected "${args.rest[0]}"`}\n${USAGE}`);
|
|
60
|
+
return 2;
|
|
61
|
+
}
|
|
62
|
+
const loaded = loadTeamFile(io.cwd, args.values.file ? { file: args.values.file } : {});
|
|
63
|
+
if (!loaded.ok) {
|
|
64
|
+
for (const problem of loaded.errors) {
|
|
65
|
+
io.stderr(`team approve: ${problem.line ? `line ${problem.line}: ` : ''}${problem.message}\n`);
|
|
66
|
+
}
|
|
67
|
+
return 2;
|
|
68
|
+
}
|
|
69
|
+
// Validate the text load just read. A second read could store an edit under the first read's fingerprints.
|
|
70
|
+
const checked = validateTeamFile(loaded.text);
|
|
71
|
+
if (!checked.ok) {
|
|
72
|
+
for (const problem of checked.errors) {
|
|
73
|
+
io.stderr(`team approve: ${problem.line ? `line ${problem.line}: ` : ''}${problem.message}\n`);
|
|
74
|
+
}
|
|
75
|
+
return 2;
|
|
76
|
+
}
|
|
77
|
+
const placed = placedProblems(checked.team, loaded.root);
|
|
78
|
+
if (placed.length) {
|
|
79
|
+
for (const problem of placed)
|
|
80
|
+
io.stderr(`team approve: ${problem.message}\n`);
|
|
81
|
+
return 2;
|
|
82
|
+
}
|
|
83
|
+
const { team } = checked;
|
|
84
|
+
const { root, path } = loaded;
|
|
85
|
+
const text = loaded.text;
|
|
86
|
+
for (const warning of checked.warnings)
|
|
87
|
+
io.stderr(`team approve: warning, line ${warning.line}: ${warning.message}\n`);
|
|
88
|
+
const store = storePath(team.project, root, sources.home);
|
|
89
|
+
const problem = storeProblem(store, root, team);
|
|
90
|
+
if (problem) {
|
|
91
|
+
io.stderr(`team approve: ${problem}\n`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
const resolved = resolveChecks(team, root, process.env.PATH ?? '');
|
|
95
|
+
if (!resolved.ok) {
|
|
96
|
+
io.stderr(`team approve: the check for ${resolved.account} cannot be resolved\n`);
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
const previous = readApproval(store);
|
|
100
|
+
const ceilings = ceilingsOf(team);
|
|
101
|
+
const seats = team.seats.length;
|
|
102
|
+
if (previous === null) {
|
|
103
|
+
io.stdout(`${path}: never approved on this machine. The whole file:\n\n`);
|
|
104
|
+
io.stdout(`${text
|
|
105
|
+
.replace(/\n$/, '')
|
|
106
|
+
.split('\n')
|
|
107
|
+
.map((line, index) => ` ${index + 1}: ${line}`)
|
|
108
|
+
.join('\n')}\n\n`);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
const changes = [
|
|
112
|
+
...compare(approvedFingerprints(previous), fingerprints(team)).map(describe),
|
|
113
|
+
...checkDrift(previous.approval.checks, resolved.checks),
|
|
114
|
+
];
|
|
115
|
+
const lines = formatDiff(previous.file, text);
|
|
116
|
+
if (lines.length === 0)
|
|
117
|
+
io.stdout(`${path}: the same text as the copy approved on ${previous.approval.approvedAt}.\n\n`);
|
|
118
|
+
else {
|
|
119
|
+
io.stdout(`${path}: against the copy approved on ${previous.approval.approvedAt}:\n\n`);
|
|
120
|
+
io.stdout(`${lines.map((line) => ` ${line}`).join('\n')}\n\n`);
|
|
121
|
+
}
|
|
122
|
+
io.stdout(changes.length ? `Needs a new approval: ${changes.join('; ')}.\n` : 'Nothing in it needs a new approval.\n');
|
|
123
|
+
if (ceilingsLine(previous.approval.ceilings) !== ceilingsLine(ceilings)) {
|
|
124
|
+
io.stdout(`Ceilings approved: ${ceilingsLine(previous.approval.ceilings)}.\n`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
io.stdout(`Ceilings this approval fixes: ${ceilingsLine(ceilings)}.\n`);
|
|
128
|
+
io.stdout(`Seats: ${seats} (${team.seats.map((seat) => seat.name).join(', ')}).\n`);
|
|
129
|
+
if (args.flags.has('show'))
|
|
130
|
+
return 0;
|
|
131
|
+
const caller = callerOf(io);
|
|
132
|
+
if (!isOwner(caller)) {
|
|
133
|
+
io.stderr(`team approve: only the owner approves a team file, from a terminal outside herdr; this call is ${describeCaller(caller)}\n`);
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
const answer = await sources.ask(`\nType the number of seats (${seats}) to approve this file, and its commands and rules, to run: `);
|
|
137
|
+
if (answer === null || answer.trim() !== String(seats)) {
|
|
138
|
+
io.stderr('team approve: not approved; nothing was written\n');
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
const now = sources.now();
|
|
142
|
+
writeApproval(store, { approval: approvalOf(team, root, now, resolved.checks), file: text }, team.seats);
|
|
143
|
+
logLine(dirname(path), 'approve', describeCaller(caller), `approved ${seats} seats; ceilings: ${ceilingsLine(ceilings)}`, now);
|
|
144
|
+
io.stdout(`Approved. The record is in ${store}; check the rest with \`team doctor\`.\n`);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type CheckConfig } from '../check/config.ts';
|
|
2
|
+
import type { Problem } from '../file/types.ts';
|
|
3
|
+
import type { Io } from '../io.ts';
|
|
4
|
+
export type LoadConfig = (cwd: string, file?: string) => {
|
|
5
|
+
ok: true;
|
|
6
|
+
config: CheckConfig;
|
|
7
|
+
warnings: Problem[];
|
|
8
|
+
} | {
|
|
9
|
+
ok: false;
|
|
10
|
+
errors: Problem[];
|
|
11
|
+
path?: string;
|
|
12
|
+
};
|
|
13
|
+
export declare const USAGE = "Usage: team check <ref> [--pr <file>] [--since <ref>] [--file <path>]\n\n <ref> a range when it holds \"..\", passed to git as given\n (origin/main..HEAD); otherwise that one commit\n --pr <file> also check a pull request's body (\"-\" reads standard input)\n --since <ref> skip this commit and everything reachable from it, for this\n run; overrides identity.since\n --file <path> the team file, instead of .agents/team.yaml\n\nExits 0 when every commit passes, 1 when one is refused, 2 when the check\ncan't run.\n";
|
|
14
|
+
/** The file's rules, with the ledger of this machine's store when the owner has approved a file here. */
|
|
15
|
+
export declare function loadConfig(cwd: string, file?: string, home?: string): ReturnType<LoadConfig>;
|
|
16
|
+
export declare function check(argv: string[], io: Io, load?: LoadConfig): Promise<number>;
|
|
17
|
+
export default check;
|