feat(ai): Mission datamodel — long-lived autonomous work items

Foundational entity for the AI Workbench Runner. A Mission carries the
user's standing instruction (concept + objective), references to the
modules it should draw context from, a cadence, and an append-only
history of iterations. Each iteration records the plan the AI generated
for that run plus the resulting proposal statuses and user feedback.

- `data/ai/missions/types.ts` — Mission, PlanStep, MissionIteration,
  MissionCadence union (manual / interval / daily / weekly / cron)
- `data/ai/missions/cadence.ts` — pure `nextRunForCadence(cadence, from)`
  used by the store on create / update / finishIteration
- `data/ai/missions/store.ts` — CRUD + lifecycle
  (pause / resume / complete / archive / delete) + iteration helpers
  (start / finish / addFeedback)
- `data/ai/module.config.ts` — new `ai` sync app; Missions sync
  cross-device (unlike the local-only `pendingProposals`)
- `db.version(18)` adds the `aiMissions` Dexie store with indexes on
  state, createdAt, nextRunAt, and [state+nextRunAt] for the Runner's
  "due now" query

Iterations live inline on the Mission record — append-only, small N,
always loaded together by the Planner. No separate child table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 21:10:19 +02:00
parent 0f3fd4eebd
commit d7bf8a2fd4
8 changed files with 635 additions and 0 deletions

View file

@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { nextRunForCadence } from './cadence';
describe('nextRunForCadence', () => {
const from = new Date('2026-04-14T10:00:00.000Z');
it('returns undefined for manual', () => {
expect(nextRunForCadence({ kind: 'manual' }, from)).toBeUndefined();
});
it('adds the interval for interval cadence', () => {
const next = nextRunForCadence({ kind: 'interval', everyMinutes: 60 }, from);
expect(next).toBe(new Date('2026-04-14T11:00:00.000Z').toISOString());
});
it('schedules daily cadence later today when the hour has not passed', () => {
const next = nextRunForCadence({ kind: 'daily', atHour: 18, atMinute: 30 }, from);
// local-time dependent — we only assert the delta is positive and under 24h
const deltaMs = new Date(next!).getTime() - from.getTime();
expect(deltaMs).toBeGreaterThan(0);
expect(deltaMs).toBeLessThan(24 * 60 * 60_000);
});
it('schedules daily cadence tomorrow when the hour has already passed', () => {
const next = nextRunForCadence({ kind: 'daily', atHour: 6, atMinute: 0 }, from);
const deltaMs = new Date(next!).getTime() - from.getTime();
expect(deltaMs).toBeGreaterThan(0);
});
it('schedules weekly cadence on the target day-of-week', () => {
// 2026-04-14 is a Tuesday (day 2). Target: Friday (day 5).
const next = nextRunForCadence({ kind: 'weekly', dayOfWeek: 5, atHour: 9 }, from);
expect(new Date(next!).getDay()).toBe(5);
});
it('returns undefined for cron until implemented', () => {
expect(nextRunForCadence({ kind: 'cron', expression: '0 9 * * *' }, from)).toBeUndefined();
});
});

View file

@ -0,0 +1,50 @@
/**
* Cadence next-run calculation.
*
* Used by the mission store to stamp `nextRunAt` on create / update /
* finishIteration. Pure function the Runner never calls this directly,
* it just reads `nextRunAt`.
*
* Kept intentionally simple: no RRULE parser, no timezone gymnastics. If we
* need cron, we'll plug in a lib later via the `cron` variant; for now the
* common cases (manual / interval / daily / weekly) cover what real users
* will configure in a settings UI.
*/
import type { MissionCadence } from './types';
export function nextRunForCadence(cadence: MissionCadence, from: Date): string | undefined {
switch (cadence.kind) {
case 'manual':
return undefined;
case 'interval': {
const t = new Date(from.getTime() + cadence.everyMinutes * 60_000);
return t.toISOString();
}
case 'daily': {
const t = new Date(from);
t.setHours(cadence.atHour, cadence.atMinute, 0, 0);
if (t <= from) t.setDate(t.getDate() + 1);
return t.toISOString();
}
case 'weekly': {
const t = new Date(from);
t.setHours(cadence.atHour, 0, 0, 0);
const diff = (cadence.dayOfWeek - t.getDay() + 7) % 7;
if (diff === 0 && t <= from) {
t.setDate(t.getDate() + 7);
} else {
t.setDate(t.getDate() + diff);
}
return t.toISOString();
}
case 'cron':
// Not implemented — caller must schedule nextRunAt explicitly until
// a cron parser is wired in. Return undefined so the Runner ignores it.
return undefined;
}
}

