@devflow-tools/database 0.17.8 → 0.18.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/LICENSE +21 -0
- package/dist/data/LocalDataProvider.d.ts +63 -0
- package/dist/data/LocalDataProvider.js +332 -0
- package/dist/data/default-enforcer-rules.d.ts +3 -0
- package/dist/data/default-enforcer-rules.js +170 -0
- package/dist/data/devflow-schema.d.ts +2 -0
- package/dist/data/devflow-schema.js +80 -0
- package/dist/database.d.ts +45 -7
- package/dist/database.js +583 -125
- package/dist/index.d.ts +8 -4
- package/dist/index.js +11 -5
- package/dist/retrieval-ledger.d.ts +16 -1
- package/dist/retrieval-ledger.js +6 -0
- package/dist/retrieval-runtime.d.ts +39 -0
- package/dist/retrieval-runtime.js +2 -0
- package/dist/task-aggregate.d.ts +49 -0
- package/dist/task-aggregate.js +2 -0
- package/dist/task-semantic-control.d.ts +8 -1
- package/dist/task-semantic-control.js +2 -4
- package/package.json +15 -3
- package/CHANGELOG.md +0 -844
- package/__tests__/database.failure-category.test.ts +0 -44
- package/__tests__/database.host-actions.test.ts +0 -405
- package/__tests__/database.learning-candidates.test.ts +0 -93
- package/__tests__/database.memory-turn-receipts.test.ts +0 -98
- package/__tests__/database.retrieval-ledger.test.ts +0 -68
- package/__tests__/database.retrieval-sessions.test.ts +0 -79
- package/__tests__/database.semantic-resolution.test.ts +0 -87
- package/__tests__/database.skill-executions.test.ts +0 -456
- package/__tests__/database.task-runtime.test.ts +0 -73
- package/__tests__/database.test.ts +0 -177
- package/__tests__/database.work-queue.test.ts +0 -274
- package/__tests__/database.workflow-workers.test.ts +0 -60
- package/__tests__/node-sqlite.test.ts +0 -68
- package/src/database.ts +0 -5853
- package/src/host-actions.ts +0 -211
- package/src/index.ts +0 -153
- package/src/learning-candidates.ts +0 -181
- package/src/node-sqlite.ts +0 -60
- package/src/obligation-ledger.ts +0 -57
- package/src/retrieval-ledger.ts +0 -35
- package/src/retrieval-sessions.ts +0 -196
- package/src/semantic-resolution.ts +0 -181
- package/src/task-runtime.ts +0 -169
- package/src/task-semantic-control.ts +0 -270
- package/src/types.ts +0 -17
- package/src/work-queue.ts +0 -123
- package/src/workflow-workers.ts +0 -198
- package/tsconfig.json +0 -19
- package/tsconfig.tsbuildinfo +0 -1
- package/vitest.config.ts +0 -41
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
-
import { DevFlowDatabase } from '../src/database';
|
|
6
|
-
import { stableTaskRuntimeHash, type TaskRuntimeEventRecord, type TaskRuntimeSnapshotRecord } from '../src/task-runtime';
|
|
7
|
-
|
|
8
|
-
const identity = {
|
|
9
|
-
projectRoot: '/project', projectId: 'project:1', hostId: 'claude-code',
|
|
10
|
-
sessionId: 'session:1', turnId: 'turn:1', requestId: 'request:1',
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
function event(overrides: Partial<TaskRuntimeEventRecord> = {}): TaskRuntimeEventRecord {
|
|
14
|
-
return {
|
|
15
|
-
schemaVersion: 'task-runtime-event.v1', producer: 'test', producerVersion: '1.0.0',
|
|
16
|
-
eventId: 'event:1', sequence: 1, identity, taskSpecHash: 'spec:1',
|
|
17
|
-
kind: 'task.spec.bound', payload: {}, createdAt: 1, ...overrides,
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function snapshot(lastEventSequence = 1): TaskRuntimeSnapshotRecord {
|
|
22
|
-
const body = {
|
|
23
|
-
schemaVersion: 'task-runtime-snapshot.v1' as const,
|
|
24
|
-
producer: 'test', producerVersion: '1.0.0', identity, taskSpecHash: 'spec:1',
|
|
25
|
-
lastEventSequence,
|
|
26
|
-
artifacts: { requiredIdentityIds: [], resolvedIdentityIds: [], ambiguousIdentityIds: [] },
|
|
27
|
-
channels: {},
|
|
28
|
-
action: { requirement: 'not_required' as const, state: 'satisfied' as const },
|
|
29
|
-
verification: { requirement: 'not_required' as const, state: 'satisfied' as const },
|
|
30
|
-
memory: { requirement: 'required' as const, state: 'satisfied' as const },
|
|
31
|
-
durableWorkIds: [], terminal: 'completed' as const, runtimeHealth: 'healthy' as const,
|
|
32
|
-
updatedAt: lastEventSequence,
|
|
33
|
-
};
|
|
34
|
-
return { ...body, snapshotHash: stableTaskRuntimeHash(body) };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
describe('DevFlowDatabase task runtime persistence', () => {
|
|
38
|
-
let directory: string;
|
|
39
|
-
let database: DevFlowDatabase;
|
|
40
|
-
|
|
41
|
-
beforeEach(() => {
|
|
42
|
-
directory = mkdtempSync(join(tmpdir(), 'devflow-task-runtime-'));
|
|
43
|
-
database = new DevFlowDatabase(directory);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
afterEach(() => {
|
|
47
|
-
database.close();
|
|
48
|
-
rmSync(directory, { recursive: true, force: true });
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('makes identical event replay idempotent and rejects conflicting IDs or sequences', () => {
|
|
52
|
-
const first = database.appendTaskRuntimeEvent(event());
|
|
53
|
-
expect(database.appendTaskRuntimeEvent(event())).toEqual(first);
|
|
54
|
-
expect(database.listTaskRuntimeEvents('/project', 'session:1', 'turn:1')).toEqual([first]);
|
|
55
|
-
|
|
56
|
-
expect(() => database.appendTaskRuntimeEvent(event({ payload: { changed: true } })))
|
|
57
|
-
.toThrow('TASK_RUNTIME_EVENT_CONFLICT:event:1');
|
|
58
|
-
expect(() => database.appendTaskRuntimeEvent(event({ eventId: 'event:2' })))
|
|
59
|
-
.toThrow('TASK_RUNTIME_SEQUENCE_CONFLICT:1');
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('round-trips snapshots, rejects regression, and supports deterministic rebuild deletion', () => {
|
|
63
|
-
database.appendTaskRuntimeEvent(event());
|
|
64
|
-
const current = database.putTaskRuntimeSnapshot(snapshot(1));
|
|
65
|
-
expect(database.getTaskRuntimeSnapshot('/project', 'session:1', 'turn:1')).toEqual(current);
|
|
66
|
-
expect(() => database.putTaskRuntimeSnapshot(snapshot(0))).toThrow('TASK_RUNTIME_SNAPSHOT_REGRESSION');
|
|
67
|
-
|
|
68
|
-
expect(database.deleteTaskRuntimeSnapshot('/project', 'session:1', 'turn:1')).toBe(true);
|
|
69
|
-
expect(database.getTaskRuntimeSnapshot('/project', 'session:1', 'turn:1')).toBeNull();
|
|
70
|
-
expect(database.listTaskRuntimeEvents('/project', 'session:1', 'turn:1')).toEqual([event()]);
|
|
71
|
-
expect(database.putTaskRuntimeSnapshot(snapshot(1))).toEqual(current);
|
|
72
|
-
});
|
|
73
|
-
});
|
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { DevFlowDatabase } from '../src/database';
|
|
3
|
-
import { rmSync, existsSync } from 'fs';
|
|
4
|
-
import { join } from 'path';
|
|
5
|
-
import type { BenchmarkSuiteReport } from '@devflow-tools/sdk';
|
|
6
|
-
|
|
7
|
-
describe('DevFlowDatabase', () => {
|
|
8
|
-
const testDir = join(process.cwd(), '.devflow-test');
|
|
9
|
-
let db: DevFlowDatabase;
|
|
10
|
-
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
// 清理测试目录
|
|
13
|
-
if (existsSync(testDir)) {
|
|
14
|
-
rmSync(testDir, { recursive: true });
|
|
15
|
-
}
|
|
16
|
-
db = new DevFlowDatabase(testDir);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
db.close();
|
|
21
|
-
if (existsSync(testDir)) {
|
|
22
|
-
rmSync(testDir, { recursive: true });
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('should create .devflow directory and database file', () => {
|
|
27
|
-
expect(existsSync(join(testDir, '.devflow', 'devflow.db'))).toBe(true);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('should initialize schema with runs table', () => {
|
|
31
|
-
const runs = db.getRuns();
|
|
32
|
-
expect(runs).toBeDefined();
|
|
33
|
-
expect(Array.isArray(runs)).toBe(true);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('should insert and retrieve a run', () => {
|
|
37
|
-
const run = {
|
|
38
|
-
id: 'run-123',
|
|
39
|
-
source: 'mcp',
|
|
40
|
-
tool: 'test_tool',
|
|
41
|
-
input: { query: 'test' },
|
|
42
|
-
status: 'completed',
|
|
43
|
-
startedAt: Date.now(),
|
|
44
|
-
finishedAt: Date.now(),
|
|
45
|
-
tokenUsed: 100,
|
|
46
|
-
metadata: { projectRoot: testDir }
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
db.insertRun(run);
|
|
50
|
-
const retrieved = db.getRun('run-123');
|
|
51
|
-
|
|
52
|
-
expect(retrieved).toBeDefined();
|
|
53
|
-
expect(retrieved?.id).toBe('run-123');
|
|
54
|
-
expect(retrieved?.tool).toBe('test_tool');
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('should update a run', () => {
|
|
58
|
-
const run = {
|
|
59
|
-
id: 'run-456',
|
|
60
|
-
source: 'cli',
|
|
61
|
-
tool: 'test',
|
|
62
|
-
status: 'active',
|
|
63
|
-
startedAt: Date.now(),
|
|
64
|
-
tokenUsed: 0
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
db.insertRun(run);
|
|
68
|
-
db.updateRun('run-456', { status: 'completed', finishedAt: Date.now(), tokenUsed: 50 });
|
|
69
|
-
|
|
70
|
-
const retrieved = db.getRun('run-456');
|
|
71
|
-
expect(retrieved?.status).toBe('completed');
|
|
72
|
-
expect(retrieved?.token_used).toBe(50);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('should insert and retrieve run steps', () => {
|
|
76
|
-
const run = {
|
|
77
|
-
id: 'run-789',
|
|
78
|
-
source: 'test',
|
|
79
|
-
tool: 'test',
|
|
80
|
-
status: 'active',
|
|
81
|
-
startedAt: Date.now(),
|
|
82
|
-
tokenUsed: 0
|
|
83
|
-
};
|
|
84
|
-
db.insertRun(run);
|
|
85
|
-
|
|
86
|
-
const step = {
|
|
87
|
-
runId: 'run-789',
|
|
88
|
-
name: 'step-1',
|
|
89
|
-
startTime: Date.now(),
|
|
90
|
-
endTime: Date.now(),
|
|
91
|
-
duration: 100,
|
|
92
|
-
status: 'completed',
|
|
93
|
-
metadata: { tokensUsed: 50 }
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
db.insertRunStep(step);
|
|
97
|
-
const steps = db.getRunSteps('run-789');
|
|
98
|
-
|
|
99
|
-
expect(steps).toHaveLength(1);
|
|
100
|
-
expect(steps[0].name).toBe('step-1');
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('should insert and retrieve telemetry events', () => {
|
|
104
|
-
// Insert a parent run first (FK constraint)
|
|
105
|
-
db.insertRun({
|
|
106
|
-
id: 'run-123',
|
|
107
|
-
source: 'test',
|
|
108
|
-
tool: 'test',
|
|
109
|
-
status: 'active',
|
|
110
|
-
startedAt: Date.now(),
|
|
111
|
-
tokenUsed: 0
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
const event = {
|
|
115
|
-
runId: 'run-123',
|
|
116
|
-
kind: 'tool_use',
|
|
117
|
-
timestamp: Date.now(),
|
|
118
|
-
duration: 100,
|
|
119
|
-
success: 1,
|
|
120
|
-
toolName: 'test',
|
|
121
|
-
pluginName: 'react',
|
|
122
|
-
metadata: { test: true }
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
db.insertEvent(event);
|
|
126
|
-
const events = db.getEvents('run-123');
|
|
127
|
-
|
|
128
|
-
expect(events).toHaveLength(1);
|
|
129
|
-
expect(events[0].kind).toBe('tool_use');
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('should upsert project stats', () => {
|
|
133
|
-
const stats = {
|
|
134
|
-
projectRoot: testDir,
|
|
135
|
-
totalRuns: 10,
|
|
136
|
-
successfulRuns: 8,
|
|
137
|
-
totalTokens: 1000,
|
|
138
|
-
lastActiveAt: Date.now(),
|
|
139
|
-
topTools: [{ toolName: 'test', count: 5 }]
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
db.upsertProjectStats(stats);
|
|
143
|
-
const retrieved = db.getProjectStats(testDir);
|
|
144
|
-
|
|
145
|
-
expect(retrieved).toBeDefined();
|
|
146
|
-
expect(retrieved?.totalRuns).toBe(10);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('should persist and list canonical benchmark reports', () => {
|
|
150
|
-
const report = {
|
|
151
|
-
runId: 'benchmark:test',
|
|
152
|
-
suiteId: 'default',
|
|
153
|
-
suiteName: 'Default',
|
|
154
|
-
suiteVersion: '1',
|
|
155
|
-
projectRoot: testDir,
|
|
156
|
-
commit: null,
|
|
157
|
-
createdAt: Date.now(),
|
|
158
|
-
rounds: 1,
|
|
159
|
-
status: 'completed',
|
|
160
|
-
tasks: [],
|
|
161
|
-
summary: {
|
|
162
|
-
baseline: { avgTokensUsed: 10, avgFilesTouched: 2, avgCorrectnessScore: 0.5, avgDurationMs: 5 },
|
|
163
|
-
devflow: { avgTokensUsed: 5, avgFilesTouched: 1, avgCorrectnessScore: 1, avgDurationMs: 4 },
|
|
164
|
-
delta: { tokenReductionRate: 0.5, fileReductionRate: 0.5, correctnessLift: 0.5, timeSavedRate: 0.2 },
|
|
165
|
-
},
|
|
166
|
-
regressions: [],
|
|
167
|
-
} satisfies BenchmarkSuiteReport;
|
|
168
|
-
|
|
169
|
-
db.insertBenchmarkReport(report);
|
|
170
|
-
|
|
171
|
-
expect(db.getBenchmarkReport<BenchmarkSuiteReport>(report.runId)).toEqual(report);
|
|
172
|
-
expect(db.getLatestBenchmarkReport<BenchmarkSuiteReport>('default')).toEqual(report);
|
|
173
|
-
expect(db.listBenchmarkReports()).toEqual([
|
|
174
|
-
expect.objectContaining({ runId: report.runId, suiteId: 'default' }),
|
|
175
|
-
]);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
-
import { DevFlowDatabase } from '../src/database';
|
|
6
|
-
|
|
7
|
-
describe('DevFlowDatabase durable work queue', () => {
|
|
8
|
-
let directory: string;
|
|
9
|
-
let database: DevFlowDatabase;
|
|
10
|
-
const projectRoot = '/project';
|
|
11
|
-
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
directory = mkdtempSync(join(tmpdir(), 'devflow-work-queue-'));
|
|
14
|
-
database = new DevFlowDatabase(directory);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
afterEach(() => {
|
|
18
|
-
database.close();
|
|
19
|
-
rmSync(directory, { recursive: true, force: true });
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('returns the existing item for an idempotency conflict without resetting completed work', () => {
|
|
23
|
-
const first = database.enqueueWork({
|
|
24
|
-
idempotencyKey: 'memory:session-1:turn-1',
|
|
25
|
-
kind: 'memory.explicit_commit',
|
|
26
|
-
projectRoot,
|
|
27
|
-
sessionId: 'session-1',
|
|
28
|
-
turnId: 'turn-1',
|
|
29
|
-
payload: { text: 'remember this' },
|
|
30
|
-
nextAttemptAt: 10,
|
|
31
|
-
});
|
|
32
|
-
const [leased] = database.leaseWork({
|
|
33
|
-
projectRoot,
|
|
34
|
-
owner: 'worker-a',
|
|
35
|
-
limit: 1,
|
|
36
|
-
leaseMs: 100,
|
|
37
|
-
now: 10,
|
|
38
|
-
});
|
|
39
|
-
expect(leased?.id).toBe(first.id);
|
|
40
|
-
expect(database.completeWork(first.id, 'worker-a', 20)).toBe(true);
|
|
41
|
-
|
|
42
|
-
const duplicate = database.enqueueWork({
|
|
43
|
-
idempotencyKey: 'memory:session-1:turn-1',
|
|
44
|
-
kind: 'telemetry.reconcile',
|
|
45
|
-
projectRoot: '/different-project',
|
|
46
|
-
payload: { replacement: true },
|
|
47
|
-
maxAttempts: 1,
|
|
48
|
-
nextAttemptAt: 999,
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
expect(duplicate).toMatchObject({
|
|
52
|
-
id: first.id,
|
|
53
|
-
kind: 'memory.explicit_commit',
|
|
54
|
-
projectRoot,
|
|
55
|
-
payload: { text: 'remember this' },
|
|
56
|
-
state: 'completed',
|
|
57
|
-
attempts: 1,
|
|
58
|
-
maxAttempts: 5,
|
|
59
|
-
completedAt: 20,
|
|
60
|
-
});
|
|
61
|
-
expect(database.all('SELECT id FROM devflow_work_items')).toHaveLength(1);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('leases atomically across database connections and requires the lease owner to complete', () => {
|
|
65
|
-
database.enqueueWork({
|
|
66
|
-
idempotencyKey: 'session:finalize:1',
|
|
67
|
-
kind: 'session.finalize',
|
|
68
|
-
projectRoot,
|
|
69
|
-
payload: { sessionId: 'session-1' },
|
|
70
|
-
nextAttemptAt: 100,
|
|
71
|
-
});
|
|
72
|
-
const secondConnection = new DevFlowDatabase(directory);
|
|
73
|
-
try {
|
|
74
|
-
const firstLease = database.leaseWork({
|
|
75
|
-
projectRoot,
|
|
76
|
-
owner: 'worker-a',
|
|
77
|
-
kinds: ['session.finalize'],
|
|
78
|
-
limit: 1,
|
|
79
|
-
leaseMs: 1_000,
|
|
80
|
-
now: 100,
|
|
81
|
-
});
|
|
82
|
-
const competingLease = secondConnection.leaseWork({
|
|
83
|
-
projectRoot,
|
|
84
|
-
owner: 'worker-b',
|
|
85
|
-
limit: 1,
|
|
86
|
-
leaseMs: 1_000,
|
|
87
|
-
now: 100,
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
expect(firstLease).toHaveLength(1);
|
|
91
|
-
expect(firstLease[0]).toMatchObject({
|
|
92
|
-
state: 'leased',
|
|
93
|
-
attempts: 1,
|
|
94
|
-
leaseOwner: 'worker-a',
|
|
95
|
-
leaseExpiresAt: 1_100,
|
|
96
|
-
});
|
|
97
|
-
expect(competingLease).toEqual([]);
|
|
98
|
-
expect(secondConnection.completeWork(firstLease[0]!.id, 'worker-b', 200)).toBe(false);
|
|
99
|
-
expect(database.completeWork(firstLease[0]!.id, 'worker-a', 200)).toBe(true);
|
|
100
|
-
expect(database.completeWork(firstLease[0]!.id, 'worker-a', 201)).toBe(false);
|
|
101
|
-
} finally {
|
|
102
|
-
secondConnection.close();
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('applies owner and state predicates to retries and dead letters', () => {
|
|
107
|
-
const item = database.enqueueWork({
|
|
108
|
-
idempotencyKey: 'telemetry:reconcile:1',
|
|
109
|
-
kind: 'telemetry.reconcile',
|
|
110
|
-
projectRoot,
|
|
111
|
-
payload: {},
|
|
112
|
-
nextAttemptAt: 50,
|
|
113
|
-
});
|
|
114
|
-
database.leaseWork({
|
|
115
|
-
projectRoot,
|
|
116
|
-
owner: 'worker-a',
|
|
117
|
-
limit: 1,
|
|
118
|
-
leaseMs: 100,
|
|
119
|
-
now: 50,
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
expect(database.retryWork(
|
|
123
|
-
item.id,
|
|
124
|
-
'worker-b',
|
|
125
|
-
{ category: 'temporary', message: 'try again' },
|
|
126
|
-
75,
|
|
127
|
-
)).toBe(false);
|
|
128
|
-
expect(database.retryWork(
|
|
129
|
-
item.id,
|
|
130
|
-
'worker-a',
|
|
131
|
-
{ category: 'temporary', message: 'try again' },
|
|
132
|
-
75,
|
|
133
|
-
)).toBe(true);
|
|
134
|
-
expect(database.getWorkQueueHealth(projectRoot, 75)).toMatchObject({
|
|
135
|
-
queueDepth: 1,
|
|
136
|
-
failed: 1,
|
|
137
|
-
deadLetters: 0,
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
const [retried] = database.leaseWork({
|
|
141
|
-
projectRoot,
|
|
142
|
-
owner: 'worker-b',
|
|
143
|
-
limit: 1,
|
|
144
|
-
leaseMs: 100,
|
|
145
|
-
now: 75,
|
|
146
|
-
});
|
|
147
|
-
expect(retried).toMatchObject({ attempts: 2, errorCategory: 'temporary' });
|
|
148
|
-
expect(database.deadLetterWork(
|
|
149
|
-
item.id,
|
|
150
|
-
'worker-a',
|
|
151
|
-
{ category: 'invalid_schema', message: 'payload is invalid' },
|
|
152
|
-
)).toBe(false);
|
|
153
|
-
expect(database.deadLetterWork(
|
|
154
|
-
item.id,
|
|
155
|
-
'worker-b',
|
|
156
|
-
{ category: 'invalid_schema', message: 'payload is invalid' },
|
|
157
|
-
)).toBe(true);
|
|
158
|
-
expect(database.deadLetterWork(
|
|
159
|
-
item.id,
|
|
160
|
-
'worker-b',
|
|
161
|
-
{ category: 'invalid_schema', message: 'payload is invalid' },
|
|
162
|
-
)).toBe(false);
|
|
163
|
-
expect(database.getWorkQueueHealth(projectRoot, 80)).toMatchObject({
|
|
164
|
-
queueDepth: 0,
|
|
165
|
-
deadLetters: 1,
|
|
166
|
-
});
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('serializes project mutations, cancels stale work, and preserves dead-letter replay lineage', () => {
|
|
170
|
-
expect(database.acquireProjectMutationLease({
|
|
171
|
-
projectRoot, mutationKind: 'knowledge_publish', owner: 'worker-a', leaseMs: 100, now: 10,
|
|
172
|
-
})).toBe(true);
|
|
173
|
-
expect(database.acquireProjectMutationLease({
|
|
174
|
-
projectRoot, mutationKind: 'knowledge_publish', owner: 'worker-b', leaseMs: 100, now: 50,
|
|
175
|
-
})).toBe(false);
|
|
176
|
-
expect(database.acquireProjectMutationLease({
|
|
177
|
-
projectRoot, mutationKind: 'knowledge_publish', owner: 'worker-b', leaseMs: 100, now: 111,
|
|
178
|
-
})).toBe(true);
|
|
179
|
-
|
|
180
|
-
const stale = database.enqueueWork({
|
|
181
|
-
idempotencyKey: 'knowledge:stale', kind: 'knowledge.ingest', projectRoot,
|
|
182
|
-
sourceHash: 'source:v1', taskSpecHash: 'spec:v1', payload: {}, nextAttemptAt: 200,
|
|
183
|
-
});
|
|
184
|
-
expect(stale.maxAttempts).toBe(3);
|
|
185
|
-
database.leaseWork({ projectRoot, owner: 'worker-b', workItemIds: [stale.id], limit: 1, leaseMs: 100, now: 200 });
|
|
186
|
-
expect(database.cancelStaleWork({
|
|
187
|
-
id: stale.id, owner: 'worker-b', sourceHash: 'source:v2', taskSpecHash: 'spec:v1', now: 210,
|
|
188
|
-
})).toBe(true);
|
|
189
|
-
expect(database.getWorkById(stale.id)).toMatchObject({ state: 'cancelled', sourceHash: 'source:v1' });
|
|
190
|
-
|
|
191
|
-
const failed = database.enqueueWork({
|
|
192
|
-
idempotencyKey: 'knowledge:dead', kind: 'knowledge.ingest', projectRoot,
|
|
193
|
-
sourceHash: 'source:dead', payload: {}, maxAttempts: 1, nextAttemptAt: 300,
|
|
194
|
-
});
|
|
195
|
-
database.leaseWork({ projectRoot, owner: 'worker-b', workItemIds: [failed.id], limit: 1, leaseMs: 100, now: 300 });
|
|
196
|
-
database.retryWork(failed.id, 'worker-b', { category: 'semantic_invalid', message: 'empty' }, 310);
|
|
197
|
-
const replay = database.replayDeadLetterWork(failed.id, {
|
|
198
|
-
idempotencyKey: 'knowledge:dead:replay:1', sourceHash: 'source:fixed', now: 320,
|
|
199
|
-
});
|
|
200
|
-
expect(replay).toMatchObject({ state: 'pending', replayOfId: failed.id, replayCount: 1, sourceHash: 'source:fixed' });
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it('recovers expired leases and dead-letters exhausted work', () => {
|
|
204
|
-
database.enqueueWork({
|
|
205
|
-
idempotencyKey: 'memory:distill:retryable',
|
|
206
|
-
kind: 'memory.turn_distill',
|
|
207
|
-
projectRoot,
|
|
208
|
-
payload: {},
|
|
209
|
-
maxAttempts: 2,
|
|
210
|
-
nextAttemptAt: 100,
|
|
211
|
-
});
|
|
212
|
-
database.enqueueWork({
|
|
213
|
-
idempotencyKey: 'memory:vector:exhausted',
|
|
214
|
-
kind: 'memory.vector_backfill',
|
|
215
|
-
projectRoot,
|
|
216
|
-
payload: {},
|
|
217
|
-
maxAttempts: 1,
|
|
218
|
-
nextAttemptAt: 100,
|
|
219
|
-
});
|
|
220
|
-
database.leaseWork({
|
|
221
|
-
projectRoot,
|
|
222
|
-
owner: 'worker-a',
|
|
223
|
-
limit: 2,
|
|
224
|
-
leaseMs: 10,
|
|
225
|
-
now: 100,
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
expect(database.getWorkQueueHealth(projectRoot, 110)).toMatchObject({
|
|
229
|
-
queueDepth: 2,
|
|
230
|
-
leased: 2,
|
|
231
|
-
expiredLeases: 2,
|
|
232
|
-
});
|
|
233
|
-
expect(database.recoverExpiredWork(projectRoot, 110)).toBe(2);
|
|
234
|
-
expect(database.getWorkQueueHealth(projectRoot, 110)).toMatchObject({
|
|
235
|
-
queueDepth: 1,
|
|
236
|
-
leased: 0,
|
|
237
|
-
failed: 1,
|
|
238
|
-
expiredLeases: 0,
|
|
239
|
-
deadLetters: 1,
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
const recovered = database.leaseWork({
|
|
243
|
-
projectRoot,
|
|
244
|
-
owner: 'worker-b',
|
|
245
|
-
limit: 2,
|
|
246
|
-
leaseMs: 20,
|
|
247
|
-
now: 110,
|
|
248
|
-
});
|
|
249
|
-
expect(recovered).toHaveLength(1);
|
|
250
|
-
expect(recovered[0]).toMatchObject({
|
|
251
|
-
idempotencyKey: 'memory:distill:retryable',
|
|
252
|
-
state: 'leased',
|
|
253
|
-
attempts: 2,
|
|
254
|
-
leaseOwner: 'worker-b',
|
|
255
|
-
errorCategory: 'lease_expired',
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
const revived = database.enqueueWork({
|
|
259
|
-
idempotencyKey: 'memory:vector:exhausted',
|
|
260
|
-
kind: 'memory.vector_backfill',
|
|
261
|
-
projectRoot,
|
|
262
|
-
payload: {},
|
|
263
|
-
maxAttempts: 20,
|
|
264
|
-
nextAttemptAt: 120,
|
|
265
|
-
});
|
|
266
|
-
expect(revived).toMatchObject({
|
|
267
|
-
state: 'failed',
|
|
268
|
-
attempts: 1,
|
|
269
|
-
maxAttempts: 20,
|
|
270
|
-
nextAttemptAt: 120,
|
|
271
|
-
errorCategory: undefined,
|
|
272
|
-
});
|
|
273
|
-
});
|
|
274
|
-
});
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
-
import { DevFlowDatabase } from '../src/database.js';
|
|
6
|
-
|
|
7
|
-
const roots: string[] = [];
|
|
8
|
-
|
|
9
|
-
function open(): { db: DevFlowDatabase; root: string } {
|
|
10
|
-
const root = mkdtempSync(join(tmpdir(), 'devflow-workers-'));
|
|
11
|
-
roots.push(root);
|
|
12
|
-
return { db: new DevFlowDatabase(root), root };
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
afterEach(() => {
|
|
16
|
-
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
describe('workflow worker persistence', () => {
|
|
20
|
-
it('persists legal transitions and refuses terminal mutation', () => {
|
|
21
|
-
const { db, root } = open();
|
|
22
|
-
const worker = db.createWorkflowWorker({
|
|
23
|
-
runId: 'run-1', stepId: 'step-1', projectRoot: root,
|
|
24
|
-
branch: 'devflow/run-1/step-1', worktreePath: join(root, '.worker'),
|
|
25
|
-
host: 'codex', contextReceipt: 'receipt-1', baseRevision: 'a'.repeat(40),
|
|
26
|
-
});
|
|
27
|
-
db.leaseWorkflowWorker(root, worker.workerId, 'owner', 1_000, 100);
|
|
28
|
-
db.transitionWorkflowWorker({ projectRoot: root, workerId: worker.workerId, target: 'starting', leaseOwner: 'owner', now: 110 });
|
|
29
|
-
db.transitionWorkflowWorker({ projectRoot: root, workerId: worker.workerId, target: 'running', leaseOwner: 'owner', now: 120 });
|
|
30
|
-
db.transitionWorkflowWorker({ projectRoot: root, workerId: worker.workerId, target: 'failed', leaseOwner: 'owner', reason: 'test', now: 130 });
|
|
31
|
-
expect(() => db.transitionWorkflowWorker({ projectRoot: root, workerId: worker.workerId, target: 'hold' }))
|
|
32
|
-
.toThrow(/TERMINAL_CONFLICT/);
|
|
33
|
-
expect(db.listWorkflowWorkerEvents(root, worker.workerId)).toHaveLength(5);
|
|
34
|
-
db.close();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('recovers expired leases to hold without retrying the worktree', () => {
|
|
38
|
-
const { db, root } = open();
|
|
39
|
-
const worker = db.createWorkflowWorker({
|
|
40
|
-
runId: 'run-2', stepId: 'step-2', projectRoot: root,
|
|
41
|
-
branch: 'devflow/run-2/step-2', worktreePath: join(root, '.worker-2'),
|
|
42
|
-
host: 'claude-code', contextReceipt: 'receipt-2', baseRevision: 'b'.repeat(40),
|
|
43
|
-
});
|
|
44
|
-
db.leaseWorkflowWorker(root, worker.workerId, 'owner', 10, 100);
|
|
45
|
-
expect(db.recoverExpiredWorkflowWorkers(root, 111)[0]?.state).toBe('hold');
|
|
46
|
-
db.close();
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('keeps merge queue insertion idempotent', () => {
|
|
50
|
-
const { db, root } = open();
|
|
51
|
-
const input = {
|
|
52
|
-
workerId: 'worker-3', projectRoot: root, baseRevision: 'a', parentRevision: 'b',
|
|
53
|
-
candidateRevision: 'c', graderReceipts: ['grader:1'], simulationHash: 'sha256:1',
|
|
54
|
-
};
|
|
55
|
-
const first = db.enqueueWorkflowMerge(input);
|
|
56
|
-
expect(db.enqueueWorkflowMerge(input).mergeId).toBe(first.mergeId);
|
|
57
|
-
expect(db.transitionWorkflowMerge(root, first.mergeId, 'ready').state).toBe('ready');
|
|
58
|
-
db.close();
|
|
59
|
-
});
|
|
60
|
-
});
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { NodeSqliteDatabase } from '../src/node-sqlite';
|
|
3
|
-
import { unlinkSync, existsSync } from 'fs';
|
|
4
|
-
|
|
5
|
-
describe('NodeSqliteDatabase', () => {
|
|
6
|
-
let db: NodeSqliteDatabase;
|
|
7
|
-
const testDbPath = '/tmp/test-node-sqlite.db';
|
|
8
|
-
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
if (existsSync(testDbPath)) {
|
|
11
|
-
unlinkSync(testDbPath);
|
|
12
|
-
}
|
|
13
|
-
db = new NodeSqliteDatabase(testDbPath);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
afterEach(() => {
|
|
17
|
-
db.close();
|
|
18
|
-
if (existsSync(testDbPath)) {
|
|
19
|
-
unlinkSync(testDbPath);
|
|
20
|
-
}
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('should create database and execute SQL', () => {
|
|
24
|
-
db.exec(`
|
|
25
|
-
CREATE TABLE test (
|
|
26
|
-
id INTEGER PRIMARY KEY,
|
|
27
|
-
name TEXT
|
|
28
|
-
)
|
|
29
|
-
`);
|
|
30
|
-
|
|
31
|
-
const stmt = db.prepare('INSERT INTO test (name) VALUES (?)');
|
|
32
|
-
const result = stmt.run('test');
|
|
33
|
-
|
|
34
|
-
expect(result.changes).toBe(1);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('should query data', () => {
|
|
38
|
-
db.exec('CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)');
|
|
39
|
-
db.prepare('INSERT INTO test (name) VALUES (?)').run('Alice');
|
|
40
|
-
db.prepare('INSERT INTO test (name) VALUES (?)').run('Bob');
|
|
41
|
-
|
|
42
|
-
const row = db.prepare('SELECT * FROM test WHERE name = ?').get('Alice');
|
|
43
|
-
expect(row.name).toBe('Alice');
|
|
44
|
-
|
|
45
|
-
const rows = db.prepare('SELECT * FROM test').all();
|
|
46
|
-
expect(rows).toHaveLength(2);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('normalizes boolean bind parameters for node:sqlite', () => {
|
|
50
|
-
db.exec('CREATE TABLE test (success INTEGER NOT NULL)');
|
|
51
|
-
db.prepare('INSERT INTO test (success) VALUES (?)').run(true);
|
|
52
|
-
|
|
53
|
-
expect(db.prepare('SELECT success FROM test WHERE success = ?').get(true))
|
|
54
|
-
.toMatchObject({ success: 1 });
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('should support transactions', () => {
|
|
58
|
-
db.exec('CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)');
|
|
59
|
-
|
|
60
|
-
db.transaction(() => {
|
|
61
|
-
db.prepare('INSERT INTO test (name) VALUES (?)').run('Alice');
|
|
62
|
-
db.prepare('INSERT INTO test (name) VALUES (?)').run('Bob');
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
const rows = db.prepare('SELECT * FROM test').all();
|
|
66
|
-
expect(rows).toHaveLength(2);
|
|
67
|
-
});
|
|
68
|
-
});
|