@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,587 @@
1
+ /**
2
+ * WatchService (design 11.1 / contracts interfaces.ts):
3
+ * register / list / inspect / check / control / applyResponse.
4
+ * The same business-state logic is reused by the Pi tool, slash command, service RPC and tests.
5
+ * Idempotency: same requestId+digest returns the old result; different digest -> REQUEST_CONFLICT (11.4).
6
+ */
7
+ import { emptySnapshot } from '../storage/store.js';
8
+ import { newId, stableStringify } from '../util/ids.js';
9
+ import { WatcherError, ok, err } from '../util/result.js';
10
+ export class WatchService {
11
+ clock;
12
+ store;
13
+ engine;
14
+ negotiationRef;
15
+ deliveryRef;
16
+ allowedSourceIds;
17
+ requiredCheckIds;
18
+ inFlightChecks = new Map();
19
+ constructor(options) {
20
+ this.clock = options.clock;
21
+ this.store = options.store;
22
+ this.engine = options.engine;
23
+ const neg = options.negotiation;
24
+ this.negotiationRef = typeof neg === 'function' ? neg : () => neg;
25
+ const d = options.delivery;
26
+ this.deliveryRef = typeof d === 'function' ? d : () => d;
27
+ this.allowedSourceIds = options.allowedSourceIds ?? [];
28
+ this.requiredCheckIds = options.requiredCheckIds ?? new Map();
29
+ }
30
+ get negotiation() {
31
+ return this.negotiationRef();
32
+ }
33
+ requireOwner(row, actor) {
34
+ if (row.ownerSession !== actor.owner.sessionId) {
35
+ throw new WatcherError('CAPABILITY_DENIED', 'watch belongs to a different owner session');
36
+ }
37
+ }
38
+ // --- register (T1) ---
39
+ async register(requestId, candidate, actor) {
40
+ validateRegisterCandidate(candidate);
41
+ // V3: group/obligation target validation (members exist, same owner, cap); run uses the original path
42
+ if (candidate.target.kind !== 'run') {
43
+ validateCompositeTarget(candidate, actor, this.store, this.allowedSourceIds);
44
+ }
45
+ if (candidate.target.kind === 'run' && !this.allowedSourceIds.includes(candidate.target.sourceId)) {
46
+ throw new WatcherError('CAPABILITY_DENIED', `sourceId ${candidate.target.sourceId} not approved in owner profile`);
47
+ }
48
+ if (candidate.policy.transport === 'relay' && this.negotiation.transport !== 'relay') {
49
+ throw new WatcherError('DEPENDENCY_UNQUALIFIED', `transport=relay requires relay protocol 1.2 managed features; negotiation status=${this.negotiation.status}. Register with transport=local-display.`);
50
+ }
51
+ const digest = stableStringify({ op: 'register', requestId, candidate });
52
+ const now = this.clock.wallNow();
53
+ return this.store.transaction(tx => {
54
+ const watchId = newId('w');
55
+ const spec = {
56
+ ...candidate,
57
+ watchId,
58
+ generation: 1,
59
+ missionRevision: 1,
60
+ controlRevision: 1,
61
+ owner: actor.owner
62
+ };
63
+ const response = { spec };
64
+ const cmd = tx.persistCommandResult(actor.actorId, requestId, digest, response);
65
+ if (cmd.duplicate && cmd.previous) {
66
+ const prev = cmd.previous;
67
+ return prev.spec;
68
+ }
69
+ tx.insertWatch({
70
+ watchId,
71
+ generation: 1,
72
+ missionRevision: 1,
73
+ controlRevision: 1,
74
+ lifecycle: 'active',
75
+ health: 'healthy',
76
+ ownerSession: actor.owner.sessionId,
77
+ ownerBindingEpoch: actor.owner.bindingEpoch,
78
+ spec,
79
+ snapshot: emptySnapshot(),
80
+ nextDueAt: now,
81
+ now
82
+ });
83
+ return spec;
84
+ });
85
+ }
86
+ // --- list ---
87
+ async list(cursor, limit, actor, lifecycles) {
88
+ const boundedLimit = Math.max(1, Math.min(limit, 50));
89
+ const rows = this.store.transaction(tx => tx.listWatches(actor.owner.sessionId, cursor, boundedLimit, lifecycles));
90
+ const items = rows.map(row => ({
91
+ watchId: row.watchId,
92
+ lifecycle: row.lifecycle,
93
+ health: row.health,
94
+ taskState: row.snapshot.taskState,
95
+ controlRevision: row.spec.controlRevision,
96
+ checkpointId: row.spec.mission.checkpointId,
97
+ target: row.spec.target,
98
+ nextDueAt: row.nextDueAt,
99
+ openEpisodes: this.store.transaction(tx => tx.countOpenEpisodes(row.watchId)),
100
+ lastResultId: row.snapshot.lastResultId ?? null
101
+ }));
102
+ return {
103
+ items,
104
+ nextCursor: rows.length === boundedLimit ? rows[rows.length - 1].watchId : null,
105
+ // Terminal-state summary (to cut noise in the list projection): closed/expired are no longer echoed row by row to the model, only counted
106
+ terminalCount: this.store.transaction(tx => tx.countWatches(actor.owner.sessionId, ['closed', 'expired'])),
107
+ relay: { status: this.negotiation.status, transport: this.negotiation.transport }
108
+ };
109
+ }
110
+ // --- inspect ---
111
+ async inspect(watchId, actor) {
112
+ const row = this.store.transaction(tx => tx.getWatchRow(watchId));
113
+ if (!row)
114
+ throw new WatcherError('UNKNOWN_TARGET', `watch ${watchId} not found`);
115
+ this.requireOwner(row, actor);
116
+ const episodes = this.store.transaction(tx => tx.listEpisodes(watchId, 20));
117
+ const outbox = this.store.transaction(tx => tx.listOutboxByWatch(watchId, 20));
118
+ const observations = this.store.transaction(tx => tx.listObservations(watchId, 16));
119
+ const judgments = this.store.transaction(tx => tx.listJudgments(watchId, 10));
120
+ const cards = this.store.listResultCards(watchId);
121
+ return {
122
+ watch: row.spec,
123
+ lifecycle: row.lifecycle,
124
+ health: row.health,
125
+ snapshot: row.snapshot,
126
+ nextDueAt: row.nextDueAt,
127
+ episodes: episodes.map(episodeView),
128
+ outbox: outbox.map(o => ({
129
+ eventId: o.eventId,
130
+ eventType: o.eventType,
131
+ admission: o.admission,
132
+ admissionJson: o.admissionJson,
133
+ validUntil: o.validUntil,
134
+ digest: o.eventDigest
135
+ })),
136
+ observations: observations.map(o => ({
137
+ observationId: o.observationId,
138
+ sourceSeq: o.sourceSeq,
139
+ observedAt: new Date(o.observedAt).toISOString(),
140
+ kind: 'auto',
141
+ digest: o.digest
142
+ })),
143
+ judgments,
144
+ resultCards: cards,
145
+ relay: { status: this.negotiation.status, transport: this.negotiation.transport }
146
+ };
147
+ }
148
+ // --- check: bounded read-only refresh (merges in-flight requests of the same version) ---
149
+ async check(requestId, watchId, expectedControlRevision, actor) {
150
+ const row = this.store.transaction(tx => tx.getWatchRow(watchId));
151
+ if (!row)
152
+ throw new WatcherError('UNKNOWN_TARGET', `watch ${watchId} not found`);
153
+ this.requireOwner(row, actor);
154
+ if (row.controlRevision !== expectedControlRevision) {
155
+ throw new WatcherError('STALE_REVISION', `expected controlRevision ${expectedControlRevision}, current ${row.controlRevision}`);
156
+ }
157
+ const key = `${watchId}:${row.controlRevision}`;
158
+ const existing = this.inFlightChecks.get(key);
159
+ if (existing) {
160
+ return existing.promise;
161
+ }
162
+ const inspectionId = newId('insp');
163
+ const promise = (async () => {
164
+ try {
165
+ await this.engine.inspectWatch(watchId);
166
+ return { inspectionId };
167
+ }
168
+ finally {
169
+ this.inFlightChecks.delete(key);
170
+ }
171
+ })();
172
+ this.inFlightChecks.set(key, { inspectionId, promise });
173
+ return promise;
174
+ }
175
+ // --- control: pause/resume/close (T6) ---
176
+ async control(requestId, watchId, expectedControlRevision, action, reason, actor) {
177
+ const digest = stableStringify({ op: 'control', requestId, watchId, expectedControlRevision, action, reason });
178
+ const now = this.clock.wallNow();
179
+ const result = this.store.transaction(tx => {
180
+ // Idempotent replay first: same requestId+digest returns the saved result directly, without re-validating the current revision
181
+ const replay = tx.getCommand(actor.actorId, requestId);
182
+ if (replay) {
183
+ if (replay.digest === digest)
184
+ return replay.response;
185
+ throw new WatcherError('REQUEST_CONFLICT', `requestId ${requestId} replayed with different digest`);
186
+ }
187
+ const row0 = tx.getWatchRow(watchId);
188
+ if (!row0)
189
+ throw new WatcherError('UNKNOWN_TARGET', `watch ${watchId} not found`);
190
+ this.requireOwner(row0, actor);
191
+ if (row0.controlRevision !== expectedControlRevision) {
192
+ throw new WatcherError('STALE_REVISION', `expected controlRevision ${expectedControlRevision}, current ${row0.controlRevision}`);
193
+ }
194
+ const row = tx.getWatchRow(watchId);
195
+ if (!row)
196
+ throw new WatcherError('UNKNOWN_TARGET', `watch ${watchId} not found`);
197
+ this.requireOwner(row, actor);
198
+ if (row.controlRevision !== expectedControlRevision) {
199
+ throw new WatcherError('STALE_REVISION', `expected controlRevision ${expectedControlRevision}, current ${row.controlRevision}`);
200
+ }
201
+ const nextLifecycle = action === 'pause' ? 'paused' : action === 'close' ? 'closed' : 'active';
202
+ if (action === 'resume' && row.lifecycle !== 'paused') {
203
+ throw new WatcherError('INVALID_SPEC', `resume requires paused watch, current lifecycle=${row.lifecycle}`);
204
+ }
205
+ if (action === 'pause' && row.lifecycle !== 'active') {
206
+ throw new WatcherError('INVALID_SPEC', `pause requires active watch, current lifecycle=${row.lifecycle}`);
207
+ }
208
+ if (action === 'close' && row.lifecycle === 'closed') {
209
+ throw new WatcherError('INVALID_SPEC', 'watch already closed');
210
+ }
211
+ const nextControlRevision = row.controlRevision + 1;
212
+ const requestedState = action === 'pause' ? 'paused' : action === 'close' ? 'closed' : 'active';
213
+ // Scope advance intent (design 9.6): only for a relay scope that exists. A watch that
214
+ // never published a managed attention has nothing captured under its scope to fence.
215
+ // resume re-activates the scope but does not re-arm old events (they stay fenced).
216
+ const relayScope = row.snapshot.relayScope;
217
+ let scopeOperationId = null;
218
+ let nextRelayScope = relayScope;
219
+ if (relayScope && relayScope.state !== 'closed') {
220
+ scopeOperationId = newId('ctl');
221
+ tx.insertControlOutbox({
222
+ operationId: scopeOperationId,
223
+ watchId,
224
+ scopeId: relayScope.scopeId,
225
+ expectedRevision: relayScope.revision,
226
+ nextRevision: relayScope.revision + 1,
227
+ requestedState,
228
+ now
229
+ });
230
+ nextRelayScope = { scopeId: relayScope.scopeId, revision: relayScope.revision + 1, state: requestedState };
231
+ }
232
+ // Withdraw intents (design 6.5, I15, I20): every attention of this watch that may
233
+ // still wake the host. Durable in the same transaction; the relay call happens after
234
+ // commit and is retried by the control drain until it returns per-route results.
235
+ const withdrawEventIds = [];
236
+ if (action !== 'resume') {
237
+ for (const o of tx.listOutboxByWatch(watchId, 200)) {
238
+ if (o.eventType !== 'watcher.attention.v1' || o.validUntil <= now || o.admissionJson.withdraw)
239
+ continue;
240
+ if (!['pending', 'publishing', 'unknown', 'source-staged'].includes(o.admission))
241
+ continue;
242
+ tx.mergeOutboxAdmissionJson(o.eventId, {
243
+ withdraw: { operationId: newId('wd'), reason: `watch ${action}: ${reason}`.slice(0, 200), requestedAt: now, result: null }
244
+ }, now);
245
+ withdrawEventIds.push(o.eventId);
246
+ }
247
+ }
248
+ tx.updateWatch(watchId, {
249
+ lifecycle: nextLifecycle,
250
+ controlRevision: nextControlRevision,
251
+ snapshot: { ...row.snapshot, relayScope: nextRelayScope }
252
+ }, now);
253
+ // On close, supersede unresolved episodes (the issue is superseded as the watch shuts down; not disguised as responded)
254
+ if (action === 'close') {
255
+ tx.supersedeEpisodesOfWatch(watchId, now);
256
+ }
257
+ // The persisted command result is the local cut only (design 7.3 T6). Relay-side
258
+ // layers are derived from durable state on every read, so a replay never reports a
259
+ // stale snapshot of them.
260
+ const response = {
261
+ localPaused: action === 'pause' || action === 'close',
262
+ lifecycle: nextLifecycle,
263
+ controlRevision: nextControlRevision,
264
+ scopeOperationId,
265
+ withdrawEventIds,
266
+ reason
267
+ };
268
+ updateCommandFinal(tx, actor.actorId, requestId, digest, response);
269
+ return response;
270
+ });
271
+ // External I/O strictly after commit (a sync throw inside the outer transaction once
272
+ // opened a nested transaction).
273
+ if (this.deliveryRef()) {
274
+ try {
275
+ await this.engine.drainControl(watchId);
276
+ }
277
+ catch {
278
+ /* intents are durable; the loop drain retries */
279
+ }
280
+ }
281
+ return { ...result, ...this.relayCutReport(result) };
282
+ }
283
+ /**
284
+ * Layered withdrawal report (design 6.5): sourceFence for the scope advance and one entry
285
+ * per affected route. "withdrawn" is claimed only when every route reports prevented
286
+ * (I15); missing evidence stays pending/unknown, never success.
287
+ */
288
+ relayCutReport(local) {
289
+ const scopeOperationId = typeof local.scopeOperationId === 'string' ? local.scopeOperationId : null;
290
+ const withdrawEventIds = Array.isArray(local.withdrawEventIds) ? local.withdrawEventIds : [];
291
+ const relayReady = this.negotiationRef().transport === 'relay' && !!this.deliveryRef();
292
+ const status = scopeOperationId
293
+ ? this.store.transaction(tx => tx.getControlOutboxStatus(scopeOperationId))
294
+ : null;
295
+ let sourceFence;
296
+ if (!scopeOperationId) {
297
+ sourceFence = withdrawEventIds.length > 0
298
+ ? { status: 'not-needed', detail: 'no per-watch relay scope; legacy events are cut by withdraw' }
299
+ : relayReady
300
+ ? { status: 'not-needed', detail: 'nothing was captured under a relay scope for this watch' }
301
+ : { status: 'not-configured', detail: 'no relay managed path; nothing in flight to withdraw (local-display only)' };
302
+ }
303
+ else if (status === 'source-applied' || status === 'complete') {
304
+ sourceFence = { status: 'applied', operationId: scopeOperationId };
305
+ }
306
+ else if (status === 'rejected') {
307
+ sourceFence = { status: 'rejected', operationId: scopeOperationId };
308
+ }
309
+ else if (status === 'unknown') {
310
+ sourceFence = { status: 'unknown', operationId: scopeOperationId };
311
+ }
312
+ else {
313
+ sourceFence = { status: 'pending', operationId: scopeOperationId };
314
+ }
315
+ const routes = [];
316
+ let allPrevented = withdrawEventIds.length > 0;
317
+ for (const eventId of withdrawEventIds) {
318
+ const w = this.store.transaction(tx => tx.getOutbox(eventId))?.admissionJson.withdraw;
319
+ const rs = w?.result?.routes;
320
+ if (!rs) {
321
+ routes.push({ eventId, routeRef: null, disposition: 'pending' });
322
+ allPrevented = false;
323
+ continue;
324
+ }
325
+ if (rs.length === 0) {
326
+ routes.push({ eventId, routeRef: null, disposition: 'unknown' });
327
+ allPrevented = false;
328
+ }
329
+ for (const r of rs) {
330
+ routes.push({ eventId, routeRef: r.routeRef, disposition: r.disposition });
331
+ if (r.disposition !== 'prevented')
332
+ allPrevented = false;
333
+ }
334
+ }
335
+ return { sourceFence, routes, withdrawn: allPrevented };
336
+ }
337
+ // --- applyResponse: internal response pump (T5), no independent network ACK entry ---
338
+ async applyResponse(response) {
339
+ const now = this.clock.wallNow();
340
+ return this.store.transaction(tx => {
341
+ const existing = tx.getAppliedResponse(response.responseId);
342
+ if (existing) {
343
+ const r = existing.result;
344
+ return {
345
+ outcome: r.outcome ?? 'applied',
346
+ applicationRevision: r.applicationRevision ?? 0,
347
+ code: r.code ?? 'APPLIED'
348
+ };
349
+ }
350
+ const episode = tx.getEpisode(response.body.episodeId);
351
+ if (!episode) {
352
+ return { outcome: 'rejected', applicationRevision: 0, code: 'INVALID_RESPONSE' };
353
+ }
354
+ const watch = tx.getWatchRow(episode.watchId);
355
+ if (!watch || watch.ownerBindingEpoch !== response.ownerBindingEpoch) {
356
+ return { outcome: 'rejected', applicationRevision: 0, code: 'OWNER_MISMATCH' };
357
+ }
358
+ if (episode.revision !== response.body.expectedEpisodeRevision) {
359
+ return { outcome: 'stale', applicationRevision: episode.revision, code: 'STALE_EPISODE' };
360
+ }
361
+ if (episode.state === 'resolved' || episode.state === 'superseded') {
362
+ // A closed issue is not reopened by a late ACK (design 3.4)
363
+ return { outcome: 'stale', applicationRevision: episode.revision, code: 'ALREADY_CLOSED' };
364
+ }
365
+ const action = response.body.action;
366
+ const body = { ...episode.body, lastAck: { action, reason: response.body.reason, at: new Date(now).toISOString() } };
367
+ let nextState;
368
+ let snoozeUntil = null;
369
+ switch (action) {
370
+ case 'received':
371
+ case 'investigating':
372
+ nextState = 'acknowledged';
373
+ break;
374
+ case 'defer':
375
+ nextState = 'snoozed';
376
+ snoozeUntil = response.body.until ? Date.parse(response.body.until) : now + 3600_000;
377
+ break;
378
+ case 'resolved':
379
+ case 'dismiss':
380
+ nextState = 'resolved';
381
+ break;
382
+ }
383
+ const newRevision = tx.updateEpisode(episode.episodeId, episode.revision, {
384
+ state: nextState,
385
+ body,
386
+ snoozeUntil
387
+ }, now);
388
+ if (newRevision === null) {
389
+ return { outcome: 'stale', applicationRevision: episode.revision, code: 'STALE_EPISODE' };
390
+ }
391
+ const result = {
392
+ outcome: 'applied', applicationRevision: newRevision, code: 'APPLIED'
393
+ };
394
+ tx.insertAppliedResponse({
395
+ responseId: response.responseId,
396
+ watchId: episode.watchId,
397
+ episodeId: episode.episodeId,
398
+ deliveryRef: response.deliveryRef,
399
+ digest: response.digest,
400
+ result: result,
401
+ now
402
+ });
403
+ tx.insertAppliedConfirmOutbox(newId('conf'), response.responseId);
404
+ // Respond-then-close: when a relay response flows back and sets resolved, bring nextDue forward so the fast-close gate finishes immediately (5s grace)
405
+ if (nextState === 'resolved' && watch.spec.target.kind === 'run') {
406
+ const episodes = tx.listEpisodes(watch.watchId, 50);
407
+ if (episodes.length > 0 && episodes.every(e => e.state === 'resolved' || e.state === 'superseded')) {
408
+ const targetDue = Math.max(now, (watch.snapshot.terminalAt ?? now) + 5_000);
409
+ if (watch.nextDueAt === null || watch.nextDueAt > targetDue) {
410
+ tx.updateWatch(watch.watchId, { nextDueAt: targetDue }, now);
411
+ }
412
+ }
413
+ }
414
+ return result;
415
+ });
416
+ }
417
+ /** V3: local owner ACK (for the /watcher panel only; the model's tool ack still needs relay delivery, design 11.7). */
418
+ async ackEpisode(requestId, episodeId, action, reason, untilIso, actor) {
419
+ const digest = stableStringify({ op: 'ack', requestId, episodeId, action, reason, untilIso });
420
+ const now = this.clock.wallNow();
421
+ return this.store.transaction(tx => {
422
+ const replay = tx.getCommand(actor.actorId, requestId);
423
+ if (replay) {
424
+ if (replay.digest === digest)
425
+ return replay.response;
426
+ throw new WatcherError('REQUEST_CONFLICT', `requestId ${requestId} replayed with different digest`);
427
+ }
428
+ const episode = tx.getEpisode(episodeId);
429
+ if (!episode)
430
+ throw new WatcherError('UNKNOWN_TARGET', `episode ${episodeId} not found`);
431
+ const watch = tx.getWatchRow(episode.watchId);
432
+ if (!watch)
433
+ throw new WatcherError('UNKNOWN_TARGET', `watch ${episode.watchId} not found`);
434
+ this.requireOwner(watch, actor);
435
+ if (episode.state === 'resolved' || episode.state === 'superseded') {
436
+ throw new WatcherError('STALE_REVISION', `episode already ${episode.state}`);
437
+ }
438
+ const nextState = action === 'received' || action === 'investigating' ? 'acknowledged'
439
+ : action === 'defer' ? 'snoozed' : 'resolved';
440
+ const snoozeUntil = action === 'defer'
441
+ ? (untilIso ? Date.parse(untilIso) : now + 3600_000)
442
+ : null;
443
+ if (Number.isNaN(snoozeUntil))
444
+ throw new WatcherError('INVALID_SPEC', 'until must be ISO datetime');
445
+ const body = { ...episode.body, lastAck: { action, reason, at: new Date(now).toISOString(), via: 'local-owner-panel' } };
446
+ const newRevision = tx.updateEpisode(episodeId, episode.revision, { state: nextState, body, snoozeUntil }, now);
447
+ if (newRevision === null)
448
+ throw new WatcherError('STALE_REVISION', 'episode revision moved');
449
+ // Respond-then-close: when all episodes of a terminal-state watch are already resolved/superseded,
450
+ // inspection is still on the 60s terminal backoff cadence -- bring nextDue forward to after the 5s grace so the fast-close gate evaluates promptly
451
+ if (nextState === 'resolved' && watch.spec.target.kind === 'run') {
452
+ const episodes = tx.listEpisodes(watch.watchId, 50);
453
+ if (episodes.length > 0 && episodes.every(e => e.state === 'resolved' || e.state === 'superseded')) {
454
+ const targetDue = Math.max(now, (watch.snapshot.terminalAt ?? now) + 5_000);
455
+ if (watch.nextDueAt === null || watch.nextDueAt > targetDue) {
456
+ tx.updateWatch(watch.watchId, { nextDueAt: targetDue }, now);
457
+ }
458
+ }
459
+ }
460
+ const response = {
461
+ episodeId,
462
+ state: nextState,
463
+ revision: newRevision,
464
+ snoozeUntil,
465
+ note: 'local owner panel ack; no relay delivery claimed (I05/I21)'
466
+ };
467
+ updateCommandFinal(tx, actor.actorId, requestId, digest, response);
468
+ return response;
469
+ });
470
+ }
471
+ /** Minimal projection of the panel's six questions (design 11.3). */
472
+ panel(actor) {
473
+ const meta = this.store.transaction(tx => tx.meta());
474
+ const watches = this.store.transaction(tx => tx.listWatches(actor.owner.sessionId, undefined, 50));
475
+ return {
476
+ runtimeEpoch: meta.runtimeEpoch,
477
+ mode: meta.mode,
478
+ recoveryAttentionHold: meta.recoveryAttentionHold,
479
+ relay: this.negotiation,
480
+ watches: watches.map(w => ({
481
+ watchId: w.watchId,
482
+ lifecycle: w.lifecycle,
483
+ health: w.health,
484
+ taskState: w.snapshot.taskState,
485
+ nextDueAt: w.nextDueAt,
486
+ semantic: w.snapshot.semantic
487
+ ? {
488
+ mode: w.snapshot.semantic.mode,
489
+ candidates: w.snapshot.semantic.candidates ?? {},
490
+ meaningfulProgress: w.snapshot.semantic.meaningfulProgress ?? null,
491
+ consentMissing: w.snapshot.semantic.consentMissing ?? false,
492
+ budgetExhausted: w.snapshot.semantic.budgetExhausted ?? false,
493
+ error: w.snapshot.semantic.error ?? null
494
+ }
495
+ : null,
496
+ whyNoWake: noWakeReason(w, this.negotiation)
497
+ }))
498
+ };
499
+ }
500
+ }
501
+ function noWakeReason(row, negotiation) {
502
+ if (negotiation.transport !== 'relay')
503
+ return 'display-only: relay managed path not negotiated';
504
+ if (row.lifecycle === 'paused')
505
+ return 'paused';
506
+ if (row.lifecycle === 'closed')
507
+ return 'closed';
508
+ if (row.lifecycle === 'expired')
509
+ return 'expired';
510
+ return 'none';
511
+ }
512
+ function episodeView(e) {
513
+ return {
514
+ episodeId: e.episodeId,
515
+ kind: e.kind,
516
+ checkpointId: e.checkpointId,
517
+ ordinal: e.ordinal,
518
+ revision: e.revision,
519
+ state: e.state,
520
+ body: e.body,
521
+ snoozeUntil: e.snoozeUntil,
522
+ updatedAt: e.updatedAt
523
+ };
524
+ }
525
+ function updateCommandFinal(tx, actorId, requestId, digest, response) {
526
+ const r = tx.persistCommandResult(actorId, requestId, digest, response);
527
+ void r;
528
+ }
529
+ function validateRegisterCandidate(candidate) {
530
+ const errors = [];
531
+ if (!candidate.mission?.objective)
532
+ errors.push('mission.objective required');
533
+ if (!candidate.mission?.checkpointId)
534
+ errors.push('mission.checkpointId required');
535
+ if (!candidate.limits?.expiresAt || Number.isNaN(Date.parse(candidate.limits.expiresAt)))
536
+ errors.push('limits.expiresAt (ISO) required');
537
+ if (!candidate.policy)
538
+ errors.push('policy required');
539
+ if (candidate.policy && !['local-display', 'relay'].includes(candidate.policy.transport))
540
+ errors.push('policy.transport must be local-display|relay');
541
+ if (candidate.policy && !['off', 'shadow', 'active'].includes(candidate.policy.semanticMode))
542
+ errors.push('policy.semanticMode invalid');
543
+ if (candidate.limits?.pollMinMs !== undefined && candidate.limits.pollMinMs < 1000)
544
+ errors.push('limits.pollMinMs >= 1000 required');
545
+ if (errors.length > 0) {
546
+ throw new WatcherError('INVALID_SPEC', errors.join('; '));
547
+ }
548
+ }
549
+ export function serviceResult(requestId, fn) {
550
+ try {
551
+ return ok(fn(), requestId);
552
+ }
553
+ catch (e) {
554
+ if (e instanceof WatcherError)
555
+ return err(e.code, e.message, requestId);
556
+ return err('STORE_UNAVAILABLE', e instanceof Error ? e.message : String(e), requestId);
557
+ }
558
+ }
559
+ /** V3 composite target validation: members exist, same owner, generation matches, maxGroupChildren cap. */
560
+ function validateCompositeTarget(candidate, actor, store, allowedSourceIds) {
561
+ const t = candidate.target;
562
+ if (t.kind === 'group' || t.kind === 'obligation') {
563
+ const refs = t.kind === 'group' ? t.members : t.dependencies;
564
+ if (t.kind === 'group' && refs.length === 0) {
565
+ throw new WatcherError('INVALID_SPEC', 'group target requires at least one member');
566
+ }
567
+ if (refs.length > 16) {
568
+ throw new WatcherError('INVALID_SPEC', 'group/obligation references exceed maxGroupChildren=16');
569
+ }
570
+ for (const ref of refs) {
571
+ const row = store.transaction(tx => tx.getWatchRow(ref.watchId));
572
+ if (!row)
573
+ throw new WatcherError('UNKNOWN_TARGET', `member watch ${ref.watchId} not found`);
574
+ if (row.generation !== ref.generation) {
575
+ throw new WatcherError('STALE_REVISION', `member ${ref.watchId} generation ${ref.generation} != current ${row.generation}`);
576
+ }
577
+ if (row.ownerSession !== actor.owner.sessionId) {
578
+ throw new WatcherError('CAPABILITY_DENIED', `member watch ${ref.watchId} belongs to another owner session`);
579
+ }
580
+ }
581
+ if (t.kind === 'obligation') {
582
+ if (!t.hostAction || t.hostAction.trim() === '') {
583
+ throw new WatcherError('INVALID_SPEC', 'obligation target requires hostAction (the decision/action owed)');
584
+ }
585
+ }
586
+ }
587
+ }
@@ -0,0 +1,35 @@
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 type { Json, ActorContext } from '../contracts/interfaces.js';
10
+ import type { WatcherRuntime } from '../runtime.js';
11
+ export interface ToolParams {
12
+ action: string;
13
+ requestId?: string;
14
+ watchId?: string;
15
+ expectedControlRevision?: number;
16
+ reason?: string;
17
+ candidate?: Record<string, unknown>;
18
+ /** action=watch-file / watch-check (generic evidence source declared by the agent, decision-delta 2026-09-21) */
19
+ path?: string;
20
+ cwd?: string;
21
+ okPattern?: string;
22
+ failPattern?: string;
23
+ /** action=watch-check */
24
+ cmd?: string;
25
+ intervalMs?: number;
26
+ timeoutMs?: number;
27
+ failCodes?: number[];
28
+ objective?: string;
29
+ deadlineAt?: string;
30
+ maxSilenceMs?: number;
31
+ semanticMode?: 'off' | 'shadow' | 'active';
32
+ /** action=list: explicitly include terminal-state (closed/expired) watches; by default only active watches + a terminalCount summary are returned */
33
+ includeClosed?: boolean;
34
+ }
35
+ export declare function runToolAction(rt: WatcherRuntime, params: ToolParams, actor: ActorContext): Promise<Json>;