View file

@ -0,0 +1,188 @@
import 'fake-indexeddb/auto';
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() }));
vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() }));
vi.mock('$lib/triggers/inline-suggest', () => ({
checkInlineSuggestion: vi.fn().mockResolvedValue(null),
}));
import { db } from '../../database';
import {
createMission,
getMission,
listMissions,
updateMission,
pauseMission,
resumeMission,
completeMission,
archiveMission,
deleteMission,
startIteration,
finishIteration,
addIterationFeedback,
} from './store';
import { MISSIONS_TABLE } from './types';
import type { MissionCadence } from './types';
const DAILY: MissionCadence = { kind: 'daily', atHour: 9, atMinute: 0 };
beforeEach(async () => {
await db.table(MISSIONS_TABLE).clear();
});
describe('mission CRUD', () => {
it('creates an active mission with a scheduled next run', async () => {
const m = await createMission({
title: 'Weekly review',
conceptMarkdown: '# Do a weekly review',
objective: 'Review progress every Monday',
cadence: DAILY,
});
expect(m.state).toBe('active');
expect(m.iterations).toHaveLength(0);
expect(m.nextRunAt).toBeTruthy();
expect(await getMission(m.id)).toBeTruthy();
});
it('listMissions filters by state', async () => {
const a = await createMission({
title: 'A',
conceptMarkdown: '',
objective: 'x',
cadence: { kind: 'manual' },
});
await createMission({
title: 'B',
conceptMarkdown: '',
objective: 'y',
cadence: { kind: 'manual' },
});
await pauseMission(a.id);
const active = await listMissions({ state: 'active' });
const paused = await listMissions({ state: 'paused' });
expect(active).toHaveLength(1);
expect(paused).toHaveLength(1);
});
it('listMissions dueBefore only returns active missions past their nextRunAt', async () => {
const m = await createMission({
title: 'Due now',
conceptMarkdown: '',
objective: 'x',
cadence: { kind: 'interval', everyMinutes: 1 },
});
// force nextRunAt into the past
await db.table(MISSIONS_TABLE).update(m.id, {
nextRunAt: '2020-01-01T00:00:00.000Z',
});
const due = await listMissions({ dueBefore: new Date().toISOString() });
expect(due).toHaveLength(1);
});
it('updateMission with a new cadence recomputes nextRunAt', async () => {
const m = await createMission({
title: 'x',
conceptMarkdown: '',
objective: 'x',
cadence: { kind: 'manual' },
});
expect(m.nextRunAt).toBeUndefined();
await updateMission(m.id, { cadence: { kind: 'interval', everyMinutes: 30 } });
const updated = await getMission(m.id);
expect(updated?.nextRunAt).toBeTruthy();
});
it('pause / resume flip state and rescheduling', async () => {
const m = await createMission({
title: 'x',
conceptMarkdown: '',
objective: 'x',
cadence: DAILY,
});
await pauseMission(m.id);
expect((await getMission(m.id))?.state).toBe('paused');
await resumeMission(m.id);
const resumed = await getMission(m.id);
expect(resumed?.state).toBe('active');
expect(resumed?.nextRunAt).toBeTruthy();
});
it('complete clears nextRunAt', async () => {
const m = await createMission({
title: 'x',
conceptMarkdown: '',
objective: 'x',
cadence: DAILY,
});
await completeMission(m.id);
const done = await getMission(m.id);
expect(done?.state).toBe('done');
expect(done?.nextRunAt).toBeUndefined();
});
it('archive + soft-delete', async () => {
const m = await createMission({
title: 'x',
conceptMarkdown: '',
objective: 'x',
cadence: { kind: 'manual' },
});
await archiveMission(m.id);
expect((await getMission(m.id))?.state).toBe('archived');
await deleteMission(m.id);
const all = await listMissions();
expect(all).toHaveLength(0);
});
});
describe('mission iterations', () => {
it('appends a running iteration and finishes with summary + status', async () => {
const m = await createMission({
title: 'x',
conceptMarkdown: '',
objective: 'x',
cadence: DAILY,
});
const it = await startIteration(m.id, {
plan: [
{
id: 'step-1',
summary: 'Propose a task',
intent: { kind: 'toolCall', toolName: 'create_task', params: { title: 'foo' } },
status: 'planned',
},
],
});
const midRun = await getMission(m.id);
expect(midRun?.iterations).toHaveLength(1);
expect(midRun?.iterations[0].overallStatus).toBe('running');
await finishIteration(m.id, it.id, {
summary: 'Proposed a task',
overallStatus: 'awaiting-review',
});
const done = await getMission(m.id);
expect(done?.iterations[0].overallStatus).toBe('awaiting-review');
expect(done?.iterations[0].finishedAt).toBeTruthy();
expect(done?.iterations[0].summary).toBe('Proposed a task');
});
it('attaches user feedback to the iteration', async () => {
const m = await createMission({
title: 'x',
conceptMarkdown: '',
objective: 'x',
cadence: DAILY,
});
const it = await startIteration(m.id, { plan: [] });
await finishIteration(m.id, it.id, { overallStatus: 'awaiting-review' });
await addIterationFeedback(m.id, it.id, 'too aggressive — dial back');
const after = await getMission(m.id);
expect(after?.iterations[0].userFeedback).toBe('too aggressive — dial back');
});
});

