astrocode-workflow 0.4.0 → 0.4.1
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/dist/index.js +6 -0
- package/dist/shared/metrics.d.ts +66 -0
- package/dist/shared/metrics.js +112 -0
- package/dist/src/agents/commands.d.ts +9 -0
- package/dist/src/agents/commands.js +121 -0
- package/dist/src/agents/prompts.d.ts +3 -0
- package/dist/src/agents/prompts.js +232 -0
- package/dist/src/agents/registry.d.ts +6 -0
- package/dist/src/agents/registry.js +242 -0
- package/dist/src/agents/types.d.ts +14 -0
- package/dist/src/agents/types.js +8 -0
- package/dist/src/astro/workflow-runner.d.ts +15 -0
- package/dist/src/astro/workflow-runner.js +25 -0
- package/dist/src/config/config-handler.d.ts +4 -0
- package/dist/src/config/config-handler.js +46 -0
- package/dist/src/config/defaults.d.ts +3 -0
- package/dist/src/config/defaults.js +3 -0
- package/dist/src/config/loader.d.ts +11 -0
- package/dist/src/config/loader.js +82 -0
- package/dist/src/config/schema.d.ts +195 -0
- package/dist/src/config/schema.js +224 -0
- package/dist/src/hooks/continuation-enforcer.d.ts +34 -0
- package/dist/src/hooks/continuation-enforcer.js +190 -0
- package/dist/src/hooks/inject-provider.d.ts +27 -0
- package/dist/src/hooks/inject-provider.js +189 -0
- package/dist/src/hooks/tool-output-truncator.d.ts +25 -0
- package/dist/src/hooks/tool-output-truncator.js +57 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +313 -0
- package/dist/src/shared/deep-merge.d.ts +8 -0
- package/dist/src/shared/deep-merge.js +25 -0
- package/dist/src/shared/hash.d.ts +1 -0
- package/dist/src/shared/hash.js +4 -0
- package/dist/src/shared/log.d.ts +7 -0
- package/dist/src/shared/log.js +24 -0
- package/dist/src/shared/metrics.d.ts +66 -0
- package/dist/src/shared/metrics.js +112 -0
- package/dist/src/shared/model-tuning.d.ts +9 -0
- package/dist/src/shared/model-tuning.js +28 -0
- package/dist/src/shared/paths.d.ts +19 -0
- package/dist/src/shared/paths.js +64 -0
- package/dist/src/shared/text.d.ts +4 -0
- package/dist/src/shared/text.js +19 -0
- package/dist/src/shared/time.d.ts +1 -0
- package/dist/src/shared/time.js +3 -0
- package/dist/src/state/adapters/index.d.ts +41 -0
- package/dist/src/state/adapters/index.js +115 -0
- package/dist/src/state/db.d.ts +16 -0
- package/dist/src/state/db.js +225 -0
- package/dist/src/state/ids.d.ts +8 -0
- package/dist/src/state/ids.js +25 -0
- package/dist/src/state/repo-lock.d.ts +67 -0
- package/dist/src/state/repo-lock.js +580 -0
- package/dist/src/state/schema.d.ts +2 -0
- package/dist/src/state/schema.js +258 -0
- package/dist/src/state/types.d.ts +71 -0
- package/dist/src/state/types.js +1 -0
- package/dist/src/state/workflow-repo-lock.d.ts +23 -0
- package/dist/src/state/workflow-repo-lock.js +83 -0
- package/dist/src/tools/artifacts.d.ts +18 -0
- package/dist/src/tools/artifacts.js +71 -0
- package/dist/src/tools/health.d.ts +8 -0
- package/dist/src/tools/health.js +119 -0
- package/dist/src/tools/index.d.ts +20 -0
- package/dist/src/tools/index.js +97 -0
- package/dist/src/tools/init.d.ts +17 -0
- package/dist/src/tools/init.js +96 -0
- package/dist/src/tools/injects.d.ts +53 -0
- package/dist/src/tools/injects.js +325 -0
- package/dist/src/tools/lock.d.ts +4 -0
- package/dist/src/tools/lock.js +78 -0
- package/dist/src/tools/metrics.d.ts +7 -0
- package/dist/src/tools/metrics.js +61 -0
- package/dist/src/tools/repair.d.ts +8 -0
- package/dist/src/tools/repair.js +59 -0
- package/dist/src/tools/reset.d.ts +8 -0
- package/dist/src/tools/reset.js +92 -0
- package/dist/src/tools/run.d.ts +13 -0
- package/dist/src/tools/run.js +54 -0
- package/dist/src/tools/spec.d.ts +12 -0
- package/dist/src/tools/spec.js +44 -0
- package/dist/src/tools/stage.d.ts +23 -0
- package/dist/src/tools/stage.js +371 -0
- package/dist/src/tools/status.d.ts +8 -0
- package/dist/src/tools/status.js +125 -0
- package/dist/src/tools/story.d.ts +23 -0
- package/dist/src/tools/story.js +85 -0
- package/dist/src/tools/workflow.d.ts +13 -0
- package/dist/src/tools/workflow.js +359 -0
- package/dist/src/ui/inject.d.ts +12 -0
- package/dist/src/ui/inject.js +107 -0
- package/dist/src/ui/toasts.d.ts +13 -0
- package/dist/src/ui/toasts.js +39 -0
- package/dist/src/workflow/artifacts.d.ts +24 -0
- package/dist/src/workflow/artifacts.js +45 -0
- package/dist/src/workflow/baton.d.ts +72 -0
- package/dist/src/workflow/baton.js +166 -0
- package/dist/src/workflow/context.d.ts +20 -0
- package/dist/src/workflow/context.js +113 -0
- package/dist/src/workflow/directives.d.ts +39 -0
- package/dist/src/workflow/directives.js +137 -0
- package/dist/src/workflow/repair.d.ts +8 -0
- package/dist/src/workflow/repair.js +99 -0
- package/dist/src/workflow/state-machine.d.ts +86 -0
- package/dist/src/workflow/state-machine.js +216 -0
- package/dist/src/workflow/story-helpers.d.ts +9 -0
- package/dist/src/workflow/story-helpers.js +13 -0
- package/dist/state/db.d.ts +1 -0
- package/dist/state/db.js +9 -0
- package/dist/state/repo-lock.d.ts +3 -0
- package/dist/state/repo-lock.js +29 -0
- package/dist/test/integration/db-transactions.test.d.ts +1 -0
- package/dist/test/integration/db-transactions.test.js +126 -0
- package/dist/test/integration/injection-metrics.test.d.ts +1 -0
- package/dist/test/integration/injection-metrics.test.js +129 -0
- package/dist/tools/health.d.ts +8 -0
- package/dist/tools/health.js +119 -0
- package/dist/tools/index.js +9 -0
- package/dist/tools/metrics.d.ts +7 -0
- package/dist/tools/metrics.js +61 -0
- package/dist/tools/reset.d.ts +8 -0
- package/dist/tools/reset.js +92 -0
- package/dist/tools/workflow.js +178 -168
- package/dist/ui/inject.js +21 -9
- package/package.json +6 -4
- package/src/astro/workflow-runner.ts +36 -0
- package/src/config/schema.ts +1 -0
- package/src/hooks/inject-provider.ts +94 -14
- package/src/index.ts +14 -0
- package/src/shared/metrics.ts +148 -0
- package/src/state/db.ts +10 -1
- package/src/state/repo-lock.ts +706 -0
- package/src/state/schema.ts +8 -1
- package/src/state/workflow-repo-lock.ts +111 -0
- package/src/tools/health.ts +128 -0
- package/src/tools/index.ts +15 -3
- package/src/tools/init.ts +7 -6
- package/src/tools/lock.ts +75 -0
- package/src/tools/metrics.ts +71 -0
- package/src/tools/repair.ts +44 -6
- package/src/tools/reset.ts +100 -0
- package/src/tools/stage.ts +1 -0
- package/src/tools/status.ts +2 -1
- package/src/tools/story.ts +1 -0
- package/src/tools/workflow.ts +19 -1
- package/src/ui/inject.ts +21 -9
- package/src/workflow/repair.ts +2 -2
|
@@ -9,6 +9,11 @@ type ChatMessageInput = {
|
|
|
9
9
|
agent: string;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
type ToolExecuteAfterInput = {
|
|
13
|
+
tool: string;
|
|
14
|
+
sessionID?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
12
17
|
type RuntimeState = {
|
|
13
18
|
db: SqliteDb | null;
|
|
14
19
|
limitedMode: boolean;
|
|
@@ -24,19 +29,30 @@ export function createInjectProvider(opts: {
|
|
|
24
29
|
const { db } = runtime;
|
|
25
30
|
|
|
26
31
|
// Cache to avoid re-injecting the same injects repeatedly
|
|
32
|
+
// Map of inject_id -> last injected timestamp
|
|
27
33
|
const injectedCache = new Map<string, number>();
|
|
28
34
|
|
|
29
35
|
function shouldSkipInject(injectId: string, nowMs: number): boolean {
|
|
30
36
|
const lastInjected = injectedCache.get(injectId);
|
|
31
37
|
if (!lastInjected) return false;
|
|
32
38
|
|
|
33
|
-
//
|
|
34
|
-
|
|
39
|
+
// REDUCED cooldown from 5 minutes to 1 minute
|
|
40
|
+
// This allows injects to appear more frequently during workflow
|
|
41
|
+
const cooldownMs = 1 * 60 * 1000;
|
|
35
42
|
return nowMs - lastInjected < cooldownMs;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
function markInjected(injectId: string, nowMs: number) {
|
|
39
46
|
injectedCache.set(injectId, nowMs);
|
|
47
|
+
|
|
48
|
+
// Clean up old entries to prevent memory leak
|
|
49
|
+
// Remove entries older than 10 minutes
|
|
50
|
+
const tenMinutesAgo = nowMs - (10 * 60 * 1000);
|
|
51
|
+
for (const [id, timestamp] of injectedCache.entries()) {
|
|
52
|
+
if (timestamp < tenMinutesAgo) {
|
|
53
|
+
injectedCache.delete(id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
40
56
|
}
|
|
41
57
|
|
|
42
58
|
function getInjectionDiagnostics(nowIso: string, scopeAllowlist: string[], typeAllowlist: string[]): any {
|
|
@@ -89,7 +105,7 @@ export function createInjectProvider(opts: {
|
|
|
89
105
|
};
|
|
90
106
|
}
|
|
91
107
|
|
|
92
|
-
async function injectEligibleInjects(sessionId: string) {
|
|
108
|
+
async function injectEligibleInjects(sessionId: string, context?: string) {
|
|
93
109
|
const now = nowISO();
|
|
94
110
|
const nowMs = Date.now();
|
|
95
111
|
|
|
@@ -115,7 +131,7 @@ export function createInjectProvider(opts: {
|
|
|
115
131
|
// Log when no injects are eligible
|
|
116
132
|
if (EMIT_TELEMETRY) {
|
|
117
133
|
// eslint-disable-next-line no-console
|
|
118
|
-
console.log(`[Astrocode:inject] ${now}
|
|
134
|
+
console.log(`[Astrocode:inject] ${now} context=${context ?? 'unknown'} selected=0 injected=0 skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:0}`);
|
|
119
135
|
}
|
|
120
136
|
return;
|
|
121
137
|
}
|
|
@@ -130,21 +146,68 @@ export function createInjectProvider(opts: {
|
|
|
130
146
|
// Format as injection message
|
|
131
147
|
const formattedText = `[Inject: ${inject.title}]\n\n${inject.body_md}`;
|
|
132
148
|
|
|
149
|
+
try {
|
|
150
|
+
await injectChatPrompt({
|
|
151
|
+
ctx,
|
|
152
|
+
sessionId,
|
|
153
|
+
text: formattedText,
|
|
154
|
+
agent: "Astrocode"
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
injected++;
|
|
158
|
+
markInjected(inject.inject_id, nowMs);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
// Log injection failures but don't crash
|
|
161
|
+
// eslint-disable-next-line no-console
|
|
162
|
+
console.error(`[Astrocode:inject] Failed to inject ${inject.inject_id}:`, err);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Log diagnostic summary
|
|
167
|
+
if (EMIT_TELEMETRY || injected > 0) {
|
|
168
|
+
// eslint-disable-next-line no-console
|
|
169
|
+
console.log(`[Astrocode:inject] ${now} context=${context ?? 'unknown'} selected=${diagnostics.selected_eligible} injected=${injected} skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:${skippedDeduped}}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Workflow-related tools that should trigger inject + auto-approval
|
|
174
|
+
const WORKFLOW_TOOLS = new Set([
|
|
175
|
+
'astro_workflow_proceed',
|
|
176
|
+
'astro_story_queue',
|
|
177
|
+
'astro_story_approve',
|
|
178
|
+
'astro_stage_start',
|
|
179
|
+
'astro_stage_complete',
|
|
180
|
+
'astro_stage_fail',
|
|
181
|
+
'astro_run_abort',
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
// Auto-approve queued stories if enabled
|
|
185
|
+
async function maybeAutoApprove(sessionId: string) {
|
|
186
|
+
if (!config.inject?.auto_approve_queued_stories) return;
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
// Get all queued stories
|
|
190
|
+
const queued = db.prepare("SELECT story_key, title FROM stories WHERE state='queued' ORDER BY priority DESC, created_at ASC").all() as Array<{ story_key: string; title: string }>;
|
|
191
|
+
|
|
192
|
+
if (queued.length === 0) return;
|
|
193
|
+
|
|
194
|
+
// Auto-approve the highest priority queued story
|
|
195
|
+
const story = queued[0];
|
|
196
|
+
db.prepare("UPDATE stories SET state='approved', updated_at=? WHERE story_key=?").run(nowISO(), story.story_key);
|
|
197
|
+
|
|
198
|
+
// eslint-disable-next-line no-console
|
|
199
|
+
console.log(`[Astrocode:inject] Auto-approved story ${story.story_key}: ${story.title}`);
|
|
200
|
+
|
|
201
|
+
// Inject a notification about the auto-approval
|
|
133
202
|
await injectChatPrompt({
|
|
134
203
|
ctx,
|
|
135
204
|
sessionId,
|
|
136
|
-
text:
|
|
205
|
+
text: `✅ Auto-approved story ${story.story_key}: ${story.title}`,
|
|
137
206
|
agent: "Astrocode"
|
|
138
207
|
});
|
|
139
|
-
|
|
140
|
-
injected++;
|
|
141
|
-
markInjected(inject.inject_id, nowMs);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Log diagnostic summary
|
|
145
|
-
if (EMIT_TELEMETRY) {
|
|
208
|
+
} catch (err) {
|
|
146
209
|
// eslint-disable-next-line no-console
|
|
147
|
-
console.
|
|
210
|
+
console.error(`[Astrocode:inject] Auto-approval failed:`, err);
|
|
148
211
|
}
|
|
149
212
|
}
|
|
150
213
|
|
|
@@ -154,7 +217,24 @@ export function createInjectProvider(opts: {
|
|
|
154
217
|
if (!config.inject?.enabled) return;
|
|
155
218
|
|
|
156
219
|
// Inject eligible injects before processing the user's message
|
|
157
|
-
await injectEligibleInjects(input.sessionID);
|
|
220
|
+
await injectEligibleInjects(input.sessionID, 'chat_message');
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
async onToolAfter(input: ToolExecuteAfterInput) {
|
|
224
|
+
if (!config.inject?.enabled) return;
|
|
225
|
+
|
|
226
|
+
// Only inject after workflow-related tools
|
|
227
|
+
if (!WORKFLOW_TOOLS.has(input.tool)) return;
|
|
228
|
+
|
|
229
|
+
// Extract sessionID (same pattern as continuation enforcer)
|
|
230
|
+
const sessionId = input.sessionID ?? (ctx as any).sessionID;
|
|
231
|
+
if (!sessionId) return;
|
|
232
|
+
|
|
233
|
+
// Auto-approve queued stories if enabled
|
|
234
|
+
await maybeAutoApprove(sessionId);
|
|
235
|
+
|
|
236
|
+
// Inject eligible injects after workflow tool execution
|
|
237
|
+
await injectEligibleInjects(sessionId, `tool_after:${input.tool}`);
|
|
158
238
|
},
|
|
159
239
|
};
|
|
160
240
|
}
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ type ContinuationEnforcer = {
|
|
|
24
24
|
type ToolOutputTruncator = (input: any, output: any | null) => Promise<void>;
|
|
25
25
|
type InjectProvider = {
|
|
26
26
|
onChatMessage: (input: any) => Promise<void>;
|
|
27
|
+
onToolAfter: (input: any) => Promise<void>;
|
|
27
28
|
};
|
|
28
29
|
type ToastManager = {
|
|
29
30
|
show: (toast: ToastOptions) => Promise<void>;
|
|
@@ -58,6 +59,13 @@ const Astrocode: Plugin = async (ctx) => {
|
|
|
58
59
|
}
|
|
59
60
|
const repoRoot = ctx.directory;
|
|
60
61
|
|
|
62
|
+
// NOTE: Repo locking is handled at the workflow level via workflowRepoLock.
|
|
63
|
+
// The workflow tool correctly acquires and holds the lock for the entire workflow execution.
|
|
64
|
+
// Plugin-level locking is unnecessary and architecturally incorrect since:
|
|
65
|
+
// - The lock would be held for the entire session lifecycle (too long)
|
|
66
|
+
// - Individual tools are designed to be called within workflow context where lock is held
|
|
67
|
+
// - Workflow-level locking with refcounting prevents lock churn during tool execution
|
|
68
|
+
|
|
61
69
|
// Always load config first - this provides defaults even in limited mode
|
|
62
70
|
let pluginConfig: AstrocodeConfig;
|
|
63
71
|
try {
|
|
@@ -294,6 +302,11 @@ const Astrocode: Plugin = async (ctx) => {
|
|
|
294
302
|
},
|
|
295
303
|
|
|
296
304
|
"tool.execute.after": async (input: any, output: any) => {
|
|
305
|
+
// Inject eligible injects after tool execution (not just on chat messages)
|
|
306
|
+
if (injectProvider && hookEnabled("inject-provider")) {
|
|
307
|
+
await injectProvider.onToolAfter(input);
|
|
308
|
+
}
|
|
309
|
+
|
|
297
310
|
// Truncate huge tool outputs to artifacts
|
|
298
311
|
if (truncatorHook && hookEnabled("tool-output-truncator")) {
|
|
299
312
|
await truncatorHook(input, output ?? null);
|
|
@@ -325,6 +338,7 @@ const Astrocode: Plugin = async (ctx) => {
|
|
|
325
338
|
|
|
326
339
|
// Best-effort cleanup
|
|
327
340
|
close: async () => {
|
|
341
|
+
// Close database connection
|
|
328
342
|
if (db && typeof db.close === "function") {
|
|
329
343
|
try {
|
|
330
344
|
db.close();
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// src/shared/metrics.ts
|
|
2
|
+
|
|
3
|
+
export interface TransactionMetrics {
|
|
4
|
+
startTime: number;
|
|
5
|
+
duration: number;
|
|
6
|
+
success: boolean;
|
|
7
|
+
nestedDepth: number;
|
|
8
|
+
operation?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface InjectionMetrics {
|
|
12
|
+
sessionId: string;
|
|
13
|
+
attempts: number;
|
|
14
|
+
duration: number;
|
|
15
|
+
success: boolean;
|
|
16
|
+
agent?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SystemMetrics {
|
|
20
|
+
transactions: TransactionMetrics[];
|
|
21
|
+
injections: InjectionMetrics[];
|
|
22
|
+
errors: Array<{ type: string; message: string; timestamp: number }>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class MetricsCollector {
|
|
26
|
+
private transactions: TransactionMetrics[] = [];
|
|
27
|
+
private injections: InjectionMetrics[] = [];
|
|
28
|
+
private errors: Array<{ type: string; message: string; timestamp: number }> = [];
|
|
29
|
+
private maxEntries = 1000; // Keep last 1000 entries per type
|
|
30
|
+
|
|
31
|
+
recordTransaction(metrics: TransactionMetrics) {
|
|
32
|
+
this.transactions.push(metrics);
|
|
33
|
+
if (this.transactions.length > this.maxEntries) {
|
|
34
|
+
this.transactions.shift();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
recordInjection(metrics: InjectionMetrics) {
|
|
39
|
+
this.injections.push(metrics);
|
|
40
|
+
if (this.injections.length > this.maxEntries) {
|
|
41
|
+
this.injections.shift();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
recordError(type: string, message: string) {
|
|
46
|
+
this.errors.push({ type, message, timestamp: Date.now() });
|
|
47
|
+
if (this.errors.length > this.maxEntries) {
|
|
48
|
+
this.errors.shift();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getMetrics(): SystemMetrics {
|
|
53
|
+
return {
|
|
54
|
+
transactions: [...this.transactions],
|
|
55
|
+
injections: [...this.injections],
|
|
56
|
+
errors: [...this.errors],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getTransactionStats() {
|
|
61
|
+
const txs = this.transactions;
|
|
62
|
+
if (txs.length === 0) return null;
|
|
63
|
+
|
|
64
|
+
const successful = txs.filter(t => t.success);
|
|
65
|
+
const failed = txs.filter(t => !t.success);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
total: txs.length,
|
|
69
|
+
successful: successful.length,
|
|
70
|
+
failed: failed.length,
|
|
71
|
+
successRate: successful.length / txs.length,
|
|
72
|
+
avgDuration: txs.reduce((sum, t) => sum + t.duration, 0) / txs.length,
|
|
73
|
+
avgNestedDepth: txs.reduce((sum, t) => sum + t.nestedDepth, 0) / txs.length,
|
|
74
|
+
minDuration: Math.min(...txs.map(t => t.duration)),
|
|
75
|
+
maxDuration: Math.max(...txs.map(t => t.duration)),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getInjectionStats() {
|
|
80
|
+
const injections = this.injections;
|
|
81
|
+
if (injections.length === 0) return null;
|
|
82
|
+
|
|
83
|
+
const successful = injections.filter(i => i.success);
|
|
84
|
+
const failed = injections.filter(i => !i.success);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
total: injections.length,
|
|
88
|
+
successful: successful.length,
|
|
89
|
+
failed: failed.length,
|
|
90
|
+
successRate: successful.length / injections.length,
|
|
91
|
+
avgAttempts: injections.reduce((sum, i) => sum + i.attempts, 0) / injections.length,
|
|
92
|
+
avgDuration: injections.reduce((sum, i) => sum + i.duration, 0) / injections.length,
|
|
93
|
+
totalRetries: injections.reduce((sum, i) => sum + Math.max(0, i.attempts - 1), 0),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
clear() {
|
|
98
|
+
this.transactions = [];
|
|
99
|
+
this.injections = [];
|
|
100
|
+
this.errors = [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Global singleton
|
|
105
|
+
export const metrics = new MetricsCollector();
|
|
106
|
+
|
|
107
|
+
// Convenience functions
|
|
108
|
+
export function recordTransaction(metricsData: Omit<TransactionMetrics, 'startTime' | 'duration' | 'success'>) {
|
|
109
|
+
return {
|
|
110
|
+
start() {
|
|
111
|
+
return {
|
|
112
|
+
...metricsData,
|
|
113
|
+
startTime: Date.now(),
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
end(startData: ReturnType<ReturnType<typeof recordTransaction>['start']>, success: boolean) {
|
|
117
|
+
const duration = Date.now() - startData.startTime;
|
|
118
|
+
metrics.recordTransaction({
|
|
119
|
+
...startData,
|
|
120
|
+
duration,
|
|
121
|
+
success,
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function recordInjection(metricsData: Omit<InjectionMetrics, 'duration' | 'success'>) {
|
|
128
|
+
return {
|
|
129
|
+
start() {
|
|
130
|
+
return {
|
|
131
|
+
...metricsData,
|
|
132
|
+
startTime: Date.now(),
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
end(startData: any, success: boolean) {
|
|
136
|
+
const duration = Date.now() - startData.startTime;
|
|
137
|
+
metrics.recordInjection({
|
|
138
|
+
...startData,
|
|
139
|
+
duration,
|
|
140
|
+
success,
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function recordError(type: string, message: string) {
|
|
147
|
+
metrics.recordError(type, message);
|
|
148
|
+
}
|
package/src/state/db.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { SCHEMA_SQL, SCHEMA_VERSION } from "./schema";
|
|
|
5
5
|
import { nowISO } from "../shared/time";
|
|
6
6
|
import { info, warn } from "../shared/log";
|
|
7
7
|
import { createDatabaseAdapter, DatabaseConnection } from "./adapters";
|
|
8
|
+
import { recordTransaction } from "../shared/metrics";
|
|
8
9
|
|
|
9
10
|
export type SqliteDb = DatabaseConnection;
|
|
10
11
|
|
|
@@ -74,7 +75,7 @@ function savepointName(depth: number): string {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
/** BEGIN IMMEDIATE transaction helper (re-entrant). */
|
|
77
|
-
export function withTx<T>(db: SqliteDb, fn: () => T, opts?: { require?: boolean }): T {
|
|
78
|
+
export function withTx<T>(db: SqliteDb, fn: () => T, opts?: { require?: boolean; operation?: string }): T {
|
|
78
79
|
const adapter = createDatabaseAdapter();
|
|
79
80
|
const available = adapter.isAvailable();
|
|
80
81
|
|
|
@@ -84,13 +85,17 @@ export function withTx<T>(db: SqliteDb, fn: () => T, opts?: { require?: boolean
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
const depth = getDepth(db);
|
|
88
|
+
const isNested = depth > 0;
|
|
89
|
+
const txRecorder = recordTransaction({ nestedDepth: depth, operation: opts?.operation });
|
|
87
90
|
|
|
88
91
|
if (depth === 0) {
|
|
92
|
+
const txStart = txRecorder.start();
|
|
89
93
|
db.exec("BEGIN IMMEDIATE");
|
|
90
94
|
setDepth(db, 1);
|
|
91
95
|
try {
|
|
92
96
|
const out = fn();
|
|
93
97
|
db.exec("COMMIT");
|
|
98
|
+
txRecorder.end(txStart, true);
|
|
94
99
|
return out;
|
|
95
100
|
} catch (e) {
|
|
96
101
|
try {
|
|
@@ -98,6 +103,7 @@ export function withTx<T>(db: SqliteDb, fn: () => T, opts?: { require?: boolean
|
|
|
98
103
|
} catch {
|
|
99
104
|
// ignore
|
|
100
105
|
}
|
|
106
|
+
txRecorder.end(txStart, false);
|
|
101
107
|
throw e;
|
|
102
108
|
} finally {
|
|
103
109
|
setDepth(db, 0);
|
|
@@ -107,6 +113,7 @@ export function withTx<T>(db: SqliteDb, fn: () => T, opts?: { require?: boolean
|
|
|
107
113
|
// Nested: use SAVEPOINT
|
|
108
114
|
const nextDepth = depth + 1;
|
|
109
115
|
const sp = savepointName(nextDepth);
|
|
116
|
+
const txStart = txRecorder.start();
|
|
110
117
|
|
|
111
118
|
db.exec(`SAVEPOINT ${sp}`);
|
|
112
119
|
setDepth(db, nextDepth);
|
|
@@ -114,6 +121,7 @@ export function withTx<T>(db: SqliteDb, fn: () => T, opts?: { require?: boolean
|
|
|
114
121
|
try {
|
|
115
122
|
const out = fn();
|
|
116
123
|
db.exec(`RELEASE SAVEPOINT ${sp}`);
|
|
124
|
+
txRecorder.end(txStart, true);
|
|
117
125
|
return out;
|
|
118
126
|
} catch (e) {
|
|
119
127
|
try {
|
|
@@ -126,6 +134,7 @@ export function withTx<T>(db: SqliteDb, fn: () => T, opts?: { require?: boolean
|
|
|
126
134
|
} catch {
|
|
127
135
|
// ignore
|
|
128
136
|
}
|
|
137
|
+
txRecorder.end(txStart, false);
|
|
129
138
|
throw e;
|
|
130
139
|
} finally {
|
|
131
140
|
setDepth(db, depth);
|