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
package/dist/args.d.ts
ADDED
package/dist/args.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function readArgs(argv, valued, flags) {
|
|
2
|
+
const args = { values: {}, flags: new Set(), rest: [] };
|
|
3
|
+
for (let i = 0; i < argv.length; i++) {
|
|
4
|
+
const arg = argv[i];
|
|
5
|
+
if (!arg.startsWith('--')) {
|
|
6
|
+
args.rest.push(arg);
|
|
7
|
+
continue;
|
|
8
|
+
}
|
|
9
|
+
const [name, inline] = arg.slice(2).split(/=(.*)/s);
|
|
10
|
+
if (flags.includes(name) && inline === undefined)
|
|
11
|
+
args.flags.add(name);
|
|
12
|
+
else if (valued.includes(name)) {
|
|
13
|
+
const value = inline ?? argv[++i];
|
|
14
|
+
if (value === undefined || value === '')
|
|
15
|
+
return { ...args, error: `--${name} needs a value` };
|
|
16
|
+
args.values[name] = value;
|
|
17
|
+
}
|
|
18
|
+
else
|
|
19
|
+
return { ...args, error: `unknown option --${name}` };
|
|
20
|
+
}
|
|
21
|
+
return args;
|
|
22
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { TeamFile } from '../file/types.ts';
|
|
2
|
+
export type ApprovedCheck = {
|
|
3
|
+
command: string;
|
|
4
|
+
path: string;
|
|
5
|
+
hash: string;
|
|
6
|
+
};
|
|
7
|
+
/** The accounts `budgets` names a check for. Callers hand in the section in force (RFC 0003 § 5). */
|
|
8
|
+
export declare function checkCommands(budgets: TeamFile['budgets']): {
|
|
9
|
+
account: string;
|
|
10
|
+
command: string;
|
|
11
|
+
}[];
|
|
12
|
+
/** The file a command names, and its sha256. Null when it cannot be resolved. */
|
|
13
|
+
export declare function resolveCheck(command: string, root: string, pathEnv: string): {
|
|
14
|
+
path: string;
|
|
15
|
+
hash: string;
|
|
16
|
+
} | null;
|
|
17
|
+
export declare function resolveChecks(team: TeamFile, root: string, pathEnv: string): {
|
|
18
|
+
ok: true;
|
|
19
|
+
checks: Record<string, ApprovedCheck>;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
account: string;
|
|
23
|
+
command: string;
|
|
24
|
+
};
|
|
25
|
+
export type CheckReading = {
|
|
26
|
+
account: string;
|
|
27
|
+
state: 'approved' | 'unknown';
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Whether each account's check may be run. The stored absolute path is hashed
|
|
31
|
+
* again; PATH is not consulted. A missing or changed file reads unknown.
|
|
32
|
+
*/
|
|
33
|
+
export declare function checkReadings(budgets: TeamFile['budgets'], approved: Record<string, ApprovedCheck> | undefined): CheckReading[];
|
|
34
|
+
/** Lines for a resolved command whose path or hash is not the approved one. */
|
|
35
|
+
export declare function checkDrift(approved: Record<string, ApprovedCheck> | undefined, current: Record<string, ApprovedCheck>): string[];
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// A `check` command is resolved and hashed at approval. The hash is of the named
|
|
2
|
+
// file only. team never reads what that file imports, and never logs its bytes.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
6
|
+
/** The accounts `budgets` names a check for. Callers hand in the section in force (RFC 0003 § 5). */
|
|
7
|
+
export function checkCommands(budgets) {
|
|
8
|
+
return Object.entries(budgets.accounts)
|
|
9
|
+
.filter((entry) => entry[1].check !== null)
|
|
10
|
+
.map(([account, accountBudget]) => ({ account, command: accountBudget.check }));
|
|
11
|
+
}
|
|
12
|
+
/** The file a command names, and its sha256. Null when it cannot be resolved. */
|
|
13
|
+
export function resolveCheck(command, root, pathEnv) {
|
|
14
|
+
const path = command.includes('/')
|
|
15
|
+
? (isAbsolute(command) ? command : resolve(root, command))
|
|
16
|
+
: pathEnv.split(':').filter((dir) => dir && isAbsolute(dir)).map((dir) => join(dir, command)).find((file) => executable(file)) ?? '';
|
|
17
|
+
if (!path || !executable(path))
|
|
18
|
+
return null;
|
|
19
|
+
return { path, hash: createHash('sha256').update(readFileSync(path)).digest('hex') };
|
|
20
|
+
}
|
|
21
|
+
export function resolveChecks(team, root, pathEnv) {
|
|
22
|
+
const checks = {};
|
|
23
|
+
for (const { account, command } of checkCommands(team.budgets)) {
|
|
24
|
+
const found = resolveCheck(command, root, pathEnv);
|
|
25
|
+
if (!found)
|
|
26
|
+
return { ok: false, account, command };
|
|
27
|
+
checks[account] = { command, path: found.path, hash: found.hash };
|
|
28
|
+
}
|
|
29
|
+
return { ok: true, checks };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Whether each account's check may be run. The stored absolute path is hashed
|
|
33
|
+
* again; PATH is not consulted. A missing or changed file reads unknown.
|
|
34
|
+
*/
|
|
35
|
+
export function checkReadings(budgets, approved) {
|
|
36
|
+
return checkCommands(budgets).map(({ account }) => {
|
|
37
|
+
const known = approved?.[account];
|
|
38
|
+
if (!known || hashOf(known.path) !== known.hash)
|
|
39
|
+
return { account, state: 'unknown' };
|
|
40
|
+
return { account, state: 'approved' };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** Lines for a resolved command whose path or hash is not the approved one. */
|
|
44
|
+
export function checkDrift(approved, current) {
|
|
45
|
+
const lines = [];
|
|
46
|
+
const previous = approved ?? {};
|
|
47
|
+
for (const [account, check] of Object.entries(current)) {
|
|
48
|
+
const known = previous[account];
|
|
49
|
+
if (!known || known.command !== check.command || known.path !== check.path || known.hash !== check.hash) {
|
|
50
|
+
lines.push(`the check for ${account} changed`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const account of Object.keys(previous)) {
|
|
54
|
+
if (!current[account])
|
|
55
|
+
lines.push(`the check for ${account} changed`);
|
|
56
|
+
}
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
function hashOf(file) {
|
|
60
|
+
try {
|
|
61
|
+
if (!statSync(file).isFile())
|
|
62
|
+
return null;
|
|
63
|
+
return createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function executable(file) {
|
|
70
|
+
try {
|
|
71
|
+
const stat = statSync(file);
|
|
72
|
+
return stat.isFile() && (stat.mode & 0o111) !== 0;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Seat, TeamFile } from '../file/types.ts';
|
|
2
|
+
import { type Seen } from './readings.ts';
|
|
3
|
+
export type LaunchDecision = {
|
|
4
|
+
kind: 'clear';
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'unknown';
|
|
7
|
+
account: string;
|
|
8
|
+
text: string;
|
|
9
|
+
} | {
|
|
10
|
+
kind: 'refuse';
|
|
11
|
+
why: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The seat's account is its vendor. There is no separate account field on a seat. `budgets` is
|
|
15
|
+
* the section in force: an unapproved edit to a reserve refuses no one until it is approved,
|
|
16
|
+
* and an account only the unapproved edit names is not an account at all.
|
|
17
|
+
*/
|
|
18
|
+
export declare function seatBudget(budgets: TeamFile['budgets'], readings: readonly Seen[], seat: Seat, now: number): LaunchDecision;
|
|
19
|
+
/** Subscription accounts whose counted windows are all outside the reserve. */
|
|
20
|
+
export declare function accountsWithRoom(budgets: TeamFile['budgets'], readings: readonly Seen[], now: number): string[];
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { verdict } from "./readings.js";
|
|
2
|
+
const RANK = { session: 0, daily: 1, weekly: 2 };
|
|
3
|
+
/**
|
|
4
|
+
* The seat's account is its vendor. There is no separate account field on a seat. `budgets` is
|
|
5
|
+
* the section in force: an unapproved edit to a reserve refuses no one until it is approved,
|
|
6
|
+
* and an account only the unapproved edit names is not an account at all.
|
|
7
|
+
*/
|
|
8
|
+
export function seatBudget(budgets, readings, seat, now) {
|
|
9
|
+
const name = seat.vendor;
|
|
10
|
+
const account = budgets.accounts[name];
|
|
11
|
+
if (!account)
|
|
12
|
+
return { kind: 'clear' };
|
|
13
|
+
if (account.kind === 'spend')
|
|
14
|
+
return { kind: 'unknown', account: name, text: `${name} is unknown` };
|
|
15
|
+
const staleAfterMs = budgets.staleAfter * 1000;
|
|
16
|
+
const mine = readings.filter((item) => item.account === name);
|
|
17
|
+
let unknown = mine.length === 0;
|
|
18
|
+
let unconfirmed = false;
|
|
19
|
+
let counted = false;
|
|
20
|
+
let worst = null;
|
|
21
|
+
for (const group of windowsOf(mine)) {
|
|
22
|
+
const result = verdict(group, now, staleAfterMs, account.reserve);
|
|
23
|
+
if (result.kind === 'unknown') {
|
|
24
|
+
unknown = true;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
// A first sight is unconfirmed. It never refuses.
|
|
28
|
+
if (result.kind === 'unconfirmed') {
|
|
29
|
+
unconfirmed = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
counted = true;
|
|
33
|
+
const inside = account.reserve !== null && result.reading.left <= account.reserve;
|
|
34
|
+
if (!inside && result.kind !== 'refusing')
|
|
35
|
+
continue;
|
|
36
|
+
if (!worst
|
|
37
|
+
|| result.reading.left < worst.left
|
|
38
|
+
|| (result.reading.left === worst.left && RANK[result.reading.window] < RANK[worst.window])) {
|
|
39
|
+
worst = result.reading;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (worst && account.reserve !== null) {
|
|
43
|
+
const room = accountsWithRoom(budgets, readings, now).filter((accountName) => accountName !== name);
|
|
44
|
+
return {
|
|
45
|
+
kind: 'refuse',
|
|
46
|
+
why: `${name} ${worst.window} left ${worst.left}%, inside its ${account.reserve}% reserve, changed ${age(now - worst.changedAt)} ago; accounts with room: ${room.length ? room.join(', ') : 'none'}`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (unknown)
|
|
50
|
+
return { kind: 'unknown', account: name, text: `${name} is unknown` };
|
|
51
|
+
if (unconfirmed && !counted)
|
|
52
|
+
return { kind: 'unknown', account: name, text: `${name}: first sight only, not yet counted` };
|
|
53
|
+
return { kind: 'clear' };
|
|
54
|
+
}
|
|
55
|
+
/** Subscription accounts whose counted windows are all outside the reserve. */
|
|
56
|
+
export function accountsWithRoom(budgets, readings, now) {
|
|
57
|
+
const staleAfterMs = budgets.staleAfter * 1000;
|
|
58
|
+
const names = [];
|
|
59
|
+
for (const [name, account] of Object.entries(budgets.accounts)) {
|
|
60
|
+
if (account.kind !== 'subscription' || account.reserve === null)
|
|
61
|
+
continue;
|
|
62
|
+
const groups = windowsOf(readings.filter((item) => item.account === name));
|
|
63
|
+
let counted = 0;
|
|
64
|
+
let inside = false;
|
|
65
|
+
for (const group of groups) {
|
|
66
|
+
const result = verdict(group, now, staleAfterMs, account.reserve);
|
|
67
|
+
if (result.kind === 'unknown' || result.kind === 'unconfirmed')
|
|
68
|
+
continue;
|
|
69
|
+
counted += 1;
|
|
70
|
+
if (result.kind === 'refusing' || result.reading.left <= account.reserve)
|
|
71
|
+
inside = true;
|
|
72
|
+
}
|
|
73
|
+
if (counted > 0 && !inside)
|
|
74
|
+
names.push(name);
|
|
75
|
+
}
|
|
76
|
+
return names.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
77
|
+
}
|
|
78
|
+
function windowsOf(list) {
|
|
79
|
+
const groups = new Map();
|
|
80
|
+
for (const reading of list) {
|
|
81
|
+
const group = groups.get(reading.window) ?? [];
|
|
82
|
+
group.push(reading);
|
|
83
|
+
groups.set(reading.window, group);
|
|
84
|
+
}
|
|
85
|
+
return [...groups.values()];
|
|
86
|
+
}
|
|
87
|
+
function age(ms) {
|
|
88
|
+
const minutes = Math.max(0, Math.floor(ms / 60_000));
|
|
89
|
+
const hours = Math.floor(minutes / 60);
|
|
90
|
+
const rest = minutes % 60;
|
|
91
|
+
if (hours && rest)
|
|
92
|
+
return `${hours}h${rest}m`;
|
|
93
|
+
if (hours)
|
|
94
|
+
return `${hours}h`;
|
|
95
|
+
return `${minutes}m`;
|
|
96
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { QuotaFigure, WindowName } from '../profiles/quota.ts';
|
|
2
|
+
export type Seen = {
|
|
3
|
+
account: string;
|
|
4
|
+
window: WindowName;
|
|
5
|
+
left: number;
|
|
6
|
+
used: number;
|
|
7
|
+
changedAt: number;
|
|
8
|
+
resetsAt: number | null;
|
|
9
|
+
seat: string;
|
|
10
|
+
confirmed: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type StoredReading = {
|
|
13
|
+
account: string;
|
|
14
|
+
window: WindowName;
|
|
15
|
+
left: number;
|
|
16
|
+
used: number;
|
|
17
|
+
changedAt: string;
|
|
18
|
+
resetsAt: string | null;
|
|
19
|
+
seat: string;
|
|
20
|
+
confirmed: boolean;
|
|
21
|
+
};
|
|
22
|
+
export type Verdict = {
|
|
23
|
+
kind: 'fresh' | 'unconfirmed' | 'stale' | 'refusing';
|
|
24
|
+
reading: Seen;
|
|
25
|
+
} | {
|
|
26
|
+
kind: 'unknown';
|
|
27
|
+
};
|
|
28
|
+
/** Fold one seat's figure into the readings kept for this pass. */
|
|
29
|
+
export declare function observe(list: readonly Seen[], figure: QuotaFigure, seat: string, now: number): Seen[];
|
|
30
|
+
/**
|
|
31
|
+
* The reading that counts. A figure from before a known reset is dropped.
|
|
32
|
+
* Among confirmed live readings, the newest change wins. A first sight counts
|
|
33
|
+
* only when no confirmed reading is still live. Staleness is measured from
|
|
34
|
+
* `changedAt`. A stale reading inside the reserve refuses until its reset;
|
|
35
|
+
* with no reset time it is unknown.
|
|
36
|
+
*/
|
|
37
|
+
export declare function verdict(list: readonly Seen[], now: number, staleAfterMs: number, reserve: number | null): Verdict;
|
|
38
|
+
/** Readings whose reset has passed are left out. One with no reset time is kept. */
|
|
39
|
+
export declare function remember(list: readonly Seen[], now: number): Record<string, StoredReading>;
|
|
40
|
+
export declare function recall(stored: Record<string, StoredReading> | undefined): Seen[];
|
|
41
|
+
/** Write the readings that still count. The state file is the per-project cache. */
|
|
42
|
+
export declare function saveReadings(dir: string, session: string, list: readonly Seen[], now?: number): void;
|
|
43
|
+
export declare function loadReadings(dir: string, session: string): Seen[];
|
|
44
|
+
export declare function store(reading: Seen): StoredReading;
|
|
45
|
+
export declare function revive(stored: StoredReading): Seen;
|
|
46
|
+
/** When a duration such as `114h4m` ends, counted from a time. Null for no or an odd duration. */
|
|
47
|
+
export declare function resetsFrom(duration: string | null, now: number): number | null;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { emptySession, readState, updateState } from "../state.js";
|
|
2
|
+
/** Fold one seat's figure into the readings kept for this pass. */
|
|
3
|
+
export function observe(list, figure, seat, now) {
|
|
4
|
+
const next = list.slice();
|
|
5
|
+
const at = next.findIndex((item) => item.account === figure.account && item.window === figure.window && item.seat === seat);
|
|
6
|
+
const previous = at < 0 ? null : next[at] ?? null;
|
|
7
|
+
const same = previous !== null && previous.left === figure.left && previous.used === figure.used;
|
|
8
|
+
const agreed = next.some((item) => item !== previous
|
|
9
|
+
&& item.account === figure.account
|
|
10
|
+
&& item.window === figure.window
|
|
11
|
+
&& item.left === figure.left
|
|
12
|
+
&& item.used === figure.used
|
|
13
|
+
&& (item.resetsAt === null || item.resetsAt > now));
|
|
14
|
+
const reading = {
|
|
15
|
+
account: figure.account,
|
|
16
|
+
window: figure.window,
|
|
17
|
+
left: figure.left,
|
|
18
|
+
used: figure.used,
|
|
19
|
+
changedAt: same && previous ? previous.changedAt : now,
|
|
20
|
+
resetsAt: same && previous && previous.resetsAt !== null ? previous.resetsAt : resetsFrom(figure.resets, now),
|
|
21
|
+
seat,
|
|
22
|
+
confirmed: previous === null ? agreed : same ? previous.confirmed || agreed : true,
|
|
23
|
+
};
|
|
24
|
+
if (at < 0)
|
|
25
|
+
next.push(reading);
|
|
26
|
+
else
|
|
27
|
+
next[at] = reading;
|
|
28
|
+
return next;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The reading that counts. A figure from before a known reset is dropped.
|
|
32
|
+
* Among confirmed live readings, the newest change wins. A first sight counts
|
|
33
|
+
* only when no confirmed reading is still live. Staleness is measured from
|
|
34
|
+
* `changedAt`. A stale reading inside the reserve refuses until its reset;
|
|
35
|
+
* with no reset time it is unknown.
|
|
36
|
+
*/
|
|
37
|
+
export function verdict(list, now, staleAfterMs, reserve) {
|
|
38
|
+
const live = list.filter((item) => item.resetsAt === null || item.resetsAt > now);
|
|
39
|
+
const confirmed = live.filter((item) => item.confirmed);
|
|
40
|
+
const ranked = confirmed.length > 0 ? confirmed : live;
|
|
41
|
+
if (ranked.length === 0)
|
|
42
|
+
return { kind: 'unknown' };
|
|
43
|
+
const newest = Math.max(...ranked.map((item) => item.changedAt));
|
|
44
|
+
const tied = ranked.filter((item) => item.changedAt === newest);
|
|
45
|
+
const reading = tied.find((item) => item.confirmed) ?? tied[0];
|
|
46
|
+
if (!reading)
|
|
47
|
+
return { kind: 'unknown' };
|
|
48
|
+
if (!reading.confirmed)
|
|
49
|
+
return { kind: 'unconfirmed', reading };
|
|
50
|
+
if (now - reading.changedAt < staleAfterMs)
|
|
51
|
+
return { kind: 'fresh', reading };
|
|
52
|
+
const inside = reserve !== null && reading.left <= reserve;
|
|
53
|
+
if (inside && reading.resetsAt !== null)
|
|
54
|
+
return { kind: 'refusing', reading };
|
|
55
|
+
if (reading.resetsAt === null)
|
|
56
|
+
return { kind: 'unknown' };
|
|
57
|
+
return { kind: 'stale', reading };
|
|
58
|
+
}
|
|
59
|
+
/** Readings whose reset has passed are left out. One with no reset time is kept. */
|
|
60
|
+
export function remember(list, now) {
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const reading of list) {
|
|
63
|
+
if (reading.resetsAt !== null && reading.resetsAt <= now)
|
|
64
|
+
continue;
|
|
65
|
+
out[`${reading.account}/${reading.window}/${reading.seat}`] = store(reading);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
export function recall(stored) {
|
|
70
|
+
if (!stored)
|
|
71
|
+
return [];
|
|
72
|
+
return Object.values(stored).map(revive);
|
|
73
|
+
}
|
|
74
|
+
/** Write the readings that still count. The state file is the per-project cache. */
|
|
75
|
+
export function saveReadings(dir, session, list, now = Date.now()) {
|
|
76
|
+
updateState(dir, (state) => {
|
|
77
|
+
const current = state.sessions[session] ?? emptySession();
|
|
78
|
+
current.budgets = remember(list, now);
|
|
79
|
+
state.sessions[session] = current;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
export function loadReadings(dir, session) {
|
|
83
|
+
return recall(readState(dir).sessions[session]?.budgets);
|
|
84
|
+
}
|
|
85
|
+
export function store(reading) {
|
|
86
|
+
return {
|
|
87
|
+
account: reading.account,
|
|
88
|
+
window: reading.window,
|
|
89
|
+
left: reading.left,
|
|
90
|
+
used: reading.used,
|
|
91
|
+
changedAt: new Date(reading.changedAt).toISOString(),
|
|
92
|
+
resetsAt: reading.resetsAt === null ? null : new Date(reading.resetsAt).toISOString(),
|
|
93
|
+
seat: reading.seat,
|
|
94
|
+
confirmed: reading.confirmed,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export function revive(stored) {
|
|
98
|
+
return {
|
|
99
|
+
account: stored.account,
|
|
100
|
+
window: stored.window,
|
|
101
|
+
left: stored.left,
|
|
102
|
+
used: stored.used,
|
|
103
|
+
changedAt: Date.parse(stored.changedAt),
|
|
104
|
+
resetsAt: stored.resetsAt === null ? null : Date.parse(stored.resetsAt),
|
|
105
|
+
seat: stored.seat,
|
|
106
|
+
confirmed: stored.confirmed,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** When a duration such as `114h4m` ends, counted from a time. Null for no or an odd duration. */
|
|
110
|
+
export function resetsFrom(duration, now) {
|
|
111
|
+
if (duration === null)
|
|
112
|
+
return null;
|
|
113
|
+
const match = /^(?:([0-9]+)h)?(?:([0-9]+)m)?$/.exec(duration);
|
|
114
|
+
if (!match || (match[1] === undefined && match[2] === undefined))
|
|
115
|
+
return null;
|
|
116
|
+
const hours = Number(match[1] ?? 0);
|
|
117
|
+
const minutes = Number(match[2] ?? 0);
|
|
118
|
+
return now + (hours * 60 + minutes) * 60 * 1000;
|
|
119
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { BudgetAccount, TeamFile } from '../file/types.ts';
|
|
2
|
+
import type { WindowName } from '../profiles/quota.ts';
|
|
3
|
+
import { type ApprovedCheck } from './checks.ts';
|
|
4
|
+
/** One window of a subscription check reading. */
|
|
5
|
+
export type CheckWindow = {
|
|
6
|
+
window: WindowName;
|
|
7
|
+
left: number;
|
|
8
|
+
used: number;
|
|
9
|
+
/** The source's own measurement time: `at` when the line carries one, else the run's. */
|
|
10
|
+
at: number;
|
|
11
|
+
/** Anchored at the reading's own time, not at the run's. */
|
|
12
|
+
resetsAt: number | null;
|
|
13
|
+
};
|
|
14
|
+
export type CheckReading = {
|
|
15
|
+
kind: 'subscription';
|
|
16
|
+
windows: CheckWindow[];
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'spend';
|
|
19
|
+
amount: number;
|
|
20
|
+
currency: string;
|
|
21
|
+
at: number;
|
|
22
|
+
};
|
|
23
|
+
/** What one account's check reads this pass, or why it has no reading. */
|
|
24
|
+
export type CheckOutcome = {
|
|
25
|
+
account: string;
|
|
26
|
+
state: 'read';
|
|
27
|
+
reading: CheckReading;
|
|
28
|
+
} | {
|
|
29
|
+
account: string;
|
|
30
|
+
state: 'unreadable' | 'unapproved';
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* A subscription output against § 5. Any line off the contract, a figure over
|
|
34
|
+
* 100, or a window twice reads unknown — the whole output, not just the line.
|
|
35
|
+
* `at`, when printed, is the line's own time; a time later than the run is the run's.
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseSubscription(text: string, now: number): CheckReading | null;
|
|
38
|
+
/** A spend output against § 5: exactly one line, and in the floor's currency. */
|
|
39
|
+
export declare function parseSpend(text: string, currency: string, now: number): CheckReading | null;
|
|
40
|
+
/** One output against its account's contract. Null reads unknown for that account. */
|
|
41
|
+
export declare function parseOutput(account: BudgetAccount, text: string, now: number): CheckReading | null;
|
|
42
|
+
/**
|
|
43
|
+
* Runs one approved command and returns its output, or null: § 5 gives it an
|
|
44
|
+
* empty environment with PATH and HOME, a timeout, and exit 0; stderr is dropped.
|
|
45
|
+
* The timeout kills (SIGKILL: a command that traps TERM would otherwise run on,
|
|
46
|
+
* and `spawnSync` blocks the whole watch loop while it does). The command's bytes
|
|
47
|
+
* are the owner's, so they are returned to the parser only.
|
|
48
|
+
*/
|
|
49
|
+
export declare function runCommand(path: string, timeoutMs?: number): string | null;
|
|
50
|
+
/** One account's reading this pass: null when the command fails, times out, or breaks its contract. */
|
|
51
|
+
export declare function readCheck(account: BudgetAccount, path: string, now: number, timeoutMs?: number): CheckReading | null;
|
|
52
|
+
export type CheckRunner = (account: BudgetAccount, path: string, now: number) => CheckReading | null;
|
|
53
|
+
/**
|
|
54
|
+
* What each checked account reads this pass: its reading, or that it has none
|
|
55
|
+
* and why. `budgets` is the section in force: an account an unapproved edit
|
|
56
|
+
* took out is not checked, and one it changed reads under the approved entry.
|
|
57
|
+
* An account whose check is changed, missing or unapproved is not run at all
|
|
58
|
+
* (§ 5, #45). The path handed to `run` is the approved one, hashed again
|
|
59
|
+
* against the approval: the file's command is never run directly.
|
|
60
|
+
*/
|
|
61
|
+
export declare function checkOutcomes(budgets: TeamFile['budgets'], approved: Record<string, ApprovedCheck> | undefined, run: CheckRunner, now: number): CheckOutcome[];
|
|
62
|
+
/**
|
|
63
|
+
* What every checked account reads this pass, with the approval of this machine. The accounts are
|
|
64
|
+
* the ones the budgets in force name, never the file's unapproved edit of them.
|
|
65
|
+
*/
|
|
66
|
+
export declare function runChecks(team: TeamFile, root: string, now: number, home?: string, run?: CheckRunner): CheckOutcome[];
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// A `check` command's run and its output contract (RFC 0003 § 5). The raw output
|
|
2
|
+
// lives only here: it is parsed and dropped, and never returned, logged or shown.
|
|
3
|
+
// A check reads a vendor home; its bytes could carry anything the owner's tools know.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { budgetsInForce } from "../approve/approval.js";
|
|
7
|
+
import { readApproval, storePath } from "../store/store.js";
|
|
8
|
+
import { checkCommands, checkReadings } from "./checks.js";
|
|
9
|
+
import { resetsFrom } from "./readings.js";
|
|
10
|
+
// § 5: one line, an amount with at most four decimals, an uppercase currency.
|
|
11
|
+
const SPEND = /^([0-9]+(?:\.[0-9]{1,4})?) ([A-Z]{3})$/;
|
|
12
|
+
// § 5: one to three lines, each a window, each figure whole and at most 100. The
|
|
13
|
+
// printed reset is a duration; the printed measurement time is unix seconds.
|
|
14
|
+
const LINE = /^(session|daily|weekly) (100|[0-9]{1,2})% (used|left)(?: resets ([0-9]+h(?:[0-9]+m)?|[0-9]+m))?(?: at ([0-9]{10}))?$/;
|
|
15
|
+
/** An output's lines: one trailing newline allowed, and nothing else. */
|
|
16
|
+
function linesOf(text) {
|
|
17
|
+
const body = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
18
|
+
return body === '' ? null : body.split('\n');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A subscription output against § 5. Any line off the contract, a figure over
|
|
22
|
+
* 100, or a window twice reads unknown — the whole output, not just the line.
|
|
23
|
+
* `at`, when printed, is the line's own time; a time later than the run is the run's.
|
|
24
|
+
*/
|
|
25
|
+
export function parseSubscription(text, now) {
|
|
26
|
+
const lines = linesOf(text);
|
|
27
|
+
if (!lines || lines.length > 3)
|
|
28
|
+
return null;
|
|
29
|
+
const windows = [];
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
const match = LINE.exec(line);
|
|
32
|
+
if (!match)
|
|
33
|
+
return null;
|
|
34
|
+
const window = match[1];
|
|
35
|
+
if (windows.some((one) => one.window === window))
|
|
36
|
+
return null;
|
|
37
|
+
const value = Number(match[2]);
|
|
38
|
+
const used = match[3] === 'used' ? value : 100 - value;
|
|
39
|
+
const at = match[5] === undefined ? now : Math.min(Number(match[5]) * 1000, now);
|
|
40
|
+
windows.push({ window, left: 100 - used, used, at, resetsAt: match[4] === undefined ? null : resetsFrom(match[4], at) });
|
|
41
|
+
}
|
|
42
|
+
return windows.length ? { kind: 'subscription', windows } : null;
|
|
43
|
+
}
|
|
44
|
+
/** A spend output against § 5: exactly one line, and in the floor's currency. */
|
|
45
|
+
export function parseSpend(text, currency, now) {
|
|
46
|
+
const lines = linesOf(text);
|
|
47
|
+
if (!lines || lines.length !== 1)
|
|
48
|
+
return null;
|
|
49
|
+
const match = SPEND.exec(lines[0] ?? '');
|
|
50
|
+
if (!match || match[2] !== currency)
|
|
51
|
+
return null;
|
|
52
|
+
return { kind: 'spend', amount: Number(match[1]), currency: match[2], at: now };
|
|
53
|
+
}
|
|
54
|
+
/** One output against its account's contract. Null reads unknown for that account. */
|
|
55
|
+
export function parseOutput(account, text, now) {
|
|
56
|
+
if (account.kind === 'spend')
|
|
57
|
+
return account.floor === null ? null : parseSpend(text, account.floor.currency, now);
|
|
58
|
+
return parseSubscription(text, now);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Runs one approved command and returns its output, or null: § 5 gives it an
|
|
62
|
+
* empty environment with PATH and HOME, a timeout, and exit 0; stderr is dropped.
|
|
63
|
+
* The timeout kills (SIGKILL: a command that traps TERM would otherwise run on,
|
|
64
|
+
* and `spawnSync` blocks the whole watch loop while it does). The command's bytes
|
|
65
|
+
* are the owner's, so they are returned to the parser only.
|
|
66
|
+
*/
|
|
67
|
+
export function runCommand(path, timeoutMs = 10_000) {
|
|
68
|
+
try {
|
|
69
|
+
const result = spawnSync(path, [], {
|
|
70
|
+
encoding: 'utf8',
|
|
71
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
72
|
+
timeout: timeoutMs,
|
|
73
|
+
killSignal: 'SIGKILL',
|
|
74
|
+
env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '' },
|
|
75
|
+
});
|
|
76
|
+
if (result.error || result.status !== 0)
|
|
77
|
+
return null;
|
|
78
|
+
return result.stdout;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** One account's reading this pass: null when the command fails, times out, or breaks its contract. */
|
|
85
|
+
export function readCheck(account, path, now, timeoutMs = 10_000) {
|
|
86
|
+
const text = runCommand(path, timeoutMs);
|
|
87
|
+
return text === null ? null : parseOutput(account, text, now);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* What each checked account reads this pass: its reading, or that it has none
|
|
91
|
+
* and why. `budgets` is the section in force: an account an unapproved edit
|
|
92
|
+
* took out is not checked, and one it changed reads under the approved entry.
|
|
93
|
+
* An account whose check is changed, missing or unapproved is not run at all
|
|
94
|
+
* (§ 5, #45). The path handed to `run` is the approved one, hashed again
|
|
95
|
+
* against the approval: the file's command is never run directly.
|
|
96
|
+
*/
|
|
97
|
+
export function checkOutcomes(budgets, approved, run, now) {
|
|
98
|
+
const may = new Map(checkReadings(budgets, approved).map((one) => [one.account, one.state === 'approved']));
|
|
99
|
+
const outcomes = [];
|
|
100
|
+
for (const { account } of checkCommands(budgets)) {
|
|
101
|
+
const entry = budgets.accounts[account];
|
|
102
|
+
const known = approved?.[account];
|
|
103
|
+
if (entry === undefined || known === undefined || may.get(account) !== true) {
|
|
104
|
+
outcomes.push({ account, state: 'unapproved' });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const reading = run(entry, known.path, now);
|
|
108
|
+
outcomes.push(reading === null ? { account, state: 'unreadable' } : { account, state: 'read', reading });
|
|
109
|
+
}
|
|
110
|
+
return outcomes;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* What every checked account reads this pass, with the approval of this machine. The accounts are
|
|
114
|
+
* the ones the budgets in force name, never the file's unapproved edit of them.
|
|
115
|
+
*/
|
|
116
|
+
export function runChecks(team, root, now, home = homedir(), run = readCheck) {
|
|
117
|
+
const approved = readApproval(storePath(team.project, root, home))?.approval.checks;
|
|
118
|
+
return checkOutcomes(budgetsInForce(team, root, home), approved, run, now);
|
|
119
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TeamFile } from '../file/types.ts';
|
|
2
|
+
import type { WindowName } from '../profiles/quota.ts';
|
|
3
|
+
import { type Seen } from './readings.ts';
|
|
4
|
+
export type BudgetState = 'fresh' | 'unconfirmed' | 'stale' | 'refusing' | 'unknown';
|
|
5
|
+
export type BudgetRow = {
|
|
6
|
+
account: string;
|
|
7
|
+
window: WindowName | null;
|
|
8
|
+
left: number | null;
|
|
9
|
+
used: number | null;
|
|
10
|
+
resetsIn: string | null;
|
|
11
|
+
seat: string | null;
|
|
12
|
+
age: string | null;
|
|
13
|
+
source: 'status_line' | null;
|
|
14
|
+
state: BudgetState;
|
|
15
|
+
/** True when a subscription's left figure is at or inside its reserve. */
|
|
16
|
+
inside: boolean;
|
|
17
|
+
/** The reserve the row was read against, or null: the line and the JSON can't disagree. */
|
|
18
|
+
reserve: number | null;
|
|
19
|
+
};
|
|
20
|
+
/** Rows for the accounts the budgets in force name, and for any reading still in the state. */
|
|
21
|
+
export declare function budgetTable(budgets: TeamFile['budgets'], list: readonly Seen[], now: number): BudgetRow[];
|
|
22
|
+
/** An account's reserve: a subscription's own, and none for anything else. */
|
|
23
|
+
export declare function reserveOf(budgets: TeamFile['budgets'], account: string): number | null;
|
|
24
|
+
export declare function budgetLine(row: BudgetRow): string;
|