View file

@ -0,0 +1,211 @@
/**
* Mission store CRUD + lifecycle operations.
*
* Missions go through the unified Dexie write path, which means the Dexie
* hooks stamp `userId`, `__lastActor`, `__fieldTimestamps`, `__fieldActors`
* and track the row into `_pendingChanges`. Callers never touch those
* fields directly.
*
* Iterations are intentionally stored inline (`Mission.iterations`) rather
* than in a child table. They are append-only, each Mission stays small
* (tens of iterations at most), and keeping them together lets the Planner
* load the full history with a single `get(id)` which it does every run.
*/
import { db } from '../../database';
import { nextRunForCadence } from './cadence';
import type {
Mission,
MissionCadence,
MissionInputRef,
MissionIteration,
MissionState,
PlanStep,
} from './types';
import { MISSIONS_TABLE } from './types';
const table = () => db.table<Mission>(MISSIONS_TABLE);
// ── Create ─────────────────────────────────────────────────
export interface CreateMissionInput {
title: string;
conceptMarkdown: string;
objective: string;
inputs?: MissionInputRef[];
cadence: MissionCadence;
}
export async function createMission(input: CreateMissionInput): Promise<Mission> {
const now = new Date().toISOString();
const mission: Mission = {
id: crypto.randomUUID(),
createdAt: now,
updatedAt: now,
title: input.title,
conceptMarkdown: input.conceptMarkdown,
objective: input.objective,
inputs: input.inputs ?? [],
cadence: input.cadence,
state: 'active',
nextRunAt: nextRunForCadence(input.cadence, new Date()),
iterations: [],
};
await table().add(mission);
return mission;
}
// ── Read ───────────────────────────────────────────────────
export async function getMission(id: string): Promise<Mission | undefined> {
return table().get(id);
}
export interface ListMissionsFilter {
state?: MissionState;
/** Only Missions whose `nextRunAt` has passed — used by the Runner. */
dueBefore?: string;
}
export async function listMissions(filter: ListMissionsFilter = {}): Promise<Mission[]> {
let coll = table().orderBy('createdAt').reverse();
if (filter.state) coll = coll.filter((m) => m.state === filter.state);
if (filter.dueBefore) {
const cutoff = filter.dueBefore;
coll = coll.filter(
(m) => m.state === 'active' && typeof m.nextRunAt === 'string' && m.nextRunAt <= cutoff
);
}
const all = await coll.toArray();
return all.filter((m) => !m.deletedAt);
}
// ── Update ─────────────────────────────────────────────────
export interface MissionPatch {
title?: string;
conceptMarkdown?: string;
objective?: string;
inputs?: MissionInputRef[];
cadence?: MissionCadence;
}
export async function updateMission(id: string, patch: MissionPatch): Promise<void> {
const mods: Partial<Mission> = {
...patch,
updatedAt: new Date().toISOString(),
};
if (patch.cadence) {
mods.nextRunAt = nextRunForCadence(patch.cadence, new Date());
}
await table().update(id, mods);
}
// ── Lifecycle ──────────────────────────────────────────────
export async function pauseMission(id: string): Promise<void> {
await table().update(id, { state: 'paused', updatedAt: new Date().toISOString() });
}
export async function resumeMission(id: string): Promise<void> {
const mission = await getMission(id);
if (!mission) throw new Error(`Mission not found: ${id}`);
await table().update(id, {
state: 'active',
nextRunAt: nextRunForCadence(mission.cadence, new Date()),
updatedAt: new Date().toISOString(),
});
}
export async function completeMission(id: string): Promise<void> {
await table().update(id, {
state: 'done',
nextRunAt: undefined,
updatedAt: new Date().toISOString(),
});
}
export async function archiveMission(id: string): Promise<void> {
await table().update(id, { state: 'archived', updatedAt: new Date().toISOString() });
}
export async function deleteMission(id: string): Promise<void> {
await table().update(id, { deletedAt: new Date().toISOString() });
}
// ── Iterations ─────────────────────────────────────────────
export interface StartIterationInput {
plan: PlanStep[];
}
/** Begin a new iteration — appends it with status `running`. */
export async function startIteration(
missionId: string,
input: StartIterationInput
): Promise<MissionIteration> {
const mission = await getMission(missionId);
if (!mission) throw new Error(`Mission not found: ${missionId}`);
const iteration: MissionIteration = {
id: crypto.randomUUID(),
startedAt: new Date().toISOString(),
plan: input.plan,
overallStatus: 'running',
};
await table().update(missionId, {
iterations: [...mission.iterations, iteration],
updatedAt: new Date().toISOString(),
});
return iteration;
}
export interface FinishIterationInput {
summary?: string;
overallStatus: MissionIteration['overallStatus'];
/** Replace the plan with the post-run state (steps with proposal ids / final statuses). */
plan?: PlanStep[];
}
export async function finishIteration(
missionId: string,
iterationId: string,
input: FinishIterationInput
): Promise<void> {
const mission = await getMission(missionId);
if (!mission) throw new Error(`Mission not found: ${missionId}`);
const updatedIterations = mission.iterations.map((it) =>
it.id === iterationId
? {
...it,
finishedAt: new Date().toISOString(),
overallStatus: input.overallStatus,
...(input.summary !== undefined ? { summary: input.summary } : {}),
...(input.plan !== undefined ? { plan: input.plan } : {}),
}
: it
);
await table().update(missionId, {
iterations: updatedIterations,
// Advance nextRunAt now that this iteration is done
nextRunAt: nextRunForCadence(mission.cadence, new Date()),
updatedAt: new Date().toISOString(),
});
}
/** Attach free-text user feedback to the most recent iteration. */
export async function addIterationFeedback(
missionId: string,
iterationId: string,
userFeedback: string
): Promise<void> {
const mission = await getMission(missionId);
if (!mission) throw new Error(`Mission not found: ${missionId}`);
const updatedIterations = mission.iterations.map((it) =>
it.id === iterationId ? { ...it, userFeedback } : it
);
await table().update(missionId, {
iterations: updatedIterations,
updatedAt: new Date().toISOString(),
});
}

