@preventive/triage 1.0.0-alpha.14 → 1.0.0-alpha.15

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 (40) hide show
  1. package/out/brotli-fallback.js +3 -3
  2. package/out/client-admin.js +2 -2
  3. package/out/client-sync.js +14 -14
  4. package/out/graph.js +30 -5
  5. package/out/index.html +47 -7
  6. package/out/prism.js +2 -2
  7. package/out/stasis.svg +45 -0
  8. package/out/terminal.js +255 -45
  9. package/out/view.css +1 -1
  10. package/out/view.js +144 -109
  11. package/package.json +27 -4
  12. package/report/index.js +253 -0
  13. package/report/src/finding-id.js +80 -0
  14. package/report/src/finding.js +300 -0
  15. package/report/src/labels.js +33 -0
  16. package/report/src/md-structure.js +471 -0
  17. package/report/src/md-text.js +167 -0
  18. package/report/src/meta.js +51 -0
  19. package/report/src/parse-codex.js +147 -0
  20. package/report/src/parse-deepsec.js +197 -0
  21. package/report/src/parse-deepview-fields.js +375 -0
  22. package/report/src/parse-deepview-md.js +185 -0
  23. package/report/src/parse-md-id.js +137 -0
  24. package/report/src/parse-md.js +253 -0
  25. package/report/src/parse-piolium-id.js +79 -0
  26. package/report/src/parse-piolium-rows.js +131 -0
  27. package/report/src/parse-piolium-tokens.js +175 -0
  28. package/report/src/parse-piolium.js +400 -0
  29. package/report/src/utf8.js +21 -0
  30. package/report/src/write-md-finding.js +273 -0
  31. package/report/src/write-md.js +291 -0
  32. package/server-e2e/bus-receiver.ts +1 -0
  33. package/server-e2e/hub.ts +37 -6
  34. package/server-e2e/index.ts +3 -2
  35. package/server-e2e/objstore/handlers.ts +6 -5
  36. package/server-e2e/objstore/init.ts +1 -1
  37. package/server-e2e/objstore/rest-mint.ts +2 -2
  38. package/server-e2e/objstore/rest.ts +2 -2
  39. package/server-e2e/pubsub.ts +8 -5
  40. package/server-e2e/sync-handlers.ts +54 -28
