@maolon/pi-watcher 0.0.0-stage → 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.
Files changed (72) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +255 -2
  4. package/dist/cli.d.ts +8 -0
  5. package/dist/cli.js +369 -0
  6. package/dist/contracts/interfaces.d.ts +262 -0
  7. package/dist/contracts/interfaces.js +1 -0
  8. package/dist/contracts/policy-defaults.json +61 -0
  9. package/dist/contracts/relay-next.interfaces.d.ts +160 -0
  10. package/dist/contracts/relay-next.interfaces.js +1 -0
  11. package/dist/engine/cards.d.ts +68 -0
  12. package/dist/engine/cards.js +76 -0
  13. package/dist/engine/engine.d.ts +178 -0
  14. package/dist/engine/engine.js +1162 -0
  15. package/dist/engine/hard-rules.d.ts +53 -0
  16. package/dist/engine/hard-rules.js +96 -0
  17. package/dist/engine/semantic.d.ts +49 -0
  18. package/dist/engine/semantic.js +125 -0
  19. package/dist/engine/service.d.ts +62 -0
  20. package/dist/engine/service.js +587 -0
  21. package/dist/engine/tool-actions.d.ts +35 -0
  22. package/dist/engine/tool-actions.js +348 -0
  23. package/dist/engine/widget.d.ts +29 -0
  24. package/dist/engine/widget.js +52 -0
  25. package/dist/ipc/client.d.ts +26 -0
  26. package/dist/ipc/client.js +106 -0
  27. package/dist/ipc/server.d.ts +78 -0
  28. package/dist/ipc/server.js +105 -0
  29. package/dist/jev/client.d.ts +38 -0
  30. package/dist/jev/client.js +239 -0
  31. package/dist/jev/consent.d.ts +12 -0
  32. package/dist/jev/consent.js +37 -0
  33. package/dist/jev/index.d.ts +9 -0
  34. package/dist/jev/index.js +9 -0
  35. package/dist/jev/mock.d.ts +30 -0
  36. package/dist/jev/mock.js +77 -0
  37. package/dist/jev/pi-registry.d.ts +66 -0
  38. package/dist/jev/pi-registry.js +127 -0
  39. package/dist/jev/questions.d.ts +15 -0
  40. package/dist/jev/questions.js +76 -0
  41. package/dist/jev/sanitizer.d.ts +10 -0
  42. package/dist/jev/sanitizer.js +59 -0
  43. package/dist/jev/types.d.ts +78 -0
  44. package/dist/jev/types.js +54 -0
  45. package/dist/pi-extension.d.ts +123 -0
  46. package/dist/pi-extension.js +687 -0
  47. package/dist/relay/managed.d.ts +120 -0
  48. package/dist/relay/managed.js +482 -0
  49. package/dist/relay/negotiate.d.ts +39 -0
  50. package/dist/relay/negotiate.js +112 -0
  51. package/dist/runtime.d.ts +74 -0
  52. package/dist/runtime.js +246 -0
  53. package/dist/source/agent-check/adapter.d.ts +57 -0
  54. package/dist/source/agent-check/adapter.js +224 -0
  55. package/dist/source/agent-file/adapter.d.ts +57 -0
  56. package/dist/source/agent-file/adapter.js +217 -0
  57. package/dist/source/task-status-v1/adapter.d.ts +36 -0
  58. package/dist/source/task-status-v1/adapter.js +263 -0
  59. package/dist/source/task-status-v1/producer.d.ts +81 -0
  60. package/dist/source/task-status-v1/producer.js +127 -0
  61. package/dist/storage/lock.d.ts +14 -0
  62. package/dist/storage/lock.js +66 -0
  63. package/dist/storage/schema.sql +166 -0
  64. package/dist/storage/store.d.ts +352 -0
  65. package/dist/storage/store.js +555 -0
  66. package/dist/util/clock.d.ts +23 -0
  67. package/dist/util/clock.js +34 -0
  68. package/dist/util/ids.d.ts +8 -0
  69. package/dist/util/ids.js +29 -0
  70. package/dist/util/result.d.ts +28 -0
  71. package/dist/util/result.js +54 -0
  72. package/package.json +101 -4
