Skip to content

Commit 7303c3b

Browse files
committed
fix(storage): decode stored Automations instead of trusting the record
Scheduled tasks are read back with a bare `JSON.parse(...) as ScheduledTask`, so a record written before a permission mode was retired carried that value straight into `compilePermissionProfile`, which no longer has a branch for it. `normalizeCreateScheduledTaskInput` could not catch this: it validates new input and stored records never pass through it. Add `decodePersistedScheduledTask` next to the type it decodes and call it on the store's read path. It folds retired representations to their live equivalents and leaves everything else as stored — it is a compatibility fold, not a schema validator. Refs #3385 Generated-by: Claude Code
1 parent dca6faa commit 7303c3b

3 files changed

Lines changed: 84 additions & 2 deletions

File tree

packages/core/src/__tests__/scheduled-task.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
22
import { describe, it } from 'node:test';
33
import {
44
computeNextFireAt,
5+
decodePersistedScheduledTask,
56
isScheduledTaskDue,
67
nextScheduledTaskStateAfterFire,
78
normalizeCreateScheduledTaskInput,
@@ -200,3 +201,55 @@ describe('scheduled-task catalog', () => {
200201
}
201202
});
202203
});
204+
205+
describe('decodePersistedScheduledTask', () => {
206+
const base: ScheduledTask = {
207+
id: 't1',
208+
title: 'Nightly',
209+
intent: { kind: 'text', body: 'run it' },
210+
schedule: { kind: 'once', runAt: 1000 },
211+
effect: {
212+
kind: 'agent_run',
213+
execution: {
214+
cwd: '/repo',
215+
backend: 'fake',
216+
llmConnectionSlug: 'anthropic',
217+
model: 'claude',
218+
permissionMode: 'ask',
219+
collaborationMode: 'agent',
220+
orchestrationMode: 'default',
221+
},
222+
},
223+
status: 'active',
224+
nextFireAt: 1000,
225+
lastFireAt: null,
226+
fireCount: 0,
227+
maxFires: null,
228+
expiresAt: null,
229+
createdBy: { kind: 'user' },
230+
createdAt: 0,
231+
updatedAt: 0,
232+
runs: [],
233+
lastError: null,
234+
};
235+
236+
it('folds a retired permission mode to its live equivalent', () => {
237+
const stored = JSON.parse(
238+
JSON.stringify(base).replace('"permissionMode":"ask"', '"permissionMode":"execute"'),
239+
) as ScheduledTask;
240+
const decoded = decodePersistedScheduledTask(stored);
241+
assert.equal(
242+
decoded.effect.kind === 'agent_run' ? decoded.effect.execution.permissionMode : undefined,
243+
'ask',
244+
);
245+
});
246+
247+
it('returns the same task when nothing needs folding', () => {
248+
assert.equal(decodePersistedScheduledTask(base), base);
249+
});
250+
251+
it('leaves effects without an execution template alone', () => {
252+
const notify: ScheduledTask = { ...base, effect: { kind: 'notify', channel: 'local' } };
253+
assert.equal(decodePersistedScheduledTask(notify), notify);
254+
});
255+
});

packages/core/src/scheduled-task.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { compileCronExpression } from './cron-expression.js';
99
import { isCollaborationMode, type CollaborationMode } from './collaboration.js';
1010
import { isOrchestrationMode, type OrchestrationMode } from './orchestration.js';
1111
import { isThinkingLevel, type ThinkingLevel } from './model-thinking.js';
12-
import { isPermissionMode, type PermissionMode } from './permission.js';
12+
import {
13+
decodePersistedPermissionMode,
14+
isPermissionMode,
15+
type PermissionMode,
16+
} from './permission.js';
1317
import { isBotDeliveryProvider, type BotProvider } from './bot-chat-settings.js';
1418
import type { PersistedBackendKind } from './session.js';
1519

@@ -644,3 +648,27 @@ function addMonthsClamped(anchor: Date, base: Date, offset: number): number {
644648
function fail(message: string): { ok: false; message: string } {
645649
return { ok: false, message };
646650
}
651+
652+
/**
653+
* Fold retired representations in a stored ScheduledTask to their live
654+
* equivalents.
655+
*
656+
* Stored tasks are read back with `JSON.parse` and never pass through
657+
* `normalizeCreateScheduledTaskInput`, which validates *new* input and is
658+
* deliberately strict. Without this fold a task written before a value was
659+
* retired would carry that value straight into execution, where nothing
660+
* recognizes it any more. This is not a schema validator: a record that is
661+
* malformed in any other way stays as stored.
662+
*/
663+
export function decodePersistedScheduledTask(task: ScheduledTask): ScheduledTask {
664+
const { effect } = task;
665+
if (effect.kind !== 'agent_run') return task;
666+
const permissionMode = decodePersistedPermissionMode(effect.execution.permissionMode);
667+
if (permissionMode === undefined || permissionMode === effect.execution.permissionMode) {
668+
return task;
669+
}
670+
return {
671+
...task,
672+
effect: { ...effect, execution: { ...effect.execution, permissionMode } },
673+
};
674+
}

packages/storage/src/scheduled-task-store.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { resolve } from 'node:path';
33
import {
44
compareScheduledTasksForList,
55
computeNextFireAt,
6+
decodePersistedScheduledTask,
67
isScheduledTaskDue,
78
nextScheduledTaskStateAfterFire,
89
normalizeCreateScheduledTaskInput,
@@ -577,7 +578,7 @@ class SqliteScheduledTaskStore implements ScheduledTaskStore {
577578
if (typeof row.record_json !== 'string') {
578579
throw new Error(`Invalid scheduled task at row ${index + 1}`);
579580
}
580-
return JSON.parse(row.record_json) as ScheduledTask;
581+
return decodePersistedScheduledTask(JSON.parse(row.record_json) as ScheduledTask);
581582
});
582583
const claimRows = this.#lease.database
583584
.prepare(`

0 commit comments

Comments
 (0)