mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:41:08 +02:00
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:
parent
0f3fd4eebd
commit
d7bf8a2fd4
8 changed files with 635 additions and 0 deletions
39
apps/mana/apps/web/src/lib/data/ai/missions/cadence.test.ts
Normal file
39
apps/mana/apps/web/src/lib/data/ai/missions/cadence.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
50
apps/mana/apps/web/src/lib/data/ai/missions/cadence.ts
Normal file
50
apps/mana/apps/web/src/lib/data/ai/missions/cadence.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
188
apps/mana/apps/web/src/lib/data/ai/missions/store.test.ts
Normal file
188
apps/mana/apps/web/src/lib/data/ai/missions/store.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
211
apps/mana/apps/web/src/lib/data/ai/missions/store.ts
Normal file
211
apps/mana/apps/web/src/lib/data/ai/missions/store.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
119
apps/mana/apps/web/src/lib/data/ai/missions/types.ts
Normal file
119
apps/mana/apps/web/src/lib/data/ai/missions/types.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
18
apps/mana/apps/web/src/lib/data/ai/module.config.ts
Normal file
18
apps/mana/apps/web/src/lib/data/ai/module.config.ts
Normal 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' }],
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue