@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,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action dispatch + LLM projection layer for the watcher tool (design 11.4 service surface).
|
|
3
|
+
* Reused by two backends: the local primary calls it directly; attached sessions run it in the primary over IPC.
|
|
4
|
+
*
|
|
5
|
+
* Projection principle: the model only needs the minimum facts required for decisions -- do not echo the spec it submitted,
|
|
6
|
+
* and do not expose internal bookkeeping fields (owner path / digest / generation / receipt JSON).
|
|
7
|
+
* Full facts remain in SQLite and the /watcher panel (human debugging view).
|
|
8
|
+
*/
|
|
9
|
+
import { checkRunId } from '../source/agent-check/adapter.js';
|
|
10
|
+
import { fileRunId } from '../source/agent-file/adapter.js';
|
|
11
|
+
import { DEFAULT_ATTENTION_TTL_MS } from './engine.js';
|
|
12
|
+
import * as pathMod from 'node:path';
|
|
13
|
+
/** Active lifecycle filter set shared by list/watch dedup */
|
|
14
|
+
const ACTIVE_LIFECYCLES = ['active', 'paused'];
|
|
15
|
+
const rec = (v) => (v !== null && typeof v === 'object' ? v : {});
|
|
16
|
+
const str = (v) => (typeof v === 'string' && v !== '' ? v : undefined);
|
|
17
|
+
const num = (v) => (typeof v === 'number' ? v : undefined);
|
|
18
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
19
|
+
const compact = (v) => JSON.parse(JSON.stringify(v)); // drop undefined
|
|
20
|
+
function projectList(v) {
|
|
21
|
+
const r = rec(v);
|
|
22
|
+
const watches = arr(r.items).map(it => {
|
|
23
|
+
const w = rec(it);
|
|
24
|
+
const t = rec(w.target);
|
|
25
|
+
return {
|
|
26
|
+
watchId: str(w.watchId),
|
|
27
|
+
runId: str(t.runId),
|
|
28
|
+
taskState: str(w.taskState) ?? 'unknown',
|
|
29
|
+
lifecycle: str(w.lifecycle),
|
|
30
|
+
health: str(w.health),
|
|
31
|
+
openEpisodes: num(w.openEpisodes) ?? 0
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
const out = {
|
|
35
|
+
watches,
|
|
36
|
+
terminalCount: num(r.terminalCount) ?? 0,
|
|
37
|
+
relay: str(rec(r.relay).transport) ?? 'local-display'
|
|
38
|
+
};
|
|
39
|
+
const next = str(r.nextCursor);
|
|
40
|
+
if (next)
|
|
41
|
+
out.nextCursor = next;
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Log tail formatting and hard limits (design 11.4 evidence economy):
|
|
46
|
+
* 1. Filter out non-string lines; take at most the last 8 lines.
|
|
47
|
+
* 2. Truncate each line to 300 characters (prevents oversized JSON / ARGV / stack traces from blowing up context).
|
|
48
|
+
* 3. Hard-truncate total volume to 1000 characters (if over, keep the newest tail and add a truncation marker).
|
|
49
|
+
* 4. Fully replaces the old tailLines array to avoid echoing twice.
|
|
50
|
+
*/
|
|
51
|
+
function formatLatestOutput(rawLines) {
|
|
52
|
+
const lines = rawLines.filter((l) => typeof l === 'string');
|
|
53
|
+
if (lines.length === 0)
|
|
54
|
+
return undefined;
|
|
55
|
+
const sliced = lines.slice(-8).map(l => (l.length > 300 ? l.slice(0, 297) + '...' : l));
|
|
56
|
+
const joined = sliced.join('\n');
|
|
57
|
+
if (joined.length <= 1000)
|
|
58
|
+
return joined;
|
|
59
|
+
return '[... output truncated ...]\n' + joined.slice(-900);
|
|
60
|
+
}
|
|
61
|
+
function projectInspect(v) {
|
|
62
|
+
const r = rec(v);
|
|
63
|
+
const spec = rec(r.watch);
|
|
64
|
+
const mission = rec(spec.mission);
|
|
65
|
+
const snap = rec(r.snapshot);
|
|
66
|
+
const openEpisodes = arr(r.episodes)
|
|
67
|
+
.filter(e0 => rec(e0).state === 'open')
|
|
68
|
+
.map(e0 => {
|
|
69
|
+
const e = rec(e0);
|
|
70
|
+
const b = rec(e.body);
|
|
71
|
+
return compact({
|
|
72
|
+
kind: str(e.kind),
|
|
73
|
+
reason: str(b.reasonCode) ?? str(b.summary) ?? str(b.taskState)
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
const latestOutput = formatLatestOutput(arr(snap.tailLines));
|
|
77
|
+
const obj = str(mission.objective);
|
|
78
|
+
const objective = obj && obj.length > 100 ? obj.slice(0, 97) + '...' : obj;
|
|
79
|
+
return compact({
|
|
80
|
+
watchId: str(spec.watchId),
|
|
81
|
+
objective,
|
|
82
|
+
taskState: str(snap.taskState) ?? 'unknown',
|
|
83
|
+
exitCode: num(snap.exitCode),
|
|
84
|
+
stage: str(snap.stage),
|
|
85
|
+
summary: str(snap.summary),
|
|
86
|
+
latestOutput,
|
|
87
|
+
lifecycle: str(r.lifecycle),
|
|
88
|
+
health: str(r.health),
|
|
89
|
+
degradedReason: str(snap.degradedReason),
|
|
90
|
+
terminalAt: num(snap.terminalAt) !== undefined ? new Date(num(snap.terminalAt)).toISOString() : undefined,
|
|
91
|
+
lastObservedAt: num(snap.lastObservedAtMs) !== undefined ? new Date(num(snap.lastObservedAtMs)).toISOString() : undefined,
|
|
92
|
+
episodes: openEpisodes.length > 0 ? openEpisodes : undefined,
|
|
93
|
+
controlRevision: num(spec.controlRevision)
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
function projectControl(v) {
|
|
97
|
+
const r = rec(v);
|
|
98
|
+
return compact({
|
|
99
|
+
watchId: str(r.watchId),
|
|
100
|
+
lifecycle: str(r.lifecycle),
|
|
101
|
+
controlRevision: num(r.controlRevision)
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
export async function runToolAction(rt, params, actor) {
|
|
105
|
+
switch (params.action) {
|
|
106
|
+
case 'register': {
|
|
107
|
+
if (!params.requestId || !params.candidate) {
|
|
108
|
+
throw Object.assign(new Error('register requires requestId and candidate'), { code: 'INVALID_SPEC' });
|
|
109
|
+
}
|
|
110
|
+
// Finalize on demand before transport=relay registration (mid-session bind scenario)
|
|
111
|
+
if (params.candidate?.policy?.transport === 'relay') {
|
|
112
|
+
await rt.ensureRelayReady();
|
|
113
|
+
}
|
|
114
|
+
const spec = await rt.service.register(params.requestId, params.candidate, actor);
|
|
115
|
+
const isRelay = spec.policy.transport === 'relay';
|
|
116
|
+
return {
|
|
117
|
+
watchId: spec.watchId,
|
|
118
|
+
controlRevision: spec.controlRevision,
|
|
119
|
+
monitoringActive: true,
|
|
120
|
+
transport: isRelay ? 'relay' : 'local-display',
|
|
121
|
+
note: isRelay
|
|
122
|
+
? 'Watch active (relay wake enabled). Do NOT poll with write_stdin.'
|
|
123
|
+
: 'Watch active (local-display). Do NOT poll with write_stdin.'
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
case 'list': {
|
|
127
|
+
// Owner decision 2026-09-21: in-session exec->watch loops accumulate many closed/failed terminal rows; echoing them all
|
|
128
|
+
// just burns model context (33 of 35 watches were converged history). By default only active (active/paused)
|
|
129
|
+
// watches + a terminalCount summary are returned; the model sees the full history only with explicit includeClosed=true.
|
|
130
|
+
const result = await rt.service.list(undefined, 50, actor, params.includeClosed ? undefined : ACTIVE_LIFECYCLES);
|
|
131
|
+
return projectList(result);
|
|
132
|
+
}
|
|
133
|
+
case 'watch-file': {
|
|
134
|
+
// Agent-declared file evidence source (universal monitor mode, decision-delta 2026-09-21):
|
|
135
|
+
// the agent pins an exact path (log_path from an exec result, a build artifact, a report file);
|
|
136
|
+
// the declarative pattern is the only content terminal-state evidence; growth means progress; silence is a stuck signal.
|
|
137
|
+
// Replaces the removed unified-exec adapter: no external-library naming heuristics, no runId namespace collisions.
|
|
138
|
+
if (!params.requestId || typeof params.path !== 'string' || params.path.trim() === '') {
|
|
139
|
+
throw Object.assign(new Error('watch-file requires requestId and path (absolute file path, e.g. the log_path from an exec_command result)'), { code: 'INVALID_SPEC' });
|
|
140
|
+
}
|
|
141
|
+
for (const [p, name] of [[params.okPattern, 'okPattern'], [params.failPattern, 'failPattern']]) {
|
|
142
|
+
if (p !== undefined) {
|
|
143
|
+
try {
|
|
144
|
+
new RegExp(p);
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
throw Object.assign(new Error(`${name} is not a valid RegExp: ${e instanceof Error ? e.message : String(e)}`), { code: 'INVALID_SPEC' });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Relative paths resolve against the declaring session's cwd (injected by the extension layer)
|
|
152
|
+
const filePath = pathMod.isAbsolute(params.path)
|
|
153
|
+
? params.path
|
|
154
|
+
: (typeof params.cwd === 'string' && params.cwd !== '' ? pathMod.resolve(params.cwd, params.path) : pathMod.resolve(params.path));
|
|
155
|
+
const runId = fileRunId(filePath, { okPattern: params.okPattern, failPattern: params.failPattern });
|
|
156
|
+
// Dedup: an active watch with the same path + same pattern is returned directly (idempotent; keeps terminal history from crowding out the limit window)
|
|
157
|
+
const existing = await rt.service.list(undefined, 50, actor, ACTIVE_LIFECYCLES);
|
|
158
|
+
const prior = existing
|
|
159
|
+
.items?.find(w => w.target?.runId === runId && (w.lifecycle === 'active' || w.lifecycle === 'paused'));
|
|
160
|
+
if (prior?.watchId) {
|
|
161
|
+
return {
|
|
162
|
+
watchId: prior.watchId,
|
|
163
|
+
runId,
|
|
164
|
+
controlRevision: prior.controlRevision ?? 1,
|
|
165
|
+
transport: rt.negotiation.transport === 'relay' ? 'relay' : 'local-display',
|
|
166
|
+
note: 'Already watching this file — progress via watcher inspect/check.'
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const isRelay = rt.negotiation.transport === 'relay';
|
|
170
|
+
const candidate = {
|
|
171
|
+
mode: 'embedded',
|
|
172
|
+
target: {
|
|
173
|
+
kind: 'run', sourceId: 'agent-file', taskId: 'file', runId, attemptId: 'attempt-1',
|
|
174
|
+
file: {
|
|
175
|
+
path: filePath,
|
|
176
|
+
...(params.okPattern !== undefined ? { okPattern: params.okPattern } : {}),
|
|
177
|
+
...(params.failPattern !== undefined ? { failPattern: params.failPattern } : {})
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
mission: {
|
|
181
|
+
objective: typeof params.objective === 'string' && params.objective
|
|
182
|
+
? params.objective
|
|
183
|
+
: `file watch: ${pathMod.basename(filePath)}`,
|
|
184
|
+
scope: `tail declared file ${filePath}; read-only`,
|
|
185
|
+
checkpointId: 'file-watch',
|
|
186
|
+
requiredArtifacts: [], requiresChecks: false, businessAcceptance: 'not_required',
|
|
187
|
+
...(typeof params.deadlineAt === 'string' ? { deadlineAt: params.deadlineAt } : {})
|
|
188
|
+
},
|
|
189
|
+
policy: {
|
|
190
|
+
transport: isRelay ? 'relay' : 'local-display',
|
|
191
|
+
semanticMode: params.semanticMode === 'active' || params.semanticMode === 'shadow' ? params.semanticMode : 'off',
|
|
192
|
+
notificationOwner: 'watcher',
|
|
193
|
+
requestKinds: [],
|
|
194
|
+
maxRequestsPerEpisode: 1,
|
|
195
|
+
episodeCooldownMs: 60000,
|
|
196
|
+
attentionTtlMs: DEFAULT_ATTENTION_TTL_MS
|
|
197
|
+
},
|
|
198
|
+
limits: {
|
|
199
|
+
// Local stat+read is cheap: fast sampling while active, backoff while quiet (same band as the old exec watch)
|
|
200
|
+
pollMinMs: 1000, pollMaxMs: 5000,
|
|
201
|
+
maxSilenceMs: typeof params.maxSilenceMs === 'number' ? params.maxSilenceMs : 600000,
|
|
202
|
+
maxProbesPerEpisode: 2, maxJudgeRequestsPerDay: 10,
|
|
203
|
+
// Round to the hour: the candidate digest stays stable when the same requestId is replayed (idempotent)
|
|
204
|
+
expiresAt: new Date(Math.ceil(Date.now() / 3600_000) * 3600_000 + 12 * 3600_000).toISOString()
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const spec = await rt.service.register(params.requestId, candidate, actor);
|
|
208
|
+
const relay = isRelay && spec.policy.transport === 'relay';
|
|
209
|
+
return {
|
|
210
|
+
watchId: spec.watchId,
|
|
211
|
+
runId,
|
|
212
|
+
path: filePath,
|
|
213
|
+
controlRevision: spec.controlRevision,
|
|
214
|
+
transport: relay ? 'relay' : 'local-display',
|
|
215
|
+
note: relay
|
|
216
|
+
? 'Watching file via relay wake. Call watcher inspect/check anytime.'
|
|
217
|
+
: 'Watching file. Call watcher inspect/check anytime.'
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
case 'watch-check': {
|
|
221
|
+
// Bounded check command declared by the agent (decision-delta 2026-09-21): covers scenarios with no local artifact
|
|
222
|
+
// (CI build, cloud readiness, remote state). The watcher runs cmd periodically and maps results to facts via the declarative verdict mapping;
|
|
223
|
+
// a non-zero exit code is three-way ambiguous (failed/pending/broken), so the agent declares the mapping and the watcher does not guess.
|
|
224
|
+
if (!params.requestId || typeof params.cmd !== 'string' || params.cmd.trim() === '') {
|
|
225
|
+
throw Object.assign(new Error('watch-check requires requestId and cmd'), { code: 'INVALID_SPEC' });
|
|
226
|
+
}
|
|
227
|
+
if (params.cmd.length > 2000) {
|
|
228
|
+
throw Object.assign(new Error('cmd too long (max 2000 chars); put longer logic in a script file and check it'), { code: 'INVALID_SPEC' });
|
|
229
|
+
}
|
|
230
|
+
const patterns = [
|
|
231
|
+
[params.okPattern, 'okPattern'],
|
|
232
|
+
[params.failPattern, 'failPattern']
|
|
233
|
+
];
|
|
234
|
+
for (const [p, name] of patterns) {
|
|
235
|
+
if (p !== undefined) {
|
|
236
|
+
try {
|
|
237
|
+
new RegExp(p);
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
throw Object.assign(new Error(`${name} is not a valid RegExp: ${e instanceof Error ? e.message : String(e)}`), { code: 'INVALID_SPEC' });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const intervalMs = Math.min(Math.max(Math.trunc(params.intervalMs ?? 30_000), 1_000), 600_000);
|
|
245
|
+
const timeoutMs = Math.min(Math.max(Math.trunc(params.timeoutMs ?? 10_000), 1_000), 30_000);
|
|
246
|
+
const cmd = params.cmd;
|
|
247
|
+
const runId = checkRunId(cmd, { okPattern: params.okPattern, failPattern: params.failPattern, failCodes: params.failCodes });
|
|
248
|
+
// Dedup: an active watch with the same command + same verdict mapping is returned directly (idempotent)
|
|
249
|
+
const existing = await rt.service.list(undefined, 50, actor, ACTIVE_LIFECYCLES);
|
|
250
|
+
const prior = existing
|
|
251
|
+
.items?.find(w => w.target?.runId === runId && (w.lifecycle === 'active' || w.lifecycle === 'paused'));
|
|
252
|
+
if (prior?.watchId) {
|
|
253
|
+
return {
|
|
254
|
+
watchId: prior.watchId,
|
|
255
|
+
runId,
|
|
256
|
+
controlRevision: prior.controlRevision ?? 1,
|
|
257
|
+
transport: rt.negotiation.transport === 'relay' ? 'relay' : 'local-display',
|
|
258
|
+
note: 'Already watching this check command — progress via watcher inspect/check.'
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const isRelay = rt.negotiation.transport === 'relay';
|
|
262
|
+
const candidate = {
|
|
263
|
+
mode: 'embedded',
|
|
264
|
+
target: {
|
|
265
|
+
kind: 'run', sourceId: 'agent-check', taskId: 'check', runId, attemptId: 'attempt-1',
|
|
266
|
+
check: {
|
|
267
|
+
cmd,
|
|
268
|
+
...(typeof params.cwd === 'string' && params.cwd !== '' ? { cwd: params.cwd } : {}),
|
|
269
|
+
timeoutMs,
|
|
270
|
+
...(params.okPattern !== undefined ? { okPattern: params.okPattern } : {}),
|
|
271
|
+
...(params.failPattern !== undefined ? { failPattern: params.failPattern } : {}),
|
|
272
|
+
...(Array.isArray(params.failCodes) ? { failCodes: params.failCodes } : {})
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
mission: {
|
|
276
|
+
objective: typeof params.objective === 'string' && params.objective ? params.objective : `agent check: ${cmd.slice(0, 100)}`,
|
|
277
|
+
scope: 'run agent-declared bounded check command; read-only intent',
|
|
278
|
+
checkpointId: 'check-watch',
|
|
279
|
+
requiredArtifacts: [], requiresChecks: false, businessAcceptance: 'not_required',
|
|
280
|
+
...(typeof params.deadlineAt === 'string' ? { deadlineAt: params.deadlineAt } : {})
|
|
281
|
+
},
|
|
282
|
+
policy: {
|
|
283
|
+
transport: isRelay ? 'relay' : 'local-display',
|
|
284
|
+
semanticMode: params.semanticMode === 'active' || params.semanticMode === 'shadow' ? params.semanticMode : 'off',
|
|
285
|
+
notificationOwner: 'watcher',
|
|
286
|
+
requestKinds: [],
|
|
287
|
+
maxRequestsPerEpisode: 1,
|
|
288
|
+
episodeCooldownMs: 60_000,
|
|
289
|
+
attentionTtlMs: DEFAULT_ATTENTION_TTL_MS
|
|
290
|
+
},
|
|
291
|
+
limits: {
|
|
292
|
+
// Fixed cadence (pollMin=pollMax=interval): no new information is not a backoff signal, so the cadence stays predictable
|
|
293
|
+
pollMinMs: intervalMs, pollMaxMs: intervalMs,
|
|
294
|
+
// Long pending is the normal shape of a readiness check: default silence is 30min (overridable)
|
|
295
|
+
maxSilenceMs: typeof params.maxSilenceMs === 'number' ? params.maxSilenceMs : 1_800_000,
|
|
296
|
+
maxProbesPerEpisode: 2, maxJudgeRequestsPerDay: 10,
|
|
297
|
+
expiresAt: new Date(Math.ceil(Date.now() / 3600_000) * 3600_000 + 12 * 3600_000).toISOString()
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
const spec = await rt.service.register(params.requestId, candidate, actor);
|
|
301
|
+
const relay = isRelay && spec.policy.transport === 'relay';
|
|
302
|
+
return {
|
|
303
|
+
watchId: spec.watchId,
|
|
304
|
+
runId,
|
|
305
|
+
controlRevision: spec.controlRevision,
|
|
306
|
+
transport: relay ? 'relay' : 'local-display',
|
|
307
|
+
intervalMs,
|
|
308
|
+
timeoutMs,
|
|
309
|
+
note: relay
|
|
310
|
+
? 'Watching check command via relay wake. Call watcher inspect/check anytime.'
|
|
311
|
+
: 'Watching check command. Call watcher inspect/check anytime.'
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
case 'inspect': {
|
|
315
|
+
if (!params.watchId)
|
|
316
|
+
throw Object.assign(new Error('inspect requires watchId'), { code: 'INVALID_SPEC' });
|
|
317
|
+
return projectInspect(await rt.service.inspect(params.watchId, actor));
|
|
318
|
+
}
|
|
319
|
+
case 'check': {
|
|
320
|
+
if (!params.requestId || !params.watchId || typeof params.expectedControlRevision !== 'number') {
|
|
321
|
+
throw Object.assign(new Error('check requires requestId, watchId, expectedControlRevision'), { code: 'INVALID_SPEC' });
|
|
322
|
+
}
|
|
323
|
+
const r = await rt.service.check(params.requestId, params.watchId, params.expectedControlRevision, actor);
|
|
324
|
+
const inspection = projectInspect(await rt.service.inspect(params.watchId, actor));
|
|
325
|
+
return {
|
|
326
|
+
inspectionId: r.inspectionId,
|
|
327
|
+
...inspection,
|
|
328
|
+
note: 'Live progress refreshed from source.'
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
case 'pause':
|
|
332
|
+
case 'close': {
|
|
333
|
+
if (!params.requestId || !params.watchId || typeof params.expectedControlRevision !== 'number') {
|
|
334
|
+
throw Object.assign(new Error(`${params.action} requires requestId, watchId, expectedControlRevision, reason`), { code: 'INVALID_SPEC' });
|
|
335
|
+
}
|
|
336
|
+
return projectControl(await rt.service.control(params.requestId, params.watchId, params.expectedControlRevision, params.action, params.reason ?? 'via watcher tool', actor));
|
|
337
|
+
}
|
|
338
|
+
case 'ack': {
|
|
339
|
+
// No relay delivery: do not fabricate a receipt out of thin air (design 11.7)
|
|
340
|
+
throw Object.assign(new Error('ack requires a relay consumer delivery (deliveryRef); relay managed path not negotiated — use local owner panel instead'), { code: 'NO_DELIVERY' });
|
|
341
|
+
}
|
|
342
|
+
case 'update': {
|
|
343
|
+
throw Object.assign(new Error('update is owner-scoped and not yet implemented (V1)'), { code: 'INVALID_SPEC' });
|
|
344
|
+
}
|
|
345
|
+
default:
|
|
346
|
+
throw Object.assign(new Error(`unknown action ${String(params.action)}`), { code: 'INVALID_SPEC' });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI status bar widget rendering (design 11 §33: ordinary progress updates the same widget and produces no long transcript;
|
|
3
|
+
* design 08 §35: normal status updates go to the widget and not into the LLM context).
|
|
4
|
+
*
|
|
5
|
+
* Parallels pi-scheduler's `[scheduler] N scheduled · ...`, but the semantics are fact-driven:
|
|
6
|
+
* - watch counts (active / paused)
|
|
7
|
+
* - unresolved episode count (open / snoozed / acknowledged)
|
|
8
|
+
* - nearest deadline (relative time; add [!] when overdue)
|
|
9
|
+
* - unhealthy watch (degraded/blind) count [!]
|
|
10
|
+
* - number of attention items awaiting host response (attention events whose episode is still open/snoozed)
|
|
11
|
+
*
|
|
12
|
+
* Pure function: the string[] overload leaves coloring to Pi defaults; returns [] (hides the widget) when there are no watches.
|
|
13
|
+
*/
|
|
14
|
+
export interface WidgetWatch {
|
|
15
|
+
lifecycle: string;
|
|
16
|
+
health: string;
|
|
17
|
+
deadlineAt?: string | null;
|
|
18
|
+
}
|
|
19
|
+
export interface WatcherWidgetInput {
|
|
20
|
+
watches: WidgetWatch[];
|
|
21
|
+
/** Total unresolved episodes (state in open/snoozed/acknowledged) */
|
|
22
|
+
unresolvedEpisodes: number;
|
|
23
|
+
/** Attention outbox events whose episode is still open/snoozed (awaiting host response) */
|
|
24
|
+
pendingAttentions: number;
|
|
25
|
+
now: number;
|
|
26
|
+
}
|
|
27
|
+
/** Relative time: before now -> "in 12m"; after -> "12m overdue"; <1m -> "now"/"just now". */
|
|
28
|
+
export declare function formatRelative(ms: number): string;
|
|
29
|
+
export declare function renderWatcherWidget(input: WatcherWidgetInput): string[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI status bar widget rendering (design 11 §33: ordinary progress updates the same widget and produces no long transcript;
|
|
3
|
+
* design 08 §35: normal status updates go to the widget and not into the LLM context).
|
|
4
|
+
*
|
|
5
|
+
* Parallels pi-scheduler's `[scheduler] N scheduled · ...`, but the semantics are fact-driven:
|
|
6
|
+
* - watch counts (active / paused)
|
|
7
|
+
* - unresolved episode count (open / snoozed / acknowledged)
|
|
8
|
+
* - nearest deadline (relative time; add [!] when overdue)
|
|
9
|
+
* - unhealthy watch (degraded/blind) count [!]
|
|
10
|
+
* - number of attention items awaiting host response (attention events whose episode is still open/snoozed)
|
|
11
|
+
*
|
|
12
|
+
* Pure function: the string[] overload leaves coloring to Pi defaults; returns [] (hides the widget) when there are no watches.
|
|
13
|
+
*/
|
|
14
|
+
/** Relative time: before now -> "in 12m"; after -> "12m overdue"; <1m -> "now"/"just now". */
|
|
15
|
+
export function formatRelative(ms) {
|
|
16
|
+
const abs = Math.abs(ms);
|
|
17
|
+
const text = abs < 60_000 ? '<1m'
|
|
18
|
+
: abs < 3_600_000 ? `${Math.round(abs / 60_000)}m`
|
|
19
|
+
: abs < 86_400_000 ? `${Math.round(abs / 3_600_000)}h`
|
|
20
|
+
: `${Math.round(abs / 86_400_000)}d`;
|
|
21
|
+
return ms > 0 ? `in ${text}` : `${text} overdue`;
|
|
22
|
+
}
|
|
23
|
+
export function renderWatcherWidget(input) {
|
|
24
|
+
const watches = input.watches.filter(w => w.lifecycle !== 'closed' && w.lifecycle !== 'expired');
|
|
25
|
+
if (watches.length === 0)
|
|
26
|
+
return [];
|
|
27
|
+
const active = watches.filter(w => w.lifecycle === 'active');
|
|
28
|
+
const paused = watches.filter(w => w.lifecycle === 'paused');
|
|
29
|
+
const unhealthy = watches.filter(w => w.health === 'degraded' || w.health === 'blind');
|
|
30
|
+
const parts = [];
|
|
31
|
+
parts.push(`${active.length} watch${active.length === 1 ? '' : 'es'}`);
|
|
32
|
+
if (paused.length > 0)
|
|
33
|
+
parts.push(`${paused.length} paused`);
|
|
34
|
+
if (input.unresolvedEpisodes > 0) {
|
|
35
|
+
parts.push(`${input.unresolvedEpisodes} open ep${input.unresolvedEpisodes === 1 ? '' : 's'}`);
|
|
36
|
+
}
|
|
37
|
+
// Nearest business deadline (taken only from active watches; paused ones are not nagged). Wording uses due (the moment the task should be done)
|
|
38
|
+
// rather than deadline, to distinguish it from timing concepts like watch expiry and silence budget
|
|
39
|
+
const deadlines = active
|
|
40
|
+
.map(w => (w.deadlineAt ? Date.parse(w.deadlineAt) : NaN))
|
|
41
|
+
.filter(Number.isFinite);
|
|
42
|
+
if (deadlines.length > 0) {
|
|
43
|
+
const nearest = Math.min(...deadlines);
|
|
44
|
+
const rel = formatRelative(nearest - input.now);
|
|
45
|
+
parts.push(nearest <= input.now ? `[!] due ${rel}` : `due ${rel}`);
|
|
46
|
+
}
|
|
47
|
+
if (unhealthy.length > 0)
|
|
48
|
+
parts.push(`[!] ${unhealthy.length} degraded`);
|
|
49
|
+
if (input.pendingAttentions > 0)
|
|
50
|
+
parts.push(`${input.pendingAttentions} attn pending`);
|
|
51
|
+
return [`[watcher] ${parts.join(' · ')}`];
|
|
52
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared runtime IPC client (attached session side).
|
|
3
|
+
* Single in-flight queue + line-delimited JSON frames; a disconnect/timeout marks the backend as lost,
|
|
4
|
+
* and the upper layer triggers failover (retry the lock to promote to primary, or attach to a new primary).
|
|
5
|
+
*/
|
|
6
|
+
import type { IpcRequest, IpcResponse } from './server.js';
|
|
7
|
+
export interface RuntimeIpcClientOptions {
|
|
8
|
+
connectTimeoutMs?: number;
|
|
9
|
+
requestTimeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
export declare class RuntimeIpcClient {
|
|
12
|
+
private readonly socket;
|
|
13
|
+
private readonly requestTimeoutMs;
|
|
14
|
+
private buf;
|
|
15
|
+
private dead;
|
|
16
|
+
private queue;
|
|
17
|
+
private pending;
|
|
18
|
+
private constructor();
|
|
19
|
+
static connect(socketPath: string, options?: RuntimeIpcClientOptions): Promise<RuntimeIpcClient>;
|
|
20
|
+
get isDead(): boolean;
|
|
21
|
+
request(req: IpcRequest): Promise<IpcResponse>;
|
|
22
|
+
close(): void;
|
|
23
|
+
private onData;
|
|
24
|
+
private failPending;
|
|
25
|
+
private markDead;
|
|
26
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared runtime IPC client (attached session side).
|
|
3
|
+
* Single in-flight queue + line-delimited JSON frames; a disconnect/timeout marks the backend as lost,
|
|
4
|
+
* and the upper layer triggers failover (retry the lock to promote to primary, or attach to a new primary).
|
|
5
|
+
*/
|
|
6
|
+
import * as net from 'node:net';
|
|
7
|
+
import { WatcherError } from '../util/result.js';
|
|
8
|
+
export class RuntimeIpcClient {
|
|
9
|
+
socket;
|
|
10
|
+
requestTimeoutMs;
|
|
11
|
+
buf = '';
|
|
12
|
+
dead = false;
|
|
13
|
+
queue = Promise.resolve();
|
|
14
|
+
pending = null;
|
|
15
|
+
constructor(socket, requestTimeoutMs) {
|
|
16
|
+
this.socket = socket;
|
|
17
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
18
|
+
socket.setEncoding('utf8');
|
|
19
|
+
socket.on('data', (d) => this.onData(d));
|
|
20
|
+
socket.on('close', () => this.markDead());
|
|
21
|
+
socket.on('error', () => this.markDead());
|
|
22
|
+
}
|
|
23
|
+
static async connect(socketPath, options = {}) {
|
|
24
|
+
const timeoutMs = options.connectTimeoutMs ?? 1500;
|
|
25
|
+
const socket = await new Promise((resolve, reject) => {
|
|
26
|
+
const s = net.connect(socketPath);
|
|
27
|
+
const timer = setTimeout(() => {
|
|
28
|
+
s.destroy();
|
|
29
|
+
reject(new WatcherError('ROOT_LOCK_HELD', `watcher shared runtime socket connect timeout: ${socketPath}`));
|
|
30
|
+
}, timeoutMs);
|
|
31
|
+
s.once('connect', () => { clearTimeout(timer); resolve(s); });
|
|
32
|
+
s.once('error', e => {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
reject(new WatcherError('ROOT_LOCK_HELD', `watcher shared runtime socket unreachable (${e instanceof Error ? e.message : String(e)}): ${socketPath}`));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
return new RuntimeIpcClient(socket, options.requestTimeoutMs ?? 20000);
|
|
38
|
+
}
|
|
39
|
+
get isDead() { return this.dead; }
|
|
40
|
+
request(req) {
|
|
41
|
+
const run = async () => {
|
|
42
|
+
if (this.dead) {
|
|
43
|
+
throw new WatcherError('STORE_UNAVAILABLE', 'watcher shared runtime connection lost');
|
|
44
|
+
}
|
|
45
|
+
return await new Promise((resolve, reject) => {
|
|
46
|
+
const timer = setTimeout(() => {
|
|
47
|
+
this.failPending(new WatcherError('STORE_UNAVAILABLE', 'watcher shared runtime request timeout'));
|
|
48
|
+
}, this.requestTimeoutMs);
|
|
49
|
+
this.pending = { resolve, reject, timer };
|
|
50
|
+
this.socket.write(JSON.stringify(req) + '\n', err => {
|
|
51
|
+
if (err)
|
|
52
|
+
this.failPending(new WatcherError('STORE_UNAVAILABLE', `watcher shared runtime write failed: ${err.message}`));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
const p = this.queue.then(run, run);
|
|
57
|
+
this.queue = p.catch(() => undefined);
|
|
58
|
+
return p;
|
|
59
|
+
}
|
|
60
|
+
close() {
|
|
61
|
+
this.markDead();
|
|
62
|
+
this.socket.destroy();
|
|
63
|
+
}
|
|
64
|
+
onData(d) {
|
|
65
|
+
this.buf += d;
|
|
66
|
+
let idx;
|
|
67
|
+
while ((idx = this.buf.indexOf('\n')) >= 0) {
|
|
68
|
+
const line = this.buf.slice(0, idx);
|
|
69
|
+
this.buf = this.buf.slice(idx + 1);
|
|
70
|
+
if (!line.trim())
|
|
71
|
+
continue;
|
|
72
|
+
const p = this.pending;
|
|
73
|
+
this.pending = null;
|
|
74
|
+
if (!p)
|
|
75
|
+
continue;
|
|
76
|
+
clearTimeout(p.timer);
|
|
77
|
+
try {
|
|
78
|
+
p.resolve(JSON.parse(line));
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
p.reject(new WatcherError('STORE_UNAVAILABLE', `watcher shared runtime malformed response: ${e instanceof Error ? e.message : String(e)}`));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
failPending(e) {
|
|
86
|
+
const p = this.pending;
|
|
87
|
+
this.pending = null;
|
|
88
|
+
this.markDead();
|
|
89
|
+
if (p) {
|
|
90
|
+
clearTimeout(p.timer);
|
|
91
|
+
p.reject(e);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
markDead() {
|
|
95
|
+
if (this.dead)
|
|
96
|
+
return;
|
|
97
|
+
this.dead = true;
|
|
98
|
+
const p = this.pending;
|
|
99
|
+
this.pending = null;
|
|
100
|
+
if (p) {
|
|
101
|
+
clearTimeout(p.timer);
|
|
102
|
+
p.reject(new WatcherError('STORE_UNAVAILABLE', 'watcher shared runtime connection lost'));
|
|
103
|
+
}
|
|
104
|
+
this.socket.destroy();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared runtime IPC server (primary side).
|
|
3
|
+
* Multiple pi sessions on the same project root share one embedded runtime: the lock-holding session (primary) serves
|
|
4
|
+
* attached sessions' tool/panel/widget requests on this unix socket. Protocol: line-delimited JSON.
|
|
5
|
+
*
|
|
6
|
+
* Invariant (design 7.2): the whole project still has a single store writer (the only opener under epoch fencing).
|
|
7
|
+
* Attached sessions never open the DB -- all mutations are serialized through the primary.
|
|
8
|
+
*/
|
|
9
|
+
/** JSON wire format of ActorContext (permitted: Set -> string[]). */
|
|
10
|
+
export interface WireActor {
|
|
11
|
+
actorId: string;
|
|
12
|
+
owner: Record<string, unknown>;
|
|
13
|
+
profileRevision: number;
|
|
14
|
+
permitted: string[];
|
|
15
|
+
}
|
|
16
|
+
export type IpcRequest = {
|
|
17
|
+
op: 'tool';
|
|
18
|
+
params: Record<string, unknown>;
|
|
19
|
+
actor: WireActor;
|
|
20
|
+
} | {
|
|
21
|
+
op: 'panel';
|
|
22
|
+
actor: WireActor;
|
|
23
|
+
} | {
|
|
24
|
+
op: 'ack';
|
|
25
|
+
episodeId: string;
|
|
26
|
+
action: string;
|
|
27
|
+
note: string;
|
|
28
|
+
until?: string;
|
|
29
|
+
requestId: string;
|
|
30
|
+
actor: WireActor;
|
|
31
|
+
} | {
|
|
32
|
+
op: 'widget';
|
|
33
|
+
lastNotifySeq?: number;
|
|
34
|
+
ownerSession?: string;
|
|
35
|
+
};
|
|
36
|
+
/** Same shape as ServiceResult: the result envelope across the wire. */
|
|
37
|
+
export interface IpcOk {
|
|
38
|
+
ok: true;
|
|
39
|
+
requestId?: string;
|
|
40
|
+
value: unknown;
|
|
41
|
+
}
|
|
42
|
+
export interface IpcErr {
|
|
43
|
+
ok: false;
|
|
44
|
+
requestId?: string;
|
|
45
|
+
error: {
|
|
46
|
+
code: string;
|
|
47
|
+
message: string;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export type IpcResponse = IpcOk | IpcErr;
|
|
51
|
+
export interface IpcHandlers {
|
|
52
|
+
tool(params: Record<string, unknown>, actor: WireActor): Promise<IpcResponse>;
|
|
53
|
+
panel(actor: WireActor): Promise<IpcResponse>;
|
|
54
|
+
ack(req: {
|
|
55
|
+
episodeId: string;
|
|
56
|
+
action: string;
|
|
57
|
+
note: string;
|
|
58
|
+
until?: string;
|
|
59
|
+
requestId: string;
|
|
60
|
+
}, actor: WireActor): Promise<IpcResponse>;
|
|
61
|
+
/** Widget snapshot + incremental attention notices (notices after lastNotifySeq, optionally filtered by ownerSession) */
|
|
62
|
+
widget(lastNotifySeq?: number, ownerSession?: string): IpcResponse;
|
|
63
|
+
}
|
|
64
|
+
export declare function runtimeSocketPath(rootDir: string): string;
|
|
65
|
+
export declare class RuntimeIpcServer {
|
|
66
|
+
private readonly socketPath;
|
|
67
|
+
private readonly handlers;
|
|
68
|
+
private server;
|
|
69
|
+
constructor(socketPath: string, handlers: IpcHandlers);
|
|
70
|
+
/**
|
|
71
|
+
* Call only after holding the root flock: any existing socket file is then a leftover from a crashed primary,
|
|
72
|
+
* so unlink is safe (a live primary's socket cannot coexist with us -- flock is exclusive).
|
|
73
|
+
*/
|
|
74
|
+
start(): Promise<void>;
|
|
75
|
+
close(): Promise<void>;
|
|
76
|
+
private handleConn;
|
|
77
|
+
private dispatch;
|
|
78
|
+
}
|