@graphorin/server 0.6.0 → 0.7.0

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 (182) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +6 -6
  3. package/dist/app-audit-binding.d.ts +15 -0
  4. package/dist/app-audit-binding.d.ts.map +1 -0
  5. package/dist/app-audit-binding.js +136 -0
  6. package/dist/app-audit-binding.js.map +1 -0
  7. package/dist/app-daemons.d.ts +29 -0
  8. package/dist/app-daemons.d.ts.map +1 -0
  9. package/dist/app-daemons.js +27 -0
  10. package/dist/app-daemons.js.map +1 -0
  11. package/dist/app-lifecycle.js +272 -0
  12. package/dist/app-lifecycle.js.map +1 -0
  13. package/dist/app-metrics.js +79 -0
  14. package/dist/app-metrics.js.map +1 -0
  15. package/dist/app-middleware.js +92 -0
  16. package/dist/app-middleware.js.map +1 -0
  17. package/dist/app-routes.js +131 -0
  18. package/dist/app-routes.js.map +1 -0
  19. package/dist/app-ws.js +65 -0
  20. package/dist/app-ws.js.map +1 -0
  21. package/dist/app.d.ts +16 -16
  22. package/dist/app.d.ts.map +1 -1
  23. package/dist/app.js +40 -611
  24. package/dist/app.js.map +1 -1
  25. package/dist/commentary/built-in-patterns.d.ts +1 -1
  26. package/dist/commentary/built-in-patterns.js +1 -1
  27. package/dist/commentary/built-in-patterns.js.map +1 -1
  28. package/dist/config.d.ts +100 -1
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/config.js +30 -2
  31. package/dist/config.js.map +1 -1
  32. package/dist/health/checks.d.ts +15 -1
  33. package/dist/health/checks.d.ts.map +1 -1
  34. package/dist/health/checks.js +21 -0
  35. package/dist/health/checks.js.map +1 -1
  36. package/dist/health/routes.js +1 -1
  37. package/dist/index.d.ts +9 -4
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +22 -26
  40. package/dist/index.js.map +1 -1
  41. package/dist/internal/context.d.ts +2 -2
  42. package/dist/internal/wire-error.js +20 -0
  43. package/dist/internal/wire-error.js.map +1 -0
  44. package/dist/lifecycle/pre-bind.d.ts +1 -1
  45. package/dist/metrics/catalog.d.ts.map +1 -1
  46. package/dist/metrics/catalog.js.map +1 -1
  47. package/dist/metrics/tools-bridge.d.ts +15 -0
  48. package/dist/metrics/tools-bridge.d.ts.map +1 -0
  49. package/dist/metrics/tools-bridge.js +103 -0
  50. package/dist/metrics/tools-bridge.js.map +1 -0
  51. package/dist/middleware/audit.d.ts +1 -1
  52. package/dist/middleware/auth.js +1 -1
  53. package/dist/middleware/csrf.js +1 -1
  54. package/dist/middleware/index.js +1 -1
  55. package/dist/middleware/scope.d.ts.map +1 -1
  56. package/dist/middleware/scope.js +44 -4
  57. package/dist/middleware/scope.js.map +1 -1
  58. package/dist/package.js +6 -0
  59. package/dist/package.js.map +1 -0
  60. package/dist/registry/index.d.ts +33 -0
  61. package/dist/registry/index.d.ts.map +1 -1
  62. package/dist/registry/index.js.map +1 -1
  63. package/dist/replay/routes.d.ts +1 -1
  64. package/dist/replay/routes.js +2 -2
  65. package/dist/routes/agents.d.ts.map +1 -1
  66. package/dist/routes/agents.js +64 -18
  67. package/dist/routes/agents.js.map +1 -1
  68. package/dist/routes/auth.js +2 -2
  69. package/dist/routes/auth.js.map +1 -1
  70. package/dist/routes/sessions.js +5 -5
  71. package/dist/routes/sessions.js.map +1 -1
  72. package/dist/routes/tokens.d.ts +1 -1
  73. package/dist/routes/tokens.d.ts.map +1 -1
  74. package/dist/routes/tokens.js +21 -1
  75. package/dist/routes/tokens.js.map +1 -1
  76. package/dist/routes/workflows.d.ts.map +1 -1
  77. package/dist/routes/workflows.js +165 -19
  78. package/dist/routes/workflows.js.map +1 -1
  79. package/dist/runtime/retention.d.ts +105 -0
  80. package/dist/runtime/retention.d.ts.map +1 -0
  81. package/dist/runtime/retention.js +154 -0
  82. package/dist/runtime/retention.js.map +1 -0
  83. package/dist/runtime/run-state.d.ts +2 -0
  84. package/dist/runtime/run-state.d.ts.map +1 -1
  85. package/dist/runtime/run-state.js +34 -2
  86. package/dist/runtime/run-state.js.map +1 -1
  87. package/dist/sse/events.js +3 -4
  88. package/dist/sse/events.js.map +1 -1
  89. package/dist/tools-audit-bridge.d.ts +29 -0
  90. package/dist/tools-audit-bridge.d.ts.map +1 -0
  91. package/dist/tools-audit-bridge.js +103 -0
  92. package/dist/tools-audit-bridge.js.map +1 -0
  93. package/dist/workflows/timer-daemon.d.ts +69 -0
  94. package/dist/workflows/timer-daemon.d.ts.map +1 -0
  95. package/dist/workflows/timer-daemon.js +37 -0
  96. package/dist/workflows/timer-daemon.js.map +1 -0
  97. package/dist/ws/dispatcher.d.ts +1 -1
  98. package/dist/ws/dispatcher.d.ts.map +1 -1
  99. package/dist/ws/dispatcher.js +19 -12
  100. package/dist/ws/dispatcher.js.map +1 -1
  101. package/dist/ws/index.d.ts +2 -2
  102. package/dist/ws/index.js +3 -3
  103. package/dist/ws/replay-buffer.d.ts +35 -1
  104. package/dist/ws/replay-buffer.d.ts.map +1 -1
  105. package/dist/ws/replay-buffer.js +35 -3
  106. package/dist/ws/replay-buffer.js.map +1 -1
  107. package/dist/ws/upgrade.d.ts.map +1 -1
  108. package/dist/ws/upgrade.js +26 -19
  109. package/dist/ws/upgrade.js.map +1 -1
  110. package/package.json +31 -30
  111. package/src/app-audit-binding.ts +227 -0
  112. package/src/app-daemons.ts +73 -0
  113. package/src/app-lifecycle.ts +476 -0
  114. package/src/app-metrics.ts +144 -0
  115. package/src/app-middleware.ts +149 -0
  116. package/src/app-routes.ts +305 -0
  117. package/src/app-ws.ts +111 -0
  118. package/src/app.ts +317 -0
  119. package/src/commentary/audit-bridge.ts +135 -0
  120. package/src/commentary/built-in-patterns.ts +79 -0
  121. package/src/commentary/index.ts +32 -0
  122. package/src/commentary/sanitizer.ts +238 -0
  123. package/src/commentary/types.ts +130 -0
  124. package/src/config.ts +472 -0
  125. package/src/consolidator/daemon.ts +141 -0
  126. package/src/consolidator/index.ts +14 -0
  127. package/src/errors/index.ts +247 -0
  128. package/src/health/checks.ts +322 -0
  129. package/src/health/index.ts +34 -0
  130. package/src/health/routes.ts +154 -0
  131. package/src/index.ts +243 -0
  132. package/src/internal/context.ts +77 -0
  133. package/src/internal/ids.ts +17 -0
  134. package/src/internal/json.ts +47 -0
  135. package/src/internal/wire-error.ts +44 -0
  136. package/src/lifecycle/hooks.ts +66 -0
  137. package/src/lifecycle/index.ts +16 -0
  138. package/src/lifecycle/pre-bind.ts +138 -0
  139. package/src/metrics/catalog.ts +112 -0
  140. package/src/metrics/index.ts +12 -0
  141. package/src/metrics/registry.ts +337 -0
  142. package/src/metrics/tools-bridge.ts +124 -0
  143. package/src/middleware/audit.ts +114 -0
  144. package/src/middleware/auth.ts +195 -0
  145. package/src/middleware/cors.ts +72 -0
  146. package/src/middleware/csrf.ts +98 -0
  147. package/src/middleware/idempotency.ts +336 -0
  148. package/src/middleware/index.ts +38 -0
  149. package/src/middleware/rate-limit.ts +71 -0
  150. package/src/middleware/request-state.ts +79 -0
  151. package/src/middleware/scope.ts +161 -0
  152. package/src/registry/index.ts +278 -0
  153. package/src/replay/index.ts +14 -0
  154. package/src/replay/routes.ts +255 -0
  155. package/src/routes/agents.ts +363 -0
  156. package/src/routes/audit.ts +207 -0
  157. package/src/routes/auth.ts +80 -0
  158. package/src/routes/health.ts +42 -0
  159. package/src/routes/index.ts +16 -0
  160. package/src/routes/mcp.ts +97 -0
  161. package/src/routes/memory.ts +152 -0
  162. package/src/routes/sessions.ts +250 -0
  163. package/src/routes/skills.ts +91 -0
  164. package/src/routes/tokens.ts +166 -0
  165. package/src/routes/workflows.ts +616 -0
  166. package/src/runtime/retention.ts +284 -0
  167. package/src/runtime/run-state.ts +422 -0
  168. package/src/sse/events.ts +303 -0
  169. package/src/sse/index.ts +7 -0
  170. package/src/tools-audit-bridge.ts +120 -0
  171. package/src/triggers/daemon.ts +198 -0
  172. package/src/triggers/index.ts +16 -0
  173. package/src/triggers/routes.ts +139 -0
  174. package/src/workflows/timer-daemon.ts +102 -0
  175. package/src/ws/dispatcher.ts +537 -0
  176. package/src/ws/index.ts +45 -0
  177. package/src/ws/replay-buffer.ts +209 -0
  178. package/src/ws/subjects.ts +162 -0
  179. package/src/ws/ticket.ts +174 -0
  180. package/src/ws/upgrade.ts +507 -0
  181. package/dist/lifecycle/index.js +0 -3
  182. package/dist/routes/index.js +0 -12
