@crouton-kit/crouter 0.3.57 → 0.3.59
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/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +20 -5
- package/dist/clients/attach/attach-cmd.js +5 -5
- package/dist/clients/attach/chat-view.js +5 -0
- package/dist/clients/web/__tests__/push-engine.test.d.ts +1 -0
- package/dist/clients/web/__tests__/push-engine.test.js +302 -0
- package/dist/clients/web/__tests__/push-registry.test.d.ts +1 -0
- package/dist/clients/web/__tests__/push-registry.test.js +177 -0
- package/dist/clients/web/events.d.ts +5 -0
- package/dist/clients/web/events.js +16 -0
- package/dist/clients/web/push-engine.d.ts +108 -0
- package/dist/clients/web/push-engine.js +256 -0
- package/dist/clients/web/push-registry.d.ts +89 -0
- package/dist/clients/web/push-registry.js +378 -0
- package/dist/clients/web/server.js +191 -8
- package/dist/clients/web/web-client/shared/protocol.d.ts +308 -0
- package/dist/clients/web/web-client/shared/protocol.js +8 -0
- package/dist/commands/memory/read.js +8 -1
- package/dist/core/canvas/paths.d.ts +10 -0
- package/dist/core/canvas/paths.js +14 -0
- package/dist/core/substrate/render.js +7 -2
- package/dist/web-client/assets/{index-FWBlgUV2.js → index-BOlkXTJY.js} +24 -24
- package/dist/web-client/assets/index-CllMPSPq.css +2 -0
- package/dist/web-client/index.html +2 -2
- package/package.json +3 -1
- package/dist/web-client/assets/index-CKvdUlkn.css +0 -2
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// push-engine.ts — the server-side web-push DIFF ENGINE (design D8/push
|
|
2
|
+
// contract). SSE (`events.ts`) is a TRIGGER ONLY (`{kind, ts}`, never data);
|
|
3
|
+
// this module is the one authority that re-reads canvas + decks on that
|
|
4
|
+
// trigger, diffs against persisted last-seen state, and decides what (if
|
|
5
|
+
// anything) to push. It never invents a second attention signal — decks/canvas
|
|
6
|
+
// stay the truth.
|
|
7
|
+
//
|
|
8
|
+
// Two invalidation kinds drive two independent diff passes:
|
|
9
|
+
// - 'nodes' -> re-read the canvas roster, detect a conversation ROOT
|
|
10
|
+
// (parent === null) crossing into a done|dead|canceled status
|
|
11
|
+
// from a non-done status, emit one 'done' push per transition.
|
|
12
|
+
// - 'inbox' -> re-read pending asks (deck provenance), detect an ask id that
|
|
13
|
+
// is present now but was not known-notified before, emit one
|
|
14
|
+
// 'needs-you' push per NEW ask, coalesced per conversation.
|
|
15
|
+
//
|
|
16
|
+
// On server start, `seed()` records the current state and sends NOTHING — a
|
|
17
|
+
// restart is not an attention event.
|
|
18
|
+
//
|
|
19
|
+
// Last-seen state is only advanced past a suppressed (rate-capped) item when
|
|
20
|
+
// that item was not actually the reason for suppression; a rate-capped
|
|
21
|
+
// transition/ask is deliberately left out of the persisted state so the NEXT
|
|
22
|
+
// invalidation retries it rather than silently losing it forever.
|
|
23
|
+
import { isAllowedPushEndpoint } from './push-registry.js';
|
|
24
|
+
const DONE_STATUSES = new Set(['done', 'dead', 'canceled']);
|
|
25
|
+
/** Minimum interval between pushes for the same conversation root — coalesces
|
|
26
|
+
* a burst of distinct events (several new asks, or an ask+done in close
|
|
27
|
+
* succession) into at most one send per window, never a storm. */
|
|
28
|
+
const DEFAULT_RATE_WINDOW_MS = 30_000;
|
|
29
|
+
export class PushDiffEngine {
|
|
30
|
+
registry;
|
|
31
|
+
lastSeen;
|
|
32
|
+
readCanvasNodes;
|
|
33
|
+
readPendingAsks;
|
|
34
|
+
sendPush;
|
|
35
|
+
isTargetScope;
|
|
36
|
+
now;
|
|
37
|
+
rateWindowMs;
|
|
38
|
+
/** Drain-queue state: only one diff pass mutates last-seen at a time. An
|
|
39
|
+
* invalidation arriving mid-pass sets its pending flag and is reprocessed
|
|
40
|
+
* when the current pass finishes — no two passes race the same snapshot. */
|
|
41
|
+
draining = false;
|
|
42
|
+
pendingNodes = false;
|
|
43
|
+
pendingInbox = false;
|
|
44
|
+
/** Per-conversation rate-cap clock. Deliberately in-memory/process-lifetime
|
|
45
|
+
* only (not persisted) — a restart re-seeding silently is already the
|
|
46
|
+
* no-storm guarantee; the rate cap only smooths a live process's bursts. */
|
|
47
|
+
lastPushAt = new Map();
|
|
48
|
+
/** Per-(conversationId+kind) set of subscription ids already delivered for
|
|
49
|
+
* the CURRENT still-pending notification. A transient failure on one
|
|
50
|
+
* subscription must never cause a later retry to re-send to subscriptions
|
|
51
|
+
* that already got `'sent'` — this is exactly what remembers them across
|
|
52
|
+
* passes. Cleared the moment the event fully succeeds (or has no eligible
|
|
53
|
+
* subscriber left), since a later, distinct event reusing the same key
|
|
54
|
+
* must start fresh. */
|
|
55
|
+
deliveredForKey = new Map();
|
|
56
|
+
constructor(deps) {
|
|
57
|
+
this.registry = deps.registry;
|
|
58
|
+
this.lastSeen = deps.lastSeen;
|
|
59
|
+
this.readCanvasNodes = deps.readCanvasNodes;
|
|
60
|
+
this.readPendingAsks = deps.readPendingAsks;
|
|
61
|
+
this.sendPush = deps.sendPush;
|
|
62
|
+
this.isTargetScope = deps.isTargetScope;
|
|
63
|
+
this.now = deps.now ?? (() => Date.now());
|
|
64
|
+
this.rateWindowMs = deps.rateWindowMs ?? DEFAULT_RATE_WINDOW_MS;
|
|
65
|
+
}
|
|
66
|
+
/** Seed last-seen from a fresh canvas+deck read and send nothing. Call once
|
|
67
|
+
* on server start — a restart must never be an attention event. */
|
|
68
|
+
async seed() {
|
|
69
|
+
const [nodes, asks] = await Promise.all([this.readCanvasNodes(), this.readPendingAsks()]);
|
|
70
|
+
const roots = {};
|
|
71
|
+
for (const n of nodes) {
|
|
72
|
+
if (n.parent !== null)
|
|
73
|
+
continue;
|
|
74
|
+
roots[n.node_id] = { status: n.status, attention_count: n.attention_count };
|
|
75
|
+
}
|
|
76
|
+
const askState = {};
|
|
77
|
+
for (const a of asks)
|
|
78
|
+
askState[a.id] = { present: true };
|
|
79
|
+
this.lastSeen.replace({ schemaVersion: 1, updatedAt: this.now(), roots, asks: askState });
|
|
80
|
+
}
|
|
81
|
+
/** 'nodes' invalidation: re-read the canvas roster, emit a 'done' push for
|
|
82
|
+
* every conversation root that just crossed into a done|dead|canceled
|
|
83
|
+
* status from a non-done status. */
|
|
84
|
+
async onNodesInvalidation() {
|
|
85
|
+
this.pendingNodes = true;
|
|
86
|
+
await this.drain();
|
|
87
|
+
}
|
|
88
|
+
async onInboxInvalidation() {
|
|
89
|
+
this.pendingInbox = true;
|
|
90
|
+
await this.drain();
|
|
91
|
+
}
|
|
92
|
+
/** Serialize passes: if a pass is already running, just set the pending flag
|
|
93
|
+
* and return — the running drain loop will pick it up. Otherwise run pending
|
|
94
|
+
* passes until none remain. */
|
|
95
|
+
async drain() {
|
|
96
|
+
if (this.draining)
|
|
97
|
+
return;
|
|
98
|
+
this.draining = true;
|
|
99
|
+
try {
|
|
100
|
+
while (this.pendingNodes || this.pendingInbox) {
|
|
101
|
+
if (this.pendingNodes) {
|
|
102
|
+
this.pendingNodes = false;
|
|
103
|
+
await this.runNodesPass();
|
|
104
|
+
}
|
|
105
|
+
if (this.pendingInbox) {
|
|
106
|
+
this.pendingInbox = false;
|
|
107
|
+
await this.runInboxPass();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
this.draining = false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async runNodesPass() {
|
|
116
|
+
const nodes = await this.readCanvasNodes();
|
|
117
|
+
const prev = this.lastSeen.snapshot();
|
|
118
|
+
const nextRoots = {};
|
|
119
|
+
for (const n of nodes) {
|
|
120
|
+
if (n.parent !== null)
|
|
121
|
+
continue;
|
|
122
|
+
const before = prev.roots[n.node_id];
|
|
123
|
+
const isTransition = before !== undefined && !DONE_STATUSES.has(before.status) && DONE_STATUSES.has(n.status);
|
|
124
|
+
if (!isTransition) {
|
|
125
|
+
nextRoots[n.node_id] = { status: n.status, attention_count: n.attention_count };
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const sent = await this.emit(n.node_id, {
|
|
129
|
+
conversationId: n.node_id,
|
|
130
|
+
kind: 'done',
|
|
131
|
+
title: 'Conversation finished',
|
|
132
|
+
snippet: `This conversation is now ${n.status}.`,
|
|
133
|
+
});
|
|
134
|
+
// Rate-capped: keep the PRE-transition state so the next 'nodes'
|
|
135
|
+
// invalidation still sees a non-done -> done edge and retries.
|
|
136
|
+
nextRoots[n.node_id] = sent ? { status: n.status, attention_count: n.attention_count } : before;
|
|
137
|
+
}
|
|
138
|
+
this.lastSeen.replace({ schemaVersion: prev.schemaVersion, updatedAt: this.now(), roots: nextRoots, asks: prev.asks });
|
|
139
|
+
}
|
|
140
|
+
/** 'inbox' invalidation: re-read pending asks, emit exactly one 'needs-you'
|
|
141
|
+
* push per conversation for its NEW (not-yet-notified) asks, coalesced. An
|
|
142
|
+
* ask no longer present (resolved/missing) is simply dropped, never
|
|
143
|
+
* notified. */
|
|
144
|
+
async runInboxPass() {
|
|
145
|
+
const asks = await this.readPendingAsks();
|
|
146
|
+
const prev = this.lastSeen.snapshot();
|
|
147
|
+
const nextAsks = {};
|
|
148
|
+
const newByConversation = new Map();
|
|
149
|
+
for (const ask of asks) {
|
|
150
|
+
if (prev.asks[ask.id]?.present === true) {
|
|
151
|
+
// Already known + already notified at some point — keep tracked, never
|
|
152
|
+
// re-notify the same unresolved ask.
|
|
153
|
+
nextAsks[ask.id] = { present: true };
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const list = newByConversation.get(ask.conversationId) ?? [];
|
|
157
|
+
list.push(ask);
|
|
158
|
+
newByConversation.set(ask.conversationId, list);
|
|
159
|
+
}
|
|
160
|
+
for (const [conversationId, list] of newByConversation) {
|
|
161
|
+
const first = list[0];
|
|
162
|
+
const extra = list.length - 1;
|
|
163
|
+
const sent = await this.emit(conversationId, {
|
|
164
|
+
conversationId,
|
|
165
|
+
kind: 'needs-you',
|
|
166
|
+
askId: first.jobId,
|
|
167
|
+
title: first.conversationTitle,
|
|
168
|
+
snippet: extra > 0 ? `${first.title} (+${extra} more)` : first.title,
|
|
169
|
+
});
|
|
170
|
+
if (sent) {
|
|
171
|
+
for (const ask of list)
|
|
172
|
+
nextAsks[ask.id] = { present: true };
|
|
173
|
+
}
|
|
174
|
+
// Rate-capped: leave these ask ids OUT of nextAsks so the next 'inbox'
|
|
175
|
+
// invalidation still sees them as new and retries once the window clears.
|
|
176
|
+
}
|
|
177
|
+
this.lastSeen.replace({ schemaVersion: prev.schemaVersion, updatedAt: this.now(), roots: prev.roots, asks: nextAsks });
|
|
178
|
+
}
|
|
179
|
+
/** Send one push per ELIGIBLE subscription — one whose authScope is on the
|
|
180
|
+
* watched boundary (`isTargetScope`) AND that opted in for this payload kind.
|
|
181
|
+
* Respects the per-conversation rate cap; prunes any subscription the push
|
|
182
|
+
* service reports gone (404/410) or whose endpoint fails the push-service
|
|
183
|
+
* allowlist re-check. Tracks per-subscription delivery for this notification
|
|
184
|
+
* (`deliveredForKey`) so a retry after a transient failure on one
|
|
185
|
+
* subscription never re-sends to a subscription that already got `'sent'`.
|
|
186
|
+
* Returns true iff last-seen may advance: false when suppressed by the rate
|
|
187
|
+
* cap OR when a transient send error means the item must be retried next
|
|
188
|
+
* pass. A no-eligible pass returns true (nothing to retry). */
|
|
189
|
+
async emit(conversationId, payload) {
|
|
190
|
+
// Eligible = in the watched auth scope AND opted-in for this payload kind.
|
|
191
|
+
const eligible = this.registry.snapshot().filter((r) => {
|
|
192
|
+
if (!this.isTargetScope(r.authScope))
|
|
193
|
+
return false;
|
|
194
|
+
if (payload.kind === 'done' && !r.prefs.doneNotifications)
|
|
195
|
+
return false;
|
|
196
|
+
return true;
|
|
197
|
+
});
|
|
198
|
+
// Keyed by conversation + kind: a later, distinct event reusing this key
|
|
199
|
+
// (once cleared below) must start with a fresh delivered set.
|
|
200
|
+
const key = `${conversationId}:${payload.kind}`;
|
|
201
|
+
// No eligible subscriber -> the event is fully handled (nothing to retry);
|
|
202
|
+
// advance last-seen. Checked BEFORE the rate cap so a no-eligible pass never
|
|
203
|
+
// stalls the state on a phantom retry.
|
|
204
|
+
if (eligible.length === 0) {
|
|
205
|
+
this.deliveredForKey.delete(key);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
const now = this.now();
|
|
209
|
+
const last = this.lastPushAt.get(conversationId) ?? 0;
|
|
210
|
+
if (now - last < this.rateWindowMs)
|
|
211
|
+
return false; // rate-capped -> retry next pass
|
|
212
|
+
this.lastPushAt.set(conversationId, now);
|
|
213
|
+
const delivered = this.deliveredForKey.get(key) ?? new Set();
|
|
214
|
+
let transientFailure = false;
|
|
215
|
+
for (const record of eligible) {
|
|
216
|
+
// Already delivered to this subscription for this event on an earlier
|
|
217
|
+
// pass — never re-send it (this is the fix for duplicate pushes on
|
|
218
|
+
// transient-failure retry: only the subscriptions that actually failed
|
|
219
|
+
// are retried).
|
|
220
|
+
if (delivered.has(record.id))
|
|
221
|
+
continue;
|
|
222
|
+
if (!isAllowedPushEndpoint(record.endpoint)) {
|
|
223
|
+
// Defense in depth: re-check the push-service allowlist immediately
|
|
224
|
+
// before every outbound connect (no DNS/host TOCTOU), even though
|
|
225
|
+
// normalizeEndpoint already enforced it at subscribe time. A record
|
|
226
|
+
// that fails this can never be sent to safely — prune it.
|
|
227
|
+
this.registry.deleteById(record.id);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
let result;
|
|
231
|
+
try {
|
|
232
|
+
result = await this.sendPush(record, payload);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
result = 'error';
|
|
236
|
+
}
|
|
237
|
+
if (result === 'gone')
|
|
238
|
+
this.registry.deleteById(record.id);
|
|
239
|
+
else if (result === 'sent')
|
|
240
|
+
delivered.add(record.id);
|
|
241
|
+
else
|
|
242
|
+
transientFailure = true;
|
|
243
|
+
}
|
|
244
|
+
// A transient send failure must NOT advance last-seen — retry next pass
|
|
245
|
+
// (design: "update last-seen only after a successful diff pass"), and the
|
|
246
|
+
// subscriptions already delivered above must stay remembered so that retry
|
|
247
|
+
// only reaches the ones that failed. A pruned 'gone'/disallowed record is
|
|
248
|
+
// not a failure: nothing left to retry for it.
|
|
249
|
+
if (transientFailure) {
|
|
250
|
+
this.deliveredForKey.set(key, delivered);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
this.deliveredForKey.delete(key);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { NodeLifeStatus } from './web-client/shared/protocol.js';
|
|
2
|
+
export interface PushPreferences {
|
|
3
|
+
doneNotifications: boolean;
|
|
4
|
+
}
|
|
5
|
+
export interface PushSubscriptionRecord {
|
|
6
|
+
id: string;
|
|
7
|
+
endpoint: string;
|
|
8
|
+
keys: {
|
|
9
|
+
p256dh: string;
|
|
10
|
+
auth: string;
|
|
11
|
+
};
|
|
12
|
+
authScope: string;
|
|
13
|
+
createdAt: number;
|
|
14
|
+
lastSeenAt: number;
|
|
15
|
+
prefs: PushPreferences;
|
|
16
|
+
}
|
|
17
|
+
export interface PushRegistryFile {
|
|
18
|
+
schemaVersion: number;
|
|
19
|
+
records: PushSubscriptionRecord[];
|
|
20
|
+
}
|
|
21
|
+
export interface PushLastSeenRootState {
|
|
22
|
+
status: NodeLifeStatus;
|
|
23
|
+
attention_count: number;
|
|
24
|
+
}
|
|
25
|
+
export interface PushLastSeenAskState {
|
|
26
|
+
present: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface PushLastSeenFile {
|
|
29
|
+
schemaVersion: number;
|
|
30
|
+
updatedAt: number;
|
|
31
|
+
roots: Record<string, PushLastSeenRootState>;
|
|
32
|
+
asks: Record<string, PushLastSeenAskState>;
|
|
33
|
+
}
|
|
34
|
+
/** A locally generated VAPID keypair, persisted so the same identity signs
|
|
35
|
+
* every outbound push across restarts. The private key never leaves this
|
|
36
|
+
* file/process — only `publicKey` is ever served to a client. */
|
|
37
|
+
export interface VapidKeypairFile {
|
|
38
|
+
schemaVersion: number;
|
|
39
|
+
publicKey: string;
|
|
40
|
+
privateKey: string;
|
|
41
|
+
subject: string;
|
|
42
|
+
}
|
|
43
|
+
export declare class PushRequestError extends Error {
|
|
44
|
+
readonly statusCode: number;
|
|
45
|
+
constructor(statusCode: number, message: string);
|
|
46
|
+
}
|
|
47
|
+
export declare function registryPath(): string;
|
|
48
|
+
export declare function lastSeenPath(): string;
|
|
49
|
+
export declare function vapidPath(): string;
|
|
50
|
+
export declare function pushBodyMaxBytes(): number;
|
|
51
|
+
/** Re-checkable at any later point (e.g. immediately before an outbound send,
|
|
52
|
+
* not just at subscribe time) with no dependency on DNS resolution — the
|
|
53
|
+
* allowlist match is a pure string check, so there is nothing to TOCTOU. */
|
|
54
|
+
export declare function isAllowedPushEndpoint(endpoint: string): boolean;
|
|
55
|
+
export declare function normalizeEndpoint(endpoint: unknown): string;
|
|
56
|
+
export declare function subscriptionIdForEndpoint(endpoint: string): string;
|
|
57
|
+
export declare function parsePushSubscriptionBody(rawBody: string, authScope: string, now?: number): PushSubscriptionRecord;
|
|
58
|
+
export declare function parsePushDeleteBody(rawBody: string): {
|
|
59
|
+
endpoint: string;
|
|
60
|
+
};
|
|
61
|
+
/** Load the persisted VAPID keypair, generating and persisting a fresh one on
|
|
62
|
+
* first run. One keypair per server identity (design D8/push contract) —
|
|
63
|
+
* every subscription is signed with the same public key for the life of this
|
|
64
|
+
* `crtrHome()`. The private key never leaves this file/process. */
|
|
65
|
+
export declare function loadOrCreateVapidKeypair(path?: string, subject?: string): VapidKeypairFile;
|
|
66
|
+
export declare class PushRegistryStore {
|
|
67
|
+
private readonly path;
|
|
68
|
+
private state;
|
|
69
|
+
private readonly byId;
|
|
70
|
+
constructor(path?: string);
|
|
71
|
+
snapshot(): PushSubscriptionRecord[];
|
|
72
|
+
listByScope(authScope: string): PushSubscriptionRecord[];
|
|
73
|
+
upsert(record: PushSubscriptionRecord): PushSubscriptionRecord;
|
|
74
|
+
touchById(id: string, lastSeenAt: number): PushSubscriptionRecord | null;
|
|
75
|
+
/** Scoped delete for the HTTP DELETE verb: a caller may only remove a
|
|
76
|
+
* subscription it owns (its authScope matches the stored record's). Pruning a
|
|
77
|
+
* service-reported-gone endpoint is a separate, unscoped path (`deleteById`). */
|
|
78
|
+
deleteByEndpoint(endpoint: string, authScope: string): boolean;
|
|
79
|
+
deleteById(id: string): boolean;
|
|
80
|
+
private persist;
|
|
81
|
+
}
|
|
82
|
+
export declare class PushLastSeenStore {
|
|
83
|
+
private readonly path;
|
|
84
|
+
private state;
|
|
85
|
+
constructor(path?: string);
|
|
86
|
+
snapshot(): PushLastSeenFile;
|
|
87
|
+
replace(next: PushLastSeenFile): void;
|
|
88
|
+
private persist;
|
|
89
|
+
}
|