team 0.0.1 → 0.1.1
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 +24 -0
- package/dist/budgets/gate.js +127 -0
- package/dist/budgets/readings.d.ts +69 -0
- package/dist/budgets/readings.js +156 -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 +362 -0
- package/dist/commands/watch.d.ts +35 -0
- package/dist/commands/watch.js +324 -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 +27 -0
- package/dist/profiles/quota.js +57 -0
- package/dist/state.d.ts +61 -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 +42 -0
- package/dist/watch/pass.js +242 -0
- package/dist/watch/screen-core.d.ts +20 -0
- package/dist/watch/screen-core.js +356 -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 +24 -0
- package/dist/watch/screen.js +47 -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,24 @@
|
|
|
1
|
+
import type { Seat, TeamFile } from '../file/types.ts';
|
|
2
|
+
import { type Seen, type SpendReading } 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. `spend` holds the
|
|
17
|
+
* stored spend check readings; a subscription account never looks at them.
|
|
18
|
+
*/
|
|
19
|
+
export declare function seatBudget(budgets: TeamFile['budgets'], readings: readonly Seen[], seat: Seat, now: number, spend?: readonly SpendReading[]): LaunchDecision;
|
|
20
|
+
/**
|
|
21
|
+
* Accounts whose counted figures leave room: a subscription with every counted window outside
|
|
22
|
+
* its reserve, a spend account with a fresh reading above its floor.
|
|
23
|
+
*/
|
|
24
|
+
export declare function accountsWithRoom(budgets: TeamFile['budgets'], readings: readonly Seen[], now: number, spend?: readonly SpendReading[]): string[];
|
|
@@ -0,0 +1,127 @@
|
|
|
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. `spend` holds the
|
|
7
|
+
* stored spend check readings; a subscription account never looks at them.
|
|
8
|
+
*/
|
|
9
|
+
export function seatBudget(budgets, readings, seat, now, spend = []) {
|
|
10
|
+
const name = seat.vendor;
|
|
11
|
+
const account = budgets.accounts[name];
|
|
12
|
+
if (!account)
|
|
13
|
+
return { kind: 'clear' };
|
|
14
|
+
if (account.kind === 'spend') {
|
|
15
|
+
const floor = account.floor;
|
|
16
|
+
const reading = spend.find((item) => item.account === name);
|
|
17
|
+
// § 5's freshness rule for a check reading, applied to the floor: a reading with no floor to
|
|
18
|
+
// measure against, one read longer ago than `stale_after`, and one in another currency than
|
|
19
|
+
// the floor's are the same answer — unknown, said, never a refusal.
|
|
20
|
+
if (!floor || !reading || reading.currency !== floor.currency || now - reading.at >= budgets.staleAfter * 1000) {
|
|
21
|
+
return { kind: 'unknown', account: name, text: `${name} is unknown` };
|
|
22
|
+
}
|
|
23
|
+
if (reading.amount > floor.amount)
|
|
24
|
+
return { kind: 'clear' };
|
|
25
|
+
const room = accountsWithRoom(budgets, readings, now, spend).filter((accountName) => accountName !== name);
|
|
26
|
+
return {
|
|
27
|
+
kind: 'refuse',
|
|
28
|
+
why: `${name} spend ${money(reading.amount)} ${reading.currency}, at or below its ${money(floor.amount)} ${floor.currency} floor, read ${age(now - reading.at)} ago; accounts with room: ${room.length ? room.join(', ') : 'none'}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const staleAfterMs = budgets.staleAfter * 1000;
|
|
32
|
+
const mine = readings.filter((item) => item.account === name);
|
|
33
|
+
let unknown = mine.length === 0;
|
|
34
|
+
let unconfirmed = false;
|
|
35
|
+
let counted = false;
|
|
36
|
+
let worst = null;
|
|
37
|
+
for (const group of windowsOf(mine)) {
|
|
38
|
+
const result = verdict(group, now, staleAfterMs, account.reserve);
|
|
39
|
+
if (result.kind === 'unknown') {
|
|
40
|
+
unknown = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// A first sight is unconfirmed. It never refuses.
|
|
44
|
+
if (result.kind === 'unconfirmed') {
|
|
45
|
+
unconfirmed = true;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
counted = true;
|
|
49
|
+
const inside = account.reserve !== null && result.reading.left <= account.reserve;
|
|
50
|
+
if (!inside && result.kind !== 'refusing')
|
|
51
|
+
continue;
|
|
52
|
+
if (!worst
|
|
53
|
+
|| result.reading.left < worst.left
|
|
54
|
+
|| (result.reading.left === worst.left && RANK[result.reading.window] < RANK[worst.window])) {
|
|
55
|
+
worst = result.reading;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (worst && account.reserve !== null) {
|
|
59
|
+
const room = accountsWithRoom(budgets, readings, now).filter((accountName) => accountName !== name);
|
|
60
|
+
return {
|
|
61
|
+
kind: 'refuse',
|
|
62
|
+
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'}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (unknown)
|
|
66
|
+
return { kind: 'unknown', account: name, text: `${name} is unknown` };
|
|
67
|
+
if (unconfirmed && !counted)
|
|
68
|
+
return { kind: 'unknown', account: name, text: `${name}: first sight only, not yet counted` };
|
|
69
|
+
return { kind: 'clear' };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Accounts whose counted figures leave room: a subscription with every counted window outside
|
|
73
|
+
* its reserve, a spend account with a fresh reading above its floor.
|
|
74
|
+
*/
|
|
75
|
+
export function accountsWithRoom(budgets, readings, now, spend = []) {
|
|
76
|
+
const staleAfterMs = budgets.staleAfter * 1000;
|
|
77
|
+
const names = [];
|
|
78
|
+
for (const [name, account] of Object.entries(budgets.accounts)) {
|
|
79
|
+
if (account.kind === 'spend') {
|
|
80
|
+
const floor = account.floor;
|
|
81
|
+
const reading = spend.find((item) => item.account === name);
|
|
82
|
+
if (floor && reading && reading.currency === floor.currency && now - reading.at < staleAfterMs && reading.amount > floor.amount) {
|
|
83
|
+
names.push(name);
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (account.reserve === null)
|
|
88
|
+
continue;
|
|
89
|
+
const groups = windowsOf(readings.filter((item) => item.account === name));
|
|
90
|
+
let counted = 0;
|
|
91
|
+
let inside = false;
|
|
92
|
+
for (const group of groups) {
|
|
93
|
+
const result = verdict(group, now, staleAfterMs, account.reserve);
|
|
94
|
+
if (result.kind === 'unknown' || result.kind === 'unconfirmed')
|
|
95
|
+
continue;
|
|
96
|
+
counted += 1;
|
|
97
|
+
if (result.kind === 'refusing' || result.reading.left <= account.reserve)
|
|
98
|
+
inside = true;
|
|
99
|
+
}
|
|
100
|
+
if (counted > 0 && !inside)
|
|
101
|
+
names.push(name);
|
|
102
|
+
}
|
|
103
|
+
return names.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
104
|
+
}
|
|
105
|
+
function windowsOf(list) {
|
|
106
|
+
const groups = new Map();
|
|
107
|
+
for (const reading of list) {
|
|
108
|
+
const group = groups.get(reading.window) ?? [];
|
|
109
|
+
group.push(reading);
|
|
110
|
+
groups.set(reading.window, group);
|
|
111
|
+
}
|
|
112
|
+
return [...groups.values()];
|
|
113
|
+
}
|
|
114
|
+
function age(ms) {
|
|
115
|
+
const minutes = Math.max(0, Math.floor(ms / 60_000));
|
|
116
|
+
const hours = Math.floor(minutes / 60);
|
|
117
|
+
const rest = minutes % 60;
|
|
118
|
+
if (hours && rest)
|
|
119
|
+
return `${hours}h${rest}m`;
|
|
120
|
+
if (hours)
|
|
121
|
+
return `${hours}h`;
|
|
122
|
+
return `${minutes}m`;
|
|
123
|
+
}
|
|
124
|
+
/** A money figure in a refusal: the cents are shown even when the file wrote none. */
|
|
125
|
+
function money(amount) {
|
|
126
|
+
return amount.toFixed(2);
|
|
127
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
/** A spend check reading (RFC 0003 § 5): the money the account still holds. */
|
|
29
|
+
export type SpendReading = {
|
|
30
|
+
account: string;
|
|
31
|
+
amount: number;
|
|
32
|
+
currency: string;
|
|
33
|
+
/** When the check ran: a spend line carries no time of its own (§ 5). */
|
|
34
|
+
at: number;
|
|
35
|
+
};
|
|
36
|
+
export type StoredSpend = {
|
|
37
|
+
account: string;
|
|
38
|
+
amount: number;
|
|
39
|
+
currency: string;
|
|
40
|
+
at: string;
|
|
41
|
+
};
|
|
42
|
+
/** Fold one seat's figure into the readings kept for this pass. */
|
|
43
|
+
export declare function observe(list: readonly Seen[], figure: QuotaFigure, seat: string, now: number): Seen[];
|
|
44
|
+
/**
|
|
45
|
+
* The reading that counts. A figure from before a known reset is dropped.
|
|
46
|
+
* Among confirmed live readings, the newest change wins. A first sight counts
|
|
47
|
+
* only when no confirmed reading is still live. Staleness is measured from
|
|
48
|
+
* `changedAt`. A stale reading inside the reserve refuses until its reset;
|
|
49
|
+
* with no reset time it is unknown.
|
|
50
|
+
*/
|
|
51
|
+
export declare function verdict(list: readonly Seen[], now: number, staleAfterMs: number, reserve: number | null): Verdict;
|
|
52
|
+
/** Readings whose reset has passed are left out. One with no reset time is kept. */
|
|
53
|
+
export declare function remember(list: readonly Seen[], now: number): Record<string, StoredReading>;
|
|
54
|
+
export declare function recall(stored: Record<string, StoredReading> | undefined): Seen[];
|
|
55
|
+
/** Write the readings that still count. The state file is the per-project cache. */
|
|
56
|
+
export declare function saveReadings(dir: string, session: string, list: readonly Seen[], now?: number): void;
|
|
57
|
+
export declare function loadReadings(dir: string, session: string): Seen[];
|
|
58
|
+
/**
|
|
59
|
+
* Write the spend readings a pass's checks read, merging by account: an account whose check did
|
|
60
|
+
* not run this pass keeps the reading the state already holds. Nothing read, nothing written.
|
|
61
|
+
*/
|
|
62
|
+
export declare function saveSpendReadings(dir: string, session: string, list: readonly SpendReading[]): void;
|
|
63
|
+
export declare function loadSpendReadings(dir: string, session: string): SpendReading[];
|
|
64
|
+
export declare function storeSpend(reading: SpendReading): StoredSpend;
|
|
65
|
+
export declare function recallSpend(stored: Record<string, StoredSpend> | undefined): SpendReading[];
|
|
66
|
+
export declare function store(reading: Seen): StoredReading;
|
|
67
|
+
export declare function revive(stored: StoredReading): Seen;
|
|
68
|
+
/** When a duration such as `114h4m` ends, counted from a time. Null for no or an odd duration. */
|
|
69
|
+
export declare function resetsFrom(duration: string | null, now: number): number | null;
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
/**
|
|
86
|
+
* Write the spend readings a pass's checks read, merging by account: an account whose check did
|
|
87
|
+
* not run this pass keeps the reading the state already holds. Nothing read, nothing written.
|
|
88
|
+
*/
|
|
89
|
+
export function saveSpendReadings(dir, session, list) {
|
|
90
|
+
if (!list.length)
|
|
91
|
+
return;
|
|
92
|
+
updateState(dir, (state) => {
|
|
93
|
+
const current = state.sessions[session] ?? emptySession();
|
|
94
|
+
const spend = current.spend ?? {};
|
|
95
|
+
for (const reading of list)
|
|
96
|
+
spend[reading.account] = storeSpend(reading);
|
|
97
|
+
current.spend = spend;
|
|
98
|
+
state.sessions[session] = current;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
export function loadSpendReadings(dir, session) {
|
|
102
|
+
return recallSpend(readState(dir).sessions[session]?.spend);
|
|
103
|
+
}
|
|
104
|
+
export function storeSpend(reading) {
|
|
105
|
+
return {
|
|
106
|
+
account: reading.account,
|
|
107
|
+
amount: reading.amount,
|
|
108
|
+
currency: reading.currency,
|
|
109
|
+
at: new Date(reading.at).toISOString(),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export function recallSpend(stored) {
|
|
113
|
+
if (!stored)
|
|
114
|
+
return [];
|
|
115
|
+
return Object.values(stored).map((one) => ({
|
|
116
|
+
account: one.account,
|
|
117
|
+
amount: one.amount,
|
|
118
|
+
currency: one.currency,
|
|
119
|
+
at: Date.parse(one.at),
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
export function store(reading) {
|
|
123
|
+
return {
|
|
124
|
+
account: reading.account,
|
|
125
|
+
window: reading.window,
|
|
126
|
+
left: reading.left,
|
|
127
|
+
used: reading.used,
|
|
128
|
+
changedAt: new Date(reading.changedAt).toISOString(),
|
|
129
|
+
resetsAt: reading.resetsAt === null ? null : new Date(reading.resetsAt).toISOString(),
|
|
130
|
+
seat: reading.seat,
|
|
131
|
+
confirmed: reading.confirmed,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export function revive(stored) {
|
|
135
|
+
return {
|
|
136
|
+
account: stored.account,
|
|
137
|
+
window: stored.window,
|
|
138
|
+
left: stored.left,
|
|
139
|
+
used: stored.used,
|
|
140
|
+
changedAt: Date.parse(stored.changedAt),
|
|
141
|
+
resetsAt: stored.resetsAt === null ? null : Date.parse(stored.resetsAt),
|
|
142
|
+
seat: stored.seat,
|
|
143
|
+
confirmed: stored.confirmed,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** When a duration such as `114h4m` ends, counted from a time. Null for no or an odd duration. */
|
|
147
|
+
export function resetsFrom(duration, now) {
|
|
148
|
+
if (duration === null)
|
|
149
|
+
return null;
|
|
150
|
+
const match = /^(?:([0-9]+)h)?(?:([0-9]+)m)?$/.exec(duration);
|
|
151
|
+
if (!match || (match[1] === undefined && match[2] === undefined))
|
|
152
|
+
return null;
|
|
153
|
+
const hours = Number(match[1] ?? 0);
|
|
154
|
+
const minutes = Number(match[2] ?? 0);
|
|
155
|
+
return now + (hours * 60 + minutes) * 60 * 1000;
|
|
156
|
+
}
|
|
@@ -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[];
|