@@ -0,0 +1,154 @@
1
+ import { pruneSpans } from "@graphorin/store-sqlite";
2
+
3
+ //#region src/runtime/retention.ts
4
+ /**
5
+ * W-010 / W-008: the unified server-side retention scheduler.
6
+ *
7
+ * Every prune primitive in the store layer used to be manual and
8
+ * disconnected (CLI-only, API-only, or - like the idempotency sweep
9
+ * before W-061 - dead code). This module owns ONE periodic sweep over
10
+ * every SQLite growth surface, driven by the `config.retention`
11
+ * section. Policy: derived / recoverable data (span telemetry,
12
+ * consolidator run counters, exhausted DLQ batches, expired
13
+ * idempotency bodies) is pruned by default with conservative windows;
14
+ * primary user content (sessions, memory history, workflow threads,
15
+ * session audit) is strictly opt-in via an explicit `*Days` window.
16
+ *
17
+ * UNITS TABLE - the primitives do NOT share cutoff semantics, and
18
+ * conflating them produces silent no-ops, not errors:
19
+ *
20
+ * | surface | argument | unit |
21
+ * | ------------------------------ | ------------------------- | ------------- |
22
+ * | spans (pruneSpans) | `{ beforeEpochMs }` | epoch ms |
23
+ * | consolidator runs (pruneRuns) | `beforeEpochMs` | epoch ms |
24
+ * | DLQ (pruneExhaustedBatches) | `beforeEpochMs` | epoch ms |
25
+ * | idempotency (prune) | `now()` | epoch ms, compared to `expires_at` |
26
+ * | sessions (pruneSessions) | `{ beforeEpochMs, closedOnly }` | epoch ms |
27
+ * | session audit (pruneAuditEntries) | `beforeEpochMs` | epoch ms |
28
+ * | memory history (pruneHistory) | `olderThanMs` | AGE in ms - the store computes `Date.now() - olderThanMs` itself; passing an epoch cutoff here would resolve to a cutoff near 1970 and delete nothing |
29
+ * | workflow threads (pruneThreads) | `{ beforeEpochMs, onlyTerminal: true }` | epoch ms |
30
+ *
31
+ * The file-based replay-JSONL directory (`@graphorin/observability`
32
+ * `pruneTraces`) is deliberately NOT swept here: it is a filesystem
33
+ * surface, not a SQLite one - schedule it via cron (see the
34
+ * deployment guide).
35
+ *
36
+ * @packageDocumentation
37
+ */
38
+ const DAY_MS = 864e5;
39
+ /**
40
+ * Console-backed {@link RetentionLog} honouring the
41
+ * `observability.logger` config flavour. Returns `undefined` for
42
+ * `'silent'` so callers can pass the result straight through.
43
+ *
44
+ * @stable
45
+ */
46
+ function createConsoleRetentionLog(flavour) {
47
+ if (flavour === "silent") return void 0;
48
+ return (level, message, fields) => {
49
+ const sink = level === "warn" ? console.warn : console.log;
50
+ if (flavour === "json") {
51
+ sink(JSON.stringify({
52
+ level,
53
+ component: "retention",
54
+ message,
55
+ ...fields
56
+ }));
57
+ return;
58
+ }
59
+ sink(`[retention] ${level}: ${message}${fields !== void 0 ? ` ${JSON.stringify(fields)}` : ""}`);
60
+ };
61
+ }
62
+ function consolidatorOf(store) {
63
+ const candidate = store.memory.consolidator;
64
+ if (candidate !== void 0 && typeof candidate.pruneRuns === "function" && typeof candidate.pruneExhaustedBatches === "function") return candidate;
65
+ }
66
+ /**
67
+ * W-010: schedule the periodic retention sweep. Same lifecycle shape
68
+ * as `scheduleRunPruning`: `unref`-ed `setInterval` + a stop function.
69
+ * The FIRST sweep runs immediately on scheduling - a server that is
70
+ * restarted more often than `intervalMs` would otherwise never prune
71
+ * anything. Each surface is isolated in its own try/catch: one failing
72
+ * prune logs a WARN and never blocks the others. Overlapping sweeps
73
+ * are skipped (the previous sweep still running when the timer fires
74
+ * again is a signal the interval is too tight, not a reason to pile
75
+ * up writers).
76
+ *
77
+ * Returns a stop function; with `config.enabled === false` no timer is
78
+ * created and the stop function is a no-op.
79
+ *
80
+ * @stable
81
+ */
82
+ function scheduleRetentionSweeps(options) {
83
+ const { store, config, now } = options;
84
+ if (!config.enabled) return () => {};
85
+ const log = options.log ?? (() => {});
86
+ const pruneSpansImpl = options.pruneSpansImpl ?? pruneSpans;
87
+ const consolidator = consolidatorOf(store);
88
+ let sweeping = false;
89
+ let stopped = false;
90
+ const sweep = async () => {
91
+ if (sweeping) return;
92
+ sweeping = true;
93
+ const counts = {};
94
+ const runSurface = async (surface, prune) => {
95
+ if (stopped) return;
96
+ try {
97
+ counts[surface] = await prune();
98
+ } catch (err) {
99
+ if (stopped) return;
100
+ log("warn", `retention sweep failed for ${surface}`, {
101
+ surface,
102
+ error: err instanceof Error ? err.message : String(err)
103
+ });
104
+ }
105
+ };
106
+ try {
107
+ const t = now();
108
+ await runSurface("spans", () => Promise.resolve(pruneSpansImpl(store.connection, { beforeEpochMs: t - config.spansDays * DAY_MS })));
109
+ if (consolidator !== void 0) {
110
+ await runSurface("consolidatorRuns", () => consolidator.pruneRuns(t - config.consolidatorRunsDays * DAY_MS));
111
+ await runSurface("dlqExhausted", () => consolidator.pruneExhaustedBatches(t - config.dlqExhaustedDays * DAY_MS));
112
+ }
113
+ if (config.idempotency) await runSurface("idempotency", () => store.idempotency.prune(t));
114
+ if (config.sessionsDays !== void 0) {
115
+ const days = config.sessionsDays;
116
+ await runSurface("sessions", () => store.sessions.pruneSessions({
117
+ beforeEpochMs: t - days * DAY_MS,
118
+ closedOnly: config.sessionsClosedOnly
119
+ }));
120
+ }
121
+ if (config.auditDays !== void 0) {
122
+ const days = config.auditDays;
123
+ await runSurface("sessionAudit", () => store.sessions.pruneAuditEntries(t - days * DAY_MS));
124
+ }
125
+ if (config.memoryHistoryDays !== void 0) {
126
+ const days = config.memoryHistoryDays;
127
+ await runSurface("memoryHistory", () => store.memory.pruneHistory(days * DAY_MS));
128
+ }
129
+ if (config.workflowThreadsDays !== void 0) {
130
+ const days = config.workflowThreadsDays;
131
+ await runSurface("workflowThreads", () => store.checkpoints.pruneThreads({
132
+ beforeEpochMs: t - days * DAY_MS,
133
+ onlyTerminal: true
134
+ }));
135
+ }
136
+ if (!stopped && Object.values(counts).some((n) => n > 0)) log("info", "retention sweep complete", counts);
137
+ } finally {
138
+ sweeping = false;
139
+ }
140
+ };
141
+ sweep();
142
+ const timer = setInterval(() => {
143
+ sweep();
144
+ }, config.intervalMs);
145
+ if (typeof timer.unref === "function") timer.unref();
146
+ return () => {
147
+ stopped = true;
148
+ clearInterval(timer);
149
+ };
150
+ }
151
+
152
+ //#endregion
153
+ export { createConsoleRetentionLog, scheduleRetentionSweeps };
154
+ //# sourceMappingURL=retention.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retention.js","names":["log: RetentionLog","counts: Record<string, number>"],"sources":["../../src/runtime/retention.ts"],"sourcesContent":["/**\n * W-010 / W-008: the unified server-side retention scheduler.\n *\n * Every prune primitive in the store layer used to be manual and\n * disconnected (CLI-only, API-only, or - like the idempotency sweep\n * before W-061 - dead code). This module owns ONE periodic sweep over\n * every SQLite growth surface, driven by the `config.retention`\n * section. Policy: derived / recoverable data (span telemetry,\n * consolidator run counters, exhausted DLQ batches, expired\n * idempotency bodies) is pruned by default with conservative windows;\n * primary user content (sessions, memory history, workflow threads,\n * session audit) is strictly opt-in via an explicit `*Days` window.\n *\n * UNITS TABLE - the primitives do NOT share cutoff semantics, and\n * conflating them produces silent no-ops, not errors:\n *\n * | surface | argument | unit |\n * | ------------------------------ | ------------------------- | ------------- |\n * | spans (pruneSpans) | `{ beforeEpochMs }` | epoch ms |\n * | consolidator runs (pruneRuns) | `beforeEpochMs` | epoch ms |\n * | DLQ (pruneExhaustedBatches) | `beforeEpochMs` | epoch ms |\n * | idempotency (prune) | `now()` | epoch ms, compared to `expires_at` |\n * | sessions (pruneSessions) | `{ beforeEpochMs, closedOnly }` | epoch ms |\n * | session audit (pruneAuditEntries) | `beforeEpochMs` | epoch ms |\n * | memory history (pruneHistory) | `olderThanMs` | AGE in ms - the store computes `Date.now() - olderThanMs` itself; passing an epoch cutoff here would resolve to a cutoff near 1970 and delete nothing |\n * | workflow threads (pruneThreads) | `{ beforeEpochMs, onlyTerminal: true }` | epoch ms |\n *\n * The file-based replay-JSONL directory (`@graphorin/observability`\n * `pruneTraces`) is deliberately NOT swept here: it is a filesystem\n * surface, not a SQLite one - schedule it via cron (see the\n * deployment guide).\n *\n * @packageDocumentation\n */\n\nimport { pruneSpans, type SqliteConnection } from '@graphorin/store-sqlite';\n\nconst DAY_MS = 86_400_000;\n\n/**\n * Mirror of the `config.retention` section of\n * `ServerConfigSpec`.\n *\n * @stable\n */\nexport interface RetentionConfig {\n readonly enabled: boolean;\n readonly intervalMs: number;\n readonly spansDays: number;\n readonly consolidatorRunsDays: number;\n readonly dlqExhaustedDays: number;\n readonly idempotency: boolean;\n readonly sessionsDays?: number;\n readonly sessionsClosedOnly: boolean;\n readonly memoryHistoryDays?: number;\n readonly workflowThreadsDays?: number;\n readonly auditDays?: number;\n}\n\n/**\n * Structural slice of `GraphorinSqliteStore` the sweep consumes. Typed\n * structurally so custom store implementations (and test fakes) work;\n * a surface whose method is absent is skipped, never an error.\n *\n * @stable\n */\nexport interface RetentionStoreLike {\n readonly connection: SqliteConnection;\n readonly sessions: {\n pruneSessions(opts: {\n readonly beforeEpochMs?: number;\n readonly closedOnly?: boolean;\n }): Promise<number>;\n pruneAuditEntries(beforeEpochMs: number): Promise<number>;\n };\n readonly memory: {\n pruneHistory(olderThanMs: number): Promise<number>;\n };\n readonly checkpoints: {\n pruneThreads(opts: {\n readonly beforeEpochMs: number;\n readonly onlyTerminal?: boolean;\n }): Promise<number>;\n };\n readonly idempotency: {\n prune(olderThan: number): Promise<number>;\n };\n}\n\n/** @stable */\nexport type RetentionLogLevel = 'info' | 'warn';\n\n/**\n * Logging seam: `warn` on a failed surface, `info` with per-surface\n * deletion counts after each sweep. Defaults to a no-op.\n *\n * @stable\n */\nexport type RetentionLog = (\n level: RetentionLogLevel,\n message: string,\n fields?: Record<string, unknown>,\n) => void;\n\n/** @stable */\nexport interface ScheduleRetentionOptions {\n readonly store: RetentionStoreLike;\n readonly config: RetentionConfig;\n readonly now: () => number;\n readonly log?: RetentionLog;\n /**\n * Test seam for the span sweep (the real one issues SQL against\n * `store.connection`).\n *\n * @internal\n */\n readonly pruneSpansImpl?: (\n conn: SqliteConnection,\n opts: { readonly beforeEpochMs: number },\n ) => number;\n}\n\n/**\n * Console-backed {@link RetentionLog} honouring the\n * `observability.logger` config flavour. Returns `undefined` for\n * `'silent'` so callers can pass the result straight through.\n *\n * @stable\n */\nexport function createConsoleRetentionLog(\n flavour: 'json' | 'pretty' | 'silent',\n): RetentionLog | undefined {\n if (flavour === 'silent') return undefined;\n return (level, message, fields) => {\n const sink = level === 'warn' ? console.warn : console.log;\n if (flavour === 'json') {\n sink(JSON.stringify({ level, component: 'retention', message, ...fields }));\n return;\n }\n sink(\n `[retention] ${level}: ${message}${fields !== undefined ? ` ${JSON.stringify(fields)}` : ''}`,\n );\n };\n}\n\n/**\n * Consolidator state lives on `SqliteMemoryStore.consolidator`, not on\n * the `GraphorinSqliteStore` facade. Detected structurally (rather than\n * via `instanceof SqliteMemoryStore`) so compatible custom stores get\n * the sweep too and stores without the surface simply skip it.\n */\ninterface ConsolidatorPruneLike {\n pruneRuns(beforeEpochMs: number): Promise<number>;\n pruneExhaustedBatches(beforeEpochMs: number): Promise<number>;\n}\n\nfunction consolidatorOf(store: RetentionStoreLike): ConsolidatorPruneLike | undefined {\n const candidate = (store.memory as { consolidator?: Partial<ConsolidatorPruneLike> })\n .consolidator;\n if (\n candidate !== undefined &&\n typeof candidate.pruneRuns === 'function' &&\n typeof candidate.pruneExhaustedBatches === 'function'\n ) {\n return candidate as ConsolidatorPruneLike;\n }\n return undefined;\n}\n\n/**\n * W-010: schedule the periodic retention sweep. Same lifecycle shape\n * as `scheduleRunPruning`: `unref`-ed `setInterval` + a stop function.\n * The FIRST sweep runs immediately on scheduling - a server that is\n * restarted more often than `intervalMs` would otherwise never prune\n * anything. Each surface is isolated in its own try/catch: one failing\n * prune logs a WARN and never blocks the others. Overlapping sweeps\n * are skipped (the previous sweep still running when the timer fires\n * again is a signal the interval is too tight, not a reason to pile\n * up writers).\n *\n * Returns a stop function; with `config.enabled === false` no timer is\n * created and the stop function is a no-op.\n *\n * @stable\n */\nexport function scheduleRetentionSweeps(options: ScheduleRetentionOptions): () => void {\n const { store, config, now } = options;\n if (!config.enabled) return () => {};\n const log: RetentionLog = options.log ?? (() => {});\n const pruneSpansImpl = options.pruneSpansImpl ?? pruneSpans;\n const consolidator = consolidatorOf(store);\n\n let sweeping = false;\n let stopped = false;\n\n const sweep = async (): Promise<void> => {\n if (sweeping) return;\n sweeping = true;\n const counts: Record<string, number> = {};\n const runSurface = async (surface: string, prune: () => Promise<number>): Promise<void> => {\n // The immediate startup sweep can outlive a fast stop() (tests,\n // tight restart loops); the store may already be closed then, so\n // bail between surfaces instead of warning on every one.\n if (stopped) return;\n try {\n counts[surface] = await prune();\n } catch (err) {\n if (stopped) return;\n log('warn', `retention sweep failed for ${surface}`, {\n surface,\n error: err instanceof Error ? err.message : String(err),\n });\n }\n };\n try {\n const t = now();\n // Derived / recoverable surfaces - on by default.\n await runSurface('spans', () =>\n Promise.resolve(\n pruneSpansImpl(store.connection, { beforeEpochMs: t - config.spansDays * DAY_MS }),\n ),\n );\n if (consolidator !== undefined) {\n await runSurface('consolidatorRuns', () =>\n consolidator.pruneRuns(t - config.consolidatorRunsDays * DAY_MS),\n );\n await runSurface('dlqExhausted', () =>\n consolidator.pruneExhaustedBatches(t - config.dlqExhaustedDays * DAY_MS),\n );\n }\n if (config.idempotency) {\n // Compared against expires_at: pass the CURRENT moment, not a window.\n await runSurface('idempotency', () => store.idempotency.prune(t));\n }\n // Primary content - strictly opt-in via an explicit window.\n if (config.sessionsDays !== undefined) {\n const days = config.sessionsDays;\n await runSurface('sessions', () =>\n store.sessions.pruneSessions({\n beforeEpochMs: t - days * DAY_MS,\n closedOnly: config.sessionsClosedOnly,\n }),\n );\n }\n if (config.auditDays !== undefined) {\n const days = config.auditDays;\n await runSurface('sessionAudit', () => store.sessions.pruneAuditEntries(t - days * DAY_MS));\n }\n if (config.memoryHistoryDays !== undefined) {\n // AGE in ms, NOT an epoch cutoff - see the units table above.\n const days = config.memoryHistoryDays;\n await runSurface('memoryHistory', () => store.memory.pruneHistory(days * DAY_MS));\n }\n if (config.workflowThreadsDays !== undefined) {\n const days = config.workflowThreadsDays;\n await runSurface('workflowThreads', () =>\n store.checkpoints.pruneThreads({\n beforeEpochMs: t - days * DAY_MS,\n onlyTerminal: true,\n }),\n );\n }\n // Quiet on a no-op sweep (fresh DBs, tight restart loops): the\n // INFO line only appears when the sweep actually deleted rows.\n if (!stopped && Object.values(counts).some((n) => n > 0)) {\n log('info', 'retention sweep complete', counts);\n }\n } finally {\n sweeping = false;\n }\n };\n\n void sweep();\n const timer = setInterval(() => {\n void sweep();\n }, config.intervalMs);\n if (typeof (timer as { unref?: () => void }).unref === 'function') {\n (timer as { unref: () => void }).unref();\n }\n return () => {\n stopped = true;\n clearInterval(timer);\n };\n}\n"],"mappings":"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"}
@@ -62,6 +62,8 @@ interface RunStateSnapshot {
62
62
  readonly completedAt?: number;
63
63
  readonly error?: {
64
64
  readonly message: string;
65
+ readonly code?: string;
66
+ readonly hint?: string;
65
67
  };
66
68
  readonly agentId?: string;
67
69
  readonly workflowId?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"run-state.d.ts","names":[],"sources":["../../src/runtime/run-state.ts"],"sourcesContent":[],"mappings":";;AAsBA;AASA;AAOA;AAWA;AAeA;AAqBA;AAsBA;AAwBA;;;;;;;;;;;;;KA7GY,SAAA;;;;;;;;KASA,OAAA;;;;;;KAOA,iBAAA,GAAoB,QAAQ;;;;;;;;;;UAWvB,eAAA;mBACE;iBACF;;;;;;;;UAaA,gBAAA;;iBAEA;mBACE;;;;;;;;;;;;;;;;;;;KAkBP,aAAA;;;;;;;;;;;;;;;;;;;UAsBK,SAAA;;mBAEE;;;;;;;;;;cAsBN,eAAA;;;;;;;;;iCAasB;;;qCAsBE;;mCAaF,gBAAgB;;kCA0BvC,QAAQ;;;;2BAgCO"}
1
+ {"version":3,"file":"run-state.d.ts","names":[],"sources":["../../src/runtime/run-state.ts"],"sourcesContent":[],"mappings":";;AAwBA;AASA;AAOA;AAWA;AAeA;AAqBA;AAsBA;AAwBA;;;;;;;;;;;;;KA7GY,SAAA;;;;;;;;KASA,OAAA;;;;;;KAOA,iBAAA,GAAoB,QAAQ;;;;;;;;;;UAWvB,eAAA;mBACE;iBACF;;;;;;;;UAaA,gBAAA;;iBAEA;mBACE;;;;;;;;;;;;;;;;;;;;;KAkBP,aAAA;;;;;;;;;;;;;;;;;;;UAsBK,SAAA;;mBAEE;;;;;;;;;;cAsBN,eAAA;;;;;;;;;iCAasB;;;qCAsBE;;mCAaF,gBAAgB;;kCA0BvC,QAAQ;;;;2BAkCO"}
@@ -1,4 +1,21 @@
1
+ import { toWireError } from "../internal/wire-error.js";
2
+
1
3
  //#region src/runtime/run-state.ts
4
+ /**
5
+ * In-memory bookkeeping for in-flight agent / workflow runs. Exposes
6
+ * a tiny CRUD surface route handlers consume to honour the
7
+ * `GET /runs/:runId/state` and `POST /runs/:runId/abort` endpoints
8
+ * declared in the runtime architecture.
9
+ *
10
+ * The full durable resume / replay path lives in Phase 14b/c on top
11
+ * of the WebSocket layer + the consolidator daemon. Phase 14a only
12
+ * needs enough state to:
13
+ * - mint a runId on every `POST /run` / `POST /stream`,
14
+ * - track its lifecycle (`pending` → `running` → `completed` / `failed` / `aborted`),
15
+ * - propagate `AbortController.signal` so handlers can cancel.
16
+ *
17
+ * @packageDocumentation
18
+ */
2
19
  function isTerminalStatus(status) {
3
20
  return status === "completed" || status === "failed" || status === "aborted";
4
21
  }
@@ -66,7 +83,7 @@ var RunStateTracker = class {
66
83
  const wasTerminal = isTerminalStatus(record.status);
67
84
  record.status = status;
68
85
  record.completedAt = this.#now();
69
- if (err !== void 0) record.error = { message: err instanceof Error ? err.message : String(err) };
86
+ if (err !== void 0) record.error = toWireError(err);
70
87
  if (!wasTerminal) this.#emitTerminal(record);
71
88
  }
72
89
  /** Cancel a run via its `AbortController`. */
@@ -176,7 +193,22 @@ function scheduleRunPruning(runs, now, opts = {}) {
176
193
  if (typeof timer.unref === "function") timer.unref();
177
194
  return () => clearInterval(timer);
178
195
  }
196
+ /** Default cadence for the idempotency-record sweep (W-061). */
197
+ const DEFAULT_IDEMPOTENCY_PRUNE_INTERVAL_MS = 60 * 6e4;
198
+ /**
199
+ * W-107: the per-resource scope a caller must hold to touch this run.
200
+ * `'read'` gates state inspection; `'control'` gates abort/resume.
201
+ * Derived from the run descriptor: agent runs bind to
202
+ * `agents:{read|invoke}:<agentId>`, workflow runs to
203
+ * `workflows:{read|execute}:<workflowId>`. Runs without a descriptor id
204
+ * (not produced by the current trackers) fall back to the bare scope.
205
+ */
206
+ function requiredRunScope(snapshot, action) {
207
+ if (snapshot.workflowId !== void 0) return action === "read" ? `workflows:read:${snapshot.workflowId}` : `workflows:execute:${snapshot.workflowId}`;
208
+ if (snapshot.agentId !== void 0) return action === "read" ? `agents:read:${snapshot.agentId}` : `agents:invoke:${snapshot.agentId}`;
209
+ return action === "read" ? "agents:read" : "agents:invoke";
210
+ }
179
211
 
180
212
  //#endregion
181
- export { RunStateTracker, scheduleRunPruning };
213
+ export { RunStateTracker, requiredRunScope, scheduleRunPruning };
182
214
  //# sourceMappingURL=run-state.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"run-state.js","names":["#records","#now","#onTerminal","record: RunRecord","#emitTerminal","base: { -readonly [K in keyof RunStateSnapshot]?: RunStateSnapshot[K] }"],"sources":["../../src/runtime/run-state.ts"],"sourcesContent":["/**\n * In-memory bookkeeping for in-flight agent / workflow runs. Exposes\n * a tiny CRUD surface route handlers consume to honour the\n * `GET /runs/:runId/state` and `POST /runs/:runId/abort` endpoints\n * declared in the runtime architecture.\n *\n * The full durable resume / replay path lives in Phase 14b/c on top\n * of the WebSocket layer + the consolidator daemon. Phase 14a only\n * needs enough state to:\n * - mint a runId on every `POST /run` / `POST /stream`,\n * - track its lifecycle (`pending` → `running` → `completed` / `failed` / `aborted`),\n * - propagate `AbortController.signal` so handlers can cancel.\n *\n * @packageDocumentation\n */\n\n/**\n * Stable status discriminator for a run snapshot. Mirrors the values\n * exposed on the public REST surface.\n *\n * @stable\n */\nexport type RunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'aborted';\n\n/**\n * Identifying tag for the underlying execution kind. Workflows run\n * on the durable engine in `@graphorin/workflow`; agents run on the\n * `@graphorin/agent` runtime.\n *\n * @stable\n */\nexport type RunKind = 'agent' | 'workflow';\n\n/**\n * Terminal status a run can settle into (never `pending` / `running`).\n *\n * @stable\n */\nexport type TerminalRunStatus = Extract<RunStatus, 'completed' | 'failed' | 'aborted'>;\n\n/**\n * IP-15: payload handed to the {@link RunStateTracker} `onTerminal` callback\n * the first time a run reaches a terminal state. The server turns this into\n * the `graphorin_agent_runs_total` counter + `graphorin_agent_run_duration_seconds`\n * summary. `durationMs` is omitted for a run that was aborted before it ever\n * started (no meaningful wall-clock).\n *\n * @stable\n */\nexport interface TerminalRunInfo {\n readonly status: TerminalRunStatus;\n readonly kind: RunKind;\n readonly durationMs?: number;\n}\n\nfunction isTerminalStatus(status: RunStatus): status is TerminalRunStatus {\n return status === 'completed' || status === 'failed' || status === 'aborted';\n}\n\n/**\n * Snapshot returned by {@link RunStateTracker.snapshot}.\n *\n * @stable\n */\nexport interface RunStateSnapshot {\n readonly runId: string;\n readonly kind: RunKind;\n readonly status: RunStatus;\n readonly startedAt?: number;\n readonly completedAt?: number;\n readonly error?: { readonly message: string };\n readonly agentId?: string;\n readonly workflowId?: string;\n readonly threadId?: string;\n readonly sessionId?: string;\n readonly userId?: string;\n}\n\n/**\n * Bookkeeping descriptor recorded at run start. Either an agent run\n * (with `agentId`) or a workflow run (with `workflowId` + optional\n * `threadId`).\n *\n * @stable\n */\nexport type RunDescriptor =\n | {\n readonly kind: 'agent';\n readonly agentId: string;\n readonly sessionId?: string;\n readonly userId?: string;\n }\n | {\n readonly kind: 'workflow';\n readonly workflowId: string;\n readonly threadId?: string;\n readonly sessionId?: string;\n readonly userId?: string;\n };\n\n/**\n * In-flight handle returned by {@link RunStateTracker.start}. Handlers\n * pass `signal` into the underlying `agent.run / workflow.execute`\n * invocation so cancellation propagates instantly.\n *\n * @stable\n */\nexport interface RunHandle {\n readonly runId: string;\n readonly signal: AbortSignal;\n cancel(reason?: unknown): void;\n}\n\ninterface RunRecord {\n readonly runId: string;\n readonly kind: RunKind;\n readonly descriptor: RunDescriptor;\n status: RunStatus;\n controller: AbortController;\n startedAt?: number;\n completedAt?: number;\n error?: { readonly message: string };\n}\n\n/**\n * Pluggable tracker. The default in-memory implementation is the only\n * one shipped in Phase 14a; future phases plug in a SQLite-backed\n * variant so durable resume survives process restarts.\n *\n * @stable\n */\nexport class RunStateTracker {\n readonly #records: Map<string, RunRecord> = new Map();\n readonly #now: () => number;\n readonly #onTerminal: ((info: TerminalRunInfo) => void) | undefined;\n\n constructor(\n options: {\n readonly now?: () => number;\n /**\n * IP-15: invoked exactly once per run, the first time it settles into a\n * terminal state. Used to drive the run-count + duration metrics. Never\n * throws into the tracker - wrap your handler if it might.\n */\n readonly onTerminal?: (info: TerminalRunInfo) => void;\n } = {},\n ) {\n this.#now = options.now ?? Date.now;\n this.#onTerminal = options.onTerminal;\n }\n\n #emitTerminal(record: RunRecord): void {\n if (this.#onTerminal === undefined) return;\n if (!isTerminalStatus(record.status)) return;\n const durationMs =\n record.startedAt !== undefined && record.completedAt !== undefined\n ? record.completedAt - record.startedAt\n : undefined;\n this.#onTerminal({\n status: record.status,\n kind: record.kind,\n ...(durationMs !== undefined ? { durationMs } : {}),\n });\n }\n\n /** Reserve a run id without taking ownership of an AbortSignal. */\n declare(runId: string, descriptor: RunDescriptor): void {\n if (this.#records.has(runId)) return;\n const controller = new AbortController();\n this.#records.set(runId, {\n runId,\n kind: descriptor.kind,\n descriptor,\n status: 'pending',\n controller,\n });\n }\n\n /** Promote a previously-declared run to `running` (or declare it). */\n start(runId: string, descriptor: RunDescriptor): RunHandle {\n const existing = this.#records.get(runId);\n const record: RunRecord = existing ?? {\n runId,\n kind: descriptor.kind,\n descriptor,\n status: 'pending',\n controller: new AbortController(),\n };\n record.status = 'running';\n record.startedAt = this.#now();\n this.#records.set(runId, record);\n return Object.freeze({\n runId,\n signal: record.controller.signal,\n cancel: (reason?: unknown) => {\n if (!record.controller.signal.aborted) {\n record.controller.abort(reason);\n }\n },\n });\n }\n\n /** Mark a run as terminal. */\n complete(\n runId: string,\n status: Extract<RunStatus, 'completed' | 'failed' | 'aborted'>,\n err?: unknown,\n ): void {\n const record = this.#records.get(runId);\n if (record === undefined) return;\n // IP-15: only the FIRST terminal transition counts toward the metrics -\n // a run aborted then re-completed must not double-increment.\n const wasTerminal = isTerminalStatus(record.status);\n record.status = status;\n record.completedAt = this.#now();\n if (err !== undefined) {\n record.error = { message: err instanceof Error ? err.message : String(err) };\n }\n if (!wasTerminal) this.#emitTerminal(record);\n }\n\n /** Cancel a run via its `AbortController`. */\n abort(runId: string, reason?: unknown): boolean {\n const record = this.#records.get(runId);\n if (record === undefined) return false;\n if (!record.controller.signal.aborted) {\n record.controller.abort(reason);\n }\n if (record.status === 'pending' || record.status === 'running') {\n record.status = 'aborted';\n record.completedAt = this.#now();\n this.#emitTerminal(record);\n }\n return true;\n }\n\n /** Read-only snapshot, safe to JSON.stringify. */\n snapshot(runId: string): RunStateSnapshot | undefined {\n const record = this.#records.get(runId);\n if (record === undefined) return undefined;\n const base: { -readonly [K in keyof RunStateSnapshot]?: RunStateSnapshot[K] } = {\n runId: record.runId,\n kind: record.kind,\n status: record.status,\n };\n if (record.startedAt !== undefined) base.startedAt = record.startedAt;\n if (record.completedAt !== undefined) base.completedAt = record.completedAt;\n if (record.error !== undefined) base.error = record.error;\n if (record.descriptor.kind === 'agent') {\n base.agentId = record.descriptor.agentId;\n } else {\n base.workflowId = record.descriptor.workflowId;\n if (record.descriptor.threadId !== undefined) base.threadId = record.descriptor.threadId;\n }\n if (record.descriptor.sessionId !== undefined) base.sessionId = record.descriptor.sessionId;\n if (record.descriptor.userId !== undefined) base.userId = record.descriptor.userId;\n return Object.freeze(base as RunStateSnapshot);\n }\n\n /**\n * Number of runs currently in `pending` or `running`. Useful for\n * snapshots / metrics. Note that `pending` runs hold a reservation\n * but have not yet started any work - see {@link runningCount} for\n * the drain-blocking subset.\n */\n inflightCount(): number {\n let n = 0;\n for (const record of this.#records.values()) {\n if (record.status === 'pending' || record.status === 'running') n += 1;\n }\n return n;\n }\n\n /**\n * Number of runs with active work in progress (`running`). The\n * lifecycle drain blocks on this counter only - pending runs are a\n * pure reservation (e.g. an awaited WS subscription) and can be\n * aborted immediately when SIGTERM arrives.\n */\n runningCount(): number {\n let n = 0;\n for (const record of this.#records.values()) {\n if (record.status === 'running') n += 1;\n }\n return n;\n }\n\n /**\n * Drop every reserved-but-not-yet-started run. Called by the\n * server lifecycle at the start of `stop()` so the drain only\n * waits for actual work in flight.\n */\n abortPending(reason?: unknown): number {\n let aborted = 0;\n for (const record of this.#records.values()) {\n if (record.status === 'pending') {\n if (!record.controller.signal.aborted) record.controller.abort(reason);\n record.status = 'aborted';\n record.completedAt = this.#now();\n this.#emitTerminal(record);\n aborted += 1;\n }\n }\n return aborted;\n }\n\n /** Drop terminal records older than `olderThan`. */\n prune(olderThan: number): number {\n let removed = 0;\n for (const [runId, record] of this.#records) {\n if (\n record.completedAt !== undefined &&\n record.completedAt <= olderThan &&\n record.status !== 'pending' &&\n record.status !== 'running'\n ) {\n this.#records.delete(runId);\n removed += 1;\n }\n }\n return removed;\n }\n\n /** Cancel every in-flight run. Used during graceful shutdown. */\n abortAll(reason?: unknown): number {\n let aborted = 0;\n for (const [runId] of this.#records) {\n if (this.abort(runId, reason)) aborted += 1;\n }\n return aborted;\n }\n}\n\n/** Default cadence for the terminal-record prune sweep (IP-16). */\nexport const DEFAULT_RUN_PRUNE_INTERVAL_MS = 60_000;\n/** Default retention for terminal run records before they are dropped. */\nexport const DEFAULT_RUN_RETENTION_MS = 5 * 60_000;\n\n/**\n * IP-16: schedule a periodic prune of terminal run records. Without this the\n * tracker's `prune()` was never called, so every run / stream / workflow left\n * a `RunRecord` (each holding an `AbortController`) in memory forever - an\n * unbounded leak on a long-living server. Returns a stop function that clears\n * the timer; the timer is `unref`-ed so it never keeps the process alive.\n */\nexport function scheduleRunPruning(\n runs: RunStateTracker,\n now: () => number,\n opts: { readonly intervalMs?: number; readonly retentionMs?: number } = {},\n): () => void {\n const intervalMs = opts.intervalMs ?? DEFAULT_RUN_PRUNE_INTERVAL_MS;\n const retentionMs = opts.retentionMs ?? DEFAULT_RUN_RETENTION_MS;\n const timer = setInterval(() => {\n runs.prune(now() - retentionMs);\n }, intervalMs);\n if (typeof (timer as { unref?: () => void }).unref === 'function') {\n (timer as { unref: () => void }).unref();\n }\n return () => clearInterval(timer);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"run-state.js","names":["#records","#now","#onTerminal","record: RunRecord","#emitTerminal","base: { -readonly [K in keyof RunStateSnapshot]?: RunStateSnapshot[K] }"],"sources":["../../src/runtime/run-state.ts"],"sourcesContent":["/**\n * In-memory bookkeeping for in-flight agent / workflow runs. Exposes\n * a tiny CRUD surface route handlers consume to honour the\n * `GET /runs/:runId/state` and `POST /runs/:runId/abort` endpoints\n * declared in the runtime architecture.\n *\n * The full durable resume / replay path lives in Phase 14b/c on top\n * of the WebSocket layer + the consolidator daemon. Phase 14a only\n * needs enough state to:\n * - mint a runId on every `POST /run` / `POST /stream`,\n * - track its lifecycle (`pending` → `running` → `completed` / `failed` / `aborted`),\n * - propagate `AbortController.signal` so handlers can cancel.\n *\n * @packageDocumentation\n */\n\nimport { toWireError } from '../internal/wire-error.js';\n\n/**\n * Stable status discriminator for a run snapshot. Mirrors the values\n * exposed on the public REST surface.\n *\n * @stable\n */\nexport type RunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'aborted';\n\n/**\n * Identifying tag for the underlying execution kind. Workflows run\n * on the durable engine in `@graphorin/workflow`; agents run on the\n * `@graphorin/agent` runtime.\n *\n * @stable\n */\nexport type RunKind = 'agent' | 'workflow';\n\n/**\n * Terminal status a run can settle into (never `pending` / `running`).\n *\n * @stable\n */\nexport type TerminalRunStatus = Extract<RunStatus, 'completed' | 'failed' | 'aborted'>;\n\n/**\n * IP-15: payload handed to the {@link RunStateTracker} `onTerminal` callback\n * the first time a run reaches a terminal state. The server turns this into\n * the `graphorin_agent_runs_total` counter + `graphorin_agent_run_duration_seconds`\n * summary. `durationMs` is omitted for a run that was aborted before it ever\n * started (no meaningful wall-clock).\n *\n * @stable\n */\nexport interface TerminalRunInfo {\n readonly status: TerminalRunStatus;\n readonly kind: RunKind;\n readonly durationMs?: number;\n}\n\nfunction isTerminalStatus(status: RunStatus): status is TerminalRunStatus {\n return status === 'completed' || status === 'failed' || status === 'aborted';\n}\n\n/**\n * Snapshot returned by {@link RunStateTracker.snapshot}.\n *\n * @stable\n */\nexport interface RunStateSnapshot {\n readonly runId: string;\n readonly kind: RunKind;\n readonly status: RunStatus;\n readonly startedAt?: number;\n readonly completedAt?: number;\n readonly error?: { readonly message: string; readonly code?: string; readonly hint?: string };\n readonly agentId?: string;\n readonly workflowId?: string;\n readonly threadId?: string;\n readonly sessionId?: string;\n readonly userId?: string;\n}\n\n/**\n * Bookkeeping descriptor recorded at run start. Either an agent run\n * (with `agentId`) or a workflow run (with `workflowId` + optional\n * `threadId`).\n *\n * @stable\n */\nexport type RunDescriptor =\n | {\n readonly kind: 'agent';\n readonly agentId: string;\n readonly sessionId?: string;\n readonly userId?: string;\n }\n | {\n readonly kind: 'workflow';\n readonly workflowId: string;\n readonly threadId?: string;\n readonly sessionId?: string;\n readonly userId?: string;\n };\n\n/**\n * In-flight handle returned by {@link RunStateTracker.start}. Handlers\n * pass `signal` into the underlying `agent.run / workflow.execute`\n * invocation so cancellation propagates instantly.\n *\n * @stable\n */\nexport interface RunHandle {\n readonly runId: string;\n readonly signal: AbortSignal;\n cancel(reason?: unknown): void;\n}\n\ninterface RunRecord {\n readonly runId: string;\n readonly kind: RunKind;\n readonly descriptor: RunDescriptor;\n status: RunStatus;\n controller: AbortController;\n startedAt?: number;\n completedAt?: number;\n error?: { readonly message: string; readonly code?: string; readonly hint?: string };\n}\n\n/**\n * Pluggable tracker. The default in-memory implementation is the only\n * one shipped in Phase 14a; future phases plug in a SQLite-backed\n * variant so durable resume survives process restarts.\n *\n * @stable\n */\nexport class RunStateTracker {\n readonly #records: Map<string, RunRecord> = new Map();\n readonly #now: () => number;\n readonly #onTerminal: ((info: TerminalRunInfo) => void) | undefined;\n\n constructor(\n options: {\n readonly now?: () => number;\n /**\n * IP-15: invoked exactly once per run, the first time it settles into a\n * terminal state. Used to drive the run-count + duration metrics. Never\n * throws into the tracker - wrap your handler if it might.\n */\n readonly onTerminal?: (info: TerminalRunInfo) => void;\n } = {},\n ) {\n this.#now = options.now ?? Date.now;\n this.#onTerminal = options.onTerminal;\n }\n\n #emitTerminal(record: RunRecord): void {\n if (this.#onTerminal === undefined) return;\n if (!isTerminalStatus(record.status)) return;\n const durationMs =\n record.startedAt !== undefined && record.completedAt !== undefined\n ? record.completedAt - record.startedAt\n : undefined;\n this.#onTerminal({\n status: record.status,\n kind: record.kind,\n ...(durationMs !== undefined ? { durationMs } : {}),\n });\n }\n\n /** Reserve a run id without taking ownership of an AbortSignal. */\n declare(runId: string, descriptor: RunDescriptor): void {\n if (this.#records.has(runId)) return;\n const controller = new AbortController();\n this.#records.set(runId, {\n runId,\n kind: descriptor.kind,\n descriptor,\n status: 'pending',\n controller,\n });\n }\n\n /** Promote a previously-declared run to `running` (or declare it). */\n start(runId: string, descriptor: RunDescriptor): RunHandle {\n const existing = this.#records.get(runId);\n const record: RunRecord = existing ?? {\n runId,\n kind: descriptor.kind,\n descriptor,\n status: 'pending',\n controller: new AbortController(),\n };\n record.status = 'running';\n record.startedAt = this.#now();\n this.#records.set(runId, record);\n return Object.freeze({\n runId,\n signal: record.controller.signal,\n cancel: (reason?: unknown) => {\n if (!record.controller.signal.aborted) {\n record.controller.abort(reason);\n }\n },\n });\n }\n\n /** Mark a run as terminal. */\n complete(\n runId: string,\n status: Extract<RunStatus, 'completed' | 'failed' | 'aborted'>,\n err?: unknown,\n ): void {\n const record = this.#records.get(runId);\n if (record === undefined) return;\n // IP-15: only the FIRST terminal transition counts toward the metrics -\n // a run aborted then re-completed must not double-increment.\n const wasTerminal = isTerminalStatus(record.status);\n record.status = status;\n record.completedAt = this.#now();\n if (err !== undefined) {\n // W-052: keep the machine-readable code next to the message so\n // the GET run-status surface reports it too.\n record.error = toWireError(err);\n }\n if (!wasTerminal) this.#emitTerminal(record);\n }\n\n /** Cancel a run via its `AbortController`. */\n abort(runId: string, reason?: unknown): boolean {\n const record = this.#records.get(runId);\n if (record === undefined) return false;\n if (!record.controller.signal.aborted) {\n record.controller.abort(reason);\n }\n if (record.status === 'pending' || record.status === 'running') {\n record.status = 'aborted';\n record.completedAt = this.#now();\n this.#emitTerminal(record);\n }\n return true;\n }\n\n /** Read-only snapshot, safe to JSON.stringify. */\n snapshot(runId: string): RunStateSnapshot | undefined {\n const record = this.#records.get(runId);\n if (record === undefined) return undefined;\n const base: { -readonly [K in keyof RunStateSnapshot]?: RunStateSnapshot[K] } = {\n runId: record.runId,\n kind: record.kind,\n status: record.status,\n };\n if (record.startedAt !== undefined) base.startedAt = record.startedAt;\n if (record.completedAt !== undefined) base.completedAt = record.completedAt;\n if (record.error !== undefined) base.error = record.error;\n if (record.descriptor.kind === 'agent') {\n base.agentId = record.descriptor.agentId;\n } else {\n base.workflowId = record.descriptor.workflowId;\n if (record.descriptor.threadId !== undefined) base.threadId = record.descriptor.threadId;\n }\n if (record.descriptor.sessionId !== undefined) base.sessionId = record.descriptor.sessionId;\n if (record.descriptor.userId !== undefined) base.userId = record.descriptor.userId;\n return Object.freeze(base as RunStateSnapshot);\n }\n\n /**\n * Number of runs currently in `pending` or `running`. Useful for\n * snapshots / metrics. Note that `pending` runs hold a reservation\n * but have not yet started any work - see {@link runningCount} for\n * the drain-blocking subset.\n */\n inflightCount(): number {\n let n = 0;\n for (const record of this.#records.values()) {\n if (record.status === 'pending' || record.status === 'running') n += 1;\n }\n return n;\n }\n\n /**\n * Number of runs with active work in progress (`running`). The\n * lifecycle drain blocks on this counter only - pending runs are a\n * pure reservation (e.g. an awaited WS subscription) and can be\n * aborted immediately when SIGTERM arrives.\n */\n runningCount(): number {\n let n = 0;\n for (const record of this.#records.values()) {\n if (record.status === 'running') n += 1;\n }\n return n;\n }\n\n /**\n * Drop every reserved-but-not-yet-started run. Called by the\n * server lifecycle at the start of `stop()` so the drain only\n * waits for actual work in flight.\n */\n abortPending(reason?: unknown): number {\n let aborted = 0;\n for (const record of this.#records.values()) {\n if (record.status === 'pending') {\n if (!record.controller.signal.aborted) record.controller.abort(reason);\n record.status = 'aborted';\n record.completedAt = this.#now();\n this.#emitTerminal(record);\n aborted += 1;\n }\n }\n return aborted;\n }\n\n /** Drop terminal records older than `olderThan`. */\n prune(olderThan: number): number {\n let removed = 0;\n for (const [runId, record] of this.#records) {\n if (\n record.completedAt !== undefined &&\n record.completedAt <= olderThan &&\n record.status !== 'pending' &&\n record.status !== 'running'\n ) {\n this.#records.delete(runId);\n removed += 1;\n }\n }\n return removed;\n }\n\n /** Cancel every in-flight run. Used during graceful shutdown. */\n abortAll(reason?: unknown): number {\n let aborted = 0;\n for (const [runId] of this.#records) {\n if (this.abort(runId, reason)) aborted += 1;\n }\n return aborted;\n }\n}\n\n/** Default cadence for the terminal-record prune sweep (IP-16). */\nexport const DEFAULT_RUN_PRUNE_INTERVAL_MS = 60_000;\n/** Default retention for terminal run records before they are dropped. */\nexport const DEFAULT_RUN_RETENTION_MS = 5 * 60_000;\n\n/**\n * IP-16: schedule a periodic prune of terminal run records. Without this the\n * tracker's `prune()` was never called, so every run / stream / workflow left\n * a `RunRecord` (each holding an `AbortController`) in memory forever - an\n * unbounded leak on a long-living server. Returns a stop function that clears\n * the timer; the timer is `unref`-ed so it never keeps the process alive.\n */\nexport function scheduleRunPruning(\n runs: RunStateTracker,\n now: () => number,\n opts: { readonly intervalMs?: number; readonly retentionMs?: number } = {},\n): () => void {\n const intervalMs = opts.intervalMs ?? DEFAULT_RUN_PRUNE_INTERVAL_MS;\n const retentionMs = opts.retentionMs ?? DEFAULT_RUN_RETENTION_MS;\n const timer = setInterval(() => {\n runs.prune(now() - retentionMs);\n }, intervalMs);\n if (typeof (timer as { unref?: () => void }).unref === 'function') {\n (timer as { unref: () => void }).unref();\n }\n return () => clearInterval(timer);\n}\n\n/** Default cadence for the idempotency-record sweep (W-061). */\nexport const DEFAULT_IDEMPOTENCY_PRUNE_INTERVAL_MS = 60 * 60_000;\n\n/**\n * W-061: schedule a periodic prune of EXPIRED idempotency records.\n * `idempotency_records` stores each keyed POST's full `response_json`\n * with an `expires_at` column, but expiry was only ever checked on the\n * READ path - the bodies accumulated on disk indefinitely. The sweep\n * passes `now()` as the cutoff, deleting exactly the records the read\n * path already refuses to replay (IETF-draft semantics unchanged).\n * Best-effort: store errors are swallowed. Same shape as\n * {@link scheduleRunPruning}: `unref`-ed timer + stop function.\n *\n * Since W-010 the standalone server drives this surface through the\n * unified retention scheduler (`config.retention.idempotency`); this\n * standalone primitive remains for embedders running the store\n * without the server.\n */\nexport function scheduleIdempotencyPruning(\n store: { prune(olderThan: number): Promise<number> },\n now: () => number,\n opts: { readonly intervalMs?: number } = {},\n): () => void {\n const intervalMs = opts.intervalMs ?? DEFAULT_IDEMPOTENCY_PRUNE_INTERVAL_MS;\n const timer = setInterval(() => {\n void store.prune(now()).catch(() => {});\n }, intervalMs);\n if (typeof (timer as { unref?: () => void }).unref === 'function') {\n (timer as { unref: () => void }).unref();\n }\n return () => clearInterval(timer);\n}\n\n/**\n * W-107: the per-resource scope a caller must hold to touch this run.\n * `'read'` gates state inspection; `'control'` gates abort/resume.\n * Derived from the run descriptor: agent runs bind to\n * `agents:{read|invoke}:<agentId>`, workflow runs to\n * `workflows:{read|execute}:<workflowId>`. Runs without a descriptor id\n * (not produced by the current trackers) fall back to the bare scope.\n */\nexport function requiredRunScope(\n snapshot: Pick<RunStateSnapshot, 'agentId' | 'workflowId'>,\n action: 'read' | 'control',\n): string {\n if (snapshot.workflowId !== undefined) {\n return action === 'read'\n ? `workflows:read:${snapshot.workflowId}`\n : `workflows:execute:${snapshot.workflowId}`;\n }\n if (snapshot.agentId !== undefined) {\n return action === 'read'\n ? `agents:read:${snapshot.agentId}`\n : `agents:invoke:${snapshot.agentId}`;\n }\n return action === 'read' ? 'agents:read' : 'agents:invoke';\n}\n"],"mappings":"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"}
@@ -1,8 +1,7 @@
1
- import { createDeliveryCommentarySanitizer } from "../commentary/sanitizer.js";
2
- import "../commentary/index.js";
3
1
  import { createScopeMiddleware } from "../middleware/scope.js";
4
- import { ServerMessageSchema, isEventFrame, isLifecycleFrame } from "@graphorin/protocol";
2
+ import { createDeliveryCommentarySanitizer } from "../commentary/sanitizer.js";
5
3
  import { Hono } from "hono";
4
+ import { ServerMessageSchema, isEventFrame, isLifecycleFrame } from "@graphorin/protocol";
6
5
  import { stream } from "hono/streaming";
7
6
 
8
7
  //#region src/sse/events.ts
@@ -33,7 +32,7 @@ function createSseRoutes(deps) {
33
32
  const sanitizer = deps.dispatcher.sanitizer.applyToEvents.length > 0 ? deps.dispatcher.sanitizer : createDeliveryCommentarySanitizer(deps.commentary);
34
33
  const keepAliveMs = deps.keepAliveMs ?? 15e3;
35
34
  const app = new Hono();
36
- app.get("/:id/events", createScopeMiddleware("sessions:read"), sseHandler(deps.dispatcher, sanitizer, keepAliveMs, deps.perConnectionQueueLimit ?? 1e3));
35
+ app.get("/:id/events", createScopeMiddleware((_path, params) => `sessions:read:${params.id}`), sseHandler(deps.dispatcher, sanitizer, keepAliveMs, deps.perConnectionQueueLimit ?? 1e3));
37
36
  return app;
38
37
  }
