@dzhechkov/harness-core 0.8.24 → 0.8.26

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.
Files changed (64) hide show
  1. package/.dz-manifest.json +153 -33
  2. package/README.md +150 -4
  3. package/dist/confirmation-file-gate.d.ts +20 -0
  4. package/dist/confirmation-file-gate.d.ts.map +1 -0
  5. package/dist/confirmation-file-gate.js +76 -0
  6. package/dist/confirmation-file-gate.js.map +1 -0
  7. package/dist/contract-checklist.d.ts +1 -0
  8. package/dist/contract-checklist.d.ts.map +1 -1
  9. package/dist/contract-checklist.js +7 -4
  10. package/dist/contract-checklist.js.map +1 -1
  11. package/dist/core-boundary.d.ts +11 -0
  12. package/dist/core-boundary.d.ts.map +1 -0
  13. package/dist/core-boundary.js +41 -0
  14. package/dist/core-boundary.js.map +1 -0
  15. package/dist/feature-adr-landing.d.ts +4 -2
  16. package/dist/feature-adr-landing.d.ts.map +1 -1
  17. package/dist/feature-adr-landing.js +5 -3
  18. package/dist/feature-adr-landing.js.map +1 -1
  19. package/dist/feature-tier.d.ts +4 -0
  20. package/dist/feature-tier.d.ts.map +1 -0
  21. package/dist/feature-tier.js +55 -0
  22. package/dist/feature-tier.js.map +1 -0
  23. package/dist/index.d.ts +7 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +6 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/journal.d.ts +27 -0
  28. package/dist/journal.d.ts.map +1 -0
  29. package/dist/journal.js +56 -0
  30. package/dist/journal.js.map +1 -0
  31. package/dist/loop-blobs.generated.d.ts +1 -1
  32. package/dist/loop-blobs.generated.d.ts.map +1 -1
  33. package/dist/loop-blobs.generated.js +10 -1
  34. package/dist/loop-blobs.generated.js.map +1 -1
  35. package/dist/patterns.d.ts +5 -0
  36. package/dist/patterns.d.ts.map +1 -1
  37. package/dist/patterns.js +16 -0
  38. package/dist/patterns.js.map +1 -1
  39. package/dist/publish.d.ts +19 -0
  40. package/dist/publish.d.ts.map +1 -1
  41. package/dist/publish.js +38 -16
  42. package/dist/publish.js.map +1 -1
  43. package/dist/run-cleanup.d.ts +26 -0
  44. package/dist/run-cleanup.d.ts.map +1 -0
  45. package/dist/run-cleanup.js +33 -0
  46. package/dist/run-cleanup.js.map +1 -0
  47. package/dist/run-registry.d.ts +61 -0
  48. package/dist/run-registry.d.ts.map +1 -0
  49. package/dist/run-registry.js +163 -0
  50. package/dist/run-registry.js.map +1 -0
  51. package/package.json +7 -7
  52. package/sbom.json +332 -32
  53. package/src/confirmation-file-gate.ts +85 -0
  54. package/src/contract-checklist.ts +11 -5
  55. package/src/core-boundary.ts +43 -0
  56. package/src/feature-adr-landing.ts +8 -4
  57. package/src/feature-tier.ts +56 -0
  58. package/src/index.ts +9 -1
  59. package/src/journal.ts +54 -0
  60. package/src/loop-blobs.generated.ts +10 -1
  61. package/src/patterns.ts +18 -0
  62. package/src/publish.ts +49 -13
  63. package/src/run-cleanup.ts +38 -0
  64. package/src/run-registry.ts +153 -0
@@ -0,0 +1,153 @@
1
+ import { join } from 'node:path';
2
+
3
+ export const RUN_REGISTRY_BLOB_VERSION = '1.0.0';
4
+
5
+ export type RunEvent = {
6
+ event: 'started' | 'heartbeat' | 'finished'; runId: string; ts: string;
7
+ kind?: string; slug?: string; pid?: number; parentRunId?: string;
8
+ outcome?: string; reason?: string; truncated?: boolean;
9
+ };
10
+ export type RegisteredRun = RunEvent & { heartbeat?: string; finished?: string };
11
+ export type RunLiveness = { state: 'live' | 'orphaned' | 'inconclusive' | 'stalled'; reason: string };
12
+ export type RunRegistry = { status: 'readable' | 'missing' | 'inconclusive'; reason?: string; runs: RegisteredRun[]; events?: RunEvent[] };
13
+ export type RunRegistryIO = {
14
+ append(path: string, line: string): void;
15
+ read(path: string): string;
16
+ mkdir(dir: string): void;
17
+ };
18
+ export type PidProbe = (pid: number) => boolean | null;
19
+
20
+ export function probePid(pid: number, kill: (pid: number, signal: 0) => unknown = process.kill): boolean | null {
21
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
22
+ try { kill(pid, 0); return true; }
23
+ catch (error) { return (error as NodeJS.ErrnoException).code === 'ESRCH' ? false : null; }
24
+ }
25
+
26
+ export function validateRunEvent(raw: unknown): asserts raw is RunEvent {
27
+ if (!raw || typeof raw !== 'object') throw new Error('invalid run event');
28
+ const e = raw as RunEvent;
29
+ const text = (s: unknown) => typeof s === 'string' && s.trim().length > 0;
30
+ if (!['started', 'heartbeat', 'finished'].includes(e.event) || !text(e.runId) ||
31
+ !text(e.ts) || !Number.isFinite(Date.parse(e.ts))) throw new Error('invalid run event identity/time');
32
+ if (e.event === 'started' && (!text(e.kind) || !text(e.slug) || !Number.isSafeInteger(e.pid) || e.pid! <= 0))
33
+ throw new Error('started requires kind, slug and positive PID');
34
+ if (e.event === 'finished' && !text(e.outcome)) throw new Error('finished requires outcome');
35
+ if (e.parentRunId !== undefined && (!text(e.parentRunId) || e.parentRunId === e.runId)) throw new Error('invalid parentRunId');
36
+ if (e.reason !== undefined && typeof e.reason !== 'string') throw new Error('invalid reason');
37
+ }
38
+
39
+ export function appendRunEvent(root: string, ev: RunEvent, io: RunRegistryIO): void {
40
+ validateRunEvent(ev);
41
+ const bounded = { ...ev };
42
+ let line = JSON.stringify(bounded) + '\n';
43
+ if (Buffer.byteLength(line) >= 4096) {
44
+ bounded.truncated = true;
45
+ // Preserve identity and lifecycle fields. Only diagnostic text may be shortened.
46
+ const chars = Array.from(bounded.reason ?? '');
47
+ let lo = 0, hi = chars.length;
48
+ bounded.reason = '';
49
+ if (Buffer.byteLength(JSON.stringify(bounded) + '\n') >= 4096) throw new Error('run event identity exceeds PIPE_BUF');
50
+ while (lo < hi) {
51
+ const mid = Math.ceil((lo + hi) / 2);
52
+ bounded.reason = chars.slice(0, mid).join('');
53
+ if (Buffer.byteLength(JSON.stringify(bounded) + '\n') < 4096) lo = mid; else hi = mid - 1;
54
+ }
55
+ bounded.reason = chars.slice(0, lo).join('');
56
+ line = JSON.stringify(bounded) + '\n';
57
+ }
58
+ io.mkdir(join(root, '.dz', 'runs'));
59
+ io.append(join(root, '.dz', 'runs', 'registry.jsonl'), line);
60
+ }
61
+
62
+ export function readRunRegistry(root: string, io: RunRegistryIO): RunRegistry {
63
+ const runs = new Map<string, RegisteredRun>();
64
+ const events: RunEvent[] = [];
65
+ try {
66
+ const text = io.read(join(root, '.dz', 'runs', 'registry.jsonl'));
67
+ for (const line of text.split('\n')) {
68
+ if (!line.trim()) continue;
69
+ const ev: unknown = JSON.parse(line);
70
+ validateRunEvent(ev);
71
+ events.push(ev);
72
+ const run = runs.get(ev.runId);
73
+ if (ev.event === 'started') {
74
+ if (run) throw new Error('duplicate started: ' + ev.runId);
75
+ runs.set(ev.runId, { ...ev });
76
+ } else {
77
+ if (!run) throw new Error('event without started: ' + ev.runId);
78
+ if (ev.event === 'heartbeat') run.heartbeat = ev.ts;
79
+ else Object.assign(run, { finished: ev.ts, ...(ev.outcome === undefined ? {} : { outcome: ev.outcome }) });
80
+ }
81
+ }
82
+ return { status: 'readable', runs: [...runs.values()], events };
83
+ } catch (error) {
84
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing', runs: [] };
85
+ return { status: 'inconclusive', reason: 'registry-unreadable: ' + String(error), runs: [...runs.values()] };
86
+ }
87
+ }
88
+
89
+ export function liveness(run: RegisteredRun | undefined, now: number, probe: PidProbe = probePid, opts: { stallMs?: number } = {}): RunLiveness {
90
+ if (!run || run.event !== 'started') return { state: 'inconclusive', reason: 'missing-started' };
91
+ if (run.finished) return { state: 'orphaned', reason: 'recorded-finished' };
92
+ let alive: boolean | null = null;
93
+ try { alive = run.pid === undefined ? null : probe(run.pid); } catch { /* inaccessible */ }
94
+ if (alive === null) return { state: 'inconclusive', reason: 'pid-unavailable' };
95
+ const stallMs = opts.stallMs ?? 120 * 60_000;
96
+ if (alive && run.heartbeat && now - Date.parse(run.heartbeat) > stallMs) {
97
+ return { state: 'stalled', reason: `heartbeat ${(now - Date.parse(run.heartbeat)) / 60_000}m > ${stallMs / 60_000}m, pid alive` };
98
+ }
99
+ return alive ? { state: 'live', reason: 'pid-alive' } : { state: 'orphaned', reason: 'pid-absent' };
100
+ }
101
+
102
+ export function liveParents(registry: RunRegistry, now: number, probe: PidProbe = probePid, opts: { stallMs?: number } = {}):
103
+ Array<{ runId: string; parentRunId?: string; liveness: RunLiveness }> {
104
+ const byId = new Map(registry.runs.map(run => [run.runId, run]));
105
+ return registry.runs.map(run => {
106
+ let decision: RunLiveness;
107
+ if (registry.status !== 'readable') decision = { state: 'inconclusive', reason: registry.reason ?? 'registry-unreadable' };
108
+ else if (run.parentRunId) {
109
+ const parent = byId.get(run.parentRunId);
110
+ decision = parent?.finished ? { state: 'orphaned', reason: 'parent-finished' } : liveness(parent, now, probe, opts);
111
+ } else decision = liveness(run, now, probe, opts);
112
+ return { runId: run.runId, ...(run.parentRunId === undefined ? {} : { parentRunId: run.parentRunId }), liveness: decision };
113
+ });
114
+ }
115
+
116
+ /** Pure shell command assembly; projected into the Workflow sandbox by the blob generator. */
117
+ export function runRecordCommand(dz: string, root: string, event: string, runId: string,
118
+ slug: string, pid: number | null, parentRunId: string | null, outcome: string): string {
119
+ const quote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'";
120
+ let cmd = dz + ' runs-record --project ' + quote(root) + ' --event ' + quote(event) + (runId ? ' --run-id ' + quote(runId) : '');
121
+ if (event === 'started') {
122
+ cmd += ' --kind feature-adr --slug ' + quote(slug);
123
+ // Only an explicitly supplied host PID is authoritative. Never record the short-lived shell PID.
124
+ cmd += ' --pid ' + quote(pid === null ? 'host' : String(pid));
125
+ if (parentRunId) cmd += ' --parent-run-id ' + quote(parentRunId);
126
+ }
127
+ if (event === 'finished') cmd += ' --outcome ' + quote(outcome);
128
+ return cmd + ' --json';
129
+ }
130
+
131
+ /** Confirmed absence alone permits a terminal event; callers append the returned events. */
132
+ export function settleDeadRuns(registry: RunRegistry, now: number, probe: PidProbe = probePid): RunEvent[] {
133
+ if (registry.status !== 'readable') return [];
134
+ const ts = new Date(now).toISOString();
135
+ return registry.runs.filter(run => !run.finished && liveness(run, now, probe).state === 'orphaned')
136
+ .map(run => ({ event: 'finished', runId: run.runId, ts, outcome: 'died',
137
+ reason: `pid ${run.pid} absent, confirmed ${ts}` }));
138
+ }
139
+
140
+ /** Keep whole histories, using the newest event's timestamp rather than the start time. */
141
+ export function planRegistryArchive(registry: RunRegistry,
142
+ opts: { now: number; retentionMs: number; probe: PidProbe }): { archive: RunEvent[]; keep: RunEvent[] } {
143
+ const events = registry.events ?? [];
144
+ if (registry.status !== 'readable') return { archive: [], keep: [...events] };
145
+ const last = new Map<string, number>();
146
+ for (const ev of events) last.set(ev.runId, Math.max(last.get(ev.runId) ?? -Infinity, Date.parse(ev.ts)));
147
+ const eligible = new Set(registry.runs.filter(run => {
148
+ const ts = last.get(run.runId);
149
+ return ts !== undefined && opts.now - ts > opts.retentionMs &&
150
+ (run.finished || liveness(run, opts.now, opts.probe).state === 'orphaned');
151
+ }).map(run => run.runId));
152
+ return { archive: events.filter(ev => eligible.has(ev.runId)), keep: events.filter(ev => !eligible.has(ev.runId)) };
153
+ }