@maolon/pi-watcher 0.0.0-stage → 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.
Files changed (72) hide show
  1. package/CHANGELOG.md +23 -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,224 @@
1
+ /**
2
+ * agent-check SourceAdapter — agent-declared bounded check command
3
+ * (agent-declared check source).
4
+ *
5
+ * Target: { kind:'run', sourceId:'agent-check', taskId:'check',
6
+ * runId:'chk-<digest16>', attemptId:'attempt-1', check:{...} }
7
+ *
8
+ * The adapter RUNS the declared command at the watch's poll cadence (engine
9
+ * schedules read(); read executes). This is bounded probing, never a business
10
+ * action: timeoutMs hard kill (SIGTERM→SIGKILL 2s), byte-capped capture.
11
+ *
12
+ * Verdict mapping (nonzero exit is three-valued; agent declares the mapping):
13
+ * - broken : spawn error / exit 126|127 / timeout → state 'unknown' + gap
14
+ * (health degraded) — the CHECK is broken; never a task verdict.
15
+ * - failed : exitCode ∈ failCodes OR failPattern matches stdout — explicit
16
+ * declared failure evidence.
17
+ * - succeeded: okPattern declared → match required; otherwise exit 0
18
+ * (marker philosophy: exit code is explicit evidence).
19
+ * - pending : any other outcome → 'running' (condition not yet met).
20
+ * Ordering: broken → declared-fail → declared-ok → pending.
21
+ *
22
+ * Evidence economy: log_delta only when output digest changes ("no new
23
+ * information" is the Jev signal, not a journal row); status only on state
24
+ * change. Silence therefore means "output and state unchanged for
25
+ * maxSilenceMs" — stuck-pending surfaces via the silence episode.
26
+ *
27
+ * Single-flight per target: overlapping sweeps skip (never double-run).
28
+ */
29
+ import { spawn } from 'node:child_process';
30
+ import { createHash, randomUUID } from 'node:crypto';
31
+ const MAX_STDOUT_BYTES = 16 * 1024;
32
+ const MAX_STDERR_BYTES = 2 * 1024;
33
+ const MAX_DELTA_LINES = 40;
34
+ const KILL_GRACE_MS = 2000;
35
+ function normalizeCursor(cursor) {
36
+ const c = cursor;
37
+ return {
38
+ lastSeq: typeof c?.lastSeq === 'number' ? c.lastSeq : 0,
39
+ lastState: typeof c?.lastState === 'string' ? c.lastState : '',
40
+ lastOutputDigest: typeof c?.lastOutputDigest === 'string' ? c.lastOutputDigest : '',
41
+ done: c?.done === true
42
+ };
43
+ }
44
+ const digestOf = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
45
+ export class AgentCheckAdapter {
46
+ adapterId = 'agent-check';
47
+ capabilities = {
48
+ stableAttempt: true,
49
+ terminalEvents: true,
50
+ incrementalCursor: true,
51
+ exclusiveNotificationOwner: true
52
+ };
53
+ inflight = new Set();
54
+ runCheck(cmd, cwd, timeoutMs) {
55
+ return new Promise(resolve => {
56
+ const startedAt = Date.now();
57
+ let stdout = '';
58
+ let stderr = '';
59
+ let timedOut = false;
60
+ let settled = false;
61
+ const child = spawn(cmd, {
62
+ shell: true,
63
+ cwd: cwd && cwd.trim() !== '' ? cwd : undefined,
64
+ env: process.env,
65
+ windowsHide: true
66
+ });
67
+ const finish = (exitCode, spawnError) => {
68
+ if (settled)
69
+ return;
70
+ settled = true;
71
+ clearTimeout(killTimer);
72
+ clearTimeout(termTimer);
73
+ resolve({
74
+ exitCode,
75
+ timedOut,
76
+ spawnError,
77
+ stdout: stdout.slice(-MAX_STDOUT_BYTES),
78
+ stderr: stderr.slice(-MAX_STDERR_BYTES),
79
+ durationMs: Date.now() - startedAt
80
+ });
81
+ };
82
+ child.stdout?.on('data', (d) => { if (stdout.length < MAX_STDOUT_BYTES * 2)
83
+ stdout += d.toString('utf8'); });
84
+ child.stderr?.on('data', (d) => { if (stderr.length < MAX_STDERR_BYTES * 2)
85
+ stderr += d.toString('utf8'); });
86
+ child.on('error', e => finish(null, e.message));
87
+ child.on('close', code => finish(code, null));
88
+ // Timeout: SIGTERM, then SIGKILL after 2s; close always arrives after kill (fallback resolve)
89
+ const termTimer = setTimeout(() => {
90
+ timedOut = true;
91
+ try {
92
+ child.kill('SIGTERM');
93
+ }
94
+ catch { /* already dead */ }
95
+ }, timeoutMs);
96
+ const killTimer = setTimeout(() => {
97
+ try {
98
+ child.kill('SIGKILL');
99
+ }
100
+ catch { /* already dead */ }
101
+ }, timeoutMs + KILL_GRACE_MS);
102
+ });
103
+ }
104
+ verdictOf(run, check) {
105
+ // 1. broken -- the check itself is unavailable: never sentence the task to death
106
+ if (run.spawnError !== null) {
107
+ return { taskState: 'unknown', exitCode: null, broken: true, summary: `check broken: spawn error — ${run.spawnError}` };
108
+ }
109
+ if (run.timedOut) {
110
+ return { taskState: 'unknown', exitCode: null, broken: true, summary: `check broken: timed out after ${run.durationMs}ms` };
111
+ }
112
+ if (run.exitCode === 126 || run.exitCode === 127) {
113
+ return { taskState: 'unknown', exitCode: run.exitCode, broken: true, summary: `check broken: exit ${run.exitCode} (command not found / not executable)` };
114
+ }
115
+ // 2. declared-fail: explicitly declared failure evidence (the pattern also applies to exit 0)
116
+ if (check.failCodes && run.exitCode !== null && check.failCodes.includes(run.exitCode)) {
117
+ return { taskState: 'failed', exitCode: run.exitCode, broken: false, summary: `check failed: exit ${run.exitCode} (declared failCode)` };
118
+ }
119
+ if (check.failPattern && new RegExp(check.failPattern).test(run.stdout)) {
120
+ return { taskState: 'failed', exitCode: run.exitCode, broken: false, summary: `check failed: failPattern matched (${summarizeOutput(run)})` };
121
+ }
122
+ // 3. declared-ok / exit 0
123
+ if (check.okPattern) {
124
+ if (new RegExp(check.okPattern).test(run.stdout)) {
125
+ return { taskState: 'succeeded', exitCode: run.exitCode, broken: false, summary: `check ok: okPattern matched (${summarizeOutput(run)})` };
126
+ }
127
+ }
128
+ else if (run.exitCode === 0) {
129
+ return { taskState: 'succeeded', exitCode: 0, broken: false, summary: 'check ok: exit 0' };
130
+ }
131
+ // 4. pending
132
+ return { taskState: 'running', exitCode: run.exitCode, broken: false, summary: `pending: check exit ${run.exitCode ?? 'null'} (${summarizeOutput(run)})` };
133
+ }
134
+ async read(target, cursor, _signal) {
135
+ const check = target.check;
136
+ const cur = normalizeCursor(cursor);
137
+ const next = { ...cur };
138
+ if (!check || typeof check.cmd !== 'string' || check.cmd === '') {
139
+ // no command to execute: do not fabricate facts
140
+ return { observations: [], nextCursor: next, gap: true };
141
+ }
142
+ if (cur.done) {
143
+ return { observations: [], nextCursor: next, gap: false }; // terminal already reported
144
+ }
145
+ const key = `${target.sourceId}:${target.taskId}:${target.runId}:${target.attemptId}`;
146
+ if (this.inflight.has(key)) {
147
+ return { observations: [], nextCursor: next, gap: false }; // single-flight: skip this round
148
+ }
149
+ this.inflight.add(key);
150
+ let run;
151
+ try {
152
+ run = await this.runCheck(check.cmd, check.cwd, Math.min(Math.max(check.timeoutMs ?? 10_000, 1_000), 30_000));
153
+ }
154
+ finally {
155
+ this.inflight.delete(key);
156
+ }
157
+ const verdict = this.verdictOf(run, check);
158
+ const observations = [];
159
+ const now = new Date().toISOString();
160
+ // log_delta only when output changes (no new information is not an evidence line; feeds Jev's repeating signal)
161
+ const outputDigest = digestOf(run.stdout + '\u0000' + run.stderr);
162
+ if (outputDigest !== cur.lastOutputDigest) {
163
+ const lines = [...run.stdout.split('\n'), ...(run.stderr.trim() !== '' ? [run.stderr.trim()] : [])]
164
+ .filter(l => l !== '')
165
+ .slice(-MAX_DELTA_LINES);
166
+ if (lines.length > 0) {
167
+ next.lastSeq += 1;
168
+ observations.push({
169
+ schemaVersion: 1,
170
+ observationId: `obs-check-${randomUUID()}`,
171
+ target,
172
+ sourceSeq: next.lastSeq,
173
+ observedAt: now,
174
+ kind: 'log_delta',
175
+ payload: { lines, exitCode: run.exitCode, durationMs: run.durationMs },
176
+ evidenceRefs: []
177
+ });
178
+ }
179
+ }
180
+ next.lastOutputDigest = outputDigest;
181
+ // status only when the state changes
182
+ if (verdict.taskState !== cur.lastState) {
183
+ next.lastSeq += 1;
184
+ next.lastState = verdict.taskState;
185
+ observations.push({
186
+ schemaVersion: 1,
187
+ observationId: `obs-check-${randomUUID()}`,
188
+ target,
189
+ sourceSeq: next.lastSeq,
190
+ observedAt: now,
191
+ kind: 'status',
192
+ payload: {
193
+ state: verdict.taskState,
194
+ stage: 'check',
195
+ exitCode: verdict.exitCode,
196
+ summary: verdict.summary
197
+ },
198
+ evidenceRefs: []
199
+ });
200
+ }
201
+ if (verdict.taskState === 'succeeded' || verdict.taskState === 'failed') {
202
+ next.done = true;
203
+ }
204
+ // broken -> source degraded (health degraded); not a terminal state, not a task verdict
205
+ return { observations, nextCursor: next, gap: verdict.broken };
206
+ }
207
+ async probes(_target, _basis) {
208
+ // A check command has no passive artifact to read: the evidence window comes from read()'s log_delta journal;
209
+ // an active probe would run the command again (double cost), and read() already provides new output at the polling cadence.
210
+ return [];
211
+ }
212
+ async execute(_probe, _signal) {
213
+ return [];
214
+ }
215
+ async close() { }
216
+ }
217
+ function summarizeOutput(run) {
218
+ const last = run.stdout.trim().split('\n').filter(l => l !== '').slice(-1)[0] ?? '';
219
+ return last !== '' ? `last: ${last.slice(0, 120)}` : 'no output';
220
+ }
221
+ /** runId of the watch-check tool action: stable digest of the command + verdict mapping (for dedup) */
222
+ export function checkRunId(cmd, verdict) {
223
+ return `chk-${digestOf(cmd + '\u0000' + (verdict.okPattern ?? '') + '\u0000' + (verdict.failPattern ?? '') + '\u0000' + (verdict.failCodes ?? []).join(','))}`;
224
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * agent-file SourceAdapter — agent-declared file evidence (universal monitor
3
+ * mode).
4
+ *
5
+ * Target: { kind:'run', sourceId:'agent-file', taskId:'file',
6
+ * runId:'file-<digest16>', attemptId:'attempt-1',
7
+ * file:{ path, okPattern?, failPattern? } }
8
+ *
9
+ * Replaces the removed unified-exec adapter: the agent pins the exact file
10
+ * path (it holds log_path in the exec tool result), so no foreign-library
11
+ * naming heuristics, no runId namespace scans, no cross-process collisions.
12
+ * Exit detection is declared patterns over content — e.g. watching an exec
13
+ * session log with okPattern='__EXEC_EXIT__:0', failPattern='__EXEC_EXIT__:-?[1-9]'
14
+ * (the marker itself is a pi-watcher convention injected by the extension).
15
+ *
16
+ * Facts (honest, conservative):
17
+ * - file missing → 'unknown' + gap (degraded) — NOT terminal; may appear later
18
+ * - rotation/truncation (size < cursor offset) → cursor reset + gap
19
+ * - growth → bounded incremental log_delta (progress evidence; feeds silence)
20
+ * - failPattern match on tail window → failed; okPattern match → succeeded
21
+ * (fail ordered first: explicit failure evidence outranks ok)
22
+ * - no pattern hit → 'running' (liveness via lsof fd holders is informational
23
+ * only — a generic file's "no holder" is NOT exit evidence, writers may
24
+ * write per-open; the removed exec adapter's exited-unknown fabrication
25
+ * does not generalize and is gone)
26
+ * - terminal (pattern) → cursor.done; source stops reading (engine anneals
27
+ * and auto-closes)
28
+ */
29
+ import type { Json, Observation, Probe, RunTarget, SourceAdapter, SourceCapabilities } from '../../contracts/interfaces.js';
30
+ export interface AgentFileCursor {
31
+ offset: number;
32
+ lastSeq: number;
33
+ lastState: string;
34
+ /** terminal already reported → stop reading */
35
+ done: boolean;
36
+ }
37
+ export declare class AgentFileAdapter implements SourceAdapter {
38
+ readonly adapterId = "agent-file";
39
+ readonly capabilities: SourceCapabilities;
40
+ /** fd holders (lsof): only running summary info; unavailable -> null (no inference) */
41
+ private holders;
42
+ private verdictOf;
43
+ read(target: RunTarget, cursor: Json | undefined, _signal: AbortSignal): Promise<{
44
+ observations: Observation[];
45
+ nextCursor: Json;
46
+ gap: boolean;
47
+ }>;
48
+ private statusObservation;
49
+ probes(target: RunTarget, _basis: unknown): Promise<readonly Probe[]>;
50
+ execute(probe: Probe, _signal: AbortSignal): Promise<Observation[]>;
51
+ close(): Promise<void>;
52
+ }
53
+ /** runId of the watch-file tool action: stable digest of path + patterns (for dedup) */
54
+ export declare function fileRunId(path: string, patterns: {
55
+ okPattern?: string;
56
+ failPattern?: string;
57
+ }): string;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * agent-file SourceAdapter — agent-declared file evidence (universal monitor
3
+ * mode).
4
+ *
5
+ * Target: { kind:'run', sourceId:'agent-file', taskId:'file',
6
+ * runId:'file-<digest16>', attemptId:'attempt-1',
7
+ * file:{ path, okPattern?, failPattern? } }
8
+ *
9
+ * Replaces the removed unified-exec adapter: the agent pins the exact file
10
+ * path (it holds log_path in the exec tool result), so no foreign-library
11
+ * naming heuristics, no runId namespace scans, no cross-process collisions.
12
+ * Exit detection is declared patterns over content — e.g. watching an exec
13
+ * session log with okPattern='__EXEC_EXIT__:0', failPattern='__EXEC_EXIT__:-?[1-9]'
14
+ * (the marker itself is a pi-watcher convention injected by the extension).
15
+ *
16
+ * Facts (honest, conservative):
17
+ * - file missing → 'unknown' + gap (degraded) — NOT terminal; may appear later
18
+ * - rotation/truncation (size < cursor offset) → cursor reset + gap
19
+ * - growth → bounded incremental log_delta (progress evidence; feeds silence)
20
+ * - failPattern match on tail window → failed; okPattern match → succeeded
21
+ * (fail ordered first: explicit failure evidence outranks ok)
22
+ * - no pattern hit → 'running' (liveness via lsof fd holders is informational
23
+ * only — a generic file's "no holder" is NOT exit evidence, writers may
24
+ * write per-open; the removed exec adapter's exited-unknown fabrication
25
+ * does not generalize and is gone)
26
+ * - terminal (pattern) → cursor.done; source stops reading (engine anneals
27
+ * and auto-closes)
28
+ */
29
+ import * as fs from 'node:fs';
30
+ import { spawnSync } from 'node:child_process';
31
+ import { createHash, randomUUID } from 'node:crypto';
32
+ const TAIL_BYTES = 4096;
33
+ const MAX_DELTA_BYTES = 16 * 1024;
34
+ const MAX_DELTA_LINES = 200;
35
+ function normalizeCursor(cursor) {
36
+ const c = cursor;
37
+ return {
38
+ offset: typeof c?.offset === 'number' ? c.offset : 0,
39
+ lastSeq: typeof c?.lastSeq === 'number' ? c.lastSeq : 0,
40
+ lastState: typeof c?.lastState === 'string' ? c.lastState : '',
41
+ done: c?.done === true
42
+ };
43
+ }
44
+ const digestOf = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
45
+ function readTail(logPath) {
46
+ try {
47
+ const size = fs.statSync(logPath).size;
48
+ const len = Math.min(size, TAIL_BYTES);
49
+ const fh = fs.openSync(logPath, 'r');
50
+ try {
51
+ const buf = Buffer.alloc(len);
52
+ const n = fs.readSync(fh, buf, 0, len, size - len);
53
+ return buf.subarray(0, n).toString('utf8');
54
+ }
55
+ finally {
56
+ fs.closeSync(fh);
57
+ }
58
+ }
59
+ catch {
60
+ return '';
61
+ }
62
+ }
63
+ export class AgentFileAdapter {
64
+ adapterId = 'agent-file';
65
+ capabilities = {
66
+ stableAttempt: true,
67
+ terminalEvents: true,
68
+ incrementalCursor: true,
69
+ exclusiveNotificationOwner: true
70
+ };
71
+ /** fd holders (lsof): only running summary info; unavailable -> null (no inference) */
72
+ holders(filePath) {
73
+ const r = spawnSync('lsof', ['-t', '--', filePath], { timeout: 2000, encoding: 'utf8' });
74
+ if (r.error)
75
+ return null;
76
+ if (r.status !== 0 && r.status !== 1)
77
+ return null;
78
+ return (r.stdout ?? '').trim();
79
+ }
80
+ verdictOf(filePath, file, grew) {
81
+ const tail = readTail(filePath);
82
+ const lastLine = (tail.split('\n').filter(l => l !== '').slice(-1)[0] ?? '').slice(0, 120);
83
+ if (file.failPattern && new RegExp(file.failPattern).test(tail)) {
84
+ return { taskState: 'failed', summary: `failPattern matched in tail (${lastLine})`, gap: false };
85
+ }
86
+ if (file.okPattern && new RegExp(file.okPattern).test(tail)) {
87
+ return { taskState: 'succeeded', summary: `okPattern matched in tail (${lastLine})`, gap: false };
88
+ }
89
+ const holder = this.holders(filePath);
90
+ const liveness = holder === null ? 'liveness unknown (lsof unavailable)' : holder !== '' ? 'writer holds the file' : 'no fd holder (writer may write per-open)';
91
+ return { taskState: 'running', summary: grew ? `file growing; ${liveness}` : `no new content; ${liveness}`, gap: false };
92
+ }
93
+ async read(target, cursor, _signal) {
94
+ const file = target.file;
95
+ const cur = normalizeCursor(cursor);
96
+ const next = { ...cur };
97
+ if (!file || typeof file.path !== 'string' || file.path === '') {
98
+ return { observations: [], nextCursor: next, gap: true };
99
+ }
100
+ if (cur.done) {
101
+ return { observations: [], nextCursor: next, gap: false };
102
+ }
103
+ let size;
104
+ try {
105
+ size = fs.statSync(file.path).size;
106
+ }
107
+ catch {
108
+ // File missing: degraded, non-terminal (may not be created yet)
109
+ const observations = [];
110
+ if (cur.lastState !== 'unknown') {
111
+ next.lastState = 'unknown';
112
+ next.lastSeq += 1;
113
+ observations.push(this.statusObservation(target, next.lastSeq, 'unknown', `file not found: ${file.path}`));
114
+ }
115
+ return { observations, nextCursor: next, gap: true };
116
+ }
117
+ // Rotation/truncation: cursor reset, gap notice
118
+ let gap = false;
119
+ if (size < cur.offset) {
120
+ next.offset = 0;
121
+ gap = true;
122
+ }
123
+ const observations = [];
124
+ let grew = false;
125
+ if (size > next.offset) {
126
+ grew = true;
127
+ const len = Math.min(size - next.offset, MAX_DELTA_BYTES);
128
+ let text = '';
129
+ try {
130
+ const fh = fs.openSync(file.path, 'r');
131
+ try {
132
+ const buf = Buffer.alloc(len);
133
+ const n = fs.readSync(fh, buf, 0, len, next.offset);
134
+ text = buf.subarray(0, n).toString('utf8');
135
+ }
136
+ finally {
137
+ fs.closeSync(fh);
138
+ }
139
+ }
140
+ catch {
141
+ return { observations, nextCursor: next, gap: true }; // raced away; retry next sweep
142
+ }
143
+ let lines = text.split('\n');
144
+ lines.pop(); // trailing partial line (writer mid-write) — next sweep re-reads from offset
145
+ if (lines.length > MAX_DELTA_LINES) {
146
+ lines = [`[... ${lines.length - MAX_DELTA_LINES} earlier lines truncated ...]`, ...lines.slice(-MAX_DELTA_LINES)];
147
+ }
148
+ if (lines.length > 0) {
149
+ next.lastSeq += 1;
150
+ observations.push({
151
+ schemaVersion: 1,
152
+ observationId: `obs-file-${randomUUID()}`,
153
+ target,
154
+ sourceSeq: next.lastSeq,
155
+ observedAt: new Date().toISOString(),
156
+ kind: 'log_delta',
157
+ payload: { lines },
158
+ evidenceRefs: []
159
+ });
160
+ }
161
+ next.offset = size;
162
+ }
163
+ const verdict = this.verdictOf(file.path, file, grew);
164
+ if (verdict.taskState !== cur.lastState) {
165
+ next.lastSeq += 1;
166
+ next.lastState = verdict.taskState;
167
+ observations.push(this.statusObservation(target, next.lastSeq, verdict.taskState, verdict.summary));
168
+ }
169
+ if (verdict.taskState === 'succeeded' || verdict.taskState === 'failed') {
170
+ next.done = true;
171
+ }
172
+ return { observations, nextCursor: next, gap: gap || verdict.gap };
173
+ }
174
+ statusObservation(target, seq, state, summary) {
175
+ return {
176
+ schemaVersion: 1,
177
+ observationId: `obs-file-${randomUUID()}`,
178
+ target,
179
+ sourceSeq: seq,
180
+ observedAt: new Date().toISOString(),
181
+ kind: 'status',
182
+ payload: { state, stage: 'file', summary },
183
+ evidenceRefs: []
184
+ };
185
+ }
186
+ async probes(target, _basis) {
187
+ return [{
188
+ probeId: `probe-file-tail-${randomUUID()}`,
189
+ revision: 1,
190
+ target,
191
+ kind: 'read-log-delta',
192
+ scopeId: 'file-tail',
193
+ expiresAt: new Date(Date.now() + 30_000).toISOString(),
194
+ timeoutMs: 2000,
195
+ maxBytes: TAIL_BYTES
196
+ }];
197
+ }
198
+ async execute(probe, _signal) {
199
+ const path = probe.target.file?.path;
200
+ const tail = path ? readTail(path) : '';
201
+ return [{
202
+ schemaVersion: 1,
203
+ observationId: `obs-file-${randomUUID()}`,
204
+ target: probe.target,
205
+ sourceSeq: 0,
206
+ observedAt: new Date().toISOString(),
207
+ kind: 'log_delta',
208
+ payload: { probe: 'file-tail', lines: tail.split('\n').slice(-40) },
209
+ evidenceRefs: []
210
+ }];
211
+ }
212
+ async close() { }
213
+ }
214
+ /** runId of the watch-file tool action: stable digest of path + patterns (for dedup) */
215
+ export function fileRunId(path, patterns) {
216
+ return `file-${digestOf(path + '\u0000' + (patterns.okPattern ?? '') + '\u0000' + (patterns.failPattern ?? ''))}`;
217
+ }
@@ -0,0 +1,36 @@
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 type { Json, Basis, Observation, Probe, RunTarget, SourceAdapter, SourceCapabilities } from '../../contracts/interfaces.js';
9
+ export interface TaskStatusV1Cursor {
10
+ fileId: string | null;
11
+ offset: number;
12
+ lastSeq: number;
13
+ snapshotSeq: number;
14
+ }
15
+ export interface TaskStatusV1AdapterOptions {
16
+ adapterId?: string;
17
+ defaultTimeoutMs?: number;
18
+ defaultMaxBytes?: number;
19
+ }
20
+ export declare class TaskStatusV1Adapter implements SourceAdapter {
21
+ private readonly rootDir;
22
+ readonly adapterId: string;
23
+ readonly capabilities: SourceCapabilities;
24
+ private readonly timeoutMs;
25
+ private readonly maxBytes;
26
+ constructor(rootDir: string, options?: TaskStatusV1AdapterOptions);
27
+ private targetDir;
28
+ read(target: RunTarget, cursor: Json | undefined, signal: AbortSignal): Promise<{
29
+ observations: Observation[];
30
+ nextCursor: Json;
31
+ gap: boolean;
32
+ }>;
33
+ probes(target: RunTarget, _basis: Basis): Promise<readonly Probe[]>;
34
+ execute(probe: Probe, signal: AbortSignal): Promise<Observation[]>;
35
+ close(): Promise<void>;
36
+ }