blob: aa612b2db5a9b0267aa43fc5a55b823a3ccd9673 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { after, describe, test } from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import {
createOperationalStateBackup,
restoreOperationalStateBackup,
} from '../operational-state-backup.js';
import { openInteractiveScheduledTaskStoreForWrite } from '../scheduled-task-store.js';
import { createSqlitePlanStore } from '../plan-store.js';
import { createSqliteSessionTodoStore } from '../session-todo-store.js';
import { SQLITE_WORKFLOW_SCHEMA_VERSION } from '../sqlite-workflow-schema.js';
import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js';
import {
removeTrackedControlDirectories,
trackControlDirectory,
} from './fixtures/control-directory-hygiene.js';
// The control directory of each resolved root lives outside that root, so a
// temporary root's removal leaves it behind; reclaim the recorded rootIds here.
after(removeTrackedControlDirectories);
const SESSION_ID = 'session-workflow';
describe('SQLite workflow stores', () => {
test('persists Plan exclusively through events', async () => {
await withRoot(async (root) => {
const store = createSqlitePlanStore(root, {
newId: (() => {
let id = 0;
return () => `plan-${++id}`;
})(),
now: () => 100,
});
const submitted = await store.submitProposal({
sessionId: SESSION_ID,
turnId: 'turn-1',
title: 'SQLite plan',
steps: [{ id: 'one', title: 'Persist state', description: 'Write one transaction' }],
});
store.close();
const reopened = createSqlitePlanStore(root);
try {
assert.equal(
(await reopened.readState(SESSION_ID)).latestProposalId,
submitted.state.latestProposalId,
);
} finally {
reopened.close();
}
const database = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true });
try {
assert.equal(rowCount(database, 'workflow_plan_events'), 1);
assert.equal(tableExists(database, 'workflow_plan_projections'), false);
} finally {
database.close();
}
});
});
test('migrates released workflow schema 9 projections to the current workflow schema', async () => {
await withRoot(async (root) => {
const planStore = createSqlitePlanStore(root, { newId: () => 'proposal-1', now: () => 100 });
const submitted = await planStore.submitProposal({
sessionId: SESSION_ID,
turnId: 'turn-1',
title: 'Preserve Plan events',
steps: [{ id: 'one', title: 'Replay', description: 'Ignore stale projection bytes' }],
});
planStore.close();
const released = new DatabaseSync(join(root, 'runtime.sqlite'));
try {
installReleasedProjectionTables(released);
released
.prepare(
'INSERT INTO workflow_task_ledger_projections(session_id, record_json) VALUES (?, ?)',
)
.run(SESSION_ID, '{not-json');
released
.prepare(
'INSERT INTO workflow_plan_projections(session_id, store_version, record_json) VALUES (?, ?, ?)',
)
.run(SESSION_ID, 999, '{not-json');
setWorkflowSchemaVersion(released, 9);
} finally {
released.close();
}
const migratedPlan = createSqlitePlanStore(root);
try {
assert.equal(
(await migratedPlan.readState(SESSION_ID)).latestProposalId,
submitted.state.latestProposalId,
);
} finally {
migratedPlan.close();
}
const verified = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true });
try {
assert.equal(workflowSchemaVersion(verified), SQLITE_WORKFLOW_SCHEMA_VERSION);
assert.equal(tableExists(verified, 'workflow_task_ledger_projections'), false);
assert.equal(tableExists(verified, 'workflow_plan_projections'), false);
assert.equal(rowCount(verified, 'workflow_plan_events'), 1);
} finally {
verified.close();
}
});
});
test('restores SessionTodo storage and drops Task Ledger events from workflow schema 10', async () => {
await withRoot(async (root) => {
createSqliteSessionTodoStore(root).close();
// Recreate the schema-10 shape: SessionTodo storage did not exist yet and
// Task Ledger events did, so the migration has to add one and drop the other.
const released = new DatabaseSync(join(root, 'runtime.sqlite'));
try {
released.exec(`
CREATE TABLE workflow_task_ledger_events (
session_id TEXT NOT NULL,
sequence INTEGER NOT NULL CHECK (sequence >= 0),
event_id TEXT NOT NULL,
record_json TEXT NOT NULL,
PRIMARY KEY (session_id, sequence),
UNIQUE (session_id, event_id)
);
`);
released
.prepare(`
INSERT INTO workflow_task_ledger_events(session_id, sequence, event_id, record_json)
VALUES (?, 0, 'retired-event', '{}')
`)
.run(SESSION_ID);
released.exec('DROP TABLE workflow_session_todo_documents');
setWorkflowSchemaVersion(released, 10);
} finally {
released.close();
}
const migrated = createSqliteSessionTodoStore(root);
try {
assert.deepEqual(await migrated.readOrBootstrap(SESSION_ID), { items: [] });
} finally {
migrated.close();
}
const verified = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true });
try {
assert.equal(workflowSchemaVersion(verified), SQLITE_WORKFLOW_SCHEMA_VERSION);
assert.equal(tableExists(verified, 'workflow_task_ledger_events'), false);
assert.equal(rowCount(verified, 'workflow_session_todo_documents'), 1);
} finally {
verified.close();
}
});
});
test('preserves every released projection when one table has unfamiliar DDL', async () => {
await withRoot(async (root) => {
createSqlitePlanStore(root).close();
const released = new DatabaseSync(join(root, 'runtime.sqlite'));
try {
installReleasedProjectionTables(released, { planVersionFloor: -1 });
released
.prepare(
'INSERT INTO workflow_task_ledger_projections(session_id, record_json) VALUES (?, ?)',
)
.run(SESSION_ID, 'task-sentinel');
released
.prepare(
'INSERT INTO workflow_plan_projections(session_id, store_version, record_json) VALUES (?, ?, ?)',
)
.run(SESSION_ID, 0, 'plan-sentinel');
setWorkflowSchemaVersion(released, 9);
} finally {
released.close();
}
assert.throws(
() => createSqlitePlanStore(root),
(error: unknown) =>
error instanceof Error &&
(error as { code?: unknown }).code === 'operational_state_migration_blocked' &&
/unfamiliar released shape/u.test(error.message),
);
assertReleasedProjectionStatePreserved(root);
});
});
test('preserves every released projection when one table carries an extra trigger', async () => {
await withRoot(async (root) => {
createSqlitePlanStore(root).close();
const released = new DatabaseSync(join(root, 'runtime.sqlite'));
try {
installReleasedProjectionTables(released);
released.exec(`
CREATE TRIGGER workflow_task_ledger_projection_guard
AFTER INSERT ON workflow_task_ledger_projections
BEGIN
SELECT 1;
END;
`);
released
.prepare(
'INSERT INTO workflow_task_ledger_projections(session_id, record_json) VALUES (?, ?)',
)
.run(SESSION_ID, 'task-sentinel');
released
.prepare(
'INSERT INTO workflow_plan_projections(session_id, store_version, record_json) VALUES (?, ?, ?)',
)
.run(SESSION_ID, 0, 'plan-sentinel');
setWorkflowSchemaVersion(released, 9);
} finally {
released.close();
}
assert.throws(
() => createSqlitePlanStore(root),
(error: unknown) =>
error instanceof Error &&
(error as { code?: unknown }).code === 'operational_state_migration_blocked' &&
/unexpected object/u.test(error.message),
);
assertReleasedProjectionStatePreserved(root);
});
});
test('preserves an unfamiliar projection whose table name differs only by case', async () => {
await withRoot(async (root) => {
createSqlitePlanStore(root).close();
const released = new DatabaseSync(join(root, 'runtime.sqlite'));
try {
installReleasedProjectionTables(released, {
taskTableName: 'WORKFLOW_TASK_LEDGER_PROJECTIONS',
});
released
.prepare(
'INSERT INTO WORKFLOW_TASK_LEDGER_PROJECTIONS(session_id, record_json) VALUES (?, ?)',
)
.run(SESSION_ID, 'task-sentinel');
released
.prepare(
'INSERT INTO workflow_plan_projections(session_id, store_version, record_json) VALUES (?, ?, ?)',
)
.run(SESSION_ID, 0, 'plan-sentinel');
setWorkflowSchemaVersion(released, 9);
} finally {
released.close();
}
assert.throws(
() => createSqlitePlanStore(root),
(error: unknown) =>
error instanceof Error &&
(error as { code?: unknown }).code === 'operational_state_migration_blocked' &&
/unfamiliar released shape/u.test(error.message),
);
assertReleasedProjectionStatePreserved(root);
});
});
test('preserves released projections with a sqliteX-prefixed trigger', async () => {
await withRoot(async (root) => {
createSqlitePlanStore(root).close();
const released = new DatabaseSync(join(root, 'runtime.sqlite'));
try {
installReleasedProjectionTables(released);
released.exec(`
CREATE TRIGGER sqliteX_projection_guard
AFTER INSERT ON workflow_task_ledger_projections
BEGIN
SELECT 1;
END;
`);
released
.prepare(
'INSERT INTO workflow_task_ledger_projections(session_id, record_json) VALUES (?, ?)',
)
.run(SESSION_ID, 'task-sentinel');
released
.prepare(
'INSERT INTO workflow_plan_projections(session_id, store_version, record_json) VALUES (?, ?, ?)',
)
.run(SESSION_ID, 0, 'plan-sentinel');
setWorkflowSchemaVersion(released, 9);
} finally {
released.close();
}
assert.throws(
() => createSqlitePlanStore(root),
(error: unknown) =>
error instanceof Error &&
(error as { code?: unknown }).code === 'operational_state_migration_blocked' &&
/unexpected object/u.test(error.message),
);
assertReleasedProjectionStatePreserved(root);
});
});
test('preserves released projections with a sqliteX-prefixed dependent view', async () => {
await withRoot(async (root) => {
createSqlitePlanStore(root).close();
const released = new DatabaseSync(join(root, 'runtime.sqlite'));
try {
installReleasedProjectionTables(released);
released.exec(`
CREATE VIEW sqliteX_projection_guard AS
SELECT session_id, record_json
FROM workflow_task_ledger_projections;
`);
released
.prepare(
'INSERT INTO workflow_task_ledger_projections(session_id, record_json) VALUES (?, ?)',
)
.run(SESSION_ID, 'task-sentinel');
released
.prepare(
'INSERT INTO workflow_plan_projections(session_id, store_version, record_json) VALUES (?, ?, ?)',
)
.run(SESSION_ID, 0, 'plan-sentinel');
setWorkflowSchemaVersion(released, 9);
} finally {
released.close();
}
assert.throws(
() => createSqlitePlanStore(root),
(error: unknown) =>
error instanceof Error &&
(error as { code?: unknown }).code === 'operational_state_migration_blocked' &&
/unexpected schema object view:sqliteX_projection_guard/u.test(error.message),
);
assertReleasedProjectionStatePreserved(root);
const preserved = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true });
try {
assert.equal(tableExists(preserved, 'sqliteX_projection_guard', 'view'), true);
} finally {
preserved.close();
}
});
});
test('an older workflow reader rejects the newer schema without changing it', async () => {
await withRoot(async (root) => {
const store = createSqlitePlanStore(root, { newId: () => 'proposal-1', now: () => 100 });
await store.submitProposal({
sessionId: SESSION_ID,
turnId: 'turn-1',
title: 'Preserve newer workflow state',
steps: [{ id: 'one', title: 'Persist', description: 'Write one event' }],
});
store.close();
const newer = new DatabaseSync(join(root, 'runtime.sqlite'));
try {
setWorkflowSchemaVersion(newer, SQLITE_WORKFLOW_SCHEMA_VERSION + 1);
newer.exec(`
CREATE TABLE workflow_future_sentinel (value TEXT NOT NULL);
INSERT INTO workflow_future_sentinel(value) VALUES ('preserved');
`);
} finally {
newer.close();
}
assert.throws(
() => createSqlitePlanStore(root),
/Operational schema workflow is newer than supported/u,
);
const preserved = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true });
try {
assert.equal(workflowSchemaVersion(preserved), SQLITE_WORKFLOW_SCHEMA_VERSION + 1);
assert.equal(rowCount(preserved, 'workflow_plan_events'), 1);
assert.equal(
(
preserved.prepare('SELECT value FROM workflow_future_sentinel').get() as {
value?: unknown;
}
).value,
'preserved',
);
} finally {
preserved.close();
}
});
});
test('backs up and restores Plan and initialized SessionTodo state', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-workflow-backup-'));
const stateRoot = join(base, 'state');
const backupRoot = join(base, 'backup');
const restoreRoot = join(base, 'restore');
await mkdir(stateRoot);
try {
const planStore = createSqlitePlanStore(stateRoot, {
newId: () => 'backup-proposal',
now: () => 100,
});
const submitted = await planStore.submitProposal({
sessionId: SESSION_ID,
turnId: 'turn-backup',
title: 'Restore Plan event',
steps: [{ id: 'restore', title: 'Restore', description: 'Replay the event ledger' }],
});
planStore.close();
const todoStore = createSqliteSessionTodoStore(stateRoot);
await todoStore.replaceAll('todo-non-empty', [
{ content: 'Restore current Todo', status: 'in_progress' },
]);
await todoStore.replaceAll('todo-empty', []);
todoStore.close();
await createOperationalStateBackup({ stateRoot, destinationRoot: backupRoot, now: () => 10 });
await restoreOperationalStateBackup({ backupRoot, destinationRoot: restoreRoot });
const restoredPlan = createSqlitePlanStore(restoreRoot);
try {
assert.equal(
(await restoredPlan.readState(SESSION_ID)).latestProposalId,
submitted.state.latestProposalId,
);
} finally {
restoredPlan.close();
}
const restoredTodos = createSqliteSessionTodoStore(restoreRoot);
try {
assert.deepEqual(await restoredTodos.readOrBootstrap('todo-non-empty'), {
items: [{ content: 'Restore current Todo', status: 'in_progress' }],
});
assert.deepEqual(await restoredTodos.readOrBootstrap('todo-empty'), { items: [] });
} finally {
restoredTodos.close();
}
const restored = new DatabaseSync(join(restoreRoot, 'runtime.sqlite'), { readOnly: true });
try {
assert.equal(tableExists(restored, 'workflow_task_ledger_projections'), false);
assert.equal(tableExists(restored, 'workflow_plan_projections'), false);
assert.equal(rowCount(restored, 'workflow_plan_events'), 1);
assert.equal(rowCount(restored, 'workflow_session_todo_documents'), 2);
} finally {
restored.close();
}
} finally {
await rm(base, { recursive: true, force: true });
}
});
test('reconciles exact Plan retries through durable operation identity', async () => {
await withRoot(async (root) => {
const store = createSqlitePlanStore(root, {
newId: (() => {
let id = 0;
return () => `generated-${++id}`;
})(),
now: () => 100,
});
try {
const input = {
operationId: 'submit-operation',
sessionId: SESSION_ID,
turnId: 'turn-1',
title: 'Stable plan',
steps: [{ id: 'one', title: 'Persist once', description: 'Commit one event' }],
};
const submitted = await store.submitProposal(input);
await store.requestRevision({
operationId: 'revision-operation',
sessionId: SESSION_ID,
proposalId:
submitted.event.type === 'plan_submitted' ? submitted.event.proposal.proposalId : '',
});
const retried = await store.submitProposal(input);
assert.equal(retried.event.id, 'submit-operation');
assert.equal(retried.state.storeVersion, 1);
assert.equal(
(await store.readOperationReceipt(SESSION_ID, input.operationId, input))?.storeVersion,
1,
);
await assert.rejects(
store.submitProposal({ ...input, title: 'Reused identity' }),
/identity was reused/,
);
await assert.rejects(
store.readOperationReceipt(SESSION_ID, input.operationId, {
...input,
title: 'Reused identity',
}),
/identity was reused/,
);
assert.equal((await store.readState(SESSION_ID)).storeVersion, 2);
} finally {
store.close();
}
});
});
test('rejects Plan data that the Runtime Host projection cannot represent', async () => {
await withRoot(async (root) => {
const store = createSqlitePlanStore(root);
try {
await assert.rejects(
store.submitProposal({
sessionId: SESSION_ID,
turnId: 'turn-1',
title: 'Invalid identifiers',
steps: [{ id: 'step one', title: 'Reject input', description: 'Invalid id' }],
}),
/canonical entity id/,
);
await assert.rejects(
store.submitProposal({
sessionId: SESSION_ID,
turnId: 'turn-2',
title: 'Oversized text',
steps: [
{
id: 'step-1',
title: 'Reject input',
description: 'x'.repeat(16 * 1024 + 1),
},
],
}),
/text limit/,
);
await assert.rejects(
store.submitProposal({
sessionId: SESSION_ID,
turnId: 'turn-3',
title: 'Oversized projection',
steps: Array.from({ length: 16 }, (_, index) => ({
id: `step-${index}`,
title: `Step ${index}`,
description: 'x'.repeat(4_000),
})),
}),
/projection item limit/,
);
assert.equal((await store.readState(SESSION_ID)).storeVersion, 0);
} finally {
store.close();
}
});
});
test('reserves enough projection space for the complete Plan lifecycle', async () => {
await withRoot(async (root) => {
const store = createSqlitePlanStore(root);
try {
const submitted = await store.submitProposal({
operationId: 'submit-lifecycle',
sessionId: SESSION_ID,
turnId: 'turn-lifecycle',
title: 'Lifecycle-safe plan',
steps: Array.from({ length: 50 }, (_, index) => ({
id: `step-${index}`,
title: `Step ${index}`,
description: 'x'.repeat(900),
})),
});
assert.equal(submitted.event.type, 'plan_submitted');
if (submitted.event.type !== 'plan_submitted') return;
const approval = {
sessionId: SESSION_ID,
proposalId: submitted.event.proposal.proposalId,
expectedRevision: submitted.event.proposal.revision,
expectedStoreVersion: submitted.state.storeVersion,
};
const approved = await store.approveProposal({
...approval,
operationId: 'approve-lifecycle',
});
await assert.rejects(
store.approveProposal({ ...approval, operationId: 'approve-again' }),
/already approved by another operation/,
);
assert.equal(approved.event.type, 'plan_approved');
if (approved.event.type !== 'plan_approved') return;
await store.interruptActiveExecution(SESSION_ID, 'i'.repeat(1024), 'interrupt-lifecycle');
const cancelled = await store.cancelExecution({
operationId: 'cancel-lifecycle',
sessionId: SESSION_ID,
executionId: approved.event.execution.executionId,
reason: 'c'.repeat(1024),
});
assert.equal(cancelled.state.storeVersion, 4);
assert.equal(cancelled.state.executions[0]?.status, 'cancelled');
} finally {
store.close();
}
});
});
test('rejects proposals whose later lifecycle projection would overflow', async () => {
await withRoot(async (root) => {
const store = createSqlitePlanStore(root);
try {
await assert.rejects(
store.submitProposal({
sessionId: SESSION_ID,
turnId: 'turn-lifecycle-overflow',
title: 'Lifecycle overflow',
steps: Array.from({ length: 50 }, (_, index) => ({
id: `step-${index}`,
title: `Step ${index}`,
description: 'x'.repeat(1_100),
})),
}),
/projection item limit/,
);
} finally {
store.close();
}
});
});
test('purges Plan events for retired Sessions', async () => {
await withRoot(async (root) => {
const store = createSqlitePlanStore(root);
try {
await store.submitProposal({
sessionId: SESSION_ID,
turnId: 'turn-1',
title: 'Disposable plan',
steps: [{ id: 'one', title: 'Remove state', description: 'Purge the ledger' }],
});
await store.purgeSessionState(SESSION_ID);
assert.deepEqual(await store.readState(SESSION_ID), {
schemaVersion: 1,
sessionId: SESSION_ID,
storeVersion: 0,
proposals: [],
executions: [],
});
} finally {
store.close();
}
});
});
test('persists Scheduled Tasks and admits each fire once', async () => {
await withRoot(async (root) => {
const now = Date.now();
const { owner, open } = await scheduledTaskStoreRoot(root);
const store = await open();
const task = await store.create(
{
title: 'Review SQLite',
intentBody: '',
schedule: { kind: 'once', runAt: now + 1_000 },
effect: { kind: 'notify', channel: 'local' },
createdBy: { kind: 'user' },
},
now,
);
const claims = await Promise.all([
store.claimNextDue(now + 1_000),
store.claimNextDue(now + 1_000),
]);
const claim = claims.map((entry) => entry.claim).find((entry) => entry !== null);
assert.ok(claim);
assert.equal(claims.filter((entry) => entry.claim !== null).length, 1);
assert.equal((await store.claimNextDue(now + 1_000)).claim, null);
await store.settleFire(claim.id, {
at: now + 1_000,
outcome: 'ok',
message: 'done',
});
store.close();
const reopened = await open();
try {
const persisted = (await reopened.list())[0];
assert.equal(persisted?.id, task.id);
assert.equal(persisted?.status, 'completed');
assert.equal(persisted?.fireCount, 1);
} finally {
reopened.close();
await owner.close();
}
});
});
test('persists the exact ScheduledTask Agent execution identity before admission', async () => {
await withRoot(async (root) => {
const now = Date.now();
const { owner, open } = await scheduledTaskStoreRoot(root);
const store = await open();
const task = await store.create(
{
title: 'Durable Agent fire',
intentBody: 'Continue the release',
schedule: { kind: 'once', runAt: now + 1_000 },
effect: {
kind: 'agent_run',
execution: {
cwd: '/workspace',
backend: 'ai-sdk',
llmConnectionId: 'connection-default',
llmConnectionSlug: 'default',
model: 'test-model',
permissionMode: 'ask',
collaborationMode: 'agent',
orchestrationMode: 'default',
},
},
createdBy: { kind: 'user' },
},
now,
);
assert.equal(
task.effect.kind === 'agent_run' ? task.effect.execution.llmConnectionId : undefined,
'connection-default',
);
const claim = await store.claimNow(task.id, now);
await store.bindFireExecution(claim.id, {
sessionId: 'session-1',
turnId: 'turn-1',
runId: 'run-1',
userMessageId: 'message-1',
});
store.close();
const reopened = await open();
try {
assert.deepEqual((await reopened.listPendingFires())[0]?.execution, {
sessionId: 'session-1',
turnId: 'turn-1',
runId: 'run-1',
userMessageId: 'message-1',
});
} finally {
reopened.close();
await owner.close();
}
});
});
test('folds retired permission modes in tasks and pending fire claims', async () => {
await withRoot(async (root) => {
const now = Date.now();
const { owner, open } = await scheduledTaskStoreRoot(root);
const store = await open();
await assert.rejects(
() =>
store.create(
{
title: 'Reject retired input',
intentBody: 'run',
schedule: { kind: 'once', runAt: now + 1_000 },
effect: {
kind: 'agent_run',
execution: {
cwd: '/workspace',
llmConnectionId: 'connection-default',
llmConnectionSlug: 'default',
model: 'test-model',
permissionMode: 'execute',
collaborationMode: 'agent',
orchestrationMode: 'default',
},
},
createdBy: { kind: 'user' },
},
now,
),
/execution.permissionMode is required/,
);
const task = await store.create(
{
title: 'Decode retired rows',
intentBody: 'run',
schedule: { kind: 'once', runAt: now + 1_000 },
effect: {
kind: 'agent_run',
execution: {
cwd: '/workspace',
llmConnectionId: 'connection-default',
llmConnectionSlug: 'default',
model: 'test-model',
permissionMode: 'ask',
collaborationMode: 'agent',
orchestrationMode: 'default',
},
},
createdBy: { kind: 'user' },
},
now,
);
await store.claimNow(task.id, now);
store.close();
const database = new DatabaseSync(join(root, 'runtime.sqlite'));
try {
database.exec(`
UPDATE workflow_scheduled_tasks
SET record_json = json_set(record_json, '$.effect.execution.permissionMode', 'execute');
UPDATE workflow_scheduled_task_fires
SET record_json = json_set(record_json, '$.task.effect.execution.permissionMode', 'execute');
`);
} finally {
database.close();
}
const reopened = await open();
try {
const decodedTask = (await reopened.list())[0];
const decodedClaim = (await reopened.listPendingFires())[0];
assert.equal(
decodedTask?.effect.kind === 'agent_run'
? decodedTask.effect.execution.permissionMode
: undefined,
'ask',
);
assert.equal(
decodedClaim?.task.effect.kind === 'agent_run'
? decodedClaim.task.effect.execution.permissionMode
: undefined,
'ask',
);
} finally {
reopened.close();
await owner.close();
}
});
});
test('does not lower maxFires below the task fire count', async () => {
await withRoot(async (root) => {
const now = Date.now();
const { owner, open } = await scheduledTaskStoreRoot(root);
const store = await open();
try {
const task = await store.create(
{
title: 'Bounded recurrence',
intentBody: '',
schedule: { kind: 'interval', everySeconds: 60, startAt: now + 1_000 },
effect: { kind: 'notify', channel: 'local' },
createdBy: { kind: 'user' },
},
now,
);
const claim = await store.claimNow(task.id, now);
await store.settleFire(claim.id, { at: now, outcome: 'ok', message: 'done' });
await assert.rejects(
() => store.update(task.id, { maxFires: 1 }, now + 1),
/maxFires must be greater than the current fireCount/,
);
} finally {
store.close();
await owner.close();
}
});
});
});
async function scheduledTaskStoreRoot(root: string) {
const capability = trackControlDirectory(
await resolveStorageRoot({ path: root, kind: 'interactive' }),
);
const owner = await tryAcquireInteractiveRootOwner(capability);
assert.ok(owner);
if (!owner) throw new Error('Unable to acquire the ScheduledTask test root');
return {
owner,
open: () => openInteractiveScheduledTaskStoreForWrite(owner.lease),
};
}
async function withRoot(run: (root: string) => Promise<void>): Promise<void> {
const root = await mkdtemp(join(tmpdir(), 'maka-sqlite-workflow-'));
try {
await run(root);
} finally {
await rm(root, { recursive: true, force: true });
}
}
function tableExists(database: DatabaseSync, name: string, type = 'table'): boolean {
return (
database
.prepare('SELECT 1 AS present FROM sqlite_schema WHERE type = ? AND name = ?')
.get(type, name) !== undefined
);
}
function rowCount(database: DatabaseSync, table: string): number {
const row = database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as {
count?: unknown;
};
assert.equal(typeof row.count, 'number');
return row.count as number;
}
function installReleasedProjectionTables(
database: DatabaseSync,
options: { planVersionFloor?: number; taskTableName?: string } = {},
): void {
const taskTableName = options.taskTableName ?? 'workflow_task_ledger_projections';
database.exec(`
CREATE TABLE ${taskTableName} (
session_id TEXT PRIMARY KEY,
record_json TEXT NOT NULL
);
CREATE TABLE workflow_plan_projections (
session_id TEXT PRIMARY KEY,
store_version INTEGER NOT NULL CHECK (store_version >= ${options.planVersionFloor ?? 0}),
record_json TEXT NOT NULL
);
`);
}
function setWorkflowSchemaVersion(database: DatabaseSync, version: number): void {
database
.prepare("UPDATE operational_schema_migrations SET version = ? WHERE scope = 'workflow'")
.run(version);
}
function workflowSchemaVersion(database: DatabaseSync): number {
const row = database
.prepare("SELECT version FROM operational_schema_migrations WHERE scope = 'workflow'")
.get() as { version?: unknown } | undefined;
const version = row?.version;
assert.equal(typeof version, 'number');
return version as number;
}
function assertReleasedProjectionStatePreserved(root: string): void {
const preserved = new DatabaseSync(join(root, 'runtime.sqlite'), { readOnly: true });
try {
assert.equal(workflowSchemaVersion(preserved), 9);
assert.equal(rowCount(preserved, 'workflow_task_ledger_projections'), 1);
assert.equal(rowCount(preserved, 'workflow_plan_projections'), 1);
} finally {
preserved.close();
}
}