@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,284 @@
1
+ /**
2
+ * W-010 / W-008: the unified server-side retention scheduler.
3
+ *
4
+ * Every prune primitive in the store layer used to be manual and
5
+ * disconnected (CLI-only, API-only, or - like the idempotency sweep
6
+ * before W-061 - dead code). This module owns ONE periodic sweep over
7
+ * every SQLite growth surface, driven by the `config.retention`
8
+ * section. Policy: derived / recoverable data (span telemetry,
9
+ * consolidator run counters, exhausted DLQ batches, expired
10
+ * idempotency bodies) is pruned by default with conservative windows;
11
+ * primary user content (sessions, memory history, workflow threads,
12
+ * session audit) is strictly opt-in via an explicit `*Days` window.
13
+ *
14
+ * UNITS TABLE - the primitives do NOT share cutoff semantics, and
15
+ * conflating them produces silent no-ops, not errors:
16
+ *
17
+ * | surface | argument | unit |
18
+ * | ------------------------------ | ------------------------- | ------------- |
19
+ * | spans (pruneSpans) | `{ beforeEpochMs }` | epoch ms |
20
+ * | consolidator runs (pruneRuns) | `beforeEpochMs` | epoch ms |
21
+ * | DLQ (pruneExhaustedBatches) | `beforeEpochMs` | epoch ms |
22
+ * | idempotency (prune) | `now()` | epoch ms, compared to `expires_at` |
23
+ * | sessions (pruneSessions) | `{ beforeEpochMs, closedOnly }` | epoch ms |
24
+ * | session audit (pruneAuditEntries) | `beforeEpochMs` | epoch ms |
25
+ * | 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 |
26
+ * | workflow threads (pruneThreads) | `{ beforeEpochMs, onlyTerminal: true }` | epoch ms |
27
+ *
28
+ * The file-based replay-JSONL directory (`@graphorin/observability`
29
+ * `pruneTraces`) is deliberately NOT swept here: it is a filesystem
30
+ * surface, not a SQLite one - schedule it via cron (see the
31
+ * deployment guide).
32
+ *
33
+ * @packageDocumentation
34
+ */
35
+
36
+ import { pruneSpans, type SqliteConnection } from '@graphorin/store-sqlite';
37
+
38
+ const DAY_MS = 86_400_000;
39
+
40
+ /**
41
+ * Mirror of the `config.retention` section of
42
+ * `ServerConfigSpec`.
43
+ *
44
+ * @stable
45
+ */
46
+ export interface RetentionConfig {
47
+ readonly enabled: boolean;
48
+ readonly intervalMs: number;
49
+ readonly spansDays: number;
50
+ readonly consolidatorRunsDays: number;
51
+ readonly dlqExhaustedDays: number;
52
+ readonly idempotency: boolean;
53
+ readonly sessionsDays?: number;
54
+ readonly sessionsClosedOnly: boolean;
55
+ readonly memoryHistoryDays?: number;
56
+ readonly workflowThreadsDays?: number;
57
+ readonly auditDays?: number;
58
+ }
59
+
60
+ /**
61
+ * Structural slice of `GraphorinSqliteStore` the sweep consumes. Typed
62
+ * structurally so custom store implementations (and test fakes) work;
63
+ * a surface whose method is absent is skipped, never an error.
64
+ *
65
+ * @stable
66
+ */
67
+ export interface RetentionStoreLike {
68
+ readonly connection: SqliteConnection;
69
+ readonly sessions: {
70
+ pruneSessions(opts: {
71
+ readonly beforeEpochMs?: number;
72
+ readonly closedOnly?: boolean;
73
+ }): Promise<number>;
74
+ pruneAuditEntries(beforeEpochMs: number): Promise<number>;
75
+ };
76
+ readonly memory: {
77
+ pruneHistory(olderThanMs: number): Promise<number>;
78
+ };
79
+ readonly checkpoints: {
80
+ pruneThreads(opts: {
81
+ readonly beforeEpochMs: number;
82
+ readonly onlyTerminal?: boolean;
83
+ }): Promise<number>;
84
+ };
85
+ readonly idempotency: {
86
+ prune(olderThan: number): Promise<number>;
87
+ };
88
+ }
89
+
90
+ /** @stable */
91
+ export type RetentionLogLevel = 'info' | 'warn';
92
+
93
+ /**
94
+ * Logging seam: `warn` on a failed surface, `info` with per-surface
95
+ * deletion counts after each sweep. Defaults to a no-op.
96
+ *
97
+ * @stable
98
+ */
99
+ export type RetentionLog = (
100
+ level: RetentionLogLevel,
101
+ message: string,
102
+ fields?: Record<string, unknown>,
103
+ ) => void;
104
+
105
+ /** @stable */
106
+ export interface ScheduleRetentionOptions {
107
+ readonly store: RetentionStoreLike;
108
+ readonly config: RetentionConfig;
109
+ readonly now: () => number;
110
+ readonly log?: RetentionLog;
111
+ /**
112
+ * Test seam for the span sweep (the real one issues SQL against
113
+ * `store.connection`).
114
+ *
115
+ * @internal
116
+ */
117
+ readonly pruneSpansImpl?: (
118
+ conn: SqliteConnection,
119
+ opts: { readonly beforeEpochMs: number },
120
+ ) => number;
121
+ }
122
+
123
+ /**
124
+ * Console-backed {@link RetentionLog} honouring the
125
+ * `observability.logger` config flavour. Returns `undefined` for
126
+ * `'silent'` so callers can pass the result straight through.
127
+ *
128
+ * @stable
129
+ */
130
+ export function createConsoleRetentionLog(
131
+ flavour: 'json' | 'pretty' | 'silent',
132
+ ): RetentionLog | undefined {
133
+ if (flavour === 'silent') return undefined;
134
+ return (level, message, fields) => {
135
+ const sink = level === 'warn' ? console.warn : console.log;
136
+ if (flavour === 'json') {
137
+ sink(JSON.stringify({ level, component: 'retention', message, ...fields }));
138
+ return;
139
+ }
140
+ sink(
141
+ `[retention] ${level}: ${message}${fields !== undefined ? ` ${JSON.stringify(fields)}` : ''}`,
142
+ );
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Consolidator state lives on `SqliteMemoryStore.consolidator`, not on
148
+ * the `GraphorinSqliteStore` facade. Detected structurally (rather than
149
+ * via `instanceof SqliteMemoryStore`) so compatible custom stores get
150
+ * the sweep too and stores without the surface simply skip it.
151
+ */
152
+ interface ConsolidatorPruneLike {
153
+ pruneRuns(beforeEpochMs: number): Promise<number>;
154
+ pruneExhaustedBatches(beforeEpochMs: number): Promise<number>;
155
+ }
156
+
157
+ function consolidatorOf(store: RetentionStoreLike): ConsolidatorPruneLike | undefined {
158
+ const candidate = (store.memory as { consolidator?: Partial<ConsolidatorPruneLike> })
159
+ .consolidator;
160
+ if (
161
+ candidate !== undefined &&
162
+ typeof candidate.pruneRuns === 'function' &&
163
+ typeof candidate.pruneExhaustedBatches === 'function'
164
+ ) {
165
+ return candidate as ConsolidatorPruneLike;
166
+ }
167
+ return undefined;
168
+ }
169
+
170
+ /**
171
+ * W-010: schedule the periodic retention sweep. Same lifecycle shape
172
+ * as `scheduleRunPruning`: `unref`-ed `setInterval` + a stop function.
173
+ * The FIRST sweep runs immediately on scheduling - a server that is
174
+ * restarted more often than `intervalMs` would otherwise never prune
175
+ * anything. Each surface is isolated in its own try/catch: one failing
176
+ * prune logs a WARN and never blocks the others. Overlapping sweeps
177
+ * are skipped (the previous sweep still running when the timer fires
178
+ * again is a signal the interval is too tight, not a reason to pile
179
+ * up writers).
180
+ *
181
+ * Returns a stop function; with `config.enabled === false` no timer is
182
+ * created and the stop function is a no-op.
183
+ *
184
+ * @stable
185
+ */
186
+ export function scheduleRetentionSweeps(options: ScheduleRetentionOptions): () => void {
187
+ const { store, config, now } = options;
188
+ if (!config.enabled) return () => {};
189
+ const log: RetentionLog = options.log ?? (() => {});
190
+ const pruneSpansImpl = options.pruneSpansImpl ?? pruneSpans;
191
+ const consolidator = consolidatorOf(store);
192
+
193
+ let sweeping = false;
194
+ let stopped = false;
195
+
196
+ const sweep = async (): Promise<void> => {
197
+ if (sweeping) return;
198
+ sweeping = true;
199
+ const counts: Record<string, number> = {};
200
+ const runSurface = async (surface: string, prune: () => Promise<number>): Promise<void> => {
201
+ // The immediate startup sweep can outlive a fast stop() (tests,
202
+ // tight restart loops); the store may already be closed then, so
203
+ // bail between surfaces instead of warning on every one.
204
+ if (stopped) return;
205
+ try {
206
+ counts[surface] = await prune();
207
+ } catch (err) {
208
+ if (stopped) return;
209
+ log('warn', `retention sweep failed for ${surface}`, {
210
+ surface,
211
+ error: err instanceof Error ? err.message : String(err),
212
+ });
213
+ }
214
+ };
215
+ try {
216
+ const t = now();
217
+ // Derived / recoverable surfaces - on by default.
218
+ await runSurface('spans', () =>
219
+ Promise.resolve(
220
+ pruneSpansImpl(store.connection, { beforeEpochMs: t - config.spansDays * DAY_MS }),
221
+ ),
222
+ );
223
+ if (consolidator !== undefined) {
224
+ await runSurface('consolidatorRuns', () =>
225
+ consolidator.pruneRuns(t - config.consolidatorRunsDays * DAY_MS),
226
+ );
227
+ await runSurface('dlqExhausted', () =>
228
+ consolidator.pruneExhaustedBatches(t - config.dlqExhaustedDays * DAY_MS),
229
+ );
230
+ }
231
+ if (config.idempotency) {
232
+ // Compared against expires_at: pass the CURRENT moment, not a window.
233
+ await runSurface('idempotency', () => store.idempotency.prune(t));
234
+ }
235
+ // Primary content - strictly opt-in via an explicit window.
236
+ if (config.sessionsDays !== undefined) {
237
+ const days = config.sessionsDays;
238
+ await runSurface('sessions', () =>
239
+ store.sessions.pruneSessions({
240
+ beforeEpochMs: t - days * DAY_MS,
241
+ closedOnly: config.sessionsClosedOnly,
242
+ }),
243
+ );
244
+ }
245
+ if (config.auditDays !== undefined) {
246
+ const days = config.auditDays;
247
+ await runSurface('sessionAudit', () => store.sessions.pruneAuditEntries(t - days * DAY_MS));
248
+ }
249
+ if (config.memoryHistoryDays !== undefined) {
250
+ // AGE in ms, NOT an epoch cutoff - see the units table above.
251
+ const days = config.memoryHistoryDays;
252
+ await runSurface('memoryHistory', () => store.memory.pruneHistory(days * DAY_MS));
253
+ }
254
+ if (config.workflowThreadsDays !== undefined) {
255
+ const days = config.workflowThreadsDays;
256
+ await runSurface('workflowThreads', () =>
257
+ store.checkpoints.pruneThreads({
258
+ beforeEpochMs: t - days * DAY_MS,
259
+ onlyTerminal: true,
260
+ }),
261
+ );
262
+ }
263
+ // Quiet on a no-op sweep (fresh DBs, tight restart loops): the
264
+ // INFO line only appears when the sweep actually deleted rows.
265
+ if (!stopped && Object.values(counts).some((n) => n > 0)) {
266
+ log('info', 'retention sweep complete', counts);
267
+ }
268
+ } finally {
269
+ sweeping = false;
270
+ }
271
+ };
272
+
273
+ void sweep();
274
+ const timer = setInterval(() => {
275
+ void sweep();
276
+ }, config.intervalMs);
277
+ if (typeof (timer as { unref?: () => void }).unref === 'function') {
278
+ (timer as { unref: () => void }).unref();
279
+ }
280
+ return () => {
281
+ stopped = true;
282
+ clearInterval(timer);
283
+ };
284
+ }
@@ -0,0 +1,422 @@
1
+ /**
2
+ * In-memory bookkeeping for in-flight agent / workflow runs. Exposes
3
+ * a tiny CRUD surface route handlers consume to honour the
4
+ * `GET /runs/:runId/state` and `POST /runs/:runId/abort` endpoints
5
+ * declared in the runtime architecture.
6
+ *
7
+ * The full durable resume / replay path lives in Phase 14b/c on top
8
+ * of the WebSocket layer + the consolidator daemon. Phase 14a only
9
+ * needs enough state to:
10
+ * - mint a runId on every `POST /run` / `POST /stream`,
11
+ * - track its lifecycle (`pending` → `running` → `completed` / `failed` / `aborted`),
12
+ * - propagate `AbortController.signal` so handlers can cancel.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+
17
+ import { toWireError } from '../internal/wire-error.js';
18
+
19
+ /**
20
+ * Stable status discriminator for a run snapshot. Mirrors the values
21
+ * exposed on the public REST surface.
22
+ *
23
+ * @stable
24
+ */
25
+ export type RunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'aborted';
26
+
27
+ /**
28
+ * Identifying tag for the underlying execution kind. Workflows run
29
+ * on the durable engine in `@graphorin/workflow`; agents run on the
30
+ * `@graphorin/agent` runtime.
31
+ *
32
+ * @stable
33
+ */
34
+ export type RunKind = 'agent' | 'workflow';
35
+
36
+ /**
37
+ * Terminal status a run can settle into (never `pending` / `running`).
38
+ *
39
+ * @stable
40
+ */
41
+ export type TerminalRunStatus = Extract<RunStatus, 'completed' | 'failed' | 'aborted'>;
42
+
43
+ /**
44
+ * IP-15: payload handed to the {@link RunStateTracker} `onTerminal` callback
45
+ * the first time a run reaches a terminal state. The server turns this into
46
+ * the `graphorin_agent_runs_total` counter + `graphorin_agent_run_duration_seconds`
47
+ * summary. `durationMs` is omitted for a run that was aborted before it ever
48
+ * started (no meaningful wall-clock).
49
+ *
50
+ * @stable
51
+ */
52
+ export interface TerminalRunInfo {
53
+ readonly status: TerminalRunStatus;
54
+ readonly kind: RunKind;
55
+ readonly durationMs?: number;
56
+ }
57
+
58
+ function isTerminalStatus(status: RunStatus): status is TerminalRunStatus {
59
+ return status === 'completed' || status === 'failed' || status === 'aborted';
60
+ }
61
+
62
+ /**
63
+ * Snapshot returned by {@link RunStateTracker.snapshot}.
64
+ *
65
+ * @stable
66
+ */
67
+ export interface RunStateSnapshot {
68
+ readonly runId: string;
69
+ readonly kind: RunKind;
70
+ readonly status: RunStatus;
71
+ readonly startedAt?: number;
72
+ readonly completedAt?: number;
73
+ readonly error?: { readonly message: string; readonly code?: string; readonly hint?: string };
74
+ readonly agentId?: string;
75
+ readonly workflowId?: string;
76
+ readonly threadId?: string;
77
+ readonly sessionId?: string;
78
+ readonly userId?: string;
79
+ }
80
+
81
+ /**
82
+ * Bookkeeping descriptor recorded at run start. Either an agent run
83
+ * (with `agentId`) or a workflow run (with `workflowId` + optional
84
+ * `threadId`).
85
+ *
86
+ * @stable
87
+ */
88
+ export type RunDescriptor =
89
+ | {
90
+ readonly kind: 'agent';
91
+ readonly agentId: string;
92
+ readonly sessionId?: string;
93
+ readonly userId?: string;
94
+ }
95
+ | {
96
+ readonly kind: 'workflow';
97
+ readonly workflowId: string;
98
+ readonly threadId?: string;
99
+ readonly sessionId?: string;
100
+ readonly userId?: string;
101
+ };
102
+
103
+ /**
104
+ * In-flight handle returned by {@link RunStateTracker.start}. Handlers
105
+ * pass `signal` into the underlying `agent.run / workflow.execute`
106
+ * invocation so cancellation propagates instantly.
107
+ *
108
+ * @stable
109
+ */
110
+ export interface RunHandle {
111
+ readonly runId: string;
112
+ readonly signal: AbortSignal;
113
+ cancel(reason?: unknown): void;
114
+ }
115
+
116
+ interface RunRecord {
117
+ readonly runId: string;
118
+ readonly kind: RunKind;
119
+ readonly descriptor: RunDescriptor;
120
+ status: RunStatus;
121
+ controller: AbortController;
122
+ startedAt?: number;
123
+ completedAt?: number;
124
+ error?: { readonly message: string; readonly code?: string; readonly hint?: string };
125
+ }
126
+
127
+ /**
128
+ * Pluggable tracker. The default in-memory implementation is the only
129
+ * one shipped in Phase 14a; future phases plug in a SQLite-backed
130
+ * variant so durable resume survives process restarts.
131
+ *
132
+ * @stable
133
+ */
134
+ export class RunStateTracker {
135
+ readonly #records: Map<string, RunRecord> = new Map();
136
+ readonly #now: () => number;
137
+ readonly #onTerminal: ((info: TerminalRunInfo) => void) | undefined;
138
+
139
+ constructor(
140
+ options: {
141
+ readonly now?: () => number;
142
+ /**
143
+ * IP-15: invoked exactly once per run, the first time it settles into a
144
+ * terminal state. Used to drive the run-count + duration metrics. Never
145
+ * throws into the tracker - wrap your handler if it might.
146
+ */
147
+ readonly onTerminal?: (info: TerminalRunInfo) => void;
148
+ } = {},
149
+ ) {
150
+ this.#now = options.now ?? Date.now;
151
+ this.#onTerminal = options.onTerminal;
152
+ }
153
+
154
+ #emitTerminal(record: RunRecord): void {
155
+ if (this.#onTerminal === undefined) return;
156
+ if (!isTerminalStatus(record.status)) return;
157
+ const durationMs =
158
+ record.startedAt !== undefined && record.completedAt !== undefined
159
+ ? record.completedAt - record.startedAt
160
+ : undefined;
161
+ this.#onTerminal({
162
+ status: record.status,
163
+ kind: record.kind,
164
+ ...(durationMs !== undefined ? { durationMs } : {}),
165
+ });
166
+ }
167
+
168
+ /** Reserve a run id without taking ownership of an AbortSignal. */
169
+ declare(runId: string, descriptor: RunDescriptor): void {
170
+ if (this.#records.has(runId)) return;
171
+ const controller = new AbortController();
172
+ this.#records.set(runId, {
173
+ runId,
174
+ kind: descriptor.kind,
175
+ descriptor,
176
+ status: 'pending',
177
+ controller,
178
+ });
179
+ }
180
+
181
+ /** Promote a previously-declared run to `running` (or declare it). */
182
+ start(runId: string, descriptor: RunDescriptor): RunHandle {
183
+ const existing = this.#records.get(runId);
184
+ const record: RunRecord = existing ?? {
185
+ runId,
186
+ kind: descriptor.kind,
187
+ descriptor,
188
+ status: 'pending',
189
+ controller: new AbortController(),
190
+ };
191
+ record.status = 'running';
192
+ record.startedAt = this.#now();
193
+ this.#records.set(runId, record);
194
+ return Object.freeze({
195
+ runId,
196
+ signal: record.controller.signal,
197
+ cancel: (reason?: unknown) => {
198
+ if (!record.controller.signal.aborted) {
199
+ record.controller.abort(reason);
200
+ }
201
+ },
202
+ });
203
+ }
204
+
205
+ /** Mark a run as terminal. */
206
+ complete(
207
+ runId: string,
208
+ status: Extract<RunStatus, 'completed' | 'failed' | 'aborted'>,
209
+ err?: unknown,
210
+ ): void {
211
+ const record = this.#records.get(runId);
212
+ if (record === undefined) return;
213
+ // IP-15: only the FIRST terminal transition counts toward the metrics -
214
+ // a run aborted then re-completed must not double-increment.
215
+ const wasTerminal = isTerminalStatus(record.status);
216
+ record.status = status;
217
+ record.completedAt = this.#now();
218
+ if (err !== undefined) {
219
+ // W-052: keep the machine-readable code next to the message so
220
+ // the GET run-status surface reports it too.
221
+ record.error = toWireError(err);
222
+ }
223
+ if (!wasTerminal) this.#emitTerminal(record);
224
+ }
225
+
226
+ /** Cancel a run via its `AbortController`. */
227
+ abort(runId: string, reason?: unknown): boolean {
228
+ const record = this.#records.get(runId);
229
+ if (record === undefined) return false;
230
+ if (!record.controller.signal.aborted) {
231
+ record.controller.abort(reason);
232
+ }
233
+ if (record.status === 'pending' || record.status === 'running') {
234
+ record.status = 'aborted';
235
+ record.completedAt = this.#now();
236
+ this.#emitTerminal(record);
237
+ }
238
+ return true;
239
+ }
240
+
241
+ /** Read-only snapshot, safe to JSON.stringify. */
242
+ snapshot(runId: string): RunStateSnapshot | undefined {
243
+ const record = this.#records.get(runId);
244
+ if (record === undefined) return undefined;
245
+ const base: { -readonly [K in keyof RunStateSnapshot]?: RunStateSnapshot[K] } = {
246
+ runId: record.runId,
247
+ kind: record.kind,
248
+ status: record.status,
249
+ };
250
+ if (record.startedAt !== undefined) base.startedAt = record.startedAt;
251
+ if (record.completedAt !== undefined) base.completedAt = record.completedAt;
252
+ if (record.error !== undefined) base.error = record.error;
253
+ if (record.descriptor.kind === 'agent') {
254
+ base.agentId = record.descriptor.agentId;
255
+ } else {
256
+ base.workflowId = record.descriptor.workflowId;
257
+ if (record.descriptor.threadId !== undefined) base.threadId = record.descriptor.threadId;
258
+ }
259
+ if (record.descriptor.sessionId !== undefined) base.sessionId = record.descriptor.sessionId;
260
+ if (record.descriptor.userId !== undefined) base.userId = record.descriptor.userId;
261
+ return Object.freeze(base as RunStateSnapshot);
262
+ }
263
+
264
+ /**
265
+ * Number of runs currently in `pending` or `running`. Useful for
266
+ * snapshots / metrics. Note that `pending` runs hold a reservation
267
+ * but have not yet started any work - see {@link runningCount} for
268
+ * the drain-blocking subset.
269
+ */
270
+ inflightCount(): number {
271
+ let n = 0;
272
+ for (const record of this.#records.values()) {
273
+ if (record.status === 'pending' || record.status === 'running') n += 1;
274
+ }
275
+ return n;
276
+ }
277
+
278
+ /**
279
+ * Number of runs with active work in progress (`running`). The
280
+ * lifecycle drain blocks on this counter only - pending runs are a
281
+ * pure reservation (e.g. an awaited WS subscription) and can be
282
+ * aborted immediately when SIGTERM arrives.
283
+ */
284
+ runningCount(): number {
285
+ let n = 0;
286
+ for (const record of this.#records.values()) {
287
+ if (record.status === 'running') n += 1;
288
+ }
289
+ return n;
290
+ }
291
+
292
+ /**
293
+ * Drop every reserved-but-not-yet-started run. Called by the
294
+ * server lifecycle at the start of `stop()` so the drain only
295
+ * waits for actual work in flight.
296
+ */
297
+ abortPending(reason?: unknown): number {
298
+ let aborted = 0;
299
+ for (const record of this.#records.values()) {
300
+ if (record.status === 'pending') {
301
+ if (!record.controller.signal.aborted) record.controller.abort(reason);
302
+ record.status = 'aborted';
303
+ record.completedAt = this.#now();
304
+ this.#emitTerminal(record);
305
+ aborted += 1;
306
+ }
307
+ }
308
+ return aborted;
309
+ }
310
+
311
+ /** Drop terminal records older than `olderThan`. */
312
+ prune(olderThan: number): number {
313
+ let removed = 0;
314
+ for (const [runId, record] of this.#records) {
315
+ if (
316
+ record.completedAt !== undefined &&
317
+ record.completedAt <= olderThan &&
318
+ record.status !== 'pending' &&
319
+ record.status !== 'running'
320
+ ) {
321
+ this.#records.delete(runId);
322
+ removed += 1;
323
+ }
324
+ }
325
+ return removed;
326
+ }
327
+
328
+ /** Cancel every in-flight run. Used during graceful shutdown. */
329
+ abortAll(reason?: unknown): number {
330
+ let aborted = 0;
331
+ for (const [runId] of this.#records) {
332
+ if (this.abort(runId, reason)) aborted += 1;
333
+ }
334
+ return aborted;
335
+ }
336
+ }
337
+
338
+ /** Default cadence for the terminal-record prune sweep (IP-16). */
339
+ export const DEFAULT_RUN_PRUNE_INTERVAL_MS = 60_000;
340
+ /** Default retention for terminal run records before they are dropped. */
341
+ export const DEFAULT_RUN_RETENTION_MS = 5 * 60_000;
342
+
343
+ /**
344
+ * IP-16: schedule a periodic prune of terminal run records. Without this the
345
+ * tracker's `prune()` was never called, so every run / stream / workflow left
346
+ * a `RunRecord` (each holding an `AbortController`) in memory forever - an
347
+ * unbounded leak on a long-living server. Returns a stop function that clears
348
+ * the timer; the timer is `unref`-ed so it never keeps the process alive.
349
+ */
350
+ export function scheduleRunPruning(
351
+ runs: RunStateTracker,
352
+ now: () => number,
353
+ opts: { readonly intervalMs?: number; readonly retentionMs?: number } = {},
354
+ ): () => void {
355
+ const intervalMs = opts.intervalMs ?? DEFAULT_RUN_PRUNE_INTERVAL_MS;
356
+ const retentionMs = opts.retentionMs ?? DEFAULT_RUN_RETENTION_MS;
357
+ const timer = setInterval(() => {
358
+ runs.prune(now() - retentionMs);
359
+ }, intervalMs);
360
+ if (typeof (timer as { unref?: () => void }).unref === 'function') {
361
+ (timer as { unref: () => void }).unref();
362
+ }
363
+ return () => clearInterval(timer);
364
+ }
365
+
366
+ /** Default cadence for the idempotency-record sweep (W-061). */
367
+ export const DEFAULT_IDEMPOTENCY_PRUNE_INTERVAL_MS = 60 * 60_000;
368
+
369
+ /**
370
+ * W-061: schedule a periodic prune of EXPIRED idempotency records.
371
+ * `idempotency_records` stores each keyed POST's full `response_json`
372
+ * with an `expires_at` column, but expiry was only ever checked on the
373
+ * READ path - the bodies accumulated on disk indefinitely. The sweep
374
+ * passes `now()` as the cutoff, deleting exactly the records the read
375
+ * path already refuses to replay (IETF-draft semantics unchanged).
376
+ * Best-effort: store errors are swallowed. Same shape as
377
+ * {@link scheduleRunPruning}: `unref`-ed timer + stop function.
378
+ *
379
+ * Since W-010 the standalone server drives this surface through the
380
+ * unified retention scheduler (`config.retention.idempotency`); this
381
+ * standalone primitive remains for embedders running the store
382
+ * without the server.
383
+ */
384
+ export function scheduleIdempotencyPruning(
385
+ store: { prune(olderThan: number): Promise<number> },
386
+ now: () => number,
387
+ opts: { readonly intervalMs?: number } = {},
388
+ ): () => void {
389
+ const intervalMs = opts.intervalMs ?? DEFAULT_IDEMPOTENCY_PRUNE_INTERVAL_MS;
390
+ const timer = setInterval(() => {
391
+ void store.prune(now()).catch(() => {});
392
+ }, intervalMs);
393
+ if (typeof (timer as { unref?: () => void }).unref === 'function') {
394
+ (timer as { unref: () => void }).unref();
395
+ }
396
+ return () => clearInterval(timer);
397
+ }
398
+
399
+ /**
400
+ * W-107: the per-resource scope a caller must hold to touch this run.
401
+ * `'read'` gates state inspection; `'control'` gates abort/resume.
402
+ * Derived from the run descriptor: agent runs bind to
403
+ * `agents:{read|invoke}:<agentId>`, workflow runs to
404
+ * `workflows:{read|execute}:<workflowId>`. Runs without a descriptor id
405
+ * (not produced by the current trackers) fall back to the bare scope.
406
+ */
407
+ export function requiredRunScope(
408
+ snapshot: Pick<RunStateSnapshot, 'agentId' | 'workflowId'>,
409
+ action: 'read' | 'control',
410
+ ): string {
411
+ if (snapshot.workflowId !== undefined) {
412
+ return action === 'read'
413
+ ? `workflows:read:${snapshot.workflowId}`
414
+ : `workflows:execute:${snapshot.workflowId}`;
415
+ }
416
+ if (snapshot.agentId !== undefined) {
417
+ return action === 'read'
418
+ ? `agents:read:${snapshot.agentId}`
419
+ : `agents:invoke:${snapshot.agentId}`;
420
+ }
421
+ return action === 'read' ? 'agents:read' : 'agents:invoke';
422
+ }