mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
refactor(webapp): delete proposal infrastructure + ai-plan legacy wrappers
Runner no longer creates proposals (commit 5a) and no module renders the inbox (commit 5b), so the supporting code is dead. This commit deletes it. Removed: - data/ai/proposals/ (types, store, queries + tests) — the entire Proposal model + createProposal/listProposals/approveProposal API. - components/ai/AiProposalInbox.svelte — orphaned after commit 5b. - data/ai/missions/server-iteration-staging.ts + its test — the bridge that turned server-produced iterations into local proposals. Server iterations will land with executed steps directly once commit 6 migrates the server runner. - data/ai/missions/planner/ — all webapp re-exports of the old buildPlannerPrompt / parsePlannerResponse / AiPlanInput types. The new runner imports its types directly from @mana/shared-ai. - llm-tasks/ai-plan.ts — the old LlmTask that wrapped the text-JSON request/parse cycle for the LlmOrchestrator. Replaced by the direct mana-llm client in missions/llm-client.ts. Updated: - data/database.ts — v29 drops the `pendingProposals` table (passing null to .stores() deletes it on next open). Safe because nothing is live. - routes/(app)/+layout.svelte — no more startServerIterationStaging / stopServerIterationStaging in the bootstrap/teardown pair. - data/ai/missions/types.ts — strips the planStepStatusFromProposal bridge helper (proposals don't exist any more). - data/ai/missions/input-resolvers.ts — imports ResolvedInput from @mana/shared-ai directly. - data/tools/executor.test.ts — the proposal-staging test block is rewritten to match the new semantics: auto and propose both execute inline, only deny refuses. Net: ~1100 LoC removed, 0 added. Type-check green, 15 tests pass across executor + runner. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
08b7ac16bf
commit
2d15684ed4
19 changed files with 26 additions and 1555 deletions
|
|
@ -1,325 +0,0 @@
|
|||
<!--
|
||||
AiProposalInbox — renders pending AI proposals inline inside a module.
|
||||
|
||||
Drop this component anywhere in a module page. It shows zero UI when no
|
||||
proposals are pending (so the module layout is unaffected when the AI is
|
||||
idle) and a list of approval cards when the AI has staged intents.
|
||||
|
||||
Each card displays:
|
||||
- the rationale ("why" from the AI)
|
||||
- a human-readable preview of the intent (tool name + params)
|
||||
- Approve / Reject buttons that run `approveProposal` / `rejectProposal`
|
||||
and close themselves via Dexie's live query
|
||||
|
||||
The cards use a "ghost" style (semi-transparent, dashed border) so they
|
||||
read as "not real yet" — inspired by Figma's multiplayer cursors and
|
||||
Google Docs suggestions.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Check, X, Sparkle } from '@mana/shared-icons';
|
||||
import { useAiProposals } from '$lib/data/ai/proposals/queries';
|
||||
import { approveProposal, rejectProposal } from '$lib/data/ai/proposals/store';
|
||||
import { getTool } from '$lib/data/tools/registry';
|
||||
import type { Proposal } from '$lib/data/ai/proposals/types';
|
||||
|
||||
interface Props {
|
||||
/** Filter proposals to tools belonging to this module (e.g. 'todo').
|
||||
* Omit when filtering by mission only — the inbox will then render
|
||||
* every pending proposal across modules and add a module badge to
|
||||
* each card so the user knows where it'll land on approve. */
|
||||
module?: string;
|
||||
/** Filter to proposals from a specific mission. Combine with `module`
|
||||
* to scope to that mission's proposals for a single module. */
|
||||
missionId?: string;
|
||||
}
|
||||
|
||||
let { module, missionId }: Props = $props();
|
||||
|
||||
const proposals = $derived(useAiProposals({ status: 'pending', module, missionId }));
|
||||
/** Show module badge whenever the inbox is cross-module (i.e. the
|
||||
* caller didn't pin it to a single module). */
|
||||
const showModuleBadge = $derived(!module);
|
||||
|
||||
let busyId = $state<string | null>(null);
|
||||
/** Proposal whose reject-feedback textarea is currently open. */
|
||||
let rejectingId = $state<string | null>(null);
|
||||
let rejectDraft = $state('');
|
||||
|
||||
async function handleApprove(p: Proposal) {
|
||||
busyId = p.id;
|
||||
try {
|
||||
await approveProposal(p.id);
|
||||
} catch (err) {
|
||||
console.error('[AiProposalInbox] approve failed:', err);
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function openRejectForm(p: Proposal) {
|
||||
rejectingId = p.id;
|
||||
rejectDraft = '';
|
||||
}
|
||||
|
||||
function cancelReject() {
|
||||
rejectingId = null;
|
||||
rejectDraft = '';
|
||||
}
|
||||
|
||||
async function confirmReject(p: Proposal) {
|
||||
busyId = p.id;
|
||||
try {
|
||||
// Trimmed feedback, or undefined when empty — downstream planner
|
||||
// sees the field as absent rather than as an empty string.
|
||||
const feedback = rejectDraft.trim().length > 0 ? rejectDraft.trim() : undefined;
|
||||
await rejectProposal(p.id, feedback);
|
||||
rejectingId = null;
|
||||
rejectDraft = '';
|
||||
} catch (err) {
|
||||
console.error('[AiProposalInbox] reject failed:', err);
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatIntent(p: Proposal): string {
|
||||
if (p.intent.kind !== 'toolCall') return JSON.stringify(p.intent);
|
||||
const tool = getTool(p.intent.toolName);
|
||||
const label = tool?.description ?? p.intent.toolName;
|
||||
const paramsPreview = Object.entries(p.intent.params)
|
||||
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||||
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||||
.join(' · ');
|
||||
return paramsPreview ? `${label} — ${paramsPreview}` : label;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if proposals.value.length > 0}
|
||||
<section class="inbox" aria-label="Vorschläge der KI">
|
||||
{#each proposals.value as p (p.id)}
|
||||
<article class="card" class:busy={busyId === p.id}>
|
||||
<header class="header">
|
||||
{#if p.actor?.kind === 'ai'}
|
||||
<span class="agent-chip" title={`Mission: ${p.actor.missionId.slice(0, 8)}…`}>
|
||||
<span class="agent-avatar-dot">🤖</span>
|
||||
<span class="agent-name">{p.actor.displayName}</span>
|
||||
</span>
|
||||
<span class="label">schlägt vor</span>
|
||||
{:else}
|
||||
<Sparkle size={16} weight="fill" />
|
||||
<span class="label">KI schlägt vor</span>
|
||||
{/if}
|
||||
{#if showModuleBadge && p.intent.kind === 'toolCall'}
|
||||
{@const mod = getTool(p.intent.toolName)?.module ?? '?'}
|
||||
<span class="module-badge">{mod}</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<p class="intent">{formatIntent(p)}</p>
|
||||
|
||||
{#if p.rationale}
|
||||
<p class="rationale">{p.rationale}</p>
|
||||
{/if}
|
||||
|
||||
{#if rejectingId === p.id}
|
||||
<form class="reject-form" onsubmit={(e) => (e.preventDefault(), confirmReject(p))}>
|
||||
<label class="reject-label" for={`reject-${p.id}`}>
|
||||
Warum ablehnen? (optional — hilft der KI beim nächsten Versuch)
|
||||
</label>
|
||||
<textarea
|
||||
id={`reject-${p.id}`}
|
||||
bind:value={rejectDraft}
|
||||
rows="2"
|
||||
placeholder="z.B. zu aggressiv, oder: falsches Datum gewählt"
|
||||
></textarea>
|
||||
<div class="reject-actions">
|
||||
<button type="button" class="btn" disabled={busyId !== null} onclick={cancelReject}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="btn reject-confirm" disabled={busyId !== null}>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<footer class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn reject"
|
||||
disabled={busyId !== null}
|
||||
onclick={() => openRejectForm(p)}
|
||||
aria-label="Ablehnen"
|
||||
>
|
||||
<X size={16} weight="bold" />
|
||||
<span>Ablehnen</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn approve"
|
||||
disabled={busyId !== null}
|
||||
onclick={() => handleApprove(p)}
|
||||
aria-label="Übernehmen"
|
||||
>
|
||||
<Check size={16} weight="bold" />
|
||||
<span>Übernehmen</span>
|
||||
</button>
|
||||
</footer>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.inbox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px dashed color-mix(in oklab, var(--color-primary, #6b5bff) 55%, transparent);
|
||||
border-radius: 0.625rem;
|
||||
background: color-mix(in oklab, var(--color-primary, #6b5bff) 6%, var(--color-bg, transparent));
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.card.busy {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: color-mix(in oklab, var(--color-primary, #6b5bff) 85%, var(--color-fg, #000));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.module-badge {
|
||||
margin-left: auto;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: color-mix(in oklab, var(--color-primary, #6b5bff) 18%, transparent);
|
||||
color: color-mix(in oklab, var(--color-primary, #6b5bff) 90%, var(--color-fg, #000));
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.agent-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-primary, #6b5bff) 12%, transparent);
|
||||
color: color-mix(in oklab, var(--color-primary, #6b5bff) 95%, var(--color-fg, #000));
|
||||
font-size: 0.75rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.agent-avatar-dot {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.agent-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.intent {
|
||||
margin: 0.375rem 0 0;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-fg, inherit);
|
||||
}
|
||||
|
||||
.rationale {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.35;
|
||||
color: var(--color-muted, #666);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-fg, inherit);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
border-color: color-mix(in oklab, var(--color-fg, #000) 30%, transparent);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.btn.approve {
|
||||
background: color-mix(in oklab, var(--color-primary, #6b5bff) 12%, var(--color-bg, #fff));
|
||||
border-color: color-mix(in oklab, var(--color-primary, #6b5bff) 45%, transparent);
|
||||
color: color-mix(in oklab, var(--color-primary, #6b5bff) 85%, var(--color-fg, #000));
|
||||
}
|
||||
|
||||
.btn.approve:hover:not(:disabled) {
|
||||
background: color-mix(in oklab, var(--color-primary, #6b5bff) 20%, var(--color-bg, #fff));
|
||||
}
|
||||
|
||||
.reject-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
.reject-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted, #666);
|
||||
}
|
||||
.reject-form textarea {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-fg, inherit);
|
||||
}
|
||||
.reject-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn.reject-confirm {
|
||||
background: #fff0f0;
|
||||
border-color: #e99;
|
||||
color: #8a1b1b;
|
||||
}
|
||||
.btn.reject-confirm:hover:not(:disabled) {
|
||||
background: #ffe4e4;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
*/
|
||||
|
||||
import type { MissionInputRef } from './types';
|
||||
import type { ResolvedInput } from './planner/types';
|
||||
import type { ResolvedInput } from '@mana/shared-ai';
|
||||
|
||||
export type InputResolver = (ref: MissionInputRef) => Promise<ResolvedInput | null>;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { parsePlannerResponse } from './parser';
|
||||
|
||||
const TOOLS = new Set(['create_task', 'log_drink']);
|
||||
|
||||
describe('parsePlannerResponse', () => {
|
||||
it('parses a valid fenced json block', () => {
|
||||
const text = `\`\`\`json
|
||||
{
|
||||
"summary": "Plan für heute",
|
||||
"steps": [
|
||||
{ "summary": "Task anlegen", "toolName": "create_task", "params": { "title": "Foo" }, "rationale": "weil wichtig" }
|
||||
]
|
||||
}
|
||||
\`\`\``;
|
||||
const r = parsePlannerResponse(text, TOOLS);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value.summary).toBe('Plan für heute');
|
||||
expect(r.value.steps).toHaveLength(1);
|
||||
expect(r.value.steps[0].toolName).toBe('create_task');
|
||||
expect(r.value.steps[0].params).toEqual({ title: 'Foo' });
|
||||
});
|
||||
|
||||
it('accepts a bare JSON object without fence', () => {
|
||||
const text = `{ "summary": "x", "steps": [
|
||||
{ "summary": "log", "toolName": "log_drink", "params": {}, "rationale": "Routine" }
|
||||
]}`;
|
||||
const r = parsePlannerResponse(text, TOOLS);
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when no JSON block found', () => {
|
||||
const r = parsePlannerResponse('just prose no JSON here', TOOLS);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid JSON inside the fence', () => {
|
||||
const r = parsePlannerResponse('```json\n{not: valid}\n```', TOOLS);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when steps is missing or not an array', () => {
|
||||
const r = parsePlannerResponse('```json\n{"summary":"x"}\n```', TOOLS);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects steps referencing unknown tools', () => {
|
||||
const text = `\`\`\`json
|
||||
{ "summary": "", "steps": [{ "toolName": "nuke_database", "params": {}, "rationale": "why not" }] }
|
||||
\`\`\``;
|
||||
const r = parsePlannerResponse(text, TOOLS);
|
||||
expect(r.ok).toBe(false);
|
||||
if (r.ok) return;
|
||||
expect(r.reason).toContain('nuke_database');
|
||||
});
|
||||
|
||||
it('rejects steps missing rationale', () => {
|
||||
const text = `\`\`\`json
|
||||
{ "summary": "", "steps": [{ "toolName": "create_task", "params": { "title": "x" } }] }
|
||||
\`\`\``;
|
||||
const r = parsePlannerResponse(text, TOOLS);
|
||||
expect(r.ok).toBe(false);
|
||||
if (r.ok) return;
|
||||
expect(r.reason).toContain('rationale');
|
||||
});
|
||||
|
||||
it('tolerates missing summary / step summary by defaulting to empty', () => {
|
||||
const text = `\`\`\`json
|
||||
{
|
||||
"steps": [
|
||||
{ "toolName": "create_task", "params": {}, "rationale": "need one" }
|
||||
]
|
||||
}
|
||||
\`\`\``;
|
||||
const r = parsePlannerResponse(text, TOOLS);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value.summary).toBe('');
|
||||
expect(r.value.steps[0].summary).toBe('');
|
||||
});
|
||||
|
||||
it('accepts an empty steps array (no-op iteration)', () => {
|
||||
const text = `\`\`\`json
|
||||
{ "summary": "nothing to do today", "steps": [] }
|
||||
\`\`\``;
|
||||
const r = parsePlannerResponse(text, TOOLS);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value.steps).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
/**
|
||||
* Re-export of the Planner response parser from @mana/shared-ai.
|
||||
* Kept here so existing webapp imports (and tests) keep working.
|
||||
*/
|
||||
|
||||
export { parsePlannerResponse } from '@mana/shared-ai';
|
||||
export type { ParseResult } from '@mana/shared-ai';
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { buildPlannerPrompt } from './prompt';
|
||||
import type { AiPlanInput } from './types';
|
||||
import type { Mission } from '../types';
|
||||
|
||||
function baseMission(overrides: Partial<Mission> = {}): Mission {
|
||||
return {
|
||||
id: 'm-1',
|
||||
createdAt: '2026-04-14T10:00:00.000Z',
|
||||
updatedAt: '2026-04-14T10:00:00.000Z',
|
||||
title: 'Weekly review',
|
||||
conceptMarkdown: '# Concept\nDo a thing.',
|
||||
objective: 'Review progress each Monday',
|
||||
inputs: [],
|
||||
cadence: { kind: 'weekly', dayOfWeek: 1, atHour: 9 },
|
||||
state: 'active',
|
||||
iterations: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildPlannerPrompt', () => {
|
||||
it('emits system + user messages with mission title and objective', () => {
|
||||
const input: AiPlanInput = {
|
||||
mission: baseMission(),
|
||||
resolvedInputs: [],
|
||||
availableTools: [],
|
||||
};
|
||||
const { system, user } = buildPlannerPrompt(input);
|
||||
expect(user).toContain('Weekly review');
|
||||
expect(user).toContain('Review progress each Monday');
|
||||
expect(system).toContain('JSON');
|
||||
expect(system).toContain('rationale');
|
||||
});
|
||||
|
||||
it('lists available tools with their params in the system prompt', () => {
|
||||
const input: AiPlanInput = {
|
||||
mission: baseMission(),
|
||||
resolvedInputs: [],
|
||||
availableTools: [
|
||||
{
|
||||
name: 'create_task',
|
||||
module: 'todo',
|
||||
description: 'Creates a task',
|
||||
parameters: [
|
||||
{ name: 'title', type: 'string', required: true, description: 'Task title' },
|
||||
{
|
||||
name: 'priority',
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'prio',
|
||||
enum: ['low', 'high'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const { system } = buildPlannerPrompt(input);
|
||||
expect(system).toContain('create_task');
|
||||
expect(system).toContain('title');
|
||||
expect(system).toContain('(required)');
|
||||
expect(system).toContain('[low|high]');
|
||||
});
|
||||
|
||||
it('injects resolved input content into the user prompt', () => {
|
||||
const input: AiPlanInput = {
|
||||
mission: baseMission({
|
||||
inputs: [{ module: 'notes', table: 'notes', id: 'n-1' }],
|
||||
}),
|
||||
resolvedInputs: [
|
||||
{ id: 'n-1', module: 'notes', table: 'notes', title: 'Strategy', content: 'Be bold.' },
|
||||
],
|
||||
availableTools: [],
|
||||
};
|
||||
const { user } = buildPlannerPrompt(input);
|
||||
expect(user).toContain('Strategy');
|
||||
expect(user).toContain('Be bold.');
|
||||
});
|
||||
|
||||
it('includes user feedback from the most recent iteration', () => {
|
||||
const input: AiPlanInput = {
|
||||
mission: baseMission({
|
||||
iterations: [
|
||||
{
|
||||
id: 'it-1',
|
||||
startedAt: '2026-04-07T09:00:00.000Z',
|
||||
finishedAt: '2026-04-07T09:01:00.000Z',
|
||||
plan: [
|
||||
{
|
||||
id: 's-1',
|
||||
summary: 'Old step',
|
||||
intent: { kind: 'toolCall', toolName: 'create_task', params: {} },
|
||||
status: 'rejected',
|
||||
},
|
||||
],
|
||||
userFeedback: 'Zu aggressiv — bitte zurücknehmen',
|
||||
overallStatus: 'rejected',
|
||||
},
|
||||
],
|
||||
}),
|
||||
resolvedInputs: [],
|
||||
availableTools: [],
|
||||
};
|
||||
const { user } = buildPlannerPrompt(input);
|
||||
expect(user).toContain('Zu aggressiv');
|
||||
expect(user).toContain('[rejected]');
|
||||
});
|
||||
|
||||
it('truncates iteration history to the last 3', () => {
|
||||
const many = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `it-${i}`,
|
||||
startedAt: `2026-04-${String(i + 1).padStart(2, '0')}T10:00:00.000Z`,
|
||||
plan: [],
|
||||
overallStatus: 'approved' as const,
|
||||
userFeedback: `feedback-${i}`,
|
||||
}));
|
||||
const { user } = buildPlannerPrompt({
|
||||
mission: baseMission({ iterations: many }),
|
||||
resolvedInputs: [],
|
||||
availableTools: [],
|
||||
});
|
||||
// Only the last three iterations (7, 8, 9) should be present
|
||||
expect(user).toContain('feedback-9');
|
||||
expect(user).toContain('feedback-7');
|
||||
expect(user).not.toContain('feedback-5');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
/**
|
||||
* Re-export of the Planner prompt builder from @mana/shared-ai.
|
||||
* Kept here so existing webapp imports (and tests) keep working.
|
||||
*/
|
||||
|
||||
export { buildPlannerPrompt } from '@mana/shared-ai';
|
||||
export type { PlannerMessages } from '@mana/shared-ai';
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
/**
|
||||
* Re-export of Planner types from @mana/shared-ai. The shared package is
|
||||
* the source of truth — webapp and mana-ai service both import from it.
|
||||
*/
|
||||
|
||||
export type {
|
||||
AiPlanInput,
|
||||
AiPlanOutput,
|
||||
AvailableTool,
|
||||
PlannedStep,
|
||||
ResolvedInput,
|
||||
} from '@mana/shared-ai';
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
import 'fake-indexeddb/auto';
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } 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 { registerTools } from '../../tools/registry';
|
||||
import { setAiPolicy } from '../policy';
|
||||
import { createMission, finishIteration, startIteration } from './store';
|
||||
import { MISSIONS_TABLE } from './types';
|
||||
import { listProposals } from '../proposals/store';
|
||||
import { PROPOSALS_TABLE } from '../proposals/types';
|
||||
import {
|
||||
startServerIterationStaging,
|
||||
stopServerIterationStaging,
|
||||
resetServerIterationStagingCache,
|
||||
} from './server-iteration-staging';
|
||||
|
||||
registerTools([
|
||||
{
|
||||
name: 'staging_test_op',
|
||||
module: 'stagingTest',
|
||||
description: 'propose only',
|
||||
parameters: [{ name: 'val', type: 'string', required: true, description: 'v' }],
|
||||
async execute() {
|
||||
return { success: true, message: 'ok' };
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.table(MISSIONS_TABLE).clear();
|
||||
await db.table(PROPOSALS_TABLE).clear();
|
||||
resetServerIterationStagingCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopServerIterationStaging();
|
||||
});
|
||||
|
||||
describe('server-iteration staging', () => {
|
||||
it('translates a server iteration into local proposals', async () => {
|
||||
const restore = setAiPolicy({
|
||||
tools: { staging_test_op: 'propose' },
|
||||
defaultForAi: 'propose',
|
||||
});
|
||||
try {
|
||||
const m = await createMission({
|
||||
title: 'x',
|
||||
conceptMarkdown: '',
|
||||
objective: 'x',
|
||||
cadence: { kind: 'manual' },
|
||||
});
|
||||
// Simulate what mana-ai's write-back would sync into Dexie
|
||||
const it = await startIteration(m.id, {
|
||||
plan: [
|
||||
{
|
||||
id: 'srv-step-1',
|
||||
summary: 'server step',
|
||||
intent: {
|
||||
kind: 'toolCall',
|
||||
toolName: 'staging_test_op',
|
||||
params: { val: 'hello' },
|
||||
},
|
||||
status: 'planned',
|
||||
},
|
||||
],
|
||||
});
|
||||
await finishIteration(m.id, it.id, {
|
||||
overallStatus: 'awaiting-review',
|
||||
});
|
||||
// Stamp source='server' — startIteration/finishIteration don't
|
||||
// set it; the write-back path from mana-ai does.
|
||||
const row = await db.table(MISSIONS_TABLE).get(m.id);
|
||||
const patched = row.iterations.map((x: { id: string }) =>
|
||||
x.id === it.id ? { ...x, source: 'server' } : x
|
||||
);
|
||||
await db.table(MISSIONS_TABLE).update(m.id, { iterations: patched });
|
||||
|
||||
startServerIterationStaging();
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const proposals = await listProposals({ status: 'pending' });
|
||||
expect(proposals).toHaveLength(1);
|
||||
expect(proposals[0].missionId).toBe(m.id);
|
||||
expect(proposals[0].iterationId).toBe(it.id);
|
||||
expect(proposals[0].intent).toMatchObject({
|
||||
kind: 'toolCall',
|
||||
toolName: 'staging_test_op',
|
||||
params: { val: 'hello' },
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not re-stage an iteration that already has proposalIds', async () => {
|
||||
const m = await createMission({
|
||||
title: 'x',
|
||||
conceptMarkdown: '',
|
||||
objective: 'x',
|
||||
cadence: { kind: 'manual' },
|
||||
});
|
||||
const it = await startIteration(m.id, {
|
||||
plan: [
|
||||
{
|
||||
id: 'srv-step-1',
|
||||
summary: 's',
|
||||
intent: {
|
||||
kind: 'toolCall',
|
||||
toolName: 'staging_test_op',
|
||||
params: { val: 'x' },
|
||||
},
|
||||
status: 'staged',
|
||||
proposalId: 'already-there',
|
||||
},
|
||||
],
|
||||
});
|
||||
await finishIteration(m.id, it.id, { overallStatus: 'awaiting-review' });
|
||||
const row = await db.table(MISSIONS_TABLE).get(m.id);
|
||||
const patched = row.iterations.map((x: { id: string }) =>
|
||||
x.id === it.id ? { ...x, source: 'server' } : x
|
||||
);
|
||||
await db.table(MISSIONS_TABLE).update(m.id, { iterations: patched });
|
||||
|
||||
startServerIterationStaging();
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const proposals = await listProposals({ status: 'pending' });
|
||||
expect(proposals).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores browser-sourced iterations', async () => {
|
||||
const m = await createMission({
|
||||
title: 'x',
|
||||
conceptMarkdown: '',
|
||||
objective: 'x',
|
||||
cadence: { kind: 'manual' },
|
||||
});
|
||||
const it = await startIteration(m.id, {
|
||||
plan: [
|
||||
{
|
||||
id: 'browser-step',
|
||||
summary: 's',
|
||||
intent: {
|
||||
kind: 'toolCall',
|
||||
toolName: 'staging_test_op',
|
||||
params: { val: 'x' },
|
||||
},
|
||||
status: 'planned',
|
||||
},
|
||||
],
|
||||
});
|
||||
await finishIteration(m.id, it.id, { overallStatus: 'awaiting-review' });
|
||||
// leave source unset (defaults to 'browser')
|
||||
|
||||
startServerIterationStaging();
|
||||
await flush();
|
||||
|
||||
const proposals = await listProposals({ status: 'pending' });
|
||||
expect(proposals).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
/**
|
||||
* Server-iteration staging — translates server-produced Mission
|
||||
* iterations into local Proposals.
|
||||
*
|
||||
* The mana-ai Bun service writes plans back as a new `Mission.iterations[]`
|
||||
* entry with `source: 'server'`. When the webapp syncs, `applyServerChanges`
|
||||
* merges the new iterations array into the local record. This module
|
||||
* subscribes to those updates and, for each server iteration we haven't
|
||||
* processed yet, creates a Proposal per PlanStep via the existing
|
||||
* `createProposal` flow.
|
||||
*
|
||||
* Idempotency: each iteration id is tracked in a local Set so re-runs
|
||||
* (e.g. after tab reopen) don't duplicate proposals. Proposals that
|
||||
* successfully create get their id written back into `plan[i].proposalId`
|
||||
* so the Workbench UI links them; that also doubles as a durable
|
||||
* "already staged" marker surviving app restarts.
|
||||
*/
|
||||
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '../../database';
|
||||
import { MISSIONS_TABLE } from './types';
|
||||
import { createProposal } from '../proposals/store';
|
||||
import { getMission } from './store';
|
||||
import { runAsAsync, makeAgentActor, LEGACY_AI_PRINCIPAL } from '../../events/actor';
|
||||
import { getAgent } from '../agents/store';
|
||||
import { DEFAULT_AGENT_NAME } from '../agents/types';
|
||||
import type { Mission, MissionIteration, PlanStep } from './types';
|
||||
|
||||
const processedIterations = new Set<string>();
|
||||
let subscription: { unsubscribe: () => void } | null = null;
|
||||
|
||||
/**
|
||||
* Start subscribing to aiMissions changes. Each time a server iteration
|
||||
* without staged proposals shows up, translate every PlanStep into a
|
||||
* local Proposal under the originating mission's AI actor.
|
||||
*
|
||||
* Idempotent — calling twice is a no-op. Returns a stop function.
|
||||
*/
|
||||
export function startServerIterationStaging(): () => void {
|
||||
if (subscription) return stopServerIterationStaging;
|
||||
|
||||
const obs = liveQuery(() => db.table<Mission>(MISSIONS_TABLE).toArray());
|
||||
subscription = obs.subscribe({
|
||||
next: async (missions) => {
|
||||
for (const m of missions) {
|
||||
if (m.deletedAt) continue;
|
||||
for (const it of m.iterations) {
|
||||
if (it.source !== 'server') continue;
|
||||
if (processedIterations.has(it.id)) continue;
|
||||
// Pre-check: if any plan step already has a proposalId, the
|
||||
// server iteration was already staged (possibly by another
|
||||
// tab). Mark as processed so we don't race.
|
||||
const alreadyStaged = it.plan.some(
|
||||
(s) => typeof s.proposalId === 'string' && s.proposalId.length > 0
|
||||
);
|
||||
if (alreadyStaged) {
|
||||
processedIterations.add(it.id);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await stageIteration(m, it);
|
||||
processedIterations.add(it.id);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[server-staging] mission=${m.id} iteration=${it.id} failed:`,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('[server-staging] subscription error:', err);
|
||||
},
|
||||
});
|
||||
return stopServerIterationStaging;
|
||||
}
|
||||
|
||||
export function stopServerIterationStaging(): void {
|
||||
subscription?.unsubscribe();
|
||||
subscription = null;
|
||||
}
|
||||
|
||||
/** Test hook — forget which iterations we've already staged. */
|
||||
export function resetServerIterationStagingCache(): void {
|
||||
processedIterations.clear();
|
||||
}
|
||||
|
||||
async function stageIteration(mission: Mission, iteration: MissionIteration): Promise<void> {
|
||||
// Re-read the freshest mission so concurrent local edits don't get
|
||||
// clobbered when we write proposalIds back into `plan[]`.
|
||||
const fresh = await getMission(mission.id);
|
||||
if (!fresh) return;
|
||||
const stagedStepIds: Record<string, string> = {};
|
||||
|
||||
// Resolve the owning agent once per iteration (not per step) — agent
|
||||
// identity doesn't change mid-iteration. Legacy missions or missions
|
||||
// whose agent was deleted fall back to the legacy principal.
|
||||
const owningAgent = fresh.agentId ? await getAgent(fresh.agentId) : null;
|
||||
const actorAgentId = owningAgent?.id ?? LEGACY_AI_PRINCIPAL;
|
||||
const actorDisplayName = owningAgent?.name ?? DEFAULT_AGENT_NAME;
|
||||
|
||||
for (const step of iteration.plan) {
|
||||
const intent = step.intent;
|
||||
if (intent.kind !== 'toolCall') continue;
|
||||
if (step.proposalId) continue; // already staged
|
||||
|
||||
const actor = makeAgentActor({
|
||||
agentId: actorAgentId,
|
||||
displayName: actorDisplayName,
|
||||
missionId: mission.id,
|
||||
iterationId: iteration.id,
|
||||
rationale: step.summary || iteration.summary || mission.objective,
|
||||
});
|
||||
|
||||
// createProposal runs through Dexie hooks under the AI actor — the
|
||||
// row lands in `pendingProposals` and the AiProposalInbox renders
|
||||
// it as a ghost card on the relevant module page.
|
||||
const proposal = await runAsAsync(actor, () =>
|
||||
createProposal({
|
||||
actor,
|
||||
intent: {
|
||||
kind: 'toolCall',
|
||||
toolName: intent.toolName,
|
||||
params: intent.params,
|
||||
},
|
||||
rationale: actor.rationale,
|
||||
})
|
||||
);
|
||||
stagedStepIds[step.id] = proposal.id;
|
||||
}
|
||||
|
||||
if (Object.keys(stagedStepIds).length === 0) return;
|
||||
|
||||
// Write proposalIds back onto the iteration's plan[] so the Workbench
|
||||
// UI links each step to its proposal AND so other tabs skip re-staging.
|
||||
const updatedIterations: MissionIteration[] = fresh.iterations.map((it) => {
|
||||
if (it.id !== iteration.id) return it;
|
||||
const updatedPlan: PlanStep[] = it.plan.map((s) =>
|
||||
stagedStepIds[s.id] ? { ...s, proposalId: stagedStepIds[s.id], status: 'staged' as const } : s
|
||||
);
|
||||
return { ...it, plan: updatedPlan };
|
||||
});
|
||||
await db.table(MISSIONS_TABLE).update(fresh.id, {
|
||||
iterations: updatedIterations,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
/**
|
||||
* Webapp-local re-export of Mission types from @mana/shared-ai plus
|
||||
* storage-layer concerns (Dexie table name, proposal-status bridge).
|
||||
* storage-layer concerns (Dexie table name).
|
||||
*
|
||||
* The runtime types themselves live in the shared package so the
|
||||
* mana-ai Bun service parses identical rows.
|
||||
*/
|
||||
|
||||
import type { ProposalStatus } from '../proposals/types';
|
||||
import type { PlanStep } from '@mana/shared-ai';
|
||||
|
||||
export type {
|
||||
Mission,
|
||||
MissionCadence,
|
||||
|
|
@ -21,17 +18,3 @@ export type {
|
|||
} from '@mana/shared-ai';
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
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 { registerTools } from '../../tools/registry';
|
||||
import { createProposal } from './store';
|
||||
import { PROPOSALS_TABLE } from './types';
|
||||
import { makeAgentActor, LEGACY_AI_PRINCIPAL, type AiActor } from '../../events/actor';
|
||||
|
||||
// Register two tools in distinct modules so the `module` filter has
|
||||
// something to discriminate against.
|
||||
registerTools([
|
||||
{
|
||||
name: 'queries_test_todo_op',
|
||||
module: 'todo',
|
||||
description: 'd',
|
||||
parameters: [],
|
||||
async execute() {
|
||||
return { success: true, message: 'ok' };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'queries_test_calendar_op',
|
||||
module: 'calendar',
|
||||
description: 'd',
|
||||
parameters: [],
|
||||
async execute() {
|
||||
return { success: true, message: 'ok' };
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const AI: AiActor = makeAgentActor({
|
||||
agentId: LEGACY_AI_PRINCIPAL,
|
||||
displayName: 'Mana',
|
||||
missionId: 'm-a',
|
||||
iterationId: 'i-a',
|
||||
rationale: 'r',
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.table(PROPOSALS_TABLE).clear();
|
||||
});
|
||||
|
||||
describe('proposal filters (logic used by useAiProposals)', () => {
|
||||
it('filters by module via the tool registry lookup', async () => {
|
||||
await createProposal({
|
||||
actor: AI,
|
||||
intent: { kind: 'toolCall', toolName: 'queries_test_todo_op', params: {} },
|
||||
rationale: 'r',
|
||||
});
|
||||
await createProposal({
|
||||
actor: AI,
|
||||
intent: { kind: 'toolCall', toolName: 'queries_test_calendar_op', params: {} },
|
||||
rationale: 'r',
|
||||
});
|
||||
|
||||
// Replicate the filter logic used inside the live query
|
||||
const { getTool } = await import('../../tools/registry');
|
||||
const all = await db.table(PROPOSALS_TABLE).toArray();
|
||||
const todoOnly = all.filter((p) => {
|
||||
if (p.intent.kind !== 'toolCall') return false;
|
||||
const tool = getTool(p.intent.toolName);
|
||||
return tool?.module === 'todo';
|
||||
});
|
||||
expect(todoOnly).toHaveLength(1);
|
||||
expect(todoOnly[0].intent.toolName).toBe('queries_test_todo_op');
|
||||
});
|
||||
|
||||
it('filters by missionId', async () => {
|
||||
await createProposal({
|
||||
actor: AI,
|
||||
intent: { kind: 'toolCall', toolName: 'queries_test_todo_op', params: {} },
|
||||
rationale: 'r',
|
||||
});
|
||||
await createProposal({
|
||||
actor: { ...AI, missionId: 'm-b' },
|
||||
intent: { kind: 'toolCall', toolName: 'queries_test_todo_op', params: {} },
|
||||
rationale: 'r',
|
||||
});
|
||||
|
||||
const all = await db.table(PROPOSALS_TABLE).toArray();
|
||||
const onlyA = all.filter((p) => p.missionId === 'm-a');
|
||||
expect(onlyA).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
/**
|
||||
* Reactive queries over the `pendingProposals` Dexie table.
|
||||
*
|
||||
* Used by the AI workbench UI and by per-module proposal inboxes so each
|
||||
* module can render the AI's staged intents inline.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '../../database';
|
||||
import { getTool } from '../../tools/registry';
|
||||
import type { Proposal, ProposalStatus } from './types';
|
||||
import { PROPOSALS_TABLE } from './types';
|
||||
|
||||
export interface UseProposalsOptions {
|
||||
/** Filter by lifecycle state. Defaults to `'pending'`. */
|
||||
status?: ProposalStatus;
|
||||
/** Filter to proposals whose intent targets tools in this module. */
|
||||
module?: string;
|
||||
/** Filter to a specific mission. */
|
||||
missionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Svelte 5 live query returning proposals matching the given filter.
|
||||
* Re-runs whenever `pendingProposals` changes.
|
||||
*/
|
||||
export function useAiProposals(options: UseProposalsOptions = {}) {
|
||||
const { status = 'pending', module, missionId } = options;
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const all = await db.table<Proposal>(PROPOSALS_TABLE).orderBy('createdAt').reverse().toArray();
|
||||
return all.filter((p) => {
|
||||
if (p.status !== status) return false;
|
||||
if (missionId && p.missionId !== missionId) return false;
|
||||
if (module) {
|
||||
if (p.intent.kind !== 'toolCall') return false;
|
||||
const tool = getTool(p.intent.toolName);
|
||||
if (!tool || tool.module !== module) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [] as Proposal[]);
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
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 { registerTools } from '../../tools/registry';
|
||||
import {
|
||||
createProposal,
|
||||
listProposals,
|
||||
approveProposal,
|
||||
rejectProposal,
|
||||
expireOldProposals,
|
||||
getProposal,
|
||||
} from './store';
|
||||
import { PROPOSALS_TABLE } from './types';
|
||||
import { makeAgentActor, LEGACY_AI_PRINCIPAL, type AiActor } from '../../events/actor';
|
||||
|
||||
const AI: AiActor = makeAgentActor({
|
||||
agentId: LEGACY_AI_PRINCIPAL,
|
||||
displayName: 'Mana',
|
||||
missionId: 'mission-1',
|
||||
iterationId: 'iter-1',
|
||||
rationale: 'test run',
|
||||
});
|
||||
|
||||
let executed: { name: string; params: Record<string, unknown> }[] = [];
|
||||
|
||||
registerTools([
|
||||
{
|
||||
name: 'proposal_test_echo',
|
||||
module: 'proposalTest',
|
||||
description: 'Records invocation for assertions',
|
||||
parameters: [{ name: 'value', type: 'string', description: 'v', required: true }],
|
||||
async execute(params) {
|
||||
executed.push({ name: 'proposal_test_echo', params: { ...params } });
|
||||
return { success: true, message: `echo ${params.value}` };
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
beforeEach(async () => {
|
||||
executed = [];
|
||||
await db.table(PROPOSALS_TABLE).clear();
|
||||
});
|
||||
|
||||
describe('proposal lifecycle', () => {
|
||||
it('creates a pending proposal', async () => {
|
||||
const p = await createProposal({
|
||||
actor: AI,
|
||||
intent: { kind: 'toolCall', toolName: 'proposal_test_echo', params: { value: 'a' } },
|
||||
rationale: 'because',
|
||||
});
|
||||
expect(p.status).toBe('pending');
|
||||
expect(p.missionId).toBe('mission-1');
|
||||
expect(p.rationale).toBe('because');
|
||||
expect(await getProposal(p.id)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('lists pending proposals by filter', async () => {
|
||||
await createProposal({
|
||||
actor: AI,
|
||||
intent: { kind: 'toolCall', toolName: 'proposal_test_echo', params: { value: 'a' } },
|
||||
rationale: 'r',
|
||||
});
|
||||
await createProposal({
|
||||
actor: { ...AI, missionId: 'mission-2' },
|
||||
intent: { kind: 'toolCall', toolName: 'proposal_test_echo', params: { value: 'b' } },
|
||||
rationale: 'r',
|
||||
});
|
||||
const all = await listProposals({ status: 'pending' });
|
||||
expect(all).toHaveLength(2);
|
||||
const m2 = await listProposals({ missionId: 'mission-2' });
|
||||
expect(m2).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('approving runs the intent and marks the proposal approved', async () => {
|
||||
const p = await createProposal({
|
||||
actor: AI,
|
||||
intent: { kind: 'toolCall', toolName: 'proposal_test_echo', params: { value: 'go' } },
|
||||
rationale: 'r',
|
||||
});
|
||||
const { proposal, result } = await approveProposal(p.id, 'looks good');
|
||||
expect(result.success).toBe(true);
|
||||
expect(executed).toEqual([{ name: 'proposal_test_echo', params: { value: 'go' } }]);
|
||||
expect(proposal.status).toBe('approved');
|
||||
expect(proposal.userFeedback).toBe('looks good');
|
||||
|
||||
const persisted = await getProposal(p.id);
|
||||
expect(persisted?.status).toBe('approved');
|
||||
expect(persisted?.decidedBy).toBe('user');
|
||||
});
|
||||
|
||||
it('rejecting stores feedback and does not execute the intent', async () => {
|
||||
const p = await createProposal({
|
||||
actor: AI,
|
||||
intent: { kind: 'toolCall', toolName: 'proposal_test_echo', params: { value: 'x' } },
|
||||
rationale: 'r',
|
||||
});
|
||||
await rejectProposal(p.id, 'not now');
|
||||
const persisted = await getProposal(p.id);
|
||||
expect(persisted?.status).toBe('rejected');
|
||||
expect(persisted?.userFeedback).toBe('not now');
|
||||
expect(executed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses to approve a non-pending proposal', async () => {
|
||||
const p = await createProposal({
|
||||
actor: AI,
|
||||
intent: { kind: 'toolCall', toolName: 'proposal_test_echo', params: { value: 'x' } },
|
||||
rationale: 'r',
|
||||
});
|
||||
await rejectProposal(p.id);
|
||||
await expect(approveProposal(p.id)).rejects.toThrow(/rejected/);
|
||||
});
|
||||
|
||||
it('expires proposals past their expiresAt', async () => {
|
||||
await createProposal({
|
||||
actor: AI,
|
||||
intent: { kind: 'toolCall', toolName: 'proposal_test_echo', params: { value: 'x' } },
|
||||
rationale: 'r',
|
||||
expiresAt: '2020-01-01T00:00:00.000Z',
|
||||
});
|
||||
await createProposal({
|
||||
actor: AI,
|
||||
intent: { kind: 'toolCall', toolName: 'proposal_test_echo', params: { value: 'y' } },
|
||||
rationale: 'r',
|
||||
expiresAt: '2099-01-01T00:00:00.000Z',
|
||||
});
|
||||
const count = await expireOldProposals(new Date('2026-04-14T00:00:00.000Z'));
|
||||
expect(count).toBe(1);
|
||||
const pending = await listProposals({ status: 'pending' });
|
||||
expect(pending).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
/**
|
||||
* Proposal store — create, list, approve, reject, expire.
|
||||
*
|
||||
* Approval re-runs the tool the AI originally called, but this time forces
|
||||
* the executor into `auto` mode so the stored intent can't bounce back into
|
||||
* another proposal. Rejection just marks the row — the AI sees the feedback
|
||||
* on the next planner pass via `listProposals({ status: 'rejected' })`.
|
||||
*/
|
||||
|
||||
import { db } from '../../database';
|
||||
import { runAsAsync } from '../../events/actor';
|
||||
import type { Actor } from '../../events/actor';
|
||||
import type { ToolResult } from '../../tools/types';
|
||||
import type { Intent, Proposal, ProposalStatus } from './types';
|
||||
import { PROPOSALS_TABLE } from './types';
|
||||
|
||||
const table = () => db.table<Proposal>(PROPOSALS_TABLE);
|
||||
|
||||
export interface CreateProposalInput {
|
||||
actor: Extract<Actor, { kind: 'ai' }>;
|
||||
intent: Intent;
|
||||
rationale: string;
|
||||
/** ISO timestamp. Falsy → no auto-expiry. */
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export async function createProposal(input: CreateProposalInput): Promise<Proposal> {
|
||||
const proposal: Proposal = {
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: input.expiresAt,
|
||||
status: 'pending',
|
||||
actor: input.actor,
|
||||
missionId: input.actor.missionId,
|
||||
iterationId: input.actor.iterationId,
|
||||
rationale: input.rationale,
|
||||
intent: input.intent,
|
||||
};
|
||||
await table().add(proposal);
|
||||
return proposal;
|
||||
}
|
||||
|
||||
export async function getProposal(id: string): Promise<Proposal | undefined> {
|
||||
return table().get(id);
|
||||
}
|
||||
|
||||
export interface ListProposalsFilter {
|
||||
status?: ProposalStatus;
|
||||
missionId?: string;
|
||||
}
|
||||
|
||||
export async function listProposals(filter: ListProposalsFilter = {}): Promise<Proposal[]> {
|
||||
let coll = table().orderBy('createdAt').reverse();
|
||||
if (filter.status) coll = coll.filter((p) => p.status === filter.status);
|
||||
if (filter.missionId) coll = coll.filter((p) => p.missionId === filter.missionId);
|
||||
return coll.toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a pending proposal. Runs the stored intent with the AI actor
|
||||
* re-installed so downstream events and records carry the original
|
||||
* mission/iteration attribution — critical for the Workbench timeline.
|
||||
*
|
||||
* The executor is forced into `auto` by construction: the approved
|
||||
* `executeTool` call re-reads policy, which would again say `propose` —
|
||||
* so we bypass policy by calling the tool implementation directly under
|
||||
* the `ai` actor instead of routing through the policy-gated executor.
|
||||
*/
|
||||
export async function approveProposal(
|
||||
id: string,
|
||||
userFeedback?: string
|
||||
): Promise<{ proposal: Proposal; result: ToolResult }> {
|
||||
const proposal = await getProposal(id);
|
||||
if (!proposal) throw new Error(`Proposal not found: ${id}`);
|
||||
if (proposal.status !== 'pending') {
|
||||
throw new Error(`Proposal ${id} is ${proposal.status}, cannot approve`);
|
||||
}
|
||||
|
||||
const result = await runApprovedIntent(proposal);
|
||||
|
||||
const updated: Partial<Proposal> = {
|
||||
status: 'approved',
|
||||
decidedAt: new Date().toISOString(),
|
||||
decidedBy: 'user',
|
||||
userFeedback,
|
||||
};
|
||||
await table().update(id, updated);
|
||||
return { proposal: { ...proposal, ...updated }, result };
|
||||
}
|
||||
|
||||
export async function rejectProposal(id: string, userFeedback?: string): Promise<Proposal> {
|
||||
const proposal = await getProposal(id);
|
||||
if (!proposal) throw new Error(`Proposal not found: ${id}`);
|
||||
if (proposal.status !== 'pending') {
|
||||
throw new Error(`Proposal ${id} is ${proposal.status}, cannot reject`);
|
||||
}
|
||||
const updated: Partial<Proposal> = {
|
||||
status: 'rejected',
|
||||
decidedAt: new Date().toISOString(),
|
||||
decidedBy: 'user',
|
||||
userFeedback,
|
||||
};
|
||||
await table().update(id, updated);
|
||||
|
||||
// Bubble the feedback up to the Mission iteration so the next Planner
|
||||
// pass (which reads `mission.iterations[].userFeedback` — NOT
|
||||
// `pendingProposals.userFeedback`) can course-correct. Lazy-import
|
||||
// to avoid a cycle: mission store ↔ proposal store.
|
||||
if (userFeedback && proposal.missionId && proposal.iterationId) {
|
||||
try {
|
||||
const { addIterationFeedback, getMission } = await import('../missions/store');
|
||||
const mission = await getMission(proposal.missionId);
|
||||
// Merge with any existing feedback on the iteration — different
|
||||
// steps within one iteration can produce different reasons.
|
||||
const existingIt = mission?.iterations.find((it) => it.id === proposal.iterationId);
|
||||
const merged = existingIt?.userFeedback
|
||||
? `${existingIt.userFeedback}\n· ${userFeedback}`
|
||||
: userFeedback;
|
||||
await addIterationFeedback(proposal.missionId, proposal.iterationId, merged);
|
||||
} catch (err) {
|
||||
// Feedback bubble is best-effort — the proposal was still
|
||||
// rejected successfully if this fails.
|
||||
console.error('[rejectProposal] failed to bubble feedback to iteration:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...proposal, ...updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark any pending proposal whose `expiresAt` has passed as expired. Fire
|
||||
* this from a low-frequency tick (e.g. on app focus); cheap, indexed scan.
|
||||
*/
|
||||
export async function expireOldProposals(now: Date = new Date()): Promise<number> {
|
||||
const cutoff = now.toISOString();
|
||||
const stale = await table()
|
||||
.where('status')
|
||||
.equals('pending')
|
||||
.filter((p) => typeof p.expiresAt === 'string' && p.expiresAt < cutoff)
|
||||
.toArray();
|
||||
|
||||
for (const p of stale) {
|
||||
await table().update(p.id, {
|
||||
status: 'expired',
|
||||
decidedAt: cutoff,
|
||||
decidedBy: 'auto-expire',
|
||||
});
|
||||
}
|
||||
return stale.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the intent under the original AI actor, bypassing policy. The user
|
||||
* has consented via approval; re-entering the policy gate would bounce the
|
||||
* call straight back into a new proposal.
|
||||
*
|
||||
* The `executor` import is lazy: `tools/executor.ts` imports this file's
|
||||
* `createProposal`, so a top-level import here would form a cycle.
|
||||
*/
|
||||
async function runApprovedIntent(proposal: Proposal): Promise<ToolResult> {
|
||||
return runAsAsync(proposal.actor, async () => {
|
||||
if (proposal.intent.kind === 'toolCall') {
|
||||
const { executeToolRaw } = await import('../../tools/executor');
|
||||
return executeToolRaw(proposal.intent.toolName, proposal.intent.params);
|
||||
}
|
||||
throw new Error(`Unsupported intent kind: ${(proposal.intent as { kind: string }).kind}`);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
/**
|
||||
* Proposals — staged AI intents awaiting user approval.
|
||||
*
|
||||
* When an AI-attributed tool call hits a policy of `propose`, the executor
|
||||
* records a {@link Proposal} instead of performing the underlying mutation.
|
||||
* The proposal sits in the local `pendingProposals` Dexie table until the
|
||||
* user approves it (→ run the intent), rejects it, or it auto-expires.
|
||||
*
|
||||
* Proposals are intentionally local-only — they do not sync through
|
||||
* mana-sync. The approved mutation syncs normally once executed, so
|
||||
* other devices see the resulting write without ever seeing the proposal
|
||||
* state machine.
|
||||
*/
|
||||
|
||||
import type { Actor } from '../../events/actor';
|
||||
|
||||
/** Lifecycle states a proposal can be in. */
|
||||
export type ProposalStatus = 'pending' | 'approved' | 'rejected' | 'expired';
|
||||
|
||||
/**
|
||||
* Structured description of what the AI wants to happen if the proposal is
|
||||
* approved. Start with `toolCall` (execute the named tool with params) and
|
||||
* extend the union with `patch` / `create` variants once module UIs need
|
||||
* to render field-level diffs inline.
|
||||
*/
|
||||
export type Intent = ToolCallIntent;
|
||||
|
||||
export interface ToolCallIntent {
|
||||
readonly kind: 'toolCall';
|
||||
readonly toolName: string;
|
||||
readonly params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Proposal {
|
||||
readonly id: string;
|
||||
readonly createdAt: string;
|
||||
readonly expiresAt?: string;
|
||||
readonly status: ProposalStatus;
|
||||
|
||||
/**
|
||||
* The AI actor that submitted this proposal. Always `kind: 'ai'` by
|
||||
* construction — `resolvePolicy` never routes user/system writes here.
|
||||
*/
|
||||
readonly actor: Actor;
|
||||
/** Mirrors `actor.missionId` for index-based queries of "all proposals in mission X". */
|
||||
readonly missionId?: string;
|
||||
/** Mirrors `actor.iterationId`. */
|
||||
readonly iterationId?: string;
|
||||
/** The AI's stated reason for the change — surfaced in the approval UI. */
|
||||
readonly rationale: string;
|
||||
|
||||
/** What runs on approve. */
|
||||
readonly intent: Intent;
|
||||
|
||||
/** Set when the proposal leaves the `pending` state. */
|
||||
readonly decidedAt?: string;
|
||||
readonly decidedBy?: 'user' | 'auto-expire';
|
||||
/** Free-text feedback from the user, captured on approve or reject. */
|
||||
readonly userFeedback?: string;
|
||||
}
|
||||
|
||||
export const PROPOSALS_TABLE = 'pendingProposals';
|
||||
|
|
@ -651,6 +651,16 @@ db.version(28).upgrade(async (tx) => {
|
|||
}
|
||||
});
|
||||
|
||||
// v29 — Drop the legacy `pendingProposals` table. Proposals are no
|
||||
// longer created: the planner executes tool_calls directly under the
|
||||
// AI actor, and the Workbench Timeline plus per-iteration revert is
|
||||
// the review surface. Passing `null` to .stores() deletes the table on
|
||||
// open. Safe because the system hasn't shipped; no user data is lost.
|
||||
// See docs/plans/planner-function-calling.md.
|
||||
db.version(29).stores({
|
||||
pendingProposals: null,
|
||||
});
|
||||
|
||||
// ─── 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
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ vi.mock('$lib/triggers/inline-suggest', () => ({
|
|||
import { executeTool } from './executor';
|
||||
import { registerTools, getTools } from './registry';
|
||||
import { setAiPolicy } from '../ai/policy';
|
||||
import { listProposals, approveProposal } from '../ai/proposals/store';
|
||||
import { PROPOSALS_TABLE } from '../ai/proposals/types';
|
||||
import { db } from '../database';
|
||||
import { makeAgentActor, LEGACY_AI_PRINCIPAL, type Actor } from '../events/actor';
|
||||
import type { ModuleTool } from './types';
|
||||
|
||||
|
|
@ -125,45 +122,24 @@ describe('Tool Executor', () => {
|
|||
});
|
||||
|
||||
describe('Tool Executor — AI policy routing', () => {
|
||||
beforeEach(async () => {
|
||||
await db.table(PROPOSALS_TABLE).clear();
|
||||
});
|
||||
|
||||
it('runs a tool directly for user actors regardless of name', async () => {
|
||||
// test_echo has no policy entry — user default is always auto
|
||||
it('runs a tool directly for user actors', async () => {
|
||||
const result = await executeTool('test_echo', { text: 'hi' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('echo: hi');
|
||||
});
|
||||
|
||||
it('stages a proposal when ai actor hits a propose-policy tool', async () => {
|
||||
const restore = setAiPolicy({ tools: { test_echo: 'propose' }, defaultForAi: 'propose' });
|
||||
try {
|
||||
const result = await executeTool('test_echo', { text: 'stage-me' }, AI);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toMatch(/Vorgeschlagen/);
|
||||
expect((result.data as { proposalId: string }).proposalId).toBeTruthy();
|
||||
|
||||
// Tool did NOT run — it was staged
|
||||
const pending = await listProposals({ status: 'pending' });
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].rationale).toBe('because');
|
||||
expect(pending[0].missionId).toBe('m-1');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('runs directly for ai actor when policy says auto', async () => {
|
||||
const restore = setAiPolicy({ tools: { test_echo: 'auto' }, defaultForAi: 'propose' });
|
||||
try {
|
||||
const result = await executeTool('test_echo', { text: 'direct' }, AI);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('echo: direct');
|
||||
const pending = await listProposals({ status: 'pending' });
|
||||
expect(pending).toHaveLength(0);
|
||||
} finally {
|
||||
restore();
|
||||
it('executes directly for AI actors when policy allows (auto or propose)', async () => {
|
||||
// Post-migration: both auto and propose execute inline — the
|
||||
// proposal gate is gone. Only 'deny' refuses.
|
||||
for (const policy of ['auto', 'propose'] as const) {
|
||||
const restore = setAiPolicy({ tools: { test_echo: policy }, defaultForAi: 'propose' });
|
||||
try {
|
||||
const result = await executeTool('test_echo', { text: `via-${policy}` }, AI);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe(`echo: via-${policy}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -178,29 +154,12 @@ describe('Tool Executor — AI policy routing', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('approval runs the staged intent with original actor attribution', async () => {
|
||||
const restore = setAiPolicy({ tools: { test_echo: 'propose' }, defaultForAi: 'propose' });
|
||||
try {
|
||||
const staged = await executeTool('test_echo', { text: 'approved' }, AI);
|
||||
const proposalId = (staged.data as { proposalId: string }).proposalId;
|
||||
|
||||
const { result, proposal } = await approveProposal(proposalId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('echo: approved');
|
||||
expect(proposal.status).toBe('approved');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('still validates parameters before staging a proposal', async () => {
|
||||
it('validates parameters before executing regardless of actor', async () => {
|
||||
const restore = setAiPolicy({ tools: { test_echo: 'propose' }, defaultForAi: 'propose' });
|
||||
try {
|
||||
const result = await executeTool('test_echo', {}, AI);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Missing required parameter');
|
||||
const pending = await listProposals({ status: 'pending' });
|
||||
expect(pending).toHaveLength(0);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
/**
|
||||
* aiPlanTask — LLM task for the Mission Planner.
|
||||
*
|
||||
* Takes a Mission plus pre-resolved inputs + available tools, asks the
|
||||
* configured LLM backend for a structured plan, parses it, and returns
|
||||
* typed steps the Runner turns into Proposals.
|
||||
*
|
||||
* Routing:
|
||||
* - `minTier: 'browser'` — the Planner runs entirely on the device by
|
||||
* default. Users can override to mana-server / cloud in settings for
|
||||
* more capable reasoning on long missions.
|
||||
* - `contentClass: 'personal'` — the prompt contains the user's notes and
|
||||
* goals. If any linked input is from a strictly-sensitive module
|
||||
* (journal, dreams, finance), the Runner is responsible for narrowing
|
||||
* to `'sensitive'` on the request so cloud is refused.
|
||||
*
|
||||
* Error path: the parser returns a structured `ParseResult`. If parsing
|
||||
* fails, the task still returns — with `steps: []` and a summary
|
||||
* explaining why — so the Runner can record a failed iteration without
|
||||
* throwing through the whole mission queue.
|
||||
*/
|
||||
|
||||
import type { LlmBackend, LlmTask } from '@mana/shared-llm';
|
||||
import { buildPlannerPrompt } from '$lib/data/ai/missions/planner/prompt';
|
||||
import { parsePlannerResponse } from '$lib/data/ai/missions/planner/parser';
|
||||
import type { AiPlanInput, AiPlanOutput } from '$lib/data/ai/missions/planner/types';
|
||||
|
||||
export type { AiPlanInput, AiPlanOutput } from '$lib/data/ai/missions/planner/types';
|
||||
|
||||
export const aiPlanTask: LlmTask<AiPlanInput, AiPlanOutput> = {
|
||||
name: 'ai.plan',
|
||||
minTier: 'browser',
|
||||
contentClass: 'personal',
|
||||
requires: { streaming: false },
|
||||
displayLabel: 'AI Mission Planner',
|
||||
|
||||
async runLlm(input: AiPlanInput, backend: LlmBackend): Promise<AiPlanOutput> {
|
||||
const { system, user } = buildPlannerPrompt(input);
|
||||
|
||||
const result = await backend.generate({
|
||||
taskName: 'ai.plan',
|
||||
contentClass: 'personal',
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
temperature: 0.3,
|
||||
// 1024 truncates mid-response when the planner proposes 3+ steps with
|
||||
// rich rationales — the reasoning loop amplifies this because a
|
||||
// single round can legitimately stage one step per listed item
|
||||
// (e.g. 10 notes → 10 add_tag_to_note calls). 4096 fits ~15-20
|
||||
// step objects while still fast on browser tier.
|
||||
maxTokens: 4096,
|
||||
onToken: input.onToken,
|
||||
});
|
||||
|
||||
// Always populate debug payload (cheap — strings already in memory).
|
||||
// The runner decides whether to persist it based on the user's
|
||||
// localStorage `mana.ai.debug` toggle.
|
||||
const debug = {
|
||||
systemPrompt: system,
|
||||
userPrompt: user,
|
||||
rawResponse: result.content,
|
||||
latencyMs: result.latencyMs,
|
||||
};
|
||||
|
||||
const knownToolNames = new Set(input.availableTools.map((t) => t.name));
|
||||
const parsed = parsePlannerResponse(result.content, knownToolNames);
|
||||
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
steps: [],
|
||||
summary: `Plan konnte nicht erzeugt werden: ${parsed.reason}`,
|
||||
debug,
|
||||
};
|
||||
}
|
||||
return { ...parsed.value, debug };
|
||||
},
|
||||
};
|
||||
|
|
@ -8,10 +8,6 @@
|
|||
import { todoReminderSource } from '$lib/modules/todo/reminder-source';
|
||||
import { startEventStore, stopEventStore } from '$lib/data/events/event-store';
|
||||
import { startMissionTick, stopMissionTick } from '$lib/data/ai/missions/setup';
|
||||
import {
|
||||
startServerIterationStaging,
|
||||
stopServerIterationStaging,
|
||||
} from '$lib/data/ai/missions/server-iteration-staging';
|
||||
import { initTools } from '$lib/data/tools/init';
|
||||
import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge';
|
||||
import { startStreakTracker, stopStreakTracker } from '$lib/data/projections/streaks';
|
||||
|
|
@ -539,11 +535,6 @@
|
|||
// interval and runs any that are due. Safe idempotent; see
|
||||
// data/ai/missions/setup.ts.
|
||||
startMissionTick();
|
||||
// Staging-effect: subscribes to Mission updates and translates
|
||||
// server-produced iterations (source='server') into local
|
||||
// Proposals. Essential once the mana-ai service is running
|
||||
// alongside; no-op when only the foreground tick is active.
|
||||
startServerIterationStaging();
|
||||
});
|
||||
|
||||
// Restore nav collapsed state (cheap, keep inline)
|
||||
|
|
@ -639,7 +630,6 @@
|
|||
stopStreakTracker();
|
||||
stopGoalTracker();
|
||||
stopMissionTick();
|
||||
stopServerIterationStaging();
|
||||
guestMode?.destroy();
|
||||
// Fire-and-forget — we don't need to await; the in-flight task
|
||||
// will finish in the background and the next page session will
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue