aiden-runtime 4.8.0 → 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.
- package/README.md +88 -1
- package/dist/cli/v4/aidenCLI.js +35 -4
- package/dist/cli/v4/chatSession.js +43 -16
- package/dist/cli/v4/commands/daemon.js +47 -2
- package/dist/cli/v4/commands/daemonDoctor.js +212 -0
- package/dist/cli/v4/commands/daemonStatus.js +1 -1
- package/dist/cli/v4/commands/help.js +2 -0
- package/dist/cli/v4/commands/hooks.js +428 -0
- package/dist/cli/v4/commands/index.js +5 -1
- package/dist/cli/v4/commands/mcp.js +89 -1
- package/dist/cli/v4/commands/mcpClientInstall.js +359 -0
- package/dist/cli/v4/commands/memory.js +702 -0
- package/dist/cli/v4/commands/recovery.js +1 -1
- package/dist/cli/v4/commands/skin.js +7 -0
- package/dist/cli/v4/commands/theme.js +217 -0
- package/dist/cli/v4/commands/trigger.js +1 -1
- package/dist/cli/v4/commands/update.js +14 -2
- package/dist/cli/v4/design/tokens.js +52 -4
- package/dist/cli/v4/display.js +102 -46
- package/dist/cli/v4/pasteIntercept.js +214 -70
- package/dist/cli/v4/replyRenderer.js +145 -5
- package/dist/cli/v4/skinEngine.js +67 -0
- package/dist/core/v4/aidenAgent.js +45 -2
- package/dist/core/v4/daemon/api/runs.js +131 -0
- package/dist/core/v4/daemon/bootstrap.js +368 -13
- package/dist/core/v4/daemon/db/migrations.js +169 -0
- package/dist/core/v4/daemon/idempotency/runIdempotencyStore.js +128 -0
- package/dist/core/v4/daemon/incarnationStore.js +47 -0
- package/dist/core/v4/daemon/runs/attemptStore.js +67 -0
- package/dist/core/v4/daemon/runs/reclaim.js +88 -0
- package/dist/core/v4/daemon/runs/retryPolicy.js +73 -0
- package/dist/core/v4/daemon/runs/runWithRetry.js +80 -0
- package/dist/core/v4/daemon/runs/stuckAttemptWatchdog.js +72 -0
- package/dist/core/v4/daemon/spans/spanHelpers.js +272 -0
- package/dist/core/v4/daemon/spans/spanStore.js +113 -0
- package/dist/core/v4/daemon/triggerBus.js +50 -19
- package/dist/core/v4/hooks/auditQuery.js +67 -0
- package/dist/core/v4/hooks/dispatcher.js +286 -0
- package/dist/core/v4/hooks/index.js +46 -0
- package/dist/core/v4/hooks/lifecycle.js +27 -0
- package/dist/core/v4/hooks/manifest.js +142 -0
- package/dist/core/v4/hooks/registry.js +149 -0
- package/dist/core/v4/hooks/runtime/subprocessRunner.js +188 -0
- package/dist/core/v4/hooks/toolHookGate.js +76 -0
- package/dist/core/v4/hooks/trust.js +14 -0
- package/dist/core/v4/identity/contextManager.js +83 -0
- package/dist/core/v4/identity/daemonId.js +85 -0
- package/dist/core/v4/identity/enforcement.js +103 -0
- package/dist/core/v4/identity/executionContext.js +153 -0
- package/dist/core/v4/identity/hookExecution.js +62 -0
- package/dist/core/v4/identity/httpContext.js +68 -0
- package/dist/core/v4/identity/ids.js +185 -0
- package/dist/core/v4/identity/index.js +60 -0
- package/dist/core/v4/identity/subprocessContext.js +98 -0
- package/dist/core/v4/identity/traceparent.js +114 -0
- package/dist/core/v4/logger/index.js +3 -1
- package/dist/core/v4/logger/logger.js +28 -1
- package/dist/core/v4/logger/redact.js +149 -0
- package/dist/core/v4/logger/sinks/fileSink.js +13 -0
- package/dist/core/v4/logger/sinks/stdSink.js +19 -1
- package/dist/core/v4/mcp/install/backup.js +78 -0
- package/dist/core/v4/mcp/install/clientPaths.js +90 -0
- package/dist/core/v4/mcp/install/clients.js +203 -0
- package/dist/core/v4/mcp/install/healthCheck.js +83 -0
- package/dist/core/v4/mcp/install/jsoncMerge.js +174 -0
- package/dist/core/v4/mcp/install/profiles.js +109 -0
- package/dist/core/v4/mcp/install/wslDetect.js +62 -0
- package/dist/core/v4/memory/namespaceRegistry.js +117 -0
- package/dist/core/v4/memory/projectRoot.js +76 -0
- package/dist/core/v4/memory/reviewer/index.js +162 -0
- package/dist/core/v4/memory/reviewer/pendingStore.js +136 -0
- package/dist/core/v4/memory/reviewer/prompt.js +105 -0
- package/dist/core/v4/memory/reviewer/skipRules.js +92 -0
- package/dist/core/v4/memoryManager.js +57 -10
- package/dist/core/v4/paths.js +2 -0
- package/dist/core/v4/promptBuilder.js +6 -0
- package/dist/core/v4/subagent/spawnSubAgent.js +20 -7
- package/dist/core/v4/theme/bundledThemes.js +106 -0
- package/dist/core/v4/theme/themeLoader.js +160 -0
- package/dist/core/v4/theme/themeRegistry.js +97 -0
- package/dist/core/v4/theme/themeWatcher.js +95 -0
- package/dist/core/v4/toolRegistry.js +71 -8
- package/dist/core/v4/update/executeInstall.js +10 -6
- package/dist/core/v4/update/installMethodDetect.js +7 -0
- package/dist/core/version.js +67 -2
- package/dist/moat/approvalEngine.js +4 -0
- package/dist/moat/memoryGuard.js +8 -1
- package/dist/providers/v4/anthropicAdapter.js +10 -4
- package/dist/tools/v4/backends/local.js +19 -2
- package/dist/tools/v4/sessions/recallSession.js +6 -1
- package/package.json +3 -3
- package/plugins/aiden-plugin-chatgpt-plus/index.js +10 -1
- package/themes/default.yaml +52 -0
- package/themes/dracula.yaml +32 -0
- package/themes/light.yaml +32 -0
- package/themes/monochrome.yaml +31 -0
- package/themes/tokyo-night.yaml +32 -0
- package/dist/core/pluginSystem.js +0 -121
- 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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
}
|