@@ -0,0 +1,263 @@
1
+ /**
2
+ * task-status-v1 read-only SourceAdapter (design 4.1/4.2).
3
+ * - snapshot is published via atomic replace; journal is JSONL with stable sequence numbers
4
+ * - cursor stores file identity + byte offset + lastSeq + snapshotSeq
5
+ * - only complete JSON lines are accepted; a partial line waits for the next round
6
+ * - truncation/rotation changes the stream generation -> gap=true; the old cursor is not applied directly
7
+ */
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import { createHash } from 'node:crypto';
11
+ function fileIdentity(absPath) {
12
+ try {
13
+ const st = fs.statSync(absPath);
14
+ return `${st.dev}:${st.ino}`;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export class TaskStatusV1Adapter {
21
+ rootDir;
22
+ adapterId;
23
+ capabilities = {
24
+ stableAttempt: true,
25
+ terminalEvents: true,
26
+ incrementalCursor: true,
27
+ exclusiveNotificationOwner: true
28
+ };
29
+ timeoutMs;
30
+ maxBytes;
31
+ constructor(rootDir, options = {}) {
32
+ this.rootDir = rootDir;
33
+ this.adapterId = options.adapterId ?? 'task-status-v1';
34
+ this.timeoutMs = options.defaultTimeoutMs ?? 5000;
35
+ this.maxBytes = options.defaultMaxBytes ?? 65536;
36
+ }
37
+ targetDir(target) {
38
+ return path.join(this.rootDir, target.sourceId, target.taskId, target.runId, target.attemptId);
39
+ }
40
+ async read(target, cursor, signal) {
41
+ if (signal.aborted)
42
+ throw signal.reason ?? new Error('aborted');
43
+ const cur = normalizeCursor(cursor);
44
+ const dir = this.targetDir(target);
45
+ const journalPath = path.join(dir, 'journal.jsonl');
46
+ const snapshotPath = path.join(dir, 'snapshot.json');
47
+ let gap = false;
48
+ let offset = cur.offset;
49
+ let lastSeq = cur.lastSeq;
50
+ const nextCursor = { fileId: cur.fileId, offset, lastSeq, snapshotSeq: cur.snapshotSeq };
51
+ const observations = [];
52
+ // --- journal (JSONL with stable sequence numbers) ---
53
+ const journalId = fileIdentity(journalPath);
54
+ if (journalId !== null) {
55
+ if (cur.fileId !== null && cur.fileId !== journalId) {
56
+ // stream generation change (rotation/replacement): gap, re-read from the start, dedup by store
57
+ gap = true;
58
+ offset = 0;
59
+ }
60
+ const size = fs.statSync(journalPath).size;
61
+ if (offset > size) {
62
+ // in-place truncation
63
+ gap = true;
64
+ offset = 0;
65
+ }
66
+ if (size > offset) {
67
+ const fh = fs.openSync(journalPath, 'r');
68
+ try {
69
+ const readLen = Math.min(size - offset, this.maxBytes);
70
+ const buf = Buffer.alloc(readLen);
71
+ const bytesRead = fs.readSync(fh, buf, 0, readLen, offset);
72
+ const text = buf.subarray(0, bytesRead).toString('utf8');
73
+ const lines = text.split('\n');
74
+ // last element is '' (complete line) or a partial line: a partial line does not advance offset and waits for the next round
75
+ lines.pop();
76
+ let advanced = 0;
77
+ const events = [];
78
+ for (const line of lines) {
79
+ const trimmed = line.trim();
80
+ const lineLen = Buffer.byteLength(line, 'utf8') + 1;
81
+ if (trimmed === '') {
82
+ advanced += lineLen;
83
+ continue;
84
+ }
85
+ try {
86
+ events.push(JSON.parse(trimmed));
87
+ }
88
+ catch {
89
+ // complete line but invalid JSON: audit gap, does not advance state conclusions
90
+ gap = true;
91
+ }
92
+ advanced += lineLen;
93
+ }
94
+ offset += advanced;
95
+ for (const ev of events) {
96
+ if (typeof ev.sourceSeq !== 'number' || ev.sourceSeq <= lastSeq)
97
+ continue;
98
+ lastSeq = ev.sourceSeq;
99
+ observations.push(journalObservation(target, ev));
100
+ }
101
+ }
102
+ finally {
103
+ fs.closeSync(fh);
104
+ }
105
+ }
106
+ nextCursor.offset = offset;
107
+ nextCursor.lastSeq = lastSeq;
108
+ nextCursor.fileId = journalId;
109
+ }
110
+ else if (cur.fileId !== null) {
111
+ // journal disappeared -> source gap
112
+ gap = true;
113
+ nextCursor.fileId = null;
114
+ }
115
+ // --- snapshot (atomic replace; a parse failure does not overwrite the last valid state) ---
116
+ if (fs.existsSync(snapshotPath)) {
117
+ let snapshot = null;
118
+ try {
119
+ snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf8'));
120
+ }
121
+ catch {
122
+ snapshot = null;
123
+ }
124
+ if (snapshot && typeof snapshot.snapshotSeq === 'number' && snapshot.snapshotSeq > cur.snapshotSeq) {
125
+ nextCursor.snapshotSeq = snapshot.snapshotSeq;
126
+ observations.push(snapshotObservation(target, snapshot));
127
+ }
128
+ }
129
+ observations.sort((a, b) => a.sourceSeq - b.sourceSeq);
130
+ return { observations, nextCursor: nextCursor, gap };
131
+ }
132
+ async probes(target, _basis) {
133
+ const dir = this.targetDir(target);
134
+ const expiresAt = new Date(Date.now() + 60_000).toISOString();
135
+ const base = { target, scopeId: dir, expiresAt, timeoutMs: this.timeoutMs };
136
+ return [
137
+ { probeId: 'task-status-v1:read-status', revision: 1, kind: 'read-status', maxBytes: 64 * 1024, ...base },
138
+ { probeId: 'task-status-v1:read-log-delta', revision: 1, kind: 'read-log-delta', maxBytes: 128 * 1024, ...base },
139
+ { probeId: 'task-status-v1:read-artifact-manifest', revision: 1, kind: 'read-artifact-manifest', maxBytes: 64 * 1024, ...base },
140
+ { probeId: 'task-status-v1:read-validation-result', revision: 1, kind: 'read-validation-result', maxBytes: 64 * 1024, ...base }
141
+ ];
142
+ }
143
+ async execute(probe, signal) {
144
+ if (signal.aborted)
145
+ throw signal.reason ?? new Error('aborted');
146
+ const dir = probe.scopeId;
147
+ const nowIso = new Date().toISOString();
148
+ const mk = (kind, payload) => ({
149
+ schemaVersion: 1,
150
+ observationId: `obs-probe-${probe.probeId}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`,
151
+ target: probe.target,
152
+ sourceSeq: 0,
153
+ observedAt: nowIso,
154
+ kind,
155
+ payload,
156
+ evidenceRefs: []
157
+ });
158
+ const readSnapshot = () => {
159
+ const p = path.join(dir, 'snapshot.json');
160
+ if (!fs.existsSync(p))
161
+ return null;
162
+ try {
163
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ };
169
+ switch (probe.kind) {
170
+ case 'read-status': {
171
+ const snap = readSnapshot();
172
+ return [mk('status', snap ? snap : { state: 'unknown', note: 'no snapshot' })];
173
+ }
174
+ case 'read-log-delta': {
175
+ const p = path.join(dir, 'journal.jsonl');
176
+ if (!fs.existsSync(p))
177
+ return [mk('log_delta', { lines: [], note: 'no journal' })];
178
+ const lines = fs.readFileSync(p, 'utf8').split('\n').filter(l => l.trim() !== '').slice(-32);
179
+ return [mk('log_delta', { lines })];
180
+ }
181
+ case 'read-artifact-manifest': {
182
+ const snap = readSnapshot();
183
+ const payload = { artifacts: snap?.artifacts ?? [] };
184
+ if (!snap)
185
+ payload.note = 'no snapshot';
186
+ return [mk('artifact_manifest', payload)];
187
+ }
188
+ case 'read-validation-result': {
189
+ const snap = readSnapshot();
190
+ const payload = { checks: snap?.checks ?? [] };
191
+ if (!snap)
192
+ payload.note = 'no snapshot';
193
+ return [mk('validation', payload)];
194
+ }
195
+ default:
196
+ return [];
197
+ }
198
+ }
199
+ async close() {
200
+ /* nothing to close */
201
+ }
202
+ }
203
+ function normalizeCursor(cursor) {
204
+ if (!cursor || typeof cursor !== 'object')
205
+ return { fileId: null, offset: 0, lastSeq: 0, snapshotSeq: 0 };
206
+ const c = cursor;
207
+ return {
208
+ fileId: typeof c.fileId === 'string' ? c.fileId : null,
209
+ offset: typeof c.offset === 'number' ? c.offset : 0,
210
+ lastSeq: typeof c.lastSeq === 'number' ? c.lastSeq : 0,
211
+ snapshotSeq: typeof c.snapshotSeq === 'number' ? c.snapshotSeq : 0
212
+ };
213
+ }
214
+ function evidenceRefsFor(target, ev) {
215
+ const text = JSON.stringify({ target, seq: ev.sourceSeq, at: ev.at, kind: ev.kind, payload: ev.payload });
216
+ const sha = createHash('sha256').update(text).digest('hex');
217
+ return [
218
+ {
219
+ evidenceId: `ev-${sha.slice(0, 16)}`,
220
+ sourceId: target.sourceId,
221
+ capturedAt: ev.at,
222
+ sha256: sha,
223
+ bytes: Buffer.byteLength(text, 'utf8'),
224
+ sampled: false
225
+ }
226
+ ];
227
+ }
228
+ function journalObservation(target, ev) {
229
+ return {
230
+ schemaVersion: 1,
231
+ observationId: `obs-${target.sourceId}-${target.runId}-${ev.sourceSeq}`,
232
+ target,
233
+ sourceSeq: ev.sourceSeq,
234
+ observedAt: ev.at,
235
+ kind: ev.kind,
236
+ payload: ev.payload,
237
+ evidenceRefs: evidenceRefsFor(target, ev)
238
+ };
239
+ }
240
+ function snapshotObservation(target, snap) {
241
+ return {
242
+ schemaVersion: 1,
243
+ observationId: `obs-${target.sourceId}-${target.runId}-snap-${snap.snapshotSeq}`,
244
+ target,
245
+ sourceSeq: snap.snapshotSeq,
246
+ observedAt: snap.updatedAt,
247
+ kind: 'status',
248
+ payload: {
249
+ state: snap.state,
250
+ stage: snap.stage,
251
+ exitCode: snap.exitCode,
252
+ summary: snap.summary,
253
+ checks: snap.checks,
254
+ artifacts: snap.artifacts
255
+ },
256
+ evidenceRefs: evidenceRefsFor(target, {
257
+ sourceSeq: snap.snapshotSeq,
258
+ at: snap.updatedAt,
259
+ kind: 'status',
260
+ payload: { state: snap.state }
261
+ })
262
+ };
263
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * task-status-v1 fixture producer (design 4.1).
3
+ * Real async process: publishes the snapshot via a same-directory temp file + atomic replace; the journal is JSONL with stable sequence numbers.
4
+ * The watcher only reads these files and is not responsible for task execution.
5
+ */
6
+ export type TaskState = 'unknown' | 'queued' | 'running' | 'blocked' | 'succeeded' | 'failed' | 'cancelled';
7
+ export interface TaskStatusSnapshot {
8
+ schemaVersion: 1;
9
+ target: {
10
+ kind: 'run';
11
+ sourceId: string;
12
+ taskId: string;
13
+ runId: string;
14
+ attemptId: string;
15
+ };
16
+ state: TaskState;
17
+ stage: string | null;
18
+ exitCode: number | null;
19
+ summary: string | null;
20
+ checks: Array<{
21
+ checkId: string;
22
+ outcome: 'passed' | 'failed' | 'skipped' | 'unknown';
23
+ artifactDigest: string | null;
24
+ }>;
25
+ artifacts: Array<{
26
+ artifactId: string;
27
+ sha256: string;
28
+ sourceRevision: number;
29
+ }>;
30
+ snapshotSeq: number;
31
+ updatedAt: string;
32
+ }
33
+ export interface TaskStatusJournalEvent {
34
+ schemaVersion: 1;
35
+ sourceSeq: number;
36
+ at: string;
37
+ kind: 'status' | 'heartbeat' | 'log_delta' | 'validation' | 'artifact_manifest' | 'decision_request';
38
+ payload: Record<string, unknown>;
39
+ }
40
+ export declare function runDir(rootDir: string, target: {
41
+ sourceId: string;
42
+ taskId: string;
43
+ runId: string;
44
+ attemptId: string;
45
+ }): string;
46
+ export declare class TaskStatusProducer {
47
+ private readonly journalId;
48
+ private seq;
49
+ private readonly dir;
50
+ private readonly target;
51
+ private snapshot;
52
+ constructor(rootDir: string, target: {
53
+ sourceId: string;
54
+ taskId: string;
55
+ runId: string;
56
+ attemptId: string;
57
+ }, journalId?: string);
58
+ private nextSeq;
59
+ private nowIso;
60
+ appendEvent(kind: TaskStatusJournalEvent['kind'], payload: Record<string, unknown>): Promise<number>;
61
+ /** Atomic snapshot replace: tmp file + rename in the same directory. */
62
+ publishSnapshot(patch: Partial<Omit<TaskStatusSnapshot, 'schemaVersion' | 'target' | 'snapshotSeq' | 'updatedAt'>>): Promise<number>;
63
+ get lastSnapshot(): TaskStatusSnapshot | null;
64
+ }
65
+ export interface ProducerStep {
66
+ waitMs: number;
67
+ event?: {
68
+ kind: TaskStatusJournalEvent['kind'];
69
+ payload: Record<string, unknown>;
70
+ };
71
+ snapshot?: Partial<Omit<TaskStatusSnapshot, 'schemaVersion' | 'target' | 'snapshotSeq' | 'updatedAt'>>;
72
+ }
73
+ /** Real async fixture scenarios. */
74
+ export declare function runScenario(rootDir: string, target: {
75
+ sourceId: string;
76
+ taskId: string;
77
+ runId: string;
78
+ attemptId: string;
79
+ }, steps: ProducerStep[]): Promise<TaskStatusProducer>;
80
+ /** Standard demo scenarios: build-success / build-failure. */
81
+ export declare function scenarioSteps(kind: 'build-success' | 'build-failure', intervalMs?: number): ProducerStep[];
@@ -0,0 +1,127 @@
1
+ /**
2
+ * task-status-v1 fixture producer (design 4.1).
3
+ * Real async process: publishes the snapshot via a same-directory temp file + atomic replace; the journal is JSONL with stable sequence numbers.
4
+ * The watcher only reads these files and is not responsible for task execution.
5
+ */
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import { randomUUID } from 'node:crypto';
9
+ export function runDir(rootDir, target) {
10
+ return path.join(rootDir, target.sourceId, target.taskId, target.runId, target.attemptId);
11
+ }
12
+ export class TaskStatusProducer {
13
+ journalId;
14
+ seq = 0;
15
+ dir;
16
+ target;
17
+ snapshot = null;
18
+ constructor(rootDir, target, journalId = `journal-${randomUUID().slice(0, 8)}`) {
19
+ this.journalId = journalId;
20
+ this.dir = runDir(rootDir, target);
21
+ this.target = { kind: 'run', ...target };
22
+ fs.mkdirSync(this.dir, { recursive: true });
23
+ }
24
+ nextSeq() {
25
+ this.seq += 1;
26
+ return this.seq;
27
+ }
28
+ nowIso() {
29
+ return new Date().toISOString();
30
+ }
31
+ async appendEvent(kind, payload) {
32
+ const sourceSeq = this.nextSeq();
33
+ const event = {
34
+ schemaVersion: 1,
35
+ sourceSeq,
36
+ at: this.nowIso(),
37
+ kind,
38
+ payload
39
+ };
40
+ await fs.promises.appendFile(path.join(this.dir, 'journal.jsonl'), JSON.stringify(event) + '\n', 'utf8');
41
+ return sourceSeq;
42
+ }
43
+ /** Atomic snapshot replace: tmp file + rename in the same directory. */
44
+ async publishSnapshot(patch) {
45
+ const snapshotSeq = this.nextSeq();
46
+ const prev = this.snapshot;
47
+ this.snapshot = {
48
+ schemaVersion: 1,
49
+ target: this.target,
50
+ state: patch.state ?? prev?.state ?? 'unknown',
51
+ stage: patch.stage !== undefined ? patch.stage : (prev?.stage ?? null),
52
+ exitCode: patch.exitCode !== undefined ? patch.exitCode : (prev?.exitCode ?? null),
53
+ summary: patch.summary !== undefined ? patch.summary : (prev?.summary ?? null),
54
+ checks: patch.checks !== undefined ? patch.checks : (prev?.checks ?? []),
55
+ artifacts: patch.artifacts !== undefined ? patch.artifacts : (prev?.artifacts ?? []),
56
+ snapshotSeq,
57
+ updatedAt: this.nowIso()
58
+ };
59
+ const tmp = path.join(this.dir, `.snapshot.${snapshotSeq}.tmp`);
60
+ await fs.promises.writeFile(tmp, JSON.stringify(this.snapshot, null, 2), 'utf8');
61
+ await fs.promises.rename(tmp, path.join(this.dir, 'snapshot.json'));
62
+ return snapshotSeq;
63
+ }
64
+ get lastSnapshot() {
65
+ return this.snapshot;
66
+ }
67
+ }
68
+ /** Real async fixture scenarios. */
69
+ export async function runScenario(rootDir, target, steps) {
70
+ const producer = new TaskStatusProducer(rootDir, target);
71
+ for (const step of steps) {
72
+ if (step.waitMs > 0) {
73
+ await new Promise(resolve => setTimeout(resolve, step.waitMs));
74
+ }
75
+ if (step.event) {
76
+ await producer.appendEvent(step.event.kind, step.event.payload);
77
+ }
78
+ if (step.snapshot) {
79
+ await producer.publishSnapshot(step.snapshot);
80
+ }
81
+ }
82
+ return producer;
83
+ }
84
+ /** Standard demo scenarios: build-success / build-failure. */
85
+ export function scenarioSteps(kind, intervalMs = 200) {
86
+ if (kind === 'build-success') {
87
+ return [
88
+ { waitMs: intervalMs, snapshot: { state: 'running', stage: 'compile', summary: 'compiling' } },
89
+ { waitMs: intervalMs, event: { kind: 'log_delta', payload: { lines: ['cc -c main.c', 'cc -c util.c'] } } },
90
+ { waitMs: intervalMs, snapshot: { state: 'running', stage: 'test', summary: 'compile finished, running tests' } },
91
+ { waitMs: intervalMs, event: { kind: 'validation', payload: { checkId: 'unit-tests', outcome: 'passed', artifactDigest: 'deadbeef01' } } },
92
+ {
93
+ waitMs: intervalMs,
94
+ snapshot: {
95
+ state: 'succeeded',
96
+ stage: 'done',
97
+ exitCode: 0,
98
+ summary: 'build and tests completed',
99
+ checks: [{ checkId: 'unit-tests', outcome: 'passed', artifactDigest: 'deadbeef01' }],
100
+ artifacts: [{ artifactId: 'artifact-app', sha256: 'a'.repeat(64), sourceRevision: 1 }]
101
+ }
102
+ }
103
+ ];
104
+ }
105
+ return [
106
+ { waitMs: intervalMs, snapshot: { state: 'running', stage: 'compile', summary: 'compiling' } },
107
+ { waitMs: intervalMs, event: { kind: 'log_delta', payload: { lines: ['cc -c main.c'] } } },
108
+ {
109
+ waitMs: intervalMs,
110
+ event: {
111
+ kind: 'decision_request',
112
+ payload: { text: 'Compiler reports that the requested API does not exist. Need the host to choose which API contract to target.' }
113
+ }
114
+ },
115
+ {
116
+ waitMs: intervalMs,
117
+ snapshot: {
118
+ state: 'failed',
119
+ stage: 'compile',
120
+ exitCode: 2,
121
+ summary: 'executor reports build failure; not yet attributed.',
122
+ checks: [{ checkId: 'unit-tests', outcome: 'unknown', artifactDigest: null }],
123
+ artifacts: []
124
+ }
125
+ }
126
+ ];
127
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Root singleton lock: persistent advisory lock on a fixed-inode lock file (design 7.2).
3
+ * The lock file is never deleted or replaced; a second instance may only report a conflict and must not start a second set of timers.
4
+ */
5
+ export declare class RootLock {
6
+ readonly lockPath: string;
7
+ private fd;
8
+ constructor(rootDir: string);
9
+ /** Exclusive, non-blocking. Returns true when acquired; false when already held. */
10
+ tryAcquire(): Promise<boolean>;
11
+ requireAcquire(): Promise<void>;
12
+ get held(): boolean;
13
+ release(): Promise<void>;
14
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Root singleton lock: persistent advisory lock on a fixed-inode lock file (design 7.2).
3
+ * The lock file is never deleted or replaced; a second instance may only report a conflict and must not start a second set of timers.
4
+ */
5
+ import * as fs from 'node:fs';
6
+ import * as path from 'node:path';
7
+ import { flock } from 'fs-ext';
8
+ import { WatcherError } from '../util/result.js';
9
+ function flockAsync(fd, mode) {
10
+ return new Promise((resolve, reject) => {
11
+ flock(fd, mode, (err) => {
12
+ if (err)
13
+ reject(err);
14
+ else
15
+ resolve();
16
+ });
17
+ });
18
+ }
19
+ export class RootLock {
20
+ lockPath;
21
+ fd = null;
22
+ constructor(rootDir) {
23
+ this.lockPath = path.join(rootDir, 'watcher.lock');
24
+ }
25
+ /** Exclusive, non-blocking. Returns true when acquired; false when already held. */
26
+ async tryAcquire() {
27
+ if (this.fd !== null)
28
+ return true;
29
+ // 'a' keeps the existing inode; never truncate/replace the lock file.
30
+ const fd = fs.openSync(this.lockPath, 'a');
31
+ try {
32
+ await flockAsync(fd, 'exnb');
33
+ this.fd = fd;
34
+ return true;
35
+ }
36
+ catch (e) {
37
+ const code = e.code;
38
+ fs.closeSync(fd);
39
+ if (code === 'EAGAIN' || code === 'EWOULDBLOCK' || code === 'EACCES' || code === 'EDEADLK') {
40
+ return false;
41
+ }
42
+ throw e;
43
+ }
44
+ }
45
+ async requireAcquire() {
46
+ const got = await this.tryAcquire();
47
+ if (!got) {
48
+ throw new WatcherError('ROOT_LOCK_HELD', `watcher root already locked by another process: ${this.lockPath}`);
49
+ }
50
+ }
51
+ get held() {
52
+ return this.fd !== null;
53
+ }
54
+ async release() {
55
+ if (this.fd === null)
56
+ return;
57
+ const fd = this.fd;
58
+ this.fd = null;
59
+ try {
60
+ await flockAsync(fd, 'un');
61
+ }
62
+ finally {
63
+ fs.closeSync(fd);
64
+ }
65
+ }
66
+ }