aiden-runtime 4.8.1 → 4.9.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 (93) hide show
  1. package/README.md +88 -1
  2. package/dist/cli/v4/aidenCLI.js +35 -4
  3. package/dist/cli/v4/chatSession.js +34 -9
  4. package/dist/cli/v4/commands/daemon.js +47 -2
  5. package/dist/cli/v4/commands/daemonDoctor.js +212 -0
  6. package/dist/cli/v4/commands/daemonStatus.js +1 -1
  7. package/dist/cli/v4/commands/help.js +2 -0
  8. package/dist/cli/v4/commands/hooks.js +428 -0
  9. package/dist/cli/v4/commands/index.js +5 -1
  10. package/dist/cli/v4/commands/mcp.js +89 -1
  11. package/dist/cli/v4/commands/mcpClientInstall.js +359 -0
  12. package/dist/cli/v4/commands/memory.js +702 -0
  13. package/dist/cli/v4/commands/recovery.js +1 -1
  14. package/dist/cli/v4/commands/skin.js +7 -0
  15. package/dist/cli/v4/commands/theme.js +217 -0
  16. package/dist/cli/v4/commands/trigger.js +1 -1
  17. package/dist/cli/v4/design/tokens.js +52 -4
  18. package/dist/cli/v4/display.js +39 -26
  19. package/dist/cli/v4/replyRenderer.js +6 -5
  20. package/dist/cli/v4/skinEngine.js +67 -0
  21. package/dist/core/v4/aidenAgent.js +45 -2
  22. package/dist/core/v4/daemon/api/runs.js +131 -0
  23. package/dist/core/v4/daemon/bootstrap.js +368 -13
  24. package/dist/core/v4/daemon/db/migrations.js +169 -0
  25. package/dist/core/v4/daemon/idempotency/runIdempotencyStore.js +128 -0
  26. package/dist/core/v4/daemon/incarnationStore.js +47 -0
  27. package/dist/core/v4/daemon/runs/attemptStore.js +67 -0
  28. package/dist/core/v4/daemon/runs/reclaim.js +88 -0
  29. package/dist/core/v4/daemon/runs/retryPolicy.js +73 -0
  30. package/dist/core/v4/daemon/runs/runWithRetry.js +80 -0
  31. package/dist/core/v4/daemon/runs/stuckAttemptWatchdog.js +72 -0
  32. package/dist/core/v4/daemon/spans/spanHelpers.js +272 -0
  33. package/dist/core/v4/daemon/spans/spanStore.js +113 -0
  34. package/dist/core/v4/daemon/triggerBus.js +50 -19
  35. package/dist/core/v4/hooks/auditQuery.js +67 -0
  36. package/dist/core/v4/hooks/dispatcher.js +286 -0
  37. package/dist/core/v4/hooks/index.js +46 -0
  38. package/dist/core/v4/hooks/lifecycle.js +27 -0
  39. package/dist/core/v4/hooks/manifest.js +142 -0
  40. package/dist/core/v4/hooks/registry.js +149 -0
  41. package/dist/core/v4/hooks/runtime/subprocessRunner.js +188 -0
  42. package/dist/core/v4/hooks/toolHookGate.js +76 -0
  43. package/dist/core/v4/hooks/trust.js +14 -0
  44. package/dist/core/v4/identity/contextManager.js +83 -0
  45. package/dist/core/v4/identity/daemonId.js +85 -0
  46. package/dist/core/v4/identity/enforcement.js +103 -0
  47. package/dist/core/v4/identity/executionContext.js +153 -0
  48. package/dist/core/v4/identity/hookExecution.js +62 -0
  49. package/dist/core/v4/identity/httpContext.js +68 -0
  50. package/dist/core/v4/identity/ids.js +185 -0
  51. package/dist/core/v4/identity/index.js +60 -0
  52. package/dist/core/v4/identity/subprocessContext.js +98 -0
  53. package/dist/core/v4/identity/traceparent.js +114 -0
  54. package/dist/core/v4/logger/index.js +3 -1
  55. package/dist/core/v4/logger/logger.js +28 -1
  56. package/dist/core/v4/logger/redact.js +149 -0
  57. package/dist/core/v4/logger/sinks/fileSink.js +13 -0
  58. package/dist/core/v4/logger/sinks/stdSink.js +19 -1
  59. package/dist/core/v4/mcp/install/backup.js +78 -0
  60. package/dist/core/v4/mcp/install/clientPaths.js +90 -0
  61. package/dist/core/v4/mcp/install/clients.js +203 -0
  62. package/dist/core/v4/mcp/install/healthCheck.js +83 -0
  63. package/dist/core/v4/mcp/install/jsoncMerge.js +174 -0
  64. package/dist/core/v4/mcp/install/profiles.js +109 -0
  65. package/dist/core/v4/mcp/install/wslDetect.js +62 -0
  66. package/dist/core/v4/memory/namespaceRegistry.js +117 -0
  67. package/dist/core/v4/memory/projectRoot.js +76 -0
  68. package/dist/core/v4/memory/reviewer/index.js +162 -0
  69. package/dist/core/v4/memory/reviewer/pendingStore.js +136 -0
  70. package/dist/core/v4/memory/reviewer/prompt.js +105 -0
  71. package/dist/core/v4/memory/reviewer/skipRules.js +92 -0
  72. package/dist/core/v4/memoryManager.js +57 -10
  73. package/dist/core/v4/paths.js +2 -0
  74. package/dist/core/v4/subagent/spawnSubAgent.js +20 -7
  75. package/dist/core/v4/theme/bundledThemes.js +106 -0
  76. package/dist/core/v4/theme/themeLoader.js +160 -0
  77. package/dist/core/v4/theme/themeRegistry.js +97 -0
  78. package/dist/core/v4/theme/themeWatcher.js +95 -0
  79. package/dist/core/v4/toolRegistry.js +71 -8
  80. package/dist/moat/approvalEngine.js +4 -0
  81. package/dist/moat/memoryGuard.js +8 -1
  82. package/dist/providers/v4/anthropicAdapter.js +10 -4
  83. package/dist/tools/v4/backends/local.js +19 -2
  84. package/dist/tools/v4/sessions/recallSession.js +6 -1
  85. package/package.json +3 -1
  86. package/plugins/aiden-plugin-chatgpt-plus/index.js +10 -1
  87. package/themes/default.yaml +52 -0
  88. package/themes/dracula.yaml +32 -0
  89. package/themes/light.yaml +32 -0
  90. package/themes/monochrome.yaml +31 -0
  91. package/themes/tokyo-night.yaml +32 -0
  92. package/dist/core/pluginSystem.js +0 -121
  93. package/dist/tools/v4/ui/_uiSmokeTool.js +0 -60
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/daemon/runs/stuckAttemptWatchdog.ts — v4.9.0 Slice 8.
10
+ *
11
+ * Sweeps `run_attempts` (and `spans`) that are stuck `running` from
12
+ * a previous incarnation past `STUCK_THRESHOLD_MS`. Marks them
13
+ * `crashed` so post-mortem queries get one consistent shape rather
14
+ * than mixing "in-flight" with "abandoned".
15
+ *
16
+ * Wired as a `setInterval` ticker from bootstrap. Cadence default 5
17
+ * min, configurable via `AIDEN_STUCK_ATTEMPT_CHECK_MS`. Threshold 30
18
+ * min default, configurable via `AIDEN_STUCK_ATTEMPT_THRESHOLD_MS`.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.sweepStuckAttempts = sweepStuckAttempts;
22
+ const DEFAULT_THRESHOLD_MS = 30 * 60 * 1000;
23
+ /**
24
+ * Run a single sweep pass. Returns the count + ids of rows touched.
25
+ * Idempotent — calling twice in a row sweeps zero on the second call.
26
+ */
27
+ function sweepStuckAttempts(db, opts) {
28
+ const now = (opts.now ?? Date.now)();
29
+ const thresholdMs = opts.thresholdMs ?? DEFAULT_THRESHOLD_MS;
30
+ const cutoffIso = new Date(now - thresholdMs).toISOString();
31
+ const endedAtIso = new Date(now).toISOString();
32
+ // ── attempts ────────────────────────────────────────────────────────────
33
+ const attemptRows = db.prepare(`SELECT attempt_id FROM run_attempts
34
+ WHERE status = 'running'
35
+ AND incarnation_id != ?
36
+ AND started_at < ?`).all(opts.currentIncarnationId, cutoffIso);
37
+ const attemptIds = attemptRows.map((r) => r.attempt_id);
38
+ if (attemptIds.length > 0) {
39
+ db.prepare(`UPDATE run_attempts
40
+ SET status = 'crashed',
41
+ ended_at = COALESCE(ended_at, ?),
42
+ finish_reason = COALESCE(finish_reason, 'stuck_attempt_swept')
43
+ WHERE status = 'running'
44
+ AND incarnation_id != ?
45
+ AND started_at < ?`).run(endedAtIso, opts.currentIncarnationId, cutoffIso);
46
+ }
47
+ // ── spans ──────────────────────────────────────────────────────────────
48
+ // Open spans (status NULL = in-flight) from a non-current incarnation.
49
+ // We don't apply the threshold here — any open span owned by a dead
50
+ // incarnation is by definition stuck; the parent process is gone.
51
+ const spanRows = db.prepare(`SELECT span_id FROM spans
52
+ WHERE status IS NULL
53
+ AND ended_at IS NULL
54
+ AND incarnation_id != ?`).all(opts.currentIncarnationId);
55
+ const spanIds = spanRows.map((r) => r.span_id);
56
+ if (spanIds.length > 0) {
57
+ db.prepare(`UPDATE spans
58
+ SET status = 'cancelled',
59
+ ended_at = COALESCE(ended_at, ?),
60
+ error_class = COALESCE(error_class, 'OrphanedSpan'),
61
+ error_message = COALESCE(error_message, 'incarnation died with span open')
62
+ WHERE status IS NULL
63
+ AND ended_at IS NULL
64
+ AND incarnation_id != ?`).run(endedAtIso, opts.currentIncarnationId);
65
+ }
66
+ return {
67
+ reclaimedAttempts: attemptIds.length,
68
+ reclaimedSpans: spanIds.length,
69
+ attemptIds,
70
+ spanIds,
71
+ };
72
+ }
@@ -0,0 +1,272 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/daemon/spans/spanHelpers.ts — v4.9.0 Slice 5.
10
+ *
11
+ * `withSpan` is the ergonomic primitive every Slice 6+ caller will use
12
+ * to instrument a unit of work: tool dispatch, hook firing, LLM call,
13
+ * subagent fanout, etc. Each call:
14
+ *
15
+ * 1. Forks a child `ExecutionContext` via `childSpan(parent)` so the
16
+ * new `span_id` is set and `parent_span_id` chains to the parent.
17
+ * 2. Opens a row in `spans` via `openSpan(db, ...)`.
18
+ * 3. Calls `fn(childCtx)` inside a `runWithContext(childCtx, ...)`
19
+ * frame so any nested `withSpan` or `requireContext()` reaches
20
+ * the new ambient context.
21
+ * 4. On success: closes the span with `status='ok'`.
22
+ * 5. On thrown error: closes with `status='error'` + `error_class` +
23
+ * `error_message`, then rethrows so the caller's catch chain
24
+ * still runs.
25
+ *
26
+ * If the caller has no ambient context, `withSpan` falls back to
27
+ * "no-op the span" — logs a warning via the supplied logger callback
28
+ * but still runs `fn` with whatever ctx (or undefined) the caller had.
29
+ * This matches the project rule "no instrumentation primitive throws
30
+ * because context is missing" — Slice 5 must not turn a missing-context
31
+ * scenario into a 500.
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.shortInputFingerprint = shortInputFingerprint;
35
+ exports.withSpan = withSpan;
36
+ exports.withToolSpan = withToolSpan;
37
+ exports.withLlmSpan = withLlmSpan;
38
+ exports.runHookWithSpan = runHookWithSpan;
39
+ const node_crypto_1 = require("node:crypto");
40
+ const identity_1 = require("../../identity");
41
+ const spanStore_1 = require("./spanStore");
42
+ /** Stable input fingerprint — sha256 hex, first 16 chars. */
43
+ function shortInputFingerprint(args) {
44
+ const canon = canonicaliseForHash(args);
45
+ return (0, node_crypto_1.createHash)('sha256').update(JSON.stringify(canon)).digest('hex').slice(0, 16);
46
+ }
47
+ function canonicaliseForHash(v) {
48
+ if (v === null || v === undefined)
49
+ return null;
50
+ if (Array.isArray(v))
51
+ return v.map(canonicaliseForHash);
52
+ if (typeof v === 'object') {
53
+ const out = {};
54
+ for (const k of Object.keys(v).sort()) {
55
+ out[k] = canonicaliseForHash(v[k]);
56
+ }
57
+ return out;
58
+ }
59
+ return v;
60
+ }
61
+ // v4.9.0 Slice 8 — map SpanKind → EnforcementKind so the enforcement
62
+ // layer's per-kind config buckets line up with the span taxonomy.
63
+ function spanKindToEnforcementKind(k) {
64
+ switch (k) {
65
+ case 'llm': return 'llm';
66
+ case 'tool': return 'tool';
67
+ case 'hook': return 'hook';
68
+ case 'memory': return 'memory_write';
69
+ case 'http': return 'http_outbound';
70
+ case 'subprocess': return 'subprocess';
71
+ case 'subagent':
72
+ case 'other':
73
+ default: return 'tool';
74
+ }
75
+ }
76
+ /**
77
+ * Run `fn` inside an instrumented child span. See file header for the
78
+ * detailed semantics; the short version is: opens a span, runs the fn
79
+ * with that ctx installed, closes the span, rethrows on error.
80
+ */
81
+ async function withSpan(db, opts, fn) {
82
+ const parent = (0, identity_1.currentContext)();
83
+ if (!parent) {
84
+ // v4.9.0 Slice 8 — enforcement (counter + strict-throw) is
85
+ // additive to the original warn contract. Pass `warn: undefined`
86
+ // so the enforcement layer doesn't double-log; spanHelpers owns
87
+ // the opts.warn invocation below.
88
+ (0, identity_1.reportMissingContext)(spanKindToEnforcementKind(opts.kind), `${opts.kind}/${opts.name}`);
89
+ if (opts.warn) {
90
+ try {
91
+ opts.warn(`[span] withSpan(${opts.kind}/${opts.name}) — no ambient context, dropping span`);
92
+ }
93
+ catch { /* logger may not be wired yet — ignore */ }
94
+ }
95
+ // Build a minimal stand-in ctx so the inner fn still gets a value
96
+ // shaped like ExecutionContext (callers may read `.runId` etc.).
97
+ return fn({
98
+ daemonId: '',
99
+ incarnationId: '',
100
+ runId: '',
101
+ traceId: '',
102
+ spanId: '',
103
+ source: 'unknown',
104
+ attempt: 0,
105
+ });
106
+ }
107
+ const child = (0, identity_1.childSpan)(parent);
108
+ (0, spanStore_1.openSpan)(db, {
109
+ ctx: child,
110
+ kind: opts.kind,
111
+ name: opts.name,
112
+ attrs: opts.attrs,
113
+ runId: opts.runId,
114
+ attemptId: opts.attemptId,
115
+ });
116
+ try {
117
+ const out = await (0, identity_1.runWithContext)(child, () => fn(child));
118
+ (0, spanStore_1.closeSpan)(db, { spanId: child.spanId, status: 'ok' });
119
+ return out;
120
+ }
121
+ catch (err) {
122
+ const eClass = err instanceof Error ? err.name : 'NonError';
123
+ const eMsg = err instanceof Error ? err.message : String(err);
124
+ (0, spanStore_1.closeSpan)(db, {
125
+ spanId: child.spanId,
126
+ status: 'error',
127
+ errorClass: eClass,
128
+ errorMessage: eMsg,
129
+ });
130
+ throw err;
131
+ }
132
+ }
133
+ /**
134
+ * Wrap a tool execution. The span carries `kind:'tool'`, `name:<toolName>`,
135
+ * and attrs `{ input_fingerprint, side_effect_class, attempt_number }`.
136
+ * If no ambient ExecutionContext is active, this no-ops the span
137
+ * (project rule: instrumentation primitives never throw because
138
+ * context is missing) and still runs `fn`.
139
+ */
140
+ async function withToolSpan(db, opts, fn) {
141
+ return withSpan(db, {
142
+ kind: 'tool',
143
+ name: opts.toolName,
144
+ attrs: {
145
+ input_fingerprint: opts.inputFingerprint,
146
+ side_effect_class: opts.sideEffectClass,
147
+ attempt_number: opts.attemptNumber ?? 1,
148
+ },
149
+ runId: opts.runId,
150
+ attemptId: opts.attemptId,
151
+ warn: opts.warn,
152
+ }, fn);
153
+ }
154
+ /**
155
+ * Wrap an LLM provider call. The span carries `kind:'llm'`,
156
+ * `name:<model>`. Tokens / finish-reason / cost are unknown at
157
+ * open-time, so the `fn` receives a `patchAttrs(attrs)` callback to
158
+ * back-fill them after the response lands. The patch is applied via
159
+ * `closeSpan({attrsPatch})` on the success path.
160
+ */
161
+ async function withLlmSpan(db, opts, fn) {
162
+ const parent = (0, identity_1.currentContext)();
163
+ if (!parent) {
164
+ // v4.9.0 Slice 8 — enforcement (counter + strict-throw).
165
+ (0, identity_1.reportMissingContext)('llm', `llm/${opts.model}`);
166
+ if (opts.warn) {
167
+ try {
168
+ opts.warn(`[span] withLlmSpan(${opts.model}) — no ambient context, dropping span`);
169
+ }
170
+ catch { /* noop */ }
171
+ }
172
+ // Stand-in ctx, no-op patch.
173
+ return fn({ daemonId: '', incarnationId: '', runId: '', traceId: '', spanId: '',
174
+ source: 'unknown', attempt: 0 }, () => { });
175
+ }
176
+ const child = (0, identity_1.childSpan)(parent);
177
+ const initialAttrs = {
178
+ model: opts.model,
179
+ provider: opts.provider,
180
+ };
181
+ (0, spanStore_1.openSpan)(db, {
182
+ ctx: child,
183
+ kind: 'llm',
184
+ name: opts.model,
185
+ attrs: initialAttrs,
186
+ runId: opts.runId,
187
+ attemptId: opts.attemptId,
188
+ });
189
+ let patched = null;
190
+ const patchAttrs = (attrs) => {
191
+ patched = { ...(patched ?? {}), ...attrs };
192
+ };
193
+ try {
194
+ const out = await (0, identity_1.runWithContext)(child, () => fn(child, patchAttrs));
195
+ (0, spanStore_1.closeSpan)(db, {
196
+ spanId: child.spanId,
197
+ status: 'ok',
198
+ attrsPatch: patched ?? undefined,
199
+ });
200
+ return out;
201
+ }
202
+ catch (err) {
203
+ const eClass = err instanceof Error ? err.name : 'NonError';
204
+ const eMsg = err instanceof Error ? err.message : String(err);
205
+ (0, spanStore_1.closeSpan)(db, {
206
+ spanId: child.spanId,
207
+ status: 'error',
208
+ errorClass: eClass,
209
+ errorMessage: eMsg,
210
+ attrsPatch: patched ?? undefined,
211
+ });
212
+ throw err;
213
+ }
214
+ }
215
+ /**
216
+ * Wrap a hook execution with a 5s timeout (configurable). The hook
217
+ * system isn't wired yet (Slice 9 work), but this helper is the seam
218
+ * Slice 9 will call from. Returns `null` on timeout, error, or
219
+ * missing-context degraded path so callers can safely continue.
220
+ */
221
+ async function runHookWithSpan(db, opts, fn) {
222
+ const timeoutMs = opts.timeoutMs ?? 5000;
223
+ const parent = (0, identity_1.currentContext)();
224
+ if (!parent) {
225
+ // v4.9.0 Slice 8 — enforcement (counter + strict-throw).
226
+ (0, identity_1.reportMissingContext)('hook', `hook/${opts.hookName}`);
227
+ if (opts.warn) {
228
+ try {
229
+ opts.warn(`[span] runHookWithSpan(${opts.hookName}) — no ambient context, dropping span`);
230
+ }
231
+ catch { /* noop */ }
232
+ }
233
+ try {
234
+ return await fn({ daemonId: '', incarnationId: '', runId: '', traceId: '', spanId: '', source: 'unknown', attempt: 0 });
235
+ }
236
+ catch {
237
+ return null;
238
+ }
239
+ }
240
+ const child = (0, identity_1.childSpan)(parent);
241
+ (0, spanStore_1.openSpan)(db, { ctx: child, kind: 'hook', name: opts.hookName });
242
+ let timer = null;
243
+ try {
244
+ const timeoutPromise = new Promise((_resolve, reject) => {
245
+ timer = setTimeout(() => reject(new HookTimeoutError(opts.hookName, timeoutMs)), timeoutMs);
246
+ });
247
+ const work = (0, identity_1.runWithContext)(child, () => fn(child));
248
+ const out = await Promise.race([work, timeoutPromise]);
249
+ if (timer)
250
+ clearTimeout(timer);
251
+ (0, spanStore_1.closeSpan)(db, { spanId: child.spanId, status: 'ok' });
252
+ return out;
253
+ }
254
+ catch (err) {
255
+ if (timer)
256
+ clearTimeout(timer);
257
+ const isTimeout = err instanceof HookTimeoutError;
258
+ (0, spanStore_1.closeSpan)(db, {
259
+ spanId: child.spanId,
260
+ status: 'error',
261
+ errorClass: isTimeout ? 'HookTimeout' : (err instanceof Error ? err.name : 'NonError'),
262
+ errorMessage: err instanceof Error ? err.message : String(err),
263
+ });
264
+ return null;
265
+ }
266
+ }
267
+ class HookTimeoutError extends Error {
268
+ constructor(hookName, ms) {
269
+ super(`hook '${hookName}' timed out after ${ms}ms`);
270
+ this.name = 'HookTimeout';
271
+ }
272
+ }
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/daemon/spans/spanStore.ts — v4.9.0 Slice 5.
10
+ *
11
+ * Persists the span tree used by `withSpan(...)` and `aiden doctor`-style
12
+ * trace inspection. Spans flow from ExecutionContext (Slice 4) — the
13
+ * caller supplies `trace_id`, `span_id`, `parent_span_id`, `run_id?`,
14
+ * `attempt_id?`, `incarnation_id` via the context object. Slice 5 lands
15
+ * schema + writers; the tool-dispatcher + hook integration is Slice 6+.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.openSpan = openSpan;
19
+ exports.closeSpan = closeSpan;
20
+ exports.getSpan = getSpan;
21
+ exports.getTraceTree = getTraceTree;
22
+ function safeStringify(v) {
23
+ if (!v || Object.keys(v).length === 0)
24
+ return null;
25
+ try {
26
+ return JSON.stringify(v);
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ function safeParse(s) {
33
+ if (!s)
34
+ return {};
35
+ try {
36
+ return JSON.parse(s);
37
+ }
38
+ catch {
39
+ return {};
40
+ }
41
+ }
42
+ /**
43
+ * Insert a new span row in `started` state. The span_id is taken from
44
+ * `ctx.spanId` (set by `childSpan` upstream); the caller is responsible
45
+ * for forking the spanId before calling — `withSpan` does this. The
46
+ * parent_span_id is taken from `ctx.parentSpanId`, also set by
47
+ * `childSpan`. Returns the span_id for convenience.
48
+ */
49
+ function openSpan(db, opts) {
50
+ const startedAt = opts.startedAt ?? new Date().toISOString();
51
+ db.prepare(`INSERT INTO spans
52
+ (span_id, trace_id, parent_span_id, run_id, attempt_id,
53
+ incarnation_id, kind, name, started_at, attrs_json)
54
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(opts.ctx.spanId, opts.ctx.traceId, opts.ctx.parentSpanId ?? null, opts.runId ?? null, opts.attemptId ?? null, opts.ctx.incarnationId, opts.kind, opts.name, startedAt, safeStringify(opts.attrs));
55
+ return opts.ctx.spanId;
56
+ }
57
+ /**
58
+ * Close an in-flight span. COALESCE-protected on `ended_at` so a
59
+ * double-close (e.g. handler + finalizer both fire) keeps the first
60
+ * timestamp. attrs are shallow-merged into the existing attrs_json.
61
+ */
62
+ function closeSpan(db, opts) {
63
+ const endedAt = opts.endedAt ?? new Date().toISOString();
64
+ // Shallow-merge attrs by reading + replacing if a patch is supplied.
65
+ if (opts.attrsPatch) {
66
+ const cur = db.prepare(`SELECT attrs_json FROM spans WHERE span_id = ?`)
67
+ .get(opts.spanId);
68
+ const merged = { ...safeParse(cur?.attrs_json ?? null), ...opts.attrsPatch };
69
+ db.prepare(`UPDATE spans
70
+ SET status = COALESCE(status, ?),
71
+ ended_at = COALESCE(ended_at, ?),
72
+ error_class = COALESCE(error_class, ?),
73
+ error_message = COALESCE(error_message, ?),
74
+ attrs_json = ?
75
+ WHERE span_id = ?`).run(opts.status, endedAt, opts.errorClass ?? null, opts.errorMessage ?? null, safeStringify(merged), opts.spanId);
76
+ return;
77
+ }
78
+ db.prepare(`UPDATE spans
79
+ SET status = COALESCE(status, ?),
80
+ ended_at = COALESCE(ended_at, ?),
81
+ error_class = COALESCE(error_class, ?),
82
+ error_message = COALESCE(error_message, ?)
83
+ WHERE span_id = ?`).run(opts.status, endedAt, opts.errorClass ?? null, opts.errorMessage ?? null, opts.spanId);
84
+ }
85
+ /** Single-span lookup. */
86
+ function getSpan(db, spanId) {
87
+ const r = db.prepare(`SELECT * FROM spans WHERE span_id = ?`)
88
+ .get(spanId);
89
+ return r ?? null;
90
+ }
91
+ /**
92
+ * Build the span tree for a trace. Returns the root(s) sorted by
93
+ * started_at, each with `children` recursively populated. A trace may
94
+ * have multiple roots if disconnected spans share the same trace_id
95
+ * (e.g. a fan-out scenario); usually it's one root.
96
+ */
97
+ function getTraceTree(db, traceId) {
98
+ const rows = db.prepare(`SELECT * FROM spans WHERE trace_id = ? ORDER BY started_at ASC`).all(traceId);
99
+ const nodes = new Map();
100
+ for (const r of rows) {
101
+ nodes.set(r.span_id, { ...r, children: [] });
102
+ }
103
+ const roots = [];
104
+ for (const node of nodes.values()) {
105
+ if (node.parent_span_id && nodes.has(node.parent_span_id)) {
106
+ nodes.get(node.parent_span_id).children.push(node);
107
+ }
108
+ else {
109
+ roots.push(node);
110
+ }
111
+ }
112
+ return roots;
113
+ }
@@ -73,25 +73,56 @@ function createTriggerBus(opts) {
73
73
  insert(ev) {
74
74
  const now = Date.now();
75
75
  const payloadJson = JSON.stringify(ev.payload ?? {});
76
- const result = db
77
- .prepare(`INSERT OR IGNORE INTO trigger_events
78
- (source, source_key, idempotency_key, payload_json,
79
- status, attempts, created_at, updated_at)
80
- VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)`)
81
- .run(ev.source, ev.sourceKey, ev.idempotencyKey ?? null, payloadJson, now, now);
82
- if (result.changes > 0) {
83
- return { id: Number(result.lastInsertRowid), inserted: true };
84
- }
85
- // Dedup hit return the existing id.
86
- const existing = db
87
- .prepare('SELECT id FROM trigger_events WHERE source = ? AND idempotency_key = ?')
88
- .get(ev.source, ev.idempotencyKey ?? null);
89
- if (!existing) {
90
- // Defensive shouldn't happen unless idempotency_key was null,
91
- // in which case INSERT OR IGNORE wouldn't have skipped.
92
- throw new Error('triggerBus.insert: INSERT OR IGNORE produced no row but no existing match found');
93
- }
94
- return { id: existing.id, inserted: false };
76
+ // v4.9.0 Slice 5 — wrap insert + idempotency write in a single
77
+ // transaction so a crash between them can't leave a stranded
78
+ // trigger_events row without an idempotency anchor.
79
+ let outcome = null;
80
+ const tx = db.transaction(() => {
81
+ const result = db
82
+ .prepare(`INSERT OR IGNORE INTO trigger_events
83
+ (source, source_key, idempotency_key, payload_json,
84
+ status, attempts, created_at, updated_at)
85
+ VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)`)
86
+ .run(ev.source, ev.sourceKey, ev.idempotencyKey ?? null, payloadJson, now, now);
87
+ if (result.changes > 0) {
88
+ const newId = Number(result.lastInsertRowid);
89
+ // Slice 5 — anchor the run-idempotency row keyed on
90
+ // (`trigger:<source>`, `<sourceKey>::<idempotencyKey>`). We
91
+ // skip if idempotencyKey is null (nothing to dedupe against).
92
+ if (opts.enableRunIdempotency && ev.idempotencyKey) {
93
+ const ns = `trigger:${ev.source}`;
94
+ const key = `${ev.sourceKey}::${ev.idempotencyKey}`;
95
+ const fp = ev.idempotencyKey;
96
+ db.prepare(`INSERT OR IGNORE INTO run_idempotency_keys
97
+ (namespace, key, fingerprint, trigger_event_id,
98
+ status, created_at)
99
+ VALUES (?, ?, ?, ?, 'accepted', ?)`).run(ns, key, fp, newId, new Date(now).toISOString());
100
+ }
101
+ outcome = { id: newId, inserted: true };
102
+ return;
103
+ }
104
+ // Dedup hit — return the existing id.
105
+ const existing = db
106
+ .prepare('SELECT id FROM trigger_events WHERE source = ? AND idempotency_key = ?')
107
+ .get(ev.source, ev.idempotencyKey ?? null);
108
+ if (!existing) {
109
+ throw new Error('triggerBus.insert: INSERT OR IGNORE produced no row but no existing match found');
110
+ }
111
+ if (opts.enableRunIdempotency && ev.idempotencyKey && opts.onIdempotencyConflict) {
112
+ // Slice 5 — surface the dedup-as-duplicate event for visibility.
113
+ try {
114
+ opts.onIdempotencyConflict({
115
+ source: ev.source,
116
+ sourceKey: ev.sourceKey,
117
+ key: `${ev.sourceKey}::${ev.idempotencyKey}`,
118
+ });
119
+ }
120
+ catch { /* notification must never throw */ }
121
+ }
122
+ outcome = { id: existing.id, inserted: false };
123
+ });
124
+ tx();
125
+ return outcome;
95
126
  },
96
127
  claim(opts2 = { ownerId: '' }) {
97
128
  const leaseMs = opts2.leaseMs ?? exports.DEFAULT_CLAIM_LEASE_MS;
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.queryHookExecutions = queryHookExecutions;
4
+ exports.failureRates = failureRates;
5
+ exports.countByStatus = countByStatus;
6
+ function queryHookExecutions(db, q = {}) {
7
+ const where = [];
8
+ const params = [];
9
+ if (q.hookId) {
10
+ where.push('e.hook_id = ?');
11
+ params.push(q.hookId);
12
+ }
13
+ if (q.event) {
14
+ where.push('e.event = ?');
15
+ params.push(q.event);
16
+ }
17
+ if (q.status) {
18
+ where.push('e.status = ?');
19
+ params.push(q.status);
20
+ }
21
+ if (q.since) {
22
+ where.push('e.started_at >= ?');
23
+ params.push(q.since);
24
+ }
25
+ const limit = Math.min(Math.max(q.limit ?? 50, 1), 1000);
26
+ const sql = `
27
+ SELECT e.hook_execution_id, e.hook_id, h.name AS hook_name,
28
+ e.subscription_id, e.event, e.status, e.decision,
29
+ e.elapsed_ms, e.exit_code, e.error_kind, e.error_message,
30
+ e.started_at, e.finished_at, e.run_id, e.trace_id
31
+ FROM hook_executions e
32
+ LEFT JOIN hooks h ON h.hook_id = e.hook_id
33
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
34
+ ORDER BY e.started_at DESC
35
+ LIMIT ?`;
36
+ params.push(limit);
37
+ return db.prepare(sql).all(...params);
38
+ }
39
+ function failureRates(db, lookbackN = 100) {
40
+ // Per-hook: count last N executions and how many were non-ok.
41
+ const ids = db.prepare(`SELECT hook_id, name FROM hooks`).all();
42
+ const rows = [];
43
+ for (const h of ids) {
44
+ const recent = db.prepare(`SELECT status FROM hook_executions WHERE hook_id = ?
45
+ ORDER BY started_at DESC LIMIT ?`).all(h.hook_id, lookbackN);
46
+ if (recent.length === 0)
47
+ continue;
48
+ const failures = recent.filter((r) => r.status !== 'ok').length;
49
+ rows.push({
50
+ hook_id: h.hook_id,
51
+ hook_name: h.name,
52
+ total: recent.length,
53
+ failures,
54
+ failureRate: failures / recent.length,
55
+ });
56
+ }
57
+ return rows;
58
+ }
59
+ /** Count rows matching a status set over the recent window. */
60
+ function countByStatus(db, sinceIso) {
61
+ const rows = db.prepare(`SELECT status, COUNT(*) AS n FROM hook_executions
62
+ WHERE started_at >= ? GROUP BY status`).all(sinceIso);
63
+ const out = {};
64
+ for (const r of rows)
65
+ out[r.status] = r.n;
66
+ return out;
67
+ }