@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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +255 -2
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +369 -0
- package/dist/contracts/interfaces.d.ts +262 -0
- package/dist/contracts/interfaces.js +1 -0
- package/dist/contracts/policy-defaults.json +61 -0
- package/dist/contracts/relay-next.interfaces.d.ts +160 -0
- package/dist/contracts/relay-next.interfaces.js +1 -0
- package/dist/engine/cards.d.ts +68 -0
- package/dist/engine/cards.js +76 -0
- package/dist/engine/engine.d.ts +178 -0
- package/dist/engine/engine.js +1162 -0
- package/dist/engine/hard-rules.d.ts +53 -0
- package/dist/engine/hard-rules.js +96 -0
- package/dist/engine/semantic.d.ts +49 -0
- package/dist/engine/semantic.js +125 -0
- package/dist/engine/service.d.ts +62 -0
- package/dist/engine/service.js +587 -0
- package/dist/engine/tool-actions.d.ts +35 -0
- package/dist/engine/tool-actions.js +348 -0
- package/dist/engine/widget.d.ts +29 -0
- package/dist/engine/widget.js +52 -0
- package/dist/ipc/client.d.ts +26 -0
- package/dist/ipc/client.js +106 -0
- package/dist/ipc/server.d.ts +78 -0
- package/dist/ipc/server.js +105 -0
- package/dist/jev/client.d.ts +38 -0
- package/dist/jev/client.js +239 -0
- package/dist/jev/consent.d.ts +12 -0
- package/dist/jev/consent.js +37 -0
- package/dist/jev/index.d.ts +9 -0
- package/dist/jev/index.js +9 -0
- package/dist/jev/mock.d.ts +30 -0
- package/dist/jev/mock.js +77 -0
- package/dist/jev/pi-registry.d.ts +66 -0
- package/dist/jev/pi-registry.js +127 -0
- package/dist/jev/questions.d.ts +15 -0
- package/dist/jev/questions.js +76 -0
- package/dist/jev/sanitizer.d.ts +10 -0
- package/dist/jev/sanitizer.js +59 -0
- package/dist/jev/types.d.ts +78 -0
- package/dist/jev/types.js +54 -0
- package/dist/pi-extension.d.ts +123 -0
- package/dist/pi-extension.js +687 -0
- package/dist/relay/managed.d.ts +120 -0
- package/dist/relay/managed.js +482 -0
- package/dist/relay/negotiate.d.ts +39 -0
- package/dist/relay/negotiate.js +112 -0
- package/dist/runtime.d.ts +74 -0
- package/dist/runtime.js +246 -0
- package/dist/source/agent-check/adapter.d.ts +57 -0
- package/dist/source/agent-check/adapter.js +224 -0
- package/dist/source/agent-file/adapter.d.ts +57 -0
- package/dist/source/agent-file/adapter.js +217 -0
- package/dist/source/task-status-v1/adapter.d.ts +36 -0
- package/dist/source/task-status-v1/adapter.js +263 -0
- package/dist/source/task-status-v1/producer.d.ts +81 -0
- package/dist/source/task-status-v1/producer.js +127 -0
- package/dist/storage/lock.d.ts +14 -0
- package/dist/storage/lock.js +66 -0
- package/dist/storage/schema.sql +166 -0
- package/dist/storage/store.d.ts +352 -0
- package/dist/storage/store.js +555 -0
- package/dist/util/clock.d.ts +23 -0
- package/dist/util/clock.js +34 -0
- package/dist/util/ids.d.ts +8 -0
- package/dist/util/ids.js +29 -0
- package/dist/util/result.d.ts +28 -0
- package/dist/util/result.js +54 -0
- 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
|
+
}
|