@graphorin/server 0.6.1 → 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.
- package/CHANGELOG.md +71 -0
- package/README.md +3 -3
- package/dist/app-audit-binding.d.ts +15 -0
- package/dist/app-audit-binding.d.ts.map +1 -0
- package/dist/app-audit-binding.js +136 -0
- package/dist/app-audit-binding.js.map +1 -0
- package/dist/app-daemons.d.ts +29 -0
- package/dist/app-daemons.d.ts.map +1 -0
- package/dist/app-daemons.js +27 -0
- package/dist/app-daemons.js.map +1 -0
- package/dist/app-lifecycle.js +272 -0
- package/dist/app-lifecycle.js.map +1 -0
- package/dist/app-metrics.js +79 -0
- package/dist/app-metrics.js.map +1 -0
- package/dist/app-middleware.js +92 -0
- package/dist/app-middleware.js.map +1 -0
- package/dist/app-routes.js +131 -0
- package/dist/app-routes.js.map +1 -0
- package/dist/app-ws.js +65 -0
- package/dist/app-ws.js.map +1 -0
- package/dist/app.d.ts +16 -16
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +35 -607
- package/dist/app.js.map +1 -1
- package/dist/commentary/built-in-patterns.d.ts +1 -1
- package/dist/commentary/built-in-patterns.js +1 -1
- package/dist/commentary/built-in-patterns.js.map +1 -1
- package/dist/config.d.ts +100 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +30 -2
- package/dist/config.js.map +1 -1
- package/dist/health/checks.d.ts +15 -1
- package/dist/health/checks.d.ts.map +1 -1
- package/dist/health/checks.js +21 -0
- package/dist/health/checks.js.map +1 -1
- package/dist/health/routes.js +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -24
- package/dist/index.js.map +1 -1
- package/dist/internal/context.d.ts +2 -2
- package/dist/internal/wire-error.js +20 -0
- package/dist/internal/wire-error.js.map +1 -0
- package/dist/lifecycle/pre-bind.d.ts +1 -1
- package/dist/metrics/catalog.d.ts.map +1 -1
- package/dist/metrics/catalog.js.map +1 -1
- package/dist/metrics/tools-bridge.d.ts +15 -0
- package/dist/metrics/tools-bridge.d.ts.map +1 -0
- package/dist/metrics/tools-bridge.js +103 -0
- package/dist/metrics/tools-bridge.js.map +1 -0
- package/dist/middleware/audit.d.ts +1 -1
- package/dist/middleware/auth.js +1 -1
- package/dist/middleware/csrf.js +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/scope.d.ts.map +1 -1
- package/dist/middleware/scope.js +44 -4
- package/dist/middleware/scope.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/dist/registry/index.d.ts +33 -0
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js.map +1 -1
- package/dist/replay/routes.d.ts +1 -1
- package/dist/replay/routes.js +2 -2
- package/dist/routes/agents.d.ts.map +1 -1
- package/dist/routes/agents.js +64 -18
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/auth.js +2 -2
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/sessions.js +5 -5
- package/dist/routes/sessions.js.map +1 -1
- package/dist/routes/tokens.d.ts +1 -1
- package/dist/routes/tokens.d.ts.map +1 -1
- package/dist/routes/tokens.js +21 -1
- package/dist/routes/tokens.js.map +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/routes/workflows.js +165 -19
- package/dist/routes/workflows.js.map +1 -1
- package/dist/runtime/retention.d.ts +105 -0
- package/dist/runtime/retention.d.ts.map +1 -0
- package/dist/runtime/retention.js +154 -0
- package/dist/runtime/retention.js.map +1 -0
- package/dist/runtime/run-state.d.ts +2 -0
- package/dist/runtime/run-state.d.ts.map +1 -1
- package/dist/runtime/run-state.js +34 -2
- package/dist/runtime/run-state.js.map +1 -1
- package/dist/sse/events.js +3 -4
- package/dist/sse/events.js.map +1 -1
- package/dist/tools-audit-bridge.d.ts +29 -0
- package/dist/tools-audit-bridge.d.ts.map +1 -0
- package/dist/tools-audit-bridge.js +103 -0
- package/dist/tools-audit-bridge.js.map +1 -0
- package/dist/workflows/timer-daemon.d.ts +69 -0
- package/dist/workflows/timer-daemon.d.ts.map +1 -0
- package/dist/workflows/timer-daemon.js +37 -0
- package/dist/workflows/timer-daemon.js.map +1 -0
- package/dist/ws/dispatcher.d.ts +1 -1
- package/dist/ws/dispatcher.js +19 -12
- package/dist/ws/dispatcher.js.map +1 -1
- package/dist/ws/index.d.ts +2 -2
- package/dist/ws/index.js +3 -3
- package/dist/ws/replay-buffer.d.ts +35 -1
- package/dist/ws/replay-buffer.d.ts.map +1 -1
- package/dist/ws/replay-buffer.js +35 -3
- package/dist/ws/replay-buffer.js.map +1 -1
- package/dist/ws/upgrade.d.ts.map +1 -1
- package/dist/ws/upgrade.js +24 -18
- package/dist/ws/upgrade.js.map +1 -1
- package/package.json +30 -29
- package/src/app-audit-binding.ts +227 -0
- package/src/app-daemons.ts +73 -0
- package/src/app-lifecycle.ts +476 -0
- package/src/app-metrics.ts +144 -0
- package/src/app-middleware.ts +149 -0
- package/src/app-routes.ts +305 -0
- package/src/app-ws.ts +111 -0
- package/src/app.ts +317 -0
- package/src/commentary/audit-bridge.ts +135 -0
- package/src/commentary/built-in-patterns.ts +79 -0
- package/src/commentary/index.ts +32 -0
- package/src/commentary/sanitizer.ts +238 -0
- package/src/commentary/types.ts +130 -0
- package/src/config.ts +472 -0
- package/src/consolidator/daemon.ts +141 -0
- package/src/consolidator/index.ts +14 -0
- package/src/errors/index.ts +247 -0
- package/src/health/checks.ts +322 -0
- package/src/health/index.ts +34 -0
- package/src/health/routes.ts +154 -0
- package/src/index.ts +243 -0
- package/src/internal/context.ts +77 -0
- package/src/internal/ids.ts +17 -0
- package/src/internal/json.ts +47 -0
- package/src/internal/wire-error.ts +44 -0
- package/src/lifecycle/hooks.ts +66 -0
- package/src/lifecycle/index.ts +16 -0
- package/src/lifecycle/pre-bind.ts +138 -0
- package/src/metrics/catalog.ts +112 -0
- package/src/metrics/index.ts +12 -0
- package/src/metrics/registry.ts +337 -0
- package/src/metrics/tools-bridge.ts +124 -0
- package/src/middleware/audit.ts +114 -0
- package/src/middleware/auth.ts +195 -0
- package/src/middleware/cors.ts +72 -0
- package/src/middleware/csrf.ts +98 -0
- package/src/middleware/idempotency.ts +336 -0
- package/src/middleware/index.ts +38 -0
- package/src/middleware/rate-limit.ts +71 -0
- package/src/middleware/request-state.ts +79 -0
- package/src/middleware/scope.ts +161 -0
- package/src/registry/index.ts +278 -0
- package/src/replay/index.ts +14 -0
- package/src/replay/routes.ts +255 -0
- package/src/routes/agents.ts +363 -0
- package/src/routes/audit.ts +207 -0
- package/src/routes/auth.ts +80 -0
- package/src/routes/health.ts +42 -0
- package/src/routes/index.ts +16 -0
- package/src/routes/mcp.ts +97 -0
- package/src/routes/memory.ts +152 -0
- package/src/routes/sessions.ts +250 -0
- package/src/routes/skills.ts +91 -0
- package/src/routes/tokens.ts +166 -0
- package/src/routes/workflows.ts +616 -0
- package/src/runtime/retention.ts +284 -0
- package/src/runtime/run-state.ts +422 -0
- package/src/sse/events.ts +303 -0
- package/src/sse/index.ts +7 -0
- package/src/tools-audit-bridge.ts +120 -0
- package/src/triggers/daemon.ts +198 -0
- package/src/triggers/index.ts +16 -0
- package/src/triggers/routes.ts +139 -0
- package/src/workflows/timer-daemon.ts +102 -0
- package/src/ws/dispatcher.ts +537 -0
- package/src/ws/index.ts +45 -0
- package/src/ws/replay-buffer.ts +209 -0
- package/src/ws/subjects.ts +162 -0
- package/src/ws/ticket.ts +174 -0
- package/src/ws/upgrade.ts +507 -0
- package/dist/lifecycle/index.js +0 -3
- 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
|
+
}
|