@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,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;
|