@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,120 @@
1
+ /**
2
+ * Watcher-side relay integration (design 09 / relay-next 02 s2.2, 4-stage managed delivery).
3
+ *
4
+ * Division of responsibilities:
5
+ * - This module (watcher repo): embedded managed SourceHost, consumer declaration file, invite production,
6
+ * audience/scope configuration (owner surface), and the ManagedDeliveryPort implementation (publish/withdraw/receipt/
7
+ * host-response consumption/applied confirmation).
8
+ * - relay repo (done): target-side admit/guard/pump/respond, Pi session pump wiring
9
+ * (stage 4), declarative consumer registration (<relay-home>/consumers).
10
+ *
11
+ * Discipline: wake goes only through the relay managed path (I05/I21); this module never touches pi sendMessage.
12
+ */
13
+ import { type SourceHost } from '@maolon/pi-relay/source';
14
+ import type { Id, Json, ManagedDeliveryPort, VerifiedConsumerResponse } from '../contracts/interfaces.js';
15
+ import type { ManagedReceipt, WithdrawResult, ApplicationResult } from '../contracts/relay-next.interfaces.js';
16
+ export interface WatcherRelayOptions {
17
+ /** relay home (must match the host session's relay target; default ~/.pi/relay) */
18
+ relayHome: string;
19
+ realm?: string;
20
+ sourceId?: string;
21
+ channelId?: string;
22
+ consumerProfileId?: string;
23
+ }
24
+ /** watcher application event TypeManifest (design 9.3: watcher.attention/result.v1 is the application-layer manifest) */
25
+ interface TypeManifestShape {
26
+ type: string;
27
+ schemaVersion: number;
28
+ kind: 'event';
29
+ dataSchema: Record<string, unknown>;
30
+ }
31
+ export declare const WATCHER_TYPE_MANIFESTS: readonly TypeManifestShape[];
32
+ export declare const WATCHER_EVENT_TYPES: readonly ["watcher.attention.v1", "watcher.result.v1"];
33
+ /** Per-root sourceId: with a global extension, every pi session starts a watcher runtime for its own cwd root.
34
+ * If all roots shared the sourceId 'pi-watcher', the same source store in one relay home would be
35
+ * held exclusively (owner lock) by the first process that opens it until it exits, so an idle session of an unrelated project could
36
+ * permanently block roots that really need relay.
37
+ * Root primary election ensures only one runtime per root opens the store -> one sourceId per root structurally eliminates cross-root contention.
38
+ * The charset must satisfy the relay bind-request validation /^[A-Za-z0-9][A-Za-z0-9._:-]*$/. */
39
+ export declare function deriveSourceId(rootDir: string): string;
40
+ /**
41
+ * Unlimited validity for the local-domain managed audience sentinel.
42
+ * Value = pi-relay FOREVER / STANDING_EXPIRES_AT = MAX_SAFE_INTEGER (accepted on localTrust
43
+ * channels in current pi-relay, stored as 9007199254740991, aligned with standing membership).
44
+ * Monitoring channel lifetime must be >= the task horizon: a 24h wall silently kills the wake chain in long sessions.
45
+ * This source's channel is always localTrust:true (same-uid standing on this machine); if a non-localTrust
46
+ * channel is introduced in the future, this choice must be tied to the channel trust level. Remote/invite domains are still guarded by the relay-side 24h cap.
47
+ */
48
+ export declare const LOCAL_AUDIENCE_VALID_UNTIL_MS = 9007199254740991;
49
+ export declare class WatcherRelaySource implements ManagedDeliveryPort {
50
+ readonly host: SourceHost;
51
+ private readonly opts;
52
+ private readonly secretsPath;
53
+ private readonly setupPath;
54
+ private setup;
55
+ private constructor();
56
+ static open(watcherRoot: string, options: WatcherRelayOptions): Promise<WatcherRelaySource>;
57
+ /** Declarative consumer registration (relay stage 4 generic config surface: <relay-home>/consumers); idempotent: returns if it already exists. */
58
+ ensureConsumerDeclaration(force?: boolean): {
59
+ file: string;
60
+ existed: boolean;
61
+ };
62
+ /** Produce a one-time invite (the file is placed in capabilities/ by the relay source; hand it to the session model's relay_bindings bind). */
63
+ createInvite(): {
64
+ inviteFile: string;
65
+ };
66
+ /** audience + scope configuration (idempotent; requires the session target to be bound, since the route set is frozen from the active membership). */
67
+ provision(): {
68
+ audienceRef: string;
69
+ scopeId: string;
70
+ scopeRevision: number;
71
+ };
72
+ /** Whether a binding membership already exists (precondition for provision; the route set is frozen from the active membership). */
73
+ hasActiveMembership(): boolean;
74
+ /**
75
+ * Diagnose relay truth: read the source store only and report the actual state of the audience/scope
76
+ * the setup points at, plus the active membership route set. After resume the local setup may be stale (closed).
77
+ */
78
+ diagnose(): {
79
+ audienceRef: string | null;
80
+ audienceState: string | null;
81
+ routeSet: string[];
82
+ scopeId: string | null;
83
+ scopeState: string | null;
84
+ activeMemberships: number;
85
+ };
86
+ /**
87
+ * resume/reopen self-heal: local setup claims provisioned
88
+ * but relay truth is closed/missing (explicitly closed on last exit, or the store evolved) -> switch to a fresh
89
+ * audienceRef + scopeId and reconfigure (closed never reopens / routeSet is frozen and immutable -> healing = new identity).
90
+ * Precondition: an active membership is required (routeSet is frozen from the current membership; with no binding, auto-bind first).
91
+ * Idempotent: no-op when healthy. Returns whether a reconfiguration was performed.
92
+ */
93
+ ensureHealed(): {
94
+ healed: boolean;
95
+ reason?: string;
96
+ };
97
+ /** Reconfigure with a fresh audienceRef + scopeId (healing primitive; idempotency is guaranteed by ensureHealed's health check). */
98
+ private reprovision;
99
+ ready(): boolean;
100
+ statusJson(): Json;
101
+ private persistSetup;
102
+ private options;
103
+ private mapReceipt;
104
+ publish(frozenRequestBytes: Uint8Array, scope?: {
105
+ scopeId: Id;
106
+ revision: number;
107
+ }): Promise<ManagedReceipt>;
108
+ reconcile(eventId: Id): Promise<ManagedReceipt>;
109
+ advanceScope(operationId: Id, scopeId: Id, expectedRevision: number, nextRevision: number, state: 'active' | 'paused' | 'closed'): Promise<Json>;
110
+ withdraw(operationId: Id, eventId: Id, reason: string): Promise<WithdrawResult>;
111
+ /** Consume host responses: managed watch update stream -> VerifiedConsumerResponse mapping. */
112
+ readResponses(after: number): Promise<{
113
+ cursor: number;
114
+ responses: VerifiedConsumerResponse[];
115
+ resyncRequired: boolean;
116
+ }>;
117
+ confirmApplied(operationId: Id, responseId: Id, result: ApplicationResult): Promise<void>;
118
+ close(): Promise<void>;
119
+ }
120
+ export {};
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Watcher-side relay integration (design 09 / relay-next 02 s2.2, 4-stage managed delivery).
3
+ *
4
+ * Division of responsibilities:
5
+ * - This module (watcher repo): embedded managed SourceHost, consumer declaration file, invite production,
6
+ * audience/scope configuration (owner surface), and the ManagedDeliveryPort implementation (publish/withdraw/receipt/
7
+ * host-response consumption/applied confirmation).
8
+ * - relay repo (done): target-side admit/guard/pump/respond, Pi session pump wiring
9
+ * (stage 4), declarative consumer registration (<relay-home>/consumers).
10
+ *
11
+ * Discipline: wake goes only through the relay managed path (I05/I21); this module never touches pi sendMessage.
12
+ */
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import { createHash } from 'node:crypto';
16
+ import Database from 'better-sqlite3';
17
+ import { createSource, resetSource, SourceAuthorityMismatch } from '@maolon/pi-relay/source';
18
+ import { writeConsumerDeclaration, declarationFile } from '@maolon/pi-relay/consumer';
19
+ import { secret, newId as relayNewId } from '@maolon/pi-relay/protocol';
20
+ const envelopeProps = {
21
+ schemaVersion: { type: 'integer', minimum: 1, maximum: 1 },
22
+ envelopeId: { type: 'string', minLength: 1, maxLength: 160 },
23
+ episodeId: { type: 'string', minLength: 1, maxLength: 160 },
24
+ episodeRevision: { type: 'integer', minimum: 1 },
25
+ watchId: { type: 'string', minLength: 1, maxLength: 160 },
26
+ generation: { type: 'integer', minimum: 1 },
27
+ missionRevision: { type: 'integer', minimum: 1 },
28
+ controlRevision: { type: 'integer', minimum: 1 },
29
+ ownerBindingEpoch: { type: 'integer', minimum: 1 },
30
+ target: { type: 'object' },
31
+ reasonCode: { type: 'string', minLength: 1, maxLength: 64 },
32
+ summary: { type: 'string', maxLength: 2048 },
33
+ evidenceRefs: { type: 'array', maxItems: 64, items: { type: 'object' } },
34
+ requiredNextStep: { type: 'string', maxLength: 64 },
35
+ occurredAt: { type: 'string', maxLength: 64 },
36
+ validUntil: { type: 'string', maxLength: 64 }
37
+ };
38
+ export const WATCHER_TYPE_MANIFESTS = [
39
+ {
40
+ type: 'watcher.attention.v1',
41
+ schemaVersion: 1,
42
+ kind: 'event',
43
+ dataSchema: {
44
+ type: 'object',
45
+ additionalProperties: false,
46
+ properties: envelopeProps,
47
+ required: ['schemaVersion', 'envelopeId', 'episodeId', 'episodeRevision', 'watchId', 'generation', 'missionRevision', 'controlRevision', 'ownerBindingEpoch', 'target', 'reasonCode', 'summary', 'occurredAt', 'validUntil']
48
+ }
49
+ },
50
+ {
51
+ type: 'watcher.result.v1',
52
+ schemaVersion: 1,
53
+ kind: 'event',
54
+ dataSchema: {
55
+ type: 'object',
56
+ additionalProperties: false,
57
+ properties: envelopeProps,
58
+ required: ['schemaVersion', 'envelopeId', 'episodeId', 'watchId', 'generation', 'reasonCode', 'summary', 'occurredAt', 'validUntil']
59
+ }
60
+ }
61
+ ];
62
+ export const WATCHER_EVENT_TYPES = ['watcher.attention.v1', 'watcher.result.v1'];
63
+ const OWNER = { kind: 'owner' };
64
+ function readJson(p) {
65
+ try {
66
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
72
+ function writePrivate(p, value) {
73
+ fs.mkdirSync(path.dirname(p), { recursive: true });
74
+ fs.writeFileSync(p, JSON.stringify(value, null, 2), { mode: 0o600 });
75
+ }
76
+ /** Per-root sourceId: with a global extension, every pi session starts a watcher runtime for its own cwd root.
77
+ * If all roots shared the sourceId 'pi-watcher', the same source store in one relay home would be
78
+ * held exclusively (owner lock) by the first process that opens it until it exits, so an idle session of an unrelated project could
79
+ * permanently block roots that really need relay.
80
+ * Root primary election ensures only one runtime per root opens the store -> one sourceId per root structurally eliminates cross-root contention.
81
+ * The charset must satisfy the relay bind-request validation /^[A-Za-z0-9][A-Za-z0-9._:-]*$/. */
82
+ export function deriveSourceId(rootDir) {
83
+ let slug = path.basename(path.resolve(rootDir))
84
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
85
+ if (!slug)
86
+ slug = 'root';
87
+ // A brand-new root directory may not exist yet (realpath fails): fall back to the resolved path to stay stable
88
+ let real = path.resolve(rootDir);
89
+ try {
90
+ real = fs.realpathSync(real);
91
+ }
92
+ catch { /* not created -> use the resolved value */ }
93
+ const h = createHash('sha256').update(real).digest('hex').slice(0, 8);
94
+ return `pi-watcher:${slug}-${h}`;
95
+ }
96
+ /**
97
+ * Unlimited validity for the local-domain managed audience sentinel.
98
+ * Value = pi-relay FOREVER / STANDING_EXPIRES_AT = MAX_SAFE_INTEGER (accepted on localTrust
99
+ * channels in current pi-relay, stored as 9007199254740991, aligned with standing membership).
100
+ * Monitoring channel lifetime must be >= the task horizon: a 24h wall silently kills the wake chain in long sessions.
101
+ * This source's channel is always localTrust:true (same-uid standing on this machine); if a non-localTrust
102
+ * channel is introduced in the future, this choice must be tied to the channel trust level. Remote/invite domains are still guarded by the relay-side 24h cap.
103
+ */
104
+ export const LOCAL_AUDIENCE_VALID_UNTIL_MS = 9007199254740991;
105
+ export class WatcherRelaySource {
106
+ host;
107
+ opts;
108
+ secretsPath;
109
+ setupPath;
110
+ setup;
111
+ constructor(host, opts, secretsPath, setupPath, setup) {
112
+ this.host = host;
113
+ this.opts = opts;
114
+ this.secretsPath = secretsPath;
115
+ this.setupPath = setupPath;
116
+ this.setup = setup;
117
+ }
118
+ static async open(watcherRoot, options) {
119
+ const realPath = (p) => {
120
+ try {
121
+ return fs.realpathSync(p);
122
+ }
123
+ catch {
124
+ return p;
125
+ }
126
+ };
127
+ const opts = {
128
+ // The relay private-path policy requires a stable realpath (macOS /tmp -> /private/tmp); create the directory first, then take the real path
129
+ relayHome: (() => {
130
+ const p = path.resolve(options.relayHome);
131
+ fs.mkdirSync(p, { recursive: true, mode: 0o700 });
132
+ // An existing directory (e.g. pre-created externally) does not inherit 0700; fill in the private-boundary requirement
133
+ try {
134
+ fs.chmodSync(p, 0o700);
135
+ }
136
+ catch { /* best effort; the boundary check will report an error as a fallback */ }
137
+ return realPath(p);
138
+ })(),
139
+ realm: options.realm ?? 'local',
140
+ sourceId: options.sourceId ?? 'pi-watcher',
141
+ channelId: options.channelId ?? 'W',
142
+ consumerProfileId: options.consumerProfileId ?? 'pi-watcher-host'
143
+ };
144
+ fs.mkdirSync(watcherRoot, { recursive: true });
145
+ watcherRoot = realPath(path.resolve(watcherRoot));
146
+ const secretsPath = path.join(watcherRoot, 'relay-secrets.json');
147
+ const setupPath = path.join(watcherRoot, 'relay-setup.json');
148
+ const existingSecrets = readJson(secretsPath);
149
+ const secrets = existingSecrets ?? {
150
+ ownerToken: secret(),
151
+ publisherToken: secret()
152
+ };
153
+ if (!existingSecrets)
154
+ writePrivate(secretsPath, secrets);
155
+ const setup = readJson(setupPath) ?? {
156
+ consumerProfileId: opts.consumerProfileId,
157
+ audienceRef: 'watcher-main',
158
+ scopeId: 'watcher-scope-1',
159
+ scopeRevision: 0,
160
+ provisioned: false
161
+ };
162
+ const sourceConfig = {
163
+ version: 1,
164
+ sourceId: opts.sourceId,
165
+ realm: opts.realm,
166
+ home: opts.relayHome,
167
+ ownerToken: secrets.ownerToken,
168
+ publisherTokens: { [opts.channelId]: secrets.publisherToken },
169
+ channels: [{
170
+ id: opts.channelId,
171
+ types: WATCHER_TYPE_MANIFESTS,
172
+ allowedModes: ['display', 'resume'],
173
+ maxAutoTargets: 4,
174
+ // Local standing binding: same-uid sessions on this machine can skip
175
+ // the invite and bind standing directly; remote/non-opted-in channels still go through the invite ceremony
176
+ localTrust: true
177
+ }]
178
+ };
179
+ let host;
180
+ try {
181
+ host = await createSource(sourceConfig, {});
182
+ }
183
+ catch (e) {
184
+ // Channel config drift (an existing store predates localTrust, rejected by relay's channel digest invariant):
185
+ // this source is owned by us and the owner/publisher tokens match (authority mismatch is still thrown as-is).
186
+ // Automatic recovery: quarantine the old store (auditable, into .quarantine) -> rebuild with the new config; the standing path
187
+ // needs no old invite/membership and is fully rebuilt automatically.
188
+ const isChannelDrift = e instanceof SourceAuthorityMismatch === false
189
+ && e.code === 'invalid_state';
190
+ if (!isChannelDrift)
191
+ throw e;
192
+ const reset = resetSource(opts.sourceId, opts.relayHome);
193
+ if (!reset.reset)
194
+ throw e;
195
+ host = await createSource(sourceConfig, {});
196
+ }
197
+ return new WatcherRelaySource(host, opts, secretsPath, setupPath, setup);
198
+ }
199
+ // --- owner configuration surface (not a model action; called by CLI relay-setup)---
200
+ /** Declarative consumer registration (relay stage 4 generic config surface: <relay-home>/consumers); idempotent: returns if it already exists. */
201
+ ensureConsumerDeclaration(force = false) {
202
+ const file = declarationFile(this.opts.relayHome, this.setup.consumerProfileId);
203
+ const existed = fs.existsSync(file);
204
+ if (existed && !force)
205
+ return { file, existed: true };
206
+ writeConsumerDeclaration(this.opts.relayHome, {
207
+ profileId: this.setup.consumerProfileId,
208
+ displayName: 'pi-watcher host session',
209
+ description: 'Pi host session: consumes watcher attention/result events, responds watcher.response.v1',
210
+ eventTypes: [...WATCHER_EVENT_TYPES],
211
+ responseTypes: ['watcher.response.v1'],
212
+ requestedMode: 'resume',
213
+ policy: {
214
+ admission: 'auto',
215
+ // Local-domain transition policy (2026-09-21): bindLocal's standing registration does not yet issue a target->source
216
+ // proof credential (pi-relay internal.source.proof only recognizes the invite principal);
217
+ // requireCurrentScope:true would make the pump defer forever (observed in production). Same-uid in the local domain
218
+ // can already read the source store, so a wider staleness window is accepted; revert to true once the official standing proof credential lands.
219
+ requireCurrentScope: false,
220
+ timeoutMs: 1000
221
+ }
222
+ }, { force: true });
223
+ return { file, existed };
224
+ }
225
+ /** Produce a one-time invite (the file is placed in capabilities/ by the relay source; hand it to the session model's relay_bindings bind). */
226
+ createInvite() {
227
+ const invite = this.host.core.createInvite({
228
+ operationId: relayNewId('op'),
229
+ channelId: this.opts.channelId,
230
+ ttlMs: 600_000,
231
+ bindingTtlMs: 86_400_000,
232
+ allowResume: true
233
+ });
234
+ const sourceStoreDir = path.join(this.opts.relayHome, 'sources', sha256Hex(this.opts.sourceId));
235
+ const inviteFile = path.join(sourceStoreDir, 'capabilities', `${invite.inviteId}.json`);
236
+ this.setup.inviteFile = inviteFile;
237
+ this.persistSetup();
238
+ return { inviteFile };
239
+ }
240
+ /** audience + scope configuration (idempotent; requires the session target to be bound, since the route set is frozen from the active membership). */
241
+ provision() {
242
+ this.host.managed.provisionAudience(OWNER, {
243
+ audienceRef: this.setup.audienceRef,
244
+ channelId: this.opts.channelId,
245
+ consumerProfileId: this.setup.consumerProfileId,
246
+ requestedMode: 'resume',
247
+ // Plan A (2026-09-23): the local-domain sentinel has unlimited validity and no longer hits the 24h wall; see the LOCAL_AUDIENCE_VALID_UNTIL_MS comment
248
+ validUntilMs: LOCAL_AUDIENCE_VALID_UNTIL_MS
249
+ });
250
+ if (this.setup.scopeRevision === 0) {
251
+ this.host.managed.advanceScope(OWNER, {
252
+ operationId: relayNewId('op'),
253
+ scopeId: this.setup.scopeId,
254
+ expectedRevision: 0,
255
+ nextRevision: 1,
256
+ state: 'active'
257
+ });
258
+ this.setup.scopeRevision = 1;
259
+ }
260
+ this.setup.provisioned = true;
261
+ this.persistSetup();
262
+ return { audienceRef: this.setup.audienceRef, scopeId: this.setup.scopeId, scopeRevision: this.setup.scopeRevision };
263
+ }
264
+ /** Whether a binding membership already exists (precondition for provision; the route set is frozen from the active membership). */
265
+ hasActiveMembership() {
266
+ const status = this.host.core.status();
267
+ return (status?.memberships?.n ?? 0) > 0;
268
+ }
269
+ /**
270
+ * Diagnose relay truth: read the source store only and report the actual state of the audience/scope
271
+ * the setup points at, plus the active membership route set. After resume the local setup may be stale (closed).
272
+ */
273
+ diagnose() {
274
+ const storePath = path.join(this.opts.relayHome, 'sources', sha256Hex(this.opts.sourceId), 'source.sqlite');
275
+ if (!fs.existsSync(storePath)) {
276
+ return { audienceRef: null, audienceState: null, routeSet: [], scopeId: null, scopeState: null, activeMemberships: 0 };
277
+ }
278
+ const db = new Database(storePath, { readonly: true });
279
+ try {
280
+ const audience = db.prepare('SELECT state, route_set_json FROM managed_audiences WHERE audience_ref=?')
281
+ .get(this.setup.audienceRef);
282
+ const scope = db.prepare('SELECT state FROM managed_scopes WHERE scope_id=?')
283
+ .get(this.setup.scopeId);
284
+ const memberships = db.prepare("SELECT COUNT(*) AS n FROM memberships WHERE state='active'")
285
+ .get();
286
+ return {
287
+ audienceRef: audience ? this.setup.audienceRef : null,
288
+ audienceState: audience?.state ?? null,
289
+ routeSet: audience ? JSON.parse(audience.route_set_json) : [],
290
+ scopeId: scope ? this.setup.scopeId : null,
291
+ scopeState: scope?.state ?? null,
292
+ activeMemberships: memberships.n
293
+ };
294
+ }
295
+ finally {
296
+ db.close();
297
+ }
298
+ }
299
+ /**
300
+ * resume/reopen self-heal: local setup claims provisioned
301
+ * but relay truth is closed/missing (explicitly closed on last exit, or the store evolved) -> switch to a fresh
302
+ * audienceRef + scopeId and reconfigure (closed never reopens / routeSet is frozen and immutable -> healing = new identity).
303
+ * Precondition: an active membership is required (routeSet is frozen from the current membership; with no binding, auto-bind first).
304
+ * Idempotent: no-op when healthy. Returns whether a reconfiguration was performed.
305
+ */
306
+ ensureHealed() {
307
+ if (!this.setup.provisioned)
308
+ return { healed: false, reason: 'not provisioned' };
309
+ const d = this.diagnose();
310
+ const needsHeal = d.audienceState !== 'open' || d.scopeState !== 'active';
311
+ if (!needsHeal)
312
+ return { healed: false, reason: 'healthy' };
313
+ if (d.activeMemberships === 0)
314
+ return { healed: false, reason: 'no active membership; re-bind first' };
315
+ this.reprovision();
316
+ return { healed: true };
317
+ }
318
+ /** Reconfigure with a fresh audienceRef + scopeId (healing primitive; idempotency is guaranteed by ensureHealed's health check). */
319
+ reprovision() {
320
+ const bump = (base) => {
321
+ const m = /^(.*?)(?:-(\d+))?$/.exec(base);
322
+ const n = m?.[2] ? parseInt(m[2], 10) + 1 : 2;
323
+ return `${m?.[1]}-${n}`;
324
+ };
325
+ this.setup.audienceRef = bump(this.setup.audienceRef);
326
+ this.setup.scopeId = bump(this.setup.scopeId);
327
+ this.setup.scopeRevision = 0;
328
+ this.host.managed.provisionAudience(OWNER, {
329
+ audienceRef: this.setup.audienceRef,
330
+ channelId: this.opts.channelId,
331
+ consumerProfileId: this.setup.consumerProfileId,
332
+ requestedMode: 'resume',
333
+ validUntilMs: LOCAL_AUDIENCE_VALID_UNTIL_MS
334
+ });
335
+ this.host.managed.advanceScope(OWNER, {
336
+ operationId: relayNewId('op'),
337
+ scopeId: this.setup.scopeId,
338
+ expectedRevision: 0,
339
+ nextRevision: 1,
340
+ state: 'active'
341
+ });
342
+ this.setup.scopeRevision = 1;
343
+ this.setup.provisioned = true;
344
+ this.persistSetup();
345
+ return { audienceRef: this.setup.audienceRef, scopeId: this.setup.scopeId, scopeRevision: this.setup.scopeRevision };
346
+ }
347
+ ready() {
348
+ return this.setup.provisioned && this.setup.scopeRevision > 0;
349
+ }
350
+ statusJson() {
351
+ return {
352
+ relayHome: this.opts.relayHome,
353
+ realm: this.opts.realm,
354
+ sourceId: this.opts.sourceId,
355
+ consumerProfileId: this.setup.consumerProfileId,
356
+ audienceRef: this.setup.audienceRef,
357
+ scopeId: this.setup.scopeId,
358
+ scopeRevision: this.setup.scopeRevision,
359
+ provisioned: this.setup.provisioned,
360
+ inviteFile: this.setup.inviteFile ?? null,
361
+ activeMembership: this.hasActiveMembership()
362
+ };
363
+ }
364
+ persistSetup() {
365
+ writePrivate(this.setupPath, this.setup);
366
+ }
367
+ // --- ManagedDeliveryPort (watcher contract)---
368
+ options(scope) {
369
+ return {
370
+ audienceRef: this.setup.audienceRef,
371
+ scope: scope
372
+ ? { id: scope.scopeId, revision: scope.revision }
373
+ : { id: this.setup.scopeId, revision: this.setup.scopeRevision },
374
+ consumerProfileId: this.setup.consumerProfileId,
375
+ requestedMode: 'resume'
376
+ };
377
+ }
378
+ mapReceipt(r) {
379
+ return {
380
+ eventId: r.eventId,
381
+ sourceState: r.sourceState,
382
+ sourceCursor: r.sourceCursor,
383
+ scope: r.scope,
384
+ routes: (r.routes ?? []),
385
+ responses: (r.responses ?? [])
386
+ };
387
+ }
388
+ async publish(frozenRequestBytes, scope) {
389
+ const envelope = JSON.parse(Buffer.from(frozenRequestBytes).toString('utf8'));
390
+ const event = {
391
+ kind: 'event',
392
+ id: envelope.envelopeId,
393
+ type: 'watcher.attention.v1',
394
+ schemaVersion: 1,
395
+ occurredAt: envelope.occurredAt,
396
+ validUntil: envelope.validUntil,
397
+ data: envelope
398
+ };
399
+ const receipt = await this.host.managed.publishManaged(OWNER, { event, options: this.options(scope) });
400
+ return this.mapReceipt(receipt);
401
+ }
402
+ async reconcile(eventId) {
403
+ return this.mapReceipt(this.host.managed.managedReceipt(OWNER, eventId));
404
+ }
405
+ async advanceScope(operationId, scopeId, expectedRevision, nextRevision, state) {
406
+ const r = this.host.managed.advanceScope(OWNER, {
407
+ operationId, scopeId, expectedRevision, nextRevision, state
408
+ });
409
+ if (scopeId === this.setup.scopeId && nextRevision > this.setup.scopeRevision) {
410
+ this.setup.scopeRevision = nextRevision;
411
+ this.persistSetup();
412
+ }
413
+ return r;
414
+ }
415
+ async withdraw(operationId, eventId, reason) {
416
+ return this.host.managed.withdraw(OWNER, { operationId, eventId, reason });
417
+ }
418
+ /** Consume host responses: managed watch update stream -> VerifiedConsumerResponse mapping. */
419
+ async readResponses(after) {
420
+ const updates = this.host.managed.managedWatch(OWNER, after, 128);
421
+ const responses = [];
422
+ for (const u of updates.updates) {
423
+ // Update shape of the target-side appendManaged: {cursor,eventId,routeRef,targetRevision,at,fact:{authentication,fact}}
424
+ const outer = u;
425
+ const candidates = [];
426
+ if (outer.fact && typeof outer.fact === 'object' && 'fact' in outer.fact && outer.fact.fact) {
427
+ const inner = outer.fact.fact;
428
+ if (inner.kind === 'consumer-response')
429
+ candidates.push(inner);
430
+ }
431
+ else if (outer.kind === 'consumer-response' && outer.fact) {
432
+ candidates.push(outer.fact);
433
+ }
434
+ else if (Array.isArray(outer.responses)) {
435
+ candidates.push(...outer.responses);
436
+ }
437
+ for (const c of candidates) {
438
+ const body = (c.data ?? {});
439
+ if (!body?.episodeId || !body?.action)
440
+ continue;
441
+ responses.push({
442
+ responseId: c.responseId,
443
+ deliveryRef: c.deliveryRef,
444
+ ownerBindingEpoch: Number(c.data.ownerBindingEpoch ?? 1),
445
+ digest: digestOf(c),
446
+ body
447
+ });
448
+ }
449
+ }
450
+ return { cursor: updates.cursor, responses, resyncRequired: updates.resyncRequired };
451
+ }
452
+ async confirmApplied(operationId, responseId, result) {
453
+ this.host.managed.confirmApplied(OWNER, {
454
+ operationId, responseId,
455
+ result: result
456
+ });
457
+ }
458
+ async close() {
459
+ // Explicit lifecycle wrap-up: once the sentinel has no TTL, closing is the only way to invalidate it.
460
+ // scope closed + audience closed, both idempotent best-effort; a failure does not block local wrap-up.
461
+ try {
462
+ if (this.setup.provisioned && this.setup.scopeRevision > 0) {
463
+ await this.advanceScope(relayNewId('op'), this.setup.scopeId, this.setup.scopeRevision, this.setup.scopeRevision + 1, 'closed');
464
+ }
465
+ }
466
+ catch { /* scope already closed / relay unreachable: do not block */ }
467
+ try {
468
+ if (this.setup.provisioned) {
469
+ this.host.managed.closeAudience(OWNER, this.setup.audienceRef);
470
+ }
471
+ }
472
+ catch { /* audience not provisioned / already closed: do not block */ }
473
+ await this.host.close();
474
+ }
475
+ }
476
+ function sha256Hex(input) {
477
+ // Consistent with the relay's source storage directory derivation (<home>/sources/<sha256(sourceId)>)
478
+ return createHash('sha256').update(input, 'utf8').digest('hex');
479
+ }
480
+ function digestOf(value) {
481
+ return createHash('sha256').update(JSON.stringify(value), 'utf8').digest('hex');
482
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Relay 1.2 managed-delivery feature negotiation (design 09 / V0).
3
+ *
4
+ * The watcher owns task semantics; automatic wake goes only through the relay managed path.
5
+ * relay protocol 1.2 (managed receipts / withdraw / scope advance / consumer gate /
6
+ * durable response / applied confirm / audience handle / owner setup) is currently a
7
+ * PROPOSED proposal and is not yet implemented.
8
+ *
9
+ * When negotiation is unavailable: the watcher only provides local observation and display (local-display),
10
+ * and does not silently degrade to a direct wake path without a guard (I05/I21).
11
+ */
12
+ export type RelayNegotiationStatus = 'unavailable' | 'legacy-1-1' | 'managed-1-2';
13
+ export interface RelayNegotiation {
14
+ status: RelayNegotiationStatus;
15
+ protocolMinor: number | null;
16
+ features: string[];
17
+ requiredFeatures: string[];
18
+ detail: string;
19
+ checkedAt: string;
20
+ /** transport usable on the watcher side, derived from this */
21
+ transport: 'local-display' | 'relay';
22
+ }
23
+ export declare const MANAGED_REQUIRED_FEATURES: readonly ["source.managed-receipts", "event.withdraw", "scope.advance", "consumer.gate", "durable-response", "applied-confirm", "audience.handle", "owner.setup"];
24
+ export type ProbeResult = {
25
+ present: boolean;
26
+ protocolMinor?: number;
27
+ features?: string[];
28
+ detail?: string;
29
+ };
30
+ export interface NegotiateOptions {
31
+ /** Injectable probe function (for tests) */
32
+ probeRelay?: () => Promise<ProbeResult>;
33
+ }
34
+ export declare function negotiateRelay(options?: NegotiateOptions): Promise<RelayNegotiation>;
35
+ /** V1 closed loop: derive the negotiation result from the real state of the watcher's embedded managed source (design 9.5 compatibility mode). */
36
+ export declare function negotiationFromRelaySource(relay: {
37
+ ready(): boolean;
38
+ hasActiveMembership(): boolean;
39
+ } | null, error?: string): RelayNegotiation;