@tokenfactory/acc-runner 0.47.17 → 0.47.18

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.
@@ -0,0 +1,66 @@
1
+ // Type surface for audit-terminal-claims-core.mjs, so the fixture test can
2
+ // import it under NodeNext + strict without allowJs.
3
+ export interface AuditSource { read: boolean; error?: string; earliest?: string | null }
4
+ export interface AuditEvent { id: string; target_id: string; created_at: string; verb: string; payload: Record<string, unknown> }
5
+ export interface AuditTaskEvent { task_id: string; created_at: string; kind: string; payload: Record<string, unknown> }
6
+ export interface AuditTask { id: string; status: string; pr_number?: number | null; repo?: string | null; branch?: string | null; runner_id?: string | null }
7
+ export interface AuditLock { task_id: string; runner_id: string; locked_at?: string }
8
+ export interface AuditRunner { id: string; current_task_id: string | null; heartbeat_at?: string | null }
9
+
10
+ export interface AuditBound {
11
+ statement: string;
12
+ reads: string[];
13
+ cannot_see: string[];
14
+ earliest_retained: Record<string, string | null>;
15
+ unread_sources: Array<{ source: string; error: string }>;
16
+ }
17
+
18
+ export interface AuditRow {
19
+ task_id: string;
20
+ pr_number: number | null;
21
+ terminal_status: string;
22
+ terminal_at: string;
23
+ evidence: Array<'claim_lock_held_past_terminal' | 'pane_output_after_terminal'>;
24
+ claim: { record: string | null; granted_at: string | null; runner_id: string | null; runner_id_source: string };
25
+ lock: { released_at: string | null; right_censored: boolean; persisted_ms: number } | null;
26
+ pane_output_after_terminal: { events: number; first_at: string; last_at: string; persisted_ms: number } | null;
27
+ cancellation_event: { verb: string; at: string; elapsed_ms: unknown; token_spend: unknown; token_spend_status: unknown } | null;
28
+ claim_persisted_ms: number;
29
+ pane_active_after_terminal_ms: number;
30
+ repo: string | null;
31
+ branch: string | null;
32
+ commit_after_terminal: boolean | null;
33
+ }
34
+
35
+ export interface AuditReport {
36
+ bound: AuditBound;
37
+ date_range: { from_inclusive: string; to_exclusive: string };
38
+ terminal_tasks_examined: number;
39
+ task_rows_examined: number;
40
+ count: number;
41
+ total_claim_time_ms: number;
42
+ total_pane_time_after_terminal_ms: number;
43
+ rows: AuditRow[];
44
+ current_terminal_locks: AuditLock[];
45
+ runners_pointing_at_terminal_tasks: Array<{ runner_id: string; current_task_id: string; heartbeat_at: string | null }>;
46
+ }
47
+
48
+ export const TERMINAL_AUDIT_STATUSES: string[];
49
+ export const CLAIM_VERB: string;
50
+ export const RELEASE_VERB: string;
51
+ export const STATUS_VERB: string;
52
+ export const CANCELLATION_VERBS: string[];
53
+ export const AUDIT_VERBS: string[];
54
+ export const AUDIT_READS: string[];
55
+ export const AUDIT_BLIND_SPOTS: string[];
56
+ export function auditBound(input: { sources: Record<string, AuditSource> }): AuditBound;
57
+ export function analyzeTerminalClaims(input: {
58
+ from: string;
59
+ to: string;
60
+ events: AuditEvent[];
61
+ taskEvents: AuditTaskEvent[];
62
+ tasks: AuditTask[];
63
+ locks: AuditLock[];
64
+ runners: AuditRunner[];
65
+ sources: Record<string, AuditSource>;
66
+ }): AuditReport;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Pure analysis for audit-terminal-claims.mjs: given the retained records the
3
+ * script read, list the tasks that reached merged/archived while a runner still
4
+ * held a claim or file lock, and state what those records cannot show.
5
+ *
6
+ * No I/O here, so the fixture test can drive it without a database.
7
+ */
8
+
9
+ export const TERMINAL_AUDIT_STATUSES = ['merged', 'archived'];
10
+ export const CLAIM_VERB = 'task.claim_locks_granted';
11
+ export const RELEASE_VERB = 'task.locks_released';
12
+ export const STATUS_VERB = 'task.status_changed';
13
+ export const CANCELLATION_VERBS = ['runner.terminal_cancelled', 'runner.write_refused'];
14
+ export const AUDIT_VERBS = [STATUS_VERB, CLAIM_VERB, RELEASE_VERB, ...CANCELLATION_VERBS];
15
+
16
+ /** The fixed part of the bound. The per-run part (earliest retained rows, unread sources) is added by auditBound. */
17
+ export const AUDIT_READS = [
18
+ 'acc.activity_events and acc.activity_events_archive (attached partitions): task.status_changed to merged/archived; '
19
+ + 'task.claim_locks_granted, the claim record acc.claim_task_with_locks writes when a runner claims a task (the claim and its file lock are one record); '
20
+ + 'task.locks_released; runner.terminal_cancelled and runner.write_refused',
21
+ 'acc.task_events kind=log with a stdout/stderr stream, for the terminal tasks only: pane output written after the terminal transition',
22
+ 'acc.tasks: status, pr_number, repo, branch, runner_id (runner_id is attribution kept after completion, not proof of a live claim)',
23
+ 'acc.task_file_locks and acc.runners.current_task_id: the current snapshot only, with no history',
24
+ ];
25
+
26
+ export const AUDIT_BLIND_SPOTS = [
27
+ 'Claims whose claim/release events were rolled off acc.activity_events and whose archive partition was detached; nothing older than the earliest retained event below can be seen.',
28
+ 'Claims taken by any path that wrote no task.claim_locks_granted event.',
29
+ 'A pane that kept running after the terminal transition released its lock (the release happens in the same transaction) and wrote no task_events and no cancellation event: work or spend with no output is invisible.',
30
+ 'task_events removed with their task (on delete cascade) and any row the reading identity cannot see under RLS.',
31
+ 'Past values of acc.runners.current_task_id; only the current pointer is read.',
32
+ 'Commits on deleted or force-pushed branches, and push times (the commit probe reads committer timestamps).',
33
+ ];
34
+
35
+ const ms = (iso) => Date.parse(iso);
36
+ const isPaneOutput = (e) => e.kind === 'log' && (e.payload?.stream === 'stdout' || e.payload?.stream === 'stderr');
37
+
38
+ /**
39
+ * @param {{ sources: Record<string, { read: boolean, error?: string, earliest?: string | null }> }} input
40
+ */
41
+ export function auditBound({ sources }) {
42
+ const unread = Object.entries(sources)
43
+ .filter(([, s]) => !s.read)
44
+ .map(([source, s]) => ({ source, error: s.error ?? 'not read' }));
45
+ const earliestRetained = Object.fromEntries(
46
+ Object.entries(sources).filter(([, s]) => s.read && 'earliest' in s).map(([source, s]) => [source, s.earliest ?? null]),
47
+ );
48
+ return {
49
+ statement: 'Bounded audit: it reports only what the retained claim and lock records below can show. '
50
+ + 'A task missing from rows is not shown to be clean; it may sit in a blind spot. '
51
+ + 'Intervals with no release are censored at TO_ISO.',
52
+ reads: AUDIT_READS,
53
+ cannot_see: AUDIT_BLIND_SPOTS,
54
+ earliest_retained: earliestRetained,
55
+ unread_sources: unread,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * @param {{
61
+ * from: string, to: string,
62
+ * events: Array<{ id: string, target_id: string, created_at: string, verb: string, payload: Record<string, unknown> }>,
63
+ * taskEvents: Array<{ task_id: string, created_at: string, kind: string, payload: Record<string, unknown> }>,
64
+ * tasks: Array<{ id: string, status: string, pr_number?: number | null, repo?: string | null, branch?: string | null, runner_id?: string | null }>,
65
+ * locks: Array<{ task_id: string, runner_id: string, locked_at?: string }>,
66
+ * runners: Array<{ id: string, current_task_id: string | null, heartbeat_at?: string | null }>,
67
+ * sources: Record<string, { read: boolean, error?: string, earliest?: string | null }>,
68
+ * }} input
69
+ */
70
+ export function analyzeTerminalClaims({ from, to, events, taskEvents, tasks, locks, runners, sources }) {
71
+ const fromMs = ms(from);
72
+ const toMs = ms(to);
73
+ const taskById = new Map(tasks.map((t) => [t.id, t]));
74
+ const byTask = new Map();
75
+ for (const e of [...events].sort((a, b) => ms(a.created_at) - ms(b.created_at))) {
76
+ if (!byTask.has(e.target_id)) byTask.set(e.target_id, []);
77
+ byTask.get(e.target_id).push(e);
78
+ }
79
+ const paneByTask = new Map();
80
+ for (const e of taskEvents.filter(isPaneOutput)) {
81
+ if (!paneByTask.has(e.task_id)) paneByTask.set(e.task_id, []);
82
+ paneByTask.get(e.task_id).push(e);
83
+ }
84
+
85
+ // One row per task, anchored at its first merged/archived transition in the window.
86
+ const terminals = new Map();
87
+ for (const [taskId, history] of byTask) {
88
+ const first = history.find((e) => e.verb === STATUS_VERB
89
+ && TERMINAL_AUDIT_STATUSES.includes(e.payload?.to)
90
+ && ms(e.created_at) >= fromMs && ms(e.created_at) < toMs);
91
+ if (first) terminals.set(taskId, first);
92
+ }
93
+
94
+ const rows = [];
95
+ for (const [taskId, terminal] of terminals) {
96
+ const at = ms(terminal.created_at);
97
+ const history = byTask.get(taskId);
98
+ const grant = history.filter((e) => e.verb === CLAIM_VERB && ms(e.created_at) <= at).at(-1) ?? null;
99
+ const release = grant
100
+ ? history.find((e) => e.verb === RELEASE_VERB && ms(e.created_at) >= ms(grant.created_at)) ?? null
101
+ : null;
102
+ // The terminal transition releases locks in its own transaction, so a release
103
+ // stamped at or before the terminal event is zero overrun.
104
+ const lockHeld = !!grant && !(release && ms(release.created_at) <= at);
105
+ const paneAfter = (paneByTask.get(taskId) ?? [])
106
+ .filter((e) => ms(e.created_at) > at && ms(e.created_at) < toMs)
107
+ .sort((a, b) => ms(a.created_at) - ms(b.created_at));
108
+ if (!lockHeld && paneAfter.length === 0) continue;
109
+
110
+ const task = taskById.get(taskId);
111
+ const lockPersistedMs = lockHeld ? Math.min(release ? ms(release.created_at) : toMs, toMs) - at : 0;
112
+ const panePersistedMs = paneAfter.length ? ms(paneAfter.at(-1).created_at) - at : 0;
113
+ const cancellation = history.find((e) => CANCELLATION_VERBS.includes(e.verb) && ms(e.created_at) >= at) ?? null;
114
+ rows.push({
115
+ task_id: taskId,
116
+ pr_number: task?.pr_number ?? null,
117
+ terminal_status: terminal.payload.to,
118
+ terminal_at: terminal.created_at,
119
+ evidence: [lockHeld ? 'claim_lock_held_past_terminal' : null, paneAfter.length ? 'pane_output_after_terminal' : null].filter(Boolean),
120
+ claim: grant
121
+ ? { record: CLAIM_VERB, granted_at: grant.created_at, runner_id: grant.payload?.runner_id ?? null, runner_id_source: 'claim_event' }
122
+ : { record: null, granted_at: null, runner_id: task?.runner_id ?? null, runner_id_source: 'tasks.runner_id (attribution only; claim record not retained)' },
123
+ lock: grant
124
+ ? { released_at: release?.created_at ?? null, right_censored: lockHeld && !release, persisted_ms: lockPersistedMs }
125
+ : null,
126
+ pane_output_after_terminal: paneAfter.length
127
+ ? { events: paneAfter.length, first_at: paneAfter[0].created_at, last_at: paneAfter.at(-1).created_at, persisted_ms: panePersistedMs }
128
+ : null,
129
+ cancellation_event: cancellation
130
+ ? {
131
+ verb: cancellation.verb, at: cancellation.created_at,
132
+ elapsed_ms: cancellation.payload?.elapsed_ms ?? null,
133
+ token_spend: cancellation.payload?.token_spend ?? null,
134
+ token_spend_status: cancellation.payload?.token_spend_status ?? null,
135
+ }
136
+ : null,
137
+ // Claim time is the lock held past terminal, nothing else: pane output
138
+ // after the lock was released is work, not a held claim, and is reported
139
+ // separately so the two are never summed into one figure.
140
+ claim_persisted_ms: lockPersistedMs,
141
+ pane_active_after_terminal_ms: panePersistedMs,
142
+ repo: task?.repo ?? null,
143
+ branch: task?.branch ?? null,
144
+ commit_after_terminal: null,
145
+ });
146
+ }
147
+ rows.sort((a, b) => ms(a.terminal_at) - ms(b.terminal_at));
148
+
149
+ const isTerminal = (id) => TERMINAL_AUDIT_STATUSES.includes(taskById.get(id)?.status);
150
+ return {
151
+ bound: auditBound({ sources }),
152
+ date_range: { from_inclusive: from, to_exclusive: to },
153
+ terminal_tasks_examined: terminals.size,
154
+ task_rows_examined: tasks.length,
155
+ count: rows.length,
156
+ total_claim_time_ms: rows.reduce((sum, row) => sum + row.claim_persisted_ms, 0),
157
+ total_pane_time_after_terminal_ms: rows.reduce((sum, row) => sum + row.pane_active_after_terminal_ms, 0),
158
+ rows,
159
+ current_terminal_locks: locks.filter((lock) => isTerminal(lock.task_id)),
160
+ runners_pointing_at_terminal_tasks: runners
161
+ .filter((r) => r.current_task_id && isTerminal(r.current_task_id))
162
+ .map((r) => ({ runner_id: r.id, current_task_id: r.current_task_id, heartbeat_at: r.heartbeat_at ?? null })),
163
+ };
164
+ }
@@ -0,0 +1,98 @@
1
+ /** Report only. Usage: node packages/acc-runner/scripts/audit-terminal-claims.mjs FROM_ISO TO_ISO
2
+ * Uses the runner's stored login and SELECTs only. No ACC/GitHub mutations.
3
+ *
4
+ * BOUND: this audit sees only the claim and lock records that are still retained
5
+ * and visible to the runner's identity under RLS. It reads task.claim_locks_granted
6
+ * (the claim record; a claim and its file lock are one row), task.locks_released,
7
+ * task.status_changed and runner cancellation events from acc.activity_events plus
8
+ * the attached partitions of acc.activity_events_archive; pane output from
9
+ * acc.task_events; and the current acc.task_file_locks / acc.runners.current_task_id
10
+ * snapshot. It cannot see claims whose events were purged or detached, claims taken
11
+ * by a path that wrote no claim event, a pane that worked silently after its lock was
12
+ * released, or past current_task_id values. The full statement, with the earliest
13
+ * retained row per source and any source that could not be read, is printed as
14
+ * `bound` at the top of the report.
15
+ */
16
+ import { execFile } from 'node:child_process';
17
+ import { promisify } from 'node:util';
18
+ import { getTokenProvider } from '../dist/token-provider.js';
19
+ import { createRunnerClient } from '../dist/supabase.js';
20
+ import { loadConfig, applyBootstrapConfig } from '../dist/config.js';
21
+ import { AUDIT_VERBS, TERMINAL_AUDIT_STATUSES, STATUS_VERB, analyzeTerminalClaims } from './audit-terminal-claims-core.mjs';
22
+
23
+ const [from, to] = process.argv.slice(2);
24
+ if (!from || !to || !Number.isFinite(Date.parse(from)) || !Number.isFinite(Date.parse(to)) || Date.parse(from) >= Date.parse(to)) {
25
+ throw new Error('Supply an increasing FROM_ISO TO_ISO date range');
26
+ }
27
+ const session = await getTokenProvider().load();
28
+ if (!session) throw new Error('Runner login is required; audit not run');
29
+ const cfg = applyBootstrapConfig(loadConfig(), session);
30
+ const db = createRunnerClient(cfg, session.access_token);
31
+ const exec = promisify(execFile);
32
+
33
+ async function all(table, columns, orderBy, filter = query => query) {
34
+ const out = [];
35
+ for (let offset = 0; ; offset += 500) {
36
+ const result = await filter(db.from(table).select(columns)).order(orderBy).range(offset, offset + 499);
37
+ if (result.error) throw new Error(`${table} read failed: ${result.error.message}`);
38
+ out.push(...result.data);
39
+ if (result.data.length < 500) return out;
40
+ }
41
+ }
42
+ async function earliestOf(table) {
43
+ const r = await db.from(table).select('created_at').order('created_at').limit(1);
44
+ if (r.error) throw new Error(`${table} read failed: ${r.error.message}`);
45
+ return r.data?.[0]?.created_at ?? null;
46
+ }
47
+
48
+ // Required sources fail the run; optional ones are recorded as unread in the bound.
49
+ const sources = {};
50
+ async function source(name, read, { required = false, withEarliest = true } = {}) {
51
+ try {
52
+ const rows = await read();
53
+ sources[name] = withEarliest ? { read: true, earliest: await earliestOf(name) } : { read: true };
54
+ return rows;
55
+ } catch (err) {
56
+ if (required) throw err;
57
+ sources[name] = { read: false, error: err.message };
58
+ return [];
59
+ }
60
+ }
61
+
62
+ const eventColumns = 'id,target_id,created_at,verb,payload';
63
+ const byVerb = query => query.in('verb', AUDIT_VERBS).lt('created_at', to);
64
+ const hot = await source('activity_events', () => all('activity_events', eventColumns, 'id', byVerb), { required: true });
65
+ const archived = await source('activity_events_archive', () => all('activity_events_archive', eventColumns, 'id', byVerb));
66
+ const seen = new Set(hot.map(e => e.id));
67
+ const events = [...hot, ...archived.filter(e => !seen.has(e.id))];
68
+ const tasks = await source('tasks', () => all('tasks', 'id,status,pr_number,repo,branch,runner_id', 'id'), { required: true, withEarliest: false });
69
+ const locks = await source('task_file_locks', () => all('task_file_locks', 'task_id,runner_id,locked_at', 'task_id'), { required: true, withEarliest: false });
70
+ const runners = await source('runners', () => all('runners', 'id,current_task_id,heartbeat_at', 'id'), { withEarliest: false });
71
+
72
+ const terminalIds = [...new Set(events
73
+ .filter(e => e.verb === STATUS_VERB && TERMINAL_AUDIT_STATUSES.includes(e.payload?.to)
74
+ && Date.parse(e.created_at) >= Date.parse(from) && Date.parse(e.created_at) < Date.parse(to))
75
+ .map(e => e.target_id))];
76
+ const taskEvents = await source('task_events', async () => {
77
+ const out = [];
78
+ for (let i = 0; i < terminalIds.length; i += 100) {
79
+ out.push(...await all('task_events', 'id,task_id,created_at,kind,payload', 'id', query => query
80
+ .in('task_id', terminalIds.slice(i, i + 100)).eq('kind', 'log').lt('created_at', to)));
81
+ }
82
+ return out;
83
+ });
84
+
85
+ const report = analyzeTerminalClaims({ from, to, events, taskEvents, tasks, locks, runners, sources });
86
+ for (const row of report.rows) {
87
+ if (!row.repo || !row.branch) { row.commit_probe = 'missing repo/branch'; continue; }
88
+ const endpoint = `repos/${row.repo}/commits?sha=${encodeURIComponent(row.branch)}&since=${encodeURIComponent(row.terminal_at)}&until=${encodeURIComponent(to)}&per_page=100`;
89
+ try {
90
+ const { stdout } = await exec('gh', ['api', '--paginate', endpoint, '--jq', '.[] | [.sha, .commit.committer.date] | @tsv'], { timeout: 60000, maxBuffer: 16 * 1024 * 1024 });
91
+ const commits = stdout.trim().split('\n').filter(Boolean).map(line => line.split('\t'))
92
+ .filter(([, date]) => Date.parse(date) > Date.parse(row.terminal_at));
93
+ row.commit_after_terminal = commits.length > 0;
94
+ row.commit_shas_after_terminal = commits.map(([sha]) => sha);
95
+ row.commit_probe = 'branch history by committer timestamp; not a push-time audit';
96
+ } catch { row.commit_probe = 'unavailable (deleted branch, permissions, or network); not inferred false'; }
97
+ }
98
+ console.log(JSON.stringify({ project: new URL(cfg.supabaseUrl).hostname, ...report }, null, 2));