View file

@ -0,0 +1,119 @@
/**
* Missions long-lived AI work items.
*
* A Mission is the user's standing instruction to the AI ("review my goals
* every Monday and propose three concrete actions"). The AI plans, stages
* proposals (via `pendingProposals`), collects feedback, and iterates.
*
* This file is data-model only. The Planner and Runner live elsewhere and
* operate on Missions see `COMPANION_BRAIN_ARCHITECTURE.md §20` for the
* full pipeline.
*
* Missions sync (unlike proposals) they are long-lived user-authored
* artifacts and should be available cross-device. Sensitive user-typed
* text (conceptMarkdown, objective, rationale strings in iterations) is
* encryption-eligible; IDs, timestamps, state enums stay plaintext.
*/
import type { ProposalStatus } from '../proposals/types';
/** Lifecycle of a Mission. */
export type MissionState = 'active' | 'paused' | 'done' | 'archived';
/** How often the Runner should pick this Mission up. */
export type MissionCadence =
| { readonly kind: 'manual' }
| { readonly kind: 'interval'; readonly everyMinutes: number }
| { readonly kind: 'daily'; readonly atHour: number; readonly atMinute: number }
| {
readonly kind: 'weekly';
readonly dayOfWeek: 0 | 1 | 2 | 3 | 4 | 5 | 6;
readonly atHour: number;
}
| { readonly kind: 'cron'; readonly expression: string };
/** Reference to a record in another module this Mission draws context from. */
export interface MissionInputRef {
/** Source module — e.g. `'notes'`, `'goals'`, `'kontext'`. */
readonly module: string;
/** Dexie table name (needed because some modules have multiple). */
readonly table: string;
/** Record id. */
readonly id: string;
}
/** A single step the Planner proposed for the current iteration. */
export interface PlanStep {
readonly id: string;
/** Human-readable summary shown in the Workbench. */
readonly summary: string;
/** What runs — today only toolCalls; later: sub-missions, text-generation, etc. */
readonly intent:
| {
readonly kind: 'toolCall';
readonly toolName: string;
readonly params: Record<string, unknown>;
}
| { readonly kind: 'note'; readonly body: string };
/** Proposal id if this step was staged; undefined before execution. */
readonly proposalId?: string;
/** Outcome of this step. */
readonly status: 'planned' | 'staged' | 'approved' | 'rejected' | 'skipped' | 'failed';
}
/** One autonomous run of the Mission, produced by the Runner invoking the Planner. */
export interface MissionIteration {
readonly id: string;
readonly startedAt: string;
readonly finishedAt?: string;
/** Plan the Planner generated this run. */
readonly plan: readonly PlanStep[];
/** AI's own notes on what it did and why (for the next iteration's context). */
readonly summary?: string;
/** Free-text feedback the user attached on review. */
readonly userFeedback?: string;
/** Shortcut derived from step proposal statuses — used by queries / UI. */
readonly overallStatus: 'running' | 'awaiting-review' | 'approved' | 'rejected' | 'failed';
}
export interface Mission {
readonly id: string;
readonly createdAt: string;
readonly updatedAt: string;
/** Short user-facing name. */
title: string;
/** Markdown doc describing the concept / rules of engagement. */
conceptMarkdown: string;
/** One-sentence concrete objective. */
objective: string;
/** Module records this Mission reads from for context. */
inputs: readonly MissionInputRef[];
/** Cadence the Runner honours. */
cadence: MissionCadence;
/** Lifecycle. */
state: MissionState;
/** ISO timestamp of the next scheduled run, or undefined if not scheduled. */
nextRunAt?: string;
/** All past iterations, newest last. */
iterations: readonly MissionIteration[];
// ── Bookkeeping ──────────────────────────
userId?: string;
deletedAt?: string;
}
export const MISSIONS_TABLE = 'aiMissions';
/** Helper — derive a summary status for a proposal-id lookup. */
export function planStepStatusFromProposal(status: ProposalStatus): PlanStep['status'] {
switch (status) {
case 'pending':
return 'staged';
case 'approved':
return 'approved';
case 'rejected':
return 'rejected';
case 'expired':
return 'skipped';
}
}

View file

@ -0,0 +1,18 @@
/**
* Module config for AI Workbench tables that participate in sync.
*
* Sync surface:
* - `aiMissions` long-lived user-authored AI work items (cross-device)
*
* NOT in this config:
* - `pendingProposals` intentionally local-only (see proposals/types.ts).
* Approvals run the underlying tool which writes through its own module's
* sync path, so proposals don't need to travel.
*/
import type { ModuleConfig } from '$lib/data/module-registry';
export const aiModuleConfig: ModuleConfig = {
appId: 'ai',
tables: [{ name: 'aiMissions' }],
};

View file

@ -508,6 +508,14 @@ db.version(17).stores({
pendingProposals: 'id, status, createdAt, missionId, [status+createdAt]',
});
// v18 — AI Missions: long-lived autonomous work items. Syncs cross-device
// (unlike `pendingProposals`) so the user can configure a mission on one
// device and see it run on another. Indexes power the Runner's "due now"
// query and the Workbench's state filters.
db.version(18).stores({
aiMissions: 'id, state, createdAt, nextRunAt, [state+nextRunAt]',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -96,6 +96,7 @@ import { meditateModuleConfig } from '$lib/modules/meditate/module.config';
import { sleepModuleConfig } from '$lib/modules/sleep/module.config';
import { moodModuleConfig } from '$lib/modules/mood/module.config';
import { kontextModuleConfig } from '$lib/modules/kontext/module.config';
import { aiModuleConfig } from '$lib/data/ai/module.config';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
manaCoreConfig,
@ -147,6 +148,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
sleepModuleConfig,
moodModuleConfig,
kontextModuleConfig,
aiModuleConfig,
];
// ─── Derived Maps ──────────────────────────────────────────