@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 +16 -0
- package/__tests__/database.host-actions.test.ts +405 -0
- package/dist/database.d.ts +14 -0
- package/dist/database.js +250 -0
- package/dist/host-actions.d.ts +47 -0
- package/dist/host-actions.js +152 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -1
- package/package.json +1 -1
- package/src/database.ts +323 -0
- package/src/host-actions.ts +211 -0
- package/src/index.ts +15 -0
package/src/database.ts
CHANGED
|
@@ -19,6 +19,18 @@ import {
|
|
|
19
19
|
type SessionObligationRecord,
|
|
20
20
|
type SessionObligationState,
|
|
21
21
|
} from './obligation-ledger';
|
|
22
|
+
import {
|
|
23
|
+
hostActionReportsEqual,
|
|
24
|
+
mapHostActionRow,
|
|
25
|
+
serializeHostActionReport,
|
|
26
|
+
type FailHostActionInput,
|
|
27
|
+
type HostActionRecord,
|
|
28
|
+
type HostActionState,
|
|
29
|
+
type ReportHostActionInput,
|
|
30
|
+
type RequestHostActionInput,
|
|
31
|
+
type StartHostActionInput,
|
|
32
|
+
type VerifyHostActionInput,
|
|
33
|
+
} from './host-actions';
|
|
22
34
|
|
|
23
35
|
export interface BenchmarkReportRecord {
|
|
24
36
|
runId: string;
|
|
@@ -587,6 +599,79 @@ export class DevFlowDatabase {
|
|
|
587
599
|
|
|
588
600
|
CREATE INDEX IF NOT EXISTS idx_session_closures_state
|
|
589
601
|
ON devflow_session_closures(project_root, state, updated_at DESC);
|
|
602
|
+
|
|
603
|
+
CREATE TABLE IF NOT EXISTS devflow_host_actions (
|
|
604
|
+
action_id TEXT PRIMARY KEY,
|
|
605
|
+
run_id TEXT NOT NULL,
|
|
606
|
+
engine_run_id TEXT NOT NULL,
|
|
607
|
+
step_id TEXT NOT NULL,
|
|
608
|
+
project_root TEXT NOT NULL,
|
|
609
|
+
session_id TEXT,
|
|
610
|
+
execution_id TEXT,
|
|
611
|
+
context_receipt TEXT,
|
|
612
|
+
state TEXT NOT NULL DEFAULT 'waiting'
|
|
613
|
+
CHECK(state IN ('waiting', 'running', 'reported', 'verified', 'failed', 'cancelled', 'degraded')),
|
|
614
|
+
report_json TEXT NOT NULL DEFAULT '{}',
|
|
615
|
+
evidence_hash TEXT,
|
|
616
|
+
created_at INTEGER NOT NULL,
|
|
617
|
+
updated_at INTEGER NOT NULL,
|
|
618
|
+
finished_at INTEGER,
|
|
619
|
+
UNIQUE(run_id, step_id),
|
|
620
|
+
CHECK(
|
|
621
|
+
(state IN ('verified', 'failed', 'cancelled', 'degraded') AND finished_at IS NOT NULL)
|
|
622
|
+
OR (state IN ('waiting', 'running', 'reported') AND finished_at IS NULL)
|
|
623
|
+
)
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
CREATE INDEX IF NOT EXISTS idx_host_actions_project
|
|
627
|
+
ON devflow_host_actions(project_root, created_at DESC);
|
|
628
|
+
CREATE INDEX IF NOT EXISTS idx_host_actions_session
|
|
629
|
+
ON devflow_host_actions(project_root, session_id, created_at DESC);
|
|
630
|
+
CREATE INDEX IF NOT EXISTS idx_host_actions_run
|
|
631
|
+
ON devflow_host_actions(project_root, run_id, created_at, action_id);
|
|
632
|
+
CREATE INDEX IF NOT EXISTS idx_host_actions_action
|
|
633
|
+
ON devflow_host_actions(action_id);
|
|
634
|
+
|
|
635
|
+
CREATE TABLE IF NOT EXISTS devflow_host_action_events (
|
|
636
|
+
event_id TEXT PRIMARY KEY,
|
|
637
|
+
action_id TEXT NOT NULL,
|
|
638
|
+
project_root TEXT NOT NULL,
|
|
639
|
+
run_id TEXT NOT NULL,
|
|
640
|
+
step_id TEXT NOT NULL,
|
|
641
|
+
from_state TEXT
|
|
642
|
+
CHECK(from_state IS NULL OR from_state IN ('waiting', 'running', 'reported', 'verified', 'failed', 'cancelled', 'degraded')),
|
|
643
|
+
to_state TEXT NOT NULL
|
|
644
|
+
CHECK(to_state IN ('waiting', 'running', 'reported', 'verified', 'failed', 'cancelled', 'degraded')),
|
|
645
|
+
report_json TEXT NOT NULL DEFAULT '{}',
|
|
646
|
+
evidence_hash TEXT,
|
|
647
|
+
created_at INTEGER NOT NULL
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
CREATE INDEX IF NOT EXISTS idx_host_action_events_action
|
|
651
|
+
ON devflow_host_action_events(action_id, created_at, event_id);
|
|
652
|
+
CREATE INDEX IF NOT EXISTS idx_host_action_events_project_run
|
|
653
|
+
ON devflow_host_action_events(project_root, run_id, created_at, event_id);
|
|
654
|
+
|
|
655
|
+
CREATE TRIGGER IF NOT EXISTS prevent_host_action_event_replace
|
|
656
|
+
BEFORE INSERT ON devflow_host_action_events
|
|
657
|
+
WHEN EXISTS (
|
|
658
|
+
SELECT 1 FROM devflow_host_action_events WHERE event_id = NEW.event_id
|
|
659
|
+
)
|
|
660
|
+
BEGIN
|
|
661
|
+
SELECT RAISE(ABORT, 'devflow_host_action_events is append-only');
|
|
662
|
+
END;
|
|
663
|
+
|
|
664
|
+
CREATE TRIGGER IF NOT EXISTS prevent_host_action_event_update
|
|
665
|
+
BEFORE UPDATE ON devflow_host_action_events
|
|
666
|
+
BEGIN
|
|
667
|
+
SELECT RAISE(ABORT, 'devflow_host_action_events is append-only');
|
|
668
|
+
END;
|
|
669
|
+
|
|
670
|
+
CREATE TRIGGER IF NOT EXISTS prevent_host_action_event_delete
|
|
671
|
+
BEFORE DELETE ON devflow_host_action_events
|
|
672
|
+
BEGIN
|
|
673
|
+
SELECT RAISE(ABORT, 'devflow_host_action_events is append-only');
|
|
674
|
+
END;
|
|
590
675
|
`);
|
|
591
676
|
|
|
592
677
|
// Migration: add session_id to skill_executions (SQLite compat — ignore if exists)
|
|
@@ -3150,6 +3235,244 @@ export class DevFlowDatabase {
|
|
|
3150
3235
|
}));
|
|
3151
3236
|
}
|
|
3152
3237
|
|
|
3238
|
+
// ---- Durable Host Actions ----
|
|
3239
|
+
|
|
3240
|
+
requestHostAction(input: RequestHostActionInput): HostActionRecord {
|
|
3241
|
+
this.validateHostActionIdentity(input);
|
|
3242
|
+
const actionId = input.actionId ?? randomUUID();
|
|
3243
|
+
if (!actionId.trim()) throw new Error('Host action requires an action ID');
|
|
3244
|
+
|
|
3245
|
+
return this.withImmediateTransaction(() => {
|
|
3246
|
+
const now = Date.now();
|
|
3247
|
+
const result = this.db.prepare(`
|
|
3248
|
+
INSERT INTO devflow_host_actions (
|
|
3249
|
+
action_id, run_id, engine_run_id, step_id, project_root, session_id,
|
|
3250
|
+
execution_id, context_receipt, state, report_json, evidence_hash,
|
|
3251
|
+
created_at, updated_at, finished_at
|
|
3252
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'waiting', '{}', NULL, ?, ?, NULL)
|
|
3253
|
+
ON CONFLICT DO NOTHING
|
|
3254
|
+
`).run(
|
|
3255
|
+
actionId,
|
|
3256
|
+
input.runId,
|
|
3257
|
+
input.engineRunId,
|
|
3258
|
+
input.stepId,
|
|
3259
|
+
input.projectRoot,
|
|
3260
|
+
input.sessionId ?? null,
|
|
3261
|
+
input.executionId ?? null,
|
|
3262
|
+
input.contextReceipt ?? null,
|
|
3263
|
+
now,
|
|
3264
|
+
now,
|
|
3265
|
+
);
|
|
3266
|
+
|
|
3267
|
+
const rows = this.db.prepare(`
|
|
3268
|
+
SELECT * FROM devflow_host_actions
|
|
3269
|
+
WHERE action_id = ? OR (run_id = ? AND step_id = ?)
|
|
3270
|
+
`).all(actionId, input.runId, input.stepId) as Array<Record<string, unknown>>;
|
|
3271
|
+
|
|
3272
|
+
if (rows.length !== 1) {
|
|
3273
|
+
throw new Error(`HOST_ACTION_IDENTITY_CONFLICT:${input.runId}:${input.stepId}`);
|
|
3274
|
+
}
|
|
3275
|
+
const record = mapHostActionRow(rows[0]!);
|
|
3276
|
+
const sameIdentity = (input.actionId === undefined || record.actionId === input.actionId)
|
|
3277
|
+
&& record.runId === input.runId
|
|
3278
|
+
&& record.engineRunId === input.engineRunId
|
|
3279
|
+
&& record.stepId === input.stepId
|
|
3280
|
+
&& record.projectRoot === input.projectRoot
|
|
3281
|
+
&& record.sessionId === input.sessionId
|
|
3282
|
+
&& record.executionId === input.executionId
|
|
3283
|
+
&& record.contextReceipt === input.contextReceipt;
|
|
3284
|
+
if (!sameIdentity) {
|
|
3285
|
+
throw new Error(`HOST_ACTION_IDENTITY_CONFLICT:${input.runId}:${input.stepId}`);
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
if (result.changes > 0) this.appendHostActionEvent(record, null, 'waiting', now);
|
|
3289
|
+
return record;
|
|
3290
|
+
});
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
startHostAction(input: StartHostActionInput): HostActionRecord {
|
|
3294
|
+
return this.transitionHostAction(input, 'running', {});
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
reportHostAction(input: ReportHostActionInput): HostActionRecord {
|
|
3298
|
+
return this.transitionHostAction(input, 'reported', input.report, input.evidenceHash);
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
verifyHostAction(input: VerifyHostActionInput): HostActionRecord {
|
|
3302
|
+
if (typeof input.evidenceHash !== 'string' || !input.evidenceHash.trim()) {
|
|
3303
|
+
throw new Error('Host action verification requires a non-empty evidence hash');
|
|
3304
|
+
}
|
|
3305
|
+
return this.transitionHostAction(input, 'verified', input.report, input.evidenceHash, true);
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
failHostAction(input: FailHostActionInput): HostActionRecord {
|
|
3309
|
+
const outcome = input.outcome ?? 'failed';
|
|
3310
|
+
if (outcome !== 'failed' && outcome !== 'cancelled' && outcome !== 'degraded') {
|
|
3311
|
+
throw new Error(`Invalid host action failure outcome: ${String(outcome)}`);
|
|
3312
|
+
}
|
|
3313
|
+
return this.transitionHostAction(
|
|
3314
|
+
input,
|
|
3315
|
+
outcome,
|
|
3316
|
+
input.report,
|
|
3317
|
+
input.evidenceHash,
|
|
3318
|
+
);
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3321
|
+
getHostAction(projectRoot: string, actionId: string): HostActionRecord | null {
|
|
3322
|
+
const row = this.db.prepare(`
|
|
3323
|
+
SELECT * FROM devflow_host_actions
|
|
3324
|
+
WHERE project_root = ? AND action_id = ?
|
|
3325
|
+
`).get(projectRoot, actionId) as Record<string, unknown> | undefined;
|
|
3326
|
+
return row ? mapHostActionRow(row) : null;
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
listHostActionsForRun(projectRoot: string, runId: string): HostActionRecord[] {
|
|
3330
|
+
const rows = this.db.prepare(`
|
|
3331
|
+
SELECT * FROM devflow_host_actions
|
|
3332
|
+
WHERE project_root = ? AND run_id = ?
|
|
3333
|
+
ORDER BY created_at ASC, action_id ASC
|
|
3334
|
+
`).all(projectRoot, runId) as Array<Record<string, unknown>>;
|
|
3335
|
+
return rows.map(mapHostActionRow);
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
private transitionHostAction(
|
|
3339
|
+
identity: StartHostActionInput,
|
|
3340
|
+
targetState: Exclude<HostActionState, 'waiting'>,
|
|
3341
|
+
report: Record<string, unknown>,
|
|
3342
|
+
evidenceHash?: string,
|
|
3343
|
+
requireNonEmptyReport = false,
|
|
3344
|
+
): HostActionRecord {
|
|
3345
|
+
if (!identity.actionId.trim()) throw new Error('Host action requires an action ID');
|
|
3346
|
+
if (!identity.projectRoot.trim()) throw new Error('Host action requires a project root');
|
|
3347
|
+
const reportJson = serializeHostActionReport(report);
|
|
3348
|
+
if (requireNonEmptyReport && reportJson === '{}') {
|
|
3349
|
+
throw new Error('Host action verification requires a non-empty report');
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
return this.withImmediateTransaction(() => {
|
|
3353
|
+
const existing = this.getHostAction(identity.projectRoot, identity.actionId);
|
|
3354
|
+
if (!existing) throw new Error(`HOST_ACTION_NOT_FOUND:${identity.actionId}`);
|
|
3355
|
+
|
|
3356
|
+
if (this.isTerminalHostActionState(existing.state)) {
|
|
3357
|
+
const identical = existing.state === targetState
|
|
3358
|
+
&& existing.evidenceHash === evidenceHash
|
|
3359
|
+
&& hostActionReportsEqual(existing.report, report);
|
|
3360
|
+
if (identical) return existing;
|
|
3361
|
+
throw new Error(`HOST_ACTION_TERMINAL_CONFLICT:${identity.actionId}`);
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
if (!this.isLegalHostActionTransition(existing.state, targetState)) {
|
|
3365
|
+
throw new Error(`HOST_ACTION_INVALID_TRANSITION:${existing.state}->${targetState}`);
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
const now = Date.now();
|
|
3369
|
+
const finishedAt = this.isTerminalHostActionState(targetState) ? now : null;
|
|
3370
|
+
const result = this.db.prepare(`
|
|
3371
|
+
UPDATE devflow_host_actions
|
|
3372
|
+
SET state = ?, report_json = ?, evidence_hash = ?, updated_at = ?, finished_at = ?
|
|
3373
|
+
WHERE project_root = ? AND action_id = ? AND state = ?
|
|
3374
|
+
`).run(
|
|
3375
|
+
targetState,
|
|
3376
|
+
reportJson,
|
|
3377
|
+
evidenceHash ?? null,
|
|
3378
|
+
now,
|
|
3379
|
+
finishedAt,
|
|
3380
|
+
identity.projectRoot,
|
|
3381
|
+
identity.actionId,
|
|
3382
|
+
existing.state,
|
|
3383
|
+
);
|
|
3384
|
+
if (result.changes !== 1) {
|
|
3385
|
+
throw new Error(`HOST_ACTION_UPDATE_CONFLICT:${identity.actionId}`);
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3388
|
+
const updated = this.getHostAction(identity.projectRoot, identity.actionId)!;
|
|
3389
|
+
this.appendHostActionEvent(updated, existing.state, targetState, now);
|
|
3390
|
+
return updated;
|
|
3391
|
+
});
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
private appendHostActionEvent(
|
|
3395
|
+
record: HostActionRecord,
|
|
3396
|
+
fromState: HostActionState | null,
|
|
3397
|
+
toState: HostActionState,
|
|
3398
|
+
createdAt: number,
|
|
3399
|
+
): void {
|
|
3400
|
+
this.db.prepare(`
|
|
3401
|
+
INSERT INTO devflow_host_action_events (
|
|
3402
|
+
event_id, action_id, project_root, run_id, step_id, from_state,
|
|
3403
|
+
to_state, report_json, evidence_hash, created_at
|
|
3404
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
3405
|
+
`).run(
|
|
3406
|
+
randomUUID(),
|
|
3407
|
+
record.actionId,
|
|
3408
|
+
record.projectRoot,
|
|
3409
|
+
record.runId,
|
|
3410
|
+
record.stepId,
|
|
3411
|
+
fromState,
|
|
3412
|
+
toState,
|
|
3413
|
+
serializeHostActionReport(record.report),
|
|
3414
|
+
record.evidenceHash ?? null,
|
|
3415
|
+
createdAt,
|
|
3416
|
+
);
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
private validateHostActionIdentity(input: RequestHostActionInput): void {
|
|
3420
|
+
const fields: Array<[string, string]> = [
|
|
3421
|
+
['run ID', input.runId],
|
|
3422
|
+
['engine run ID', input.engineRunId],
|
|
3423
|
+
['step ID', input.stepId],
|
|
3424
|
+
['project root', input.projectRoot],
|
|
3425
|
+
];
|
|
3426
|
+
for (const [name, value] of fields) {
|
|
3427
|
+
if (!value.trim()) throw new Error(`Host action requires a ${name}`);
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
private withImmediateTransaction<T>(operation: () => T): T {
|
|
3432
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
3433
|
+
try {
|
|
3434
|
+
const result = operation();
|
|
3435
|
+
this.db.exec('COMMIT');
|
|
3436
|
+
return result;
|
|
3437
|
+
} catch (error) {
|
|
3438
|
+
try { this.db.exec('ROLLBACK'); } catch {}
|
|
3439
|
+
throw error;
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
|
|
3443
|
+
private isTerminalHostActionState(state: HostActionState): boolean {
|
|
3444
|
+
return state === 'verified'
|
|
3445
|
+
|| state === 'failed'
|
|
3446
|
+
|| state === 'cancelled'
|
|
3447
|
+
|| state === 'degraded';
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
private isLegalHostActionTransition(
|
|
3451
|
+
fromState: HostActionState,
|
|
3452
|
+
toState: HostActionState,
|
|
3453
|
+
): boolean {
|
|
3454
|
+
if (fromState === 'waiting') {
|
|
3455
|
+
return toState === 'running'
|
|
3456
|
+
|| toState === 'reported'
|
|
3457
|
+
|| toState === 'failed'
|
|
3458
|
+
|| toState === 'cancelled'
|
|
3459
|
+
|| toState === 'degraded';
|
|
3460
|
+
}
|
|
3461
|
+
if (fromState === 'running') {
|
|
3462
|
+
return toState === 'reported'
|
|
3463
|
+
|| toState === 'failed'
|
|
3464
|
+
|| toState === 'cancelled'
|
|
3465
|
+
|| toState === 'degraded';
|
|
3466
|
+
}
|
|
3467
|
+
if (fromState === 'reported') {
|
|
3468
|
+
return toState === 'verified'
|
|
3469
|
+
|| toState === 'failed'
|
|
3470
|
+
|| toState === 'cancelled'
|
|
3471
|
+
|| toState === 'degraded';
|
|
3472
|
+
}
|
|
3473
|
+
return false;
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3153
3476
|
// Convenience methods for raw SQL queries (used by AutoChecker)
|
|
3154
3477
|
all(sql: string, ...params: unknown[]): unknown[] {
|
|
3155
3478
|
return this.db.prepare(sql).all(...params);
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
export type HostActionState =
|
|
2
|
+
| 'waiting'
|
|
3
|
+
| 'running'
|
|
4
|
+
| 'reported'
|
|
5
|
+
| 'verified'
|
|
6
|
+
| 'failed'
|
|
7
|
+
| 'cancelled'
|
|
8
|
+
| 'degraded';
|
|
9
|
+
|
|
10
|
+
export interface HostActionRecord {
|
|
11
|
+
actionId: string;
|
|
12
|
+
runId: string;
|
|
13
|
+
engineRunId: string;
|
|
14
|
+
stepId: string;
|
|
15
|
+
projectRoot: string;
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
executionId?: string;
|
|
18
|
+
contextReceipt?: string;
|
|
19
|
+
state: HostActionState;
|
|
20
|
+
report: Record<string, unknown>;
|
|
21
|
+
evidenceHash?: string;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
updatedAt: number;
|
|
24
|
+
finishedAt?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RequestHostActionInput {
|
|
28
|
+
actionId?: string;
|
|
29
|
+
runId: string;
|
|
30
|
+
engineRunId: string;
|
|
31
|
+
stepId: string;
|
|
32
|
+
projectRoot: string;
|
|
33
|
+
sessionId?: string;
|
|
34
|
+
executionId?: string;
|
|
35
|
+
contextReceipt?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface StartHostActionInput {
|
|
39
|
+
actionId: string;
|
|
40
|
+
projectRoot: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ReportHostActionInput {
|
|
44
|
+
actionId: string;
|
|
45
|
+
projectRoot: string;
|
|
46
|
+
report: Record<string, unknown>;
|
|
47
|
+
evidenceHash?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface VerifyHostActionInput extends ReportHostActionInput {
|
|
51
|
+
evidenceHash: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface FailHostActionInput extends ReportHostActionInput {
|
|
55
|
+
outcome?: 'failed' | 'cancelled' | 'degraded';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const HOST_ACTION_STATES = new Set<HostActionState>([
|
|
59
|
+
'waiting',
|
|
60
|
+
'running',
|
|
61
|
+
'reported',
|
|
62
|
+
'verified',
|
|
63
|
+
'failed',
|
|
64
|
+
'cancelled',
|
|
65
|
+
'degraded',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
export function isHostActionState(value: unknown): value is HostActionState {
|
|
69
|
+
return typeof value === 'string' && HOST_ACTION_STATES.has(value as HostActionState);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function mapHostActionRow(row: Record<string, unknown>): HostActionRecord {
|
|
73
|
+
if (!isHostActionState(row.state)) {
|
|
74
|
+
throw new Error(`Invalid persisted host action state: ${String(row.state)}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
actionId: asRequiredString(row.action_id, 'action_id'),
|
|
79
|
+
runId: asRequiredString(row.run_id, 'run_id'),
|
|
80
|
+
engineRunId: asRequiredString(row.engine_run_id, 'engine_run_id'),
|
|
81
|
+
stepId: asRequiredString(row.step_id, 'step_id'),
|
|
82
|
+
projectRoot: asRequiredString(row.project_root, 'project_root'),
|
|
83
|
+
sessionId: asOptionalString(row.session_id),
|
|
84
|
+
executionId: asOptionalString(row.execution_id),
|
|
85
|
+
contextReceipt: asOptionalString(row.context_receipt),
|
|
86
|
+
state: row.state,
|
|
87
|
+
report: parseReport(row.report_json),
|
|
88
|
+
evidenceHash: asOptionalString(row.evidence_hash),
|
|
89
|
+
createdAt: asFiniteNumber(row.created_at, 'created_at'),
|
|
90
|
+
updatedAt: asFiniteNumber(row.updated_at, 'updated_at'),
|
|
91
|
+
finishedAt: asOptionalFiniteNumber(row.finished_at),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function serializeHostActionReport(report: Record<string, unknown>): string {
|
|
96
|
+
if (!isPlainObject(report)) throw new Error('Host action report must be a plain object');
|
|
97
|
+
return JSON.stringify(canonicalizeJson(report, '$', new WeakSet<object>()));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function hostActionReportsEqual(
|
|
101
|
+
left: Record<string, unknown>,
|
|
102
|
+
right: Record<string, unknown>,
|
|
103
|
+
): boolean {
|
|
104
|
+
return serializeHostActionReport(left) === serializeHostActionReport(right);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseReport(value: unknown): Record<string, unknown> {
|
|
108
|
+
if (typeof value !== 'string') {
|
|
109
|
+
throw new Error('Invalid persisted host action report_json: expected JSON text');
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const parsed: unknown = JSON.parse(value);
|
|
113
|
+
if (!isPlainObject(parsed)) {
|
|
114
|
+
throw new Error('expected a JSON object');
|
|
115
|
+
}
|
|
116
|
+
serializeHostActionReport(parsed);
|
|
117
|
+
return parsed;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
120
|
+
throw new Error(`Invalid persisted host action report_json: ${detail}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function canonicalizeJson(value: unknown, path: string, ancestors: WeakSet<object>): unknown {
|
|
125
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
126
|
+
if (typeof value === 'number') {
|
|
127
|
+
if (!Number.isFinite(value) || Object.is(value, -0)) {
|
|
128
|
+
throw nonJsonValueError(path);
|
|
129
|
+
}
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
if (typeof value !== 'object') throw nonJsonValueError(path);
|
|
133
|
+
if (ancestors.has(value)) throw new Error(`Host action report contains a cycle at ${path}`);
|
|
134
|
+
|
|
135
|
+
ancestors.add(value);
|
|
136
|
+
try {
|
|
137
|
+
if (Array.isArray(value)) return canonicalizeArray(value, path, ancestors);
|
|
138
|
+
if (!isPlainObject(value)) throw nonJsonValueError(path);
|
|
139
|
+
|
|
140
|
+
const result: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
141
|
+
const keys = Reflect.ownKeys(value);
|
|
142
|
+
for (const key of keys) {
|
|
143
|
+
if (typeof key !== 'string') throw nonJsonValueError(`${path}[symbol]`);
|
|
144
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
145
|
+
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
|
146
|
+
throw nonJsonValueError(`${path}.${key}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const key of (keys as string[]).slice().sort(comparePropertyKeys)) {
|
|
150
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
|
|
151
|
+
result[key] = canonicalizeJson(descriptor.value, `${path}.${key}`, ancestors);
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
} finally {
|
|
155
|
+
ancestors.delete(value);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function canonicalizeArray(value: unknown[], path: string, ancestors: WeakSet<object>): unknown[] {
|
|
160
|
+
if (Object.getPrototypeOf(value) !== Array.prototype) throw nonJsonValueError(path);
|
|
161
|
+
const keys = Reflect.ownKeys(value);
|
|
162
|
+
const expectedKeys = new Set(['length', ...Array.from({ length: value.length }, (_, index) => String(index))]);
|
|
163
|
+
if (keys.some(key => typeof key !== 'string' || !expectedKeys.has(key))) {
|
|
164
|
+
throw nonJsonValueError(path);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return Array.from({ length: value.length }, (_, index) => {
|
|
168
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
169
|
+
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
|
170
|
+
throw nonJsonValueError(`${path}[${index}]`);
|
|
171
|
+
}
|
|
172
|
+
return canonicalizeJson(descriptor.value, `${path}[${index}]`, ancestors);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function comparePropertyKeys(left: string, right: string): number {
|
|
177
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function nonJsonValueError(path: string): Error {
|
|
181
|
+
return new Error(`Host action report contains a non-JSON value at ${path}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
185
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
186
|
+
const prototype = Object.getPrototypeOf(value);
|
|
187
|
+
return prototype === Object.prototype || prototype === null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function asRequiredString(value: unknown, column: string): string {
|
|
191
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
192
|
+
throw new Error(`Invalid persisted host action ${column}`);
|
|
193
|
+
}
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function asOptionalString(value: unknown): string | undefined {
|
|
198
|
+
return typeof value === 'string' ? value : undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function asFiniteNumber(value: unknown, column: string): number {
|
|
202
|
+
const number = Number(value);
|
|
203
|
+
if (!Number.isFinite(number)) throw new Error(`Invalid persisted host action ${column}`);
|
|
204
|
+
return number;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function asOptionalFiniteNumber(value: unknown): number | undefined {
|
|
208
|
+
if (value === null || value === undefined) return undefined;
|
|
209
|
+
const number = Number(value);
|
|
210
|
+
return Number.isFinite(number) ? number : undefined;
|
|
211
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,21 @@ export type {
|
|
|
30
30
|
WorkQueueHealth,
|
|
31
31
|
WorkState,
|
|
32
32
|
} from './work-queue';
|
|
33
|
+
export {
|
|
34
|
+
hostActionReportsEqual,
|
|
35
|
+
isHostActionState,
|
|
36
|
+
mapHostActionRow,
|
|
37
|
+
serializeHostActionReport,
|
|
38
|
+
} from './host-actions';
|
|
39
|
+
export type {
|
|
40
|
+
FailHostActionInput,
|
|
41
|
+
HostActionRecord,
|
|
42
|
+
HostActionState,
|
|
43
|
+
ReportHostActionInput,
|
|
44
|
+
RequestHostActionInput,
|
|
45
|
+
StartHostActionInput,
|
|
46
|
+
VerifyHostActionInput,
|
|
47
|
+
} from './host-actions';
|
|
33
48
|
export {
|
|
34
49
|
mapSessionObligationRow,
|
|
35
50
|
normalizeTurnId,
|