@devflow-tools/database 0.16.9 → 0.16.11
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 +16 -0
- package/__tests__/database.memory-turn-receipts.test.ts +71 -0
- package/__tests__/database.skill-executions.test.ts +92 -5
- package/__tests__/database.work-queue.test.ts +240 -0
- package/dist/database.d.ts +77 -0
- package/dist/database.js +699 -3
- package/dist/index.d.ts +2 -1
- package/dist/work-queue.d.ts +74 -0
- package/dist/work-queue.js +2 -0
- package/package.json +2 -2
- package/src/database.ts +828 -3
- package/src/index.ts +15 -0
- package/src/work-queue.ts +94 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/database.ts
CHANGED
|
@@ -2,6 +2,17 @@ import { NodeSqliteDatabase } from './node-sqlite';
|
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
3
|
import { existsSync, mkdirSync } from 'fs';
|
|
4
4
|
import { homedir } from 'os';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
import type {
|
|
7
|
+
EnqueueWorkInput,
|
|
8
|
+
LeaseWorkInput,
|
|
9
|
+
RequestSessionClosureInput,
|
|
10
|
+
SessionClosureRecord,
|
|
11
|
+
WorkError,
|
|
12
|
+
WorkItemRecord,
|
|
13
|
+
WorkQueueHealth,
|
|
14
|
+
WorkState,
|
|
15
|
+
} from './work-queue';
|
|
5
16
|
|
|
6
17
|
export interface BenchmarkReportRecord {
|
|
7
18
|
runId: string;
|
|
@@ -76,12 +87,21 @@ const CONTEXT_REQUIRED_SKILLS = new Set([
|
|
|
76
87
|
]);
|
|
77
88
|
|
|
78
89
|
function toolNameMatches(event: any, expected: string): boolean {
|
|
79
|
-
if (event
|
|
90
|
+
if (!isEffectiveToolEvent(event)) return false;
|
|
80
91
|
const name = String(event.mcpToolName ?? event.toolName ?? '')
|
|
81
92
|
.replace(/^mcp__[^_]+__/, '');
|
|
82
93
|
return name === expected;
|
|
83
94
|
}
|
|
84
95
|
|
|
96
|
+
function isEffectiveToolEvent(event: any): boolean {
|
|
97
|
+
if (event.blocked || event.error || event.output == null) return false;
|
|
98
|
+
if (typeof event.output === 'object') {
|
|
99
|
+
const output = event.output as Record<string, unknown>;
|
|
100
|
+
if (output.error || output.isError === true || output.success === false || output.degraded === true) return false;
|
|
101
|
+
}
|
|
102
|
+
return countTelemetryResults(event.output) > 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
85
105
|
function obligationsForSkill(skillName: string, configured: unknown): string[] {
|
|
86
106
|
const configuredTools = Array.isArray(configured)
|
|
87
107
|
? configured.filter((tool): tool is string => typeof tool === 'string' && tool.length > 0)
|
|
@@ -124,6 +144,37 @@ export interface MemoryDistillCheckpointRecord {
|
|
|
124
144
|
createdAt: number;
|
|
125
145
|
}
|
|
126
146
|
|
|
147
|
+
export type MemoryTurnStatus = 'pending' | 'committed' | 'skipped';
|
|
148
|
+
|
|
149
|
+
export interface MemoryTurnRecord {
|
|
150
|
+
turnId: string;
|
|
151
|
+
projectRoot: string;
|
|
152
|
+
sessionId: string;
|
|
153
|
+
promptHash: string;
|
|
154
|
+
eventId: string;
|
|
155
|
+
status: MemoryTurnStatus;
|
|
156
|
+
receiptId?: string;
|
|
157
|
+
memoryIds: string[];
|
|
158
|
+
source?: string;
|
|
159
|
+
reason?: string;
|
|
160
|
+
stopPromptedAt?: number;
|
|
161
|
+
createdAt: number;
|
|
162
|
+
decidedAt?: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface HookFallbackRecord {
|
|
166
|
+
id: string;
|
|
167
|
+
projectRoot: string;
|
|
168
|
+
sessionId?: string;
|
|
169
|
+
toolUseId?: string;
|
|
170
|
+
requestType: string;
|
|
171
|
+
tool: string;
|
|
172
|
+
reason: 'timeout' | 'unreachable' | 'protocol' | 'unknown';
|
|
173
|
+
durationMs: number;
|
|
174
|
+
attempts: number;
|
|
175
|
+
createdAt: number;
|
|
176
|
+
}
|
|
177
|
+
|
|
127
178
|
export function getGlobalDevFlowDbPath(home = homedir()): string {
|
|
128
179
|
const stateDir = process.env.DEVFLOW_STATE_DIR ?? join(home, '.devflow', 'global');
|
|
129
180
|
return join(stateDir, 'devflow.db');
|
|
@@ -239,6 +290,10 @@ export class DevFlowDatabase {
|
|
|
239
290
|
total_duration INTEGER DEFAULT 0,
|
|
240
291
|
mcp_compliance_rate REAL DEFAULT 0,
|
|
241
292
|
missed_mcp_tools TEXT,
|
|
293
|
+
failed_tool_calls INTEGER DEFAULT 0,
|
|
294
|
+
blocked_tool_calls INTEGER DEFAULT 0,
|
|
295
|
+
fallback_count INTEGER DEFAULT 0,
|
|
296
|
+
fallback_reasons TEXT,
|
|
242
297
|
created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000)
|
|
243
298
|
);
|
|
244
299
|
|
|
@@ -378,6 +433,89 @@ export class DevFlowDatabase {
|
|
|
378
433
|
|
|
379
434
|
CREATE INDEX IF NOT EXISTS idx_distill_checkpoints_project
|
|
380
435
|
ON devflow_memory_distill_checkpoints(project_root, created_at DESC);
|
|
436
|
+
|
|
437
|
+
CREATE TABLE IF NOT EXISTS devflow_memory_turns (
|
|
438
|
+
turn_id TEXT PRIMARY KEY,
|
|
439
|
+
project_root TEXT NOT NULL,
|
|
440
|
+
session_id TEXT NOT NULL,
|
|
441
|
+
prompt_hash TEXT NOT NULL,
|
|
442
|
+
event_id TEXT NOT NULL,
|
|
443
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
444
|
+
CHECK(status IN ('pending', 'committed', 'skipped')),
|
|
445
|
+
receipt_id TEXT,
|
|
446
|
+
memory_ids TEXT NOT NULL DEFAULT '[]',
|
|
447
|
+
source TEXT,
|
|
448
|
+
reason TEXT,
|
|
449
|
+
stop_prompted_at INTEGER,
|
|
450
|
+
created_at INTEGER NOT NULL,
|
|
451
|
+
decided_at INTEGER
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
CREATE INDEX IF NOT EXISTS idx_memory_turns_pending
|
|
455
|
+
ON devflow_memory_turns(project_root, session_id, status, created_at DESC);
|
|
456
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_turns_receipt
|
|
457
|
+
ON devflow_memory_turns(receipt_id) WHERE receipt_id IS NOT NULL;
|
|
458
|
+
|
|
459
|
+
CREATE TABLE IF NOT EXISTS devflow_hook_fallbacks (
|
|
460
|
+
id TEXT PRIMARY KEY,
|
|
461
|
+
project_root TEXT NOT NULL,
|
|
462
|
+
session_id TEXT,
|
|
463
|
+
tool_use_id TEXT,
|
|
464
|
+
request_type TEXT NOT NULL,
|
|
465
|
+
tool TEXT NOT NULL,
|
|
466
|
+
reason TEXT NOT NULL CHECK(reason IN ('timeout', 'unreachable', 'protocol', 'unknown')),
|
|
467
|
+
duration_ms INTEGER NOT NULL,
|
|
468
|
+
attempts INTEGER NOT NULL,
|
|
469
|
+
created_at INTEGER NOT NULL
|
|
470
|
+
);
|
|
471
|
+
CREATE INDEX IF NOT EXISTS idx_hook_fallbacks_session
|
|
472
|
+
ON devflow_hook_fallbacks(project_root, session_id, created_at DESC);
|
|
473
|
+
|
|
474
|
+
CREATE TABLE IF NOT EXISTS devflow_work_items (
|
|
475
|
+
id TEXT PRIMARY KEY,
|
|
476
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
477
|
+
kind TEXT NOT NULL,
|
|
478
|
+
project_root TEXT NOT NULL,
|
|
479
|
+
session_id TEXT,
|
|
480
|
+
turn_id TEXT,
|
|
481
|
+
payload TEXT NOT NULL,
|
|
482
|
+
state TEXT NOT NULL DEFAULT 'pending'
|
|
483
|
+
CHECK(state IN ('pending', 'leased', 'completed', 'failed', 'dead_letter')),
|
|
484
|
+
attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts >= 0),
|
|
485
|
+
max_attempts INTEGER NOT NULL DEFAULT 5 CHECK(max_attempts > 0),
|
|
486
|
+
lease_owner TEXT,
|
|
487
|
+
lease_expires_at INTEGER,
|
|
488
|
+
next_attempt_at INTEGER NOT NULL,
|
|
489
|
+
error_category TEXT,
|
|
490
|
+
error_message TEXT,
|
|
491
|
+
created_at INTEGER NOT NULL,
|
|
492
|
+
updated_at INTEGER NOT NULL,
|
|
493
|
+
completed_at INTEGER
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
CREATE INDEX IF NOT EXISTS idx_work_items_ready
|
|
497
|
+
ON devflow_work_items(project_root, state, next_attempt_at, created_at);
|
|
498
|
+
CREATE INDEX IF NOT EXISTS idx_work_items_expired_leases
|
|
499
|
+
ON devflow_work_items(project_root, state, lease_expires_at);
|
|
500
|
+
CREATE INDEX IF NOT EXISTS idx_work_items_completed
|
|
501
|
+
ON devflow_work_items(project_root, completed_at DESC);
|
|
502
|
+
|
|
503
|
+
CREATE TABLE IF NOT EXISTS devflow_session_closures (
|
|
504
|
+
session_id TEXT NOT NULL,
|
|
505
|
+
project_root TEXT NOT NULL,
|
|
506
|
+
state TEXT NOT NULL DEFAULT 'active'
|
|
507
|
+
CHECK(state IN ('active', 'closing', 'closed', 'closed_with_pending_work')),
|
|
508
|
+
receipt_id TEXT NOT NULL UNIQUE,
|
|
509
|
+
work_item_id TEXT,
|
|
510
|
+
pending_work_count INTEGER NOT NULL DEFAULT 0 CHECK(pending_work_count >= 0),
|
|
511
|
+
requested_at INTEGER NOT NULL,
|
|
512
|
+
updated_at INTEGER NOT NULL,
|
|
513
|
+
closed_at INTEGER,
|
|
514
|
+
PRIMARY KEY (project_root, session_id)
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
CREATE INDEX IF NOT EXISTS idx_session_closures_state
|
|
518
|
+
ON devflow_session_closures(project_root, state, updated_at DESC);
|
|
381
519
|
`);
|
|
382
520
|
|
|
383
521
|
// Migration: add session_id to skill_executions (SQLite compat — ignore if exists)
|
|
@@ -394,6 +532,10 @@ export class DevFlowDatabase {
|
|
|
394
532
|
try { this.db.exec('ALTER TABLE tool_call_events ADD COLUMN tool_use_id TEXT'); } catch {}
|
|
395
533
|
try { this.db.exec('ALTER TABLE sessions ADD COLUMN metadata TEXT'); } catch {}
|
|
396
534
|
try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN metadata TEXT'); } catch {}
|
|
535
|
+
try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN failed_tool_calls INTEGER DEFAULT 0'); } catch {}
|
|
536
|
+
try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN blocked_tool_calls INTEGER DEFAULT 0'); } catch {}
|
|
537
|
+
try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN fallback_count INTEGER DEFAULT 0'); } catch {}
|
|
538
|
+
try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN fallback_reasons TEXT'); } catch {}
|
|
397
539
|
try {
|
|
398
540
|
this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_events_session_tool_use
|
|
399
541
|
ON tool_call_events(session_id, tool_use_id) WHERE tool_use_id IS NOT NULL`);
|
|
@@ -925,6 +1067,10 @@ export class DevFlowDatabase {
|
|
|
925
1067
|
totalDuration: row.total_duration,
|
|
926
1068
|
mcpComplianceRate: row.mcp_compliance_rate,
|
|
927
1069
|
missedMcpTools: row.missed_mcp_tools ? JSON.parse(row.missed_mcp_tools) : null,
|
|
1070
|
+
failedToolCalls: row.failed_tool_calls ?? 0,
|
|
1071
|
+
blockedToolCalls: row.blocked_tool_calls ?? 0,
|
|
1072
|
+
fallbackCount: row.fallback_count ?? 0,
|
|
1073
|
+
fallbackReasons: row.fallback_reasons ? JSON.parse(row.fallback_reasons) : [],
|
|
928
1074
|
createdAt: row.created_at,
|
|
929
1075
|
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
930
1076
|
};
|
|
@@ -936,6 +1082,10 @@ export class DevFlowDatabase {
|
|
|
936
1082
|
totalDuration?: number;
|
|
937
1083
|
mcpComplianceRate?: number;
|
|
938
1084
|
missedMcpTools?: string[];
|
|
1085
|
+
failedToolCalls?: number;
|
|
1086
|
+
blockedToolCalls?: number;
|
|
1087
|
+
fallbackCount?: number;
|
|
1088
|
+
fallbackReasons?: string[];
|
|
939
1089
|
totalToolCalls?: number;
|
|
940
1090
|
mcpToolCalls?: number;
|
|
941
1091
|
directToolCalls?: number;
|
|
@@ -950,6 +1100,10 @@ export class DevFlowDatabase {
|
|
|
950
1100
|
if (updates.totalDuration !== undefined) { sets.push('total_duration = ?'); values.push(updates.totalDuration); }
|
|
951
1101
|
if (updates.mcpComplianceRate !== undefined) { sets.push('mcp_compliance_rate = ?'); values.push(updates.mcpComplianceRate); }
|
|
952
1102
|
if (updates.missedMcpTools !== undefined) { sets.push('missed_mcp_tools = ?'); values.push(JSON.stringify(updates.missedMcpTools)); }
|
|
1103
|
+
if (updates.failedToolCalls !== undefined) { sets.push('failed_tool_calls = ?'); values.push(updates.failedToolCalls); }
|
|
1104
|
+
if (updates.blockedToolCalls !== undefined) { sets.push('blocked_tool_calls = ?'); values.push(updates.blockedToolCalls); }
|
|
1105
|
+
if (updates.fallbackCount !== undefined) { sets.push('fallback_count = ?'); values.push(updates.fallbackCount); }
|
|
1106
|
+
if (updates.fallbackReasons !== undefined) { sets.push('fallback_reasons = ?'); values.push(JSON.stringify(updates.fallbackReasons)); }
|
|
953
1107
|
if (updates.totalToolCalls !== undefined) { sets.push('total_tool_calls = ?'); values.push(updates.totalToolCalls); }
|
|
954
1108
|
if (updates.mcpToolCalls !== undefined) { sets.push('mcp_tool_calls = ?'); values.push(updates.mcpToolCalls); }
|
|
955
1109
|
if (updates.directToolCalls !== undefined) { sets.push('direct_tool_calls = ?'); values.push(updates.directToolCalls); }
|
|
@@ -988,6 +1142,11 @@ export class DevFlowDatabase {
|
|
|
988
1142
|
subagentCount: row.subagent_count,
|
|
989
1143
|
totalDuration: row.total_duration,
|
|
990
1144
|
mcpComplianceRate: row.mcp_compliance_rate,
|
|
1145
|
+
missedMcpTools: row.missed_mcp_tools ? JSON.parse(row.missed_mcp_tools) : [],
|
|
1146
|
+
failedToolCalls: row.failed_tool_calls ?? 0,
|
|
1147
|
+
blockedToolCalls: row.blocked_tool_calls ?? 0,
|
|
1148
|
+
fallbackCount: row.fallback_count ?? 0,
|
|
1149
|
+
fallbackReasons: row.fallback_reasons ? JSON.parse(row.fallback_reasons) : [],
|
|
991
1150
|
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
992
1151
|
}));
|
|
993
1152
|
}
|
|
@@ -1176,9 +1335,39 @@ export class DevFlowDatabase {
|
|
|
1176
1335
|
FROM tool_call_events WHERE execution_id = ?
|
|
1177
1336
|
`).get(executionId) as any;
|
|
1178
1337
|
const execution = this.getSkillExecution(executionId);
|
|
1338
|
+
const events = this.listToolCallEvents(executionId);
|
|
1179
1339
|
const total = Number(row?.total ?? 0);
|
|
1180
1340
|
const mcp = Number(row?.mcp ?? 0);
|
|
1181
1341
|
const obligation = this.getExecutionObligationCompliance(executionId);
|
|
1342
|
+
const failures = events.filter(event => Boolean(event.error) && !event.blocked);
|
|
1343
|
+
const blocked = events.filter(event => event.blocked);
|
|
1344
|
+
const directFallbacks = events.filter(event => event.mcpFallback);
|
|
1345
|
+
const hookFallbacks = execution?.sessionId
|
|
1346
|
+
? this.listHookFallbacks({
|
|
1347
|
+
sessionId: execution.sessionId,
|
|
1348
|
+
from: execution.startedAt,
|
|
1349
|
+
to: finishedAt,
|
|
1350
|
+
})
|
|
1351
|
+
: [];
|
|
1352
|
+
const fallbackReasons = [...new Set([
|
|
1353
|
+
...directFallbacks.map(() => 'direct_tool_during_context'),
|
|
1354
|
+
...hookFallbacks.map(fallback => `daemon_${fallback.reason}`),
|
|
1355
|
+
])];
|
|
1356
|
+
const eventReceiptIds = collectCanonicalReceiptIds(events.map(event => event.output));
|
|
1357
|
+
const suppliedMemoryReceiptIds = Array.isArray(metadata?.memoryReceiptIds)
|
|
1358
|
+
? metadata.memoryReceiptIds.filter((id): id is string => typeof id === 'string')
|
|
1359
|
+
: [];
|
|
1360
|
+
const suppliedDistillReceiptIds = Array.isArray(metadata?.distillReceiptIds)
|
|
1361
|
+
? metadata.distillReceiptIds.filter((id): id is string => typeof id === 'string')
|
|
1362
|
+
: [];
|
|
1363
|
+
const memoryReceiptIds = [...new Set([
|
|
1364
|
+
...suppliedMemoryReceiptIds,
|
|
1365
|
+
...eventReceiptIds.filter(id => id.startsWith('memory-receipt:')),
|
|
1366
|
+
])];
|
|
1367
|
+
const distillReceiptIds = [...new Set([
|
|
1368
|
+
...suppliedDistillReceiptIds,
|
|
1369
|
+
...eventReceiptIds.filter(id => id.startsWith('distill-receipt:')),
|
|
1370
|
+
])];
|
|
1182
1371
|
this.updateSkillExecution(executionId, {
|
|
1183
1372
|
status,
|
|
1184
1373
|
finishedAt,
|
|
@@ -1190,10 +1379,46 @@ export class DevFlowDatabase {
|
|
|
1190
1379
|
? Math.max(0, finishedAt - execution.startedAt)
|
|
1191
1380
|
: row?.first_at != null && row?.last_at != null ? row.last_at - row.first_at : 0,
|
|
1192
1381
|
mcpComplianceRate: obligation.rate,
|
|
1193
|
-
|
|
1382
|
+
missedMcpTools: obligation.missedTools,
|
|
1383
|
+
failedToolCalls: failures.length,
|
|
1384
|
+
blockedToolCalls: blocked.length,
|
|
1385
|
+
fallbackCount: directFallbacks.length + hookFallbacks.length,
|
|
1386
|
+
fallbackReasons,
|
|
1387
|
+
metadata: {
|
|
1388
|
+
...(metadata ?? {}),
|
|
1389
|
+
applicableObligations: obligation.applicable,
|
|
1390
|
+
satisfiedObligations: obligation.satisfied,
|
|
1391
|
+
missedTools: obligation.missedTools,
|
|
1392
|
+
actualFailureCount: failures.length,
|
|
1393
|
+
blockedCount: blocked.length,
|
|
1394
|
+
fallbackCount: directFallbacks.length + hookFallbacks.length,
|
|
1395
|
+
fallbackReasons,
|
|
1396
|
+
memoryReceiptIds,
|
|
1397
|
+
distillReceiptIds,
|
|
1398
|
+
},
|
|
1194
1399
|
});
|
|
1195
1400
|
}
|
|
1196
1401
|
|
|
1402
|
+
reconcileRunningSkillExecutionsForSession(
|
|
1403
|
+
sessionId: string,
|
|
1404
|
+
finishedAt = Date.now(),
|
|
1405
|
+
metadata?: Record<string, unknown>,
|
|
1406
|
+
): any[] {
|
|
1407
|
+
const rows = this.db.prepare(`
|
|
1408
|
+
SELECT execution_id
|
|
1409
|
+
FROM skill_executions
|
|
1410
|
+
WHERE session_id = ? AND status = 'running'
|
|
1411
|
+
ORDER BY started_at ASC
|
|
1412
|
+
`).all(sessionId) as Array<{ execution_id: string }>;
|
|
1413
|
+
const reconciled: any[] = [];
|
|
1414
|
+
for (const row of rows) {
|
|
1415
|
+
this.reconcileSkillExecution(row.execution_id, 'completed', finishedAt, metadata);
|
|
1416
|
+
const execution = this.getSkillExecution(row.execution_id);
|
|
1417
|
+
if (execution) reconciled.push(execution);
|
|
1418
|
+
}
|
|
1419
|
+
return reconciled;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1197
1422
|
getExecutionObligationCompliance(executionId: string): {
|
|
1198
1423
|
rate: number;
|
|
1199
1424
|
applicable: number;
|
|
@@ -1214,7 +1439,7 @@ export class DevFlowDatabase {
|
|
|
1214
1439
|
if (obligation.endsWith(':domain')) {
|
|
1215
1440
|
const family = obligation.slice(0, -':domain'.length);
|
|
1216
1441
|
return events.some((event) => {
|
|
1217
|
-
if (event.
|
|
1442
|
+
if (!event.isMcpTool || !isEffectiveToolEvent(event)) return false;
|
|
1218
1443
|
const name = String(event.mcpToolName ?? event.toolName ?? '')
|
|
1219
1444
|
.replace(/^mcp__[^_]+__/, '');
|
|
1220
1445
|
return name.startsWith(`${family}_`);
|
|
@@ -1742,6 +1967,403 @@ export class DevFlowDatabase {
|
|
|
1742
1967
|
};
|
|
1743
1968
|
}
|
|
1744
1969
|
|
|
1970
|
+
// ---- Durable Work Queue ----
|
|
1971
|
+
|
|
1972
|
+
enqueueWork(input: EnqueueWorkInput): WorkItemRecord {
|
|
1973
|
+
if (!input.idempotencyKey.trim()) throw new Error('Work idempotency key is required');
|
|
1974
|
+
if (!input.projectRoot.trim()) throw new Error('Work project root is required');
|
|
1975
|
+
const maxAttempts = input.maxAttempts ?? 5;
|
|
1976
|
+
if (!Number.isSafeInteger(maxAttempts) || maxAttempts <= 0) {
|
|
1977
|
+
throw new Error('Work maxAttempts must be a positive safe integer');
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
const createdAt = Date.now();
|
|
1981
|
+
const nextAttemptAt = input.nextAttemptAt ?? createdAt;
|
|
1982
|
+
if (!Number.isSafeInteger(nextAttemptAt)) {
|
|
1983
|
+
throw new Error('Work nextAttemptAt must be a safe integer');
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
const row = this.db.prepare(`
|
|
1987
|
+
INSERT INTO devflow_work_items (
|
|
1988
|
+
id, idempotency_key, kind, project_root, session_id, turn_id, payload,
|
|
1989
|
+
state, attempts, max_attempts, lease_owner, lease_expires_at,
|
|
1990
|
+
next_attempt_at, error_category, error_message, created_at, updated_at, completed_at
|
|
1991
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, NULL, NULL, ?, NULL, NULL, ?, ?, NULL)
|
|
1992
|
+
ON CONFLICT(idempotency_key) DO UPDATE SET
|
|
1993
|
+
state = CASE
|
|
1994
|
+
WHEN devflow_work_items.state = 'dead_letter'
|
|
1995
|
+
AND excluded.max_attempts > devflow_work_items.attempts
|
|
1996
|
+
THEN 'failed'
|
|
1997
|
+
ELSE devflow_work_items.state
|
|
1998
|
+
END,
|
|
1999
|
+
max_attempts = MAX(devflow_work_items.max_attempts, excluded.max_attempts),
|
|
2000
|
+
next_attempt_at = CASE
|
|
2001
|
+
WHEN devflow_work_items.state = 'dead_letter'
|
|
2002
|
+
AND excluded.max_attempts > devflow_work_items.attempts
|
|
2003
|
+
THEN excluded.next_attempt_at
|
|
2004
|
+
ELSE devflow_work_items.next_attempt_at
|
|
2005
|
+
END,
|
|
2006
|
+
error_category = CASE
|
|
2007
|
+
WHEN devflow_work_items.state = 'dead_letter'
|
|
2008
|
+
AND excluded.max_attempts > devflow_work_items.attempts
|
|
2009
|
+
THEN NULL
|
|
2010
|
+
ELSE devflow_work_items.error_category
|
|
2011
|
+
END,
|
|
2012
|
+
error_message = CASE
|
|
2013
|
+
WHEN devflow_work_items.state = 'dead_letter'
|
|
2014
|
+
AND excluded.max_attempts > devflow_work_items.attempts
|
|
2015
|
+
THEN NULL
|
|
2016
|
+
ELSE devflow_work_items.error_message
|
|
2017
|
+
END,
|
|
2018
|
+
updated_at = CASE
|
|
2019
|
+
WHEN devflow_work_items.state = 'dead_letter'
|
|
2020
|
+
AND excluded.max_attempts > devflow_work_items.attempts
|
|
2021
|
+
THEN excluded.updated_at
|
|
2022
|
+
ELSE devflow_work_items.updated_at
|
|
2023
|
+
END
|
|
2024
|
+
RETURNING *
|
|
2025
|
+
`).get(
|
|
2026
|
+
randomUUID(),
|
|
2027
|
+
input.idempotencyKey,
|
|
2028
|
+
input.kind,
|
|
2029
|
+
input.projectRoot,
|
|
2030
|
+
input.sessionId ?? null,
|
|
2031
|
+
input.turnId ?? null,
|
|
2032
|
+
JSON.stringify(input.payload ?? null),
|
|
2033
|
+
maxAttempts,
|
|
2034
|
+
nextAttemptAt,
|
|
2035
|
+
createdAt,
|
|
2036
|
+
createdAt,
|
|
2037
|
+
) as any;
|
|
2038
|
+
|
|
2039
|
+
return this.mapWorkItem(row);
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
getWorkByIdempotencyKey(idempotencyKey: string): WorkItemRecord | null {
|
|
2043
|
+
const row = this.db.prepare(
|
|
2044
|
+
'SELECT * FROM devflow_work_items WHERE idempotency_key = ?',
|
|
2045
|
+
).get(idempotencyKey) as any;
|
|
2046
|
+
return row ? this.mapWorkItem(row) : null;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
requestSessionClosure(input: RequestSessionClosureInput): SessionClosureRecord {
|
|
2050
|
+
if (!input.sessionId.trim()) throw new Error('Session closure requires a session ID');
|
|
2051
|
+
if (!input.projectRoot.trim()) throw new Error('Session closure requires a project root');
|
|
2052
|
+
if (!input.receiptId.trim()) throw new Error('Session closure requires a receipt ID');
|
|
2053
|
+
|
|
2054
|
+
return this.db.transaction(() => {
|
|
2055
|
+
const now = Date.now();
|
|
2056
|
+
this.db.prepare(`
|
|
2057
|
+
INSERT INTO devflow_session_closures (
|
|
2058
|
+
session_id, project_root, state, receipt_id, pending_work_count,
|
|
2059
|
+
requested_at, updated_at
|
|
2060
|
+
) VALUES (?, ?, 'active', ?, 0, ?, ?)
|
|
2061
|
+
ON CONFLICT(project_root, session_id) DO NOTHING
|
|
2062
|
+
`).run(input.sessionId, input.projectRoot, input.receiptId, now, now);
|
|
2063
|
+
|
|
2064
|
+
const work = this.enqueueWork({
|
|
2065
|
+
idempotencyKey: input.receiptId,
|
|
2066
|
+
kind: 'session.finalize',
|
|
2067
|
+
projectRoot: input.projectRoot,
|
|
2068
|
+
sessionId: input.sessionId,
|
|
2069
|
+
payload: input.payload,
|
|
2070
|
+
maxAttempts: input.maxAttempts,
|
|
2071
|
+
});
|
|
2072
|
+
const pending = this.db.prepare(`
|
|
2073
|
+
SELECT COUNT(*) AS count
|
|
2074
|
+
FROM devflow_work_items
|
|
2075
|
+
WHERE project_root = ? AND session_id = ?
|
|
2076
|
+
AND state IN ('pending', 'leased', 'failed', 'dead_letter')
|
|
2077
|
+
`).get(input.projectRoot, input.sessionId) as { count?: number };
|
|
2078
|
+
const pendingWorkCount = Number(pending?.count ?? 0);
|
|
2079
|
+
this.db.prepare(`
|
|
2080
|
+
UPDATE devflow_session_closures
|
|
2081
|
+
SET state = CASE
|
|
2082
|
+
WHEN state IN ('closed', 'closed_with_pending_work')
|
|
2083
|
+
THEN CASE WHEN ? > 0 THEN 'closed_with_pending_work' ELSE 'closed' END
|
|
2084
|
+
WHEN ? = 'completed'
|
|
2085
|
+
THEN CASE WHEN ? > 0 THEN 'closed_with_pending_work' ELSE 'closed' END
|
|
2086
|
+
WHEN ? = 'dead_letter' THEN 'closed_with_pending_work'
|
|
2087
|
+
ELSE 'closing'
|
|
2088
|
+
END,
|
|
2089
|
+
work_item_id = COALESCE(work_item_id, ?),
|
|
2090
|
+
pending_work_count = ?,
|
|
2091
|
+
closed_at = CASE WHEN ? = 'completed' THEN ? ELSE closed_at END,
|
|
2092
|
+
updated_at = ?
|
|
2093
|
+
WHERE project_root = ? AND session_id = ?
|
|
2094
|
+
`).run(
|
|
2095
|
+
pendingWorkCount,
|
|
2096
|
+
work.state,
|
|
2097
|
+
pendingWorkCount,
|
|
2098
|
+
work.state,
|
|
2099
|
+
work.id,
|
|
2100
|
+
pendingWorkCount,
|
|
2101
|
+
work.state,
|
|
2102
|
+
now,
|
|
2103
|
+
now,
|
|
2104
|
+
input.projectRoot,
|
|
2105
|
+
input.sessionId,
|
|
2106
|
+
);
|
|
2107
|
+
return this.getSessionClosure(input.projectRoot, input.sessionId)!;
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
getSessionClosure(projectRoot: string, sessionId: string): SessionClosureRecord | null {
|
|
2112
|
+
const row = this.db.prepare(`
|
|
2113
|
+
SELECT * FROM devflow_session_closures
|
|
2114
|
+
WHERE project_root = ? AND session_id = ?
|
|
2115
|
+
`).get(projectRoot, sessionId) as any;
|
|
2116
|
+
return row ? this.mapSessionClosure(row) : null;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
completeSessionClosure(
|
|
2120
|
+
projectRoot: string,
|
|
2121
|
+
sessionId: string,
|
|
2122
|
+
excludingWorkItemId?: string,
|
|
2123
|
+
closedAt = Date.now(),
|
|
2124
|
+
): SessionClosureRecord {
|
|
2125
|
+
const pending = this.db.prepare(`
|
|
2126
|
+
SELECT COUNT(*) AS count
|
|
2127
|
+
FROM devflow_work_items
|
|
2128
|
+
WHERE project_root = ? AND session_id = ?
|
|
2129
|
+
AND state IN ('pending', 'leased', 'failed', 'dead_letter')
|
|
2130
|
+
AND (? IS NULL OR id <> ?)
|
|
2131
|
+
`).get(projectRoot, sessionId, excludingWorkItemId ?? null, excludingWorkItemId ?? null) as { count?: number };
|
|
2132
|
+
const pendingWorkCount = Number(pending?.count ?? 0);
|
|
2133
|
+
const state = pendingWorkCount > 0 ? 'closed_with_pending_work' : 'closed';
|
|
2134
|
+
this.db.prepare(`
|
|
2135
|
+
UPDATE devflow_session_closures
|
|
2136
|
+
SET state = ?, pending_work_count = ?, closed_at = ?, updated_at = ?
|
|
2137
|
+
WHERE project_root = ? AND session_id = ? AND state = 'closing'
|
|
2138
|
+
`).run(state, pendingWorkCount, closedAt, closedAt, projectRoot, sessionId);
|
|
2139
|
+
const closure = this.getSessionClosure(projectRoot, sessionId);
|
|
2140
|
+
if (!closure) throw new Error(`Session closure ${sessionId} does not exist`);
|
|
2141
|
+
return closure;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
listSessionClosures(projectRoot: string, limit = 100): SessionClosureRecord[] {
|
|
2145
|
+
const rows = this.db.prepare(`
|
|
2146
|
+
SELECT * FROM devflow_session_closures
|
|
2147
|
+
WHERE project_root = ?
|
|
2148
|
+
ORDER BY updated_at DESC
|
|
2149
|
+
LIMIT ?
|
|
2150
|
+
`).all(projectRoot, Math.max(1, Math.min(limit, 1_000))) as any[];
|
|
2151
|
+
return rows.map(row => this.mapSessionClosure(row));
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
leaseWork(input: LeaseWorkInput): WorkItemRecord[] {
|
|
2155
|
+
if (!input.owner.trim()) throw new Error('Work lease owner is required');
|
|
2156
|
+
if (!Number.isFinite(input.limit) || input.limit <= 0) return [];
|
|
2157
|
+
if (!Number.isSafeInteger(input.leaseMs) || input.leaseMs <= 0) {
|
|
2158
|
+
throw new Error('Work leaseMs must be a positive safe integer');
|
|
2159
|
+
}
|
|
2160
|
+
if (input.kinds?.length === 0) return [];
|
|
2161
|
+
|
|
2162
|
+
const now = input.now ?? Date.now();
|
|
2163
|
+
const leaseExpiresAt = now + input.leaseMs;
|
|
2164
|
+
if (!Number.isSafeInteger(now) || !Number.isSafeInteger(leaseExpiresAt)) {
|
|
2165
|
+
throw new Error('Work lease timestamps must be safe integers');
|
|
2166
|
+
}
|
|
2167
|
+
const limit = Math.min(Math.floor(input.limit), 1_000);
|
|
2168
|
+
const kinds = input.kinds ? [...new Set(input.kinds)] : undefined;
|
|
2169
|
+
const kindClause = kinds
|
|
2170
|
+
? `AND kind IN (${kinds.map(() => '?').join(', ')})`
|
|
2171
|
+
: '';
|
|
2172
|
+
|
|
2173
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
2174
|
+
try {
|
|
2175
|
+
const candidates = this.db.prepare(`
|
|
2176
|
+
SELECT id, state
|
|
2177
|
+
FROM devflow_work_items
|
|
2178
|
+
WHERE project_root = ?
|
|
2179
|
+
AND state IN ('pending', 'failed')
|
|
2180
|
+
AND lease_owner IS NULL
|
|
2181
|
+
AND lease_expires_at IS NULL
|
|
2182
|
+
AND next_attempt_at <= ?
|
|
2183
|
+
AND attempts < max_attempts
|
|
2184
|
+
${kindClause}
|
|
2185
|
+
ORDER BY next_attempt_at ASC, created_at ASC, id ASC
|
|
2186
|
+
LIMIT ?
|
|
2187
|
+
`).all(input.projectRoot, now, ...(kinds ?? []), limit) as Array<{
|
|
2188
|
+
id: string;
|
|
2189
|
+
state: WorkState;
|
|
2190
|
+
}>;
|
|
2191
|
+
|
|
2192
|
+
const leased: WorkItemRecord[] = [];
|
|
2193
|
+
for (const candidate of candidates) {
|
|
2194
|
+
const result = this.db.prepare(`
|
|
2195
|
+
UPDATE devflow_work_items
|
|
2196
|
+
SET state = 'leased', attempts = attempts + 1, lease_owner = ?,
|
|
2197
|
+
lease_expires_at = ?, updated_at = ?
|
|
2198
|
+
WHERE id = ? AND project_root = ? AND state = ?
|
|
2199
|
+
AND lease_owner IS NULL AND lease_expires_at IS NULL
|
|
2200
|
+
AND next_attempt_at <= ? AND attempts < max_attempts
|
|
2201
|
+
`).run(
|
|
2202
|
+
input.owner,
|
|
2203
|
+
leaseExpiresAt,
|
|
2204
|
+
now,
|
|
2205
|
+
candidate.id,
|
|
2206
|
+
input.projectRoot,
|
|
2207
|
+
candidate.state,
|
|
2208
|
+
now,
|
|
2209
|
+
);
|
|
2210
|
+
if (result.changes === 1) {
|
|
2211
|
+
const row = this.db.prepare('SELECT * FROM devflow_work_items WHERE id = ?')
|
|
2212
|
+
.get(candidate.id) as any;
|
|
2213
|
+
leased.push(this.mapWorkItem(row));
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
this.db.exec('COMMIT');
|
|
2218
|
+
return leased;
|
|
2219
|
+
} catch (error) {
|
|
2220
|
+
try { this.db.exec('ROLLBACK'); } catch {}
|
|
2221
|
+
throw error;
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
completeWork(id: string, owner: string, now = Date.now()): boolean {
|
|
2226
|
+
const work = this.db.prepare(`
|
|
2227
|
+
SELECT project_root, session_id FROM devflow_work_items WHERE id = ?
|
|
2228
|
+
`).get(id) as { project_root?: string; session_id?: string } | undefined;
|
|
2229
|
+
const completed = this.db.prepare(`
|
|
2230
|
+
UPDATE devflow_work_items
|
|
2231
|
+
SET state = 'completed', lease_owner = NULL, lease_expires_at = NULL,
|
|
2232
|
+
updated_at = ?, completed_at = ?
|
|
2233
|
+
WHERE id = ? AND state = 'leased' AND lease_owner = ?
|
|
2234
|
+
`).run(now, now, id, owner).changes === 1;
|
|
2235
|
+
if (completed && work?.project_root && work.session_id) {
|
|
2236
|
+
this.refreshClosedSessionClosure(work.project_root, work.session_id, now);
|
|
2237
|
+
}
|
|
2238
|
+
return completed;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
retryWork(id: string, owner: string, error: WorkError, nextAttemptAt: number): boolean {
|
|
2242
|
+
const updatedAt = Date.now();
|
|
2243
|
+
return this.db.prepare(`
|
|
2244
|
+
UPDATE devflow_work_items
|
|
2245
|
+
SET state = CASE WHEN attempts >= max_attempts THEN 'dead_letter' ELSE 'failed' END,
|
|
2246
|
+
lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
|
|
2247
|
+
error_category = ?, error_message = ?, updated_at = ?
|
|
2248
|
+
WHERE id = ? AND state = 'leased' AND lease_owner = ?
|
|
2249
|
+
`).run(
|
|
2250
|
+
nextAttemptAt,
|
|
2251
|
+
error.category,
|
|
2252
|
+
error.message,
|
|
2253
|
+
updatedAt,
|
|
2254
|
+
id,
|
|
2255
|
+
owner,
|
|
2256
|
+
).changes === 1;
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
deadLetterWork(id: string, owner: string, error: WorkError): boolean {
|
|
2260
|
+
return this.db.prepare(`
|
|
2261
|
+
UPDATE devflow_work_items
|
|
2262
|
+
SET state = 'dead_letter', lease_owner = NULL, lease_expires_at = NULL,
|
|
2263
|
+
error_category = ?, error_message = ?, updated_at = ?
|
|
2264
|
+
WHERE id = ? AND state = 'leased' AND lease_owner = ?
|
|
2265
|
+
`).run(error.category, error.message, Date.now(), id, owner).changes === 1;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
recoverExpiredWork(projectRoot: string, now = Date.now()): number {
|
|
2269
|
+
return this.db.prepare(`
|
|
2270
|
+
UPDATE devflow_work_items
|
|
2271
|
+
SET state = CASE WHEN attempts >= max_attempts THEN 'dead_letter' ELSE 'failed' END,
|
|
2272
|
+
lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
|
|
2273
|
+
error_category = 'lease_expired',
|
|
2274
|
+
error_message = 'Work lease expired before completion',
|
|
2275
|
+
updated_at = ?
|
|
2276
|
+
WHERE project_root = ? AND state = 'leased' AND lease_expires_at <= ?
|
|
2277
|
+
`).run(now, now, projectRoot, now).changes;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
getWorkQueueHealth(projectRoot: string, now = Date.now()): WorkQueueHealth {
|
|
2281
|
+
const row = this.db.prepare(`
|
|
2282
|
+
SELECT
|
|
2283
|
+
COALESCE(SUM(CASE WHEN state IN ('pending', 'leased', 'failed') THEN 1 ELSE 0 END), 0) AS queue_depth,
|
|
2284
|
+
COALESCE(SUM(CASE WHEN state = 'pending' THEN 1 ELSE 0 END), 0) AS pending,
|
|
2285
|
+
COALESCE(SUM(CASE WHEN state = 'leased' THEN 1 ELSE 0 END), 0) AS leased,
|
|
2286
|
+
COALESCE(SUM(CASE WHEN state = 'failed' THEN 1 ELSE 0 END), 0) AS failed,
|
|
2287
|
+
COALESCE(SUM(CASE WHEN state = 'leased' AND lease_expires_at <= ? THEN 1 ELSE 0 END), 0) AS expired_leases,
|
|
2288
|
+
COALESCE(SUM(CASE WHEN state = 'dead_letter' THEN 1 ELSE 0 END), 0) AS dead_letters,
|
|
2289
|
+
MIN(CASE WHEN state IN ('pending', 'leased', 'failed') THEN created_at END) AS oldest_pending_at,
|
|
2290
|
+
MAX(CASE WHEN state = 'completed' THEN completed_at END) AS last_successful_drain_at
|
|
2291
|
+
FROM devflow_work_items
|
|
2292
|
+
WHERE project_root = ?
|
|
2293
|
+
`).get(now, projectRoot) as any;
|
|
2294
|
+
const oldestPendingAt = row.oldest_pending_at == null
|
|
2295
|
+
? undefined
|
|
2296
|
+
: Number(row.oldest_pending_at);
|
|
2297
|
+
|
|
2298
|
+
return {
|
|
2299
|
+
projectRoot,
|
|
2300
|
+
queueDepth: Number(row.queue_depth),
|
|
2301
|
+
pending: Number(row.pending),
|
|
2302
|
+
leased: Number(row.leased),
|
|
2303
|
+
failed: Number(row.failed),
|
|
2304
|
+
expiredLeases: Number(row.expired_leases),
|
|
2305
|
+
deadLetters: Number(row.dead_letters),
|
|
2306
|
+
oldestPendingAgeMs: oldestPendingAt === undefined ? 0 : Math.max(0, now - oldestPendingAt),
|
|
2307
|
+
lastSuccessfulDrainAt: row.last_successful_drain_at == null
|
|
2308
|
+
? undefined
|
|
2309
|
+
: Number(row.last_successful_drain_at),
|
|
2310
|
+
};
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
private mapWorkItem(row: any): WorkItemRecord {
|
|
2314
|
+
return {
|
|
2315
|
+
id: row.id,
|
|
2316
|
+
idempotencyKey: row.idempotency_key,
|
|
2317
|
+
kind: row.kind,
|
|
2318
|
+
projectRoot: row.project_root,
|
|
2319
|
+
sessionId: row.session_id ?? undefined,
|
|
2320
|
+
turnId: row.turn_id ?? undefined,
|
|
2321
|
+
payload: parseJson(row.payload),
|
|
2322
|
+
state: row.state,
|
|
2323
|
+
attempts: Number(row.attempts),
|
|
2324
|
+
maxAttempts: Number(row.max_attempts),
|
|
2325
|
+
leaseOwner: row.lease_owner ?? undefined,
|
|
2326
|
+
leaseExpiresAt: row.lease_expires_at == null ? undefined : Number(row.lease_expires_at),
|
|
2327
|
+
nextAttemptAt: Number(row.next_attempt_at),
|
|
2328
|
+
errorCategory: row.error_category ?? undefined,
|
|
2329
|
+
errorMessage: row.error_message ?? undefined,
|
|
2330
|
+
createdAt: Number(row.created_at),
|
|
2331
|
+
updatedAt: Number(row.updated_at),
|
|
2332
|
+
completedAt: row.completed_at == null ? undefined : Number(row.completed_at),
|
|
2333
|
+
};
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
private mapSessionClosure(row: any): SessionClosureRecord {
|
|
2337
|
+
return {
|
|
2338
|
+
sessionId: row.session_id,
|
|
2339
|
+
projectRoot: row.project_root,
|
|
2340
|
+
state: row.state,
|
|
2341
|
+
receiptId: row.receipt_id,
|
|
2342
|
+
workItemId: row.work_item_id ?? undefined,
|
|
2343
|
+
pendingWorkCount: Number(row.pending_work_count),
|
|
2344
|
+
requestedAt: Number(row.requested_at),
|
|
2345
|
+
updatedAt: Number(row.updated_at),
|
|
2346
|
+
closedAt: row.closed_at == null ? undefined : Number(row.closed_at),
|
|
2347
|
+
};
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
private refreshClosedSessionClosure(projectRoot: string, sessionId: string, now: number): void {
|
|
2351
|
+
const pending = this.db.prepare(`
|
|
2352
|
+
SELECT COUNT(*) AS count
|
|
2353
|
+
FROM devflow_work_items
|
|
2354
|
+
WHERE project_root = ? AND session_id = ?
|
|
2355
|
+
AND state IN ('pending', 'leased', 'failed', 'dead_letter')
|
|
2356
|
+
`).get(projectRoot, sessionId) as { count?: number };
|
|
2357
|
+
const pendingWorkCount = Number(pending?.count ?? 0);
|
|
2358
|
+
this.db.prepare(`
|
|
2359
|
+
UPDATE devflow_session_closures
|
|
2360
|
+
SET state = CASE WHEN ? = 0 THEN 'closed' ELSE 'closed_with_pending_work' END,
|
|
2361
|
+
pending_work_count = ?, updated_at = ?
|
|
2362
|
+
WHERE project_root = ? AND session_id = ?
|
|
2363
|
+
AND state IN ('closed', 'closed_with_pending_work')
|
|
2364
|
+
`).run(pendingWorkCount, pendingWorkCount, now, projectRoot, sessionId);
|
|
2365
|
+
}
|
|
2366
|
+
|
|
1745
2367
|
// ---- Hook Lifecycle ----
|
|
1746
2368
|
|
|
1747
2369
|
getHookReceipt(projectRoot: string): HookReceiptRecord | null {
|
|
@@ -1853,6 +2475,183 @@ export class DevFlowDatabase {
|
|
|
1853
2475
|
.run(now).changes;
|
|
1854
2476
|
}
|
|
1855
2477
|
|
|
2478
|
+
beginMemoryTurn(input: Omit<MemoryTurnRecord, 'status' | 'memoryIds'>): MemoryTurnRecord {
|
|
2479
|
+
this.db.prepare(`
|
|
2480
|
+
INSERT OR IGNORE INTO devflow_memory_turns
|
|
2481
|
+
(turn_id, project_root, session_id, prompt_hash, event_id, status, memory_ids, created_at)
|
|
2482
|
+
VALUES (?, ?, ?, ?, ?, 'pending', '[]', ?)
|
|
2483
|
+
`).run(
|
|
2484
|
+
input.turnId,
|
|
2485
|
+
input.projectRoot,
|
|
2486
|
+
input.sessionId,
|
|
2487
|
+
input.promptHash,
|
|
2488
|
+
input.eventId,
|
|
2489
|
+
input.createdAt,
|
|
2490
|
+
);
|
|
2491
|
+
return this.getMemoryTurn(input.turnId)!;
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
getMemoryTurn(turnId: string): MemoryTurnRecord | null {
|
|
2495
|
+
const row = this.db.prepare(
|
|
2496
|
+
'SELECT * FROM devflow_memory_turns WHERE turn_id = ?',
|
|
2497
|
+
).get(turnId) as any;
|
|
2498
|
+
return row ? this.mapMemoryTurn(row) : null;
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
getPendingMemoryTurn(projectRoot: string, sessionId: string): MemoryTurnRecord | null {
|
|
2502
|
+
const row = this.db.prepare(`
|
|
2503
|
+
SELECT * FROM devflow_memory_turns
|
|
2504
|
+
WHERE project_root = ? AND session_id = ? AND status = 'pending'
|
|
2505
|
+
ORDER BY created_at DESC LIMIT 1
|
|
2506
|
+
`).get(projectRoot, sessionId) as any;
|
|
2507
|
+
return row ? this.mapMemoryTurn(row) : null;
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
commitMemoryTurn(input: {
|
|
2511
|
+
turnId: string;
|
|
2512
|
+
receiptId: string;
|
|
2513
|
+
memoryIds: string[];
|
|
2514
|
+
source: string;
|
|
2515
|
+
reason?: string;
|
|
2516
|
+
decidedAt?: number;
|
|
2517
|
+
}): MemoryTurnRecord {
|
|
2518
|
+
this.db.prepare(`
|
|
2519
|
+
UPDATE devflow_memory_turns
|
|
2520
|
+
SET status = 'committed', receipt_id = ?, memory_ids = ?, source = ?, reason = ?, decided_at = ?
|
|
2521
|
+
WHERE turn_id = ? AND status = 'pending'
|
|
2522
|
+
`).run(
|
|
2523
|
+
input.receiptId,
|
|
2524
|
+
JSON.stringify([...new Set(input.memoryIds)]),
|
|
2525
|
+
input.source,
|
|
2526
|
+
input.reason ?? null,
|
|
2527
|
+
input.decidedAt ?? Date.now(),
|
|
2528
|
+
input.turnId,
|
|
2529
|
+
);
|
|
2530
|
+
const turn = this.getMemoryTurn(input.turnId);
|
|
2531
|
+
if (!turn || turn.status !== 'committed') {
|
|
2532
|
+
throw new Error(`Memory turn ${input.turnId} is not pending or does not exist`);
|
|
2533
|
+
}
|
|
2534
|
+
return turn;
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
skipMemoryTurn(input: {
|
|
2538
|
+
turnId: string;
|
|
2539
|
+
receiptId: string;
|
|
2540
|
+
reason: string;
|
|
2541
|
+
decidedAt?: number;
|
|
2542
|
+
}): MemoryTurnRecord {
|
|
2543
|
+
this.db.prepare(`
|
|
2544
|
+
UPDATE devflow_memory_turns
|
|
2545
|
+
SET status = 'skipped', receipt_id = ?, memory_ids = '[]', source = 'host_skip',
|
|
2546
|
+
reason = ?, decided_at = ?
|
|
2547
|
+
WHERE turn_id = ? AND status = 'pending'
|
|
2548
|
+
`).run(
|
|
2549
|
+
input.receiptId,
|
|
2550
|
+
input.reason,
|
|
2551
|
+
input.decidedAt ?? Date.now(),
|
|
2552
|
+
input.turnId,
|
|
2553
|
+
);
|
|
2554
|
+
const turn = this.getMemoryTurn(input.turnId);
|
|
2555
|
+
if (!turn || turn.status !== 'skipped') {
|
|
2556
|
+
throw new Error(`Memory turn ${input.turnId} is not pending or does not exist`);
|
|
2557
|
+
}
|
|
2558
|
+
return turn;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
markMemoryTurnStopPrompted(turnId: string, promptedAt = Date.now()): boolean {
|
|
2562
|
+
return this.db.prepare(`
|
|
2563
|
+
UPDATE devflow_memory_turns SET stop_prompted_at = ?
|
|
2564
|
+
WHERE turn_id = ? AND status = 'pending' AND stop_prompted_at IS NULL
|
|
2565
|
+
`).run(promptedAt, turnId).changes === 1;
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
listMemoryTurns(projectRoot: string, sessionId?: string, limit = 50): MemoryTurnRecord[] {
|
|
2569
|
+
const rows = (sessionId
|
|
2570
|
+
? this.db.prepare(`SELECT * FROM devflow_memory_turns
|
|
2571
|
+
WHERE project_root = ? AND session_id = ? ORDER BY created_at DESC LIMIT ?`)
|
|
2572
|
+
.all(projectRoot, sessionId, limit)
|
|
2573
|
+
: this.db.prepare(`SELECT * FROM devflow_memory_turns
|
|
2574
|
+
WHERE project_root = ? ORDER BY created_at DESC LIMIT ?`)
|
|
2575
|
+
.all(projectRoot, limit)) as any[];
|
|
2576
|
+
return rows.map(row => this.mapMemoryTurn(row));
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
insertHookFallback(record: HookFallbackRecord): boolean {
|
|
2580
|
+
return this.db.prepare(`
|
|
2581
|
+
INSERT OR IGNORE INTO devflow_hook_fallbacks
|
|
2582
|
+
(id, project_root, session_id, tool_use_id, request_type, tool, reason,
|
|
2583
|
+
duration_ms, attempts, created_at)
|
|
2584
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2585
|
+
`).run(
|
|
2586
|
+
record.id,
|
|
2587
|
+
record.projectRoot,
|
|
2588
|
+
record.sessionId ?? null,
|
|
2589
|
+
record.toolUseId ?? null,
|
|
2590
|
+
record.requestType,
|
|
2591
|
+
record.tool,
|
|
2592
|
+
record.reason,
|
|
2593
|
+
record.durationMs,
|
|
2594
|
+
record.attempts,
|
|
2595
|
+
record.createdAt,
|
|
2596
|
+
).changes === 1;
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
listHookFallbacks(filter: {
|
|
2600
|
+
projectRoot?: string;
|
|
2601
|
+
sessionId?: string;
|
|
2602
|
+
from?: number;
|
|
2603
|
+
to?: number;
|
|
2604
|
+
limit?: number;
|
|
2605
|
+
} = {}): HookFallbackRecord[] {
|
|
2606
|
+
const conditions: string[] = [];
|
|
2607
|
+
const values: unknown[] = [];
|
|
2608
|
+
if (filter.projectRoot) { conditions.push('project_root = ?'); values.push(filter.projectRoot); }
|
|
2609
|
+
if (filter.sessionId) { conditions.push('session_id = ?'); values.push(filter.sessionId); }
|
|
2610
|
+
if (filter.from !== undefined) { conditions.push('created_at >= ?'); values.push(filter.from); }
|
|
2611
|
+
if (filter.to !== undefined) { conditions.push('created_at <= ?'); values.push(filter.to); }
|
|
2612
|
+
values.push(Math.max(1, Math.min(filter.limit ?? 500, 5_000)));
|
|
2613
|
+
const rows = this.db.prepare(`
|
|
2614
|
+
SELECT * FROM devflow_hook_fallbacks
|
|
2615
|
+
${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''}
|
|
2616
|
+
ORDER BY created_at DESC LIMIT ?
|
|
2617
|
+
`).all(...values) as any[];
|
|
2618
|
+
return rows.map(row => ({
|
|
2619
|
+
id: row.id,
|
|
2620
|
+
projectRoot: row.project_root,
|
|
2621
|
+
sessionId: row.session_id ?? undefined,
|
|
2622
|
+
toolUseId: row.tool_use_id ?? undefined,
|
|
2623
|
+
requestType: row.request_type,
|
|
2624
|
+
tool: row.tool,
|
|
2625
|
+
reason: row.reason,
|
|
2626
|
+
durationMs: row.duration_ms,
|
|
2627
|
+
attempts: row.attempts,
|
|
2628
|
+
createdAt: row.created_at,
|
|
2629
|
+
}));
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
private mapMemoryTurn(row: any): MemoryTurnRecord {
|
|
2633
|
+
let memoryIds: string[] = [];
|
|
2634
|
+
try {
|
|
2635
|
+
const parsed: unknown = JSON.parse(row.memory_ids ?? '[]');
|
|
2636
|
+
if (Array.isArray(parsed)) memoryIds = parsed.filter((id): id is string => typeof id === 'string');
|
|
2637
|
+
} catch {}
|
|
2638
|
+
return {
|
|
2639
|
+
turnId: row.turn_id,
|
|
2640
|
+
projectRoot: row.project_root,
|
|
2641
|
+
sessionId: row.session_id,
|
|
2642
|
+
promptHash: row.prompt_hash,
|
|
2643
|
+
eventId: row.event_id,
|
|
2644
|
+
status: row.status,
|
|
2645
|
+
receiptId: row.receipt_id ?? undefined,
|
|
2646
|
+
memoryIds,
|
|
2647
|
+
source: row.source ?? undefined,
|
|
2648
|
+
reason: row.reason ?? undefined,
|
|
2649
|
+
stopPromptedAt: row.stop_prompted_at ?? undefined,
|
|
2650
|
+
createdAt: row.created_at,
|
|
2651
|
+
decidedAt: row.decided_at ?? undefined,
|
|
2652
|
+
};
|
|
2653
|
+
}
|
|
2654
|
+
|
|
1856
2655
|
recordMemoryDistillCheckpoint(checkpoint: MemoryDistillCheckpointRecord): void {
|
|
1857
2656
|
this.db.prepare(`
|
|
1858
2657
|
INSERT INTO devflow_memory_distill_checkpoints
|
|
@@ -1945,3 +2744,29 @@ function countTelemetryResults(value: unknown, depth = 0): number {
|
|
|
1945
2744
|
if (record.error) return 0;
|
|
1946
2745
|
return Object.keys(record).length > 0 ? 1 : 0;
|
|
1947
2746
|
}
|
|
2747
|
+
|
|
2748
|
+
function collectCanonicalReceiptIds(values: unknown[]): string[] {
|
|
2749
|
+
const receiptIds = new Set<string>();
|
|
2750
|
+
const visit = (value: unknown, depth: number): void => {
|
|
2751
|
+
if (depth > 5 || value == null) return;
|
|
2752
|
+
if (typeof value === 'string') {
|
|
2753
|
+
if (value.startsWith('memory-receipt:') || value.startsWith('distill-receipt:')) {
|
|
2754
|
+
receiptIds.add(value);
|
|
2755
|
+
return;
|
|
2756
|
+
}
|
|
2757
|
+
if ((value.startsWith('{') || value.startsWith('[')) && value.length < 1_000_000) {
|
|
2758
|
+
try { visit(JSON.parse(value), depth + 1); } catch {}
|
|
2759
|
+
}
|
|
2760
|
+
return;
|
|
2761
|
+
}
|
|
2762
|
+
if (Array.isArray(value)) {
|
|
2763
|
+
value.forEach(item => visit(item, depth + 1));
|
|
2764
|
+
return;
|
|
2765
|
+
}
|
|
2766
|
+
if (typeof value === 'object') {
|
|
2767
|
+
Object.values(value as Record<string, unknown>).forEach(item => visit(item, depth + 1));
|
|
2768
|
+
}
|
|
2769
|
+
};
|
|
2770
|
+
values.forEach(value => visit(value, 0));
|
|
2771
|
+
return [...receiptIds];
|
|
2772
|
+
}
|