@devflow-tools/database 0.16.10 → 0.16.12
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 +27 -0
- package/__tests__/database.skill-executions.test.ts +94 -1
- package/__tests__/database.work-queue.test.ts +240 -0
- package/dist/database.d.ts +22 -0
- package/dist/database.js +514 -16
- package/dist/index.d.ts +1 -0
- package/dist/node-sqlite.js +1 -6
- package/dist/work-queue.d.ts +74 -0
- package/dist/work-queue.js +2 -0
- package/package.json +3 -3
- package/src/database.ts +605 -15
- package/src/index.ts +12 -0
- package/src/node-sqlite.ts +1 -6
- 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;
|
|
@@ -130,6 +141,7 @@ export interface MemoryDistillCheckpointRecord {
|
|
|
130
141
|
trigger: 'pre_compact' | 'session_end';
|
|
131
142
|
pendingEvents: number;
|
|
132
143
|
releasedLeases: number;
|
|
144
|
+
details?: Record<string, unknown>;
|
|
133
145
|
createdAt: number;
|
|
134
146
|
}
|
|
135
147
|
|
|
@@ -417,6 +429,7 @@ export class DevFlowDatabase {
|
|
|
417
429
|
trigger TEXT NOT NULL CHECK(trigger IN ('pre_compact', 'session_end')),
|
|
418
430
|
pending_events INTEGER NOT NULL DEFAULT 0,
|
|
419
431
|
released_leases INTEGER NOT NULL DEFAULT 0,
|
|
432
|
+
details TEXT NOT NULL DEFAULT '{}',
|
|
420
433
|
created_at INTEGER NOT NULL
|
|
421
434
|
);
|
|
422
435
|
|
|
@@ -459,6 +472,52 @@ export class DevFlowDatabase {
|
|
|
459
472
|
);
|
|
460
473
|
CREATE INDEX IF NOT EXISTS idx_hook_fallbacks_session
|
|
461
474
|
ON devflow_hook_fallbacks(project_root, session_id, created_at DESC);
|
|
475
|
+
|
|
476
|
+
CREATE TABLE IF NOT EXISTS devflow_work_items (
|
|
477
|
+
id TEXT PRIMARY KEY,
|
|
478
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
479
|
+
kind TEXT NOT NULL,
|
|
480
|
+
project_root TEXT NOT NULL,
|
|
481
|
+
session_id TEXT,
|
|
482
|
+
turn_id TEXT,
|
|
483
|
+
payload TEXT NOT NULL,
|
|
484
|
+
state TEXT NOT NULL DEFAULT 'pending'
|
|
485
|
+
CHECK(state IN ('pending', 'leased', 'completed', 'failed', 'dead_letter')),
|
|
486
|
+
attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts >= 0),
|
|
487
|
+
max_attempts INTEGER NOT NULL DEFAULT 5 CHECK(max_attempts > 0),
|
|
488
|
+
lease_owner TEXT,
|
|
489
|
+
lease_expires_at INTEGER,
|
|
490
|
+
next_attempt_at INTEGER NOT NULL,
|
|
491
|
+
error_category TEXT,
|
|
492
|
+
error_message TEXT,
|
|
493
|
+
created_at INTEGER NOT NULL,
|
|
494
|
+
updated_at INTEGER NOT NULL,
|
|
495
|
+
completed_at INTEGER
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
CREATE INDEX IF NOT EXISTS idx_work_items_ready
|
|
499
|
+
ON devflow_work_items(project_root, state, next_attempt_at, created_at);
|
|
500
|
+
CREATE INDEX IF NOT EXISTS idx_work_items_expired_leases
|
|
501
|
+
ON devflow_work_items(project_root, state, lease_expires_at);
|
|
502
|
+
CREATE INDEX IF NOT EXISTS idx_work_items_completed
|
|
503
|
+
ON devflow_work_items(project_root, completed_at DESC);
|
|
504
|
+
|
|
505
|
+
CREATE TABLE IF NOT EXISTS devflow_session_closures (
|
|
506
|
+
session_id TEXT NOT NULL,
|
|
507
|
+
project_root TEXT NOT NULL,
|
|
508
|
+
state TEXT NOT NULL DEFAULT 'active'
|
|
509
|
+
CHECK(state IN ('active', 'closing', 'closed', 'closed_with_pending_work')),
|
|
510
|
+
receipt_id TEXT NOT NULL UNIQUE,
|
|
511
|
+
work_item_id TEXT,
|
|
512
|
+
pending_work_count INTEGER NOT NULL DEFAULT 0 CHECK(pending_work_count >= 0),
|
|
513
|
+
requested_at INTEGER NOT NULL,
|
|
514
|
+
updated_at INTEGER NOT NULL,
|
|
515
|
+
closed_at INTEGER,
|
|
516
|
+
PRIMARY KEY (project_root, session_id)
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
CREATE INDEX IF NOT EXISTS idx_session_closures_state
|
|
520
|
+
ON devflow_session_closures(project_root, state, updated_at DESC);
|
|
462
521
|
`);
|
|
463
522
|
|
|
464
523
|
// Migration: add session_id to skill_executions (SQLite compat — ignore if exists)
|
|
@@ -475,6 +534,7 @@ export class DevFlowDatabase {
|
|
|
475
534
|
try { this.db.exec('ALTER TABLE tool_call_events ADD COLUMN tool_use_id TEXT'); } catch {}
|
|
476
535
|
try { this.db.exec('ALTER TABLE sessions ADD COLUMN metadata TEXT'); } catch {}
|
|
477
536
|
try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN metadata TEXT'); } catch {}
|
|
537
|
+
try { this.db.exec("ALTER TABLE devflow_memory_distill_checkpoints ADD COLUMN details TEXT NOT NULL DEFAULT '{}'"); } catch {}
|
|
478
538
|
try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN failed_tool_calls INTEGER DEFAULT 0'); } catch {}
|
|
479
539
|
try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN blocked_tool_calls INTEGER DEFAULT 0'); } catch {}
|
|
480
540
|
try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN fallback_count INTEGER DEFAULT 0'); } catch {}
|
|
@@ -1019,6 +1079,16 @@ export class DevFlowDatabase {
|
|
|
1019
1079
|
};
|
|
1020
1080
|
}
|
|
1021
1081
|
|
|
1082
|
+
getLatestSkillExecutionForSession(sessionId: string): any | null {
|
|
1083
|
+
const row = this.db.prepare(`
|
|
1084
|
+
SELECT execution_id FROM skill_executions
|
|
1085
|
+
WHERE session_id = ?
|
|
1086
|
+
ORDER BY started_at DESC, execution_id DESC
|
|
1087
|
+
LIMIT 1
|
|
1088
|
+
`).get(sessionId) as { execution_id?: string } | undefined;
|
|
1089
|
+
return row?.execution_id ? this.getSkillExecution(row.execution_id) : null;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1022
1092
|
updateSkillExecution(executionId: string, updates: {
|
|
1023
1093
|
status?: string;
|
|
1024
1094
|
finishedAt?: number;
|
|
@@ -1059,6 +1129,20 @@ export class DevFlowDatabase {
|
|
|
1059
1129
|
}
|
|
1060
1130
|
}
|
|
1061
1131
|
|
|
1132
|
+
mergeSkillExecutionMetadata(
|
|
1133
|
+
executionId: string,
|
|
1134
|
+
incoming: Record<string, unknown>,
|
|
1135
|
+
): Record<string, unknown> | null {
|
|
1136
|
+
const execution = this.getSkillExecution(executionId);
|
|
1137
|
+
if (!execution) return null;
|
|
1138
|
+
const existing = execution.metadata && typeof execution.metadata === 'object'
|
|
1139
|
+
? execution.metadata as Record<string, unknown>
|
|
1140
|
+
: {};
|
|
1141
|
+
const merged = mergeExecutionMetadata(existing, incoming);
|
|
1142
|
+
this.updateSkillExecution(executionId, { metadata: merged });
|
|
1143
|
+
return merged;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1062
1146
|
listSkillExecutions(limit: number, skillName?: string): any[] {
|
|
1063
1147
|
let query = 'SELECT * FROM skill_executions';
|
|
1064
1148
|
const params: any[] = [];
|
|
@@ -1286,23 +1370,38 @@ export class DevFlowDatabase {
|
|
|
1286
1370
|
const blocked = events.filter(event => event.blocked);
|
|
1287
1371
|
const directFallbacks = events.filter(event => event.mcpFallback);
|
|
1288
1372
|
const hookFallbacks = execution?.sessionId
|
|
1289
|
-
? this.
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1373
|
+
? (this.db.prepare(`
|
|
1374
|
+
SELECT * FROM devflow_hook_fallbacks
|
|
1375
|
+
WHERE session_id = ? AND created_at >= ? AND created_at <= ?
|
|
1376
|
+
ORDER BY created_at ASC
|
|
1377
|
+
`).all(execution.sessionId, execution.startedAt, finishedAt) as any[]).map(row => ({
|
|
1378
|
+
id: row.id,
|
|
1379
|
+
projectRoot: row.project_root,
|
|
1380
|
+
sessionId: row.session_id ?? undefined,
|
|
1381
|
+
toolUseId: row.tool_use_id ?? undefined,
|
|
1382
|
+
requestType: row.request_type,
|
|
1383
|
+
tool: row.tool,
|
|
1384
|
+
reason: row.reason,
|
|
1385
|
+
durationMs: row.duration_ms,
|
|
1386
|
+
attempts: row.attempts,
|
|
1387
|
+
createdAt: row.created_at,
|
|
1388
|
+
} satisfies HookFallbackRecord))
|
|
1294
1389
|
: [];
|
|
1295
1390
|
const fallbackReasons = [...new Set([
|
|
1296
1391
|
...directFallbacks.map(() => 'direct_tool_during_context'),
|
|
1297
1392
|
...hookFallbacks.map(fallback => `daemon_${fallback.reason}`),
|
|
1298
1393
|
])];
|
|
1299
1394
|
const eventReceiptIds = collectCanonicalReceiptIds(events.map(event => event.output));
|
|
1300
|
-
const
|
|
1301
|
-
? metadata
|
|
1302
|
-
:
|
|
1303
|
-
const
|
|
1304
|
-
|
|
1305
|
-
: []
|
|
1395
|
+
const existingMetadata = execution?.metadata && typeof execution.metadata === 'object'
|
|
1396
|
+
? execution.metadata as Record<string, unknown>
|
|
1397
|
+
: {};
|
|
1398
|
+
const mergedMetadata = mergeExecutionMetadata(existingMetadata, metadata ?? {});
|
|
1399
|
+
const suppliedMemoryReceiptIds = [existingMetadata.memoryReceiptIds, metadata?.memoryReceiptIds]
|
|
1400
|
+
.flatMap(value => Array.isArray(value) ? value : [])
|
|
1401
|
+
.filter((id): id is string => typeof id === 'string');
|
|
1402
|
+
const suppliedDistillReceiptIds = [existingMetadata.distillReceiptIds, metadata?.distillReceiptIds]
|
|
1403
|
+
.flatMap(value => Array.isArray(value) ? value : [])
|
|
1404
|
+
.filter((id): id is string => typeof id === 'string');
|
|
1306
1405
|
const memoryReceiptIds = [...new Set([
|
|
1307
1406
|
...suppliedMemoryReceiptIds,
|
|
1308
1407
|
...eventReceiptIds.filter(id => id.startsWith('memory-receipt:')),
|
|
@@ -1328,7 +1427,7 @@ export class DevFlowDatabase {
|
|
|
1328
1427
|
fallbackCount: directFallbacks.length + hookFallbacks.length,
|
|
1329
1428
|
fallbackReasons,
|
|
1330
1429
|
metadata: {
|
|
1331
|
-
...
|
|
1430
|
+
...mergedMetadata,
|
|
1332
1431
|
applicableObligations: obligation.applicable,
|
|
1333
1432
|
satisfiedObligations: obligation.satisfied,
|
|
1334
1433
|
missedTools: obligation.missedTools,
|
|
@@ -1342,6 +1441,26 @@ export class DevFlowDatabase {
|
|
|
1342
1441
|
});
|
|
1343
1442
|
}
|
|
1344
1443
|
|
|
1444
|
+
reconcileRunningSkillExecutionsForSession(
|
|
1445
|
+
sessionId: string,
|
|
1446
|
+
finishedAt = Date.now(),
|
|
1447
|
+
metadata?: Record<string, unknown>,
|
|
1448
|
+
): any[] {
|
|
1449
|
+
const rows = this.db.prepare(`
|
|
1450
|
+
SELECT execution_id
|
|
1451
|
+
FROM skill_executions
|
|
1452
|
+
WHERE session_id = ? AND status = 'running'
|
|
1453
|
+
ORDER BY started_at ASC
|
|
1454
|
+
`).all(sessionId) as Array<{ execution_id: string }>;
|
|
1455
|
+
const reconciled: any[] = [];
|
|
1456
|
+
for (const row of rows) {
|
|
1457
|
+
this.reconcileSkillExecution(row.execution_id, 'completed', finishedAt, metadata);
|
|
1458
|
+
const execution = this.getSkillExecution(row.execution_id);
|
|
1459
|
+
if (execution) reconciled.push(execution);
|
|
1460
|
+
}
|
|
1461
|
+
return reconciled;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1345
1464
|
getExecutionObligationCompliance(executionId: string): {
|
|
1346
1465
|
rate: number;
|
|
1347
1466
|
applicable: number;
|
|
@@ -1890,6 +2009,421 @@ export class DevFlowDatabase {
|
|
|
1890
2009
|
};
|
|
1891
2010
|
}
|
|
1892
2011
|
|
|
2012
|
+
// ---- Durable Work Queue ----
|
|
2013
|
+
|
|
2014
|
+
enqueueWork(input: EnqueueWorkInput): WorkItemRecord {
|
|
2015
|
+
if (!input.idempotencyKey.trim()) throw new Error('Work idempotency key is required');
|
|
2016
|
+
if (!input.projectRoot.trim()) throw new Error('Work project root is required');
|
|
2017
|
+
const maxAttempts = input.maxAttempts ?? 5;
|
|
2018
|
+
if (!Number.isSafeInteger(maxAttempts) || maxAttempts <= 0) {
|
|
2019
|
+
throw new Error('Work maxAttempts must be a positive safe integer');
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
const createdAt = Date.now();
|
|
2023
|
+
const nextAttemptAt = input.nextAttemptAt ?? createdAt;
|
|
2024
|
+
if (!Number.isSafeInteger(nextAttemptAt)) {
|
|
2025
|
+
throw new Error('Work nextAttemptAt must be a safe integer');
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
const row = this.db.prepare(`
|
|
2029
|
+
INSERT INTO devflow_work_items (
|
|
2030
|
+
id, idempotency_key, kind, project_root, session_id, turn_id, payload,
|
|
2031
|
+
state, attempts, max_attempts, lease_owner, lease_expires_at,
|
|
2032
|
+
next_attempt_at, error_category, error_message, created_at, updated_at, completed_at
|
|
2033
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, NULL, NULL, ?, NULL, NULL, ?, ?, NULL)
|
|
2034
|
+
ON CONFLICT(idempotency_key) DO UPDATE SET
|
|
2035
|
+
state = CASE
|
|
2036
|
+
WHEN devflow_work_items.state = 'dead_letter'
|
|
2037
|
+
AND excluded.max_attempts > devflow_work_items.attempts
|
|
2038
|
+
THEN 'failed'
|
|
2039
|
+
ELSE devflow_work_items.state
|
|
2040
|
+
END,
|
|
2041
|
+
max_attempts = MAX(devflow_work_items.max_attempts, excluded.max_attempts),
|
|
2042
|
+
next_attempt_at = CASE
|
|
2043
|
+
WHEN devflow_work_items.state = 'dead_letter'
|
|
2044
|
+
AND excluded.max_attempts > devflow_work_items.attempts
|
|
2045
|
+
THEN excluded.next_attempt_at
|
|
2046
|
+
ELSE devflow_work_items.next_attempt_at
|
|
2047
|
+
END,
|
|
2048
|
+
error_category = CASE
|
|
2049
|
+
WHEN devflow_work_items.state = 'dead_letter'
|
|
2050
|
+
AND excluded.max_attempts > devflow_work_items.attempts
|
|
2051
|
+
THEN NULL
|
|
2052
|
+
ELSE devflow_work_items.error_category
|
|
2053
|
+
END,
|
|
2054
|
+
error_message = CASE
|
|
2055
|
+
WHEN devflow_work_items.state = 'dead_letter'
|
|
2056
|
+
AND excluded.max_attempts > devflow_work_items.attempts
|
|
2057
|
+
THEN NULL
|
|
2058
|
+
ELSE devflow_work_items.error_message
|
|
2059
|
+
END,
|
|
2060
|
+
updated_at = CASE
|
|
2061
|
+
WHEN devflow_work_items.state = 'dead_letter'
|
|
2062
|
+
AND excluded.max_attempts > devflow_work_items.attempts
|
|
2063
|
+
THEN excluded.updated_at
|
|
2064
|
+
ELSE devflow_work_items.updated_at
|
|
2065
|
+
END
|
|
2066
|
+
RETURNING *
|
|
2067
|
+
`).get(
|
|
2068
|
+
randomUUID(),
|
|
2069
|
+
input.idempotencyKey,
|
|
2070
|
+
input.kind,
|
|
2071
|
+
input.projectRoot,
|
|
2072
|
+
input.sessionId ?? null,
|
|
2073
|
+
input.turnId ?? null,
|
|
2074
|
+
JSON.stringify(input.payload ?? null),
|
|
2075
|
+
maxAttempts,
|
|
2076
|
+
nextAttemptAt,
|
|
2077
|
+
createdAt,
|
|
2078
|
+
createdAt,
|
|
2079
|
+
) as any;
|
|
2080
|
+
|
|
2081
|
+
return this.mapWorkItem(row);
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
getWorkByIdempotencyKey(idempotencyKey: string): WorkItemRecord | null {
|
|
2085
|
+
const row = this.db.prepare(
|
|
2086
|
+
'SELECT * FROM devflow_work_items WHERE idempotency_key = ?',
|
|
2087
|
+
).get(idempotencyKey) as any;
|
|
2088
|
+
return row ? this.mapWorkItem(row) : null;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
requestSessionClosure(input: RequestSessionClosureInput): SessionClosureRecord {
|
|
2092
|
+
if (!input.sessionId.trim()) throw new Error('Session closure requires a session ID');
|
|
2093
|
+
if (!input.projectRoot.trim()) throw new Error('Session closure requires a project root');
|
|
2094
|
+
if (!input.receiptId.trim()) throw new Error('Session closure requires a receipt ID');
|
|
2095
|
+
|
|
2096
|
+
return this.db.transaction(() => {
|
|
2097
|
+
const now = Date.now();
|
|
2098
|
+
this.db.prepare(`
|
|
2099
|
+
INSERT INTO devflow_session_closures (
|
|
2100
|
+
session_id, project_root, state, receipt_id, pending_work_count,
|
|
2101
|
+
requested_at, updated_at
|
|
2102
|
+
) VALUES (?, ?, 'active', ?, 0, ?, ?)
|
|
2103
|
+
ON CONFLICT(project_root, session_id) DO NOTHING
|
|
2104
|
+
`).run(input.sessionId, input.projectRoot, input.receiptId, now, now);
|
|
2105
|
+
|
|
2106
|
+
const work = this.enqueueWork({
|
|
2107
|
+
idempotencyKey: input.receiptId,
|
|
2108
|
+
kind: 'session.finalize',
|
|
2109
|
+
projectRoot: input.projectRoot,
|
|
2110
|
+
sessionId: input.sessionId,
|
|
2111
|
+
payload: input.payload,
|
|
2112
|
+
maxAttempts: input.maxAttempts,
|
|
2113
|
+
});
|
|
2114
|
+
const pending = this.db.prepare(`
|
|
2115
|
+
SELECT COUNT(*) AS count
|
|
2116
|
+
FROM devflow_work_items
|
|
2117
|
+
WHERE project_root = ? AND session_id = ?
|
|
2118
|
+
AND state IN ('pending', 'leased', 'failed', 'dead_letter')
|
|
2119
|
+
`).get(input.projectRoot, input.sessionId) as { count?: number };
|
|
2120
|
+
const pendingWorkCount = Number(pending?.count ?? 0);
|
|
2121
|
+
this.db.prepare(`
|
|
2122
|
+
UPDATE devflow_session_closures
|
|
2123
|
+
SET state = CASE
|
|
2124
|
+
WHEN state IN ('closed', 'closed_with_pending_work')
|
|
2125
|
+
THEN CASE WHEN ? > 0 THEN 'closed_with_pending_work' ELSE 'closed' END
|
|
2126
|
+
WHEN ? = 'completed'
|
|
2127
|
+
THEN CASE WHEN ? > 0 THEN 'closed_with_pending_work' ELSE 'closed' END
|
|
2128
|
+
WHEN ? = 'dead_letter' THEN 'closed_with_pending_work'
|
|
2129
|
+
ELSE 'closing'
|
|
2130
|
+
END,
|
|
2131
|
+
work_item_id = COALESCE(work_item_id, ?),
|
|
2132
|
+
pending_work_count = ?,
|
|
2133
|
+
closed_at = CASE WHEN ? = 'completed' THEN ? ELSE closed_at END,
|
|
2134
|
+
updated_at = ?
|
|
2135
|
+
WHERE project_root = ? AND session_id = ?
|
|
2136
|
+
`).run(
|
|
2137
|
+
pendingWorkCount,
|
|
2138
|
+
work.state,
|
|
2139
|
+
pendingWorkCount,
|
|
2140
|
+
work.state,
|
|
2141
|
+
work.id,
|
|
2142
|
+
pendingWorkCount,
|
|
2143
|
+
work.state,
|
|
2144
|
+
now,
|
|
2145
|
+
now,
|
|
2146
|
+
input.projectRoot,
|
|
2147
|
+
input.sessionId,
|
|
2148
|
+
);
|
|
2149
|
+
return this.getSessionClosure(input.projectRoot, input.sessionId)!;
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
getSessionClosure(projectRoot: string, sessionId: string): SessionClosureRecord | null {
|
|
2154
|
+
const row = this.db.prepare(`
|
|
2155
|
+
SELECT * FROM devflow_session_closures
|
|
2156
|
+
WHERE project_root = ? AND session_id = ?
|
|
2157
|
+
`).get(projectRoot, sessionId) as any;
|
|
2158
|
+
return row ? this.mapSessionClosure(row) : null;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
completeSessionClosure(
|
|
2162
|
+
projectRoot: string,
|
|
2163
|
+
sessionId: string,
|
|
2164
|
+
excludingWorkItemId?: string,
|
|
2165
|
+
closedAt = Date.now(),
|
|
2166
|
+
): SessionClosureRecord {
|
|
2167
|
+
const pending = this.db.prepare(`
|
|
2168
|
+
SELECT COUNT(*) AS count
|
|
2169
|
+
FROM devflow_work_items
|
|
2170
|
+
WHERE project_root = ? AND session_id = ?
|
|
2171
|
+
AND state IN ('pending', 'leased', 'failed', 'dead_letter')
|
|
2172
|
+
AND (? IS NULL OR id <> ?)
|
|
2173
|
+
`).get(projectRoot, sessionId, excludingWorkItemId ?? null, excludingWorkItemId ?? null) as { count?: number };
|
|
2174
|
+
const pendingWorkCount = Number(pending?.count ?? 0);
|
|
2175
|
+
const state = pendingWorkCount > 0 ? 'closed_with_pending_work' : 'closed';
|
|
2176
|
+
this.db.prepare(`
|
|
2177
|
+
UPDATE devflow_session_closures
|
|
2178
|
+
SET state = ?, pending_work_count = ?, closed_at = ?, updated_at = ?
|
|
2179
|
+
WHERE project_root = ? AND session_id = ? AND state = 'closing'
|
|
2180
|
+
`).run(state, pendingWorkCount, closedAt, closedAt, projectRoot, sessionId);
|
|
2181
|
+
const closure = this.getSessionClosure(projectRoot, sessionId);
|
|
2182
|
+
if (!closure) throw new Error(`Session closure ${sessionId} does not exist`);
|
|
2183
|
+
return closure;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
listSessionClosures(projectRoot: string, limit = 100): SessionClosureRecord[] {
|
|
2187
|
+
const rows = this.db.prepare(`
|
|
2188
|
+
SELECT * FROM devflow_session_closures
|
|
2189
|
+
WHERE project_root = ?
|
|
2190
|
+
ORDER BY updated_at DESC
|
|
2191
|
+
LIMIT ?
|
|
2192
|
+
`).all(projectRoot, Math.max(1, Math.min(limit, 1_000))) as any[];
|
|
2193
|
+
return rows.map(row => this.mapSessionClosure(row));
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
leaseWork(input: LeaseWorkInput): WorkItemRecord[] {
|
|
2197
|
+
if (!input.owner.trim()) throw new Error('Work lease owner is required');
|
|
2198
|
+
if (!Number.isFinite(input.limit) || input.limit <= 0) return [];
|
|
2199
|
+
if (!Number.isSafeInteger(input.leaseMs) || input.leaseMs <= 0) {
|
|
2200
|
+
throw new Error('Work leaseMs must be a positive safe integer');
|
|
2201
|
+
}
|
|
2202
|
+
if (input.kinds?.length === 0) return [];
|
|
2203
|
+
|
|
2204
|
+
const now = input.now ?? Date.now();
|
|
2205
|
+
const leaseExpiresAt = now + input.leaseMs;
|
|
2206
|
+
if (!Number.isSafeInteger(now) || !Number.isSafeInteger(leaseExpiresAt)) {
|
|
2207
|
+
throw new Error('Work lease timestamps must be safe integers');
|
|
2208
|
+
}
|
|
2209
|
+
const limit = Math.min(Math.floor(input.limit), 1_000);
|
|
2210
|
+
const kinds = input.kinds ? [...new Set(input.kinds)] : undefined;
|
|
2211
|
+
const kindClause = kinds
|
|
2212
|
+
? `AND kind IN (${kinds.map(() => '?').join(', ')})`
|
|
2213
|
+
: '';
|
|
2214
|
+
|
|
2215
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
2216
|
+
try {
|
|
2217
|
+
const candidates = this.db.prepare(`
|
|
2218
|
+
SELECT id, state
|
|
2219
|
+
FROM devflow_work_items
|
|
2220
|
+
WHERE project_root = ?
|
|
2221
|
+
AND state IN ('pending', 'failed')
|
|
2222
|
+
AND lease_owner IS NULL
|
|
2223
|
+
AND lease_expires_at IS NULL
|
|
2224
|
+
AND next_attempt_at <= ?
|
|
2225
|
+
AND attempts < max_attempts
|
|
2226
|
+
${kindClause}
|
|
2227
|
+
ORDER BY next_attempt_at ASC, created_at ASC, id ASC
|
|
2228
|
+
LIMIT ?
|
|
2229
|
+
`).all(input.projectRoot, now, ...(kinds ?? []), limit) as Array<{
|
|
2230
|
+
id: string;
|
|
2231
|
+
state: WorkState;
|
|
2232
|
+
}>;
|
|
2233
|
+
|
|
2234
|
+
const leased: WorkItemRecord[] = [];
|
|
2235
|
+
for (const candidate of candidates) {
|
|
2236
|
+
const result = this.db.prepare(`
|
|
2237
|
+
UPDATE devflow_work_items
|
|
2238
|
+
SET state = 'leased', attempts = attempts + 1, lease_owner = ?,
|
|
2239
|
+
lease_expires_at = ?, updated_at = ?
|
|
2240
|
+
WHERE id = ? AND project_root = ? AND state = ?
|
|
2241
|
+
AND lease_owner IS NULL AND lease_expires_at IS NULL
|
|
2242
|
+
AND next_attempt_at <= ? AND attempts < max_attempts
|
|
2243
|
+
`).run(
|
|
2244
|
+
input.owner,
|
|
2245
|
+
leaseExpiresAt,
|
|
2246
|
+
now,
|
|
2247
|
+
candidate.id,
|
|
2248
|
+
input.projectRoot,
|
|
2249
|
+
candidate.state,
|
|
2250
|
+
now,
|
|
2251
|
+
);
|
|
2252
|
+
if (result.changes === 1) {
|
|
2253
|
+
const row = this.db.prepare('SELECT * FROM devflow_work_items WHERE id = ?')
|
|
2254
|
+
.get(candidate.id) as any;
|
|
2255
|
+
leased.push(this.mapWorkItem(row));
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
this.db.exec('COMMIT');
|
|
2260
|
+
return leased;
|
|
2261
|
+
} catch (error) {
|
|
2262
|
+
try { this.db.exec('ROLLBACK'); } catch {}
|
|
2263
|
+
throw error;
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
completeWork(id: string, owner: string, now = Date.now()): boolean {
|
|
2268
|
+
const work = this.db.prepare(`
|
|
2269
|
+
SELECT project_root, session_id FROM devflow_work_items WHERE id = ?
|
|
2270
|
+
`).get(id) as { project_root?: string; session_id?: string } | undefined;
|
|
2271
|
+
const completed = this.db.prepare(`
|
|
2272
|
+
UPDATE devflow_work_items
|
|
2273
|
+
SET state = 'completed', lease_owner = NULL, lease_expires_at = NULL,
|
|
2274
|
+
updated_at = ?, completed_at = ?
|
|
2275
|
+
WHERE id = ? AND state = 'leased' AND lease_owner = ?
|
|
2276
|
+
`).run(now, now, id, owner).changes === 1;
|
|
2277
|
+
if (completed && work?.project_root && work.session_id) {
|
|
2278
|
+
this.refreshClosedSessionClosure(work.project_root, work.session_id, now);
|
|
2279
|
+
}
|
|
2280
|
+
return completed;
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
retryWork(id: string, owner: string, error: WorkError, nextAttemptAt: number): boolean {
|
|
2284
|
+
const updatedAt = Date.now();
|
|
2285
|
+
return this.db.prepare(`
|
|
2286
|
+
UPDATE devflow_work_items
|
|
2287
|
+
SET state = CASE WHEN attempts >= max_attempts THEN 'dead_letter' ELSE 'failed' END,
|
|
2288
|
+
lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
|
|
2289
|
+
error_category = ?, error_message = ?, updated_at = ?
|
|
2290
|
+
WHERE id = ? AND state = 'leased' AND lease_owner = ?
|
|
2291
|
+
`).run(
|
|
2292
|
+
nextAttemptAt,
|
|
2293
|
+
error.category,
|
|
2294
|
+
error.message,
|
|
2295
|
+
updatedAt,
|
|
2296
|
+
id,
|
|
2297
|
+
owner,
|
|
2298
|
+
).changes === 1;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
deferWork(id: string, owner: string, error: WorkError, nextAttemptAt: number): boolean {
|
|
2302
|
+
const updatedAt = Date.now();
|
|
2303
|
+
return this.db.prepare(`
|
|
2304
|
+
UPDATE devflow_work_items
|
|
2305
|
+
SET state = 'pending', attempts = MAX(0, attempts - 1),
|
|
2306
|
+
lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
|
|
2307
|
+
error_category = ?, error_message = ?, updated_at = ?
|
|
2308
|
+
WHERE id = ? AND state = 'leased' AND lease_owner = ?
|
|
2309
|
+
`).run(
|
|
2310
|
+
nextAttemptAt,
|
|
2311
|
+
error.category,
|
|
2312
|
+
error.message,
|
|
2313
|
+
updatedAt,
|
|
2314
|
+
id,
|
|
2315
|
+
owner,
|
|
2316
|
+
).changes === 1;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
deadLetterWork(id: string, owner: string, error: WorkError): boolean {
|
|
2320
|
+
return this.db.prepare(`
|
|
2321
|
+
UPDATE devflow_work_items
|
|
2322
|
+
SET state = 'dead_letter', lease_owner = NULL, lease_expires_at = NULL,
|
|
2323
|
+
error_category = ?, error_message = ?, updated_at = ?
|
|
2324
|
+
WHERE id = ? AND state = 'leased' AND lease_owner = ?
|
|
2325
|
+
`).run(error.category, error.message, Date.now(), id, owner).changes === 1;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
recoverExpiredWork(projectRoot: string, now = Date.now()): number {
|
|
2329
|
+
return this.db.prepare(`
|
|
2330
|
+
UPDATE devflow_work_items
|
|
2331
|
+
SET state = CASE WHEN attempts >= max_attempts THEN 'dead_letter' ELSE 'failed' END,
|
|
2332
|
+
lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
|
|
2333
|
+
error_category = 'lease_expired',
|
|
2334
|
+
error_message = 'Work lease expired before completion',
|
|
2335
|
+
updated_at = ?
|
|
2336
|
+
WHERE project_root = ? AND state = 'leased' AND lease_expires_at <= ?
|
|
2337
|
+
`).run(now, now, projectRoot, now).changes;
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
getWorkQueueHealth(projectRoot: string, now = Date.now()): WorkQueueHealth {
|
|
2341
|
+
const row = this.db.prepare(`
|
|
2342
|
+
SELECT
|
|
2343
|
+
COALESCE(SUM(CASE WHEN state IN ('pending', 'leased', 'failed') THEN 1 ELSE 0 END), 0) AS queue_depth,
|
|
2344
|
+
COALESCE(SUM(CASE WHEN state = 'pending' THEN 1 ELSE 0 END), 0) AS pending,
|
|
2345
|
+
COALESCE(SUM(CASE WHEN state = 'leased' THEN 1 ELSE 0 END), 0) AS leased,
|
|
2346
|
+
COALESCE(SUM(CASE WHEN state = 'failed' THEN 1 ELSE 0 END), 0) AS failed,
|
|
2347
|
+
COALESCE(SUM(CASE WHEN state = 'leased' AND lease_expires_at <= ? THEN 1 ELSE 0 END), 0) AS expired_leases,
|
|
2348
|
+
COALESCE(SUM(CASE WHEN state = 'dead_letter' THEN 1 ELSE 0 END), 0) AS dead_letters,
|
|
2349
|
+
MIN(CASE WHEN state IN ('pending', 'leased', 'failed') THEN created_at END) AS oldest_pending_at,
|
|
2350
|
+
MAX(CASE WHEN state = 'completed' THEN completed_at END) AS last_successful_drain_at
|
|
2351
|
+
FROM devflow_work_items
|
|
2352
|
+
WHERE project_root = ?
|
|
2353
|
+
`).get(now, projectRoot) as any;
|
|
2354
|
+
const oldestPendingAt = row.oldest_pending_at == null
|
|
2355
|
+
? undefined
|
|
2356
|
+
: Number(row.oldest_pending_at);
|
|
2357
|
+
|
|
2358
|
+
return {
|
|
2359
|
+
projectRoot,
|
|
2360
|
+
queueDepth: Number(row.queue_depth),
|
|
2361
|
+
pending: Number(row.pending),
|
|
2362
|
+
leased: Number(row.leased),
|
|
2363
|
+
failed: Number(row.failed),
|
|
2364
|
+
expiredLeases: Number(row.expired_leases),
|
|
2365
|
+
deadLetters: Number(row.dead_letters),
|
|
2366
|
+
oldestPendingAgeMs: oldestPendingAt === undefined ? 0 : Math.max(0, now - oldestPendingAt),
|
|
2367
|
+
lastSuccessfulDrainAt: row.last_successful_drain_at == null
|
|
2368
|
+
? undefined
|
|
2369
|
+
: Number(row.last_successful_drain_at),
|
|
2370
|
+
};
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
private mapWorkItem(row: any): WorkItemRecord {
|
|
2374
|
+
return {
|
|
2375
|
+
id: row.id,
|
|
2376
|
+
idempotencyKey: row.idempotency_key,
|
|
2377
|
+
kind: row.kind,
|
|
2378
|
+
projectRoot: row.project_root,
|
|
2379
|
+
sessionId: row.session_id ?? undefined,
|
|
2380
|
+
turnId: row.turn_id ?? undefined,
|
|
2381
|
+
payload: parseJson(row.payload),
|
|
2382
|
+
state: row.state,
|
|
2383
|
+
attempts: Number(row.attempts),
|
|
2384
|
+
maxAttempts: Number(row.max_attempts),
|
|
2385
|
+
leaseOwner: row.lease_owner ?? undefined,
|
|
2386
|
+
leaseExpiresAt: row.lease_expires_at == null ? undefined : Number(row.lease_expires_at),
|
|
2387
|
+
nextAttemptAt: Number(row.next_attempt_at),
|
|
2388
|
+
errorCategory: row.error_category ?? undefined,
|
|
2389
|
+
errorMessage: row.error_message ?? undefined,
|
|
2390
|
+
createdAt: Number(row.created_at),
|
|
2391
|
+
updatedAt: Number(row.updated_at),
|
|
2392
|
+
completedAt: row.completed_at == null ? undefined : Number(row.completed_at),
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
private mapSessionClosure(row: any): SessionClosureRecord {
|
|
2397
|
+
return {
|
|
2398
|
+
sessionId: row.session_id,
|
|
2399
|
+
projectRoot: row.project_root,
|
|
2400
|
+
state: row.state,
|
|
2401
|
+
receiptId: row.receipt_id,
|
|
2402
|
+
workItemId: row.work_item_id ?? undefined,
|
|
2403
|
+
pendingWorkCount: Number(row.pending_work_count),
|
|
2404
|
+
requestedAt: Number(row.requested_at),
|
|
2405
|
+
updatedAt: Number(row.updated_at),
|
|
2406
|
+
closedAt: row.closed_at == null ? undefined : Number(row.closed_at),
|
|
2407
|
+
};
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
private refreshClosedSessionClosure(projectRoot: string, sessionId: string, now: number): void {
|
|
2411
|
+
const pending = this.db.prepare(`
|
|
2412
|
+
SELECT COUNT(*) AS count
|
|
2413
|
+
FROM devflow_work_items
|
|
2414
|
+
WHERE project_root = ? AND session_id = ?
|
|
2415
|
+
AND state IN ('pending', 'leased', 'failed', 'dead_letter')
|
|
2416
|
+
`).get(projectRoot, sessionId) as { count?: number };
|
|
2417
|
+
const pendingWorkCount = Number(pending?.count ?? 0);
|
|
2418
|
+
this.db.prepare(`
|
|
2419
|
+
UPDATE devflow_session_closures
|
|
2420
|
+
SET state = CASE WHEN ? = 0 THEN 'closed' ELSE 'closed_with_pending_work' END,
|
|
2421
|
+
pending_work_count = ?, updated_at = ?
|
|
2422
|
+
WHERE project_root = ? AND session_id = ?
|
|
2423
|
+
AND state IN ('closed', 'closed_with_pending_work')
|
|
2424
|
+
`).run(pendingWorkCount, pendingWorkCount, now, projectRoot, sessionId);
|
|
2425
|
+
}
|
|
2426
|
+
|
|
1893
2427
|
// ---- Hook Lifecycle ----
|
|
1894
2428
|
|
|
1895
2429
|
getHookReceipt(projectRoot: string): HookReceiptRecord | null {
|
|
@@ -2102,6 +2636,18 @@ export class DevFlowDatabase {
|
|
|
2102
2636
|
return rows.map(row => this.mapMemoryTurn(row));
|
|
2103
2637
|
}
|
|
2104
2638
|
|
|
2639
|
+
listSessionMemoryTurnsForReconciliation(
|
|
2640
|
+
projectRoot: string,
|
|
2641
|
+
sessionId: string,
|
|
2642
|
+
): MemoryTurnRecord[] {
|
|
2643
|
+
const rows = this.db.prepare(`
|
|
2644
|
+
SELECT * FROM devflow_memory_turns
|
|
2645
|
+
WHERE project_root = ? AND session_id = ?
|
|
2646
|
+
ORDER BY created_at ASC, turn_id ASC
|
|
2647
|
+
`).all(projectRoot, sessionId) as any[];
|
|
2648
|
+
return rows.map(row => this.mapMemoryTurn(row));
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2105
2651
|
insertHookFallback(record: HookFallbackRecord): boolean {
|
|
2106
2652
|
return this.db.prepare(`
|
|
2107
2653
|
INSERT OR IGNORE INTO devflow_hook_fallbacks
|
|
@@ -2181,8 +2727,13 @@ export class DevFlowDatabase {
|
|
|
2181
2727
|
recordMemoryDistillCheckpoint(checkpoint: MemoryDistillCheckpointRecord): void {
|
|
2182
2728
|
this.db.prepare(`
|
|
2183
2729
|
INSERT INTO devflow_memory_distill_checkpoints
|
|
2184
|
-
(id, project_root, session_id, trigger, pending_events, released_leases, created_at)
|
|
2185
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
2730
|
+
(id, project_root, session_id, trigger, pending_events, released_leases, details, created_at)
|
|
2731
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
2732
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
2733
|
+
pending_events = excluded.pending_events,
|
|
2734
|
+
released_leases = MAX(devflow_memory_distill_checkpoints.released_leases, excluded.released_leases),
|
|
2735
|
+
details = excluded.details,
|
|
2736
|
+
created_at = MIN(devflow_memory_distill_checkpoints.created_at, excluded.created_at)
|
|
2186
2737
|
`).run(
|
|
2187
2738
|
checkpoint.id,
|
|
2188
2739
|
checkpoint.projectRoot,
|
|
@@ -2190,13 +2741,14 @@ export class DevFlowDatabase {
|
|
|
2190
2741
|
checkpoint.trigger,
|
|
2191
2742
|
checkpoint.pendingEvents,
|
|
2192
2743
|
checkpoint.releasedLeases,
|
|
2744
|
+
JSON.stringify(checkpoint.details ?? {}),
|
|
2193
2745
|
checkpoint.createdAt,
|
|
2194
2746
|
);
|
|
2195
2747
|
}
|
|
2196
2748
|
|
|
2197
2749
|
listMemoryDistillCheckpoints(projectRoot: string, limit = 50): MemoryDistillCheckpointRecord[] {
|
|
2198
2750
|
const rows = this.db.prepare(`
|
|
2199
|
-
SELECT id, project_root, session_id, trigger, pending_events, released_leases, created_at
|
|
2751
|
+
SELECT id, project_root, session_id, trigger, pending_events, released_leases, details, created_at
|
|
2200
2752
|
FROM devflow_memory_distill_checkpoints
|
|
2201
2753
|
WHERE project_root = ?
|
|
2202
2754
|
ORDER BY created_at DESC
|
|
@@ -2209,6 +2761,7 @@ export class DevFlowDatabase {
|
|
|
2209
2761
|
trigger: row.trigger,
|
|
2210
2762
|
pendingEvents: Number(row.pending_events),
|
|
2211
2763
|
releasedLeases: Number(row.released_leases),
|
|
2764
|
+
details: row.details ? JSON.parse(row.details) : {},
|
|
2212
2765
|
createdAt: Number(row.created_at),
|
|
2213
2766
|
}));
|
|
2214
2767
|
}
|
|
@@ -2296,3 +2849,40 @@ function collectCanonicalReceiptIds(values: unknown[]): string[] {
|
|
|
2296
2849
|
values.forEach(value => visit(value, 0));
|
|
2297
2850
|
return [...receiptIds];
|
|
2298
2851
|
}
|
|
2852
|
+
|
|
2853
|
+
function stableMetadataKey(value: unknown): string {
|
|
2854
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? String(value);
|
|
2855
|
+
if (Array.isArray(value)) return `[${value.map(stableMetadataKey).join(',')}]`;
|
|
2856
|
+
const record = value as Record<string, unknown>;
|
|
2857
|
+
return `{${Object.keys(record).sort()
|
|
2858
|
+
.map(key => `${JSON.stringify(key)}:${stableMetadataKey(record[key])}`)
|
|
2859
|
+
.join(',')}}`;
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
function unionMetadataArrays(existing: unknown, incoming: unknown): unknown[] | undefined {
|
|
2863
|
+
const values = [
|
|
2864
|
+
...(Array.isArray(existing) ? existing : []),
|
|
2865
|
+
...(Array.isArray(incoming) ? incoming : []),
|
|
2866
|
+
];
|
|
2867
|
+
if (values.length === 0 && !Array.isArray(existing) && !Array.isArray(incoming)) return undefined;
|
|
2868
|
+
const unique = new Map<string, unknown>();
|
|
2869
|
+
for (const value of values) unique.set(stableMetadataKey(value), value);
|
|
2870
|
+
return [...unique.values()];
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
function mergeExecutionMetadata(
|
|
2874
|
+
existing: Record<string, unknown>,
|
|
2875
|
+
incoming: Record<string, unknown>,
|
|
2876
|
+
): Record<string, unknown> {
|
|
2877
|
+
const merged = { ...existing, ...incoming };
|
|
2878
|
+
for (const key of [
|
|
2879
|
+
'evidenceDegradations',
|
|
2880
|
+
'evidenceContractOutcomes',
|
|
2881
|
+
'pendingEvidenceContractIds',
|
|
2882
|
+
'sessionFinalizationDegradations',
|
|
2883
|
+
]) {
|
|
2884
|
+
const union = unionMetadataArrays(existing[key], incoming[key]);
|
|
2885
|
+
if (union !== undefined) merged[key] = union;
|
|
2886
|
+
}
|
|
2887
|
+
return merged;
|
|
2888
|
+
}
|