@@ -48,9 +48,9 @@ export type ObjstoreDeps = {
48
48
  // Cross-instance pub/sub for objstore-deleted. Fired alongside the
49
49
  // local `broadcast` after a successful delete so peers on OTHER
50
50
  // server instances see the version drop in real time. Carries the
51
- // full (tag, resourceTag, version) tuple inline — the workspace_object
51
+ // full (tag, resourceTag, version, incarnation) tuple inline — the workspace_object
52
52
  // row is gone post-delete, so the bus payload IS the wire data.
53
- publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
53
+ publishObjDeleted: (tag: string, resourceTag: string, version: number, incarnation: string) => void
54
54
  getNonce: (socket: WebSocket) => string | undefined
55
55
  debug: boolean
56
56
  // Auth gate for the FIRST put-begin against a never-before-seen
@@ -191,13 +191,14 @@ async function handleDelete(deps: ObjstoreDeps, socket: WebSocket, msg: Objstore
191
191
  // observes its own PUTs as echo events. The same symmetry on
192
192
  // `onDeleted` lets `session.onDeleted` fire for the session's
193
193
  // own deletes — pinned by `tests/objstore-client-races.test.js`.
194
- deps.broadcast(tag, { type: 'objstore-deleted', workspaceTag: tag, resourceTag, version: result.deletedVersion }, null)
194
+ // A successful nonempty delete matched this authenticated incarnation.
195
+ deps.broadcast(tag, { type: 'objstore-deleted', workspaceTag: tag, resourceTag, version: result.deletedVersion, incarnation: prevIncarnation! }, null)
195
196
  // Cross-instance fan-out (Neon mode). The workspace_object row is
196
197
  // gone post-delete so the bus payload carries (tag, resourceTag,
197
- // version) inline; the receiver builds its `objstore-deleted`
198
+ // version, incarnation) inline; the receiver builds its `objstore-deleted`
198
199
  // broadcast directly from the bus envelope. SQLite mode publishes
199
200
  // to a no-op.
200
- deps.publishObjDeleted(tag, resourceTag, result.deletedVersion)
201
+ deps.publishObjDeleted(tag, resourceTag, result.deletedVersion, prevIncarnation!)
201
202
  if (deps.debug) console.log(`objstore delete → ${debugTag(tag)}/${resourceTag.slice(0, 8)}…`)
202
203
  }
203
204
 
@@ -30,7 +30,7 @@ export type ObjstoreInitDeps = {
30
30
  // mode passes Postgres LISTEN/NOTIFY-backed implementations. See
31
31
  // server-e2e/pubsub.ts for the bus design.
32
32
  publishObjPut: (tag: string, resourceTag: string) => void
33
- publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
33
+ publishObjDeleted: (tag: string, resourceTag: string, version: number, incarnation: string) => void
34
34
  getNonce: (socket: WebSocket) => string | undefined
35
35
  debug: boolean
36
36
  // Auth gate for the FIRST objstore-put-begin against a workspace
@@ -219,6 +219,6 @@ async function handleRestDelete(
219
219
  // fans out to subscribers (including the originator, matching the WS path's
220
220
  // `except: null`) so peers' `onDeleted` fire.
221
221
  if (result.deletedVersion === 0) return
222
- deps.broadcast(route.tag, { type: 'objstore-deleted', workspaceTag: route.tag, resourceTag: route.resourceTag, version: result.deletedVersion }, null)
223
- deps.publishObjDeleted(route.tag, route.resourceTag, result.deletedVersion)
222
+ deps.broadcast(route.tag, { type: 'objstore-deleted', workspaceTag: route.tag, resourceTag: route.resourceTag, version: result.deletedVersion, incarnation: prevIncarnation! }, null)
223
+ deps.publishObjDeleted(route.tag, route.resourceTag, result.deletedVersion, prevIncarnation!)
224
224
  }
@@ -71,8 +71,8 @@ export type ObjstoreRestDeps = {
71
71
  // Cross-instance pub/sub for objstore-deleted — fired alongside the local
72
72
  // `broadcast` after a successful REST delete mint, so peers on OTHER
73
73
  // instances drop the resource in real time. Carries (tag, resourceTag,
74
- // version) inline (the row is gone post-delete). SQLite mode passes a no-op.
75
- publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
74
+ // version, incarnation) inline (the row is gone post-delete). SQLite mode passes a no-op.
75
+ publishObjDeleted: (tag: string, resourceTag: string, version: number, incarnation: string) => void
76
76
  // New-workspace operator gate for the REST put-begin mint — the
77
77
  // connection-independent analog of the WS path's `authGate`. Returns
78
78
  // `true` to DENY (a password is configured AND the workspace is
@@ -57,13 +57,13 @@ function newSenderId(): string {
57
57
  // - 'objput': objstore-put broadcast. `res` is the resource tag; the
58
58
  // receiver SELECTs the live row by (tag, res) for the rest of the
59
59
  // metadata fields.
60
- // - 'objdel': objstore-deleted broadcast. `ver` is the deleted version
61
- // — inline because the row is gone from workspace_object after the
62
- // delete commit.
60
+ // - 'objdel': objstore-deleted broadcast. `ver` and `incarnation` identify
61
+ // the deleted row, which is gone from workspace_object after commit.
62
+ // Incarnation is optional only for messages from older relay instances.
63
63
  export type BusMessage =
64
64
  | { kind: 'rev'; tag: string; id: string }
65
65
  | { kind: 'objput'; tag: string; res: string }
66
- | { kind: 'objdel'; tag: string; res: string; ver: number }
66
+ | { kind: 'objdel'; tag: string; res: string; ver: number; incarnation?: string }
67
67
 
68
68
  // Receiver wired up by the hub layer (see server-e2e/index.ts). Each handler
69
69
  // runs once per remote message; failures are logged but don't crash the
@@ -388,7 +388,10 @@ function parseBusMessage(raw: Record<string, unknown>): BusMessage | null {
388
388
  const ver = raw['ver']
389
389
  if (typeof res !== 'string') return null
390
390
  if (typeof ver !== 'number' || !Number.isSafeInteger(ver)) return null
391
- return { kind: 'objdel', tag, res, ver }
391
+ const incarnation = raw['incarnation']
392
+ if (incarnation === undefined) return { kind: 'objdel', tag, res, ver }
393
+ if (typeof incarnation !== 'string' || incarnation.length === 0) return null
394
+ return { kind: 'objdel', tag, res, ver, incarnation }
392
395
  }
393
396
  return null
394
397
  }
@@ -26,6 +26,8 @@ type WireRevision = {
26
26
  signature: string
27
27
  }
28
28
 
29
+ type SubscriptionLookup = { resume: () => void }
30
+
29
31
  // Structured result of the shared save pipeline (`commitSave`), rendered
30
32
  // per transport: WS → protocol frames; REST → JSON + HTTP status.
31
33
  type SaveOutcome =
@@ -79,6 +81,7 @@ export type SyncHandlersDeps = {
79
81
  // payload budget. Optional: a SQLite deployment passes a no-op.
80
82
  publishRevision: (tag: string, revisionId: string) => void
81
83
  subscribe: (socket: WebSocket, tag: string) => void
84
+ pauseBroadcasts: (socket: WebSocket) => () => void
82
85
  getNonce: (socket: WebSocket) => string | undefined
83
86
  requiresAuth: (socket: WebSocket) => boolean
84
87
  // Whether an operator password is configured. The REST save plane's
@@ -112,6 +115,9 @@ export type SyncHandlers = {
112
115
 
113
116
  export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
114
117
  const { handle, send, broadcast, publishRevision, subscribe, getNonce, requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
118
+ // Only the newest lookup for a socket/tag may publish a snapshot.
119
+ // Versions cannot order different object incarnations (or deletions).
120
+ const subscriptionLookups = new WeakMap<WebSocket, Map<string, SubscriptionLookup>>()
115
121
 
116
122
  // Typed wrapper for the three `workspace-save-error` emit sites
117
123
  // (too-large at handleSave, stale-base after the catch-up, busy at
@@ -348,34 +354,54 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
348
354
  return
349
355
  }
350
356
  const tag = msg.workspaceTag
351
- subscribe(socket, tag)
352
- // Explicit ack — distinguishes "the server processed my subscribe
353
- // and registered me as a peer" from "the WebSocket is open". A
354
- // client that sent a malformed / bad-sig subscribe never gets this;
355
- // a client that did gets one before the chain arrives. Lets the UI
356
- // surface a `connecting → online` transition based on real handshake
357
- // completion, not just socket state.
358
- //
359
- // The ack also carries the objstore inventory snapshot: the same
360
- // subscribe that registers this socket for objstore-put / -deleted
361
- // broadcasts seeds the client's initial inventory in one handshake.
362
- // The client keeps it live thereafter from those broadcasts.
363
- // Returns [] for a triage-only workspace. A failing inventory
364
- // lookup must NOT sink the subscribe — degrade to an empty snapshot
365
- // (broadcasts will fill it in) so the ack + chain still go out.
366
- let resources: object[] = []
367
- try { resources = await objstoreResources(tag) }
368
- catch (err) { if (debug) console.warn('subscribe: objstore inventory lookup failed', debugTag(tag), err) }
369
- send(socket, { type: 'workspace-subscribed', workspaceTag: tag, resources })
370
- // `from` is the last revision id the client claims to have applied —
371
- // now a base64url string, not an integer. We send only revisions
372
- // after that. Client lying about `from` just means they get a
373
- // smaller catch-up — their subsequent saves will reveal stale state
374
- // on the usual base-mismatch path. Null / missing → full chain.
375
- const fromId = typeof msg.from === 'string' ? msg.from : null
376
- const revisions = chainForWire(await chainFrom(handle, tag, fromId))
377
- if (debug) console.log(`subscribe ${debugTag(tag)} from=${fromId?.slice(0, 8) ?? 'null'} → chain ${revisions.length}`)
378
- send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
357
+ const lookups = subscriptionLookups.get(socket) ?? new Map<string, SubscriptionLookup>()
358
+ subscriptionLookups.set(socket, lookups)
359
+ const previous = lookups.get(tag)
360
+ const resume = deps.pauseBroadcasts(socket)
361
+ const lookup = { resume }
362
+ lookups.set(tag, lookup)
363
+ // Acquire the replacement's pause before releasing its predecessor so
364
+ // buffered broadcasts cannot overtake the replacement snapshot. A hung
365
+ // obsolete read must not keep the whole socket paused. The hub's release
366
+ // is idempotent, so the old finally can still call it when the read ends.
367
+ previous?.resume()
368
+ try {
369
+ subscribe(socket, tag)
370
+ // Explicit ack — distinguishes "the server processed my subscribe
371
+ // and registered me as a peer" from "the WebSocket is open". A
372
+ // client that sent a malformed / bad-sig subscribe never gets this;
373
+ // a client that did gets one before the chain arrives. Lets the UI
374
+ // surface a `connecting → online` transition based on real handshake
375
+ // completion, not just socket state.
376
+ //
377
+ // The ack also carries the objstore inventory snapshot: the same
378
+ // subscribe that registers this socket for objstore-put / -deleted
379
+ // broadcasts seeds the client's initial inventory in one handshake.
380
+ // The client keeps it live thereafter from those broadcasts.
381
+ // Returns [] for a triage-only workspace. A lookup failure is NOT an
382
+ // empty inventory: clients interpret absent resources as deletions.
383
+ // Reconnect to retry the complete handshake on either transport.
384
+ const resources = await objstoreResources(tag)
385
+ if (socket.readyState !== socket.OPEN || lookups.get(tag) !== lookup) return
386
+ // `from` is the last revision id the client claims to have applied —
387
+ // now a base64url string, not an integer. We send only revisions
388
+ // after that. Client lying about `from` just means they get a
389
+ // smaller catch-up — their subsequent saves will reveal stale state
390
+ // on the usual base-mismatch path. Null / missing → full chain.
391
+ const fromId = typeof msg.from === 'string' ? msg.from : null
392
+ const revisions = chainForWire(await chainFrom(handle, tag, fromId))
393
+ if (socket.readyState !== socket.OPEN || lookups.get(tag) !== lookup) return
394
+ if (debug) console.log(`subscribe ${debugTag(tag)} from=${fromId?.slice(0, 8) ?? 'null'} → chain ${revisions.length}`)
395
+ send(socket, { type: 'workspace-subscribed', workspaceTag: tag, resources })
396
+ send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
397
+ } catch (err) {
398
+ if (lookups.get(tag) !== lookup) return
399
+ if (debug) console.warn('subscribe: snapshot lookup failed', debugTag(tag), err)
400
+ socket.close(1011, 'subscription unavailable')
401
+ } finally {
402
+ if (lookups.get(tag) === lookup) lookups.delete(tag)
403
+ resume()
404
+ }
379
405
  }
380
406
 
381
407
  return { handleSave, handleSaveRest, handleSubscribe, sendSaveError }