39
38
  function sseHandler(dispatcher, sanitizer, keepAliveMs, perConnectionQueueLimit) {
@@ -1 +1 @@
1
- {"version":3,"file":"events.js","names":["sanitizer: DeliveryCommentarySanitizer","lastEventId: string | undefined","queue: ServerMessage[]","resumeNotify: (() => void) | undefined"],"sources":["../../src/sse/events.ts"],"sourcesContent":["/**\n * Server-Sent Events fallback for non-WebSocket-friendly clients.\n *\n * The endpoint is unidirectional: the server pushes\n * `ServerMessage` event frames; control-plane operations\n * (subscribe / unsubscribe / abort / resume) go through REST.\n *\n * Unlike the WS surface, the SSE responder is not multiplexed -\n * one HTTP request per session subject. Reconnection is handled by\n * the standard EventSource `Last-Event-ID` mechanism: when the\n * client supplies the header on resume, the responder walks the\n * dispatcher's replay buffer from that cursor.\n *\n * @packageDocumentation\n */\n\nimport {\n isEventFrame,\n isLifecycleFrame,\n type ServerEventFrame,\n type ServerMessage,\n ServerMessageSchema,\n} from '@graphorin/protocol';\nimport type { Context, MiddlewareHandler } from 'hono';\nimport { Hono } from 'hono';\nimport { stream } from 'hono/streaming';\nimport type { StreamingApi } from 'hono/utils/stream';\n\nimport {\n createDeliveryCommentarySanitizer,\n type DeliveryCommentaryConfig,\n type DeliveryCommentarySanitizer,\n} from '../commentary/index.js';\nimport type { ServerVariables } from '../internal/context.js';\nimport { createScopeMiddleware } from '../middleware/scope.js';\nimport type { WsDispatcher } from '../ws/dispatcher.js';\n\n/**\n * Stable shape consumed by {@link createSseRoutes}.\n *\n * @stable\n */\nexport interface SseRoutesDeps {\n /**\n * Cap on the per-connection SSE delivery queue (IP-9). A consumer\n * that stops reading past this many buffered frames is closed\n * instead of growing the queue without bound. Default 1000.\n */\n readonly perConnectionQueueLimit?: number;\n readonly dispatcher: WsDispatcher;\n readonly commentary?: DeliveryCommentaryConfig;\n /**\n * How long the SSE responder waits between heartbeat comments to\n * keep proxies / load balancers from closing the idle connection.\n * Default `15_000` ms.\n */\n readonly keepAliveMs?: number;\n readonly now?: () => number;\n}\n\n/**\n * Build the SSE event-stream router. Mounts\n * `GET /sessions/:id/events` (the canonical path documented in the\n * runtime spec); operators that want a different path should mount\n * the router under a custom prefix.\n *\n * @stable\n */\nexport function createSseRoutes(deps: SseRoutesDeps): Hono<{ Variables: ServerVariables }> {\n const sanitizer: DeliveryCommentarySanitizer =\n deps.dispatcher.sanitizer.applyToEvents.length > 0\n ? deps.dispatcher.sanitizer\n : createDeliveryCommentarySanitizer(deps.commentary);\n const keepAliveMs = deps.keepAliveMs ?? 15_000;\n\n const app = new Hono<{ Variables: ServerVariables }>();\n app.get(\n '/:id/events',\n createScopeMiddleware('sessions:read'),\n sseHandler(deps.dispatcher, sanitizer, keepAliveMs, deps.perConnectionQueueLimit ?? 1000),\n );\n return app;\n}\n\nfunction sseHandler(\n dispatcher: WsDispatcher,\n sanitizer: DeliveryCommentarySanitizer,\n keepAliveMs: number,\n perConnectionQueueLimit: number,\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n return async (c: Context<{ Variables: ServerVariables }>) => {\n const sessionId = c.req.param('id');\n if (sessionId === undefined || sessionId.length === 0) {\n return c.json(\n { error: 'session-id-required', message: 'Path parameter :id is required.' },\n 400,\n );\n }\n const subject = `session:${sessionId}/events`;\n const sinceEventId = c.req.header('last-event-id') ?? c.req.header('Last-Event-ID');\n\n c.header('Content-Type', 'text/event-stream; charset=utf-8');\n c.header('Cache-Control', 'no-cache, no-transform');\n c.header('Connection', 'keep-alive');\n c.header('X-Accel-Buffering', 'no');\n\n return stream(c, async (writer) => {\n const replay = dispatcher.replayBuffer.replay(subject, sinceEventId);\n let lastEventId: string | undefined;\n for (const frame of replay.events) {\n const sanitized = sanitizer.sanitize({ ...frame, subscriptionId: 'sse' }, 'sse');\n await writeFrame(writer as StreamingApi, sanitized);\n lastEventId = sanitized.eventId;\n }\n if (replay.droppedCount > 0) {\n await writeReplayMarker(writer as StreamingApi, replay.droppedCount, lastEventId);\n }\n // Live tail - register a synthetic subscription so the\n // dispatcher fans new events into this connection. The\n // dispatcher already validates + sanitizes every frame; here we\n // just write the bytes onto the wire.\n const subscriberId = `sse-${Math.random().toString(36).slice(2, 10)}`;\n const subscriptionId = `${subscriberId}-sub`;\n const authState = c.get('state').auth;\n // IP-13: the anonymous (auth.kind='none') principal carries `admin:*` in\n // grantedScopes, so the dispatcher's per-subject scope check still passes.\n const grantedScopes =\n authState.kind === 'token' || authState.kind === 'anonymous' ? authState.grantedScopes : [];\n const tokenId =\n authState.kind === 'token'\n ? authState.token.tokenId\n : authState.kind === 'anonymous'\n ? 'anonymous'\n : 'sse-anon';\n let queue: ServerMessage[] = [];\n let closedByBackpressure = false;\n const queueLimit = perConnectionQueueLimit;\n let resumeNotify: (() => void) | undefined;\n const notify = (): void => {\n if (resumeNotify !== undefined) {\n const fn = resumeNotify;\n resumeNotify = undefined;\n fn();\n }\n };\n const handle = {\n id: subscriberId,\n tokenId,\n // The strict-mode subscribe path enforces scope; we trust the\n // outer middleware (`createScopeMiddleware('sessions:read')`)\n // to gate this handler so we forward an empty scope set -\n // the dispatcher's per-subject scope check still requires\n // the `agents:invoke:<sessionId>` grant.\n grantedScopes,\n send: (frame: ServerMessage) => {\n // IP-9: bound the queue - a stalled consumer is closed, not\n // buffered into the heap forever.\n if (queue.length >= queueLimit) {\n closedByBackpressure = true;\n queue = [];\n notify();\n return;\n }\n queue.push(frame);\n notify();\n },\n close: () => {\n queue = [];\n notify();\n },\n };\n const reg = dispatcher.registerSubscriber(handle);\n const result = dispatcher.subscribe({\n subscriberId,\n subject,\n subscriptionId,\n ...(lastEventId !== undefined ? { sinceEventId: lastEventId } : {}),\n });\n if (!result.ok) {\n reg.unregister();\n await writer.write(\n encodeSse({\n event: 'error',\n data: JSON.stringify({\n error: 'sse-subscribe-failed',\n reason: result.reason,\n }),\n }),\n );\n await writer.close();\n return;\n }\n const heartbeat = setInterval(() => {\n // Fire-and-forget heartbeat comment - clients ignore comment\n // lines (RFC 6455 § 9.1).\n writer.write(': keep-alive\\n\\n').catch(() => undefined);\n }, keepAliveMs);\n let aborted = false;\n const onAbort = (): void => {\n aborted = true;\n notify();\n };\n c.req.raw.signal.addEventListener('abort', onAbort, { once: true });\n try {\n while (!aborted) {\n if (closedByBackpressure) {\n // IP-9: the consumer stalled past the queue cap - close the\n // stream with a terminal lifecycle frame instead of\n // buffering unboundedly.\n await writer.write(\n encodeSse({\n event: 'lifecycle',\n data: JSON.stringify({\n kind: 'lifecycle',\n subscriptionId,\n status: 'failed',\n reason: 'flow.throttled',\n }),\n }),\n );\n aborted = true;\n continue;\n }\n if (queue.length > 0) {\n const next = queue.shift();\n if (next === undefined) continue;\n if (isEventFrame(next)) {\n await writeFrame(writer as StreamingApi, next);\n } else if (isLifecycleFrame(next)) {\n await writer.write(\n encodeSse({\n event: 'lifecycle',\n data: JSON.stringify(next),\n }),\n );\n if (\n next.status === 'completed' ||\n next.status === 'aborted' ||\n next.status === 'failed'\n ) {\n aborted = true;\n }\n }\n continue;\n }\n await new Promise<void>((resolve) => {\n resumeNotify = resolve;\n });\n }\n } finally {\n clearInterval(heartbeat);\n c.req.raw.signal.removeEventListener('abort', onAbort);\n dispatcher.unsubscribe(subscriptionId);\n reg.unregister();\n }\n });\n };\n}\n\nasync function writeFrame(writer: StreamingApi, frame: ServerEventFrame): Promise<void> {\n const validated = ServerMessageSchema.safeParse(frame);\n if (!validated.success) return;\n await writer.write(\n encodeSse({\n event: frame.type,\n id: frame.eventId,\n data: JSON.stringify(frame),\n }),\n );\n}\n\nasync function writeReplayMarker(\n writer: StreamingApi,\n droppedCount: number,\n lastEventId: string | undefined,\n): Promise<void> {\n await writer.write(\n encodeSse({\n ...(lastEventId !== undefined ? { id: lastEventId } : {}),\n event: 'replay-marker',\n data: JSON.stringify({\n v: '1',\n kind: 'replay-marker',\n subscriptionId: 'sse',\n eventId: lastEventId ?? `evt-replay-${Date.now().toString(36)}`,\n droppedCount,\n }),\n }),\n );\n}\n\nfunction encodeSse(frame: { event?: string; id?: string; data: string }): string {\n let out = '';\n if (frame.event !== undefined) out += `event: ${frame.event}\\n`;\n if (frame.id !== undefined) out += `id: ${frame.id}\\n`;\n for (const line of frame.data.split('\\n')) {\n out += `data: ${line}\\n`;\n }\n out += '\\n';\n return out;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"events.js","names":["sanitizer: DeliveryCommentarySanitizer","lastEventId: string | undefined","queue: ServerMessage[]","resumeNotify: (() => void) | undefined"],"sources":["../../src/sse/events.ts"],"sourcesContent":["/**\n * Server-Sent Events fallback for non-WebSocket-friendly clients.\n *\n * The endpoint is unidirectional: the server pushes\n * `ServerMessage` event frames; control-plane operations\n * (subscribe / unsubscribe / abort / resume) go through REST.\n *\n * Unlike the WS surface, the SSE responder is not multiplexed -\n * one HTTP request per session subject. Reconnection is handled by\n * the standard EventSource `Last-Event-ID` mechanism: when the\n * client supplies the header on resume, the responder walks the\n * dispatcher's replay buffer from that cursor.\n *\n * @packageDocumentation\n */\n\nimport {\n isEventFrame,\n isLifecycleFrame,\n type ServerEventFrame,\n type ServerMessage,\n ServerMessageSchema,\n} from '@graphorin/protocol';\nimport type { Context, MiddlewareHandler } from 'hono';\nimport { Hono } from 'hono';\nimport { stream } from 'hono/streaming';\nimport type { StreamingApi } from 'hono/utils/stream';\n\nimport {\n createDeliveryCommentarySanitizer,\n type DeliveryCommentaryConfig,\n type DeliveryCommentarySanitizer,\n} from '../commentary/index.js';\nimport type { ServerVariables } from '../internal/context.js';\nimport { createScopeMiddleware } from '../middleware/scope.js';\nimport type { WsDispatcher } from '../ws/dispatcher.js';\n\n/**\n * Stable shape consumed by {@link createSseRoutes}.\n *\n * @stable\n */\nexport interface SseRoutesDeps {\n /**\n * Cap on the per-connection SSE delivery queue (IP-9). A consumer\n * that stops reading past this many buffered frames is closed\n * instead of growing the queue without bound. Default 1000.\n */\n readonly perConnectionQueueLimit?: number;\n readonly dispatcher: WsDispatcher;\n readonly commentary?: DeliveryCommentaryConfig;\n /**\n * How long the SSE responder waits between heartbeat comments to\n * keep proxies / load balancers from closing the idle connection.\n * Default `15_000` ms.\n */\n readonly keepAliveMs?: number;\n readonly now?: () => number;\n}\n\n/**\n * Build the SSE event-stream router. Mounts\n * `GET /sessions/:id/events` (the canonical path documented in the\n * runtime spec); operators that want a different path should mount\n * the router under a custom prefix.\n *\n * @stable\n */\nexport function createSseRoutes(deps: SseRoutesDeps): Hono<{ Variables: ServerVariables }> {\n const sanitizer: DeliveryCommentarySanitizer =\n deps.dispatcher.sanitizer.applyToEvents.length > 0\n ? deps.dispatcher.sanitizer\n : createDeliveryCommentarySanitizer(deps.commentary);\n const keepAliveMs = deps.keepAliveMs ?? 15_000;\n\n const app = new Hono<{ Variables: ServerVariables }>();\n app.get(\n '/:id/events',\n // W-107: per-resource, mirroring the WS subject gate - a token\n // scoped to one session streams it over the browser SSE fallback.\n createScopeMiddleware((_path, params) => `sessions:read:${params.id}`),\n sseHandler(deps.dispatcher, sanitizer, keepAliveMs, deps.perConnectionQueueLimit ?? 1000),\n );\n return app;\n}\n\nfunction sseHandler(\n dispatcher: WsDispatcher,\n sanitizer: DeliveryCommentarySanitizer,\n keepAliveMs: number,\n perConnectionQueueLimit: number,\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n return async (c: Context<{ Variables: ServerVariables }>) => {\n const sessionId = c.req.param('id');\n if (sessionId === undefined || sessionId.length === 0) {\n return c.json(\n { error: 'session-id-required', message: 'Path parameter :id is required.' },\n 400,\n );\n }\n const subject = `session:${sessionId}/events`;\n const sinceEventId = c.req.header('last-event-id') ?? c.req.header('Last-Event-ID');\n\n c.header('Content-Type', 'text/event-stream; charset=utf-8');\n c.header('Cache-Control', 'no-cache, no-transform');\n c.header('Connection', 'keep-alive');\n c.header('X-Accel-Buffering', 'no');\n\n return stream(c, async (writer) => {\n const replay = dispatcher.replayBuffer.replay(subject, sinceEventId);\n let lastEventId: string | undefined;\n for (const frame of replay.events) {\n const sanitized = sanitizer.sanitize({ ...frame, subscriptionId: 'sse' }, 'sse');\n await writeFrame(writer as StreamingApi, sanitized);\n lastEventId = sanitized.eventId;\n }\n if (replay.droppedCount > 0) {\n await writeReplayMarker(writer as StreamingApi, replay.droppedCount, lastEventId);\n }\n // Live tail - register a synthetic subscription so the\n // dispatcher fans new events into this connection. The\n // dispatcher already validates + sanitizes every frame; here we\n // just write the bytes onto the wire.\n const subscriberId = `sse-${Math.random().toString(36).slice(2, 10)}`;\n const subscriptionId = `${subscriberId}-sub`;\n const authState = c.get('state').auth;\n // IP-13: the anonymous (auth.kind='none') principal carries `admin:*` in\n // grantedScopes, so the dispatcher's per-subject scope check still passes.\n const grantedScopes =\n authState.kind === 'token' || authState.kind === 'anonymous' ? authState.grantedScopes : [];\n const tokenId =\n authState.kind === 'token'\n ? authState.token.tokenId\n : authState.kind === 'anonymous'\n ? 'anonymous'\n : 'sse-anon';\n let queue: ServerMessage[] = [];\n let closedByBackpressure = false;\n const queueLimit = perConnectionQueueLimit;\n let resumeNotify: (() => void) | undefined;\n const notify = (): void => {\n if (resumeNotify !== undefined) {\n const fn = resumeNotify;\n resumeNotify = undefined;\n fn();\n }\n };\n const handle = {\n id: subscriberId,\n tokenId,\n // The strict-mode subscribe path enforces scope; the outer\n // middleware gates this handler per-resource\n // (`sessions:read:<sessionId>`, W-107) and the dispatcher's\n // per-subject check requires the same grant - the two layers\n // are consistent for session-scoped tokens.\n grantedScopes,\n send: (frame: ServerMessage) => {\n // IP-9: bound the queue - a stalled consumer is closed, not\n // buffered into the heap forever.\n if (queue.length >= queueLimit) {\n closedByBackpressure = true;\n queue = [];\n notify();\n return;\n }\n queue.push(frame);\n notify();\n },\n close: () => {\n queue = [];\n notify();\n },\n };\n const reg = dispatcher.registerSubscriber(handle);\n const result = dispatcher.subscribe({\n subscriberId,\n subject,\n subscriptionId,\n ...(lastEventId !== undefined ? { sinceEventId: lastEventId } : {}),\n });\n if (!result.ok) {\n reg.unregister();\n await writer.write(\n encodeSse({\n event: 'error',\n data: JSON.stringify({\n error: 'sse-subscribe-failed',\n reason: result.reason,\n }),\n }),\n );\n await writer.close();\n return;\n }\n const heartbeat = setInterval(() => {\n // Fire-and-forget heartbeat comment - clients ignore comment\n // lines (RFC 6455 § 9.1).\n writer.write(': keep-alive\\n\\n').catch(() => undefined);\n }, keepAliveMs);\n let aborted = false;\n const onAbort = (): void => {\n aborted = true;\n notify();\n };\n c.req.raw.signal.addEventListener('abort', onAbort, { once: true });\n try {\n while (!aborted) {\n if (closedByBackpressure) {\n // IP-9: the consumer stalled past the queue cap - close the\n // stream with a terminal lifecycle frame instead of\n // buffering unboundedly.\n await writer.write(\n encodeSse({\n event: 'lifecycle',\n data: JSON.stringify({\n kind: 'lifecycle',\n subscriptionId,\n status: 'failed',\n reason: 'flow.throttled',\n }),\n }),\n );\n aborted = true;\n continue;\n }\n if (queue.length > 0) {\n const next = queue.shift();\n if (next === undefined) continue;\n if (isEventFrame(next)) {\n await writeFrame(writer as StreamingApi, next);\n } else if (isLifecycleFrame(next)) {\n await writer.write(\n encodeSse({\n event: 'lifecycle',\n data: JSON.stringify(next),\n }),\n );\n if (\n next.status === 'completed' ||\n next.status === 'aborted' ||\n next.status === 'failed'\n ) {\n aborted = true;\n }\n }\n continue;\n }\n await new Promise<void>((resolve) => {\n resumeNotify = resolve;\n });\n }\n } finally {\n clearInterval(heartbeat);\n c.req.raw.signal.removeEventListener('abort', onAbort);\n dispatcher.unsubscribe(subscriptionId);\n reg.unregister();\n }\n });\n };\n}\n\nasync function writeFrame(writer: StreamingApi, frame: ServerEventFrame): Promise<void> {\n const validated = ServerMessageSchema.safeParse(frame);\n if (!validated.success) return;\n await writer.write(\n encodeSse({\n event: frame.type,\n id: frame.eventId,\n data: JSON.stringify(frame),\n }),\n );\n}\n\nasync function writeReplayMarker(\n writer: StreamingApi,\n droppedCount: number,\n lastEventId: string | undefined,\n): Promise<void> {\n await writer.write(\n encodeSse({\n ...(lastEventId !== undefined ? { id: lastEventId } : {}),\n event: 'replay-marker',\n data: JSON.stringify({\n v: '1',\n kind: 'replay-marker',\n subscriptionId: 'sse',\n eventId: lastEventId ?? `evt-replay-${Date.now().toString(36)}`,\n droppedCount,\n }),\n }),\n );\n}\n\nfunction encodeSse(frame: { event?: string; id?: string; data: string }): string {\n let out = '';\n if (frame.event !== undefined) out += `event: ${frame.event}\\n`;\n if (frame.id !== undefined) out += `id: ${frame.id}\\n`;\n for (const line of frame.data.split('\\n')) {\n out += `data: ${line}\\n`;\n }\n out += '\\n';\n return out;\n}\n"],"mappings":"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"}
@@ -0,0 +1,29 @@
1
+ import { AuditDb, AuditEntryInput } from "@graphorin/security/audit";
2
+ import { ToolAuditEvent } from "@graphorin/tools/audit";
3
+
4
+ //#region src/tools-audit-bridge.d.ts
5
+
6
+ /** Volume policy for the tool-audit bridge. @stable */
7
+ type ToolEventsAuditPolicy = 'security' | 'all' | 'off';
8
+ /** Translate a tools audit event into an audit-chain entry. */
9
+ declare function toolAuditEventToAuditInput(event: ToolAuditEvent): AuditEntryInput;
10
+ /** Handle returned by {@link bridgeToolAuditToAudit}. @stable */
11
+ interface ToolAuditBridge {
12
+ /** Unsubscribe from the tool-audit bus. */
13
+ readonly stop: () => void;
14
+ /** Resolve once every queued audit write has settled. */
15
+ readonly drain: () => Promise<void>;
16
+ }
17
+ /**
18
+ * Subscribe the audit chain to the tools audit bus. Returns a handle
19
+ * whose `stop()` MUST run on shutdown (the bus is process-global).
20
+ *
21
+ * @stable
22
+ */
23
+ declare function bridgeToolAuditToAudit(db: AuditDb, options?: {
24
+ readonly policy?: ToolEventsAuditPolicy;
25
+ readonly onWriteError?: (event: ToolAuditEvent, error: unknown) => void;
26
+ }): ToolAuditBridge;
27
+ //#endregion
28
+ export { ToolAuditBridge, ToolEventsAuditPolicy, bridgeToolAuditToAudit, toolAuditEventToAuditInput };
29
+ //# sourceMappingURL=tools-audit-bridge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tools-audit-bridge.d.ts","names":[],"sources":["../src/tools-audit-bridge.ts"],"sourcesContent":[],"mappings":";;;;;;KA6BY,qBAAA;;iBAwBI,0BAAA,QAAkC,iBAAiB;;UAqBlD,eAAA;;;;wBAIO;;;;;;;;iBASR,sBAAA,KACV;oBAEgB;kCACc;IAEjC"}
@@ -0,0 +1,103 @@
1
+ import { appendAudit } from "@graphorin/security/audit";
2
+ import { onToolAudit } from "@graphorin/tools/audit";
3
+
4
+ //#region src/tools-audit-bridge.ts
5
+ /**
6
+ * W-051: bridge the `@graphorin/tools` audit-event bus (`onToolAudit`,
7
+ * which `@graphorin/mcp` also emits into) into the server's
8
+ * tamper-evident audit log. Without this, a shadow-mode
9
+ * `tool:dataflow:flagged` was invisible to an operator unless they
10
+ * hand-wired a subscriber - which no guide showed.
11
+ *
12
+ * Mirrors `commentary/audit-bridge.ts`: writes serialise through one
13
+ * promise tail so concurrent events never race `appendAudit`'s seq
14
+ * chain, a failed write is isolated (warn, never throw into the
15
+ * emitter), and `drain()` lets shutdown/tests await in-flight writes.
16
+ *
17
+ * Volume control (`config.audit.toolEvents`):
18
+ * - `'security'` (default): only the security-significant subset -
19
+ * dataflow flagged/blocked/declassified, sanitization hits/blocks,
20
+ * approval lifecycle, collisions, cap-disabled. High-frequency
21
+ * `tool:execute:*` chatter stays OUT of the Merkle chain.
22
+ * - `'all'`: every tool audit event, including per-call execute
23
+ * start/end/error (bounded by call volume - size the chain
24
+ * accordingly).
25
+ * - `'off'`: bridge disabled.
26
+ *
27
+ * @packageDocumentation
28
+ */
29
+ /**
30
+ * The `'security'` allowlist: decisions an operator reviews, not
31
+ * per-call chatter.
32
+ */
33
+ const SECURITY_ACTIONS = new Set([
34
+ "tool:dataflow:flagged",
35
+ "tool:dataflow:blocked",
36
+ "tool:dataflow:declassified",
37
+ "tool:result:sanitization:hit",
38
+ "tool:result:sanitization:blocked",
39
+ "tool:approval:requested",
40
+ "tool:approval:granted",
41
+ "tool:approval:denied",
42
+ "tool:collision:detected",
43
+ "tool:collision:priority-resolved",
44
+ "tool:collision:auto-prefix-applied",
45
+ "tool:collision:manual-rejected",
46
+ "tool:collision:suppressed",
47
+ "tool:result:cap-disabled"
48
+ ]);
49
+ /** Translate a tools audit event into an audit-chain entry. */
50
+ function toolAuditEventToAuditInput(event) {
51
+ const context = {
52
+ ...event.context?.runId !== void 0 ? { runId: event.context.runId } : {},
53
+ ...event.context?.sessionId !== void 0 ? { sessionId: event.context.sessionId } : {}
54
+ };
55
+ return {
56
+ actor: {
57
+ kind: event.actor.kind,
58
+ id: event.actor.id
59
+ },
60
+ action: event.action,
61
+ target: event.target,
62
+ decision: event.decision,
63
+ ts: event.ts,
64
+ ...Object.keys(context).length > 0 ? { context } : {},
65
+ metadata: {
66
+ ...event.metadata ?? {},
67
+ ...event.context?.stepNumber !== void 0 ? { stepNumber: event.context.stepNumber } : {},
68
+ ...event.context?.toolCallId !== void 0 ? { toolCallId: event.context.toolCallId } : {}
69
+ }
70
+ };
71
+ }
72
+ /**
73
+ * Subscribe the audit chain to the tools audit bus. Returns a handle
74
+ * whose `stop()` MUST run on shutdown (the bus is process-global).
75
+ *
76
+ * @stable
77
+ */
78
+ function bridgeToolAuditToAudit(db, options = {}) {
79
+ const policy = options.policy ?? "security";
80
+ if (policy === "off") return {
81
+ stop: () => {},
82
+ drain: async () => {}
83
+ };
84
+ const onWriteError = options.onWriteError ?? defaultOnWriteError;
85
+ let tail = Promise.resolve();
86
+ return {
87
+ stop: onToolAudit((event) => {
88
+ if (policy === "security" && !SECURITY_ACTIONS.has(event.action)) return;
89
+ const input = toolAuditEventToAuditInput(event);
90
+ tail = tail.then(() => appendAudit(db, input)).catch((error) => onWriteError(event, error));
91
+ }),
92
+ drain: async () => {
93
+ await tail;
94
+ }
95
+ };
96
+ }
97
+ function defaultOnWriteError(event, error) {
98
+ console.warn(`[graphorin/server] WARN: failed to write a tool audit entry (${event.action}): ${error instanceof Error ? error.message : String(error)}`);
99
+ }
100
+
101
+ //#endregion
102
+ export { bridgeToolAuditToAudit, toolAuditEventToAuditInput };
103
+ //# sourceMappingURL=tools-audit-bridge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tools-audit-bridge.js","names":["SECURITY_ACTIONS: ReadonlySet<string>","context: AuditEntryInput['context']","tail: Promise<unknown>"],"sources":["../src/tools-audit-bridge.ts"],"sourcesContent":["/**\n * W-051: bridge the `@graphorin/tools` audit-event bus (`onToolAudit`,\n * which `@graphorin/mcp` also emits into) into the server's\n * tamper-evident audit log. Without this, a shadow-mode\n * `tool:dataflow:flagged` was invisible to an operator unless they\n * hand-wired a subscriber - which no guide showed.\n *\n * Mirrors `commentary/audit-bridge.ts`: writes serialise through one\n * promise tail so concurrent events never race `appendAudit`'s seq\n * chain, a failed write is isolated (warn, never throw into the\n * emitter), and `drain()` lets shutdown/tests await in-flight writes.\n *\n * Volume control (`config.audit.toolEvents`):\n * - `'security'` (default): only the security-significant subset -\n * dataflow flagged/blocked/declassified, sanitization hits/blocks,\n * approval lifecycle, collisions, cap-disabled. High-frequency\n * `tool:execute:*` chatter stays OUT of the Merkle chain.\n * - `'all'`: every tool audit event, including per-call execute\n * start/end/error (bounded by call volume - size the chain\n * accordingly).\n * - `'off'`: bridge disabled.\n *\n * @packageDocumentation\n */\n\nimport { type AuditDb, type AuditEntryInput, appendAudit } from '@graphorin/security/audit';\nimport { onToolAudit, type ToolAuditEvent } from '@graphorin/tools/audit';\n\n/** Volume policy for the tool-audit bridge. @stable */\nexport type ToolEventsAuditPolicy = 'security' | 'all' | 'off';\n\n/**\n * The `'security'` allowlist: decisions an operator reviews, not\n * per-call chatter.\n */\nconst SECURITY_ACTIONS: ReadonlySet<string> = new Set([\n 'tool:dataflow:flagged',\n 'tool:dataflow:blocked',\n 'tool:dataflow:declassified',\n 'tool:result:sanitization:hit',\n 'tool:result:sanitization:blocked',\n 'tool:approval:requested',\n 'tool:approval:granted',\n 'tool:approval:denied',\n 'tool:collision:detected',\n 'tool:collision:priority-resolved',\n 'tool:collision:auto-prefix-applied',\n 'tool:collision:manual-rejected',\n 'tool:collision:suppressed',\n 'tool:result:cap-disabled',\n]);\n\n/** Translate a tools audit event into an audit-chain entry. */\nexport function toolAuditEventToAuditInput(event: ToolAuditEvent): AuditEntryInput {\n const context: AuditEntryInput['context'] = {\n ...(event.context?.runId !== undefined ? { runId: event.context.runId } : {}),\n ...(event.context?.sessionId !== undefined ? { sessionId: event.context.sessionId } : {}),\n };\n return {\n actor: { kind: event.actor.kind, id: event.actor.id },\n action: event.action,\n target: event.target,\n decision: event.decision,\n ts: event.ts,\n ...(Object.keys(context).length > 0 ? { context } : {}),\n metadata: {\n ...(event.metadata ?? {}),\n ...(event.context?.stepNumber !== undefined ? { stepNumber: event.context.stepNumber } : {}),\n ...(event.context?.toolCallId !== undefined ? { toolCallId: event.context.toolCallId } : {}),\n },\n };\n}\n\n/** Handle returned by {@link bridgeToolAuditToAudit}. @stable */\nexport interface ToolAuditBridge {\n /** Unsubscribe from the tool-audit bus. */\n readonly stop: () => void;\n /** Resolve once every queued audit write has settled. */\n readonly drain: () => Promise<void>;\n}\n\n/**\n * Subscribe the audit chain to the tools audit bus. Returns a handle\n * whose `stop()` MUST run on shutdown (the bus is process-global).\n *\n * @stable\n */\nexport function bridgeToolAuditToAudit(\n db: AuditDb,\n options: {\n readonly policy?: ToolEventsAuditPolicy;\n readonly onWriteError?: (event: ToolAuditEvent, error: unknown) => void;\n } = {},\n): ToolAuditBridge {\n const policy = options.policy ?? 'security';\n if (policy === 'off') {\n return { stop: () => {}, drain: async () => {} };\n }\n const onWriteError = options.onWriteError ?? defaultOnWriteError;\n let tail: Promise<unknown> = Promise.resolve();\n const stop = onToolAudit((event) => {\n if (policy === 'security' && !SECURITY_ACTIONS.has(event.action)) return;\n const input = toolAuditEventToAuditInput(event);\n tail = tail.then(() => appendAudit(db, input)).catch((error) => onWriteError(event, error));\n });\n return {\n stop,\n drain: async () => {\n await tail;\n },\n };\n}\n\nfunction defaultOnWriteError(event: ToolAuditEvent, error: unknown): void {\n console.warn(\n `[graphorin/server] WARN: failed to write a tool audit entry (${event.action}): ${\n error instanceof Error ? error.message : String(error)\n }`,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmCA,MAAMA,mBAAwC,IAAI,IAAI;CACpD;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD,CAAC;;AAGF,SAAgB,2BAA2B,OAAwC;CACjF,MAAMC,UAAsC;EAC1C,GAAI,MAAM,SAAS,UAAU,SAAY,EAAE,OAAO,MAAM,QAAQ,OAAO,GAAG,EAAE;EAC5E,GAAI,MAAM,SAAS,cAAc,SAAY,EAAE,WAAW,MAAM,QAAQ,WAAW,GAAG,EAAE;EACzF;AACD,QAAO;EACL,OAAO;GAAE,MAAM,MAAM,MAAM;GAAM,IAAI,MAAM,MAAM;GAAI;EACrD,QAAQ,MAAM;EACd,QAAQ,MAAM;EACd,UAAU,MAAM;EAChB,IAAI,MAAM;EACV,GAAI,OAAO,KAAK,QAAQ,CAAC,SAAS,IAAI,EAAE,SAAS,GAAG,EAAE;EACtD,UAAU;GACR,GAAI,MAAM,YAAY,EAAE;GACxB,GAAI,MAAM,SAAS,eAAe,SAAY,EAAE,YAAY,MAAM,QAAQ,YAAY,GAAG,EAAE;GAC3F,GAAI,MAAM,SAAS,eAAe,SAAY,EAAE,YAAY,MAAM,QAAQ,YAAY,GAAG,EAAE;GAC5F;EACF;;;;;;;;AAiBH,SAAgB,uBACd,IACA,UAGI,EAAE,EACW;CACjB,MAAM,SAAS,QAAQ,UAAU;AACjC,KAAI,WAAW,MACb,QAAO;EAAE,YAAY;EAAI,OAAO,YAAY;EAAI;CAElD,MAAM,eAAe,QAAQ,gBAAgB;CAC7C,IAAIC,OAAyB,QAAQ,SAAS;AAM9C,QAAO;EACL,MANW,aAAa,UAAU;AAClC,OAAI,WAAW,cAAc,CAAC,iBAAiB,IAAI,MAAM,OAAO,CAAE;GAClE,MAAM,QAAQ,2BAA2B,MAAM;AAC/C,UAAO,KAAK,WAAW,YAAY,IAAI,MAAM,CAAC,CAAC,OAAO,UAAU,aAAa,OAAO,MAAM,CAAC;IAC3F;EAGA,OAAO,YAAY;AACjB,SAAM;;EAET;;AAGH,SAAS,oBAAoB,OAAuB,OAAsB;AACxE,SAAQ,KACN,gEAAgE,MAAM,OAAO,KAC3E,iBAAiB,QAAQ,MAAM,UAAU,OAAO,MAAM,GAEzD"}