@devflow-tools/database 0.16.18 → 0.16.19

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 CHANGED
@@ -3,6 +3,22 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [0.16.19](https://github.com/shilongfeicool/dev-flow/compare/v0.16.18...v0.16.19) (2026-07-28)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **database:** harden host action evidence ([b74dc11](https://github.com/shilongfeicool/dev-flow/commit/b74dc110e0b96583dd741d2772c286e414928e45))
12
+
13
+
14
+ ### Features
15
+
16
+ * **database:** persist workflow host actions ([6e42606](https://github.com/shilongfeicool/dev-flow/commit/6e42606ff028a591878ade9378c5277257a35961))
17
+
18
+
19
+
20
+
21
+
6
22
  ## [0.16.18](https://github.com/shilongfeicool/dev-flow/compare/v0.16.17...v0.16.18) (2026-07-27)
7
23
 
8
24
  **Note:** Version bump only for package @devflow-tools/database
@@ -0,0 +1,405 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { Worker } from 'node:worker_threads';
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import { DevFlowDatabase } from '../src/database';
7
+ import type { FailHostActionInput, VerifyHostActionInput } from '../src/host-actions';
8
+
9
+ describe('DevFlowDatabase durable host actions', () => {
10
+ let directory: string;
11
+ let database: DevFlowDatabase;
12
+ const projectRoot = '/project-a';
13
+
14
+ beforeEach(() => {
15
+ directory = mkdtempSync(join(tmpdir(), 'devflow-host-actions-'));
16
+ database = new DevFlowDatabase(directory);
17
+ });
18
+
19
+ afterEach(() => {
20
+ database.close();
21
+ rmSync(directory, { recursive: true, force: true });
22
+ });
23
+
24
+ function request(actionId: string, stepId = 'execute') {
25
+ return database.requestHostAction({
26
+ actionId,
27
+ runId: 'telemetry-run-1',
28
+ engineRunId: 'engine-run-1',
29
+ stepId,
30
+ projectRoot,
31
+ sessionId: 'session-1',
32
+ executionId: 'execution-1',
33
+ contextReceipt: 'context-1',
34
+ });
35
+ }
36
+
37
+ function eventCount(actionId: string): number {
38
+ return Number((database.get(
39
+ 'SELECT COUNT(*) AS count FROM devflow_host_action_events WHERE action_id = ?',
40
+ actionId,
41
+ ) as { count: number }).count);
42
+ }
43
+
44
+ it('persists the full waiting to running to reported to verified flow', () => {
45
+ expect(request('action-full')).toMatchObject({
46
+ actionId: 'action-full',
47
+ runId: 'telemetry-run-1',
48
+ engineRunId: 'engine-run-1',
49
+ state: 'waiting',
50
+ report: {},
51
+ finishedAt: undefined,
52
+ });
53
+ expect(database.startHostAction({ actionId: 'action-full', projectRoot })).toMatchObject({
54
+ state: 'running',
55
+ });
56
+ expect(database.reportHostAction({
57
+ actionId: 'action-full',
58
+ projectRoot,
59
+ report: { files: ['src/index.ts'], exitCode: 0 },
60
+ evidenceHash: 'execution-hash',
61
+ })).toMatchObject({ state: 'reported', evidenceHash: 'execution-hash' });
62
+ const verified = database.verifyHostAction({
63
+ actionId: 'action-full',
64
+ projectRoot,
65
+ report: { command: 'npm test', exitCode: 0 },
66
+ evidenceHash: 'verification-hash',
67
+ });
68
+
69
+ expect(verified).toMatchObject({
70
+ state: 'verified',
71
+ report: { command: 'npm test', exitCode: 0 },
72
+ evidenceHash: 'verification-hash',
73
+ });
74
+ expect(verified.finishedAt).toEqual(expect.any(Number));
75
+ expect(eventCount('action-full')).toBe(4);
76
+ });
77
+
78
+ it('supports a direct waiting to reported transition for MCP reports', () => {
79
+ request('action-direct');
80
+ expect(database.reportHostAction({
81
+ actionId: 'action-direct',
82
+ projectRoot,
83
+ report: { toolCalls: ['write_file'] },
84
+ evidenceHash: 'direct-hash',
85
+ })).toMatchObject({ state: 'reported', finishedAt: undefined });
86
+ expect(eventCount('action-direct')).toBe(2);
87
+ });
88
+
89
+ it('rolls back invalid transitions without appending an event', () => {
90
+ request('action-invalid');
91
+ const before = eventCount('action-invalid');
92
+
93
+ expect(() => database.verifyHostAction({
94
+ actionId: 'action-invalid',
95
+ projectRoot,
96
+ report: { command: 'npm test', exitCode: 0 },
97
+ evidenceHash: 'verify-hash',
98
+ })).toThrow('HOST_ACTION_INVALID_TRANSITION:waiting->verified');
99
+ expect(database.getHostAction(projectRoot, 'action-invalid')?.state).toBe('waiting');
100
+ expect(eventCount('action-invalid')).toBe(before);
101
+ });
102
+
103
+ it('makes identical terminal reports idempotent and rejects changed duplicates', () => {
104
+ request('action-terminal');
105
+ database.reportHostAction({
106
+ actionId: 'action-terminal',
107
+ projectRoot,
108
+ report: { exitCode: 0 },
109
+ evidenceHash: 'execution-hash',
110
+ });
111
+ const input = {
112
+ actionId: 'action-terminal',
113
+ projectRoot,
114
+ report: { summary: 'passed', exitCode: 0 },
115
+ evidenceHash: 'terminal-hash',
116
+ };
117
+ const terminal = database.verifyHostAction(input);
118
+ const before = eventCount('action-terminal');
119
+
120
+ expect(database.verifyHostAction({
121
+ ...input,
122
+ report: { exitCode: 0, summary: 'passed' },
123
+ })).toEqual(terminal);
124
+ expect(eventCount('action-terminal')).toBe(before);
125
+ expect(() => database.verifyHostAction({
126
+ ...input,
127
+ evidenceHash: 'changed-hash',
128
+ })).toThrow('HOST_ACTION_TERMINAL_CONFLICT:action-terminal');
129
+ expect(() => database.verifyHostAction({
130
+ ...input,
131
+ report: { summary: 'different', exitCode: 0 },
132
+ })).toThrow('HOST_ACTION_TERMINAL_CONFLICT:action-terminal');
133
+ expect(eventCount('action-terminal')).toBe(before);
134
+ });
135
+
136
+ it.each(['failed', 'cancelled', 'degraded'] as const)(
137
+ 'reaches %s with a completion timestamp',
138
+ outcome => {
139
+ const actionId = `action-${outcome}`;
140
+ request(actionId, outcome);
141
+ const terminal = database.failHostAction({
142
+ actionId,
143
+ projectRoot,
144
+ outcome,
145
+ report: { reason: `${outcome} by host` },
146
+ evidenceHash: `${outcome}-hash`,
147
+ });
148
+
149
+ expect(terminal.state).toBe(outcome);
150
+ expect(terminal.finishedAt).toEqual(expect.any(Number));
151
+ expect(eventCount(actionId)).toBe(2);
152
+ },
153
+ );
154
+
155
+ it('rejects request identity conflicts instead of merging metadata', () => {
156
+ request('action-identity');
157
+ const identical = request('action-identity');
158
+ expect(identical.actionId).toBe('action-identity');
159
+ expect(eventCount('action-identity')).toBe(1);
160
+
161
+ expect(() => database.requestHostAction({
162
+ actionId: 'different-action',
163
+ runId: 'telemetry-run-1',
164
+ engineRunId: 'different-engine-run',
165
+ stepId: 'execute',
166
+ projectRoot,
167
+ sessionId: 'different-session',
168
+ executionId: 'execution-1',
169
+ contextReceipt: 'context-1',
170
+ })).toThrow('HOST_ACTION_IDENTITY_CONFLICT:telemetry-run-1:execute');
171
+ expect(database.all('SELECT action_id FROM devflow_host_actions')).toHaveLength(1);
172
+ });
173
+
174
+ it('scopes action and run reads by project root', () => {
175
+ request('action-isolated');
176
+
177
+ expect(database.getHostAction('/project-b', 'action-isolated')).toBeNull();
178
+ expect(database.listHostActionsForRun('/project-b', 'telemetry-run-1')).toEqual([]);
179
+ expect(database.listHostActionsForRun(projectRoot, 'telemetry-run-1')).toHaveLength(1);
180
+ expect(() => database.startHostAction({
181
+ actionId: 'action-isolated',
182
+ projectRoot: '/project-b',
183
+ })).toThrow('HOST_ACTION_NOT_FOUND:action-isolated');
184
+ });
185
+
186
+ it('keeps rows and schema stable across close and reopen', () => {
187
+ request('action-durable');
188
+ database.reportHostAction({
189
+ actionId: 'action-durable',
190
+ projectRoot,
191
+ report: { result: 'observed' },
192
+ evidenceHash: 'durable-hash',
193
+ });
194
+ const schemaBefore = database.all(`
195
+ SELECT type, name, sql FROM sqlite_master
196
+ WHERE name LIKE '%host_action%'
197
+ ORDER BY type, name
198
+ `);
199
+ database.close();
200
+ database = new DevFlowDatabase(directory);
201
+
202
+ expect(database.getHostAction(projectRoot, 'action-durable')).toMatchObject({
203
+ state: 'reported',
204
+ report: { result: 'observed' },
205
+ evidenceHash: 'durable-hash',
206
+ });
207
+ expect(eventCount('action-durable')).toBe(2);
208
+ expect(database.all(`
209
+ SELECT type, name, sql FROM sqlite_master
210
+ WHERE name LIKE '%host_action%'
211
+ ORDER BY type, name
212
+ `)).toEqual(schemaBefore);
213
+ });
214
+
215
+ it('uses UUID text event IDs and enforces append-only event rows', () => {
216
+ request('action-events');
217
+ const [event] = database.all(`
218
+ SELECT event_id FROM devflow_host_action_events WHERE action_id = ?
219
+ `, 'action-events') as Array<{ event_id: string }>;
220
+
221
+ expect(event?.event_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
222
+ expect(() => database.all(
223
+ 'UPDATE devflow_host_action_events SET evidence_hash = ? WHERE event_id = ?',
224
+ 'changed',
225
+ event!.event_id,
226
+ )).toThrow('append-only');
227
+ });
228
+
229
+ it('rejects INSERT OR REPLACE attempts without mutating an event', () => {
230
+ request('action-replace');
231
+ const [event] = database.all(`
232
+ SELECT * FROM devflow_host_action_events WHERE action_id = ?
233
+ `, 'action-replace') as Array<Record<string, unknown>>;
234
+
235
+ expect(() => database.all(`
236
+ INSERT OR REPLACE INTO devflow_host_action_events (
237
+ event_id, action_id, project_root, run_id, step_id, from_state,
238
+ to_state, report_json, evidence_hash, created_at
239
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
240
+ `,
241
+ event!.event_id,
242
+ event!.action_id,
243
+ event!.project_root,
244
+ event!.run_id,
245
+ event!.step_id,
246
+ event!.from_state,
247
+ event!.to_state,
248
+ event!.report_json,
249
+ 'replacement-hash',
250
+ event!.created_at,
251
+ )).toThrow('append-only');
252
+
253
+ expect(database.get(
254
+ 'SELECT evidence_hash FROM devflow_host_action_events WHERE event_id = ?',
255
+ event!.event_id,
256
+ )).toMatchObject({ evidence_hash: null });
257
+ });
258
+
259
+ it.each([
260
+ ['undefined', { value: undefined }],
261
+ ['NaN', { value: Number.NaN }],
262
+ ['Infinity', { value: Number.POSITIVE_INFINITY }],
263
+ ['negative zero', { value: -0 }],
264
+ ['function', { value: () => undefined }],
265
+ ['bigint', { value: 1n }],
266
+ ['symbol', { value: Symbol('value') }],
267
+ ['non-plain object', { value: new Date(0) }],
268
+ ['sparse array', { value: Array(1) }],
269
+ ])('rejects lossy %s report values', (_name, report) => {
270
+ request(`action-json-${_name}`);
271
+ expect(() => database.reportHostAction({
272
+ actionId: `action-json-${_name}`,
273
+ projectRoot,
274
+ report,
275
+ evidenceHash: 'report-hash',
276
+ })).toThrow('Host action report contains a non-JSON value');
277
+ });
278
+
279
+ it('rejects cyclic reports', () => {
280
+ request('action-json-cycle');
281
+ const report: Record<string, unknown> = {};
282
+ report.self = report;
283
+
284
+ expect(() => database.reportHostAction({
285
+ actionId: 'action-json-cycle',
286
+ projectRoot,
287
+ report,
288
+ evidenceHash: 'report-hash',
289
+ })).toThrow('Host action report contains a cycle');
290
+ });
291
+
292
+ it.each(['not-json', '[]'])(
293
+ 'fails closed when persisted report JSON is corrupt: %s',
294
+ reportJson => {
295
+ request('action-corrupt');
296
+ database.all(
297
+ 'UPDATE devflow_host_actions SET report_json = ? WHERE action_id = ?',
298
+ reportJson,
299
+ 'action-corrupt',
300
+ );
301
+
302
+ expect(() => database.getHostAction(projectRoot, 'action-corrupt'))
303
+ .toThrow('Invalid persisted host action report_json');
304
+ },
305
+ );
306
+
307
+ it('requires non-empty verification evidence and report content', () => {
308
+ request('action-verification-input');
309
+ database.reportHostAction({
310
+ actionId: 'action-verification-input',
311
+ projectRoot,
312
+ report: { output: 'observed' },
313
+ evidenceHash: 'execution-hash',
314
+ });
315
+ const baseInput = {
316
+ actionId: 'action-verification-input',
317
+ projectRoot,
318
+ report: { command: 'npm test', exitCode: 0 },
319
+ };
320
+
321
+ expect(() => database.verifyHostAction({
322
+ ...baseInput,
323
+ evidenceHash: ' ',
324
+ })).toThrow('Host action verification requires a non-empty evidence hash');
325
+ expect(() => database.verifyHostAction(baseInput as VerifyHostActionInput))
326
+ .toThrow('Host action verification requires a non-empty evidence hash');
327
+ expect(() => database.verifyHostAction({
328
+ ...baseInput,
329
+ report: {},
330
+ evidenceHash: 'verification-hash',
331
+ })).toThrow('Host action verification requires a non-empty report');
332
+ expect(() => database.failHostAction({
333
+ ...baseInput,
334
+ outcome: 'verified',
335
+ } as unknown as FailHostActionInput)).toThrow('Invalid host action failure outcome');
336
+ expect(database.getHostAction(projectRoot, 'action-verification-input')?.state).toBe('reported');
337
+ expect(eventCount('action-verification-input')).toBe(2);
338
+ });
339
+
340
+ it('waits for a competing writer and resolves the committed request idempotently', async () => {
341
+ const worker = new Worker(`
342
+ const { parentPort, workerData } = require('node:worker_threads');
343
+ const { DatabaseSync } = require('node:sqlite');
344
+ const db = new DatabaseSync(workerData.dbPath);
345
+ db.exec('PRAGMA busy_timeout = 5000');
346
+ db.exec('BEGIN IMMEDIATE');
347
+ const now = Date.now();
348
+ db.prepare(
349
+ 'INSERT INTO devflow_host_actions (' +
350
+ 'action_id, run_id, engine_run_id, step_id, project_root, session_id, ' +
351
+ 'execution_id, context_receipt, state, report_json, evidence_hash, ' +
352
+ 'created_at, updated_at, finished_at' +
353
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'waiting', '{}', NULL, ?, ?, NULL)",
354
+ ).run(
355
+ 'action-contended', 'telemetry-run-1', 'engine-run-1', 'execute', '/project-a',
356
+ 'session-1', 'execution-1', 'context-1', now, now,
357
+ );
358
+ db.prepare(
359
+ 'INSERT INTO devflow_host_action_events (' +
360
+ 'event_id, action_id, project_root, run_id, step_id, from_state, ' +
361
+ 'to_state, report_json, evidence_hash, created_at' +
362
+ ") VALUES (?, ?, ?, ?, ?, NULL, 'waiting', '{}', NULL, ?)",
363
+ ).run(
364
+ 'worker-event-contended', 'action-contended', '/project-a',
365
+ 'telemetry-run-1', 'execute', now,
366
+ );
367
+ parentPort.postMessage('locked');
368
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
369
+ db.exec('COMMIT');
370
+ db.close();
371
+ parentPort.postMessage('committed');
372
+ `, {
373
+ eval: true,
374
+ workerData: { dbPath: join(directory, '.devflow', 'devflow.db') },
375
+ });
376
+ let duplicate: ReturnType<typeof request> | undefined;
377
+
378
+ try {
379
+ await new Promise<void>((resolve, reject) => {
380
+ worker.on('message', message => {
381
+ if (message === 'locked') {
382
+ try {
383
+ duplicate = request('action-contended');
384
+ } catch (error) {
385
+ reject(error);
386
+ }
387
+ }
388
+ if (message === 'committed') resolve();
389
+ });
390
+ worker.on('error', reject);
391
+ worker.on('exit', code => {
392
+ if (code !== 0) reject(new Error(`Host-action lock worker exited with code ${code}`));
393
+ });
394
+ });
395
+
396
+ expect(duplicate).toMatchObject({
397
+ actionId: 'action-contended',
398
+ state: 'waiting',
399
+ });
400
+ expect(eventCount('action-contended')).toBe(1);
401
+ } finally {
402
+ await worker.terminate();
403
+ }
404
+ });
405
+ });
@@ -1,5 +1,6 @@
1
1
  import type { EnqueueWorkInput, LeaseWorkInput, RequestSessionClosureInput, SessionClosureRecord, WorkError, WorkItemRecord, WorkQueueHealth } from './work-queue';
2
2
  import { type SessionObligationRecord, type SessionObligationState } from './obligation-ledger';
3
+ import { type FailHostActionInput, type HostActionRecord, type ReportHostActionInput, type RequestHostActionInput, type StartHostActionInput, type VerifyHostActionInput } from './host-actions';
3
4
  export interface BenchmarkReportRecord {
4
5
  runId: string;
5
6
  suiteId: string;
@@ -400,6 +401,19 @@ export declare class DevFlowDatabase {
400
401
  private mapMemoryTurn;
401
402
  recordMemoryDistillCheckpoint(checkpoint: MemoryDistillCheckpointRecord): void;
402
403
  listMemoryDistillCheckpoints(projectRoot: string, limit?: number): MemoryDistillCheckpointRecord[];
404
+ requestHostAction(input: RequestHostActionInput): HostActionRecord;
405
+ startHostAction(input: StartHostActionInput): HostActionRecord;
406
+ reportHostAction(input: ReportHostActionInput): HostActionRecord;
407
+ verifyHostAction(input: VerifyHostActionInput): HostActionRecord;
408
+ failHostAction(input: FailHostActionInput): HostActionRecord;
409
+ getHostAction(projectRoot: string, actionId: string): HostActionRecord | null;
410
+ listHostActionsForRun(projectRoot: string, runId: string): HostActionRecord[];
411
+ private transitionHostAction;
412
+ private appendHostActionEvent;
413
+ private validateHostActionIdentity;
414
+ private withImmediateTransaction;
415
+ private isTerminalHostActionState;
416
+ private isLegalHostActionTransition;
403
417
  all(sql: string, ...params: unknown[]): unknown[];
404
418
  get(sql: string, ...params: unknown[]): unknown;
405
419
  close(): void;