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,110 @@
|
|
|
1
|
+
import { herdrCommand } from "../herdr.js";
|
|
2
|
+
import { seatModel } from "./statusline.js";
|
|
3
|
+
export const WATCH_LABEL = 'watchdog';
|
|
4
|
+
// Compares the file and the state with the live session. Pure: every input is handed in.
|
|
5
|
+
// `watch` is the watch values in force — the approved ones — so an unapproved edit can't move a verdict.
|
|
6
|
+
export function compare(team, session, state, live, now = new Date(), watch = team.watch) {
|
|
7
|
+
const rows = [];
|
|
8
|
+
const differences = [];
|
|
9
|
+
const notes = [];
|
|
10
|
+
const claimed = new Set();
|
|
11
|
+
const labels = new Map(live.workspaces.map((workspace) => [workspace.id, workspace.label]));
|
|
12
|
+
const anyRunning = team.seats.some((seat) => live.agents.some((agent) => agent.name === seat.name));
|
|
13
|
+
for (const seat of team.seats) {
|
|
14
|
+
const agent = live.agents.find((candidate) => candidate.name === seat.name);
|
|
15
|
+
const recorded = state.seats[seat.name];
|
|
16
|
+
if (agent) {
|
|
17
|
+
claimed.add(agent.pane);
|
|
18
|
+
const model = modelOf(seat, agent, live, differences, notes);
|
|
19
|
+
rows.push({ name: seat.name, state: seat.parked ? `${agent.status}, parked` : agent.status, model, pane: agent.pane });
|
|
20
|
+
if (seat.stopped) {
|
|
21
|
+
differences.push({
|
|
22
|
+
what: `${seat.name} is marked stopped in the file and is running`,
|
|
23
|
+
repair: `team remove ${seat.name} --keep, or take "stopped: true" off the seat`,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (recorded && recorded.stage !== 'ready') {
|
|
27
|
+
differences.push({ what: `${seat.name}: its launch stopped at "${recorded.stage}"`, repair: 'team up (it resumes the launch)' });
|
|
28
|
+
}
|
|
29
|
+
if (recorded?.rules === 'undelivered') {
|
|
30
|
+
differences.push({ what: `${seat.name}: its rules were not delivered`, repair: `team remove ${seat.name} --keep, then team add ${seat.name}` });
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (seat.stopped) {
|
|
35
|
+
rows.push({ name: seat.name, state: 'stopped', model: seat.display, pane: '-' });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
// An agent sits in the seat's workspace under another name, or under none.
|
|
39
|
+
const stray = live.agents.find((candidate) => !claimed.has(candidate.pane) && labels.get(candidate.workspace) === seat.label
|
|
40
|
+
&& !team.seats.some((other) => other.name === candidate.name));
|
|
41
|
+
if (stray) {
|
|
42
|
+
claimed.add(stray.pane);
|
|
43
|
+
rows.push({ name: seat.name, state: 'wrong name', model: seat.display, pane: stray.pane });
|
|
44
|
+
differences.push({
|
|
45
|
+
what: `${seat.name}: the agent in its workspace "${seat.label}" is ${stray.name ? `named "${stray.name}"` : 'unnamed'}`,
|
|
46
|
+
repair: herdrCommand(session, 'agent', 'rename', stray.pane, seat.name),
|
|
47
|
+
});
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
rows.push({ name: seat.name, state: 'missing', model: seat.display, pane: '-' });
|
|
51
|
+
differences.push({
|
|
52
|
+
what: `${seat.name} is in the file and is not running`,
|
|
53
|
+
repair: anyRunning ? `team add ${seat.name}` : 'team up',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
for (const [name, recorded] of Object.entries(state.seats)) {
|
|
57
|
+
if (!recorded.temporary)
|
|
58
|
+
continue;
|
|
59
|
+
const agent = live.agents.find((candidate) => candidate.name === name);
|
|
60
|
+
if (agent) {
|
|
61
|
+
claimed.add(agent.pane);
|
|
62
|
+
rows.push({ name, state: `${agent.status}, temporary`, model: `like ${recorded.temporary.like}`, pane: agent.pane });
|
|
63
|
+
notes.push(`${name} is temporary, until ${recorded.temporary.until}`);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
differences.push({ what: `${name}: a temporary seat is recorded and is not running`, repair: `team remove ${name}` });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const agent of live.agents) {
|
|
70
|
+
if (claimed.has(agent.pane) || labels.get(agent.workspace) === WATCH_LABEL)
|
|
71
|
+
continue;
|
|
72
|
+
differences.push({
|
|
73
|
+
what: `${agent.name ?? `an unnamed ${agent.agent ?? 'agent'}`} (${agent.pane}) is running and is not in the file`,
|
|
74
|
+
repair: 'add the seat to the file and run team approve, or close it',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
for (const [task, worktree] of Object.entries(state.worktrees)) {
|
|
78
|
+
if (worktree.setup === 'failed') {
|
|
79
|
+
differences.push({ what: `worktree ${task}: its setup failed`, repair: `team worktree remove ${task}` });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (live.running && anyRunning) {
|
|
83
|
+
const beat = state.watch ? Date.parse(state.watch.heartbeat) : NaN;
|
|
84
|
+
if (!state.watch) {
|
|
85
|
+
differences.push({ what: 'no watch has run for this session', repair: `team watch --session ${session}` });
|
|
86
|
+
}
|
|
87
|
+
else if (!(now.getTime() - beat <= 2 * watch.interval * 1000)) {
|
|
88
|
+
const minutes = Number.isFinite(beat) ? Math.round((now.getTime() - beat) / 60_000) : null;
|
|
89
|
+
differences.push({
|
|
90
|
+
what: `the watch's last pass was ${minutes === null ? 'never recorded' : `${minutes} minute(s) ago`}`,
|
|
91
|
+
repair: `team watch --session ${session}`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { rows, differences, notes };
|
|
96
|
+
}
|
|
97
|
+
function modelOf(seat, agent, live, differences, notes) {
|
|
98
|
+
const running = seatModel(seat, live.screens[agent.pane]);
|
|
99
|
+
if (!running) {
|
|
100
|
+
notes.push(`${seat.name}: version unread (its screen doesn't show the model)`);
|
|
101
|
+
return `${seat.display} (unread)`;
|
|
102
|
+
}
|
|
103
|
+
if (running.model === seat.model && running.version === seat.version)
|
|
104
|
+
return seat.display;
|
|
105
|
+
differences.push({
|
|
106
|
+
what: `${seat.name} runs ${running.model} ${running.version}; the file says ${seat.model} ${seat.version}`,
|
|
107
|
+
repair: `restart the seat on the file's model (team remove ${seat.name} --keep, then team add ${seat.name}), or correct the file and run team approve`,
|
|
108
|
+
});
|
|
109
|
+
return `${running.model} ${running.version} (file: ${seat.display})`;
|
|
110
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type Running = {
|
|
2
|
+
model: string;
|
|
3
|
+
version: string;
|
|
4
|
+
};
|
|
5
|
+
export declare function runningModel(cli: string, screen: string): Running | null;
|
|
6
|
+
export declare function seatModel(seat: {
|
|
7
|
+
cli: string;
|
|
8
|
+
model: string;
|
|
9
|
+
}, screen: string | undefined): Running | null;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Each CLI shows its model in its own words. The profile's status_model rules turn a pane's
|
|
2
|
+
// visible text into the file's two fields, `model` and `version`, or null when the text doesn't
|
|
3
|
+
// show them: "unread" is never a mismatch. The last six lines are the window. A line the rules
|
|
4
|
+
// claim but cannot name clears an earlier match; that is how an unknown Codex footer stays unread.
|
|
5
|
+
import { statusOnLine } from "../profiles/profile.js";
|
|
6
|
+
export function runningModel(cli, screen) {
|
|
7
|
+
let found = null;
|
|
8
|
+
for (const line of screen.split('\n').slice(-6)) {
|
|
9
|
+
const hit = statusOnLine(cli, line);
|
|
10
|
+
if (hit === 'unreadable')
|
|
11
|
+
found = null;
|
|
12
|
+
else if (hit)
|
|
13
|
+
found = hit;
|
|
14
|
+
}
|
|
15
|
+
return found;
|
|
16
|
+
}
|
|
17
|
+
// The model a seat runs, as far as its screen can say. Claude Code names Claude's families only:
|
|
18
|
+
// for a seat that runs another maker's model through it, the line says nothing to compare, so
|
|
19
|
+
// the seat is unread rather than wrong.
|
|
20
|
+
export function seatModel(seat, screen) {
|
|
21
|
+
if (screen === undefined)
|
|
22
|
+
return null;
|
|
23
|
+
if (seat.cli === 'claude-code' && !seat.model.startsWith('Claude '))
|
|
24
|
+
return null;
|
|
25
|
+
return runningModel(seat.cli, screen);
|
|
26
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Fingerprints } from '../approve/fingerprint.ts';
|
|
2
|
+
/**
|
|
3
|
+
* The user-level store: what must survive the project folder and stay out of
|
|
4
|
+
* every seat's working folders. One folder per project root, so a project
|
|
5
|
+
* that is moved or renamed is approved again.
|
|
6
|
+
*/
|
|
7
|
+
export interface Ceilings {
|
|
8
|
+
seats: number;
|
|
9
|
+
temporary: number;
|
|
10
|
+
vendors: Record<string, number>;
|
|
11
|
+
}
|
|
12
|
+
export interface Approval {
|
|
13
|
+
format: 1;
|
|
14
|
+
approvedAt: string;
|
|
15
|
+
/** The project root the approval is for. */
|
|
16
|
+
root: string;
|
|
17
|
+
fingerprints: Fingerprints;
|
|
18
|
+
/** Fixed here, and never recomputed from the roster. */
|
|
19
|
+
ceilings: Ceilings;
|
|
20
|
+
/** Each account's check command, resolved and hashed. Absent on an older record. */
|
|
21
|
+
checks?: Record<string, {
|
|
22
|
+
command: string;
|
|
23
|
+
path: string;
|
|
24
|
+
hash: string;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
export interface LedgerEntry {
|
|
28
|
+
display: string;
|
|
29
|
+
role: string;
|
|
30
|
+
model: string;
|
|
31
|
+
version: string;
|
|
32
|
+
}
|
|
33
|
+
/** The store's folder for a project: `<home>/.config/team/<project>-<hash of the root's path>`. */
|
|
34
|
+
export declare function storePath(project: string, root: string, home?: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* The store of the project at this root, found without its file: the folder
|
|
37
|
+
* whose name ends with the root's hash. Null when the owner approved nothing
|
|
38
|
+
* for this root on this machine.
|
|
39
|
+
*/
|
|
40
|
+
export declare function findStore(root: string, home?: string): string | null;
|
|
41
|
+
/** The text of the file as the owner last approved it for this root, or null. */
|
|
42
|
+
export declare function approvedCopy(root: string, home?: string): string | null;
|
|
43
|
+
export interface ApprovalRecord {
|
|
44
|
+
approval: Approval;
|
|
45
|
+
/** The text of the file as it was approved. */
|
|
46
|
+
file: string;
|
|
47
|
+
}
|
|
48
|
+
/** The approval for this project, or null when the owner has approved nothing here. */
|
|
49
|
+
export declare function readApproval(store: string): ApprovalRecord | null;
|
|
50
|
+
/** The ledger: every `display` and `role` the team has had. */
|
|
51
|
+
export declare function readLedger(store: string): LedgerEntry[];
|
|
52
|
+
/** The ledger with these seats added; entries are never removed. */
|
|
53
|
+
export declare function mergeLedger(ledger: readonly LedgerEntry[], seats: readonly LedgerEntry[]): LedgerEntry[];
|
|
54
|
+
/**
|
|
55
|
+
* Records an approval. The fingerprints that let a file run and the text they
|
|
56
|
+
* are of go in one file, in one atomic write, so a write that stops halfway
|
|
57
|
+
* leaves the earlier approval whole. `approved.yaml` is a copy for the owner
|
|
58
|
+
* to read, and is never read back.
|
|
59
|
+
*/
|
|
60
|
+
export declare function writeApproval(store: string, record: ApprovalRecord, seats: readonly LedgerEntry[]): void;
|
|
61
|
+
/** Adds seats the team has had. The approval itself is left as it is. */
|
|
62
|
+
export declare function recordLedger(store: string, seats: readonly LedgerEntry[]): void;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
const APPROVAL = 'approval.json';
|
|
6
|
+
const APPROVED_FILE = 'approved.yaml';
|
|
7
|
+
const LEDGER = 'ledger.json';
|
|
8
|
+
function rootHash(root) {
|
|
9
|
+
let real = root;
|
|
10
|
+
try {
|
|
11
|
+
real = realpathSync(root);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// A root that doesn't exist yet keeps its path as given.
|
|
15
|
+
}
|
|
16
|
+
return createHash('sha256').update(real).digest('hex').slice(0, 12);
|
|
17
|
+
}
|
|
18
|
+
function storesFolder(home) {
|
|
19
|
+
return join(home, '.config', 'team');
|
|
20
|
+
}
|
|
21
|
+
/** The store's folder for a project: `<home>/.config/team/<project>-<hash of the root's path>`. */
|
|
22
|
+
export function storePath(project, root, home = homedir()) {
|
|
23
|
+
const name = project.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
24
|
+
return join(storesFolder(home), `${name}-${rootHash(root)}`);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The store of the project at this root, found without its file: the folder
|
|
28
|
+
* whose name ends with the root's hash. Null when the owner approved nothing
|
|
29
|
+
* for this root on this machine.
|
|
30
|
+
*/
|
|
31
|
+
export function findStore(root, home = homedir()) {
|
|
32
|
+
const suffix = `-${rootHash(root)}`;
|
|
33
|
+
let names;
|
|
34
|
+
try {
|
|
35
|
+
names = readdirSync(storesFolder(home));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const name = names.sort().find((candidate) => candidate.endsWith(suffix));
|
|
41
|
+
return name === undefined ? null : join(storesFolder(home), name);
|
|
42
|
+
}
|
|
43
|
+
/** The text of the file as the owner last approved it for this root, or null. */
|
|
44
|
+
export function approvedCopy(root, home = homedir()) {
|
|
45
|
+
const store = findStore(root, home);
|
|
46
|
+
if (store === null)
|
|
47
|
+
return null;
|
|
48
|
+
try {
|
|
49
|
+
return readApproval(store)?.file ?? null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function readJson(path) {
|
|
56
|
+
let text;
|
|
57
|
+
try {
|
|
58
|
+
text = readFileSync(path, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error.code === 'ENOENT')
|
|
62
|
+
return null;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
return JSON.parse(text);
|
|
66
|
+
}
|
|
67
|
+
/** Writes to a temporary file, then renames it: a reader never sees half a file. */
|
|
68
|
+
function writeAtomic(path, text) {
|
|
69
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
70
|
+
writeFileSync(temporary, text, { mode: 0o600 });
|
|
71
|
+
renameSync(temporary, path);
|
|
72
|
+
}
|
|
73
|
+
/** The approval for this project, or null when the owner has approved nothing here. */
|
|
74
|
+
export function readApproval(store) {
|
|
75
|
+
const stored = readJson(join(store, APPROVAL));
|
|
76
|
+
if (stored === null)
|
|
77
|
+
return null;
|
|
78
|
+
if (stored.format !== 1)
|
|
79
|
+
throw new Error(`${join(store, APPROVAL)}: unknown format ${String(stored.format)}`);
|
|
80
|
+
const { file, ...approval } = stored;
|
|
81
|
+
return typeof file === 'string' ? { approval, file } : null;
|
|
82
|
+
}
|
|
83
|
+
/** The ledger: every `display` and `role` the team has had. */
|
|
84
|
+
export function readLedger(store) {
|
|
85
|
+
return readJson(join(store, LEDGER)) ?? [];
|
|
86
|
+
}
|
|
87
|
+
function sameEntry(a, b) {
|
|
88
|
+
return a.display === b.display && a.role === b.role && a.model === b.model && a.version === b.version;
|
|
89
|
+
}
|
|
90
|
+
/** The ledger with these seats added; entries are never removed. */
|
|
91
|
+
export function mergeLedger(ledger, seats) {
|
|
92
|
+
const merged = [...ledger];
|
|
93
|
+
for (const seat of seats) {
|
|
94
|
+
const entry = { display: seat.display, role: seat.role, model: seat.model, version: seat.version };
|
|
95
|
+
if (!merged.some((known) => sameEntry(known, entry)))
|
|
96
|
+
merged.push(entry);
|
|
97
|
+
}
|
|
98
|
+
return merged;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Records an approval. The fingerprints that let a file run and the text they
|
|
102
|
+
* are of go in one file, in one atomic write, so a write that stops halfway
|
|
103
|
+
* leaves the earlier approval whole. `approved.yaml` is a copy for the owner
|
|
104
|
+
* to read, and is never read back.
|
|
105
|
+
*/
|
|
106
|
+
export function writeApproval(store, record, seats) {
|
|
107
|
+
mkdirSync(store, { recursive: true, mode: 0o700 });
|
|
108
|
+
writeAtomic(join(store, LEDGER), `${JSON.stringify(mergeLedger(readLedger(store), seats), null, 2)}\n`);
|
|
109
|
+
writeAtomic(join(store, APPROVAL), `${JSON.stringify({ ...record.approval, file: record.file }, null, 2)}\n`);
|
|
110
|
+
writeAtomic(join(store, APPROVED_FILE), record.file);
|
|
111
|
+
}
|
|
112
|
+
/** Adds seats the team has had. The approval itself is left as it is. */
|
|
113
|
+
export function recordLedger(store, seats) {
|
|
114
|
+
mkdirSync(store, { recursive: true, mode: 0o700 });
|
|
115
|
+
writeAtomic(join(store, LEDGER), `${JSON.stringify(mergeLedger(readLedger(store), seats), null, 2)}\n`);
|
|
116
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { CheckOutcome } from '../budgets/run.ts';
|
|
2
|
+
import type { Seen } from '../budgets/readings.ts';
|
|
3
|
+
import type { TeamFile } from '../file/types.ts';
|
|
4
|
+
import type { HerdrAgent } from '../herdr.ts';
|
|
5
|
+
import type { QuotaFigure } from '../profiles/quota.ts';
|
|
6
|
+
import type { Live } from '../status/compare.ts';
|
|
7
|
+
import type { SessionState } from '../state.ts';
|
|
8
|
+
import type { Machine } from './machine.ts';
|
|
9
|
+
import type { Screen } from './screen.ts';
|
|
10
|
+
export type Report = {
|
|
11
|
+
key: string;
|
|
12
|
+
text: string;
|
|
13
|
+
to: 'operator' | 'owner';
|
|
14
|
+
};
|
|
15
|
+
export type Attention = 'permission' | 'question' | 'blocked' | 'unknown' | null;
|
|
16
|
+
export type SeatHistory = {
|
|
17
|
+
lastWorking: number | undefined;
|
|
18
|
+
idleSince: number | undefined;
|
|
19
|
+
};
|
|
20
|
+
export type SeatObservation = {
|
|
21
|
+
name: string;
|
|
22
|
+
seat: {
|
|
23
|
+
cli: string;
|
|
24
|
+
model: string;
|
|
25
|
+
version: string;
|
|
26
|
+
} | undefined;
|
|
27
|
+
agent: HerdrAgent | undefined;
|
|
28
|
+
herdr: boolean;
|
|
29
|
+
screen: Screen;
|
|
30
|
+
cli: string;
|
|
31
|
+
vendor: string;
|
|
32
|
+
quota: QuotaFigure[];
|
|
33
|
+
running: boolean;
|
|
34
|
+
quiet: boolean;
|
|
35
|
+
working: boolean;
|
|
36
|
+
prompt: boolean;
|
|
37
|
+
lead: boolean;
|
|
38
|
+
parked: boolean;
|
|
39
|
+
stopped: boolean;
|
|
40
|
+
attention: Attention;
|
|
41
|
+
model: {
|
|
42
|
+
model: string;
|
|
43
|
+
version: string;
|
|
44
|
+
} | null;
|
|
45
|
+
history: SeatHistory;
|
|
46
|
+
};
|
|
47
|
+
export type TeamObservation = {
|
|
48
|
+
team: TeamFile;
|
|
49
|
+
state: SessionState;
|
|
50
|
+
live: Live;
|
|
51
|
+
machine: Machine;
|
|
52
|
+
limits: TeamFile['machine'];
|
|
53
|
+
seats: SeatObservation[];
|
|
54
|
+
known: Set<string>;
|
|
55
|
+
workers: {
|
|
56
|
+
idle: boolean;
|
|
57
|
+
}[];
|
|
58
|
+
approval: string[] | null | undefined;
|
|
59
|
+
readings: Seen[];
|
|
60
|
+
outcomes: readonly CheckOutcome[];
|
|
61
|
+
};
|
|
62
|
+
export type CheckContext = {
|
|
63
|
+
now: number;
|
|
64
|
+
watch: TeamFile['watch'];
|
|
65
|
+
budgets: TeamFile['budgets'];
|
|
66
|
+
once(key: string, text: string, to?: Report['to']): Report | null;
|
|
67
|
+
memory<T>(kind: string, start: () => T): T;
|
|
68
|
+
};
|
|
69
|
+
export interface SeatCheck {
|
|
70
|
+
name: string;
|
|
71
|
+
run(seat: SeatObservation, ctx: CheckContext): Report[];
|
|
72
|
+
}
|
|
73
|
+
export interface TeamCheck {
|
|
74
|
+
name: string;
|
|
75
|
+
run(team: TeamObservation, ctx: CheckContext): Report[];
|
|
76
|
+
}
|
|
77
|
+
export declare const CHECK_NAMES: readonly ["missing", "model-drift", "attention", "unsent", "idle", "extra", "team-idle", "approval", "load", "memory", "disk", "swap-free", "swap-growth", "budget"];
|
|
78
|
+
export declare const ALWAYS_ON: readonly string[];
|
|
79
|
+
/** A duration in whole minutes, as the report texts write it. */
|
|
80
|
+
export declare function minutes(ms: number): number;
|
|
81
|
+
/** The report `ctx.once` may have made, as the list `run` returns. */
|
|
82
|
+
export declare function reported(report: Report | null): Report[];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Every check there is, in the order the core runs them: the seat checks first, then the team
|
|
2
|
+
// checks. The order decides the order of the lines in the log, nothing else.
|
|
3
|
+
export const CHECK_NAMES = [
|
|
4
|
+
'missing',
|
|
5
|
+
'model-drift',
|
|
6
|
+
'attention',
|
|
7
|
+
'unsent',
|
|
8
|
+
'idle',
|
|
9
|
+
'extra',
|
|
10
|
+
'team-idle',
|
|
11
|
+
'approval',
|
|
12
|
+
'load',
|
|
13
|
+
'memory',
|
|
14
|
+
'disk',
|
|
15
|
+
'swap-free',
|
|
16
|
+
'swap-growth',
|
|
17
|
+
'budget',
|
|
18
|
+
];
|
|
19
|
+
// The checks the file cannot turn off (RFC 0002 § 4.2): the four whose condition is the one thing
|
|
20
|
+
// the watch exists for. The schema refuses them in `watch.checks`, and so does `team approve`.
|
|
21
|
+
export const ALWAYS_ON = ['attention', 'missing', 'model-drift', 'approval'];
|
|
22
|
+
/** A duration in whole minutes, as the report texts write it. */
|
|
23
|
+
export function minutes(ms) {
|
|
24
|
+
return Math.floor(ms / 60_000);
|
|
25
|
+
}
|
|
26
|
+
/** The report `ctx.once` may have made, as the list `run` returns. */
|
|
27
|
+
export function reported(report) {
|
|
28
|
+
return report ? [report] : [];
|
|
29
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { reported } from "../check.js";
|
|
2
|
+
// The owner's seat: a file that was never approved on this machine, or one that differs from the
|
|
3
|
+
// file that was. Reported once while the difference holds. The watch never applies the file
|
|
4
|
+
// itself — the report tells the owner, and nothing runs the differing file as approved.
|
|
5
|
+
export const approval = {
|
|
6
|
+
name: 'approval',
|
|
7
|
+
run(team, ctx) {
|
|
8
|
+
if (team.approval === null) {
|
|
9
|
+
return reported(ctx.once('approval', 'the file was never approved on this machine', 'owner'));
|
|
10
|
+
}
|
|
11
|
+
if (team.approval?.length) {
|
|
12
|
+
return reported(ctx.once('approval', `the file differs from the approved one: ${team.approval.join('; ')}`, 'owner'));
|
|
13
|
+
}
|
|
14
|
+
return [];
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { reported } from "../check.js";
|
|
2
|
+
// The one exclusive reading the core made of the seat's screen and status: a seat waits at a
|
|
3
|
+
// permission prompt its owner has to answer, asks a question the operator has to act on, is
|
|
4
|
+
// blocked on a screen the watch doesn't recognise, or reports a status the watch doesn't know.
|
|
5
|
+
// A permission prompt and a trust question are the same seat state to the watch.
|
|
6
|
+
export const attention = {
|
|
7
|
+
name: 'attention',
|
|
8
|
+
run(seat, ctx) {
|
|
9
|
+
if (seat.attention === 'permission') {
|
|
10
|
+
return reported(ctx.once(`blocked:${seat.name}`, `${seat.name} waits at a permission prompt: its owner's to answer`, 'owner'));
|
|
11
|
+
}
|
|
12
|
+
if (seat.attention === 'question') {
|
|
13
|
+
return reported(ctx.once(`question:${seat.name}`, `${seat.name} asked a question: the operator's to act on`));
|
|
14
|
+
}
|
|
15
|
+
if (seat.attention === 'blocked') {
|
|
16
|
+
return reported(ctx.once(`blocked:${seat.name}`, `${seat.name} is blocked, and its screen is not one the watch recognises`, 'owner'));
|
|
17
|
+
}
|
|
18
|
+
if (seat.attention === 'unknown' && seat.agent) {
|
|
19
|
+
return reported(ctx.once(`unknown:${seat.name}`, `${seat.name}: herdr reports the status "${seat.agent.status}"`));
|
|
20
|
+
}
|
|
21
|
+
return [];
|
|
22
|
+
},
|
|
23
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { verdict } from "../../budgets/readings.js";
|
|
2
|
+
import { WINDOWS } from "../../profiles/quota.js";
|
|
3
|
+
import { reported } from "../check.js";
|
|
4
|
+
export const budget = {
|
|
5
|
+
name: 'budget',
|
|
6
|
+
run(team, ctx) {
|
|
7
|
+
const reports = [];
|
|
8
|
+
const marks = ctx.memory('budget', () => ({}));
|
|
9
|
+
const staleMs = ctx.budgets.staleAfter * 1000;
|
|
10
|
+
for (const [name, account] of Object.entries(ctx.budgets.accounts)) {
|
|
11
|
+
const counted = countedOf(name, account, team, staleMs, ctx.now);
|
|
12
|
+
// A figure nobody has yet seen (a first sight, not yet counted) is not "unknown": the
|
|
13
|
+
// launch gate is the one that says so, and a watch restart would otherwise report every run.
|
|
14
|
+
if (counted === 'unseen')
|
|
15
|
+
continue;
|
|
16
|
+
if (counted === null) {
|
|
17
|
+
reports.push(...reported(unknown(name, team, ctx)));
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (counted.kind === 'spend') {
|
|
21
|
+
const floor = account.floor;
|
|
22
|
+
if (floor !== null && counted.amount <= floor.amount) {
|
|
23
|
+
const text = `${name} ${counted.amount} ${counted.currency} left, at its ${floor.amount} ${floor.currency} floor`;
|
|
24
|
+
reports.push(...reported(ctx.once(`budget:floor:${name}`, text, 'owner')));
|
|
25
|
+
}
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
reports.push(...crossings(marks, name, counted.windows, ctx.budgets.marks, ctx.now));
|
|
29
|
+
const reserve = account.reserve;
|
|
30
|
+
if (reserve === null)
|
|
31
|
+
continue;
|
|
32
|
+
const inside = counted.windows.filter((window) => window.left <= reserve);
|
|
33
|
+
const worst = inside.reduce((a, b) => (a === null ? b : tightest(a, b)), null);
|
|
34
|
+
if (worst) {
|
|
35
|
+
const text = `${name} ${worst.window} left ${worst.left}%, inside its ${reserve}% reserve`;
|
|
36
|
+
reports.push(...reported(ctx.once(`budget:reserve:${name}`, text, 'owner')));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return reports;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* The account's figure this pass, from the first source that has one (§ 4.3, § 5). A check
|
|
44
|
+
* reading counts while it is fresh; a screen reading counts when the verdict keeps it — fresh,
|
|
45
|
+
* stale, or refusing — and a bare first sight is `unseen`. Null: nothing counts at all.
|
|
46
|
+
*/
|
|
47
|
+
function countedOf(name, account, team, staleMs, now) {
|
|
48
|
+
for (const source of account.sources) {
|
|
49
|
+
if (source === 'check') {
|
|
50
|
+
const outcome = team.outcomes.find((one) => one.account === name);
|
|
51
|
+
if (!outcome || outcome.state !== 'read')
|
|
52
|
+
continue;
|
|
53
|
+
if (outcome.reading.kind === 'spend') {
|
|
54
|
+
if (now - outcome.reading.at < staleMs) {
|
|
55
|
+
return { kind: 'spend', amount: outcome.reading.amount, currency: outcome.reading.currency };
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const windows = outcome.reading.windows
|
|
60
|
+
.filter((window) => now - window.at < staleMs)
|
|
61
|
+
.map(({ window, left, used, resetsAt }) => ({ window, left, used, resetsAt }));
|
|
62
|
+
if (windows.length)
|
|
63
|
+
return { kind: 'subscription', windows };
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const groups = new Map();
|
|
67
|
+
for (const reading of team.readings) {
|
|
68
|
+
if (reading.account !== name)
|
|
69
|
+
continue;
|
|
70
|
+
const group = groups.get(reading.window) ?? [];
|
|
71
|
+
group.push(reading);
|
|
72
|
+
groups.set(reading.window, group);
|
|
73
|
+
}
|
|
74
|
+
const windows = [];
|
|
75
|
+
let seen = false;
|
|
76
|
+
for (const group of groups.values()) {
|
|
77
|
+
const result = verdict(group, now, staleMs, account.reserve);
|
|
78
|
+
if (result.kind === 'unknown')
|
|
79
|
+
continue;
|
|
80
|
+
if (result.kind === 'unconfirmed') {
|
|
81
|
+
seen = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
windows.push({
|
|
85
|
+
window: result.reading.window,
|
|
86
|
+
left: result.reading.left,
|
|
87
|
+
used: result.reading.used,
|
|
88
|
+
resetsAt: result.reading.resetsAt,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (windows.length)
|
|
92
|
+
return { kind: 'subscription', windows };
|
|
93
|
+
if (seen)
|
|
94
|
+
return 'unseen';
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/** The account reads unknown while a seat that spends it is running: the operator can stop it. */
|
|
100
|
+
function unknown(name, team, ctx) {
|
|
101
|
+
const on = team.seats.filter((seat) => seat.running && seat.vendor === name).map((seat) => seat.name);
|
|
102
|
+
if (!on.length)
|
|
103
|
+
return null;
|
|
104
|
+
const verb = on.length === 1 ? 'runs' : 'run';
|
|
105
|
+
return ctx.once(`budget:unknown:${name}`, `${name} is unknown while ${on.join(', ')} ${verb} on it`, 'operator');
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The marks this pass's figures cross (§ 6): each once per window, re-armed at the window's
|
|
109
|
+
* known reset, or — with no reset time known — when the figure drops ten points, which is a
|
|
110
|
+
* new window's figure.
|
|
111
|
+
*/
|
|
112
|
+
function crossings(marks, name, windows, list, now) {
|
|
113
|
+
const reports = [];
|
|
114
|
+
for (const window of windows) {
|
|
115
|
+
const key = `${name}\0${window.window}`;
|
|
116
|
+
const previous = marks[key];
|
|
117
|
+
const already = previous && !rearmed(previous, window, now) ? previous.reported : [];
|
|
118
|
+
const crossed = list.filter((mark) => window.used >= mark && !already.includes(mark));
|
|
119
|
+
for (const mark of crossed) {
|
|
120
|
+
reports.push({
|
|
121
|
+
key: `budget:mark:${name}:${window.window}:${mark}`,
|
|
122
|
+
text: `${name} ${window.window} is ${window.used}% used, past the ${mark}% mark`,
|
|
123
|
+
to: 'operator',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
marks[key] = { used: window.used, resetsAt: window.resetsAt, reported: [...already, ...crossed] };
|
|
127
|
+
}
|
|
128
|
+
return reports;
|
|
129
|
+
}
|
|
130
|
+
function rearmed(previous, window, now) {
|
|
131
|
+
if (previous.resetsAt !== null && now >= previous.resetsAt) {
|
|
132
|
+
// The window the marks were reported in has ended; the new figure names the next one.
|
|
133
|
+
return window.resetsAt === null || window.resetsAt > now;
|
|
134
|
+
}
|
|
135
|
+
return previous.resetsAt === null && window.used <= previous.used - 10;
|
|
136
|
+
}
|
|
137
|
+
function tightest(a, b) {
|
|
138
|
+
if (a.left !== b.left)
|
|
139
|
+
return b.left < a.left ? b : a;
|
|
140
|
+
return WINDOWS.indexOf(b.window) < WINDOWS.indexOf(a.window) ? b : a;
|
|
141
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { gb } from "../machine.js";
|
|
2
|
+
import { reported } from "../check.js";
|
|
3
|
+
// Free disk below the file's minimum, on the project's volume. A figure that wasn't read is
|
|
4
|
+
// never reported.
|
|
5
|
+
export const disk = {
|
|
6
|
+
name: 'disk',
|
|
7
|
+
run(team, ctx) {
|
|
8
|
+
const value = team.machine.diskFree;
|
|
9
|
+
if (value === null || value >= team.limits.diskMin)
|
|
10
|
+
return [];
|
|
11
|
+
return reported(ctx.once('disk', `free disk is ${gb(value)}, below ${gb(team.limits.diskMin)}`, 'owner'));
|
|
12
|
+
},
|
|
13
|
+
};
|