diff --git a/.github/workflows/ticktick-live-smoke.yml b/.github/workflows/ticktick-live-smoke.yml new file mode 100644 index 00000000..19707e93 --- /dev/null +++ b/.github/workflows/ticktick-live-smoke.yml @@ -0,0 +1,51 @@ +name: ticktick-live-smoke + +# The plan's credentialed LIVE contract validation (docs/plans/ +# external-task-views-plan.md): deterministic tests use hand-authored wire +# values, so upstream TickTick schema/tool drift stays green locally — this job +# is the drift detector. Scheduled weekly + manually dispatchable; deliberately +# NOT part of the PR gate (needs a repo secret; must not hammer the live API). +# +# Two lanes, each through its production surface (review R10 #3): +# - OpenAPI (Web lane): the `#[ignore]`d Rust test `live_openapi_contract_smoke` +# runs the REAL decoder (reqwest → wire.rs → normalize) and asserts +# representative-shape counters (the smoke account stages due/tags/checklist). +# - MCP (Worker lane): scripts/ticktick-live-smoke.mjs asserts initialize + +# tools/list still offer the exact read allowlist. +# Both are non-capturing: counts/booleans only reach the log. + +on: + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + smoke: + name: live-contract-smoke + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: "." + shared-key: core + - name: Smoke the OpenAPI lane through the production decoder + env: + TICKTICK_ACCESS_TOKEN: ${{ secrets.TICKTICK_ACCESS_TOKEN }} + run: > + cargo test --manifest-path crates/core/Cargo.toml + live_openapi_contract_smoke -- --ignored --nocapture + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Smoke the MCP lane (initialize + tools/list allowlist) + env: + TICKTICK_ACCESS_TOKEN: ${{ secrets.TICKTICK_ACCESS_TOKEN }} + run: node scripts/ticktick-live-smoke.mjs diff --git a/Cargo.lock b/Cargo.lock index 2e1c5715..fe1bb4c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,6 +186,7 @@ dependencies = [ "axum", "base64", "futures-util", + "libc", "reqwest", "rust-embed", "schemars", @@ -203,6 +204,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "url", "uuid", ] diff --git a/apps/web/src/components/ToolActivity.tsx b/apps/web/src/components/ToolActivity.tsx index fca6a759..442b44ab 100644 --- a/apps/web/src/components/ToolActivity.tsx +++ b/apps/web/src/components/ToolActivity.tsx @@ -1,12 +1,16 @@ +import { EXTERNAL_TOOL_PREFIX, isExternalToolName } from "@inkstone/protocol"; import { AlertTriangle, BookOpen, Check, + ChevronRight, + ListChecks, type LucideIcon, Search, Sparkles, Wrench, } from "lucide-react"; +import { useState } from "react"; import { cn } from "@/lib/utils.js"; import type { ToolCall } from "@/store/chat"; @@ -47,6 +51,12 @@ function humanize(name: string): string { function presentation(name: string): ToolPresentation { const known = TOOL_PRESENTATION[name]; if (known) return known; + if (isExternalToolName(name)) { + // An external (Worker-executed MCP) tool: label it by its TickTick verb + // (external-task-views A3/A4) — read-only by the dual allowlist. + const label = `TickTick · ${name.slice(EXTERNAL_TOOL_PREFIX.length).replace(/[_-]+/g, " ")}`; + return { active: label, done: label, Icon: ListChecks, access: "read" }; + } const label = humanize(name); return { active: label, done: label, Icon: Wrench }; } @@ -58,21 +68,26 @@ const MAX_VISIBLE_ARGS = 3; * into one row, except errored calls — each is its own group so the failed arg * is never buried in a survivors' row. `status` is the aggregate (running if any * member is in flight). `args` is capped at {@link MAX_VISIBLE_ARGS}; `overflow` - * is how many more were folded away. `key` is stable across renders. */ + * is how many more were folded away. `key` is stable across renders. `call` is + * set ONLY for external (`ticktick_*`) break-outs (external-task-views A4): the + * single call the row represents, whose `result` the expansion reveals. */ export type ToolCallGroup = { key: string; name: string; status: ToolCall["status"]; args: string[]; overflow: number; + call?: ToolCall; }; /** Collapse a turn's tool calls into grouped rows (ADR-0043). Non-errored calls * of the same tool merge (args deduped + joined, in first-seen order, status - * running-if-any); each errored call breaks out into its own row. Groups are - * ordered by first occurrence; a tool's errored break-out sorts at the position - * of its first errored call. Shared by the live and rehydrated paths, so both - * render identically. */ + * running-if-any); each errored call breaks out into its own row. EXTERNAL + * (`ticktick_*`) calls NEVER group (external-task-views A4): one expandable row + * per call, keyed by `tool_call_id`, so two same-name calls keep their distinct + * results. Groups are ordered by first occurrence; a tool's errored break-out + * sorts at the position of its first errored call. Shared by the live and + * rehydrated paths, so both render identically. */ export function groupToolCalls( toolCalls: readonly ToolCall[], ): ToolCallGroup[] { @@ -83,6 +98,20 @@ export function groupToolCalls( for (const call of toolCalls) { const arg = call.arg?.trim() ? call.arg.trim() : undefined; + // External calls never merge (A4): per-call identity is the point — the + // row expands to THIS call's model-received result. + if (isExternalToolName(call.name)) { + groups.push({ + key: call.id, + name: call.name, + status: call.status, + args: [], + overflow: 0, + call, + }); + continue; + } + // Errored calls never merge — each is its own row showing the failed arg. if (call.status === "error") { groups.push({ @@ -140,13 +169,109 @@ export function ToolActivity({ aria-live="polite" className="flex w-full flex-col gap-1.5" > - {groups.map((group) => ( - - ))} + {groups.map((group) => + group.call === undefined ? ( + + ) : ( + + ), + )} ); } +/** One EXTERNAL (`ticktick_*`) call (external-task-views A4): a collapsed + * name + status row that expands on demand to the normalized + * `TranscriptToolResult.content` the model received — errors identically + * (collapsed error row → expanded error content). Never grouped; never shows + * credentials or raw MCP metadata (the result IS the normalized content). */ +function ExternalToolRow({ call }: { call: ToolCall }) { + const [expanded, setExpanded] = useState(false); + const { active, done, Icon } = presentation(call.name); + const running = call.status === "running"; + const errored = call.status === "error"; + const label = running ? active : done; + const contentText = (call.result?.content ?? []) + .map((block) => block.text) + .join("\n"); + const expandable = !running && call.result !== undefined; + + const srText = running + ? `${active}, read-only, in progress` + : errored + ? `${done} failed` + : `${done}, read-only, done`; + + return ( +
  • + + {expanded && expandable && ( +
    +					{contentText}
    +				
    + )} +
  • + ); +} + function ToolCallRow({ group }: { group: ToolCallGroup }) { const { active, done, Icon, access } = presentation(group.name); const running = group.status === "running"; diff --git a/apps/web/src/components/library/TasksView.tsx b/apps/web/src/components/library/TasksView.tsx new file mode 100644 index 00000000..060ec00c --- /dev/null +++ b/apps/web/src/components/library/TasksView.tsx @@ -0,0 +1,220 @@ +import type { TickTickTaskRow } from "@inkstone/protocol"; +import { AlertTriangle, ListTodo, RefreshCw } from "lucide-react"; +import { useState } from "react"; +import { cn } from "@/lib/utils.js"; + +// The Web Tasks surface (external-task-views A2/S2). Presentational — the route +// owns the `useTickTick` hook (status-first + reconnect protocol) and passes +// the resolved view here, so this renders without a runtime and is unit-tested +// directly. NOT linked in nav (dev flag): reachable only at /library/tasks. + +/** A task's due tuple rendered for display: an all-day date, or the timed + * instant with its zone (S1a: one due tuple, never a bare instant). BOTH use + * `due.time_zone` — an all-day date rendered in UTC would show the previous + * calendar day for a positive-offset zone (e.g. Asia/Shanghai), so the local + * zone is the only correct frame. */ +function DueLabel({ due }: { due: NonNullable }) { + // TickTick's wire values are looser than Intl/Date accept (CodeRabbit #336): + // offsets come as non-ISO `+0000` (normalize to `+00:00` for `Date`) and the + // zone can be absent/empty (pass `undefined`, not `""` — an empty zone throws). + const date = new Date(due.date.replace(/([+-]\d{2})(\d{2})$/, "$1:$2")); + const timeZone = due.time_zone || undefined; + const text = due.is_all_day + ? date.toLocaleDateString(undefined, { timeZone }) + : date.toLocaleString(undefined, { timeZone }); + return {text}; +} + +function TaskRow({ task }: { task: TickTickTaskRow }) { + const done = task.checklist_items.filter((i) => i.done).length; + return ( +
  • + + {task.title} + + {task.checklist_items.length > 0 && ( + + {done}/{task.checklist_items.length} + + )} + {task.tags.map((tag) => ( + + {tag} + + ))} + + {task.list_name ?? "unnamed list"} + + {task.due && } +
  • + ); +} + +export interface TasksViewProps { + readonly connected: boolean; + readonly statusResolved: boolean; + readonly statusError: boolean; + readonly rows: readonly TickTickTaskRow[]; + readonly sourceLimitReached: boolean; + /** A first task read that failed with NO rows to show → the error state. */ + readonly tasksInitialError: boolean; + /** A background refetch failed but the last-good rows are still held → keep + * rendering them with a stale indicator (A2 failure semantics). */ + readonly tasksStaleError: boolean; + readonly tasksLoading: boolean; + /** Manual refresh (A2, review R12 #4): re-resolves status + re-reads tasks. + * Doubles as the retry affordance on the error states. */ + readonly refresh: () => void; + readonly refreshing: boolean; +} + +/** The Tasks surface body. Renders the not-connected / status-error / + * initial-error / loading states, the truncation warning when TickTick returned + * its 200-item ceiling, a STALE indicator when a background refetch failed (the + * rows are still shown), and the rows — with a LOCAL title filter (A2: any + * filtering the UI offers is display-only over the one fetched result). */ +export function TasksView({ + connected, + statusResolved, + statusError, + rows, + sourceLimitReached, + tasksInitialError, + tasksStaleError, + tasksLoading, + refresh, + refreshing, +}: TasksViewProps) { + const [filter, setFilter] = useState(""); + + const retry = ( + + ); + + // A status READ that failed (couldn't reach Core) is an error — distinct from + // a resolved `not_connected` state and from an empty task list. + if (statusError) { + return ( +
    + Couldn't reach TickTick. Check that Inkstone is running, then retry. + {retry} +
    + ); + } + if (statusResolved && !connected) { + return ( +
    + TickTick is not connected. Provision a credential file and restart + Inkstone. +
    + ); + } + // A FIRST task read that failed with nothing cached → the error state. A + // failed BACKGROUND refetch (rows present) falls through and keeps the rows. + if (tasksInitialError) { + return ( +
    + Couldn't reach TickTick. Check that Inkstone is running, then retry. + {retry} +
    + ); + } + + const needle = filter.trim().toLowerCase(); + const visible = needle + ? rows.filter((t) => t.title.toLowerCase().includes(needle)) + : rows; + + return ( +
    +
    + +

    Tasks

    + +
    + + setFilter(e.target.value)} + className="w-full max-w-sm rounded-lg border border-secondary/50 bg-transparent px-3 py-1.5 text-sm" + /> + + {sourceLimitReached && ( +
    + + TickTick returned its 200-item limit; this view may be incomplete. +
    + )} + + {tasksStaleError && ( +
    + + Couldn't refresh from TickTick; showing the last-loaded tasks. +
    + )} + + {tasksLoading || !statusResolved ? ( + // `!statusResolved` here is the STATUS-PENDING phase (status-error and + // resolved-disconnected are handled above): show loading, never a false + // "No tasks." while the first status read is still in flight (review M5). +

    Loading tasks…

    + ) : visible.length === 0 ? ( +

    No tasks.

    + ) : ( +
      + {visible.map((task) => ( + + ))} +
    + )} +
    + ); +} diff --git a/apps/web/src/lib/hooks/useTickTick.ts b/apps/web/src/lib/hooks/useTickTick.ts new file mode 100644 index 00000000..3a2b1cb5 --- /dev/null +++ b/apps/web/src/lib/hooks/useTickTick.ts @@ -0,0 +1,176 @@ +import type { + TickTickStatusResult, + TickTickTaskRow, + TickTickTasksListResult, +} from "@inkstone/protocol"; +import { type ConnectionStatus, WsClient } from "@inkstone/ui-sdk"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Effect, Fiber, Stream } from "effect"; +import { useCallback, useEffect, useState } from "react"; +import { useRuntime } from "@/runtime"; + +// The Web lane's TanStack integration (external-task-views A2). The connection +// ID is the SOLE task-query key: the fixed Core read (`{"status":[0]}` + kind +// filtering) means one task query per connection, and any list/tag/date +// filtering the Tasks UI offers is display-only, applied locally over that one +// result. The reconnect purge is app-lifetime in `TickTickReconnectSync` (F2); +// the hook here only reads. + +/** `["ticktick","tasks",]` — the task query key. The id alone + * keys it; a restart mints a new id (A5), so account B's rows can never land + * under account A's key. */ +const tasksKey = (connectionId: string) => + ["ticktick", "tasks", connectionId] as const; + +const TASKS_KEY_PREFIX = ["ticktick", "tasks"] as const; +const STATUS_KEY = ["ticktick", "status"] as const; + +/** Split a failed task read into an INITIAL failure (no successful fetch yet — + * `data` is `undefined`) vs. a STALE-refetch failure (a prior fetch's data, + * possibly an empty list, is retained by TanStack through the error). Keyed on + * `data === undefined`, NOT row count (review #4): a successful fetch of a + * genuinely empty account retains `{tasks: []}`, so its later refetch failure is + * STALE — keeping the empty view with an error hint — never a full initial-error + * screen. */ +export function classifyTasksError( + isError: boolean, + data: TickTickTasksListResult | undefined, +): { tasksInitialError: boolean; tasksStaleError: boolean } { + return { + tasksInitialError: isError && data === undefined, + tasksStaleError: isError && data !== undefined, + }; +} + +/** App-lifetime WS-reconnect sync for the Web Tasks lane (A2, review F2). It MUST + * mount at the app ROOT, not inside the route: a Core restart (account swap) can + * happen while the Tasks view is UNMOUNTED, and a route-local effect would miss + * it — the remount would then trust the infinitely-fresh stale status/tasks cache + * and render account A under connection B. On a real RECONNECT edge + * (`(reconnecting|disconnected) → connected`, NOT a mount's replayed `connected` + * — ADR-0051 `.changes`) it drops every cached task query and `resetQueries` + * status, so the next status read mints the fresh id and the task read re-keys to + * the current account. Renders nothing. */ +export function TickTickReconnectSync(): null { + const runtime = useRuntime(); + const queryClient = useQueryClient(); + + useEffect(() => { + const program = Effect.flatMap(WsClient, (client) => { + let prev: ConnectionStatus | undefined; + return Stream.runForEach(client.connectionStatus(), (state) => + Effect.sync(() => { + const reconnected = + prev !== undefined && prev !== "connected" && state === "connected"; + prev = state; + if (reconnected) { + queryClient.removeQueries({ queryKey: TASKS_KEY_PREFIX }); + void queryClient.resetQueries({ queryKey: STATUS_KEY }); + } + }), + ); + }); + const fiber = runtime.runFork(program); + return () => { + runtime.runFork(Fiber.interrupt(fiber)); + }; + }, [runtime, queryClient]); + + return null; +} + +/** The Web Tasks surface's data (A2). Status is its own query on the global + * staleTime; its `connection_id` is the SOLE task-query key. The reconnect purge + * lives app-lifetime in {@link TickTickReconnectSync} (a route-local one would + * miss a reconnect while Tasks is unmounted), so this hook only READS — a plain + * focus refetches the task list (60s staleTime), never status, and a plain mount + * purges nothing. After a reconnect reset, `connectionId` is `undefined` until the + * fresh id resolves, so the task read can never fire under a stale account's key. */ +export function useTickTick() { + const runtime = useRuntime(); + const queryClient = useQueryClient(); + const [manualRefreshing, setManualRefreshing] = useState(false); + + const readStatus = useCallback( + () => + runtime.runPromise( + Effect.flatMap(WsClient, (client) => client.tickTickStatus()), + ), + [runtime], + ); + const readTasks = useCallback( + () => + runtime.runPromise( + Effect.flatMap(WsClient, (client) => client.tickTickTasksList()), + ), + [runtime], + ); + + const status = useQuery({ + queryKey: STATUS_KEY, + // Uses the global `staleTime: Infinity` (main.tsx). The account can only + // change across a Core restart, and the WS `connected` transition below + // RESETS this query then — NOT every window focus (review M4). A plain + // focus must never refetch status and blank the Tasks surface. + queryFn: readStatus, + }); + + // The connection ID keys the task read. It is `undefined` until status + // resolves `connected`; the app-lifetime `TickTickReconnectSync` reset clears + // `status.data` (→ `undefined` here → the task read disables) while a reconnect + // re-resolves, so a task fetch can never run under a stale id. No `isFetching` + // term — that dropped the id on every focus refetch and thrashed the cache (M4). + const connectionId = + status.data?.state === "connected" ? status.data.connection_id : undefined; + + const tasks = useQuery({ + queryKey: connectionId ? tasksKey(connectionId) : TASKS_KEY_PREFIX, + enabled: connectionId !== undefined, + staleTime: 60_000, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + queryFn: readTasks, + }); + + const refresh = useCallback(() => { + setManualRefreshing(true); + queryClient.removeQueries({ queryKey: TASKS_KEY_PREFIX }); + + void queryClient + .resetQueries({ queryKey: STATUS_KEY, exact: true }) + .then(() => { + const fresh = + queryClient.getQueryData(STATUS_KEY); + if (fresh?.state !== "connected") return; + return queryClient.fetchQuery({ + queryKey: tasksKey(fresh.connection_id), + queryFn: readTasks, + staleTime: 0, + }); + }) + .catch(() => undefined) + .finally(() => { + setManualRefreshing(false); + }); + }, [queryClient, readTasks]); + + // #4: a FAILED background refetch keeps the last-good rows (TanStack retains + // `data` on error), so distinguish it from an initial failure with no rows. + const rows = (tasks.data?.tasks ?? []) as readonly TickTickTaskRow[]; + return { + connected: status.data?.state === "connected", + statusResolved: status.isSuccess, + // A status read that FAILED (not merely disconnected) — the surface shows + // an error, not an empty "No tasks." list. + statusError: status.isError, + rows, + sourceLimitReached: tasks.data?.source_limit_reached ?? false, + // Initial-vs-stale failure split (review #4): see `classifyTasksError`. + ...classifyTasksError(tasks.isError, tasks.data), + tasksLoading: tasks.isLoading && connectionId !== undefined, + // Status-first manual refresh: task cache purge → fresh status → fresh-key + // task read. A disabled task observer is never refetched. + refresh, + refreshing: manualRefreshing || status.isFetching || tasks.isFetching, + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 793f7966..0cdb3297 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -4,6 +4,7 @@ import { AlertTriangle, Compass } from "lucide-react"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { EmptyState } from "./components/ui/empty-state.tsx"; +import { TickTickReconnectSync } from "./lib/hooks/useTickTick.ts"; import { routeTree } from "./routeTree.gen"; import { RuntimeProvider } from "./runtime.tsx"; import "./index.css"; @@ -89,6 +90,9 @@ createRoot(root).render( + {/* App-lifetime: observes WS reconnects even while Tasks is unmounted + (review F2), so an account swap can't survive in a stale cache. */} + diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index a020b33e..4e206448 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as LibraryIndexRouteImport } from './routes/library/index' import { Route as ChatIndexRouteImport } from './routes/_chat/index' import { Route as SettingsModelsRouteImport } from './routes/settings/models' import { Route as LibraryTimelineRouteImport } from './routes/library/timeline' +import { Route as LibraryTasksRouteImport } from './routes/library/tasks' import { Route as LibraryMediaRouteImport } from './routes/library/media' import { Route as LibraryHealthRouteImport } from './routes/library/health' import { Route as LibraryGtdRouteImport } from './routes/library/gtd' @@ -57,6 +58,11 @@ const LibraryTimelineRoute = LibraryTimelineRouteImport.update({ path: '/timeline', getParentRoute: () => LibraryRouteRoute, } as any) +const LibraryTasksRoute = LibraryTasksRouteImport.update({ + id: '/tasks', + path: '/tasks', + getParentRoute: () => LibraryRouteRoute, +} as any) const LibraryMediaRoute = LibraryMediaRouteImport.update({ id: '/media', path: '/media', @@ -97,6 +103,7 @@ export interface FileRoutesByFullPath { '/library/gtd': typeof LibraryGtdRoute '/library/health': typeof LibraryHealthRoute '/library/media': typeof LibraryMediaRoute + '/library/tasks': typeof LibraryTasksRoute '/library/timeline': typeof LibraryTimelineRoute '/settings/models': typeof SettingsModelsRoute '/library/': typeof LibraryIndexRoute @@ -109,6 +116,7 @@ export interface FileRoutesByTo { '/library/gtd': typeof LibraryGtdRoute '/library/health': typeof LibraryHealthRoute '/library/media': typeof LibraryMediaRoute + '/library/tasks': typeof LibraryTasksRoute '/library/timeline': typeof LibraryTimelineRoute '/settings/models': typeof SettingsModelsRoute '/': typeof ChatIndexRoute @@ -125,6 +133,7 @@ export interface FileRoutesById { '/library/gtd': typeof LibraryGtdRoute '/library/health': typeof LibraryHealthRoute '/library/media': typeof LibraryMediaRoute + '/library/tasks': typeof LibraryTasksRoute '/library/timeline': typeof LibraryTimelineRoute '/settings/models': typeof SettingsModelsRoute '/_chat/': typeof ChatIndexRoute @@ -142,6 +151,7 @@ export interface FileRouteTypes { | '/library/gtd' | '/library/health' | '/library/media' + | '/library/tasks' | '/library/timeline' | '/settings/models' | '/library/' @@ -154,6 +164,7 @@ export interface FileRouteTypes { | '/library/gtd' | '/library/health' | '/library/media' + | '/library/tasks' | '/library/timeline' | '/settings/models' | '/' @@ -169,6 +180,7 @@ export interface FileRouteTypes { | '/library/gtd' | '/library/health' | '/library/media' + | '/library/tasks' | '/library/timeline' | '/settings/models' | '/_chat/' @@ -233,6 +245,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LibraryTimelineRouteImport parentRoute: typeof LibraryRouteRoute } + '/library/tasks': { + id: '/library/tasks' + path: '/tasks' + fullPath: '/library/tasks' + preLoaderRoute: typeof LibraryTasksRouteImport + parentRoute: typeof LibraryRouteRoute + } '/library/media': { id: '/library/media' path: '/media' @@ -283,6 +302,7 @@ interface LibraryRouteRouteChildren { LibraryGtdRoute: typeof LibraryGtdRoute LibraryHealthRoute: typeof LibraryHealthRoute LibraryMediaRoute: typeof LibraryMediaRoute + LibraryTasksRoute: typeof LibraryTasksRoute LibraryTimelineRoute: typeof LibraryTimelineRoute LibraryIndexRoute: typeof LibraryIndexRoute } @@ -292,6 +312,7 @@ const LibraryRouteRouteChildren: LibraryRouteRouteChildren = { LibraryGtdRoute: LibraryGtdRoute, LibraryHealthRoute: LibraryHealthRoute, LibraryMediaRoute: LibraryMediaRoute, + LibraryTasksRoute: LibraryTasksRoute, LibraryTimelineRoute: LibraryTimelineRoute, LibraryIndexRoute: LibraryIndexRoute, } diff --git a/apps/web/src/routes/library/tasks.tsx b/apps/web/src/routes/library/tasks.tsx new file mode 100644 index 00000000..93e98614 --- /dev/null +++ b/apps/web/src/routes/library/tasks.tsx @@ -0,0 +1,16 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { TasksView } from "@/components/library/TasksView"; +import { useTickTick } from "@/lib/hooks/useTickTick"; + +// The hidden Tasks surface (external-task-views S2). Reachable only by URL +// (/library/tasks), deliberately NOT linked in TopicNav — the dev flag until +// the S4 cutover promotes GTD → Tasks. The route owns the reconnect protocol +// (via useTickTick); TasksView is the pure presentation. +function TasksRoute() { + const view = useTickTick(); + return ; +} + +export const Route = createFileRoute("/library/tasks")({ + component: TasksRoute, +}); diff --git a/apps/web/src/store/bridge.ts b/apps/web/src/store/bridge.ts index 17cedb48..12e94d8b 100644 --- a/apps/web/src/store/bridge.ts +++ b/apps/web/src/store/bridge.ts @@ -420,18 +420,21 @@ export async function awaitRun( } /** - * Stop a Run from the chat surface (ADR-0014). Fires `run/cancel`, then settles - * the UI off the authoritative response: interrupt the subscribe fiber, apply a - * synthetic `cancelled` event to settle the bubble, and drop any pending Proposal. + * Stop a Run from the chat surface (ADR-0014). Fires `run/cancel`, then EITHER + * lets the live subscribe stream deliver the real terminal, OR settles the UI off + * the response — interrupt the fiber, apply a synthetic `cancelled` to settle the + * bubble, drop any pending Proposal. * - * We settle here for every (outcome, state) EXCEPT the one case whose terminal is - * owned elsewhere — `already_terminal` on a *running* Run, where the live subscribe - * stream already delivered (or will deliver) the real `done`/`error`/`cancelled`. - * `accepted` settles (Core committed the cancel; for a running Run its real - * `cancelled` is idempotent with the synthetic one). `unknown_run` settles (Core has - * no run/hub, so NO stream event will ever come — bailing would leak the fiber). A - * *parked* Run (awaiting a Proposal decision) has no live tail, so it settles on any - * outcome rather than wedge the Stop control. See docs/design/web-store.md. + * The live stream OWNS the terminal (we return, settling nothing) in exactly two + * cases: `accepted` WITH a live tail — a running Run, whose real interrupted + * `tool_call`s + `cancelled` arrive on the hub this tab subscribes to (applying + * them is what keeps live == reload) — and `already_terminal` on a *running* Run + * (its real `done`/`error`/`cancelled` already came or will). Every other + * (outcome, state) settles off THIS response because no stream event will come: a + * *parked* Run (no live tail, any outcome), an `accepted` running Run WITHOUT a + * hub (the resume window), or `unknown_run` (no hub — bailing would leak the + * fiber). The inline conditions below are the authoritative table. See + * docs/design/web-store.md. */ export async function cancelRun( runtime: WsRuntime, @@ -440,8 +443,11 @@ export async function cancelRun( const program = Effect.flatMap(WsClient, (client) => client.cancelRun(runId)); let outcome: "accepted" | "already_terminal" | "unknown_run"; + let liveTail: boolean; try { - outcome = (await runtime.runPromise(program)).outcome; + const result = await runtime.runPromise(program); + outcome = result.outcome; + liveTail = result.live_tail; } catch { // Cancel is best-effort; a failed request leaves the Run as-is. return; @@ -452,33 +458,47 @@ export async function cancelRun( return; } - // Parked-ness is a record field read, not re-derived from Proposal status: the - // record stays `parked` from the moment a Proposal attaches through `deciding` - // and a failed decide, flipping back to `running` only when the resume stream - // re-subscribes (which then owns the terminal). A racing cancel during deciding - // clears the Proposal here, and decideProposal's currency guard then bails. + // Parked-ness is a record field read (not re-derived from Proposal status): + // the record stays `parked` from proposal-attach through `deciding` and a + // failed decide, flipping to `running` only when the resume stream + // re-subscribes (which then owns the terminal). const parked = isRunParked(runId); - // The ONLY outcome whose terminal is owned elsewhere is `already_terminal` on a - // non-parked Run: its live subscribe stream already delivered (or will deliver) - // the real done/error/cancelled, which settles the bubble and reaps the fiber. - // Every other case must settle here: `accepted` (Core committed the cancel), - // and `unknown_run` (Core has no run/hub, so NO stream event will ever come — - // bailing would leak the fiber and wedge Stop forever). `parked` always settles - // since a parked Run has no live tail regardless of outcome. - if (outcome === "already_terminal" && !parked) { + // `live_tail` (external-task-views A4) is Core's authoritative answer to "will + // a terminal reach me on the live stream?" — no timer guess. Leave the bubble + // to the live stream in exactly two cases: + // - `accepted` with `live_tail`: Core published (or will publish) the + // interrupted `tool_call` event(s) then `cancelled` on the hub this tab is + // subscribed to. The stream must APPLY them (interrupting here would drop an + // interrupted external call's result and diverge live from reload); its + // takeUntil(cancelled) reaps the fiber and fires onRunSettled. + // - `already_terminal` on a RUNNING Run: its live stream already delivered (or + // will deliver) the real done/error/cancelled. + if ( + (outcome === "accepted" && liveTail) || + (outcome === "already_terminal" && !parked) + ) { return; } - // Interrupt first so the fiber's takeUntil can't race a real terminal event, - // then settle deterministically off the authoritative cancel response. + // Everything else settles off this response — no stream event will come: + // parked (no live tail, any outcome), an `accepted` running-without-hub + // resume window, or `unknown_run` (no hub — bailing would leak the fiber). + settleCancelledLocally(runtime, threadId, runId); +} + +/** The synthetic cancel settle: interrupt the fiber (so its takeUntil can't + * race), apply a local `cancelled`, drop any Proposal, and refresh the + * recent-Runs feed from this authoritative settle point (the interrupted + * fiber's own onRunSettled is identity-gated off). */ +function settleCancelledLocally( + runtime: WsRuntime, + threadId: string, + runId: RunId, +): void { interruptRun(runtime, runId); applyEvent(threadId, runId, { kind: "cancelled" }); clearProposal(runId); - // A cancel settles the Run HERE, not via the stream finalizer (which we just - // interrupted — its onRunSettled is gated off precisely so resume/unmount - // teardowns don't fire it). So refresh the recent-Runs feed from this - // authoritative settle point, else a user-stopped Run lingers as Running/Waiting. onRunSettled?.(); } diff --git a/apps/web/src/store/chat.ts b/apps/web/src/store/chat.ts index 47e277fb..2db81b81 100644 --- a/apps/web/src/store/chat.ts +++ b/apps/web/src/store/chat.ts @@ -2,45 +2,29 @@ import type { ProposalReviewContext, ResolvedNode } from "@inkstone/protocol"; import type { RunEventValue } from "@inkstone/ui-sdk"; import { useStore } from "zustand"; import { createStore } from "zustand/vanilla"; - -/** A tool call surfaced live within an assistant turn (ADR-0006 tool_call Run Event). */ -export interface ToolCall { - readonly id: string; - readonly name: string; - readonly status: "running" | "completed" | "error"; - /** The tool's display argument (ADR-0043), e.g. a search query; absent for argless tools. */ - readonly arg?: string; -} - -/** - * One item in an assistant turn's ordered timeline (ADR-0045): a contiguous run - * of text, a tool-call boundary, a positional marker for the Proposal card, or a - * `reasoning` (thinking) trace. The `proposal` segment carries ONLY `runId` — the - * {@link PendingProposal} map stays the source of interactive state; this segment - * just says "the card renders HERE in the timeline". The `reasoning` kind (ADR-0045 - * amendment, #202) is now realized — the model's thinking, default-collapsed, with - * an optional `durationMs` (web-clocked live open→seal, Core-computed on reload). It - * is EXCLUDED from {@link concatText}, so the trace never leaks into the reply text. - * The `attachment` kind (ADR-0058) is an image on a user Message — the bytes live - * at `GET /media/{mediaId}`; `width`/`height` are pixel dims when known. It carries - * no text, so {@link concatText} excludes it by construction. - */ -export type Segment = - | { readonly kind: "text"; readonly text: string } - | { readonly kind: "tool_call"; readonly call: ToolCall } - | { readonly kind: "proposal"; readonly runId: string } - | { - readonly kind: "reasoning"; - readonly text: string; - readonly durationMs?: number; - } - | { - readonly kind: "attachment"; - readonly mediaId: string; - readonly mime: string; - readonly width?: number; - readonly height?: number; - }; +import { + appendProposalSegment, + appendReasoningSegment, + appendTextSegment, + concatText, + type Segment, + sealOpenReasoning, + settleRunningToolSegments, + type ToolCall, + toSegment, + upsertToolSegment, +} from "./timeline.js"; + +// The timeline model + its pure reducers live in ./timeline.ts (review F3); +// re-export the public surface so existing `@/store/chat` importers (ToolCall, +// Segment, concatText, toSegment, …) are unaffected by the split. +export { + concatText, + type Segment, + type ToolCall, + toSegment, + type WireSegment, +} from "./timeline.js"; /** The canonical live UI message; mirrors the wire `MessageView` shape. */ export interface Message { @@ -71,17 +55,6 @@ export interface Message { readonly cancelled?: boolean; } -/** Concatenate the text of every `text` segment in order — the single source for the - * flat reply text the copy button, ⌘K search-match, typing-indicator, and retry read - * (ADR-0045: there is no denormalized flat `text`; it derives from segments). */ -export function concatText(segments: readonly Segment[]): string { - let text = ""; - for (const seg of segments) { - if (seg.kind === "text") text += seg.text; - } - return text; -} - /** * Reactive hydrate-on-focus lifecycle (replaces the old non-reactive Set): * `loading` while `thread/get` is in flight, `error` on a transient failed fetch @@ -464,123 +437,6 @@ export function resetMessageForRetry(threadId: string, runId: string): void { // The web mirror of Core's run_steps sequencer: each Run Event extends the ordered // `segments[]` so the live render is the same shape the reload will read (slice 3). -/** - * Thread a `text_delta` into the timeline (ADR-0045), mirroring the flat-text - * SET-vs-APPEND rule (ADR-0022) so `concatText(segments) === flat text` always holds: - * - * - **APPEND** (disarmed tail): extend the OPEN trailing text segment; if the trailing - * segment is non-text (a tool/proposal just sealed the run) or the timeline is empty, - * OPEN a fresh text segment — the web mirror of Core's open-on-first-delta. - * - **SET** (armed cumulative snapshot): the delta is the cumulative concat of ALL the - * turn's text so far (`group_concat`, no boundary markers — `select_run_snapshot`), so - * it replaces EVERY existing text segment, not just the trailing one. Collapse all text - * segments into ONE carrying the snapshot at the position of the FIRST text segment, and - * drop the rest — PRESERVING the interleaved tool_call/proposal segments' order. If no - * text segment exists yet, OPEN one at the end. Replacing only the last text segment (the - * prior rule) left earlier text segments in place, so a post-park resume snapshot that - * re-includes pre-park prose DUPLICATED it (concatText = "A" + "A B" ≠ flat "A B"). - */ -function appendTextSegment( - segments: readonly Segment[], - delta: string, - armed: boolean, -): readonly Segment[] { - if (armed) { - return setCumulativeText(segments, delta); - } - const last = segments[segments.length - 1]; - if (last?.kind === "text") { - return [ - ...segments.slice(0, -1), - { kind: "text", text: last.text + delta }, - ]; - } - return [...segments, { kind: "text", text: delta }]; -} - -/** - * Reconcile a cumulative-snapshot SET into the timeline: the snapshot is the WHOLE - * turn's text, so the result has exactly one text segment carrying it (at the first - * existing text position) and keeps every non-text segment in its place. With no text - * segment yet, the snapshot opens one at the end. This is what makes - * `concatText(segments) === snapshot` hold even when the turn had multiple pre-snapshot - * text runs (text→tool→text→park→resume) — the duplicated-prefix case the prior - * last-text-only rule missed. - */ -function setCumulativeText( - segments: readonly Segment[], - snapshot: string, -): readonly Segment[] { - const firstTextIndex = segments.findIndex((seg) => seg.kind === "text"); - if (firstTextIndex === -1) { - return [...segments, { kind: "text", text: snapshot }]; - } - const result: Segment[] = []; - for (let i = 0; i < segments.length; i++) { - const seg = segments[i]; - if (i === firstTextIndex) { - result.push({ kind: "text", text: snapshot }); - } else if (seg.kind !== "text") { - result.push(seg); - } - // Drop every other text segment — its content is already in the snapshot. - } - return result; -} - -/** - * Thread a `reasoning_delta` into the timeline (ADR-0045 amendment): APPEND-ONLY, - * the disarmed twin of {@link appendTextSegment}. There is NO armed cumulative-SET - * path — the resume snapshot is text-only (`type='text'` SQL filter), so a reasoning - * segment never receives a snapshot delta. If the trailing segment is `reasoning`, - * extend its text (`opened: false`); else OPEN a fresh reasoning segment (`opened: - * true`, the web mirror of Core's open-on-first-delta). A text/tool/proposal between - * two reasoning runs correctly opens a new one. The `opened` flag is the single source - * of "did a fresh block start here" — `applyEvent` uses it to (re)stamp the block's - * open-time, rather than re-deriving the trailing-segment check separately. - */ -function appendReasoningSegment( - segments: readonly Segment[], - delta: string, -): { segments: readonly Segment[]; opened: boolean } { - const last = segments[segments.length - 1]; - if (last?.kind === "reasoning") { - return { - segments: [ - ...segments.slice(0, -1), - { kind: "reasoning", text: last.text + delta }, - ], - opened: false, - }; - } - return { - segments: [...segments, { kind: "reasoning", text: delta }], - opened: true, - }; -} - -/** Seal the OPEN trailing reasoning segment with a web-clocked `durationMs` when a Run - * terminates (ADR-0045 amendment: live clocks its own open→seal). No-op if the trailing - * segment is not reasoning, already sealed, or no open-time was recorded — the reloaded - * path carries Core's authoritative `duration_ms`, so live is a nicety. */ -function sealOpenReasoning( - segments: readonly Segment[], - openedAt: number | undefined, - now: number, -): readonly Segment[] { - if (openedAt === undefined) { - return segments; - } - const last = segments[segments.length - 1]; - if (last?.kind !== "reasoning" || last.durationMs !== undefined) { - return segments; - } - return [ - ...segments.slice(0, -1), - { kind: "reasoning", text: last.text, durationMs: now - openedAt }, - ]; -} - /** * A timeline boundary arrived for `runId` (a text delta, a new tool call, or the Run * terminal): seal the open reasoning block with its web-clocked `durationMs` AND clear @@ -616,51 +472,6 @@ function sealReasoningAtBoundary( }; } -/** Upsert a `tool_call` segment by call id (ADR-0045): a new id appends a fresh - * segment at the end of the timeline; a known id flips its call's status in place. */ -function upsertToolSegment( - segments: readonly Segment[], - call: ToolCall, -): readonly Segment[] { - const found = segments.some( - (seg) => seg.kind === "tool_call" && seg.call.id === call.id, - ); - if (!found) { - return [...segments, { kind: "tool_call", call }]; - } - return segments.map((seg) => - seg.kind === "tool_call" && seg.call.id === call.id - ? { kind: "tool_call", call: { ...seg.call, status: call.status } } - : seg, - ); -} - -/** Settle any `running` tool_call SEGMENT to `terminal` when its Run ends (the - * segment-aware twin of {@link settleRunningToolCalls}; the lost-boundary case). */ -function settleRunningToolSegments( - segments: readonly Segment[], - terminal: "completed" | "error", -): readonly Segment[] { - return segments.map((seg) => - seg.kind === "tool_call" && seg.call.status === "running" - ? { kind: "tool_call", call: { ...seg.call, status: terminal } } - : seg, - ); -} - -/** Append a `proposal` segment for `runId` at the current end of the timeline, - * unless one is already present (skip-if-present): the seam where a Proposal enters - * the timeline (it does NOT flow through {@link applyEvent}) — see {@link setPendingProposal}. */ -function appendProposalSegment( - segments: readonly Segment[], - runId: string, -): readonly Segment[] { - if (segments.some((seg) => seg.kind === "proposal")) { - return segments; - } - return [...segments, { kind: "proposal", runId }]; -} - /** Attach a `proposal` segment (skip-if-present) to the assistant message owning * `runId` within its thread — the {@link setPendingProposal} / {@link * rehydrateDecidedProposal} timeline seam, shared so both enter the timeline identically. @@ -766,6 +577,42 @@ export function applyEvent( return s; } + if (event.kind === "snapshot") { + // Full-timeline snapshot (review P1 #2): atomically REPLACE the run's + // segments with the ordered wire timeline (text / reasoning / tool_call + // in run_steps order, incl. a still-running call), then DISARM the + // cumulative-text bit so subsequent tail `text_delta`s APPEND to the + // snapshot rather than SET over it. This is the reconnect authority — + // it supersedes whatever `thread/get` painted, in true order. + const segments = event.segments.map((seg) => toSegment(runId, seg)); + const next = updateRunMessage(s, threadId, runId, (m) => ({ + ...m, + segments, + })); + const run = next.runs[runId]; + if (run === undefined) { + return next; + } + // If the snapshot's timeline ENDS with an OPEN reasoning block + // (streaming, no `durationMs`), re-anchor `reasoningOpenedAt` so the next + // boundary can seal it — the wire snapshot carries no open-time, so + // without this the block stays unsealed forever (review F4). Any other + // trailing segment clears it. Re-anchoring to `now` times a post-reconnect + // seal from the snapshot — the best available once the true open-time is lost. + const last = segments[segments.length - 1]; + const reasoningOpenedAt = + last?.kind === "reasoning" && last.durationMs === undefined + ? now + : undefined; + return { + ...next, + runs: { + ...next.runs, + [runId]: { ...run, snapshotArmed: false, reasoningOpenedAt }, + }, + }; + } + if (event.kind === "text_delta") { // A text delta means the model finished any open reasoning block: seal it // with a web-clocked duration NOW (not at terminal) so the disclosure reads @@ -830,13 +677,15 @@ export function applyEvent( ? sealReasoningAtBoundary(s, threadId, runId, now) : s; // Upsert into the timeline: `started` appends a `running` segment, a - // terminal status flips the matching one in place (ADR-0045). + // terminal status flips the matching one in place (ADR-0045), merging + // the model-received `result` when the event carries one (A4). const status = event.status === "started" ? "running" : event.status; const call: ToolCall = { id: event.tool_call_id, name: event.name, status, arg: event.arg, + result: event.result, }; return updateRunMessage(sealed, threadId, runId, (m) => ({ ...m, diff --git a/apps/web/src/store/hydrate.ts b/apps/web/src/store/hydrate.ts index bbe27ed1..30df5c69 100644 --- a/apps/web/src/store/hydrate.ts +++ b/apps/web/src/store/hydrate.ts @@ -18,67 +18,9 @@ import { rehydrateDecidedProposal, type Segment, setHydrationStatus, + toSegment, } from "./chat.js"; -type WireSegment = ThreadGetResult["messages"][number]["segments"][number]; - -/** A persisted tool call's wire status maps to a live `tool_call` segment status: - * `error` keeps its spelling, anything else (a rehydrated call is `completed`) - * settles to `completed`. A rehydrated call is never `running`. */ -function toToolCallStatus(status: string): "completed" | "error" { - return status === "error" ? "error" : "completed"; -} - -/** Map one wire `Segment` to a live store {@link Segment} (ADR-0045), preserving - * its timeline position. A `tool_call` segment carries no id (the durable record - * has one, but the live row keys only on render order), so synthesize a stable - * `:seg:` id from its index, keeping React keys distinct. The wire - * `proposal` segment becomes a positional `{kind:"proposal", runId}` marker — the - * decided card's interactive state lives in the `proposals` map, seeded separately - * by {@link rehydrateDecidedProposals}. */ -function toSegment( - messageId: string, - runId: string, - segment: WireSegment, - index: number, -): Segment { - switch (segment.kind) { - case "text": - return { kind: "text", text: segment.text }; - case "tool_call": - return { - kind: "tool_call", - call: { - id: `${messageId}:seg:${index}`, - name: segment.name, - status: toToolCallStatus(segment.status), - arg: segment.arg, - }, - }; - case "proposal": - return { kind: "proposal", runId }; - case "reasoning": - // The model's thinking trace (ADR-0045 amendment): the wire carries Core's - // computed `duration_ms`; store it as `durationMs`. Excluded from concatText. - return { - kind: "reasoning", - text: segment.text, - durationMs: segment.duration_ms, - }; - case "attachment": - // An image on a user Message (ADR-0058): the bytes live at - // `GET /media/{media_id}`; `width`/`height` are omitted (not null) when the - // upload didn't supply them. Carries no text, so concatText excludes it. - return { - kind: "attachment", - mediaId: segment.media_id, - mime: segment.mime, - width: segment.width, - height: segment.height, - }; - } -} - /** Map a wire `MessageView` to the live {@link Message}, narrowing role/status via * defensive guards. The ordered `segments[]` is consumed VERBATIM (ADR-0045): the * wire already carries the true `run_steps` order, so the reload renders the same @@ -99,8 +41,8 @@ export function toMessage(view: ThreadGetResult["messages"][number]): Message { // it must never be flagged. const cancelled = status === "incomplete" && view.terminal_reason === "cancelled"; - const segments: Segment[] = view.segments.map((segment, i) => - toSegment(view.id, view.run_id, segment, i), + const segments: Segment[] = view.segments.map((segment) => + toSegment(view.run_id, segment), ); return { id: view.id, diff --git a/apps/web/src/store/timeline.ts b/apps/web/src/store/timeline.ts new file mode 100644 index 00000000..deb84679 --- /dev/null +++ b/apps/web/src/store/timeline.ts @@ -0,0 +1,289 @@ +import type { ThreadGetResult, TranscriptToolResult } from "@inkstone/protocol"; + +// The assistant-turn TIMELINE model + its pure reducers (ADR-0045), extracted +// from the Zustand store module (review F3): the wire→segment mapping and the +// segment-array transforms that build a turn's ordered timeline. Every function +// here is PURE over `Segment[]` — no store/`ChatState` dependency — so the store +// module (`chat.ts`) owns only state wiring, and these reducers are unit-testable +// in isolation. `chat.ts` re-exports the public types + `toSegment`/`concatText` +// so existing `@/store/chat` importers are unaffected. + +/** A tool call surfaced live within an assistant turn (ADR-0006 tool_call Run Event). */ +export interface ToolCall { + readonly id: string; + readonly name: string; + readonly status: "running" | "completed" | "error"; + /** The tool's display argument (ADR-0043), e.g. a search query; absent for argless tools. */ + readonly arg?: string; + /** The normalized result the model received (external-task-views A4): + * carried on terminal events of external (`ticktick_*`) calls so the + * collapsed row can expand to it — identically live and after reload. */ + readonly result?: TranscriptToolResult; +} + +/** + * One item in an assistant turn's ordered timeline (ADR-0045): a contiguous run + * of text, a tool-call boundary, a positional marker for the Proposal card, or a + * `reasoning` (thinking) trace. The `proposal` segment carries ONLY `runId` — the + * {@link PendingProposal} map stays the source of interactive state; this segment + * just says "the card renders HERE in the timeline". The `reasoning` kind (ADR-0045 + * amendment, #202) is now realized — the model's thinking, default-collapsed, with + * an optional `durationMs` (web-clocked live open→seal, Core-computed on reload). It + * is EXCLUDED from {@link concatText}, so the trace never leaks into the reply text. + * The `attachment` kind (ADR-0058) is an image on a user Message — the bytes live + * at `GET /media/{mediaId}`; `width`/`height` are pixel dims when known. It carries + * no text, so {@link concatText} excludes it by construction. + */ +export type Segment = + | { readonly kind: "text"; readonly text: string } + | { readonly kind: "tool_call"; readonly call: ToolCall } + | { readonly kind: "proposal"; readonly runId: string } + | { + readonly kind: "reasoning"; + readonly text: string; + readonly durationMs?: number; + } + | { + readonly kind: "attachment"; + readonly mediaId: string; + readonly mime: string; + readonly width?: number; + readonly height?: number; + }; + +/** One wire `Segment` — from a `thread/get` result OR a `run/subscribe` + * `snapshot` Run Event (both carry the same `protocol::Segment`). */ +export type WireSegment = + ThreadGetResult["messages"][number]["segments"][number]; + +/** A wire tool-call status → the live `tool_call` segment status. `running` (a + * live `snapshot`'s in-flight call, review P1 #2) and `error` keep their + * spelling; anything else (a rehydrated `thread/get` call is `completed`) + * settles to `completed`. */ +function toToolCallStatus(status: string): "running" | "completed" | "error" { + if (status === "running") return "running"; + if (status === "error") return "error"; + return "completed"; +} + +/** Map one wire `Segment` to a live store {@link Segment} (ADR-0045), preserving + * its timeline position — SHARED by `thread/get` rehydration and the + * `run/subscribe` `snapshot` event (review P1 #2). A `tool_call` keys on the + * durable `tool_call_id` (external-task-views A4) and carries the model-received + * `result`; a wire `proposal` becomes a positional `{kind:"proposal", runId}` + * marker (its interactive state lives in the `proposals` map, seeded separately). */ +export function toSegment(runId: string, segment: WireSegment): Segment { + switch (segment.kind) { + case "text": + return { kind: "text", text: segment.text }; + case "tool_call": + return { + kind: "tool_call", + call: { + id: segment.tool_call_id, + name: segment.name, + status: toToolCallStatus(segment.status), + arg: segment.arg, + result: segment.result, + }, + }; + case "proposal": + return { kind: "proposal", runId }; + case "reasoning": + return { + kind: "reasoning", + text: segment.text, + durationMs: segment.duration_ms, + }; + case "attachment": + return { + kind: "attachment", + mediaId: segment.media_id, + mime: segment.mime, + width: segment.width, + height: segment.height, + }; + } +} + +/** Concatenate the text of every `text` segment in order — the single source for the + * flat reply text the copy button, ⌘K search-match, typing-indicator, and retry read + * (ADR-0045: there is no denormalized flat `text`; it derives from segments). */ +export function concatText(segments: readonly Segment[]): string { + let text = ""; + for (const seg of segments) { + if (seg.kind === "text") text += seg.text; + } + return text; +} + +/** + * Thread a `text_delta` into the timeline (ADR-0045), mirroring the flat-text + * SET-vs-APPEND rule (ADR-0022) so `concatText(segments) === flat text` always holds: + * + * - **APPEND** (disarmed tail): extend the OPEN trailing text segment; if the trailing + * segment is non-text (a tool/proposal just sealed the run) or the timeline is empty, + * OPEN a fresh text segment — the web mirror of Core's open-on-first-delta. + * - **SET** (armed cumulative snapshot): the delta is the cumulative concat of ALL the + * turn's text so far (`group_concat`, no boundary markers — `select_run_snapshot`), so + * it replaces EVERY existing text segment, not just the trailing one. Collapse all text + * segments into ONE carrying the snapshot at the position of the FIRST text segment, and + * drop the rest — PRESERVING the interleaved tool_call/proposal segments' order. If no + * text segment exists yet, OPEN one at the end. Replacing only the last text segment (the + * prior rule) left earlier text segments in place, so a post-park resume snapshot that + * re-includes pre-park prose DUPLICATED it (concatText = "A" + "A B" ≠ flat "A B"). + */ +export function appendTextSegment( + segments: readonly Segment[], + delta: string, + armed: boolean, +): readonly Segment[] { + if (armed) { + return setCumulativeText(segments, delta); + } + const last = segments[segments.length - 1]; + if (last?.kind === "text") { + return [ + ...segments.slice(0, -1), + { kind: "text", text: last.text + delta }, + ]; + } + return [...segments, { kind: "text", text: delta }]; +} + +/** + * Reconcile a cumulative-snapshot SET into the timeline: the snapshot is the WHOLE + * turn's text, so the result has exactly one text segment carrying it (at the first + * existing text position) and keeps every non-text segment in its place. With no text + * segment yet, the snapshot opens one at the end. This is what makes + * `concatText(segments) === snapshot` hold even when the turn had multiple pre-snapshot + * text runs (text→tool→text→park→resume) — the duplicated-prefix case the prior + * last-text-only rule missed. + */ +function setCumulativeText( + segments: readonly Segment[], + snapshot: string, +): readonly Segment[] { + const firstTextIndex = segments.findIndex((seg) => seg.kind === "text"); + if (firstTextIndex === -1) { + return [...segments, { kind: "text", text: snapshot }]; + } + const result: Segment[] = []; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + if (i === firstTextIndex) { + result.push({ kind: "text", text: snapshot }); + } else if (seg.kind !== "text") { + result.push(seg); + } + // Drop every other text segment — its content is already in the snapshot. + } + return result; +} + +/** + * Thread a `reasoning_delta` into the timeline (ADR-0045 amendment): APPEND-ONLY, + * the disarmed twin of {@link appendTextSegment}. There is NO armed cumulative-SET + * path — the resume snapshot is text-only (`type='text'` SQL filter), so a reasoning + * segment never receives a snapshot delta. If the trailing segment is `reasoning`, + * extend its text (`opened: false`); else OPEN a fresh reasoning segment (`opened: + * true`, the web mirror of Core's open-on-first-delta). A text/tool/proposal between + * two reasoning runs correctly opens a new one. The `opened` flag is the single source + * of "did a fresh block start here" — `applyEvent` uses it to (re)stamp the block's + * open-time, rather than re-deriving the trailing-segment check separately. + */ +export function appendReasoningSegment( + segments: readonly Segment[], + delta: string, +): { segments: readonly Segment[]; opened: boolean } { + const last = segments[segments.length - 1]; + if (last?.kind === "reasoning") { + return { + segments: [ + ...segments.slice(0, -1), + { kind: "reasoning", text: last.text + delta }, + ], + opened: false, + }; + } + return { + segments: [...segments, { kind: "reasoning", text: delta }], + opened: true, + }; +} + +/** Seal the OPEN trailing reasoning segment with a web-clocked `durationMs` when a Run + * terminates (ADR-0045 amendment: live clocks its own open→seal). No-op if the trailing + * segment is not reasoning, already sealed, or no open-time was recorded — the reloaded + * path carries Core's authoritative `duration_ms`, so live is a nicety. */ +export function sealOpenReasoning( + segments: readonly Segment[], + openedAt: number | undefined, + now: number, +): readonly Segment[] { + if (openedAt === undefined) { + return segments; + } + const last = segments[segments.length - 1]; + if (last?.kind !== "reasoning" || last.durationMs !== undefined) { + return segments; + } + return [ + ...segments.slice(0, -1), + { kind: "reasoning", text: last.text, durationMs: now - openedAt }, + ]; +} + +/** Upsert a `tool_call` segment by call id (ADR-0045): a new id appends a fresh + * segment at the end of the timeline; a known id merges the terminal `status` + * AND `result` into the existing call in place (external-task-views A4 — the + * started row keeps its fields; the terminal event settles them). */ +export function upsertToolSegment( + segments: readonly Segment[], + call: ToolCall, +): readonly Segment[] { + const found = segments.some( + (seg) => seg.kind === "tool_call" && seg.call.id === call.id, + ); + if (!found) { + return [...segments, { kind: "tool_call", call }]; + } + return segments.map((seg) => + seg.kind === "tool_call" && seg.call.id === call.id + ? { + kind: "tool_call", + call: { + ...seg.call, + status: call.status, + ...(call.result === undefined ? {} : { result: call.result }), + }, + } + : seg, + ); +} + +/** Settle any `running` tool_call SEGMENT to `terminal` when its Run ends (the + * segment-aware twin of `settleRunningToolCalls`; the lost-boundary case). */ +export function settleRunningToolSegments( + segments: readonly Segment[], + terminal: "completed" | "error", +): readonly Segment[] { + return segments.map((seg) => + seg.kind === "tool_call" && seg.call.status === "running" + ? { kind: "tool_call", call: { ...seg.call, status: terminal } } + : seg, + ); +} + +/** Append a `proposal` segment for `runId` at the current end of the timeline, + * unless one is already present (skip-if-present): the seam where a Proposal enters + * the timeline (it does NOT flow through `applyEvent`) — see `setPendingProposal`. */ +export function appendProposalSegment( + segments: readonly Segment[], + runId: string, +): readonly Segment[] { + if (segments.some((seg) => seg.kind === "proposal")) { + return segments; + } + return [...segments, { kind: "proposal", runId }]; +} diff --git a/apps/web/test/components/ChatColumn.test.tsx b/apps/web/test/components/ChatColumn.test.tsx index 46b71c7b..26bf7cb6 100644 --- a/apps/web/test/components/ChatColumn.test.tsx +++ b/apps/web/test/components/ChatColumn.test.tsx @@ -14,7 +14,7 @@ import { } from "@test/test-utils/renderWithCore"; import { act, cleanup, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { Deferred, Effect, Stream } from "effect"; +import { Deferred, Effect, Queue, Stream } from "effect"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ChatColumn } from "@/components/ChatColumn.js"; import { @@ -66,7 +66,8 @@ function makeStubOverrides(opts: { subscribeRun: () => Stream.fromIterable(opts.events), cancelRun: opts.cancelRun ?? - (() => Effect.succeed({ outcome: "accepted" as const })), + (() => + Effect.succeed({ outcome: "accepted" as const, live_tail: false })), // Fail fast on an UNEXPECTED retry path: a test that drives run/retry must // opt in via `opts.retryRun`; an accidental retry in an unrelated test dies // rather than silently passing on a default "accepted" (CodeRabbit #244). @@ -857,17 +858,33 @@ describe("ChatColumn", () => { it("shows a Stop control while a run streams and settles the bubble on cancel", async () => { const user = userEvent.setup(); + // The stream stays OPEN after the partial delta (a live tail), and — per + // the A4 cancel contract — Core delivers the real `cancelled` event right + // behind an accepted response; the bridge now applies THAT instead of + // synthesizing its own (which would clobber interrupted tool results). + const tail = Effect.runSync(Queue.unbounded()); const cancelRun = vi.fn(() => - Effect.succeed({ outcome: "accepted" as const }), + Effect.sync(() => { + Queue.unsafeOffer(tail, { kind: "cancelled" } as RunEventValue); + // live_tail: true — the live stream delivers the real `cancelled`. + return { outcome: "accepted" as const, live_tail: true }; + }), ); - // A partial (non-terminal) stream: the assistant turn stays active (no - // done/error/cancelled), so activeRunId stays set and Stop is shown. - const overrides = makeStubOverrides({ - runId: "run-stop", - threadId: "thread-stop", - events: [{ kind: "text_delta", delta: "echo: h" }], - cancelRun, - }); + const overrides = { + ...makeStubOverrides({ + runId: "run-stop", + threadId: "thread-stop", + events: [], + cancelRun, + }), + subscribeRun: () => + Stream.concat( + Stream.fromIterable([ + { kind: "text_delta", delta: "echo: h" } as RunEventValue, + ]), + Stream.fromQueue(tail), + ), + }; await renderFocused(overrides, "threadA"); diff --git a/apps/web/test/components/ToolActivity.test.tsx b/apps/web/test/components/ToolActivity.test.tsx index 06538c02..b7784dd4 100644 --- a/apps/web/test/components/ToolActivity.test.tsx +++ b/apps/web/test/components/ToolActivity.test.tsx @@ -1,4 +1,5 @@ import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it } from "vitest"; import { groupToolCalls, ToolActivity } from "@/components/ToolActivity.js"; import type { ToolCall } from "@/store/chat"; @@ -143,3 +144,115 @@ describe("ToolActivity grouped rendering", () => { expect(row).toHaveTextContent("+1"); }); }); + +// ── External (`ticktick_*`) calls — external-task-views A4 ────────────────── + +const externalResult = (text: string, isError = false) => ({ + content: [{ type: "text" as const, text }], + is_error: isError, +}); + +describe("external calls never group", () => { + it("two same-name external calls stay two rows, keyed by call id, results distinct", () => { + const groups = groupToolCalls([ + call({ + id: "tc_a", + name: "ticktick_search_task", + result: externalResult("first result"), + }), + call({ + id: "tc_b", + name: "ticktick_search_task", + result: externalResult("second result"), + }), + ]); + expect(groups).toHaveLength(2); + expect(groups.map((g) => g.key)).toEqual(["tc_a", "tc_b"]); + expect(groups[0].call?.result?.content[0].text).toBe("first result"); + expect(groups[1].call?.result?.content[0].text).toBe("second result"); + }); + + it("an external call never merges into a same-position Core group", () => { + const groups = groupToolCalls([ + call({ id: "1", arg: "Lev" }), + call({ id: "tc_ext", name: "ticktick_filter_tasks" }), + call({ id: "2", arg: "Acme" }), + ]); + // Core calls still merge around it; the external row keeps its slot. + expect(groups.map((g) => g.key)).toEqual([ + "group:search_entities", + "tc_ext", + ]); + expect(groups[1].call?.name).toBe("ticktick_filter_tasks"); + }); +}); + +describe("external expandable row", () => { + it("renders collapsed, expands to the model-received content, and collapses back", async () => { + render( + , + ); + const row = screen.getByTestId("tool-call"); + expect(row).toHaveAttribute("data-status", "completed"); + // Collapsed by default: the content is NOT in the document. + expect(screen.queryByTestId("tool-call-result")).toBeNull(); + + const toggle = screen.getByRole("button"); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByTestId("tool-call-result")).toHaveTextContent( + "1 task found: S1 timed", + ); + + await userEvent.click(toggle); + expect(screen.queryByTestId("tool-call-result")).toBeNull(); + }); + + it("an errored external call renders the error row and expands to the error content — identically", async () => { + render( + , + ); + const row = screen.getByTestId("tool-call"); + expect(row).toHaveAttribute("data-status", "error"); + expect(row).toHaveTextContent("failed"); + + await userEvent.click(screen.getByRole("button")); + expect(screen.getByTestId("tool-call-result")).toHaveTextContent( + "interrupted", + ); + }); + + it("a running external row is not expandable yet (no result)", () => { + render( + , + ); + expect(screen.getByRole("button")).toBeDisabled(); + expect(screen.queryByTestId("tool-call-result")).toBeNull(); + }); +}); diff --git a/apps/web/test/components/library/TasksView.test.tsx b/apps/web/test/components/library/TasksView.test.tsx new file mode 100644 index 00000000..9141cd6d --- /dev/null +++ b/apps/web/test/components/library/TasksView.test.tsx @@ -0,0 +1,228 @@ +import type { TickTickTaskRow } from "@inkstone/protocol"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it } from "vitest"; +import { TasksView, type TasksViewProps } from "@/components/library/TasksView"; + +afterEach(cleanup); + +function row( + partial: Partial & { id: string }, +): TickTickTaskRow { + return { + title: "a task", + kind: "TEXT", + priority: 0, + tags: [], + checklist_items: [], + ...partial, + }; +} + +function view(overrides: Partial = {}): TasksViewProps { + return { + connected: true, + statusResolved: true, + statusError: false, + rows: [], + sourceLimitReached: false, + tasksInitialError: false, + tasksStaleError: false, + tasksLoading: false, + refresh: () => {}, + refreshing: false, + ...overrides, + }; +} + +describe("TasksView", () => { + it("shows the not-connected notice when status resolved disconnected", () => { + render(); + expect(screen.getByTestId("ticktick-disconnected")).toBeInTheDocument(); + }); + + it("shows the error state on a status-read failure (not 'No tasks.')", () => { + render( + , + ); + expect(screen.getByTestId("ticktick-error")).toBeInTheDocument(); + expect(screen.queryByText("No tasks.")).toBeNull(); + }); + + it("an INITIAL task failure (no rows) shows the error state", () => { + render(); + expect(screen.getByTestId("ticktick-error")).toBeInTheDocument(); + }); + + it("offers a manual refresh that fires the command (A2, review R12 #4)", async () => { + let refreshed = 0; + render( + { + refreshed += 1; + }, + })} + />, + ); + await userEvent.click(screen.getByTestId("ticktick-refresh")); + expect(refreshed).toBe(1); + }); + + it("disables the refresh control while a refresh is in flight", () => { + render( + , + ); + expect(screen.getByTestId("ticktick-refresh")).toBeDisabled(); + }); + + it("the error states offer a Retry that fires the refresh command", async () => { + let refreshed = 0; + render( + { + refreshed += 1; + }, + })} + />, + ); + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(refreshed).toBe(1); + }); + + it("shows loading (never a false 'No tasks.') while the status read is pending", () => { + // The first status fetch is in flight: not resolved, not connected, no rows, + // no task-loading flag yet (the task read is still gated) — review M5. + render( + , + ); + expect(screen.getByText("Loading tasks…")).toBeInTheDocument(); + expect(screen.queryByText("No tasks.")).toBeNull(); + }); + + it("a STALE refetch failure keeps the last-good rows with a stale indicator", () => { + render( + , + ); + // The rows survive a failed background refetch (A2 failure semantics)… + expect(screen.getByTestId("ticktick-task")).toHaveTextContent("buy milk"); + // …with a stale indicator, NOT the full error screen. + expect(screen.getByTestId("ticktick-stale-warning")).toBeInTheDocument(); + expect(screen.queryByTestId("ticktick-error")).toBeNull(); + }); + + it("renders the truncation warning ONLY when the source limit was reached", () => { + const { rerender } = render( + , + ); + expect(screen.queryByTestId("ticktick-truncation-warning")).toBeNull(); + + rerender( + , + ); + expect(screen.getByTestId("ticktick-truncation-warning")).toHaveTextContent( + "200-item limit", + ); + }); + + it("renders one row per task, with an unmatched list shown as 'unnamed list'", () => { + render( + , + ); + const rows = screen.getAllByTestId("ticktick-task"); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveTextContent("buy milk"); + expect(rows[0]).toHaveTextContent("Inbox"); + expect(rows[1]).toHaveTextContent("unnamed list"); + }); + + it("filters LOCALLY by title over the one fetched result (A2 display-only filtering)", async () => { + const user = userEvent.setup(); + render( + , + ); + expect(screen.getAllByTestId("ticktick-task")).toHaveLength(2); + await user.type( + screen.getByRole("searchbox", { name: /filter tasks/i }), + "milk", + ); + const rows = screen.getAllByTestId("ticktick-task"); + expect(rows).toHaveLength(1); + expect(rows[0]).toHaveTextContent("buy milk"); + }); + + it("renders an all-day due in its own timezone, not UTC (Asia/Shanghai regression)", () => { + // An all-day 2026-08-20 in Asia/Shanghai (+8) is stored as the UTC instant + // of local midnight: 2026-08-19T16:00:00Z. Formatting in UTC would show + // Aug 19 (the previous day); the local zone must show Aug 20. + const date = "2026-08-19T16:00:00.000+0000"; + render( + , + ); + const rowText = screen.getByTestId("ticktick-task").textContent ?? ""; + const shanghai = new Date(date).toLocaleDateString(undefined, { + timeZone: "Asia/Shanghai", + }); + const utc = new Date(date).toLocaleDateString(undefined, { + timeZone: "UTC", + }); + expect(shanghai).not.toBe(utc); // the two zones disagree on the calendar day + expect(rowText).toContain(shanghai); + expect(rowText).not.toContain(utc); + }); + + it("shows a checklist progress count", () => { + render( + , + ); + expect(screen.getByTestId("ticktick-task")).toHaveTextContent("1/2"); + }); +}); diff --git a/apps/web/test/lib/hooks/useTickTick.test.tsx b/apps/web/test/lib/hooks/useTickTick.test.tsx new file mode 100644 index 00000000..899db86e --- /dev/null +++ b/apps/web/test/lib/hooks/useTickTick.test.tsx @@ -0,0 +1,341 @@ +import type { + TickTickStatusResult, + TickTickTasksListResult, +} from "@inkstone/protocol"; +import type { ConnectionStatus } from "@inkstone/ui-sdk"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { makeCoreRuntime } from "@test/test-utils/renderWithCore"; +import { act, render, renderHook, waitFor } from "@testing-library/react"; +import { Effect, Queue, Stream } from "effect"; +import type { ReactNode } from "react"; +import { describe, expect, it } from "vitest"; +import { + classifyTasksError, + TickTickReconnectSync, + useTickTick, +} from "@/lib/hooks/useTickTick.js"; +import { RuntimeProvider } from "@/runtime"; + +// The A2 reconnect protocol splits across two homes: `useTickTick` GATES the task +// read on the connection id, and the app-lifetime `TickTickReconnectSync` PURGES +// the cache on a real WS reconnect edge — NOT a mount's replayed `connected` +// (review F2/F5). Both are pinned here, deterministically (no stream timing). + +function wrapper(opts: { + status?: TickTickStatusResult; + tasks?: TickTickTasksListResult; + statusRead?: () => Effect.Effect; + tasksRead?: () => Effect.Effect; + onTasksCall?: () => void; + connectionStatus?: Stream.Stream; + queryClient: QueryClient; +}) { + const connectionStatus = opts.connectionStatus; + const runtime = makeCoreRuntime({ + overrides: { + tickTickStatus: + opts.statusRead ?? + (() => Effect.succeed(opts.status ?? { state: "not_connected" })), + tickTickTasksList: () => + Effect.suspend(() => { + opts.onTasksCall?.(); + return ( + opts.tasksRead?.() ?? + Effect.succeed( + opts.tasks ?? { tasks: [], source_limit_reached: false }, + ) + ); + }), + ...(connectionStatus ? { connectionStatus: () => connectionStatus } : {}), + }, + }); + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); +} + +const task = (id: string, title: string) => ({ + id, + title, + kind: "TEXT" as const, + priority: 0, + tags: [], + checklist_items: [], +}); + +describe("useTickTick task-read gating", () => { + it("gates the task read on a known connection id (never fetches while not connected)", async () => { + let tasksCalls = 0; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const { result } = renderHook(() => useTickTick(), { + wrapper: wrapper({ + status: { state: "not_connected" }, + onTasksCall: () => { + tasksCalls += 1; + }, + queryClient, + }), + }); + + await waitFor(() => expect(result.current.statusResolved).toBe(true)); + expect(result.current.connected).toBe(false); + // The task read is `enabled` only once an id is known — it never fired. + expect(tasksCalls).toBe(0); + expect(result.current.rows).toEqual([]); + }); + + it("a retry from disconnected awaits fresh status and then fetches the fresh key", async () => { + let statusCalls = 0; + let tasksCalls = 0; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const { result } = renderHook(() => useTickTick(), { + wrapper: wrapper({ + statusRead: () => + Effect.sync(() => { + statusCalls += 1; + return statusCalls === 1 + ? ({ state: "not_connected" } as const) + : ({ state: "connected", connection_id: "acct-B" } as const); + }), + tasksRead: () => + Effect.sync(() => { + tasksCalls += 1; + return { + tasks: [task("b-1", "B task")], + source_limit_reached: false, + }; + }), + queryClient, + }), + }); + + await waitFor(() => expect(result.current.statusResolved).toBe(true)); + expect(result.current.connected).toBe(false); + expect(tasksCalls).toBe(0); + + act(() => result.current.refresh()); + + await waitFor(() => + expect(result.current.rows.map((row) => row.id)).toEqual(["b-1"]), + ); + expect(statusCalls).toBe(2); + expect(tasksCalls).toBe(1); + expect( + queryClient.getQueryData(["ticktick", "tasks", "acct-B"]), + ).toMatchObject({ tasks: [{ id: "b-1" }] }); + }); + + it("clears account A immediately and waits for delayed account B status before fetching", async () => { + let resolveFreshStatus!: (status: TickTickStatusResult) => void; + const freshStatus = new Promise((resolve) => { + resolveFreshStatus = resolve; + }); + let statusCalls = 0; + let tasksCalls = 0; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const { result } = renderHook(() => useTickTick(), { + wrapper: wrapper({ + statusRead: () => { + statusCalls += 1; + return statusCalls === 1 + ? Effect.succeed({ + state: "connected", + connection_id: "acct-A", + } as const) + : Effect.promise(() => freshStatus); + }, + tasksRead: () => + Effect.sync(() => { + tasksCalls += 1; + return tasksCalls === 1 + ? { + tasks: [task("a-1", "A task")], + source_limit_reached: false, + } + : { + tasks: [task("b-1", "B task")], + source_limit_reached: false, + }; + }), + queryClient, + }), + }); + + await waitFor(() => + expect(result.current.rows.map((row) => row.id)).toEqual(["a-1"]), + ); + expect(tasksCalls).toBe(1); + + act(() => result.current.refresh()); + + await waitFor(() => expect(result.current.rows).toEqual([])); + expect( + queryClient.getQueryData(["ticktick", "tasks", "acct-A"]), + ).toBeUndefined(); + expect(tasksCalls).toBe(1); + expect(result.current.refreshing).toBe(true); + + await act(async () => { + resolveFreshStatus({ + state: "connected", + connection_id: "acct-B", + }); + await freshStatus; + }); + + await waitFor(() => + expect(result.current.rows.map((row) => row.id)).toEqual(["b-1"]), + ); + expect(statusCalls).toBe(2); + expect(tasksCalls).toBe(2); + expect( + queryClient.getQueryData(["ticktick", "tasks", "acct-A"]), + ).toBeUndefined(); + expect( + queryClient.getQueryData(["ticktick", "tasks", "acct-B"]), + ).toMatchObject({ tasks: [{ id: "b-1" }] }); + }); +}); + +describe("TickTickReconnectSync (app-lifetime cache purge)", () => { + it("drops task caches and resets status on a WS reconnect (disconnected→connected)", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + // Warm caches under the PRIOR account — the tab survived a Core restart. + queryClient.setQueryData(["ticktick", "tasks", "acct-A"], { + tasks: [ + { + id: "old", + title: "A's task", + kind: "TEXT", + priority: 0, + tags: [], + checklist_items: [], + }, + ], + source_limit_reached: false, + }); + queryClient.setQueryData(["ticktick", "status"], { + state: "connected", + connection_id: "acct-A", + }); + + render(, { + wrapper: wrapper({ + // A real reconnect EDGE: a drop, then back to connected. + connectionStatus: Stream.make( + "reconnecting" as ConnectionStatus, + "connected" as ConnectionStatus, + ), + queryClient, + }), + }); + + // The reconnect drops every task query AND resets status, so the next + // status read mints the fresh id — account A can never render under B. + await waitFor(() => + expect( + queryClient.getQueryData(["ticktick", "tasks", "acct-A"]), + ).toBeUndefined(), + ); + await waitFor(() => + expect(queryClient.getQueryData(["ticktick", "status"])).toBeUndefined(), + ); + }); + + it("leaves a warm cache intact on a plain mount replay (no prior drop)", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(["ticktick", "tasks", "acct-A"], { + tasks: [ + { + id: "warm", + title: "warm task", + kind: "TEXT", + priority: 0, + tags: [], + checklist_items: [], + }, + ], + source_limit_reached: false, + }); + + // A test-driven stream (not a canned one): first the mount replay, then — + // only after asserting survival — a REAL reconnect edge. The eventual purge + // proves the SAME ordered fiber consumed the replay first, so the survival + // assertion is ordering-based, not a fixed sleep (CodeRabbit #336). + const queue = Effect.runSync(Queue.unbounded()); + render(, { + wrapper: wrapper({ + connectionStatus: Stream.fromQueue(queue), + queryClient, + }), + }); + + // Mount replay: a lone `connected` with NO prior non-connected state + // (ADR-0051 `.changes` replays current on subscribe). + await Effect.runPromise( + Queue.offer(queue, "connected" as ConnectionStatus), + ); + // The replay is NOT a reconnect → the warm cache survives (review F2/F5). + expect( + queryClient.getQueryData(["ticktick", "tasks", "acct-A"]), + ).toBeDefined(); + + // Now a REAL edge through the same stream: the purge landing proves the + // observer processed the whole ordered sequence — replay included. + await Effect.runPromise( + Queue.offer(queue, "reconnecting" as ConnectionStatus), + ); + await Effect.runPromise( + Queue.offer(queue, "connected" as ConnectionStatus), + ); + await waitFor(() => + expect( + queryClient.getQueryData(["ticktick", "tasks", "acct-A"]), + ).toBeUndefined(), + ); + }); +}); + +// Review #4: the initial-vs-stale split keys on `data === undefined`, not row +// count. TanStack retains the last-good `data` through a failed refetch (a +// successful fetch then a refetch failure lands `status: 'error'` with the prior +// data still present) — so a valid EMPTY fetch (`{tasks: []}`) whose refetch +// later fails is a STALE error over an empty cache, NEVER an initial one. The +// old `rows.length === 0` could not tell that empty-but-valid cache from "no data +// yet"; this classifier can. +describe("classifyTasksError (initial vs stale)", () => { + it("classifies a failed refetch over a valid empty cache as stale, not initial", () => { + expect( + classifyTasksError(true, { tasks: [], source_limit_reached: false }), + ).toEqual({ tasksInitialError: false, tasksStaleError: true }); + }); + + it("classifies a failure with no prior fetch as initial", () => { + expect(classifyTasksError(true, undefined)).toEqual({ + tasksInitialError: true, + tasksStaleError: false, + }); + }); + + it("reports neither flag when there is no error", () => { + expect( + classifyTasksError(false, { tasks: [], source_limit_reached: false }), + ).toEqual({ tasksInitialError: false, tasksStaleError: false }); + expect(classifyTasksError(false, undefined)).toEqual({ + tasksInitialError: false, + tasksStaleError: false, + }); + }); +}); diff --git a/apps/web/test/store/bridge.test.tsx b/apps/web/test/store/bridge.test.tsx index 9701a187..9ab76439 100644 --- a/apps/web/test/store/bridge.test.tsx +++ b/apps/web/test/store/bridge.test.tsx @@ -33,6 +33,7 @@ import { } from "@/store/bridge.js"; import { appendMessage, + applyEvent, attachRun, getChatState, resetChatStore, @@ -358,11 +359,18 @@ describe("decideProposal resume fiber tracking (M2)", () => { }); describe("cancelRun (ADR-0014)", () => { - /** A stub whose subscribeRun never terminates and whose cancelRun returns `outcome`. */ + /** A stub whose subscribeRun never terminates and whose cancelRun returns + * `outcome` + `live_tail` (default false — no live stream event follows). */ function makeCancelRuntime(outcome: { readonly outcome: "accepted" | "already_terminal" | "unknown_run"; + readonly live_tail?: boolean; }) { - const cancelRun = vi.fn(() => Effect.succeed(outcome)); + const cancelRun = vi.fn(() => + Effect.succeed({ + outcome: outcome.outcome, + live_tail: outcome.live_tail ?? false, + }), + ); const subscribeRun = vi.fn( (_runId: RunId): Stream.Stream => Stream.fromQueue(Effect.runSync(Queue.unbounded())), @@ -422,6 +430,95 @@ describe("cancelRun (ADR-0014)", () => { await runtime.dispose(); }); + it("on accepted for a RUNNING run: applies the real interrupted tool_call + cancelled tail (A4)", async () => { + // Core pins the wire order response → interrupted tool_call(s) → + // cancelled (external-task-views A4). The bridge must let the live stream + // APPLY them — a synthetic settle would drop the interrupted result and + // diverge live from reload. + const queue = Effect.runSync(Queue.unbounded()); + const cancelRun = vi.fn(() => + Effect.sync(() => { + // The event tail lands right behind the response, as Core does. + Queue.unsafeOffer(queue, { + kind: "tool_call", + tool_call_id: "tc_ext", + name: "ticktick_filter_tasks", + status: "error", + result: { + content: [{ type: "text", text: "interrupted" }], + is_error: true, + }, + }); + Queue.unsafeOffer(queue, { kind: "cancelled" }); + // live_tail: true — the stream delivers the real interrupted + + // cancelled tail; the bridge must NOT synthesize its own. + return { outcome: "accepted" as const, live_tail: true }; + }), + ); + const subscribeRun = vi.fn( + (_runId: RunId): Stream.Stream => + Stream.fromQueue(queue), + ); + const runtime = makeCoreRuntime({ overrides: { subscribeRun, cancelRun } }); + const runId = "run-cancel-external" as RunId; + seedActiveRun(runId, runtime); + // The external call is in flight (a running row from the started event). + applyEvent("t1", runId, { + kind: "tool_call", + tool_call_id: "tc_ext", + name: "ticktick_filter_tasks", + status: "started", + }); + + await cancelRunBridge(runtime, runId); + // The stream applies the tail and its takeUntil reaps the fiber. + await vi.waitFor(() => { + expect(hasRunFiber(runId)).toBe(false); + }); + + const thread = getChatState().threads.t1; + const message = thread?.messages.find((m) => m.id === "a1"); + expect(message?.status).toBe("incomplete"); + expect(message?.cancelled).toBe(true); + // The row settled from the REAL interrupted event: error + the + // Core-generated result — the same object reload will serve. + const row = message?.segments.find((s) => s.kind === "tool_call"); + expect(row?.kind === "tool_call" && row.call).toEqual({ + id: "tc_ext", + name: "ticktick_filter_tasks", + status: "error", + arg: undefined, + result: { + content: [{ type: "text", text: "interrupted" }], + is_error: true, + }, + }); + + await runtime.dispose(); + }); + + it("on accepted WITHOUT a live tail: settles immediately off the response (no timer)", async () => { + // live_tail: false is Core's explicit "no stream event will follow" + // (parked, or the running-without-hub resume window). The bridge settles + // synthetically at once — no 3-second grace timer, no wedged Stop. + const { runtime } = makeCancelRuntime({ + outcome: "accepted", + live_tail: false, + }); + const runId = "run-cancel-no-tail" as RunId; + seedActiveRun(runId, runtime); + + await cancelRunBridge(runtime, runId); + + const thread = getChatState().threads.t1; + expect(thread?.messages.find((m) => m.id === "a1")?.status).toBe( + "incomplete", + ); + expect(hasRunFiber(runId)).toBe(false); + + await runtime.dispose(); + }); + it("on already_terminal for a RUNNING run: leaves it untouched (the stream owns the final state)", async () => { const { runtime, cancelRun } = makeCancelRuntime({ outcome: "already_terminal", @@ -541,7 +638,8 @@ describe("cancelRun (ADR-0014)", () => { const runtime = makeCoreRuntime({ overrides: { subscribeRun, - cancelRun: () => Effect.succeed({ outcome: "accepted" as const }), + cancelRun: () => + Effect.succeed({ outcome: "accepted" as const, live_tail: false }), retryRun: () => Effect.succeed({ outcome: "accepted" as const }), proposalDecide: () => Effect.promise(() => decideGate).pipe( diff --git a/apps/web/test/store/chat.test.tsx b/apps/web/test/store/chat.test.tsx index f01638dd..063b6868 100644 --- a/apps/web/test/store/chat.test.tsx +++ b/apps/web/test/store/chat.test.tsx @@ -211,6 +211,51 @@ describe("chat store + stream bridge", () => { await runtime.dispose(); }); + it("merges a terminal event's result into the existing external call (A4)", async () => { + const queue = Effect.runSync(Queue.unbounded()); + const runtime = makeStubRuntime(queue, "run-external"); + + await send(runtime, "threadA", "how many tasks tomorrow?"); + + Queue.unsafeOffer(queue, { + kind: "tool_call", + tool_call_id: "tc_ext", + name: "ticktick_filter_tasks", + status: "started", + }); + Queue.unsafeOffer(queue, { + kind: "tool_call", + tool_call_id: "tc_ext", + name: "ticktick_filter_tasks", + status: "completed", + result: { + content: [{ type: "text", text: "1 task found: S1 timed" }], + is_error: false, + }, + }); + Queue.unsafeOffer(queue, { kind: "done" }); + await awaitRun(runtime, "run-external"); + + const assistant = getChatState().threads.threadA?.messages[1]; + expect( + (assistant?.segments ?? []) + .filter((s) => s.kind === "tool_call") + .map((s) => s.call), + ).toEqual([ + { + id: "tc_ext", + name: "ticktick_filter_tasks", + status: "completed", + result: { + content: [{ type: "text", text: "1 task found: S1 timed" }], + is_error: false, + }, + }, + ]); + + await runtime.dispose(); + }); + it("maps a tool_call error status onto the matching row", async () => { const queue = Effect.runSync(Queue.unbounded()); const runtime = makeStubRuntime(queue, "run-tool-err"); @@ -550,6 +595,40 @@ describe("segment timeline (ADR-0045)", () => { expect(concatText(segs)).toBe("First. Second. Done."); }); + it("a snapshot ending with an OPEN reasoning block seals it on the next boundary (F4)", () => { + seedRun("tF4", "run-f4"); + + // A reconnect `snapshot` whose LAST segment is an OPEN reasoning block (no + // `duration_ms`). The wire snapshot carries no open-time, so applyEvent must + // RE-ANCHOR `reasoningOpenedAt` to `now`; without it a later boundary can + // never seal the block and it stays "Thinking…" forever (review F4). + applyEvent( + "tF4", + "run-f4", + { + kind: "snapshot", + segments: [ + { kind: "text", text: "hello" }, + { kind: "reasoning", text: "thinking" }, + ], + }, + 1_000, + ); + // Right after the snapshot the block is open (unsealed). + expect(segmentsOf("tF4", "run-f4")).toEqual([ + { kind: "text", text: "hello" }, + { kind: "reasoning", text: "thinking" }, + ]); + + // A later boundary seals it with a duration web-clocked from the snapshot instant. + applyEvent("tF4", "run-f4", { kind: "text_delta", delta: "world" }, 3_000); + expect(segmentsOf("tF4", "run-f4")).toEqual([ + { kind: "text", text: "hello" }, + { kind: "reasoning", text: "thinking", durationMs: 2_000 }, + { kind: "text", text: "world" }, + ]); + }); + it("appends a proposal segment exactly once per run (skip-if-present)", () => { seedRun("tDup", "run-dup"); const proposal = { diff --git a/apps/web/test/store/hydrate.test.tsx b/apps/web/test/store/hydrate.test.tsx index 45262db0..1ad6eafd 100644 --- a/apps/web/test/store/hydrate.test.tsx +++ b/apps/web/test/store/hydrate.test.tsx @@ -99,16 +99,31 @@ describe("refresh-durable hydration", () => { segments: [ { kind: "tool_call", + tool_call_id: "tc_1", name: "search_entities", status: "completed", arg: "Lev", }, { kind: "tool_call", + tool_call_id: "tc_2", name: "search_entities", status: "error", arg: "Acme", }, + // An external call reloads WITH its model-received result + // (external-task-views A4), so the row expands identically + // to the live one. + { + kind: "tool_call", + tool_call_id: "tc_ext", + name: "ticktick_filter_tasks", + status: "completed", + result: { + content: [{ type: "text", text: "1 task found" }], + is_error: false, + }, + }, { kind: "text", text: "done" }, ], }, @@ -127,23 +142,41 @@ describe("refresh-durable hydration", () => { await hydrateThread(runtime, "tTools"); const assistant = getChatState().threads.tTools?.messages[1]; + // The rows key on the DURABLE tool_call_id (external-task-views A4) — the + // same id the live `tool_call` Run Event carried, so the reload row keys + // identically to the live one. expect(assistant?.segments.filter((s) => s.kind === "tool_call")).toEqual([ { kind: "tool_call", call: { - id: "m2:seg:0", + id: "tc_1", name: "search_entities", status: "completed", arg: "Lev", + result: undefined, }, }, { kind: "tool_call", call: { - id: "m2:seg:1", + id: "tc_2", name: "search_entities", status: "error", arg: "Acme", + result: undefined, + }, + }, + { + kind: "tool_call", + call: { + id: "tc_ext", + name: "ticktick_filter_tasks", + status: "completed", + arg: undefined, + result: { + content: [{ type: "text", text: "1 task found" }], + is_error: false, + }, }, }, ]); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 1d74a7c6..03c91df7 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -2,11 +2,15 @@ name = "core" version = "0.1.0" edition = "2024" +# let-chains (used across the crate) stabilized in 1.88; CI runs unpinned stable. +rust-version = "1.88" [dependencies] anyhow = "1" axum = { version = "0.8", features = ["ws"] } base64 = "0.22" +libc = "0.2" +reqwest = { version = "0.12", features = ["json"] } rust-embed = { version = "8", features = ["mime-guess"] } schemars = "0.8" serde = { version = "1", features = ["derive"] } @@ -21,6 +25,7 @@ tower-http = { version = "0.6", features = ["fs"] } tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +url = "2" uuid = { version = "1", features = ["v7", "serde"] } [dev-dependencies] diff --git a/crates/core/src/cancel.rs b/crates/core/src/cancel.rs index dd145181..a8be8c62 100644 --- a/crates/core/src/cancel.rs +++ b/crates/core/src/cancel.rs @@ -1,121 +1,95 @@ //! Run cancellation as one deep, directly-testable verb (ADR-0029, extending the //! `proposal/decide` → [`crate::decide`] precedent to `run/cancel`). //! -//! [`cancel`] owns the whole decision: read the Run status, pick the parked vs -//! running guarded transition (ADR-0028), and on a won running-cancel perform the -//! Worker signal. It returns a typed [`Outcome`] — `Accepted` / `AlreadyTerminal` -//! / `UnknownRun` — the three [ADR-0014](../docs/adr/0014-client-core-wire-protocol.md) -//! result values, NOT error codes. The only failure channel is a DB fault, which -//! rides `anyhow::Error` (the handler maps it to `-32603`); the negative-but- -//! expected domain outcomes stay in the `Ok` payload (ADR-0029 "protocol error vs -//! result value"). +//! [`cancel`] owns the whole decision AND its side effects: read the Run status, +//! pick the parked vs running guarded transition (ADR-0028), and — for a won +//! running-cancel with a live hub — signal the Worker, frame the Response (via +//! the injected `respond`), settle + publish the interrupted external calls, and +//! publish the terminal `Cancelled`, ALL under ONE lifecycle-slot + hub-gate +//! acquisition. Holding both across the settle tx AND the raw event sends makes +//! generation changes and `run/subscribe` classification atomic with the whole +//! sequence. //! -//! The hub interaction is INJECTED as a closure (`get_hub`) so the decision + the -//! Worker signal are assertable against a `:memory:` pool without the live `Hubs` -//! registry — mirroring how [`crate::decide::apply`] injects `worker::resume` -//! (ADR-0026: the verb takes no new subsystem dependency). On a won running-cancel -//! the verb signals the live Worker and returns the won [`RunHub`] inside -//! [`Outcome::Accepted`]; the terminal `Cancelled` publish + `hub::remove` are -//! performed by [`publish_cancelled`], which the thin handler calls AFTER framing -//! its Response — preserving the deterministic `response → cancelled` wire order. +//! Response framing is INJECTED as `respond` so the verb frames it in the right +//! place (inside the gate, BEFORE the events — pinning the wire order +//! response → interrupted → cancelled) while staying assertable against a +//! `:memory:` pool. The only failure channel is a DB fault on `anyhow::Error` +//! (the handler maps it to `-32603`); expected domain outcomes ride the `respond` +//! string (`accepted` / `already_terminal` / `unknown_run`), ADR-0029. use sqlx::SqlitePool; use uuid::Uuid; use crate::db::{self, RunStatus}; -use crate::hub::{self, Hubs, RunHub}; +use crate::hub::{self, Hubs}; use crate::protocol::RunEvent; -/// The result of a cancel request (ADR-0014 result values, not error codes). The -/// handler maps each to its wire `outcome` string. -pub enum Outcome { - /// The Run was live (running) or parked and is now cancelling. For a won - /// running-cancel this carries the live [`RunHub`] the verb signalled, so the - /// handler can publish the terminal `Cancelled` AFTER framing its Response; a - /// parked cancel carries `None` (no live Worker to signal or publish for). - Accepted { hub: Option }, - /// The Run had already finished (terminal) or a concurrent winner committed the - /// terminal transition first — nothing to cancel. - AlreadyTerminal, - /// No Run with this id. - UnknownRun, -} - -/// Cancel a Run (ADR-0014, ADR-0028). Reads the status, picks the guarded -/// transition, and on a won running-cancel signals the live Worker via the -/// injected `get_hub`. Returns the typed [`Outcome`]; a DB fault is the only -/// `Err`. -/// -/// `get_hub` resolves the live [`RunHub`] for a run id (production: `|id| -/// hub::get(hubs, id)`); injected so the decision + Worker signal are testable -/// against `:memory:` without the live registry. -pub async fn cancel(pool: &SqlitePool, run_id: Uuid, get_hub: F) -> anyhow::Result -where - F: FnOnce(Uuid) -> Option, -{ +/// Cancel a Run (ADR-0014/0028/0029). The transient lifecycle slot makes +/// the durable status, active hub generation, and any terminal drain one +/// linearized decision. `respond(outcome, live_tail)` is framed before live +/// terminal events so the initiating connection observes response first. +pub async fn cancel( + pool: &SqlitePool, + hubs: &Hubs, + run_id: Uuid, + respond: impl FnOnce(&str, bool), +) -> anyhow::Result<()> { + let lifecycle = hub::lifecycle(hubs, run_id).await; match db::run_status(pool, run_id).await? { - // Unknown run id — an ADR-0014 result value, not an error code. - None => Ok(Outcome::UnknownRun), + None => respond("unknown_run", false), Some(RunStatus::Parked) => { - // Parked Run has no live Worker: a pure tier-2 flip of the Run + its - // pending Proposal. A rollback (no pending Proposal, or a concurrent - // decide/cancel already won) maps to AlreadyTerminal. - if db::cancel_parked_run(pool, run_id, db::now_ms()).await? { - Ok(Outcome::Accepted { hub: None }) - } else { - Ok(Outcome::AlreadyTerminal) - } + let accepted = db::cancel_parked_run(pool, run_id, db::now_ms()).await?; + respond( + if accepted { + "accepted" + } else { + "already_terminal" + }, + false, + ); } - Some(RunStatus::Running) => { - // Win the guarded running -> cancelled transition first; the DB - // transition is the user-visible outcome. On a win, signal the live - // Worker (cleanup) and hand the hub back so the handler publishes the - // terminal Cancelled AFTER framing its Response. - if db::cancel_running_run(pool, run_id, db::now_ms()).await?.won() { - let hub = get_hub(run_id); - if let Some(run_hub) = &hub { - run_hub.cancel(); + Some(RunStatus::Running) => match hub::get(hubs, run_id) { + Some(run_hub) => { + let gate = run_hub.gate().await; + match db::cancel_running_run(pool, run_id, db::now_ms()).await? { + db::Terminal::Won { interrupted } => { + run_hub.cancel(); + respond("accepted", true); + crate::worker::publish_interrupted(&run_hub, interrupted); + run_hub.send(RunEvent::Cancelled); + hub::remove_own(hubs, run_id, &run_hub, &lifecycle); + } + db::Terminal::Lost => respond("already_terminal", false), } - Ok(Outcome::Accepted { hub }) - } else { - // The Worker committed a terminal transition first. - Ok(Outcome::AlreadyTerminal) + drop(gate); } - } - // Completed, errored, or cancelled — the Run already ended. The terminal set - // is classified once by `is_terminal` (ADR-0028), not re-spelled here. - Some(status) if status.is_terminal() => Ok(Outcome::AlreadyTerminal), - // Unreachable: the two live states are matched above and the rest are - // terminal. A guarded arm does not count toward match exhaustiveness, so - // this explicit arm is required. - Some(_) => Ok(Outcome::AlreadyTerminal), + None => { + let terminal = db::cancel_running_run(pool, run_id, db::now_ms()).await?; + respond( + if terminal.won() { + "accepted" + } else { + "already_terminal" + }, + false, + ); + } + }, + Some(_) => respond("already_terminal", false), } -} - -/// Publish the terminal `Cancelled` Run Event and remove the hub, after a won -/// running-cancel. Called by the handler AFTER it frames the cancel Response, so -/// the client always sees `response → cancelled` (not a racing broadcast). The -/// gated publish (`lock → send → unlock`, ADR-0022) is -/// [`RunHub::publish_gated`]; then `hub::remove`. -pub async fn publish_cancelled(hubs: &Hubs, run_id: Uuid, hub: Option) { - let Some(run_hub) = hub else { - return; - }; - - run_hub.publish_gated(RunEvent::Cancelled).await; - - hub::remove(hubs, run_id); + Ok(()) } #[cfg(test)] mod tests { - use crate::db::test_support::memory_pool; - use super::{cancel, publish_cancelled, Outcome}; + use super::cancel; use crate::db; + use crate::db::test_support::memory_pool; use crate::hub; - use crate::protocol::RunEvent; + use crate::protocol::{RunEvent, ToolCallStatus, TranscriptToolResult}; use crate::workflow::Workflow; use sqlx::SqlitePool; + use std::cell::Cell; use uuid::Uuid; fn test_workflow() -> Workflow { @@ -127,6 +101,7 @@ mod tests { system_prompt: "sp".to_string(), thinking_level: Some("off".to_string()), tools: vec!["propose_workspace_mutation".to_string()], + external_tools: false, } } @@ -191,25 +166,41 @@ mod tests { .expect("count pending proposals") } - // 1. Parked Run → Accepted (no hub); the Run AND its pending Proposal flip to - // cancelled. The verb takes the pure tier-2 parked path; no Worker to signal. + /// Run `cancel`, recording the single `respond(outcome, live_tail)` call. + async fn cancel_recording(pool: &SqlitePool, hubs: &hub::Hubs, run_id: Uuid) -> (String, bool) { + let recorded: Cell> = Cell::new(None); + cancel(pool, hubs, run_id, |outcome, live_tail| { + recorded.set(Some((outcome.to_string(), live_tail))); + }) + .await + .expect("cancel ok"); + recorded + .into_inner() + .expect("respond was called exactly once") + } + + // 1. Parked Run → accepted (no live tail); the Run AND its pending Proposal + // flip to cancelled. Pure tier-2 parked path; no Worker to signal. #[tokio::test] async fn parked_run_is_accepted_and_flips_run_and_proposal() { let pool = memory_pool().await; let run_id = seed_parked_run(&pool).await; - assert_eq!(pending_proposal_count(&pool, run_id).await, 1, "seed: one pending proposal"); + assert_eq!( + pending_proposal_count(&pool, run_id).await, + 1, + "seed: one pending proposal" + ); - // get_hub must NOT be consulted on the parked path — a parked Run has no - // live Worker. Panic if it is, to pin the parked branch. - let outcome = cancel(&pool, run_id, |_| panic!("parked path must not touch the hub")) - .await - .expect("cancel ok"); + let hubs = hub::new_hubs(); + let (outcome, live_tail) = cancel_recording(&pool, &hubs, run_id).await; - assert!( - matches!(outcome, Outcome::Accepted { hub: None }), - "a parked cancel is Accepted with no hub" + assert_eq!(outcome, "accepted"); + assert!(!live_tail, "a parked cancel has no live tail"); + assert_eq!( + run_status_str(&pool, run_id).await, + Some("cancelled"), + "run cancelled" ); - assert_eq!(run_status_str(&pool, run_id).await, Some("cancelled"), "run cancelled"); assert_eq!( pending_proposal_count(&pool, run_id).await, 0, @@ -217,102 +208,140 @@ mod tests { ); } - // 2. Running Run, cancel WINS → Accepted carrying the live hub; the verb - // signalled the Worker (is_cancelled), and publish_cancelled then broadcasts - // Cancelled + removes the hub. + // 2. Running Run, cancel WINS → accepted with a live tail; the verb signals + // the Worker (is_cancelled), publishes the terminal Cancelled, and removes + // the hub — ALL inside one gated section (review P1 #3). #[tokio::test] - async fn running_won_signals_then_publishes_and_removes() { + async fn running_won_signals_publishes_and_removes_under_the_gate() { let pool = memory_pool().await; let run_id = seed_running_run(&pool).await; - // A real registered hub + a tail subscriber to observe the published event. let hubs = hub::new_hubs(); - let registered = hub::create(&hubs, run_id); + let registered = hub::register(&hubs, run_id).expect("fresh run registers"); let mut tail = registered.subscribe_raw(); - let outcome = cancel(&pool, run_id, |id| hub::get(&hubs, id)) - .await - .expect("cancel ok"); - - let hub = match outcome { - Outcome::Accepted { hub: Some(hub) } => hub, - _ => panic!("a won running-cancel is Accepted with the live hub"), - }; - assert_eq!(run_status_str(&pool, run_id).await, Some("cancelled"), "run cancelled"); - assert!(hub.is_cancelled(), "the verb signalled the live Worker"); - // No terminal event published yet — that's publish_cancelled's job, AFTER - // the handler frames its Response. - assert!(tail.try_recv().is_err(), "verb itself publishes no event"); - - publish_cancelled(&hubs, run_id, Some(hub)).await; + let (outcome, live_tail) = cancel_recording(&pool, &hubs, run_id).await; + assert_eq!(outcome, "accepted"); + assert!(live_tail, "a won running-cancel carries a live tail"); + assert_eq!( + run_status_str(&pool, run_id).await, + Some("cancelled"), + "run cancelled" + ); + assert!( + registered.is_cancelled(), + "the verb signalled the live Worker" + ); + // The terminal Cancelled was published under the gate (no separate step), + // and the hub was removed. assert!( matches!(tail.try_recv(), Ok(RunEvent::Cancelled)), - "publish_cancelled broadcasts the terminal Cancelled" + "the gated section broadcasts the terminal Cancelled" + ); + assert!( + hub::get(&hubs, run_id).is_none(), + "the hub is removed after publish" ); - assert!(hub::get(&hubs, run_id).is_none(), "the hub is removed after publish"); } - // 3. Running Run, but a terminal transition already committed → cancel LOSES the - // guard → AlreadyTerminal; no signal, no publish. + // 2b. Cancel-after-started (external-task-views A4): a running Run with a + // PENDING external call settles it as interrupted inside the SAME gated + // section, publishing interrupted `tool_call` → `Cancelled` in order. #[tokio::test] - async fn running_lost_to_committed_terminal_is_already_terminal() { + async fn cancel_after_external_started_publishes_interrupted_then_cancelled() { let pool = memory_pool().await; let run_id = seed_running_run(&pool).await; - // The Worker reached `done` first: commit the running -> completed move. - assert!( - db::complete_run(&pool, run_id, db::now_ms()).await.expect("complete").won(), - "seed: the run completes before the cancel" - ); + // The Worker reported an external call started; no finished frame yet. + db::persist_tool_call( + &pool, + run_id, + "tc-ext", + "ticktick_filter_tasks", + r#"{"filter":{"status":[0]}}"#, + db::now_ms(), + ) + .await + .expect("persist pending external call"); - let outcome = cancel(&pool, run_id, |_| panic!("a lost running-cancel must not touch the hub")) - .await - .expect("cancel ok"); + let hubs = hub::new_hubs(); + let registered = hub::register(&hubs, run_id).expect("fresh run registers"); + let mut tail = registered.subscribe_raw(); - assert!( - matches!(outcome, Outcome::AlreadyTerminal), - "a running-cancel that lost the guard is AlreadyTerminal" - ); + let (outcome, _live_tail) = cancel_recording(&pool, &hubs, run_id).await; + assert_eq!(outcome, "accepted"); + + // The row settled in the cancel transition, as an error carrying the + // Core-generated interrupted result. + let (status, payload): (String, Option) = + sqlx::query_as("SELECT status, result_payload FROM tool_calls WHERE id = 'tc-ext'") + .fetch_one(&pool) + .await + .expect("read settled row"); + assert_eq!(status, "errored"); assert_eq!( - run_status_str(&pool, run_id).await, - Some("completed"), - "the committed completion stands" + serde_json::from_str::(&payload.unwrap()).unwrap(), + TranscriptToolResult::interrupted() ); + + // Pinned order (all published under the one gate): interrupted tool_call + // event(s) BEFORE the terminal Cancelled. + let mut events = Vec::new(); + while let Ok(event) = tail.try_recv() { + events.push(event); + } + match events.as_slice() { + [ + RunEvent::ToolCall { + tool_call_id, + status: ToolCallStatus::Error, + result: Some(result), + .. + }, + RunEvent::Cancelled, + ] => { + assert_eq!(tool_call_id, "tc-ext"); + assert_eq!(*result, TranscriptToolResult::interrupted()); + } + other => panic!("expected [interrupted tool_call, Cancelled], got {other:?}"), + } } - // 4. A Run that already ended (terminal) → AlreadyTerminal, classified by - // is_terminal without re-reading the running/parked guards. + // 3. A Run that already ended (terminal) → already_terminal, classified by + // is_terminal without touching the hub. #[tokio::test] async fn terminal_run_is_already_terminal() { let pool = memory_pool().await; let run_id = seed_running_run(&pool).await; - assert!(db::complete_run(&pool, run_id, db::now_ms()).await.expect("complete").won()); + assert!( + db::complete_run(&pool, run_id, db::now_ms()) + .await + .expect("complete") + .won() + ); - let outcome = cancel(&pool, run_id, |_| panic!("a terminal Run must not touch the hub")) - .await - .expect("cancel ok"); + let hubs = hub::new_hubs(); + let (outcome, live_tail) = cancel_recording(&pool, &hubs, run_id).await; - assert!( - matches!(outcome, Outcome::AlreadyTerminal), - "cancelling an already-completed Run is AlreadyTerminal" + assert_eq!( + outcome, "already_terminal", + "cancelling a completed Run is already_terminal" ); + assert!(!live_tail); } - // 5. An id with no Run row → UnknownRun. + // 4. An id with no Run row → unknown_run. #[tokio::test] async fn unknown_run_is_unknown_run() { let pool = memory_pool().await; - let outcome = cancel(&pool, Uuid::now_v7(), |_| panic!("unknown Run must not touch the hub")) - .await - .expect("cancel ok"); - assert!( - matches!(outcome, Outcome::UnknownRun), - "an unknown run id is UnknownRun" - ); + let hubs = hub::new_hubs(); + let (outcome, live_tail) = cancel_recording(&pool, &hubs, Uuid::now_v7()).await; + assert_eq!(outcome, "unknown_run"); + assert!(!live_tail); } - // 6. Parked Run whose pending Proposal already vanished (a concurrent decide won) - // → the guarded parked transition rolls back → AlreadyTerminal. + // 5. Parked Run whose pending Proposal already vanished (a concurrent decide + // won) → the guarded parked transition rolls back → already_terminal. #[tokio::test] async fn parked_race_lost_is_already_terminal() { let pool = memory_pool().await; @@ -328,15 +357,18 @@ mod tests { .await .expect("force proposal accepted"); - let outcome = cancel(&pool, run_id, |_| panic!("a lost parked race must not touch the hub")) - .await - .expect("cancel ok"); + let hubs = hub::new_hubs(); + let (outcome, _live_tail) = cancel_recording(&pool, &hubs, run_id).await; - assert!( - matches!(outcome, Outcome::AlreadyTerminal), - "a parked cancel that lost the proposal race is AlreadyTerminal" + assert_eq!( + outcome, "already_terminal", + "a lost parked race is already_terminal" ); // The Run stays parked (the transition rolled back) — the live decide owns it. - assert_eq!(run_status_str(&pool, run_id).await, Some("parked"), "run stays parked"); + assert_eq!( + run_status_str(&pool, run_id).await, + Some("parked"), + "run stays parked" + ); } } diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index ed9be11d..8354ba0c 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -12,6 +12,11 @@ use std::time::Duration; /// The default one-shot collector timeout (titler + probe): 15 seconds. const DEFAULT_TIMEOUT_MS: u64 = 15_000; +/// A7's per-request bound for the TickTick OpenAPI lane (review R12 #6): its +/// own default (30s — a remote SaaS read, slower than local provider probes), +/// overridable via `INKSTONE_TICKTICK_TIMEOUT_MS` like every other timeout. +const TICKTICK_TIMEOUT_DEFAULT_MS: u64 = 30_000; + /// Boot-resolved configuration. Each field corresponds to one `INKSTONE_*` env /// var; `None` means "unset, use the runtime default" (e.g. derive from the OS /// data dir). @@ -27,11 +32,20 @@ pub struct Config { pub log_dir_override: Option, pub title_timeout: Duration, pub provider_test_timeout: Duration, + /// Per-request bound for TickTick OpenAPI reads (A7, review R12 #6). + pub ticktick_timeout: Duration, pub worker_pre_spawn_delay: Option, pub worker_log_path: Option, /// The one browser origin allowed to open `/ws` remotely (ADR-0007). /// Empty is unset; unset keeps remote browser origins closed. pub public_origin: Option, + /// Test-only override of the TickTick MCP endpoint (external-task-views + /// A7): production uses the compile-time const. Empty is unset. + pub ticktick_mcp_url_override: Option, + /// Test-only override of the TickTick OpenAPI base URL (external-task-views + /// A7): the fake-HTTP-server harness points the Web lane at a local + /// fixture. Production uses the compile-time const. Empty is unset. + pub ticktick_api_url_override: Option, } impl Default for Config { @@ -63,8 +77,15 @@ impl Config { .map(PathBuf::from), workflows_dir_override: get("INKSTONE_WORKFLOWS_DIR").map(PathBuf::from), log_dir_override: get("INKSTONE_LOG_DIR").map(PathBuf::from), - title_timeout: parse_timeout_ms(&get("INKSTONE_TITLE_TIMEOUT_MS")), - provider_test_timeout: parse_timeout_ms(&get("INKSTONE_PROVIDER_TEST_TIMEOUT_MS")), + title_timeout: parse_timeout_ms(&get("INKSTONE_TITLE_TIMEOUT_MS"), DEFAULT_TIMEOUT_MS), + provider_test_timeout: parse_timeout_ms( + &get("INKSTONE_PROVIDER_TEST_TIMEOUT_MS"), + DEFAULT_TIMEOUT_MS, + ), + ticktick_timeout: parse_timeout_ms( + &get("INKSTONE_TICKTICK_TIMEOUT_MS"), + TICKTICK_TIMEOUT_DEFAULT_MS, + ), worker_pre_spawn_delay: get("INKSTONE_WORKER_PRE_SPAWN_DELAY_MS") .and_then(|v| v.to_str().and_then(|s| s.parse::().ok())) .filter(|ms| *ms > 0) @@ -73,20 +94,26 @@ impl Config { public_origin: get("INKSTONE_PUBLIC_ORIGIN") .and_then(|value| value.into_string().ok()) .filter(|value| !value.is_empty()), + ticktick_mcp_url_override: get("INKSTONE_TICKTICK_MCP_URL") + .and_then(|value| value.into_string().ok()) + .filter(|value| !value.is_empty()), + ticktick_api_url_override: get("INKSTONE_TICKTICK_API_URL") + .and_then(|value| value.into_string().ok()) + .filter(|value| !value.is_empty()), } } } -/// Parse a timeout env var: unset, unparseable, or `0` falls back to 15s. +/// Parse a timeout env var: unset, unparseable, or `0` falls back to `default_ms`. /// `0` is rejected because a zero-length timeout fires instantly, turning every /// one-shot into a silent no-op. -fn parse_timeout_ms(raw: &Option) -> Duration { +fn parse_timeout_ms(raw: &Option, default_ms: u64) -> Duration { let ms = raw .as_ref() .and_then(|v| v.to_str()) .and_then(|s| s.parse::().ok()) .filter(|ms| *ms > 0) - .unwrap_or(DEFAULT_TIMEOUT_MS); + .unwrap_or(default_ms); Duration::from_millis(ms) } @@ -182,9 +209,12 @@ mod tests { env.insert("INKSTONE_LOG_DIR", "/tmp/logs"); env.insert("INKSTONE_TITLE_TIMEOUT_MS", "5000"); env.insert("INKSTONE_PROVIDER_TEST_TIMEOUT_MS", "3000"); + env.insert("INKSTONE_TICKTICK_TIMEOUT_MS", "250"); env.insert("INKSTONE_WORKER_PRE_SPAWN_DELAY_MS", "100"); env.insert("INKSTONE_WORKER_LOG_PATH", "/tmp/worker.jsonl"); env.insert("INKSTONE_PUBLIC_ORIGIN", "https://inkstone.example.com"); + env.insert("INKSTONE_TICKTICK_MCP_URL", "http://127.0.0.1:9/mcp"); + env.insert("INKSTONE_TICKTICK_API_URL", "http://127.0.0.1:9"); let cfg = Config::from_lookup(lookup(&env)); @@ -202,6 +232,7 @@ mod tests { assert_eq!(cfg.log_dir_override, Some(PathBuf::from("/tmp/logs"))); assert_eq!(cfg.title_timeout, Duration::from_millis(5000)); assert_eq!(cfg.provider_test_timeout, Duration::from_millis(3000)); + assert_eq!(cfg.ticktick_timeout, Duration::from_millis(250)); assert_eq!(cfg.worker_pre_spawn_delay, Some(Duration::from_millis(100))); assert_eq!( cfg.worker_log_path, @@ -211,6 +242,14 @@ mod tests { cfg.public_origin.as_deref(), Some("https://inkstone.example.com") ); + assert_eq!( + cfg.ticktick_mcp_url_override.as_deref(), + Some("http://127.0.0.1:9/mcp") + ); + assert_eq!( + cfg.ticktick_api_url_override.as_deref(), + Some("http://127.0.0.1:9") + ); } #[test] @@ -226,9 +265,12 @@ mod tests { assert_eq!(cfg.log_dir_override, None); assert_eq!(cfg.title_timeout, Duration::from_millis(15_000)); assert_eq!(cfg.provider_test_timeout, Duration::from_millis(15_000)); + assert_eq!(cfg.ticktick_timeout, Duration::from_millis(30_000)); assert_eq!(cfg.worker_pre_spawn_delay, None); assert_eq!(cfg.worker_log_path, None); assert_eq!(cfg.public_origin, None); + assert_eq!(cfg.ticktick_mcp_url_override, None); + assert_eq!(cfg.ticktick_api_url_override, None); } #[test] @@ -250,11 +292,13 @@ mod tests { let mut env = HashMap::new(); env.insert("INKSTONE_TITLE_TIMEOUT_MS", "0"); env.insert("INKSTONE_PROVIDER_TEST_TIMEOUT_MS", "0"); + env.insert("INKSTONE_TICKTICK_TIMEOUT_MS", "0"); let cfg = Config::from_lookup(lookup(&env)); assert_eq!(cfg.title_timeout, Duration::from_millis(15_000)); assert_eq!(cfg.provider_test_timeout, Duration::from_millis(15_000)); + assert_eq!(cfg.ticktick_timeout, Duration::from_millis(30_000)); } #[test] @@ -262,11 +306,13 @@ mod tests { let mut env = HashMap::new(); env.insert("INKSTONE_TITLE_TIMEOUT_MS", "not-a-number"); env.insert("INKSTONE_PROVIDER_TEST_TIMEOUT_MS", "abc"); + env.insert("INKSTONE_TICKTICK_TIMEOUT_MS", "invalid"); let cfg = Config::from_lookup(lookup(&env)); assert_eq!(cfg.title_timeout, Duration::from_millis(15_000)); assert_eq!(cfg.provider_test_timeout, Duration::from_millis(15_000)); + assert_eq!(cfg.ticktick_timeout, Duration::from_millis(30_000)); } #[test] diff --git a/crates/core/src/credentials.rs b/crates/core/src/credentials.rs index b1154cbb..bf7d9b7f 100644 --- a/crates/core/src/credentials.rs +++ b/crates/core/src/credentials.rs @@ -87,7 +87,7 @@ fn credentials_dir() -> Result { Ok(parent.join("credentials")) } -fn credential_path(provider: &str) -> Result { +pub(crate) fn credential_path(provider: &str) -> Result { // Defense-in-depth: `provider` becomes a filename, so a value containing a // path separator or `..` could escape the credentials dir. Handlers gate // against the known-provider allowlist, but reject traversal here too so no diff --git a/crates/core/src/db/lifecycle.rs b/crates/core/src/db/lifecycle.rs index 8e98ad48..1ac3e30a 100644 --- a/crates/core/src/db/lifecycle.rs +++ b/crates/core/src/db/lifecycle.rs @@ -25,6 +25,54 @@ impl Moved { } } +/// An external (`ticktick_*`) call a terminal transition settled as +/// interrupted (external-task-views A4): the Run died between the call's +/// started and finished frames. The caller publishes a +/// `tool_call {status: error, result: interrupted}` event per entry after the +/// transaction commits, before the terminal Run Event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InterruptedExternalCall { + pub tool_call_id: String, + pub name: String, +} + +/// A terminal transition's outcome. `Won` carries the external calls its settle +/// interrupted; `Lost` means a concurrent terminal transition already claimed the +/// Run. A lost move never has interrupted rows — the enum makes that +/// unrepresentable (was a struct whose "empty on a lost move" invariant lived in +/// prose). +#[derive(Debug)] +pub enum Terminal { + Won { + interrupted: Vec, + }, + Lost, +} + +impl Terminal { + pub fn won(&self) -> bool { + matches!(self, Terminal::Won { .. }) + } +} + +/// Settle the Run's still-pending external calls inside a won terminal +/// transition (external-task-views A4) — every terminal verb calls this, so +/// cancel, worker error, EOF, AND the boot recovery sweep share one rule. +async fn settle_interrupted_external_calls( + conn: &mut SqliteConnection, + run_id: Uuid, + now_ms: i64, +) -> sqlx::Result> { + let payload = serde_json::to_string(&crate::protocol::TranscriptToolResult::interrupted()) + .expect("TranscriptToolResult serializes"); + let rows = + queries::settle_pending_external_tool_calls(&mut *conn, run_id, &payload, now_ms).await?; + Ok(rows + .into_iter() + .map(|(tool_call_id, name)| InterruptedExternalCall { tool_call_id, name }) + .collect()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TerminalReason { Completed, @@ -85,17 +133,6 @@ impl RunStatus { } } - /// Whether the Run has ended. The terminal grouping lives here once, so a - /// read site asks the type instead of re-spelling the - /// `completed | errored | cancelled` set. `running`/`parked` are non-terminal - /// (a `parked` Run resumes; see CONTEXT.md *Run status*). - pub fn is_terminal(self) -> bool { - match self { - Self::Completed | Self::Errored | Self::Cancelled => true, - Self::Running | Self::Parked => false, - } - } - /// Whether the Run is parked, waiting on a Decision (ADR-0025). The parked /// classifier lives here once for the resume-gate and subscribe read sites. pub fn is_parked(self) -> bool { @@ -106,7 +143,7 @@ impl RunStatus { conn: &mut SqliteConnection, run_id: Uuid, now_ms: i64, - ) -> sqlx::Result { + ) -> sqlx::Result { debug_assert_eq!(Self::Running.as_str(), "running"); debug_assert_eq!(Self::Completed.as_str(), "completed"); let moved = Moved::from_rows( @@ -119,12 +156,13 @@ impl RunStatus { .await?, ); if !moved.won() { - return Ok(moved); + return Ok(Terminal::Lost); } queries::mark_assistant_messages_completed(&mut *conn, run_id, now_ms).await?; run_log::append(&mut *conn, run_id, RunLogKind::Done, None, now_ms).await?; - Ok(moved) + let interrupted = settle_interrupted_external_calls(&mut *conn, run_id, now_ms).await?; + Ok(Terminal::Won { interrupted }) } pub(super) async fn fail( @@ -134,7 +172,7 @@ impl RunStatus { error_code: &str, error_message: &str, now_ms: i64, - ) -> sqlx::Result { + ) -> sqlx::Result { debug_assert_eq!(Self::Running.as_str(), "running"); debug_assert_eq!(Self::Errored.as_str(), "errored"); let moved = Moved::from_rows( @@ -149,14 +187,15 @@ impl RunStatus { .await?, ); if !moved.won() { - return Ok(moved); + return Ok(Terminal::Lost); } queries::mark_streaming_messages_incomplete(&mut *conn, run_id, now_ms).await?; let payload = serde_json::json!({ "code": error_code, "message": error_message }).to_string(); run_log::append(&mut *conn, run_id, RunLogKind::Error, Some(&payload), now_ms).await?; - Ok(moved) + let interrupted = settle_interrupted_external_calls(&mut *conn, run_id, now_ms).await?; + Ok(Terminal::Won { interrupted }) } pub(super) async fn park( @@ -194,8 +233,8 @@ impl RunStatus { /// `resume` guards `parked` and would match 0 rows here. On `won()` the terminal /// fields are cleared back to live by the guarded query, and the retry milestone /// reuses [`RunLogKind::Running`] — the same "now running" moment a fresh start - /// logs (no new kind, no `run_log` CHECK change). `is_terminal()` and the boot - /// sweep are unchanged; this is the single user-initiated exception. + /// logs (no new kind, no `run_log` CHECK change). The boot sweep is + /// unchanged; this is the single user-initiated exception. pub(super) async fn retry( conn: &mut SqliteConnection, run_id: Uuid, @@ -234,6 +273,10 @@ impl RunStatus { } queries::mark_streaming_messages_incomplete(&mut *conn, run_id, now_ms).await?; + // Settle still-pending external rows in this transition too — the one + // rule EVERY terminal verb shares. A parked Run has no live tail, so the + // settled rows surface on reload/late-subscribe, not as events. + settle_interrupted_external_calls(&mut *conn, run_id, now_ms).await?; let payload = serde_json::json!({ "target": "run" }).to_string(); run_log::append(&mut *conn, run_id, RunLogKind::Cancelled, Some(&payload), now_ms).await?; Ok(moved) @@ -243,7 +286,7 @@ impl RunStatus { conn: &mut SqliteConnection, run_id: Uuid, now_ms: i64, - ) -> sqlx::Result { + ) -> sqlx::Result { debug_assert_eq!(Self::Running.as_str(), "running"); debug_assert_eq!(Self::Cancelled.as_str(), "cancelled"); let moved = Moved::from_rows( @@ -256,13 +299,14 @@ impl RunStatus { .await?, ); if !moved.won() { - return Ok(moved); + return Ok(Terminal::Lost); } queries::mark_streaming_messages_incomplete(&mut *conn, run_id, now_ms).await?; let payload = serde_json::json!({ "target": "run" }).to_string(); run_log::append(&mut *conn, run_id, RunLogKind::Cancelled, Some(&payload), now_ms).await?; - Ok(moved) + let interrupted = settle_interrupted_external_calls(&mut *conn, run_id, now_ms).await?; + Ok(Terminal::Won { interrupted }) } } @@ -432,14 +476,6 @@ mod tests { assert_eq!(RunStatus::from_str("bogus"), None); assert_eq!(RunStatus::from_str(""), None); - // Terminal grouping: completed/errored/cancelled are terminal; the two - // live states are not. - assert!(RunStatus::Completed.is_terminal()); - assert!(RunStatus::Errored.is_terminal()); - assert!(RunStatus::Cancelled.is_terminal()); - assert!(!RunStatus::Running.is_terminal()); - assert!(!RunStatus::Parked.is_terminal()); - // Parked grouping: only `parked`. assert!(RunStatus::Parked.is_parked()); for status in [ diff --git a/crates/core/src/db/message_fts.rs b/crates/core/src/db/message_fts.rs index 2ee35daa..e6da9dab 100644 --- a/crates/core/src/db/message_fts.rs +++ b/crates/core/src/db/message_fts.rs @@ -61,6 +61,7 @@ mod tests { system_prompt: String::new(), thinking_level: None, tools: Vec::new(), + external_tools: false, } } diff --git a/crates/core/src/db/mod.rs b/crates/core/src/db/mod.rs index 310ce0c6..f775d412 100644 --- a/crates/core/src/db/mod.rs +++ b/crates/core/src/db/mod.rs @@ -30,6 +30,7 @@ use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; pub use intent_graph::{IntentGraphOutcome, apply_intent_graph_proposal, resolved_plan_for}; pub(crate) use intent_graph::validate_intent_graph_payload; pub use lifecycle::Moved; +pub use lifecycle::{InterruptedExternalCall, Terminal}; // `RunStatus` is the read+write Interface for Run status: the write verbs live on // it (ADR-0028), and the read seam (`run_status`, `RunSnapshot.status`) now returns // it too, so read sites match compiler-checked variants instead of raw strings @@ -74,12 +75,13 @@ pub(crate) use queries::PartType; // `RunSnapshot` is not re-exported: its one consumer (`run/subscribe`) reads // fields off `select_run_snapshot`'s return without naming the type. pub use runs::{ - AttachmentSeed, TimelineStep, append_assistant_part, assistant_message_id_for_run, - cancel_parked_run, cancel_running_run, complete_run, error_run, error_run_with_message, - history_for_run, list_run_history, mark_run_running, open_assistant_part, - persist_initial_run, persist_thread_with_first_run, persist_tool_call, prepare_retry, - read_run_timeline, recover_interrupted_runs, resolve_tool_call, run_prompt_and_thread, - run_status, run_workflow_snapshot, select_run_snapshot, + AttachmentSeed, ExternalToolFinish, TimelineStep, append_assistant_part, + assistant_message_id_for_run, begin_external_tool_call, cancel_parked_run, cancel_running_run, + complete_run, error_run, error_run_with_message, finish_external_tool_call, history_for_run, + list_run_history, mark_run_running, open_assistant_part, persist_initial_run, + persist_thread_with_first_run, persist_tool_call, prepare_retry, read_run_timeline, + recover_interrupted_runs, resolve_tool_call, run_prompt_and_thread, run_status, + run_workflow_snapshot, select_run_snapshot, }; // Result/row types no caller names (`Backlinks`, `CurrentEntityRow`, // `ResolvedEntityRef`) and the V0-internal GTD reads (`todos_by_*`, consumed @@ -95,7 +97,7 @@ pub use entities_read::{ // binary-only crate an unreachable re-export trips `unused_imports`. pub use threads::{ MessageSegment, archive_thread, get_thread_with_messages, list_archived_threads, - list_threads, thread_exists, unarchive_thread, update_thread_title, + list_threads, run_live_segments, thread_exists, unarchive_thread, update_thread_title, }; /// Current wall-clock time as ms since UNIX_EPOCH (the `*_at` columns). diff --git a/crates/core/src/db/queries.rs b/crates/core/src/db/queries.rs index bc0af633..74e13f43 100644 --- a/crates/core/src/db/queries.rs +++ b/crates/core/src/db/queries.rs @@ -2398,26 +2398,19 @@ where pub(super) async fn select_run_snapshot<'e, E>( executor: E, run_id: Uuid, -) -> sqlx::Result, String)>> +) -> sqlx::Result)>> where E: Executor<'e, Database = Sqlite>, { - let row: Option<(Option, String)> = sqlx::query_as( - "SELECT ( \ - SELECT group_concat(text, '') FROM ( \ - SELECT text FROM message_parts \ - WHERE message_id = m.id AND type = 'text' ORDER BY seq \ - ) \ - ) AS text, \ - r.status \ - FROM runs r \ - JOIN messages m ON m.run_id = r.id AND m.role = 'assistant' \ - WHERE r.id = ?1", - ) - .bind(run_id.to_string()) - .fetch_optional(executor) - .await?; - Ok(row) + // `(status, error_message)` — naturally authoritative (`None` iff the Run + // does not exist), the property the errored-late-subscribe fix relies on. + // The assistant text is no longer read here: the `run/subscribe` timeline + // now rides the ordered segment `Snapshot` (review P1 #2), and `thread/get` + // assembles its own via `segment_timeline`. + sqlx::query_as("SELECT status, error_message FROM runs WHERE id = ?1") + .bind(run_id.to_string()) + .fetch_optional(executor) + .await } /// Read the Workflow fields a Run snapshotted at its start (ADR-0024): the @@ -2557,6 +2550,117 @@ where .map(|_| ()) } +/// Insert a pending EXTERNAL tool-call row ONLY while its Run is still +/// `running` (external-task-views A4): the Worker executes MCP tools itself and +/// its frames race the Run's terminal transitions. A started frame that arrives +/// after the Run was cancelled/errored must NOT land an orphan pending row, so +/// the insert is guarded on the Run's live status. Returns rows affected (1 = +/// inserted, 0 = the Run was already terminal → the caller publishes nothing). +pub(super) async fn insert_external_tool_call_if_running<'e, E>( + executor: E, + id: &str, + run_id: Uuid, + name: &str, + request_payload: &str, + now_ms: i64, +) -> sqlx::Result +where + E: Executor<'e, Database = Sqlite>, +{ + Ok(sqlx::query( + "INSERT INTO tool_calls (id, run_id, name, request_payload, status, requested_at) \ + SELECT ?, ?, ?, ?, 'pending', ? \ + WHERE EXISTS (SELECT 1 FROM runs WHERE id = ? AND status = 'running')", + ) + .bind(id) + .bind(run_id.to_string()) + .bind(name) + .bind(request_payload) + .bind(now_ms) + .bind(run_id.to_string()) + .execute(executor) + .await? + .rows_affected()) +} + +/// Resolve an EXTERNAL tool-call row, guarded on `run_id` AND `status='pending'` +/// (external-task-views A4). Scoping by `run_id` means a stray id can never +/// resolve another Run's row; the `pending` guard means a finished frame that +/// races a terminal settle (cancel/EOF, which flips the row to `errored` with +/// the interrupted result) matches 0 rows and loses — it can never clobber a +/// settled row to a success-shaped result. `RETURNING name` hands back the +/// resolved row's tool name (`Some` = won), so the caller publishes the finished +/// event without tracking id→name pairings itself — the pending guard is the one +/// pairing authority (review M1). +pub(super) async fn resolve_external_tool_call<'e, E>( + executor: E, + id: &str, + run_id: Uuid, + status: &str, + result_payload: &str, + now_ms: i64, +) -> sqlx::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + sqlx::query_scalar( + "UPDATE tool_calls SET status = ?, result_payload = ?, resolved_at = ? \ + WHERE id = ? AND run_id = ? AND status = 'pending' \ + RETURNING name", + ) + .bind(status) + .bind(result_payload) + .bind(now_ms) + .bind(id) + .bind(run_id.to_string()) + .fetch_optional(executor) + .await +} + +/// Read the status of one external call scoped to its Run. Used only after a +/// guarded resolve matched no pending row, to distinguish settled from missing. +pub(super) async fn external_tool_call_status<'e, E>( + executor: E, + id: &str, + run_id: Uuid, +) -> sqlx::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + sqlx::query_scalar("SELECT status FROM tool_calls WHERE id = ? AND run_id = ?") + .bind(id) + .bind(run_id.to_string()) + .fetch_optional(executor) + .await +} + +/// Settle every still-pending EXTERNAL (`ticktick_*`) call of a terminating +/// Run with the Core-generated interrupted result. Returns the settled +/// `(id, name)` pairs for post-commit publication. +pub(super) async fn settle_pending_external_tool_calls<'e, E>( + executor: E, + run_id: Uuid, + result_payload: &str, + now_ms: i64, +) -> sqlx::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + let prefix = crate::tools::EXTERNAL_TOOL_PREFIX; + sqlx::query_as( + "UPDATE tool_calls SET status = 'errored', result_payload = ?, resolved_at = ? \ + WHERE run_id = ? AND status = 'pending' AND substr(name, 1, ?) = ? \ + RETURNING id, name", + ) + .bind(result_payload) + .bind(now_ms) + .bind(run_id.to_string()) + .bind(prefix.len() as i64) + .bind(prefix) + .fetch_all(executor) + .await +} + /// Insert a `run_steps` row of kind `tool_call`, interleaving the tool call /// into the Run timeline at `seq`. pub(super) async fn insert_tool_call_run_step<'e, E>( @@ -2634,7 +2738,7 @@ where { sqlx::query_as( "SELECT rs.kind, mp.text, \ - tc.name, tc.status, tc.request_payload, \ + tc.id, tc.name, tc.status, tc.request_payload, tc.result_payload, \ p.id, p.mutation_kind, p.status, \ mp.type, \ ( \ @@ -2663,45 +2767,51 @@ where .bind(assistant_message_id) .fetch_all(executor) .await - .map(|rows: Vec<(_, _, _, _, _, _, _, _, _, Option, Option, i64)>| { - rows.into_iter() - .map( - |( - kind, - part_text, - tc_name, - tc_status, - request_payload, - proposal_id, - mutation_kind, - proposal_status, - part_type, - duration_to_next, - run_ended_at, - step_created_at, - )| { - // Resolve the reasoning span at the seam: the next step's - // delta if there is one, else `run.ended_at − created_at`. - // A negative span (clock skew) or an unknown end → None. - let duration_ms = duration_to_next - .or_else(|| run_ended_at.map(|end| end - step_created_at)) - .filter(|&d| d >= 0); - SegmentTimelineRow { + .map( + |rows: Vec<(_, _, _, _, _, _, _, _, _, _, _, Option, Option, i64)>| { + rows.into_iter() + .map( + |( kind, part_text, - part_type, + tool_call_id, tc_name, tc_status, request_payload, + result_payload, proposal_id, mutation_kind, proposal_status, - duration_ms, - } - }, - ) - .collect() - }) + part_type, + duration_to_next, + run_ended_at, + step_created_at, + )| { + // Resolve the reasoning span at the seam: the next step's + // delta if there is one, else `run.ended_at − created_at`. + // A negative span (clock skew) or an unknown end → None. + let duration_ms = duration_to_next + .or_else(|| run_ended_at.map(|end| end - step_created_at)) + .filter(|&d| d >= 0); + SegmentTimelineRow { + kind, + part_text, + part_type, + tool_call_id, + tc_name, + tc_status, + request_payload, + result_payload, + proposal_id, + mutation_kind, + proposal_status, + duration_ms, + } + }, + ) + .collect() + }, + ) } /// One row of the [`segment_timeline`] walk, named so the caller @@ -2714,9 +2824,11 @@ pub(super) struct SegmentTimelineRow { pub kind: String, pub part_text: Option, pub part_type: Option, + pub tool_call_id: Option, pub tc_name: Option, pub tc_status: Option, pub request_payload: Option, + pub result_payload: Option, pub proposal_id: Option, pub mutation_kind: Option, pub proposal_status: Option, diff --git a/crates/core/src/db/runs.rs b/crates/core/src/db/runs.rs index aa735304..4871ffa3 100644 --- a/crates/core/src/db/runs.rs +++ b/crates/core/src/db/runs.rs @@ -9,7 +9,7 @@ use uuid::Uuid; // Lifecycle types come through the facade's re-exports (mod.rs keeps their // ADR-0028/0029 annotations), not `super::lifecycle` directly, so the facade // surface stays the one import path. -use super::{Moved, ProposalStatus, RunStatus, TerminalReason}; +use super::{Moved, ProposalStatus, RunStatus, Terminal, TerminalReason}; use super::queries::{self, PartType}; use super::run_log; use crate::workflow::Workflow; @@ -345,6 +345,83 @@ pub async fn resolve_tool_call( queries::resolve_tool_call(pool, tool_call_id, status, result_payload, now_ms).await } +/// Begin an EXTERNAL (Worker-executed MCP) tool call (external-task-views A4): +/// persist the pending row + its `run_steps` entry in one transaction, GUARDED +/// so the insert lands only while the Run is still `running`. Returns +/// [`Moved::Won`] when the row was inserted, [`Moved::Lost`] when the Run had +/// already gone terminal (a started frame that raced cancellation/EOF) — the +/// caller publishes the started event ONLY on a win, so a phantom row can never +/// reach a client. +pub async fn begin_external_tool_call( + pool: &SqlitePool, + run_id: Uuid, + tool_call_id: &str, + name: &str, + request_payload: &str, + now_ms: i64, +) -> sqlx::Result { + let mut tx = pool.begin().await?; + let inserted = queries::insert_external_tool_call_if_running( + &mut *tx, + tool_call_id, + run_id, + name, + request_payload, + now_ms, + ) + .await?; + if inserted == 0 { + // The Run is no longer running — no row, no step, no event. + return Ok(Moved::Lost); + } + let seq = queries::next_run_step_seq(&mut *tx, run_id).await?; + queries::insert_tool_call_run_step(&mut *tx, run_id, seq, tool_call_id, now_ms).await?; + tx.commit().await?; + Ok(Moved::Won) +} + +/// The authoritative outcome of pairing an external finished frame with its +/// durable started row. +#[derive(Debug, PartialEq, Eq)] +pub enum ExternalToolFinish { + Resolved(String), + AlreadySettled, + Missing, +} + +/// Finish an EXTERNAL tool call, distinguishing a resolved pending row from an +/// already-settled row and a frame that was never started. The update + fallback +/// classification share one transaction, so cancellation cannot change the +/// answer between them. +pub async fn finish_external_tool_call( + pool: &SqlitePool, + run_id: Uuid, + tool_call_id: &str, + status: &str, + result_payload: &str, + now_ms: i64, +) -> sqlx::Result { + let mut tx = pool.begin().await?; + let resolved = queries::resolve_external_tool_call( + &mut *tx, + tool_call_id, + run_id, + status, + result_payload, + now_ms, + ) + .await?; + let outcome = match resolved { + Some(name) => ExternalToolFinish::Resolved(name), + None => match queries::external_tool_call_status(&mut *tx, tool_call_id, run_id).await? { + Some(_) => ExternalToolFinish::AlreadySettled, + None => ExternalToolFinish::Missing, + }, + }; + tx.commit().await?; + Ok(outcome) +} + /// Read a Run's [`RunStatus`] (ADR-0025); `None` when the Run does not exist. /// Backs `run/subscribe`'s parked branch and the forwarder's no-false-done check. /// @@ -406,18 +483,21 @@ pub async fn cancel_parked_run(pool: &SqlitePool, run_id: Uuid, now_ms: i64) -> Ok(true) } -/// Cancel a running Run in one guarded transition. Returns `Won` only if the -/// Run was still `running`; a lost race means a Worker terminal transition got -/// there first and the caller maps the cancel request to `already_terminal`. +/// Cancel a running Run in one guarded transition. Wins only if the Run was +/// still `running`; a lost race means a Worker terminal transition got there +/// first and the caller maps the cancel request to `already_terminal`. The won +/// transition also settles still-pending external calls as interrupted +/// (external-task-views A4) — the returned [`Terminal`] carries them for the +/// post-commit `tool_call` event publishes. pub async fn cancel_running_run( pool: &SqlitePool, run_id: Uuid, now_ms: i64, -) -> sqlx::Result { +) -> sqlx::Result { let mut tx = pool.begin().await?; - let moved = RunStatus::cancel_running(&mut *tx, run_id, now_ms).await?; + let terminal = RunStatus::cancel_running(&mut *tx, run_id, now_ms).await?; tx.commit().await?; - Ok(moved) + Ok(terminal) } /// Prepare an errored Run for in-place retry (ADR-0028 retry amendment, #230) in @@ -596,22 +676,24 @@ pub async fn read_run_timeline(pool: &SqlitePool, run_id: Uuid) -> sqlx::Result< Ok(steps) } -/// A Run's snapshot for `run/subscribe` (ADR-0022): the assistant message's -/// cumulative text at the subscribe instant plus the Run's status. `text` is -/// empty for a Run that has streamed no delta yet. +/// A Run's terminal-state snapshot for `run/subscribe` (ADR-0022): the Run's +/// status + persisted error message at the subscribe instant. The assistant +/// timeline now rides the ordered segment `Snapshot` (review P1 #2), so this no +/// longer carries text. #[derive(Debug)] pub struct RunSnapshot { - pub text: String, - /// The Run's [`RunStatus`]. Part of the ADR-0022 snapshot shape; the - /// subscribe handler reads it to tell terminal from live under the gate, and - /// the `thread/get` rehydration read consumes it in a later slice. + /// The Run's [`RunStatus`]. The subscribe handler reads it under the gate to + /// tell terminal from live and pick the terminal event. pub status: RunStatus, + /// The persisted `error_message` when the Run settled `errored` (`None` + /// otherwise). Lets `run/subscribe` emit a faithful `RunEvent::Error` for a + /// re-attach to an already-errored Run, rather than mis-reporting `done`. + pub error_message: Option, } -/// Read the snapshot-then-tail starting point: the assistant message's -/// cumulative text (all `message_parts` concatenated in `seq` order) and the -/// Run status. `None` when the Run does not exist (subscribe handler stays -/// defensible against unknown run ids). +/// Read the Run's status + error message. `None` when the Run does not exist +/// (subscribe handler stays defensible against unknown run ids — the property +/// the errored-late-subscribe fix relies on). /// /// The stored status is parsed into [`RunStatus`] at this seam; an unknown value /// surfaces as a loud `sqlx::Error::Decode` (see [`run_status`]). @@ -619,14 +701,14 @@ pub async fn select_run_snapshot( pool: &SqlitePool, run_id: Uuid, ) -> sqlx::Result> { - let Some((text, status)) = queries::select_run_snapshot(pool, run_id).await? else { + let Some((status, error_message)) = queries::select_run_snapshot(pool, run_id).await? else { return Ok(None); }; let status = RunStatus::from_str(&status) .ok_or_else(|| sqlx::Error::Decode(format!("unknown stored run status {status:?}").into()))?; Ok(Some(RunSnapshot { - text: text.unwrap_or_default(), status, + error_message, })) } @@ -656,6 +738,7 @@ pub async fn run_workflow_snapshot( thinking_level: Some(thinking_level), system_prompt: base.system_prompt.clone(), tools: base.tools.clone(), + external_tools: base.external_tools, }, )) } @@ -663,18 +746,18 @@ pub async fn run_workflow_snapshot( /// Clean termination on the Worker's `done`: flip `runs` and the assistant /// `messages` row to `completed` and append a `done` `run_log` row, in one /// transaction so a reader never sees an in-between mix. -pub async fn complete_run(pool: &SqlitePool, run_id: Uuid, now_ms: i64) -> sqlx::Result { +pub async fn complete_run(pool: &SqlitePool, run_id: Uuid, now_ms: i64) -> sqlx::Result { let mut tx = pool.begin().await?; - let moved = RunStatus::complete(&mut *tx, run_id, now_ms).await?; + let terminal = RunStatus::complete(&mut *tx, run_id, now_ms).await?; tx.commit().await?; - Ok(moved) + Ok(terminal) } /// Worker stdout EOF without a `done` event (worker died/killed/hung up). Flip /// `runs` to `errored` (`terminal_reason='worker_disconnected'`), every /// `streaming` Message to `incomplete` (ADR-0017 invariant), and append an /// `error` `run_log` row. One transaction. -pub async fn error_run(pool: &SqlitePool, run_id: Uuid, now_ms: i64) -> sqlx::Result { +pub async fn error_run(pool: &SqlitePool, run_id: Uuid, now_ms: i64) -> sqlx::Result { error_run_with_message( pool, run_id, @@ -698,13 +781,13 @@ pub async fn error_run_with_message( error_code: &str, error_message: &str, now_ms: i64, -) -> sqlx::Result { +) -> sqlx::Result { let mut tx = pool.begin().await?; - let moved = + let terminal = RunStatus::fail(&mut *tx, run_id, terminal_reason, error_code, error_message, now_ms) .await?; tx.commit().await?; - Ok(moved) + Ok(terminal) } /// Boot recovery sweep (ADR-0012): on Core start, force-error every `running` @@ -720,7 +803,7 @@ pub async fn recover_interrupted_runs(pool: &SqlitePool, now_ms: i64) -> sqlx::R let mut swept: u64 = 0; for id in queries::select_running_run_ids(&mut *tx).await? { let run_id = Uuid::parse_str(&id).map_err(|e| sqlx::Error::Decode(e.into()))?; - let moved = RunStatus::fail( + let terminal = RunStatus::fail( &mut *tx, run_id, TerminalReason::CoreRestarted, @@ -729,7 +812,7 @@ pub async fn recover_interrupted_runs(pool: &SqlitePool, now_ms: i64) -> sqlx::R now_ms, ) .await?; - swept += moved.won() as u64; + swept += terminal.won() as u64; } tx.commit().await?; Ok(swept) @@ -943,15 +1026,79 @@ mod tests { .expect("count run events") } + /// The terminal settle (external-task-views A4) touches ONLY still-pending + /// EXTERNAL rows: a pending Core row stays pending (the reload filter owns + /// it, as before), and an already-resolved external row keeps its real + /// result. The boot recovery sweep — funnelled through the same + /// `RunStatus::fail` — settles too. + #[tokio::test] + async fn terminal_settle_interrupts_only_pending_external_rows() { + let pool = memory_pool().await; + let run_id = Uuid::parse_str("55555555-5555-4555-8555-555555555555").unwrap(); + insert_bare_run(&pool, &run_id.to_string(), "running").await; + // Three rows: a pending external, a pending CORE, a completed external. + persist_tool_call(&pool, run_id, "tc-ext-pending", "ticktick_search_task", "{}", 1) + .await + .expect("pending external"); + persist_tool_call(&pool, run_id, "tc-core-pending", "read_thread", "{}", 1) + .await + .expect("pending core"); + persist_tool_call(&pool, run_id, "tc-ext-done", "ticktick_filter_tasks", "{}", 1) + .await + .expect("resolved external"); + resolve_tool_call( + &pool, + "tc-ext-done", + "completed", + r#"{"content":[{"type":"text","text":"1 task found"}],"is_error":false}"#, + 2, + ) + .await + .expect("resolve external"); + + // Boot recovery (RunStatus::fail funnel) is a terminal transition too. + let swept = recover_interrupted_runs(&pool, 42).await.expect("sweep"); + assert_eq!(swept, 1); + + let rows: Vec<(String, String, Option)> = sqlx::query_as( + "SELECT id, status, result_payload FROM tool_calls WHERE run_id = ?1 ORDER BY id", + ) + .bind(run_id.to_string()) + .fetch_all(&pool) + .await + .expect("read rows"); + let by_id: std::collections::HashMap<&str, (&str, Option<&str>)> = rows + .iter() + .map(|(id, status, payload)| (id.as_str(), (status.as_str(), payload.as_deref()))) + .collect(); + assert_eq!( + by_id["tc-ext-pending"], + ( + "errored", + Some(r#"{"content":[{"type":"text","text":"interrupted"}],"is_error":true}"#) + ), + "the pending external row settles as interrupted" + ); + assert_eq!( + by_id["tc-core-pending"], + ("pending", None), + "a pending Core row is never touched" + ); + assert_eq!( + by_id["tc-ext-done"].0, "completed", + "an already-resolved external row keeps its real result" + ); + } + #[tokio::test] async fn complete_loses_on_parked_and_writes_no_done_event() { let pool = memory_pool().await; let run_id = Uuid::parse_str("33333333-3333-4333-8333-333333333333").unwrap(); insert_bare_run(&pool, &run_id.to_string(), "parked").await; - let moved = complete_run(&pool, run_id, 42).await.expect("complete"); + let terminal = complete_run(&pool, run_id, 42).await.expect("complete"); - assert_eq!(moved, Moved::Lost); + assert!(!terminal.won()); assert_eq!(run_status_of(&pool, &run_id.to_string()).await, "parked"); assert_eq!(run_event_count(&pool, &run_id.to_string(), "done").await, 0); } @@ -962,9 +1109,9 @@ mod tests { let run_id = Uuid::parse_str("44444444-4444-4444-8444-444444444444").unwrap(); insert_bare_run(&pool, &run_id.to_string(), "parked").await; - let moved = error_run(&pool, run_id, 42).await.expect("error"); + let terminal = error_run(&pool, run_id, 42).await.expect("error"); - assert_eq!(moved, Moved::Lost); + assert!(!terminal.won()); assert_eq!(run_status_of(&pool, &run_id.to_string()).await, "parked"); assert_eq!( run_event_count(&pool, &run_id.to_string(), "error").await, @@ -1309,18 +1456,13 @@ mod tests { ); } - /// The snapshot-composition rule (finding F9, the Core half): a Run's - /// subscribe snapshot is the assistant message's CUMULATIVE text — all - /// `type='text'` parts concatenated in `seq` order, reasoning excluded. - /// CROSS-LANGUAGE MIRROR: the web client (`apps/web/src/store/chat.ts` - /// `setCumulativeText` / the `appendTextSegment` armed path) assumes exactly - /// this rule, because Core sends the snapshot as a plain `text_delta` - /// (`runs/subscribe.rs` `send_text_delta(… &snap.text)` sites) — - /// indistinguishable on the wire from a tail delta; only the client's armed - /// bit disambiguates. Tagging the snapshot on the wire is the recorded - /// follow-up (F9's full fix); until then this test tethers the two halves. + /// The assistant timeline's `type='text'` parts persist in `seq` order with + /// reasoning interleaved — the raw shape `run_live_segments` assembles into + /// the ordered `Snapshot` (review P1 #2), reasoning kept in place, text not + /// coalesced across it. (The `run/subscribe` wire tagging F9 flagged is now + /// the explicit `snapshot` Run Event.) #[tokio::test] - async fn select_run_snapshot_concats_text_parts_in_seq_order() { + async fn assistant_text_parts_persist_in_seq_order() { let pool = memory_pool().await; let thread_id = Uuid::now_v7(); let run_id = Uuid::now_v7(); @@ -1364,17 +1506,66 @@ mod tests { queries::insert_text_part(&mut *tx, assistant_id, 2, "world") .await .expect("text part 2"); + // The run_steps spine the segment assembler orders by (production writes + // part + step together via `open_assistant_part`). + for seq in 0..3 { + queries::insert_message_run_step(&mut *tx, run_id, seq, assistant_id, seq, 1) + .await + .expect("message run step"); + } tx.commit().await.expect("commit seed"); - let snap = select_run_snapshot(&pool, run_id) + // The assistant text parts concatenate in seq order, reasoning excluded. + // (`run_live_segments` now assembles these as SEPARATE ordered segments + // with reasoning interleaved — review P1 #2; here we pin the persisted + // parts directly.) + let text: Option = sqlx::query_scalar( + "SELECT group_concat(text, '') FROM ( \ + SELECT text FROM message_parts \ + WHERE message_id = ?1 AND type = 'text' ORDER BY seq )", + ) + .bind(assistant_id.to_string()) + .fetch_one(&pool) + .await + .expect("read text"); + assert_eq!( + text.unwrap_or_default(), + "Hello world", + "text parts concatenate in seq order, reasoning excluded" + ); + assert!( + run_status(&pool, run_id) + .await + .expect("status") + .expect("run exists") + .is_parked(), + "the seeded run is parked" + ); + + // The assembled timeline (the `run/subscribe` snapshot source) preserves + // the text–reasoning–text interleave as SEPARATE ordered segments. + let segments = crate::db::run_live_segments(&pool, run_id, true) .await - .expect("read ok") - .expect("run exists"); + .expect("assemble live segments"); + let shape: Vec = segments + .iter() + .map(|seg| match seg { + crate::db::MessageSegment::Text { text } => format!("text:{text}"), + crate::db::MessageSegment::Reasoning { text, .. } => { + format!("reasoning:{text}") + } + other => format!("other:{other:?}"), + }) + .collect(); assert_eq!( - snap.text, "Hello world", - "cumulative = all text parts, seq order, reasoning excluded" + shape, + vec![ + "text:Hello ".to_string(), + "reasoning:thinking…".to_string(), + "text:world".to_string(), + ], + "run_live_segments keeps the interleave in seq order" ); - assert!(snap.status.is_parked(), "status rides the snapshot"); } /// Drive a bare Run to `errored` directly (terminal fields stamped), to seed diff --git a/crates/core/src/db/threads.rs b/crates/core/src/db/threads.rs index b0f76e4c..3e54b7ba 100644 --- a/crates/core/src/db/threads.rs +++ b/crates/core/src/db/threads.rs @@ -72,10 +72,16 @@ pub enum MessageSegment { /// wire spelling (`errored`/anything-unexpected → `error`, `completed` → /// `completed`) and the display `arg` derived from the request payload via the /// same per-tool extractor the live `tool_call` Run Event uses. + /// `tool_call_id` is the durable per-call identity (external-task-views A4); + /// `result` is the normalized model-received content, decoded from + /// `result_payload` for external (`ticktick_*`) calls so the reload row + /// expands identically to the live one. ToolCall { + tool_call_id: String, name: String, status: String, arg: Option, + result: Option, }, /// The decided Proposal the turn parked on (ADR-0044): `accepted`/`rejected` /// only — pending/cancelled are skipped at assembly. `entity_id` (ADR-0044 @@ -105,6 +111,57 @@ pub enum MessageSegment { }, } +/// Map a db-side timeline item to its wire [`crate::protocol::Segment`] variant +/// (1:1, order-preserving, ADR-0045). Used by `thread/get` rehydration AND the +/// `run/subscribe` full-timeline snapshot (review P1 #2), so the reloaded and the +/// live-snapshot timelines are assembled identically. +impl From for crate::protocol::Segment { + fn from(segment: MessageSegment) -> Self { + use crate::protocol::Segment; + match segment { + MessageSegment::Text { text } => Segment::Text { text }, + MessageSegment::ToolCall { + tool_call_id, + name, + status, + arg, + result, + } => Segment::ToolCall { + tool_call_id, + name, + status, + arg, + result, + }, + MessageSegment::Proposal { + proposal_id, + mutation_kind, + status, + entity_id, + } => Segment::Proposal { + proposal_id, + mutation_kind, + status, + entity_id, + }, + MessageSegment::Reasoning { text, duration_ms } => { + Segment::Reasoning { text, duration_ms } + } + MessageSegment::Attachment { + media_id, + mime, + width, + height, + } => Segment::Attachment { + media_id, + mime, + width, + height, + }, + } + } +} + /// One Message in a `thread/get` read. `segments` is the assistant turn's ordered /// timeline (ADR-0045) — text/tool_call/proposal items in `run_steps` order; a user /// Message carries a single `text` segment. Replaces the prior assembled flat @@ -162,7 +219,9 @@ pub async fn get_thread_with_messages( // `segment_rows_for_run`'s tolerant arg parsing) with a Diagnostic Log // warning (ADR-0038) so the vanished image stays greppable. let segments = if role == "assistant" { - segment_rows_for_run(pool, &run_id, &id).await? + // thread/get reload excludes still-pending calls (owned by the live + // tail, ADR-0043); the live subscribe snapshot includes them (#2). + segment_rows_for_run(pool, &run_id, &id, false).await? } else { user_segments(&id, queries::parts_by_message(pool, &id).await?) }; @@ -268,6 +327,7 @@ async fn segment_rows_for_run( pool: &SqlitePool, run_id: &str, assistant_message_id: &str, + include_pending: bool, ) -> sqlx::Result> { let Ok(run_uuid) = Uuid::parse_str(run_id) else { return Ok(Vec::new()); @@ -287,9 +347,11 @@ async fn segment_rows_for_run( kind, part_text, part_type, + tool_call_id, tc_name, tc_status, request_payload, + result_payload, proposal_id, mutation_kind, proposal_status, @@ -340,11 +402,19 @@ async fn segment_rows_for_run( }); } } else if !crate::tools::is_proposal(&name) { - // A non-Proposal tool call → a settled tool-activity row - // (ADR-0043). Skip a `pending` call; map the persisted status to - // the wire spelling, never leaking a non-vocabulary value. + // A non-Proposal tool call → a tool-activity row (ADR-0043). + // Map the persisted status to the wire spelling (never leaking + // a non-vocabulary value). thread/get (`include_pending=false`) + // SKIPS a `pending` call — its result is owned by the live tail; + // the `run/subscribe` snapshot (`include_pending=true`) keeps it + // as `running` so a reconnect shows the in-flight call (#2). let status = tc_status.unwrap_or_default(); - if status != "pending" { + let wire_status = match status.as_str() { + "pending" => include_pending.then_some("running"), + "completed" => Some("completed"), + _ => Some("error"), + }; + if let Some(wire_status) = wire_status { // Derive the display arg from the stored request payload via // the same per-tool extractor the live `tool_call` Run Event // uses, so the reloaded row matches the live one. A malformed @@ -353,14 +423,27 @@ async fn segment_rows_for_run( .as_deref() .and_then(|p| serde_json::from_str::(p).ok()) .and_then(|params| crate::tools::display_arg(&name, ¶ms)); + // An external call's payload IS a TranscriptToolResult + // (the finished frame or the interrupted settle wrote + // it) — served so the reload row expands identically to + // the live one (A4). A pending call has none yet; Core-tool + // rows carry none in v1. + let result = if crate::tools::is_external(&name) { + result_payload.as_deref().and_then(|payload| { + serde_json::from_str::( + payload, + ) + .ok() + }) + } else { + None + }; segments.push(MessageSegment::ToolCall { + tool_call_id: tool_call_id.unwrap_or_default(), name, - status: if status == "completed" { - "completed".to_string() - } else { - "error".to_string() - }, + status: wire_status.to_string(), arg, + result, }); } } @@ -371,6 +454,26 @@ async fn segment_rows_for_run( Ok(segments) } +/// The run's ordered timeline for the `run/subscribe` full-timeline snapshot +/// (review P1 #2): the SAME assembly `thread/get` uses. `include_pending` is the +/// liveness gate (CodeRabbit #336): a LIVE hub renders still-pending calls as +/// `running` (the in-flight external call a reconnect must show), while a run +/// with NO live hub (terminal/parked) excludes them — a Core row orphaned +/// `pending` by a crash would otherwise reach the Client as `running` on a +/// finished Run, forever, and disagree with `thread/get`. Empty when the Run +/// has no assistant message. +pub async fn run_live_segments( + pool: &SqlitePool, + run_id: Uuid, + include_pending: bool, +) -> sqlx::Result> { + let Some(assistant_message_id) = queries::assistant_message_id_for_run(pool, run_id).await? + else { + return Ok(Vec::new()); + }; + segment_rows_for_run(pool, &run_id.to_string(), &assistant_message_id, include_pending).await +} + #[cfg(test)] mod tests { use crate::db::test_support::memory_pool; @@ -381,6 +484,146 @@ mod tests { }; use crate::workflow::Workflow; + /// External (`ticktick_*`) rows rehydrate with their durable + /// `tool_call_id` and the model-received `result` (external-task-views + /// A4): a completed row serves its TranscriptToolResult verbatim, and a + /// row the cancel transition settled renders as an ERROR carrying the + /// Core-generated interrupted result — the same object the live + /// interrupted `tool_call` event carried, so live and reload agree by + /// construction. Core-tool rows carry `result: None` in v1. + #[tokio::test] + async fn thread_get_serves_external_results_and_interrupted_settle() { + let pool = memory_pool().await; + let thread_id = Uuid::now_v7(); + let run_id = Uuid::now_v7(); + let assistant_id = Uuid::now_v7(); + + let mut tx = pool.begin().await.expect("begin"); + queries::insert_thread(&mut *tx, thread_id, "T", 1) + .await + .expect("thread"); + sqlx::query( + "INSERT INTO runs \ + (id, thread_id, workflow_name, workflow_version, provider, model, \ + thinking_level, user_message_id, status, started_at) \ + VALUES (?, ?, 'w', '1', 'p', 'm', 'off', ?, 'running', 1)", + ) + .bind(run_id.to_string()) + .bind(thread_id.to_string()) + .bind(assistant_id.to_string()) + .execute(&mut *tx) + .await + .expect("run"); + queries::insert_message( + &mut *tx, + assistant_id, + thread_id, + run_id, + "assistant", + "streaming", + 1, + ) + .await + .expect("assistant message"); + tx.commit().await.expect("commit seed"); + + // A completed external call with its TranscriptToolResult payload… + persist_tool_call( + &pool, + run_id, + "tc-ext-ok", + "ticktick_filter_tasks", + r#"{"filter":{"status":[0]}}"#, + 2, + ) + .await + .expect("persist external"); + resolve_tool_call( + &pool, + "tc-ext-ok", + "completed", + r#"{"content":[{"type":"text","text":"1 task found"}],"is_error":false}"#, + 3, + ) + .await + .expect("resolve external"); + // …a Core call (result never served in v1)… + persist_tool_call(&pool, run_id, "tc-core", "read_thread", "{}", 4) + .await + .expect("persist core"); + resolve_tool_call(&pool, "tc-core", "completed", r#"{"content":[]}"#, 5) + .await + .expect("resolve core"); + // …and a still-pending external call the REAL cancel transition settles. + persist_tool_call( + &pool, + run_id, + "tc-ext-hang", + "ticktick_search_task", + "{}", + 6, + ) + .await + .expect("persist pending external"); + let terminal = crate::db::cancel_running_run(&pool, run_id, 7) + .await + .expect("cancel"); + let crate::db::Terminal::Won { interrupted } = terminal else { + panic!("cancel won the terminal transition"); + }; + assert_eq!(interrupted.len(), 1); + + let (_, messages) = get_thread_with_messages(&pool, thread_id) + .await + .expect("thread/get") + .expect("thread exists"); + let assistant = messages + .iter() + .find(|m| m.role == "assistant") + .expect("assistant row"); + let tool_calls: Vec<_> = assistant + .segments + .iter() + .filter_map(|s| match s { + MessageSegment::ToolCall { + tool_call_id, + name, + status, + result, + .. + } => Some(( + tool_call_id.as_str(), + name.as_str(), + status.as_str(), + result.clone(), + )), + _ => None, + }) + .collect(); + assert_eq!( + tool_calls, + vec![ + ( + "tc-ext-ok", + "ticktick_filter_tasks", + "completed", + Some(crate::protocol::TranscriptToolResult::text( + "1 task found", + false + )), + ), + ("tc-core", "read_thread", "completed", None), + ( + "tc-ext-hang", + "ticktick_search_task", + "error", + Some(crate::protocol::TranscriptToolResult::interrupted()), + ), + ], + "external rows serve id + result; the settled row renders as an interrupted error" + ); + } + /// ADR-0045: `thread/get` rehydrates the assistant turn's ORDERED `segments[]` /// from `run_steps` in seq order — text/tool_call/proposal interleaved as they /// happened. Folds in the ADR-0043 rules (a non-Proposal tool call rehydrates as @@ -517,15 +760,25 @@ mod tests { other => panic!("segment[0] is the text segment, got {other:?}"), } match &assistant.segments[1] { - MessageSegment::ToolCall { name, status, arg } => { + MessageSegment::ToolCall { + tool_call_id, + name, + status, + arg, + result, + } => { + assert!(!tool_call_id.is_empty(), "the durable per-call id is served"); assert_eq!(name, "search_entities"); assert_eq!(status, "completed"); assert_eq!(arg.as_deref(), Some("Lev")); + assert_eq!(*result, None, "a Core-tool row carries no result in v1"); } other => panic!("segment[1] is the completed search, got {other:?}"), } match &assistant.segments[2] { - MessageSegment::ToolCall { name, status, arg } => { + MessageSegment::ToolCall { + name, status, arg, .. + } => { assert_eq!(name, "search_entities"); // `errored` maps to the wire `error` spelling. assert_eq!(status, "error"); @@ -1866,6 +2119,7 @@ mod tests { system_prompt: String::new(), thinking_level: None, tools: Vec::new(), + external_tools: false, } } diff --git a/crates/core/src/hub.rs b/crates/core/src/hub.rs index 7a492023..23167bf1 100644 --- a/crates/core/src/hub.rs +++ b/crates/core/src/hub.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use std::future::Future; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, Weak}; use tokio::sync::{broadcast, watch}; use uuid::Uuid; @@ -28,8 +28,8 @@ const HUB_BUFFER: usize = 256; /// exclusive with the subscribe handler's `snapshot → attach`, so every delta /// falls wholly before or after a subscribe instant (ADR-0022 exactly-once). /// Both are private: the gate ritual lives behind this type's methods -/// ([`Self::gate`], [`Self::publish_gated`], [`Self::snapshot_then_attach`], -/// [`Self::send`]) so no call site re-spells the lock ordering. `cancel_tx` is +/// ([`Self::gate`], [`Self::snapshot_then_attach`], [`Self::send`]) so no call +/// site re-spells the lock ordering. `cancel_tx` is /// the in-memory signal Core flips after durably winning a cancellation; the /// Worker loop observes it and stops. #[derive(Clone)] @@ -52,45 +52,48 @@ impl RunHub { /// Acquire the ADR-0022 per-run gate. The returned guard makes the caller's /// `persist → publish` (or `snapshot → attach`) critical section mutually - /// exclusive with the other side's. Prefer the shaped helpers - /// ([`Self::publish_gated`], [`Self::snapshot_then_attach`]); this exists - /// for multi-step brackets the closures can't express (e.g. the Worker's - /// cancel-check-then-send tool-call brackets). + /// exclusive with the other side's. Held directly across a persist/settle tx + /// AND one or more raw [`Self::send`]s — the terminal-settlement bracket + /// (review P1 #3: settle → interrupted → terminal, one acquisition) — or + /// paired with [`Self::snapshot_then_attach`] on the subscribe side. NOT + /// re-entrant, so publish via raw `send` while holding it, never a nested + /// `gate()`. pub async fn gate(&self) -> tokio::sync::MutexGuard<'_, ()> { self.gate.lock().await } - /// `lock → send → unlock`: the terminal/ephemeral publish ritual (ADR-0022). - pub async fn publish_gated(&self, event: RunEvent) { - let guard = self.gate.lock().await; - let _ = self.tx.send(event); - drop(guard); + /// The gate as an OWNED guard (`Arc`-backed), for [`activate`]'s candidate + /// lock: it must outlive the registration loop's borrow scope. + async fn gate_owned(&self) -> tokio::sync::OwnedMutexGuard<()> { + self.gate.clone().lock_owned().await } /// `lock → snapshot (caller's async read) → attach receiver → unlock` — /// ADR-0022 snapshot-then-tail. The read runs under the gate, so every /// delta falls wholly before or after the subscribe instant (in the - /// snapshot or on the tail, never both, never neither). - pub async fn snapshot_then_attach( - &self, - read: F, - ) -> (T, broadcast::Receiver) + /// snapshot or on the tail, never both, never neither). Returns a + /// [`RunTail`] that owns the receiver + gate (no `Sender`), so the caller + /// can `recover()` from a lag under the gate without keeping the channel open. + pub async fn snapshot_then_attach(&self, read: F) -> (T, RunTail) where F: FnOnce() -> Fut, Fut: Future, { let guard = self.gate.lock().await; let snapshot = read().await; - let receiver = self.tx.subscribe(); + let tail = RunTail { + receiver: self.tx.subscribe(), + gate: self.gate.clone(), + }; drop(guard); - (snapshot, receiver) + (snapshot, tail) } - /// Raw, UNGATED sender access. Gating is the caller's responsibility: take - /// [`Self::gate`] around it for a persist→publish bracket, or call it bare - /// for publishes whose ordering the terminal tx itself provides (the - /// run loop's post-terminal-tx `Done`/`Error`) and for pre-attach sends. - /// The shaped helpers cover the common rituals. + /// Raw sender access; the caller must hold [`Self::gate`] (the + /// persist→publish bracket / the terminal-settlement bracket — every + /// production publish is gated). [`RunTail::recover`] depends on that: an + /// ungated send landing between its gated re-read and `resubscribe()` would + /// be lost to the recovering subscriber. pub fn send(&self, event: RunEvent) { let _ = self.tx.send(event); } @@ -113,44 +116,238 @@ impl RunHub { pub fn is_cancelled(&self) -> bool { *self.cancel_tx.borrow() } + + /// Registration identity: two handles are the same hub iff they share the + /// gate allocation (every clone of one registration does; two registrations + /// never do). Backs [`remove_own`]'s identity check. + fn same(&self, other: &RunHub) -> bool { + Arc::ptr_eq(&self.gate, &other.gate) + } +} + +/// A subscriber's live-tail handle (review F3): the broadcast `Receiver` PLUS the +/// per-run gate — but NO `Sender`. That sender-free ownership is the whole point: +/// the forwarder can [`recover`](Self::recover) from a broadcast lag under the +/// gate (re-snapshot + re-attach), yet still observe `RecvError::Closed` when the +/// Worker drops its sender — a `RunHub` clone would keep a `Sender` alive and +/// wedge that close. The lock ritual lives here, not re-spelled at the call site. +pub struct RunTail { + receiver: broadcast::Receiver, + gate: Arc>, +} + +impl RunTail { + /// The next live event, or `Lagged`/`Closed` (ADR-0022). `Lagged` → the + /// caller [`recover`](Self::recover)s; `Closed` → the Worker dropped its + /// sender (terminal / `hub::remove`). + pub async fn recv(&mut self) -> Result { + self.receiver.recv().await + } + + /// Recover from a broadcast lag: UNDER THE GATE, run `read` (re-snapshot the + /// persisted timeline) AND re-attach a FRESH receiver at the current tail + /// (`resubscribe`), so the snapshot's last-committed event meets the resumed + /// tail EXACTLY — no event replayed from the stale ring buffer (which would + /// duplicate text/reasoning), none lost. Same `lock → read → attach → unlock` + /// ritual as [`RunHub::snapshot_then_attach`], owned here so the forwarder + /// never touches the raw mutex. + pub async fn recover(&mut self, read: F) -> T + where + F: FnOnce() -> Fut, + Fut: Future, + { + let guard = self.gate.lock().await; + let snapshot = read().await; + self.receiver = self.receiver.resubscribe(); + drop(guard); + snapshot + } + + /// Assemble a tail from raw parts (tests drive lag/close directly against a + /// bare channel; production tails come from [`RunHub::snapshot_then_attach`]). + #[cfg(test)] + pub fn from_parts( + receiver: broadcast::Receiver, + gate: Arc>, + ) -> Self { + Self { receiver, gate } + } } -/// Shared map of in-flight Runs. `std::sync::Mutex` is fine: touched only at -/// spawn / subscribe / terminal (never per-delta), so the critical section never -/// spans an `.await`. `Arc` keeps `AppState` `Clone`. -pub type Hubs = Arc>>; +/// Shared registry of in-flight Runs and their transient lifecycle locks. +/// +/// The per-run lifecycle lock linearizes generation changes with cancel and +/// subscribe classification. Its registry entry is weak: once no operation +/// holds or waits for the lock, a later lookup prunes it, so completed Run ids +/// do not accumulate forever. The inner `std::sync::Mutex` is held only for map +/// access and never across an `.await`. +#[derive(Clone)] +pub struct Hubs { + inner: Arc>, +} + +#[derive(Default)] +struct Registry { + active: HashMap, + lifecycle: HashMap>>, +} + +/// Proof that the caller owns one Run's lifecycle transition slot. Operations +/// which change the active generation require this guard, making the lock order +/// structural: lifecycle first, then the [`RunHub`] snapshot gate. +pub struct LifecycleGuard { + run_id: Uuid, + _guard: tokio::sync::OwnedMutexGuard<()>, +} /// A fresh, empty hub map. pub fn new_hubs() -> Hubs { - Arc::new(Mutex::new(HashMap::new())) + Hubs { + inner: Arc::new(Mutex::new(Registry::default())), + } } -/// Create and register a hub for `run_id`. Called before the Worker spawns so a -/// fast `run/subscribe` can never miss a hub for a Run about to stream. -pub fn create(hubs: &Hubs, run_id: Uuid) -> RunHub { +/// Acquire the transient lifecycle slot for `run_id`. +pub async fn lifecycle(hubs: &Hubs, run_id: Uuid) -> LifecycleGuard { + let slot = { + let mut registry = hubs.inner.lock().expect("hubs mutex not poisoned"); + registry.lifecycle.retain(|_, weak| weak.strong_count() > 0); + match registry.lifecycle.get(&run_id).and_then(Weak::upgrade) { + Some(slot) => slot, + None => { + let slot = Arc::new(tokio::sync::Mutex::new(())); + registry.lifecycle.insert(run_id, Arc::downgrade(&slot)); + slot + } + } + }; + LifecycleGuard { + run_id, + _guard: slot.lock_owned().await, + } +} + +/// Register a fresh hub for `run_id` — ONLY if none is registered (review R8 #1). +/// `None` means another activation holds the slot (a concurrent resume/retry, or +/// a just-terminal Worker that has not yet removed its hub): the caller backs +/// off; it must NOT proceed to flip the Run's status. First-wins registration is +/// what makes the registered hub and the Run's `running` status refer to the +/// same producer — a blind insert let two activations replace each other's hub +/// and drive a Worker whose hub no subscriber or cancel could reach. +/// Test-only seeder: production activation goes through [`activate`], which +/// gate-locks the candidate before it becomes visible. +#[cfg(test)] +pub fn register(hubs: &Hubs, run_id: Uuid) -> Option { let hub = RunHub::new(); - hubs.lock() + register_candidate(hubs, run_id, &hub).then_some(hub) +} + +/// Insert `candidate` for `run_id` ONLY if the slot is vacant (first-wins). +/// [`activate`] pre-locks the candidate's gate before calling this, so the hub +/// is never visible un-gated mid-activation. +fn register_candidate(hubs: &Hubs, run_id: Uuid, candidate: &RunHub) -> bool { + match hubs + .inner + .lock() .expect("hubs mutex not poisoned") - .insert(run_id, hub.clone()); - hub + .active + .entry(run_id) + { + std::collections::hash_map::Entry::Occupied(_) => false, + std::collections::hash_map::Entry::Vacant(slot) => { + slot.insert(candidate.clone()); + true + } + } +} + +/// Activate a Run: register its hub, then run the caller's guarded status CAS +/// under that hub's gate — the ONE registry operation every activation path +/// (resume, retry, fresh spawn) goes through (review R8 #1/R9 #1). Hub-before-CAS +/// means a Run is observably `running` only while its hub is reachable, so a +/// concurrent `run/cancel` that reads `running` always finds the producer's hub +/// and signals it. +/// +/// Registry occupancy NEVER substitutes for the durable CAS (review R9 #1): a +/// producer's drain (terminal/park commit + hub removal) is ONE gated section, +/// so an occupied slot is either mid-drain — acquire ITS gate to wait the drain +/// out, then register and run the CAS — or a LIVE producer (still registered +/// after the gate round-trip), whose Run's status cannot be this activation's +/// from-state, so backing off is the CAS's own answer, not a substitute. A lost +/// or faulted CAS deregisters under the gate — identity-checked, never deleting +/// a later activation's hub. `Ok(None)` = not activated; `Err` = CAS fault. +pub async fn activate(hubs: &Hubs, run_id: Uuid, cas: F) -> Result, E> +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + let lifecycle = lifecycle(hubs, run_id).await; + if get(hubs, run_id).is_some() { + return Ok(None); + } + + // Lock the candidate's snapshot gate before publishing it. A subscriber + // therefore cannot read the pre-CAS status against this generation. + let hub = RunHub::new(); + let guard = hub.gate_owned().await; + let inserted = register_candidate(hubs, run_id, &hub); + debug_assert!(inserted, "lifecycle guard keeps the active slot vacant"); + let result = cas().await; + match result { + Ok(true) => { + drop(guard); + drop(lifecycle); + Ok(Some(hub)) + } + Ok(false) => { + remove_own(hubs, run_id, &hub, &lifecycle); + drop(guard); + Ok(None) + } + Err(e) => { + remove_own(hubs, run_id, &hub, &lifecycle); + drop(guard); + Err(e) + } + } } /// Look up the hub for `run_id`, cloning the handle if present. `None` means the /// Run is terminal/removed (or never existed), so the subscribe handler serves a /// tier-2 snapshot and the persisted terminal outcome. pub fn get(hubs: &Hubs, run_id: Uuid) -> Option { - hubs.lock() + hubs.inner + .lock() .expect("hubs mutex not poisoned") + .active .get(&run_id) .cloned() } -/// Remove the hub for `run_id`. Called after the Worker's terminal tx so -/// dropping the sender lets drained subscribers observe `RecvError::Closed`. -pub fn remove(hubs: &Hubs, run_id: Uuid) { - hubs.lock() - .expect("hubs mutex not poisoned") - .remove(&run_id); +/// Remove `run_id`'s hub ONLY if the registered entry IS `own` (identity = +/// shared gate allocation) — every removal site passes the hub it owns, so a +/// finishing Worker's cleanup can never delete a hub a newer activation (e.g. a +/// retry racing the old Worker's exit) registered for the same run (review R8 +/// #1). A stale-identity call is a no-op. +pub fn remove_own(hubs: &Hubs, run_id: Uuid, own: &RunHub, lifecycle: &LifecycleGuard) { + debug_assert_eq!(lifecycle.run_id, run_id); + let mut registry = hubs.inner.lock().expect("hubs mutex not poisoned"); + if registry + .active + .get(&run_id) + .is_some_and(|entry| entry.same(own)) + { + registry.active.remove(&run_id); + } +} + +/// Remove a generation when no durable transition accompanies the cleanup. +/// The lifecycle/gate acquisition order matches every transition path. +pub async fn retire(hubs: &Hubs, run_id: Uuid, own: &RunHub) { + let lifecycle = lifecycle(hubs, run_id).await; + let gate = own.gate().await; + remove_own(hubs, run_id, own, &lifecycle); + drop(gate); } #[cfg(test)] @@ -159,20 +356,22 @@ mod tests { use super::*; - /// The two method shapes compose: a receiver attached via - /// `snapshot_then_attach` (dummy read) receives an event published through - /// `publish_gated`. The system-level exactly-once property stays pinned by - /// the persistence_stream/subscribe integration suites; this pins delivery - /// through the hub's own interface. + /// The two shapes compose: a receiver attached via `snapshot_then_attach` + /// (dummy read) receives an event published through the production ritual — + /// `gate()` held across a raw `send`. The system-level exactly-once property + /// stays pinned by the persistence_stream/subscribe integration suites; this + /// pins delivery through the hub's own interface. #[tokio::test] - async fn publish_gated_delivers_to_attached_subscriber() { + async fn gated_send_delivers_to_attached_subscriber() { let hubs = new_hubs(); - let hub = create(&hubs, Uuid::now_v7()); + let hub = register(&hubs, Uuid::now_v7()).expect("fresh run registers"); let (snapshot, mut rx) = hub.snapshot_then_attach(|| async { "snap" }).await; assert_eq!(snapshot, "snap", "the read's value passes through"); - hub.publish_gated(RunEvent::Done).await; + let guard = hub.gate().await; + hub.send(RunEvent::Done); + drop(guard); let event = tokio::time::timeout(Duration::from_secs(5), rx.recv()) .await @@ -180,19 +379,235 @@ mod tests { .expect("channel open"); assert!( matches!(event, RunEvent::Done), - "a gated publish reaches a snapshot_then_attach receiver" + "a gated send reaches a snapshot_then_attach receiver" + ); + } + + /// Concurrent activation (review R8 #1, the concurrent-resume shape): the + /// FIRST activation wins the slot; the second backs off WITHOUT running its + /// CAS — so two resumes can never replace each other's hub, and the map + /// entry is exactly the winner's producer hub (`get` hands cancel/subscribe + /// the hub the Worker publishes into). + #[tokio::test] + async fn activate_is_first_wins_and_the_loser_cas_never_runs() { + let hubs = new_hubs(); + let run_id = Uuid::now_v7(); + + let winner = activate(&hubs, run_id, || async { Ok::<_, ()>(true) }) + .await + .expect("cas ok") + .expect("first activation wins"); + + let loser_cas_ran = std::sync::atomic::AtomicBool::new(false); + let loser = activate(&hubs, run_id, || async { + loser_cas_ran.store(true, std::sync::atomic::Ordering::SeqCst); + Ok::<_, ()>(true) + }) + .await + .expect("no fault"); + assert!(loser.is_none(), "the second activation backs off"); + assert!( + !loser_cas_ran.load(std::sync::atomic::Ordering::SeqCst), + "the loser's CAS never runs — it lost at the registry, before any flip" + ); + + let registered = get(&hubs, run_id).expect("winner's hub stays registered"); + assert!( + registered.same(&winner), + "the registered hub IS the winner's producer hub" + ); + } + + /// A lost CAS deregisters (review R8 #1, the failed-CAS shape): activation + /// registered the hub, the guarded flip lost (e.g. a cancel raced the + /// parked→running resume) — the registration must not outlive it, or a + /// producerless hub would shadow the run forever. + #[tokio::test] + async fn activate_removes_the_hub_on_a_lost_cas() { + let hubs = new_hubs(); + let run_id = Uuid::now_v7(); + + let outcome = activate(&hubs, run_id, || async { Ok::<_, ()>(false) }) + .await + .expect("no fault"); + assert!(outcome.is_none(), "a lost CAS is not an activation"); + assert!(get(&hubs, run_id).is_none(), "the lost CAS deregistered its hub"); + } + + /// A faulted CAS deregisters AND propagates (review R8 #1): a DB error + /// mid-activation must not leak a producerless hub. + #[tokio::test] + async fn activate_removes_the_hub_on_a_cas_fault() { + let hubs = new_hubs(); + let run_id = Uuid::now_v7(); + + let outcome = activate(&hubs, run_id, || async { Err::("db fault") }).await; + assert!( + matches!(outcome, Err("db fault")), + "the fault propagates" + ); + assert!(get(&hubs, run_id).is_none(), "the faulted CAS deregistered its hub"); + } + + /// A mid-activation hub is GATE-LOCKED before it is visible (review R10 #2): + /// a subscriber that finds it must block in `snapshot_then_attach` until the + /// CAS settles — it can never snapshot the pre-CAS status against the new + /// generation's channel. Deterministic on the current-thread runtime: with + /// the old order (publish, then lock) the subscriber completes at the yield + /// points below; gate-locked-first, it CANNOT complete until the CAS does. + #[tokio::test] + async fn subscribers_block_until_the_activation_cas_settles() { + let hubs = new_hubs(); + let run_id = Uuid::now_v7(); + let (cas_tx, cas_rx) = tokio::sync::oneshot::channel::<()>(); + let cas_settled = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let activation = tokio::spawn({ + let hubs = hubs.clone(); + let cas_settled = cas_settled.clone(); + async move { + activate(&hubs, run_id, || async { + cas_rx.await.expect("test releases the CAS"); + cas_settled.store(true, std::sync::atomic::Ordering::SeqCst); + Ok::<_, ()>(true) + }) + .await + } + }); + + // Wait until the candidate hub is visible in the registry. + let hub = loop { + if let Some(hub) = get(&hubs, run_id) { + break hub; + } + tokio::task::yield_now().await; + }; + + // A subscriber attaches: it must PARK on the activation's held gate. + let subscriber = tokio::spawn(async move { + let (settled, _tail) = hub + .snapshot_then_attach(|| async { + cas_settled.load(std::sync::atomic::Ordering::SeqCst) + }) + .await; + settled + }); + for _ in 0..8 { + tokio::task::yield_now().await; + } + assert!( + !subscriber.is_finished(), + "the subscriber blocks until the activation CAS settles" + ); + + // Release the CAS: activation completes, THEN the subscriber's gated + // read runs — and it observes the settled state, never the pre-CAS one. + cas_tx.send(()).expect("release the CAS"); + let activated = activation.await.expect("join").expect("no fault"); + assert!(activated.is_some(), "the activation won"); + assert!( + subscriber.await.expect("join"), + "the subscriber's snapshot ran strictly after the CAS settled" + ); + } + + /// A drain owns the lifecycle slot before the hub gate. An activation for + /// the next generation therefore waits until the terminal commit and hub + /// removal are both complete before it runs its own durable CAS. + #[tokio::test] + async fn activation_waits_out_an_inflight_drain_and_then_wins() { + let hubs = new_hubs(); + let run_id = Uuid::now_v7(); + let dying = register(&hubs, run_id).expect("producer registers"); + let lifecycle = lifecycle(&hubs, run_id).await; + let drain_guard = dying.gate().await; + + let task = tokio::spawn({ + let hubs = hubs.clone(); + async move { activate(&hubs, run_id, || async { Ok::<_, ()>(true) }).await } + }); + + remove_own(&hubs, run_id, &dying, &lifecycle); + drop(drain_guard); + drop(lifecycle); + + let hub = tokio::time::timeout(Duration::from_secs(5), task) + .await + .expect("activation completes once the drain finishes") + .expect("task joins") + .expect("no CAS fault") + .expect("the retry activates after the prior generation drains"); + let registered = get(&hubs, run_id).expect("the retry's hub is registered"); + assert!( + registered.same(&hub), + "the slot holds the retry's fresh hub" + ); + assert!(!registered.same(&dying), "the dying producer's hub is gone"); + } + + /// Identity-checked removal: a late cleanup carries its own hub identity and + /// cannot delete a newer generation registered for the same Run. + #[tokio::test] + async fn remove_own_ignores_a_stale_hub_identity() { + let hubs = new_hubs(); + let run_id = Uuid::now_v7(); + + let old = register(&hubs, run_id).expect("old registration"); + retire(&hubs, run_id, &old).await; + let fresh = register(&hubs, run_id).expect("retry re-registers"); + + retire(&hubs, run_id, &old).await; + let survivor = get(&hubs, run_id).expect("the retry's hub survives"); + assert!(survivor.same(&fresh), "the surviving hub is the retry's"); + + retire(&hubs, run_id, &fresh).await; + assert!(get(&hubs, run_id).is_none(), "own removal removes"); + } + + /// `RunTail::recover` discards the stale ring buffer (review F2/F3): a receiver + /// that lagged past capacity, after `recover`, delivers only events published + /// AFTER recovery — never a replay of the backlog the re-snapshot already + /// covered. The read's value passes through. Direct (no forwarder / tracing), + /// so the recovery boundary is pinned where it lives. + #[tokio::test] + async fn run_tail_recover_discards_the_lagged_buffer() { + let (tx, rx) = broadcast::channel::(8); + // Overflow the receiver: 9 sends on cap-8 leaves its next `recv()` lagged. + for _ in 0..9 { + let _ = tx.send(RunEvent::TextDelta { + delta: "buffered".to_string(), + }); + } + let mut tail = RunTail::from_parts(rx, Arc::new(tokio::sync::Mutex::new(()))); + + let read = tail.recover(|| async { 7_u8 }).await; + assert_eq!(read, 7, "recover returns the read's value"); + + // A post-recovery event is the NEXT thing delivered — the 9 buffered + // deltas were dropped by the resubscribe, so no replay reaches the tail. + let _ = tx.send(RunEvent::TextDelta { + delta: "sentinel".to_string(), + }); + let next = tokio::time::timeout(Duration::from_secs(5), tail.recv()) + .await + .expect("event within timeout") + .expect("channel open"); + assert!( + matches!(next, RunEvent::TextDelta { delta } if delta == "sentinel"), + "the resumed tail delivers only post-recovery events, never a replay" ); } - /// Mutual exclusion (ADR-0022): while `gate()` is held, a `publish_gated` - /// from another task BLOCKS until release — so a snapshot_then_attach - /// critical section can never interleave with a persist→publish one. The - /// publisher records a flag after sending; the flag must stay unset while - /// the gate is held and flip only after the guard drops. + /// Mutual exclusion (ADR-0022): while `gate()` is held, a SECOND `gate()` + /// acquisition from another task BLOCKS until release — so a + /// snapshot_then_attach critical section can never interleave with a + /// persist/settle → publish one (review P1 #3). The publisher records a flag + /// after its gated send; the flag must stay unset while the gate is held and + /// flip only after the guard drops. #[tokio::test] - async fn publish_gated_blocks_while_gate_is_held() { + async fn gate_blocks_a_second_acquisition_until_release() { let hubs = new_hubs(); - let hub = create(&hubs, Uuid::now_v7()); + let hub = register(&hubs, Uuid::now_v7()).expect("fresh run registers"); let guard = hub.gate().await; @@ -201,7 +616,9 @@ mod tests { let hub = hub.clone(); let published = published.clone(); tokio::spawn(async move { - hub.publish_gated(RunEvent::Done).await; + let inner = hub.gate().await; + hub.send(RunEvent::Done); + drop(inner); published.store(true, std::sync::atomic::Ordering::SeqCst); }) }; @@ -210,7 +627,7 @@ mod tests { tokio::time::sleep(Duration::from_millis(50)).await; assert!( !published.load(std::sync::atomic::Ordering::SeqCst), - "publish_gated must block while another task holds the gate" + "a second gate() acquisition must block while another task holds it" ); drop(guard); @@ -220,7 +637,7 @@ mod tests { .expect("publisher task did not panic"); assert!( published.load(std::sync::atomic::Ordering::SeqCst), - "publish_gated proceeds after the gate is dropped" + "the second acquisition proceeds after the gate is dropped" ); } } diff --git a/crates/core/src/main.rs b/crates/core/src/main.rs index 059ba129..4f75a9a1 100644 --- a/crates/core/src/main.rs +++ b/crates/core/src/main.rs @@ -22,8 +22,10 @@ mod recurrence; mod resume; mod runs; mod settings; +mod shutdown; mod skills; mod start_run; +mod ticktick; mod tools; #[cfg(not(debug_assertions))] mod web_embed; @@ -53,10 +55,15 @@ struct AppState { /// Per-run event hubs (ADR-0022): `run_id → RunHub`, shared across all /// connections so a Run's live stream outlives the socket that started it. hubs: Hubs, + /// Sticky Core-level shutdown signal. WebSockets close before `main` + /// returns the fatal error that terminates the process. + shutdown: shutdown::Receiver, } #[tokio::main] async fn main() -> Result<()> { + let shutdown_rx = shutdown::subscribe(); + // Resolve all INKSTONE_* env knobs once and freeze them in a process-global // Config. Modules read the struct, not the env — tests inject values // directly without env mutation. Must run before `logging::init`: @@ -77,6 +84,10 @@ async fn main() -> Result<()> { // boot (fail-fast, ADR-0018) rather than failing the first Run. workflow::init()?; + // Read the TickTick credential exactly once (external-task-views A5): + // missing/unreadable degrades to "not connected", never a boot failure. + ticktick::init(); + let pool = db::open().await?; // Seed the bundled example Skills into the Core-managed skills dir on first @@ -100,6 +111,7 @@ async fn main() -> Result<()> { let state = AppState { pool, hubs: hub::new_hubs(), + shutdown: shutdown_rx.clone(), }; let app = Router::new() @@ -146,7 +158,13 @@ async fn main() -> Result<()> { tracing::info!(event = "core.listening", addr = %local_addr); println!("INKSTONE_LISTENING http://{local_addr}"); - axum::serve(listener, app).await?; + axum::serve(listener, app) + .with_graceful_shutdown(shutdown::wait(shutdown_rx.clone())) + .await?; + if *shutdown_rx.borrow() { + tracing::error!(event = "core.degraded_shutdown_complete"); + anyhow::bail!("Core shut down after fatal terminal persistence"); + } Ok(()) } @@ -251,6 +269,8 @@ async fn media_handler(State(state): State, Path(id): Path) -> async fn handle_socket(mut socket: WebSocket, state: AppState) { let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let shutdown = shutdown::wait(state.shutdown.clone()); + tokio::pin!(shutdown); // Single-task multiplex: race an incoming WS frame against an outbound frame // on the per-connection channel. Responses and Notifications share the @@ -258,6 +278,10 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) { loop { tokio::select! { biased; + _ = &mut shutdown => { + let _ = socket.send(Message::Close(None)).await; + break; + } msg = socket.recv() => { let Some(Ok(msg)) = msg else { // recv closed or errored: a normal client disconnect is not a diff --git a/crates/core/src/protocol/mod.rs b/crates/core/src/protocol/mod.rs index 5ab8b3f8..012a4e81 100644 --- a/crates/core/src/protocol/mod.rs +++ b/crates/core/src/protocol/mod.rs @@ -11,6 +11,7 @@ mod proposal; mod provider; mod run; mod thread; +mod ticktick; mod worker; #[cfg(test)] @@ -23,6 +24,7 @@ pub use proposal::*; pub use provider::*; pub use run::*; pub use thread::*; +pub use ticktick::*; pub use worker::*; use serde::{Deserialize, Serialize}; diff --git a/crates/core/src/protocol/parity.rs b/crates/core/src/protocol/parity.rs index 89f07332..193ca9c8 100644 --- a/crates/core/src/protocol/parity.rs +++ b/crates/core/src/protocol/parity.rs @@ -178,6 +178,9 @@ mod parity_fixtures { s.push('\n'); s }; + // Owned by the fn so the borrowing ManifestMessage::ToolResult below + // can reference it (the wire block borrows its result). + let decision_result = TranscriptToolResult::text("Accepted.", false); // Each entry serializes one instance through the real serde path. macro_rules! fx { ($file:literal, $val:expr) => { @@ -201,6 +204,7 @@ mod parity_fixtures { "run_cancel_result.json", RunCancelResult { outcome: "accepted".to_string(), + live_tail: true, } ), fx!( @@ -732,14 +736,29 @@ mod parity_fixtures { terminal_reason: Some("completed".to_string()), segments: vec![ Segment::ToolCall { + tool_call_id: "tc_01".to_string(), name: "search_entities".to_string(), status: "completed".to_string(), arg: Some("Lev".to_string()), + // Core-tool rows carry no result in v1 (A4). + result: None, }, Segment::ToolCall { + tool_call_id: "tc_02".to_string(), name: "read_thread".to_string(), status: "completed".to_string(), arg: None, + result: None, + }, + // An EXTERNAL (`ticktick_*`) call carries the + // normalized model-received result so the reload + // row expands identically to the live one (A4). + Segment::ToolCall { + tool_call_id: "tc_03".to_string(), + name: "ticktick_filter_tasks".to_string(), + status: "completed".to_string(), + arg: None, + result: Some(TranscriptToolResult::text("1 task found", false)), }, Segment::Proposal { proposal_id: UUID_A.to_string(), @@ -881,6 +900,7 @@ mod parity_fixtures { name: "search_entities".to_string(), status: ToolCallStatus::Started, arg: Some("Lev".to_string()), + result: None, } ), fx!( @@ -890,6 +910,7 @@ mod parity_fixtures { name: "read_thread".to_string(), status: ToolCallStatus::Completed, arg: None, + result: None, } ), fx!( @@ -899,6 +920,55 @@ mod parity_fixtures { name: "read_thread".to_string(), status: ToolCallStatus::Error, arg: None, + result: None, + } + ), + // A terminal EXTERNAL-call event carries the normalized result the + // model received (external-task-views A4); an interrupted settle is + // the error-shaped case. + fx!( + "run_event.tool_call.external_completed.json", + RunEvent::ToolCall { + tool_call_id: "tc_04".to_string(), + name: "ticktick_filter_tasks".to_string(), + status: ToolCallStatus::Completed, + arg: None, + result: Some(TranscriptToolResult::text("1 task found", false)), + } + ), + fx!( + "run_event.tool_call.external_interrupted.json", + RunEvent::ToolCall { + tool_call_id: "tc_05".to_string(), + name: "ticktick_search_task".to_string(), + status: ToolCallStatus::Error, + arg: None, + result: Some(TranscriptToolResult::interrupted()), + } + ), + // snapshot (review P1 #2): the ordered full-timeline reconnect + // authority — one representative of each segment kind it carries + // (text, external tool_call with result, reasoning with duration), + // locking the nested Segment union through the RunEvent leg. + fx!( + "run_event.snapshot.json", + RunEvent::Snapshot { + segments: vec![ + Segment::Text { + text: "Bought milk. ".to_string(), + }, + Segment::ToolCall { + tool_call_id: "tc_06".to_string(), + name: "ticktick_filter_tasks".to_string(), + status: "completed".to_string(), + arg: None, + result: Some(TranscriptToolResult::text("1 task found", false)), + }, + Segment::Reasoning { + text: "Checking the list…".to_string(), + duration_ms: Some(1500), + }, + ], } ), fx!("run_event.done.json", RunEvent::Done), @@ -952,6 +1022,24 @@ mod parity_fixtures { }, } ), + fx!( + "external_tool_ack.started.json", + ExternalToolAck { + kind: "external_tool_ack", + tool_call_id: "tc_ext".to_string(), + phase: ExternalToolPhase::Started, + ok: true, + } + ), + fx!( + "external_tool_ack.finished_nack.json", + ExternalToolAck { + kind: "external_tool_ack", + tool_call_id: "tc_ext".to_string(), + phase: ExternalToolPhase::Finished, + ok: false, + } + ), // WorkerManifest (ser-only, borrowed-lifetime <'a> — owned literals live // to the serialize call inside `fx!`). Maximal: resume mode, all THREE // ManifestMessage variants (user / assistant-with-tool_calls / @@ -989,8 +1077,7 @@ mod parity_fixtures { }, ManifestMessage::ToolResult { tool_call_id: "tc_1", - content: "Accepted.", - is_error: None, + result: &decision_result, }, ], mode: Some("resume"), @@ -999,6 +1086,11 @@ mod parity_fixtures { mime: "image/png".to_string(), data_base64: "aW1hZ2UgYnl0ZXM=".to_string(), }]), + external_tools: Some(ExternalToolsManifest { + endpoint: "https://mcp.ticktick.com/", + access_token: "tok_ticktick", + timeout_ms: 30_000, + }), } ), // WorkerManifest bare: fresh start, empty history, no mode / token / @@ -1021,6 +1113,58 @@ mod parity_fixtures { mode: None, access_token: None, attachments: None, + external_tools: None, + } + ), + // ── ticktick/* Web-lane results (external-task-views A2) ────────── + fx!( + "ticktick_status_result.connected.json", + TickTickStatusResult::Connected { + connection_id: "conn-01900000".to_string(), + } + ), + fx!( + "ticktick_status_result.not_connected.json", + TickTickStatusResult::NotConnected + ), + // A maximal row (Inbox list, due tuple, RRULE, checklist) + a + // minimal one (unmatched list, undated) cover every optional leg; + // source_limit_reached true carries the truncation signal. + fx!( + "ticktick_tasks_list_result.json", + TickTickTasksListResult { + source_limit_reached: true, + tasks: vec![ + TickTickTaskRow { + id: "t1".to_string(), + list_name: Some("Inbox".to_string()), + title: "buy milk".to_string(), + kind: "CHECKLIST".to_string(), + priority: 3, + tags: vec!["errand".to_string()], + due: Some(TickTickDue { + date: "2026-08-20T17:30:00.000+0000".to_string(), + is_all_day: false, + time_zone: "America/Los_Angeles".to_string(), + }), + repeat_flag: Some("RRULE:FREQ=DAILY;INTERVAL=1".to_string()), + checklist_items: vec![TickTickChecklistItem { + title: "2%".to_string(), + done: true, + }], + }, + TickTickTaskRow { + id: "t2".to_string(), + list_name: None, + title: "think".to_string(), + kind: "TEXT".to_string(), + priority: 0, + tags: vec![], + due: None, + repeat_flag: None, + checklist_items: vec![], + }, + ], } ), // ── Decision prose (finding F12): NOT a wire type — the machine- @@ -1116,14 +1260,22 @@ mod parity_fixtures { "run_event.tool_call.started.json", "run_event.tool_call.completed.json", "run_event.tool_call.error.json", + "run_event.tool_call.external_completed.json", + "run_event.tool_call.external_interrupted.json", + "run_event.snapshot.json", "run_event.done.json", "run_event.cancelled.json", "run_event.error.json", "run_event.reasoning_delta.json", "tool_result.ok.json", "tool_result.err.json", + "external_tool_ack.started.json", + "external_tool_ack.finished_nack.json", "worker_manifest.json", "worker_manifest.bare.json", + "ticktick_status_result.connected.json", + "ticktick_status_result.not_connected.json", + "ticktick_tasks_list_result.json", "decision_prose.json", ]; // The embedded table must cover exactly what the writer emits — neither can @@ -1243,6 +1395,8 @@ mod parity_fixtures { parses!(WorkerStdout, "worker_stdout.error.json"); parses!(WorkerStdout, "worker_stdout.tool_request.json"); parses!(WorkerStdout, "worker_stdout.reasoning_delta.json"); + parses!(WorkerStdout, "worker_stdout.external_tool_started.json"); + parses!(WorkerStdout, "worker_stdout.external_tool_finished.json"); // HelperLine (deser-only): the 3 variants Core reads off the Provider // Helper's stdout (ADR-0023). Hand-authored because Core never @@ -1278,6 +1432,8 @@ mod parity_fixtures { /// `DecisionProse`, which is not a `pub` production type here; the TS /// registry's `ProviderHelperLine` is Rust's `HelperLine`). const FIXTURE_BACKED: &[&str] = &[ + "TickTickStatusResult", + "TickTickTasksListResult", "PostMessageParams", "PostMessageResult", "SubscribeParams", @@ -1338,6 +1494,7 @@ mod parity_fixtures { "SettingsSetParams", "RunEvent", "ToolResult", + "ExternalToolAck", "WorkerStdout", "WorkerManifest", "HelperLine", @@ -1376,6 +1533,10 @@ mod parity_fixtures { ("AgentToolResult", "tool_result.ok.json"), ("ToolTextContent", "tool_result.ok.json"), ("ToolErrorWire", "tool_result.err.json"), + ( + "ExternalToolPhase", + "external_tool_ack.started.json / external_tool_ack.finished_nack.json", + ), ("ProviderStatus", "provider_status_result.json"), ("ModelInfo", "model_catalog_result.json"), ("ProviderModels", "model_catalog_result.json"), @@ -1384,6 +1545,18 @@ mod parity_fixtures { ("ManifestMessage", "worker_manifest.json (all three variants)"), ("WorkflowManifest", "worker_manifest.json"), ("CoreToolDescriptor", "worker_manifest.json"), + ("ExternalToolsManifest", "worker_manifest.json external_tools"), + ("TickTickDue", "ticktick_tasks_list_result.json tasks[].due"), + ( + "TickTickChecklistItem", + "ticktick_tasks_list_result.json tasks[].checklist_items", + ), + ("TickTickTaskRow", "ticktick_tasks_list_result.json tasks[]"), + ( + "TranscriptToolResult", + "run_event.tool_call.external_*.json + worker_manifest.json tool_result \ + + worker_stdout.external_tool_finished.json (authored)", + ), ]; /// Deliberately out of the gate, each with its recorded reason. @@ -1413,6 +1586,7 @@ mod parity_fixtures { ("provider.rs", include_str!("provider.rs")), ("run.rs", include_str!("run.rs")), ("thread.rs", include_str!("thread.rs")), + ("ticktick.rs", include_str!("ticktick.rs")), ("worker.rs", include_str!("worker.rs")), ]; diff --git a/crates/core/src/protocol/run.rs b/crates/core/src/protocol/run.rs index 4ce6be4d..098cec58 100644 --- a/crates/core/src/protocol/run.rs +++ b/crates/core/src/protocol/run.rs @@ -3,6 +3,9 @@ use serde::{Deserialize, Serialize}; +use super::thread::Segment; +use super::worker::TranscriptToolResult; + /// `run/post_message` params: add a message (and its Run) to an existing Thread /// (ADR-0022). Minting a new Thread is `thread/create`'s job, so `thread_id` is /// required; malformed → `invalid_params` (-32602), unknown → `unknown_thread` @@ -44,9 +47,16 @@ pub struct RunCancelParams { /// `run/cancel` result (ADR-0014): `accepted` (live/parked, now cancelling), /// `already_terminal` (finished before the cancel arrived), or `unknown_run`. +/// `live_tail` (external-task-views A4) tells the Client whether a terminal +/// `cancelled` (plus any interrupted `tool_call` events) WILL arrive on the +/// live subscribe stream: true only for a won running-cancel with a live hub. +/// When false on an `accepted` cancel (parked, or the running-without-hub +/// resume window), NO stream event follows, so the Client settles the bubble +/// off this response — no timer guess. #[derive(Debug, Serialize)] pub struct RunCancelResult { pub outcome: String, + pub live_tail: bool, } /// `run/retry` params (ADR-0028 retry amendment, #230): the errored Run to @@ -132,6 +142,12 @@ pub enum RunEvent { /// it matches the rehydrated `ToolCallView`. #[serde(skip_serializing_if = "Option::is_none", default)] arg: Option, + /// The normalized result the model received (external-task-views A4): + /// carried on TERMINAL events of external (`ticktick_*`) calls so the + /// live expandable row matches reload; started events (and Core-tool + /// rows in v1) omit it. + #[serde(skip_serializing_if = "Option::is_none", default)] + result: Option, }, Done, Cancelled, @@ -144,6 +160,17 @@ pub enum RunEvent { ReasoningDelta { delta: String, }, + /// The full ordered timeline of the Run as of the subscribe instant + /// (external-task-views, review P1 #2): text / reasoning / tool_call / proposal + /// segments in `run_steps` order (the same assembly `thread/get` uses), + /// INCLUDING a still-`running` external call. Emitted ONCE as the + /// snapshot-then-attach snapshot; the Client ATOMICALLY REPLACES its segments + /// for the Run with this list, so a reconnect renders the true interleaved + /// order and never drops reasoning — superseding the prior text-then-tools + /// projection that emitted cumulative text before all calls. + Snapshot { + segments: Vec, + }, } /// Mirror tests: lock the Rust serde shapes to the canonical snake_case wire @@ -160,16 +187,25 @@ mod mirror_tests { const UUID_A: &str = "0190d3c1-0000-7000-8000-000000000001"; #[test] - fn run_cancel_result_encodes_outcome() { + fn run_cancel_result_encodes_outcome_and_live_tail() { for outcome in ["accepted", "already_terminal", "unknown_run"] { let r = RunCancelResult { outcome: outcome.to_string(), + live_tail: false, }; assert_eq!( serde_json::to_value(&r).unwrap(), - json!({ "outcome": outcome }), + json!({ "outcome": outcome, "live_tail": false }), ); } + let live = RunCancelResult { + outcome: "accepted".to_string(), + live_tail: true, + }; + assert_eq!( + serde_json::to_value(&live).unwrap(), + json!({ "outcome": "accepted", "live_tail": true }), + ); } #[test] @@ -252,19 +288,48 @@ mod mirror_tests { name, status: got, arg, + result, } => { assert_eq!(tool_call_id, "tc_01"); assert_eq!(name, "read_thread"); assert_eq!(*got, status); assert_eq!(*arg, None, "argless tool omits arg"); + assert_eq!(*result, None, "a Core-tool row carries no result"); } other => panic!("expected ToolCall, got {other:?}"), } - // No `arg` key when absent (skip_serializing_if). + // No `arg`/`result` key when absent (skip_serializing_if). assert_eq!(serde_json::to_value(&ev).unwrap(), wire); } } + /// A terminal external-call event carries the normalized result the model + /// received (external-task-views A4), round-tripping the whole shape. + #[test] + fn run_event_tool_call_round_trips_with_result() { + let wire = json!({ + "kind": "tool_call", + "tool_call_id": "tc_ext", + "name": "ticktick_filter_tasks", + "status": "completed", + "result": { + "content": [{ "type": "text", "text": "1 task found" }], + "is_error": false + }, + }); + let ev: RunEvent = serde_json::from_value(wire.clone()).unwrap(); + match &ev { + RunEvent::ToolCall { result, .. } => { + assert_eq!( + *result, + Some(TranscriptToolResult::text("1 task found", false)) + ); + } + other => panic!("expected ToolCall, got {other:?}"), + } + assert_eq!(serde_json::to_value(&ev).unwrap(), wire); + } + #[test] fn run_event_tool_call_round_trips_with_arg() { let wire = json!({ diff --git a/crates/core/src/protocol/thread.rs b/crates/core/src/protocol/thread.rs index 27e6c818..e200f927 100644 --- a/crates/core/src/protocol/thread.rs +++ b/crates/core/src/protocol/thread.rs @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize}; +use super::worker::TranscriptToolResult; + /// `thread/titled` Notification (ADR-0047): the one-shot titler (ADR-0046) pushes /// the generated `title` to the connection that created `thread_id`, so its /// sidebar updates live without a `thread/list` poll. Rides the connection's @@ -109,7 +111,10 @@ pub struct ThreadGetParams { /// consumer): an image/media reference on a user Message, its bytes served at /// `GET /media/{media_id}`. This SUPERSEDES the read-path shapes of ADR-0043 /// (`tool_calls`) and ADR-0044 (`proposal`): both fold into `segments`. -#[derive(Debug, Serialize)] +// `Deserialize`/`Clone` (beyond `thread/get`'s serialize-only need): `Segment` +// also rides `RunEvent::Snapshot` (review P1 #2), which the wire protocol +// round-trips. `default` on each skipped Option lets an omitted field decode. +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Segment { /// A contiguous run of assistant text (one `message_parts` row). @@ -117,12 +122,18 @@ pub enum Segment { /// A settled tool-activity row (ADR-0043): `name`, `status` (`completed`/`error` /// — the read filters `pending`), and an optional display `arg`, omitted (not /// `null`) for argless tools. Proposal tool calls are NOT emitted here — they - /// become a `proposal` segment. + /// become a `proposal` segment. `tool_call_id` is the durable per-call + /// identity (external-task-views A4) so the reload row keys and expands + /// identically to the live one; `result` is the normalized content the model + /// received, populated for external (`ticktick_*`) calls in v1. ToolCall { + tool_call_id: String, name: String, status: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + result: Option, }, /// The decided Proposal an assistant turn parked on (ADR-0044). Only /// `accepted`/`rejected` appear — a still-`pending` Proposal renders its @@ -137,7 +148,7 @@ pub enum Segment { proposal_id: String, mutation_kind: String, status: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] entity_id: Option, }, /// The model's thinking trace (ADR-0045 reasoning amendment, #202): `text` is @@ -147,7 +158,7 @@ pub enum Segment { /// unknown. Renders default-collapsed; never replayed into the worker transcript. Reasoning { text: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] duration_ms: Option, }, /// An image/media reference on a user Message (ADR-0058 consumer, the fifth @@ -158,9 +169,9 @@ pub enum Segment { Attachment { media_id: String, mime: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] width: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] height: Option, }, } @@ -182,7 +193,7 @@ pub struct MessageView { /// The owning Run's `terminal_reason` — `'cancelled'` lets the Client /// rehydrate a stopped turn calmly (ADR-0014: cancel is not an error); /// omitted (not `null`, matching the TS `S.optional`) while the Run is live. - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub terminal_reason: Option, pub segments: Vec, } diff --git a/crates/core/src/protocol/ticktick.rs b/crates/core/src/protocol/ticktick.rs new file mode 100644 index 00000000..5046df08 --- /dev/null +++ b/crates/core/src/protocol/ticktick.rs @@ -0,0 +1,171 @@ +//! `ticktick/*` Web-lane wire types (external-task-views A2): the connection +//! state the Web keys its task query on, and the normalized task rows. Core +//! holds NO task state — these are computed per read from TickTick's OpenAPI +//! (`crate::ticktick::client`). Mirrored in TS (`packages/protocol`). The +//! private OpenAPI transport-decode shapes live in `crate::ticktick::wire`, +//! not here — this module is only the TS-mirrored contract. + +use serde::Serialize; + +/// `ticktick/status` result (A5): a `state`-tagged union, so `Connected` ALWAYS +/// carries the opaque, boot-scoped connection ID and `NotConnected` never does +/// — the illegal shapes (connected-without-id, disconnected-with-id) are +/// unrepresentable. The id is the SOLE task-query key; the Web calls this FIRST +/// on every (re)connection and gates task reads on it (A2 reconnect protocol). +#[derive(Debug, Serialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum TickTickStatusResult { + Connected { connection_id: String }, + NotConnected, +} + +/// A task's single due tuple (external-task-views S1a: start/due COLLAPSE, so +/// there is no separate start). `date` is TickTick's UTC instant string; +/// `is_all_day` + `time_zone` carry the local meaning (never the instant +/// alone). Absent on an undated task. +#[derive(Debug, Serialize, Clone, PartialEq, Eq)] +pub struct TickTickDue { + pub date: String, + pub is_all_day: bool, + pub time_zone: String, +} + +/// One checklist sub-item of a `CHECKLIST` task: its title and done flag +/// (TickTick `status` 0 = open, 1 = done). +#[derive(Debug, Serialize, Clone, PartialEq, Eq)] +pub struct TickTickChecklistItem { + pub title: String, + pub done: bool, +} + +/// A normalized TickTick task row for the Web Tasks surface (A2). TickTick ids +/// are verbatim; TickTick's "project" is exposed as `list` (Project is an +/// inkstone outcome Entity). `list_name` is the resolved list: a `/project` +/// row's name, the synthetic `"Inbox"` for the `^inbox` sentinel (S1a outcome +/// 1), or `None` for an unmatched id (rendered "unnamed list"). Only `TEXT` +/// and `CHECKLIST` kinds reach here (NOTE is discarded upstream). +#[derive(Debug, Serialize, Clone, PartialEq, Eq)] +pub struct TickTickTaskRow { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub list_name: Option, + pub title: String, + pub kind: String, + pub priority: i64, + pub tags: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub due: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub repeat_flag: Option, + pub checklist_items: Vec, +} + +/// `ticktick/tasks/list` result (A2): the normalized rows plus the truncation +/// signal. `source_limit_reached` is computed on the RAW row count BEFORE kind +/// filtering (a 200-row response can normalize to fewer visible tasks — the +/// signal must survive NOTE filtering), so the Tasks UI can warn that the view +/// may be incomplete. +#[derive(Debug, Serialize)] +pub struct TickTickTasksListResult { + pub tasks: Vec, + pub source_limit_reached: bool, +} + +#[cfg(test)] +mod mirror_tests { + use super::*; + use serde_json::json; + + #[test] + fn status_connected_and_not_connected_shapes() { + // Connected ALWAYS carries the id (the union makes id-less connected + // unrepresentable); not_connected is just the tag. + assert_eq!( + serde_json::to_value(TickTickStatusResult::Connected { + connection_id: "conn-1".to_string(), + }) + .unwrap(), + json!({ "state": "connected", "connection_id": "conn-1" }) + ); + assert_eq!( + serde_json::to_value(TickTickStatusResult::NotConnected).unwrap(), + json!({ "state": "not_connected" }) + ); + } + + #[test] + fn task_row_full_and_minimal_shapes() { + // A maximal CHECKLIST-ish row. + let full = TickTickTaskRow { + id: "t1".to_string(), + list_name: Some("Inbox".to_string()), + title: "buy milk".to_string(), + kind: "CHECKLIST".to_string(), + priority: 3, + tags: vec!["errand".to_string()], + due: Some(TickTickDue { + date: "2026-08-20T17:30:00.000+0000".to_string(), + is_all_day: false, + time_zone: "America/Los_Angeles".to_string(), + }), + repeat_flag: Some("RRULE:FREQ=DAILY;INTERVAL=1".to_string()), + checklist_items: vec![TickTickChecklistItem { + title: "2%".to_string(), + done: true, + }], + }; + assert_eq!( + serde_json::to_value(&full).unwrap(), + json!({ + "id": "t1", + "list_name": "Inbox", + "title": "buy milk", + "kind": "CHECKLIST", + "priority": 3, + "tags": ["errand"], + "due": { + "date": "2026-08-20T17:30:00.000+0000", + "is_all_day": false, + "time_zone": "America/Los_Angeles" + }, + "repeat_flag": "RRULE:FREQ=DAILY;INTERVAL=1", + "checklist_items": [{ "title": "2%", "done": true }] + }) + ); + // A minimal undated TEXT row with an unmatched list: optionals omitted. + let minimal = TickTickTaskRow { + id: "t2".to_string(), + list_name: None, + title: "think".to_string(), + kind: "TEXT".to_string(), + priority: 0, + tags: vec![], + due: None, + repeat_flag: None, + checklist_items: vec![], + }; + assert_eq!( + serde_json::to_value(&minimal).unwrap(), + json!({ + "id": "t2", + "title": "think", + "kind": "TEXT", + "priority": 0, + "tags": [], + "checklist_items": [] + }) + ); + } + + #[test] + fn tasks_list_result_shape() { + assert_eq!( + serde_json::to_value(TickTickTasksListResult { + tasks: vec![], + source_limit_reached: true, + }) + .unwrap(), + json!({ "tasks": [], "source_limit_reached": true }) + ); + } +} diff --git a/crates/core/src/protocol/worker.rs b/crates/core/src/protocol/worker.rs index ae44ac38..c8a0e003 100644 --- a/crates/core/src/protocol/worker.rs +++ b/crates/core/src/protocol/worker.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; /// One `content` block of an `AgentToolResult`. Text-only today; `r#type` /// serializes as `"type"`. -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct ToolTextContent { pub r#type: String, pub text: String, @@ -27,6 +27,40 @@ pub struct AgentToolResult { pub terminate: Option, } +/// The single transcript result type for ALL tools (external-task-views A4): +/// the model-visible content blocks plus the ONE error flag. Carried by the +/// `external_tool_finished` frame, persisted in `tool_calls.result_payload` +/// for external (`ticktick_*`) calls, served on terminal `tool_call` Run +/// Events and `Segment::ToolCall`, and replayed in the resume manifest's +/// `tool_result` blocks. Deliberately no runtime `details`/`terminate` — those +/// are Worker-runtime control flow, never durable transcript state. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct TranscriptToolResult { + pub content: Vec, + pub is_error: bool, +} + +impl TranscriptToolResult { + /// A single-text-block result. + pub fn text(text: impl Into, is_error: bool) -> Self { + Self { + content: vec![ToolTextContent { + r#type: "text".to_string(), + text: text.into(), + }], + is_error, + } + } + + /// The Core-generated result a Run-termination settle writes into every + /// still-pending external call (external-task-views A4): the one case where + /// an expansion shows Core-synthesized text rather than content the model + /// received (the model saw nothing; the Run died first). + pub fn interrupted() -> Self { + Self::text("interrupted", true) + } +} + /// One tool the Workflow exposes, shipped (allowlist-filtered) in the spawn /// manifest. `json_schema` is the `schemars`-derived Draft-07 schema of the /// tool's Rust `Input` struct. @@ -64,6 +98,25 @@ pub struct ToolResult { pub outcome: ToolOutcome, } +/// Which external lifecycle frame Core is acknowledging. +#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ExternalToolPhase { + Started, + Finished, +} + +/// Core → Worker: durable acceptance or rejection of one external lifecycle +/// frame. The dedicated Worker pipe already identifies the Run, so correlation +/// needs only `tool_call_id` + `phase`. Failure detail stays in Core's log. +#[derive(Debug, Serialize, Clone, PartialEq, Eq)] +pub struct ExternalToolAck { + pub kind: &'static str, + pub tool_call_id: String, + pub phase: ExternalToolPhase, + pub ok: bool, +} + /// What Core reads off the Worker's stdout: the one-way `RunEvent`s plus the /// bidirectional `tool_request`. The `tool_request`'s `run_id` is Core-ignored /// (Core uses the spawn's authoritative run id) — kept for symmetry with the TS @@ -91,6 +144,21 @@ pub enum WorkerStdout { name: String, params: serde_json::Value, }, + /// An EXTERNAL (Worker-executed MCP, `ticktick_*`) call began + /// (external-task-views A4), from pi's `tool_execution_start` event. Core + /// persists the pending row and publishes the started `tool_call` event. + ExternalToolStarted { + tool_call_id: String, + name: String, + arguments: serde_json::Value, + }, + /// The external call's ONE terminal frame, from pi's finalized + /// `tool_execution_end`. No outer error flag — `result.is_error` is the + /// single source; `tool_calls.status` derives from it. + ExternalToolFinished { + tool_call_id: String, + result: TranscriptToolResult, + }, } /// One NDJSON line of the Provider Helper's stdout (ADR-0023): `authorize_url` @@ -142,12 +210,13 @@ pub enum ManifestMessage<'a> { #[allow(dead_code)] tool_calls: Option>>, }, - #[allow(dead_code)] + /// The paired result for a prior tool_call (ADR-0025), carried as the ONE + /// transcript result type (external-task-views A4): Core tool results, + /// Proposal Decisions, the not-executed placeholder, and MCP results all + /// ride this same shape. ToolResult { tool_call_id: &'a str, - content: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - is_error: Option, + result: &'a TranscriptToolResult, }, } @@ -201,6 +270,33 @@ pub struct WorkerManifest<'a> { pub access_token: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] pub attachments: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub external_tools: Option>, +} + +/// External (Worker-executed MCP) tool config shipped in the spawn manifest +/// (external-task-views A3/A5): the TickTick MCP endpoint + auth from Core's +/// boot-read credential state. Absent = no external tools this Run. +/// +/// `Debug` is hand-implemented to redact `access_token` — the bearer secret +/// must never reach a log line (mirrors [`crate::credentials::Credentials`]). +/// Core's tracing logs structs with `{:?}`, so a deriving Debug is the exact +/// leak primitive that convention forbids. +#[derive(Serialize)] +pub struct ExternalToolsManifest<'a> { + pub endpoint: &'a str, + pub access_token: &'a str, + pub timeout_ms: u64, +} + +impl std::fmt::Debug for ExternalToolsManifest<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExternalToolsManifest") + .field("endpoint", &self.endpoint) + .field("access_token", &"") + .field("timeout_ms", &self.timeout_ms) + .finish() + } } /// Mirror tests: lock the Rust serde shapes to the canonical snake_case wire @@ -249,4 +345,177 @@ mod mirror_tests { let done: WorkerStdout = serde_json::from_value(json!({ "kind": "done" })).unwrap(); assert!(matches!(done, WorkerStdout::Done)); } + + #[test] + fn worker_stdout_decodes_external_tool_started() { + let wire = json!({ + "kind": "external_tool_started", + "tool_call_id": "tc_ext", + "name": "ticktick_filter_tasks", + "arguments": { "filter": { "status": [0] } } + }); + let ev: WorkerStdout = serde_json::from_value(wire).unwrap(); + match ev { + WorkerStdout::ExternalToolStarted { + tool_call_id, + name, + arguments, + } => { + assert_eq!(tool_call_id, "tc_ext"); + assert_eq!(name, "ticktick_filter_tasks"); + assert_eq!(arguments["filter"]["status"], json!([0])); + } + other => panic!("expected ExternalToolStarted, got {other:?}"), + } + } + + #[test] + fn worker_stdout_decodes_external_tool_finished() { + let wire = json!({ + "kind": "external_tool_finished", + "tool_call_id": "tc_ext", + "result": { + "content": [{ "type": "text", "text": "1 task found" }], + "is_error": false + } + }); + let ev: WorkerStdout = serde_json::from_value(wire).unwrap(); + match ev { + WorkerStdout::ExternalToolFinished { + tool_call_id, + result, + } => { + assert_eq!(tool_call_id, "tc_ext"); + assert_eq!(result, TranscriptToolResult::text("1 task found", false)); + } + other => panic!("expected ExternalToolFinished, got {other:?}"), + } + // A frame missing `result.is_error` fails strict decode — the error flag + // lives once, inside the result, and is never defaulted. + assert!( + serde_json::from_value::(json!({ + "kind": "external_tool_finished", + "tool_call_id": "tc_ext", + "result": { "content": [] } + })) + .is_err() + ); + } + + #[test] + fn external_tool_ack_serializes() { + let ack = ExternalToolAck { + kind: "external_tool_ack", + tool_call_id: "tc_ext".to_string(), + phase: ExternalToolPhase::Started, + ok: true, + }; + assert_eq!( + serde_json::to_value(ack).unwrap(), + json!({ + "kind": "external_tool_ack", + "tool_call_id": "tc_ext", + "phase": "started", + "ok": true + }) + ); + } + + #[test] + fn external_tools_manifest_debug_redacts_the_token() { + let manifest = ExternalToolsManifest { + endpoint: "https://mcp.ticktick.com/", + access_token: "SECRET_TICKTICK_TOKEN", + timeout_ms: 30_000, + }; + let rendered = format!("{manifest:?}"); + assert!( + !rendered.contains("SECRET_TICKTICK_TOKEN"), + "the bearer token must never reach a Debug line" + ); + assert!( + rendered.contains("https://mcp.ticktick.com/"), + "the non-secret endpoint may show" + ); + } + + #[test] + fn transcript_tool_result_round_trips() { + let r = TranscriptToolResult::interrupted(); + let wire = serde_json::to_value(&r).unwrap(); + assert_eq!( + wire, + json!({ + "content": [{ "type": "text", "text": "interrupted" }], + "is_error": true + }) + ); + let back: TranscriptToolResult = serde_json::from_value(wire).unwrap(); + assert_eq!(back, r); + } + + #[test] + fn manifest_tool_result_block_carries_transcript_result() { + let result = TranscriptToolResult::text("Accepted.", false); + let block = ManifestMessage::ToolResult { + tool_call_id: "tc_1", + result: &result, + }; + assert_eq!( + serde_json::to_value(&block).unwrap(), + json!({ + "role": "tool_result", + "tool_call_id": "tc_1", + "result": { + "content": [{ "type": "text", "text": "Accepted." }], + "is_error": false + } + }) + ); + } + + #[test] + fn worker_manifest_external_tools_serializes_and_skips_when_absent() { + let manifest = WorkerManifest { + run_id: uuid::Uuid::parse_str(UUID_A).unwrap(), + workflow: WorkflowManifest { + name: "default", + version: "1", + provider: "faux", + model: "m", + system_prompt: "sp", + thinking_level: "off", + tools: Vec::new(), + }, + prompt: "hi", + messages: Vec::new(), + mode: None, + access_token: None, + attachments: None, + external_tools: Some(ExternalToolsManifest { + endpoint: "https://mcp.ticktick.com/", + access_token: "tok", + timeout_ms: 30_000, + }), + }; + let wire = serde_json::to_value(&manifest).unwrap(); + assert_eq!( + wire["external_tools"], + json!({ + "endpoint": "https://mcp.ticktick.com/", + "access_token": "tok", + "timeout_ms": 30_000 + }) + ); + + let manifest = WorkerManifest { + external_tools: None, + ..manifest + }; + let wire = serde_json::to_value(&manifest).unwrap(); + assert!( + wire.get("external_tools").is_none(), + "absent external_tools is skipped, not null" + ); + } } diff --git a/crates/core/src/resume.rs b/crates/core/src/resume.rs index d47f8deb..ada3000f 100644 --- a/crates/core/src/resume.rs +++ b/crates/core/src/resume.rs @@ -11,7 +11,7 @@ use sqlx::SqlitePool; use uuid::Uuid; use crate::db::{self, TimelineStep}; -use crate::protocol::{ManifestMessage, ManifestToolCall}; +use crate::protocol::{ManifestMessage, ManifestToolCall, TranscriptToolResult}; /// One reconstructed transcript block, owning its strings so the spawned resume /// task can borrow them into the (borrowing) [`ManifestMessage`]. Mirrors that @@ -26,8 +26,7 @@ pub enum Block { }, ToolResult { tool_call_id: String, - content: String, - is_error: Option, + result: TranscriptToolResult, }, } @@ -63,12 +62,10 @@ impl Block { }, Block::ToolResult { tool_call_id, - content, - is_error, + result, } => ManifestMessage::ToolResult { tool_call_id, - content, - is_error: *is_error, + result, }, } } @@ -125,6 +122,14 @@ pub async fn reconstruct(pool: &SqlitePool, run_id: Uuid) -> sqlx::Result transcript_result(&name, &payload), + None => TranscriptToolResult::text(NOT_EXECUTED, false), + }; if let Some(Block::Assistant { tool_calls, .. }) = blocks.last_mut() { tool_calls.push(ToolCallBlock { id: id.clone(), @@ -132,18 +137,9 @@ pub async fn reconstruct(pool: &SqlitePool, run_id: Uuid) -> sqlx::Result (render_result_content(&payload), None), - None => (NOT_EXECUTED.to_string(), Some(false)), - }; blocks.push(Block::ToolResult { tool_call_id: id, - content, - is_error, + result, }); } } @@ -152,18 +148,52 @@ pub async fn reconstruct(pool: &SqlitePool, run_id: Uuid) -> sqlx::Result String { - match serde_json::from_str::(payload) { - Ok(v) => v - .get("content") - .and_then(|c| c.as_str()) - .map(str::to_string) - .unwrap_or_else(|| payload.to_string()), - Err(_) => payload.to_string(), +/// Reduce a persisted `result_payload` to the ONE transcript result type +/// (external-task-views A4), keyed by the call's kind: +/// +/// - **External (`ticktick_*`)**: the payload IS a `TranscriptToolResult` (the +/// finished frame or the interrupted settle wrote it) — decode it verbatim. +/// - **Core success** (`AgentToolResult` JSON): keep the content blocks, drop +/// the runtime `details`/`terminate` sidecars, `is_error: false`. +/// - **Proposal Decision** (`{"decision", "content", is_error?}`): its +/// `content` string as one text block. +/// - **Core error** (`{"code", "message"}`): the message as one text block, +/// `is_error: TRUE` — the migration away from the old string-reduction, which +/// replayed an error payload as a success-shaped result. +/// - Anything else passes through verbatim as one text block, so a tool's +/// output is never lost. +fn transcript_result(name: &str, payload: &str) -> TranscriptToolResult { + if crate::tools::is_external(name) + && let Ok(result) = serde_json::from_str::(payload) + { + return result; } + if let Ok(value) = serde_json::from_str::(payload) { + // Proposal Decision: {"decision": …, "content": , is_error?}. + if value.get("decision").is_some() + && let Some(content) = value.get("content").and_then(|c| c.as_str()) + { + let is_error = value.get("is_error").and_then(|e| e.as_bool()) == Some(true); + return TranscriptToolResult::text(content, is_error); + } + // Core success: the persisted AgentToolResult {content: [...]}. + if let Some(content) = value.get("content") + && let Ok(blocks) = + serde_json::from_value::>(content.clone()) + { + return TranscriptToolResult { + content: blocks, + is_error: false, + }; + } + // Core error: {"code": …, "message": }. + if let Some(message) = value.get("message").and_then(|m| m.as_str()) + && value.get("code").is_some() + { + return TranscriptToolResult::text(message, true); + } + } + TranscriptToolResult::text(payload, false) } #[cfg(test)] @@ -344,8 +374,7 @@ mod tests { }, Block::ToolResult { tool_call_id, - content, - is_error, + result, }, ] => { assert_eq!(user, "Log that I bought milk."); @@ -354,8 +383,14 @@ mod tests { assert_eq!(tool_calls.len(), 1); assert_eq!(tool_calls[0].id, "tc-1"); assert_eq!(tool_call_id, "tc-1"); - assert_eq!(content, "Accepted. Created Journal Entry (entity_id=e1)."); - assert_eq!(*is_error, None, "a Decision is a normal result"); + assert_eq!( + *result, + TranscriptToolResult::text( + "Accepted. Created Journal Entry (entity_id=e1).", + false + ), + "a Decision is a normal (non-error) result" + ); } other => panic!( "expected [User, Assistant(text), Assistant(tool_call), ToolResult], got {} blocks", @@ -386,19 +421,20 @@ mod tests { let blocks = reconstruct(&pool, run_id).await.expect("reconstruct"); - let (content, is_error) = blocks + let result = blocks .iter() .find_map(|b| match b { Block::ToolResult { tool_call_id, - content, - is_error, - } if tool_call_id == "tc-pending" => Some((content.as_str(), *is_error)), + result, + } if tool_call_id == "tc-pending" => Some(result), _ => None, }) .expect("the unexecuted sibling has a paired result"); - assert_eq!(content, "not executed; resubmit if still needed"); - assert_eq!(is_error, Some(false)); + assert_eq!( + *result, + TranscriptToolResult::text("not executed; resubmit if still needed", false) + ); // The invariant itself, as a loop: no orphan tool_call. let call_ids: Vec<&str> = blocks @@ -422,6 +458,54 @@ mod tests { } } + /// An EXTERNAL (`ticktick_*`) call replays with its persisted + /// TranscriptToolResult decoded VERBATIM (external-task-views A4): the + /// resumed model reads the same content blocks + error flag the original + /// call produced, through the one schema Core results also use. The tool + /// NAME rides the paired assistant block (the Worker codec restores it). + #[tokio::test] + async fn external_call_replays_transcript_result_verbatim() { + let pool = memory_pool().await; + let run_id = seed_run(&pool).await; + insert_tool_call( + &pool, + run_id, + "tc-ext", + "ticktick_filter_tasks", + r#"{"filter":{"status":[0]}}"#, + Some(r#"{"content":[{"type":"text","text":"1 task found"}],"is_error":false}"#), + ) + .await; + step_tool(&pool, run_id, 0, "tc-ext").await; + + let blocks = reconstruct(&pool, run_id).await.expect("reconstruct"); + match blocks.as_slice() { + [ + Block::Assistant { tool_calls, .. }, + Block::ToolResult { + tool_call_id, + result, + }, + ] => { + assert_eq!(tool_calls[0].id, "tc-ext"); + assert_eq!( + tool_calls[0].name, "ticktick_filter_tasks", + "the paired assistant block carries the tool name for the codec to restore" + ); + assert_eq!(tool_call_id, "tc-ext"); + assert_eq!( + *result, + TranscriptToolResult::text("1 task found", false), + "the external result decodes verbatim, not string-reduced" + ); + } + other => panic!( + "expected [Assistant(tool_call), ToolResult], got {} blocks", + other.len() + ), + } + } + /// The attach predicate (the `matches!` on a trailing TEXT-LESS assistant /// block): because every tool_call pairs with its result IMMEDIATELY, the /// block trailing at the next call is a ToolResult — so consecutive calls @@ -530,31 +614,68 @@ mod tests { } } - /// A Decision payload surfaces its `content`; anything else — non-JSON, JSON - /// without a STRING `content` — passes through verbatim so a non-Proposal - /// tool's output is never lost. + /// Every persisted payload shape reduces to the ONE transcript result type + /// (external-task-views A4): Decisions surface their `content`; a Core + /// error migrates to `is_error: true` (no longer replayed success-shaped); + /// a Core success keeps its content blocks and DROPS the runtime + /// `details`/`terminate` sidecars; an external payload decodes verbatim; + /// anything else passes through as one text block so output is never lost. #[test] - fn render_result_content_unwraps_decision_and_passes_through() { + fn transcript_result_reduces_every_payload_shape() { + // Proposal Decisions: accept and reject are both normal results. assert_eq!( - render_result_content( - r#"{"decision":"accept","content":"Accepted. Created Todo (entity_id=e1)."}"# + transcript_result( + "propose_workspace_mutation", + r#"{"decision":"accept","content":"Accepted. Created Person (entity_id=e1)."}"# ), - "Accepted. Created Todo (entity_id=e1)." + TranscriptToolResult::text("Accepted. Created Person (entity_id=e1).", false) ); assert_eq!( - render_result_content( + transcript_result( + "propose_workspace_mutation", r#"{"decision":"reject","content":"User declined this proposal.","is_error":false}"# ), - "User declined this proposal." + TranscriptToolResult::text("User declined this proposal.", false) + ); + + // Core success: content blocks verbatim, details/terminate dropped. + assert_eq!( + transcript_result( + "search_entities", + r#"{"content":[{"type":"text","text":"no hits"}],"details":{"secret":"x"},"terminate":true}"# + ), + TranscriptToolResult::text("no hits", false) + ); + + // Core error: is_error TRUE (the old string-reduction replayed this as + // a success-shaped raw JSON string). + assert_eq!( + transcript_result("search_entities", r#"{"code":"bad_params","message":"boom"}"#), + TranscriptToolResult::text("boom", true) + ); + + // External: the payload IS a TranscriptToolResult — decoded verbatim, + // error flag preserved. + assert_eq!( + transcript_result( + "ticktick_filter_tasks", + r#"{"content":[{"type":"text","text":"interrupted"}],"is_error":true}"# + ), + TranscriptToolResult::interrupted() + ); + + // Pass-throughs: non-JSON, JSON without a decodable shape. + assert_eq!( + transcript_result("search_entities", "plain text output"), + TranscriptToolResult::text("plain text output", false) ); assert_eq!( - render_result_content("plain text output"), - "plain text output" + transcript_result("search_entities", r#"{"ok":true}"#), + TranscriptToolResult::text(r#"{"ok":true}"#, false) ); - assert_eq!(render_result_content(r#"{"ok":true}"#), r#"{"ok":true}"#); assert_eq!( - render_result_content(r#"{"content":42}"#), - r#"{"content":42}"# + transcript_result("search_entities", r#"{"content":42}"#), + TranscriptToolResult::text(r#"{"content":42}"#, false) ); } } diff --git a/crates/core/src/runs/cancel.rs b/crates/core/src/runs/cancel.rs index 742ba5f1..ba617152 100644 --- a/crates/core/src/runs/cancel.rs +++ b/crates/core/src/runs/cancel.rs @@ -1,23 +1,25 @@ //! `run/cancel` handler (ADR-0014): the thin JSON-RPC shell over the //! [`crate::cancel`] verb (ADR-0029, the `proposal/decide` → [`crate::decide`] -//! precedent applied to cancel). Decode params → call `cancel::cancel` (injecting -//! `hub::get` as the hub lookup) → frame the typed [`Outcome`] as the unchanged -//! `RunCancelResult` wire strings (`accepted` / `already_terminal` / `unknown_run`) -//! → frame a DB fault as `Internal`. A malformed `run_id` is `invalid_params`. +//! precedent applied to cancel). Decode params → call [`crate::cancel::cancel`], +//! injecting a `respond` closure that frames the unchanged `RunCancelResult` +//! wire strings (`accepted` / `already_terminal` / +//! `unknown_run`) with `live_tail`. //! -//! The parked-vs-running decision and the running-won Worker signal live in the -//! verb. On a won running-cancel the verb returns the live `RunHub`; this shell -//! publishes the terminal `RunEvent::Cancelled` + removes the hub via -//! [`crate::cancel::publish_cancelled`] AFTER framing the Response, preserving the -//! deterministic `response → cancelled` wire order. +//! The whole decision, the settle transition, the interrupted publications, and +//! the terminal `Cancelled` publish live in the verb — for a running-cancel, all +//! under ONE lifecycle-slot + hub-gate acquisition. The verb calls the injected +//! `respond` at the right point (inside the gate, BEFORE the events) so the wire +//! order `response → interrupted → cancelled` holds. A DB fault rides +//! `anyhow::Error` and is framed here as `Internal` (`-32603`); a malformed +//! `run_id` is `invalid_params` at decode. use sqlx::SqlitePool; use tokio::sync::mpsc::UnboundedSender; use super::handler::{self, HandlerError}; use super::reply::send_response; -use crate::cancel::{self, Outcome}; -use crate::hub::{self, Hubs}; +use crate::cancel; +use crate::hub::Hubs; use crate::protocol::{RunCancelParams, RunCancelResult}; pub(super) async fn handle_cancel( @@ -34,31 +36,29 @@ pub(super) async fn handle_cancel( }; let run_id = params.run_id; - // The verb owns the decision + the Worker signal; the hub lookup is injected so - // it stays testable against `:memory:` (ADR-0029). - let (outcome, cancelled_hub) = match cancel::cancel(pool, run_id, |id| hub::get(hubs, id)).await - { - Ok(Outcome::Accepted { hub }) => ("accepted", hub), - Ok(Outcome::AlreadyTerminal) => ("already_terminal", None), - Ok(Outcome::UnknownRun) => ("unknown_run", None), - Err(e) => { - handler::frame_error(out_tx, id, HandlerError::Internal(e)); - return; - } - }; - - match serde_json::to_value(RunCancelResult { - outcome: outcome.to_string(), - }) { - Ok(result) => send_response(out_tx, id, result), - Err(e) => { - handler::frame_error(out_tx, id, HandlerError::Internal(anyhow::Error::new(e))); - return; - } + // The verb frames the Response via this callback — for a running-cancel, + // INSIDE its gated section and BEFORE the terminal event, pinning the wire + // order response → interrupted → cancelled. A DB fault is the only `Err` and + // leaves the callback uncalled, so frame that error here. + let respond_id = id.clone(); + let result = cancel::cancel( + pool, + hubs, + run_id, + |outcome, live_tail| match serde_json::to_value(RunCancelResult { + outcome: outcome.to_string(), + live_tail, + }) { + Ok(value) => send_response(out_tx, respond_id, value), + Err(e) => handler::frame_error( + out_tx, + respond_id, + HandlerError::Internal(anyhow::Error::new(e)), + ), + }, + ) + .await; + if let Err(e) = result { + handler::frame_error(out_tx, id, HandlerError::Internal(e)); } - - // Publish the terminal Cancelled + remove the hub AFTER the Response is framed, - // so the client sees `response → cancelled`. A parked/lost/terminal/unknown - // outcome carries no hub, and `publish_cancelled` is then a no-op. - cancel::publish_cancelled(hubs, run_id, cancelled_hub).await; } diff --git a/crates/core/src/runs/message.rs b/crates/core/src/runs/message.rs index b01be0fe..af3c1907 100644 --- a/crates/core/src/runs/message.rs +++ b/crates/core/src/runs/message.rs @@ -60,6 +60,7 @@ mod tests { system_prompt: String::new(), thinking_level: None, tools: Vec::new(), + external_tools: false, } } diff --git a/crates/core/src/runs/mod.rs b/crates/core/src/runs/mod.rs index dd0dc661..95e0f292 100644 --- a/crates/core/src/runs/mod.rs +++ b/crates/core/src/runs/mod.rs @@ -30,6 +30,7 @@ mod thread_get; mod thread_list; mod thread_list_archived; mod thread_mutate; +mod ticktick; pub(crate) mod title; use sqlx::SqlitePool; @@ -142,6 +143,12 @@ pub async fn dispatch( "provider/status" => { provider::handle(req.id, req.params, out_tx).await; } + "ticktick/status" => { + ticktick::handle_status(req.id, req.params, out_tx).await; + } + "ticktick/tasks/list" => { + ticktick::handle_tasks_list(req.id, req.params, out_tx).await; + } "model/catalog" => { catalog::handle(req.id, req.params, out_tx).await; } diff --git a/crates/core/src/runs/reply.rs b/crates/core/src/runs/reply.rs index 32a323e7..3c1e8182 100644 --- a/crates/core/src/runs/reply.rs +++ b/crates/core/src/runs/reply.rs @@ -44,17 +44,6 @@ pub(super) fn send_run_event( let _ = out_tx.send(body); } -/// Emit a `text_delta` Run Event (the snapshot rides as one of these, -/// ADR-0022 §17). -pub(super) fn send_text_delta(out_tx: &UnboundedSender, run_id: Uuid, text: &str) { - send_run_event( - out_tx, - run_id, - &RunEvent::TextDelta { - delta: text.to_string(), - }, - ); -} /// Queue a `proposal/pending` notification (ADR-0025): the Run parked and /// `proposal_id` is its awaiting Proposal. Rides the `proposal/*` channel, not diff --git a/crates/core/src/runs/subscribe.rs b/crates/core/src/runs/subscribe.rs index 9b5cd858..b75a5532 100644 --- a/crates/core/src/runs/subscribe.rs +++ b/crates/core/src/runs/subscribe.rs @@ -15,9 +15,9 @@ use tokio::sync::broadcast; use tokio::sync::mpsc::UnboundedSender; use uuid::Uuid; -use super::reply::{send_proposal_pending, send_response, send_run_event, send_text_delta}; +use super::reply::{send_proposal_pending, send_response, send_run_event}; use crate::db::{self, RunStatus}; -use crate::hub::{self, Hubs}; +use crate::hub::{self, Hubs, RunTail}; use crate::protocol::{RunEvent, SubscribeParams, SubscribeResult}; pub(super) async fn handle( @@ -27,89 +27,116 @@ pub(super) async fn handle( params: SubscribeParams, out_tx: &UnboundedSender, ) { - // run_id is typed at decode (ADR-0029 C2): a malformed id is framed as - // invalid_params before this runs. let run_id = params.run_id; + let lifecycle = hub::lifecycle(hubs, run_id).await; match hub::get(hubs, run_id) { - // Run still streaming: snapshot under the gate, then attach - // (RunHub::snapshot_then_attach owns the ADR-0022 lock ritual). Some(run_hub) => { - let (snapshot, receiver) = run_hub - .snapshot_then_attach(|| db::select_run_snapshot(pool, run_id)) + // Snapshot and attach while the lifecycle slot pins this exact hub + // generation. The hub gate keeps the durable timeline and live tail + // gap-free; the lifecycle slot keeps generation classification stable. + let ((snapshot, segments), tail) = run_hub + .snapshot_then_attach(|| async { + ( + db::select_run_snapshot(pool, run_id).await, + db::run_live_segments(pool, run_id, true).await, + ) + }) .await; - let (snapshot_text, status) = match snapshot { - Ok(Some(snap)) => (snap.text, snap.status), - Ok(None) => (String::new(), RunStatus::Running), + let (status, error_message) = match snapshot { + Ok(Some(snap)) => (snap.status, snap.error_message), + Ok(None) => (RunStatus::Running, None), Err(e) => { tracing::error!(event = "subscribe.snapshot_read_failed", %run_id, error = ?e); - (String::new(), RunStatus::Running) + (RunStatus::Running, None) } }; - // A terminal transition can commit before the Worker drops its hub - // clone (e.g. a `run/cancel` win while it's parked in a long tool - // dispatch). A receiver attached now sits AFTER the published - // terminal event while the Worker's sender keeps the channel open, - // so the tail would block on `recv()` forever. When the status under - // the gate is already terminal, emit it and close WITHOUT attaching. send_subscribe_response(out_tx, id, run_id, status.as_str()); - send_text_delta(out_tx, run_id, &snapshot_text); + send_segment_snapshot(out_tx, run_id, segments); match status { - RunStatus::Cancelled => send_run_event(out_tx, run_id, &RunEvent::Cancelled), - RunStatus::Completed | RunStatus::Errored => { - send_run_event(out_tx, run_id, &RunEvent::Done) - } RunStatus::Running | RunStatus::Parked => { - spawn_tail_forwarder(run_id, receiver, out_tx.clone(), pool.clone()) + drop(lifecycle); + spawn_tail_forwarder(run_id, hubs.clone(), tail, out_tx.clone(), pool.clone()); + } + terminal => { + if let Some(event) = terminal_event(terminal, error_message) { + send_run_event(out_tx, run_id, &event); + } + drop(lifecycle); } } } - // No hub: terminal, parked, or unknown. Read persisted status to tell - // parked (ADR-0025) from terminal. `None` is the unknown-run id — modeled - // as the absence of a status rather than an empty-string sentinel. None => { - let status: Option = match db::run_status(pool, run_id).await { - Ok(status) => status, + // No activation or drain can cross this read: both acquire the same + // lifecycle slot before changing the hub generation or durable status. + let snapshot = match db::select_run_snapshot(pool, run_id).await { + Ok(snapshot) => snapshot, Err(e) => { - tracing::error!(event = "subscribe.run_status_read_failed", %run_id, error = ?e); + tracing::error!(event = "subscribe.snapshot_read_failed", %run_id, error = ?e); None } }; - let snapshot = db::select_run_snapshot(pool, run_id).await; - // The wire status stays a string (ADR-0029): an unknown run reports - // the empty status, exactly as before. - send_subscribe_response(out_tx, id, run_id, status.map_or("", RunStatus::as_str)); + let segments = if snapshot.is_some() { + Some(db::run_live_segments(pool, run_id, false).await) + } else { + None + }; + + send_subscribe_response( + out_tx, + id, + run_id, + snapshot.as_ref().map_or("", |snap| snap.status.as_str()), + ); + if let Some(segments) = segments { + send_segment_snapshot(out_tx, run_id, segments); + } match snapshot { - Ok(Some(snap)) => { - send_text_delta(out_tx, run_id, &snap.text); + Some(snap) if snap.status == RunStatus::Parked => { + emit_pending(out_tx, pool, run_id).await; } - Ok(None) => { - // Unknown run id — no snapshot. - } - Err(e) => { - tracing::error!(event = "subscribe.snapshot_read_failed", %run_id, error = ?e); - } - } - // No-false-done (ADR-0025): a parked Run stopped without a terminal - // event, so emit NO terminal Run Event — the Client reads `parked` - // from the response status. Cancelled gets its terminal event; - // completed, running, and the unknown/errored fallback synthesize - // `done`. - match status { - // Push `proposal/pending` (ADR-0025) so a fresh subscriber shows - // the review card without a separate `proposal/get` poll. - Some(RunStatus::Parked) => emit_pending(out_tx, pool, run_id).await, - Some(RunStatus::Cancelled) => { - send_run_event(out_tx, run_id, &RunEvent::Cancelled) + Some(snap) => { + if let Some(event) = terminal_event(snap.status, snap.error_message) { + send_run_event(out_tx, run_id, &event); + } } - _ => send_run_event(out_tx, run_id, &RunEvent::Done), + None => send_run_event( + out_tx, + run_id, + &RunEvent::Error { + message: "unknown run".to_string(), + }, + ), } + drop(lifecycle); } } } +/// The terminal `RunEvent` a settled (or live-lost) status maps to — `None` for +/// `Parked` (which pushes `proposal/pending` instead, ADR-0025). A `Running` Run +/// reached WITHOUT a live hub lost its Worker (the boot-recovery window — every +/// activation path registers its hub BEFORE flipping `running`, review R8 #1 — +/// which the recovery sweep closes) → `Error`, NEVER a synthesized `Done`. The +/// SINGLE source every subscribe terminal site consumes (review M2), so the +/// mapping can't diverge and the forwarder can't fall through to a +/// false-success catch-all. +fn terminal_event(status: RunStatus, error_message: Option) -> Option { + match status { + RunStatus::Parked => None, + RunStatus::Completed => Some(RunEvent::Done), + RunStatus::Cancelled => Some(RunEvent::Cancelled), + RunStatus::Errored => Some(RunEvent::Error { + message: error_message.unwrap_or_default(), + }), + RunStatus::Running => Some(RunEvent::Error { + message: crate::worker::WORKER_DISCONNECTED_MESSAGE.to_string(), + }), + } +} + /// Push a `proposal/pending {run_id, proposal_id}` Notification if the Run has /// a pending Proposal. A missing Proposal or read error is tolerated — the /// Client still learns the park via the `parked` response status (ADR-0025). @@ -126,6 +153,31 @@ async fn emit_pending(out_tx: &UnboundedSender, pool: &SqlitePool, run_i } } +/// Emit the Run's ordered timeline as ONE `RunEvent::Snapshot` (review P1 #2): +/// the Client atomically REPLACES its segments for the Run with this list, so the +/// reconnect timeline matches `thread/get` in interleaved order (text / reasoning +/// / tool_call), plus any still-running call. Maps db `MessageSegment` → wire +/// `Segment` via the shared `From` impl. A read fault degrades to no snapshot +/// (WARN) — the tail still delivers subsequent deltas. +fn send_segment_snapshot( + out_tx: &UnboundedSender, + run_id: Uuid, + segments: sqlx::Result>, +) { + match segments { + Ok(segments) => send_run_event( + out_tx, + run_id, + &RunEvent::Snapshot { + segments: segments.into_iter().map(crate::protocol::Segment::from).collect(), + }, + ), + Err(e) => { + tracing::warn!(event = "subscribe.segment_snapshot_read_failed", %run_id, error = ?e); + } + } +} + /// Frame the subscribe RESPONSE `{run_id, status}` (ADR-0022, ADR-0025): /// `status` is `running` while a live hub exists, else persisted `runs.status`, /// so a refreshed Client tells `parked` from terminal. Events arrive as @@ -155,9 +207,13 @@ fn send_subscribe_response( /// drop it just breaks, no synthesized `done` — the Run keeps running under the /// Worker (ADR-0012). /// -/// `Lagged` → re-snapshot (ADR-0022 §28): on buffer overflow, re-read the -/// persisted snapshot and re-emit it as a cumulative `text_delta`, then resume. -/// Lag degrades to "re-read the truth," never lost text. +/// `Lagged` → re-snapshot + re-attach under the gate (ADR-0022 §28, review F2): +/// on buffer overflow, re-read the ordered timeline AND attach a FRESH receiver +/// (`resubscribe()`, positioned at the current tail) inside the gate, so the +/// snapshot's last-committed event meets the resumed tail exactly — no event is +/// replayed from the stale buffer (which would duplicate text/reasoning), none +/// lost. The [`RunTail`] owns the receiver + gate but NO sender, so the forwarder +/// never keeps the channel open and `Closed` still fires. /// /// Terminal-event guarantee: a subscribe can attach in the window between a /// terminal event being published and `hub::remove`, with its receiver @@ -168,24 +224,19 @@ fn send_subscribe_response( /// event. fn spawn_tail_forwarder( run_id: Uuid, - mut receiver: broadcast::Receiver, + hubs: Hubs, + mut tail: RunTail, out_tx: UnboundedSender, pool: SqlitePool, ) { tokio::spawn(async move { let mut saw_terminal = false; - loop { + 'forward: loop { tokio::select! { - // Connection dropped: break WITHOUT synthesizing a `done` — - // no client to receive it (the Run keeps running, ADR-0012). - () = out_tx.closed() => { - break; - } - recv = receiver.recv() => { + () = out_tx.closed() => break, + recv = tail.recv() => { match recv { Ok(event) => { - // Track terminal events so we don't synthesize a - // `done` after a real one on channel close. if matches!( event, RunEvent::Done | RunEvent::Cancelled | RunEvent::Error { .. } @@ -195,48 +246,55 @@ fn spawn_tail_forwarder( send_run_event(&out_tx, run_id, &event); } Err(broadcast::error::RecvError::Closed) => { - // Sender dropped at the Worker's `hub::remove`. If we - // never forwarded a terminal event (attached late or - // it fell in a lagged window), synthesize a `done` so - // the stream finalizes instead of hanging. - // - // No-false-done on park (ADR-0025): a park removes - // the hub WITHOUT a terminal event, so `saw_terminal` - // is false but the Run isn't done — when persisted - // status is `parked`, push `proposal/pending` instead - // of a synthesized `done`. if !saw_terminal { - match db::run_status(&pool, run_id).await { - Ok(Some(RunStatus::Parked)) => { + // The old sender is gone. Pin generation turnover + // before deciding whether to reattach or synthesize + // the persisted terminal outcome. + let lifecycle = hub::lifecycle(&hubs, run_id).await; + if let Some(next) = hub::get(&hubs, run_id) { + let (segments, next_tail) = next + .snapshot_then_attach(|| async { + db::run_live_segments(&pool, run_id, true).await + }) + .await; + send_segment_snapshot(&out_tx, run_id, segments); + tail = next_tail; + drop(lifecycle); + continue 'forward; + } + + match db::select_run_snapshot(&pool, run_id).await { + Ok(Some(snap)) if snap.status == RunStatus::Parked => { emit_pending(&out_tx, &pool, run_id).await; } - Ok(Some(RunStatus::Cancelled)) => { - send_run_event(&out_tx, run_id, &RunEvent::Cancelled); - } - _ => { - send_run_event(&out_tx, run_id, &RunEvent::Done); + Ok(Some(snap)) => { + if let Some(event) = + terminal_event(snap.status, snap.error_message) + { + send_run_event(&out_tx, run_id, &event); + } } + Ok(None) | Err(_) => send_run_event( + &out_tx, + run_id, + &RunEvent::Error { + message: crate::worker::WORKER_DISCONNECTED_MESSAGE + .to_string(), + }, + ), } + drop(lifecycle); } break; } Err(broadcast::error::RecvError::Lagged(n)) => { - // Tolerated degradation (ADR-0038): buffer overflow - // recovers via re-snapshot, so WARN. Lagged count in - // a field, never interpolated into the message. tracing::warn!(event = "subscribe.forwarder_lagged", %run_id, n); - // Re-emit the persisted text as a cumulative - // `text_delta`; a read error is tolerated and the - // tail resumes either way. - match db::select_run_snapshot(&pool, run_id).await { - Ok(Some(snap)) => { - send_text_delta(&out_tx, run_id, &snap.text); - } - Ok(None) => {} - Err(e) => { - tracing::error!(event = "subscribe.resnapshot_read_failed", %run_id, error = ?e); - } - } + let segments = tail + .recover(|| async { + db::run_live_segments(&pool, run_id, true).await + }) + .await; + send_segment_snapshot(&out_tx, run_id, segments); } } } @@ -248,72 +306,12 @@ fn spawn_tail_forwarder( #[cfg(test)] mod tests { use crate::db::test_support::memory_pool; - use std::sync::{Arc, Mutex}; + use std::sync::Arc; use tokio::sync::mpsc; - use tracing::field::{Field, Visit}; - use tracing::Level; - use tracing_subscriber::layer::{Context, SubscriberExt}; - use tracing_subscriber::Layer; use uuid::Uuid; use super::*; - /// One captured diagnostic event: its stable `event` key, its level, and - /// the top-level `run_id` field (ADR-0038's canonical correlation field). - #[derive(Clone)] - struct CapturedEvent { - event: Option, - level: Level, - run_id: Option, - } - - /// Pulls the `event` and `run_id` field values off a `tracing` event. - /// `tracing` fields are not a map, so a `Visit` impl is the only way to read - /// specific field values. `event = "..."` records as a str; `%run_id` - /// records via its `Display` impl (debug form for everything else). - #[derive(Default)] - struct FieldGrab { - event: Option, - run_id: Option, - } - - impl Visit for FieldGrab { - fn record_str(&mut self, field: &Field, value: &str) { - match field.name() { - "event" => self.event = Some(value.to_string()), - "run_id" => self.run_id = Some(value.to_string()), - _ => {} - } - } - - fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { - // `%run_id` lands here as a `Display`-formatted value; capture it if - // `record_str` did not (subscriber backends differ). - if field.name() == "run_id" && self.run_id.is_none() { - self.run_id = Some(format!("{value:?}").trim_matches('"').to_string()); - } - } - } - - /// A minimal in-memory `tracing` Layer that appends each event's - /// `event`/level/`run_id` into a shared buffer for assertions. Hand-rolled - /// to avoid a new dev-dependency (tracing-subscriber is already a dep). - struct CaptureLayer { - events: Arc>>, - } - - impl Layer for CaptureLayer { - fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { - let mut grab = FieldGrab::default(); - event.record(&mut grab); - self.events.lock().unwrap().push(CapturedEvent { - event: grab.event, - level: *event.metadata().level(), - run_id: grab.run_id, - }); - } - } - /// Seed a Thread + Run, then commit a `running -> cancelled` transition so /// tier 2 reports `cancelled` (the state a late subscriber must read back). async fn seed_cancelled_run(pool: &SqlitePool) -> Uuid { @@ -325,6 +323,7 @@ mod tests { system_prompt: "sp".to_string(), thinking_level: Some("off".to_string()), tools: Vec::new(), + external_tools: false, }; let run_id = Uuid::now_v7(); db::persist_thread_with_first_run( @@ -351,6 +350,78 @@ mod tests { run_id } + /// Seed a Thread + Run, then stamp it `errored` with `message` (the terminal + /// fields `RunStatus::fail` leaves behind), so a late subscriber must read the + /// failure back as `Error` — never a synthesized `done`. + async fn seed_errored_run(pool: &SqlitePool, message: &str) -> Uuid { + let workflow = crate::workflow::Workflow { + name: "test".to_string(), + version: "1".to_string(), + provider: "faux".to_string(), + model: Some("m".to_string()), + system_prompt: "sp".to_string(), + thinking_level: Some("off".to_string()), + tools: Vec::new(), + external_tools: false, + }; + let run_id = Uuid::now_v7(); + db::persist_thread_with_first_run( + pool, + Uuid::now_v7(), + run_id, + Uuid::now_v7(), + Uuid::now_v7(), + &workflow, + "prompt", + &[], + "t", + 1, + ) + .await + .expect("seed run"); + sqlx::query( + "UPDATE runs SET status = 'errored', terminal_reason = 'errored', \ + error_code = 'agent_error', error_message = ?1, ended_at = 99 WHERE id = ?2", + ) + .bind(message) + .bind(run_id.to_string()) + .execute(pool) + .await + .expect("stamp errored"); + run_id + } + + /// Seed a Thread + Run left `running` (the state `run/post_message` leaves it + /// in), so a subscribe takes the live-hub snapshot path. + async fn seed_running_run(pool: &SqlitePool) -> Uuid { + let workflow = crate::workflow::Workflow { + name: "test".to_string(), + version: "1".to_string(), + provider: "faux".to_string(), + model: Some("m".to_string()), + system_prompt: "sp".to_string(), + thinking_level: Some("off".to_string()), + tools: Vec::new(), + external_tools: false, + }; + let run_id = Uuid::now_v7(); + db::persist_thread_with_first_run( + pool, + Uuid::now_v7(), + run_id, + Uuid::now_v7(), + Uuid::now_v7(), + &workflow, + "prompt", + &[], + "t", + 1, + ) + .await + .expect("seed run"); + run_id + } + /// Terminal-event guarantee (ADR-0022): a subscriber attaching after /// `Cancelled` was published — its receiver positioned past the event — /// must still terminate with exactly one `cancelled` on channel close, not @@ -366,7 +437,13 @@ mod tests { drop(event_tx); let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); - spawn_tail_forwarder(run_id, event_rx, out_tx, pool.clone()); + spawn_tail_forwarder( + run_id, + hub::new_hubs(), + RunTail::from_parts(event_rx, Arc::new(tokio::sync::Mutex::new(()))), + out_tx, + pool.clone(), + ); // Exactly one frame: a synthesized `cancelled`, then the channel closes. let body = tokio::time::timeout(std::time::Duration::from_secs(5), out_rx.recv()) @@ -395,18 +472,18 @@ mod tests { let run_id = seed_cancelled_run(&pool).await; // A live hub still registered (Worker has not reached hub::remove). let hubs = hub::new_hubs(); - let _run_hub = hub::create(&hubs, run_id); + let _run_hub = hub::register(&hubs, run_id).expect("fresh run registers"); let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); handle(&pool, &hubs, serde_json::json!(7), SubscribeParams { run_id }, &out_tx).await; - // Subscribe response, then the snapshot text_delta, then `cancelled`. + // Subscribe response, then the ordered segment snapshot, then `cancelled`. let resp: serde_json::Value = serde_json::from_str(&out_rx.recv().await.expect("subscribe response")).unwrap(); assert_eq!(resp["result"]["status"].as_str(), Some("cancelled")); let snapshot: serde_json::Value = serde_json::from_str(&out_rx.recv().await.expect("snapshot")).unwrap(); - assert_eq!(snapshot["params"]["event"]["kind"].as_str(), Some("text_delta")); + assert_eq!(snapshot["params"]["event"]["kind"].as_str(), Some("snapshot")); let terminal: serde_json::Value = serde_json::from_str(&out_rx.recv().await.expect("terminal")).unwrap(); assert_eq!( @@ -424,61 +501,290 @@ mod tests { ); } - /// Severity split (ADR-0038): a broadcast-overflow re-snapshot is a - /// *tolerated* degradation, so the forwarder logs `subscribe.forwarder_lagged` - /// at WARN (not ERROR) carrying the canonical top-level `run_id`. Overflow is - /// forced deterministically: send > capacity events into a cap-8 channel - /// BEFORE the forwarder polls, so its first `recv()` returns - /// `RecvError::Lagged` and it takes the re-snapshot arm. + /// Review #2: a late subscribe to an ERRORED Run with NO live hub must + /// terminate with `Error` (carrying the persisted message), never a + /// synthesized `done`. This is the no-hub branch the old `_ => Done` fall- + /// through mis-reported as success. #[tokio::test] - async fn forwarder_lagged_logs_warn_with_top_level_run_id() { - let captured = Arc::new(Mutex::new(Vec::::new())); - let layer = CaptureLayer { - events: captured.clone(), - }; - // Scoped to this test via a DefaultGuard — unit tests have no global - // subscriber, and the guard drops at test end so nothing leaks. - let _guard = tracing::subscriber::set_default( - tracing_subscriber::registry().with(layer), + async fn no_hub_errored_run_terminates_with_error_not_done() { + let pool = memory_pool().await; + let run_id = seed_errored_run(&pool, "provider auth failed").await; + // Empty registry → the no-hub branch (the Run's hub is long gone). + let hubs = hub::new_hubs(); + + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + handle(&pool, &hubs, serde_json::json!(9), SubscribeParams { run_id }, &out_tx).await; + + let resp: serde_json::Value = + serde_json::from_str(&out_rx.recv().await.expect("subscribe response")).unwrap(); + assert_eq!(resp["result"]["status"].as_str(), Some("errored")); + let snapshot: serde_json::Value = + serde_json::from_str(&out_rx.recv().await.expect("snapshot")).unwrap(); + assert_eq!(snapshot["params"]["event"]["kind"].as_str(), Some("snapshot")); + let terminal: serde_json::Value = + serde_json::from_str(&out_rx.recv().await.expect("terminal")).unwrap(); + assert_eq!( + terminal["params"]["event"]["kind"].as_str(), + Some("error"), + "an errored Run re-attaches as error, never a synthesized done" + ); + assert_eq!( + terminal["params"]["event"]["message"].as_str(), + Some("provider auth failed"), + "the persisted error_message rides the re-attach" ); + drop(out_tx); + assert!(out_rx.recv().await.is_none(), "exactly three frames, then close"); + } + + /// Barrier (review P1 #2): the live-hub subscribe snapshot is ONE ordered + /// `snapshot` event — the full `run_steps` timeline (text / reasoning / + /// tool_call in order), INCLUDING a call that settled after the client's + /// `thread/get` (excluded there as pending). The Client atomically replaces + /// its segments, so the settled call is delivered in true order, never lost. + #[tokio::test] + async fn live_subscribe_snapshot_carries_the_ordered_settled_call() { + let pool = memory_pool().await; + let run_id = seed_running_run(&pool).await; + // The window: the call was pending at the client's thread/get (excluded + // there), then settled before this subscribe reads its snapshot. + assert!( + db::begin_external_tool_call( + &pool, + run_id, + "tc-win", + "ticktick_filter_tasks", + r#"{"filter":{"status":[0]}}"#, + db::now_ms(), + ) + .await + .expect("begin") + .won() + ); + assert!(matches!( + db::finish_external_tool_call( + &pool, + run_id, + "tc-win", + "completed", + r#"{"content":[{"type":"text","text":"3 tasks"}],"is_error":false}"#, + db::now_ms(), + ) + .await + .expect("finish"), + db::ExternalToolFinish::Resolved(_) + )); + + // A live hub (the run is still streaming) → the snapshot-then-attach path. + let hubs = hub::new_hubs(); + let _run_hub = hub::register(&hubs, run_id).expect("fresh run registers"); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + handle( + &pool, + &hubs, + serde_json::json!(3), + SubscribeParams { run_id }, + &out_tx, + ) + .await; + + // Collect the snapshot frames (the forwarder then blocks on an empty tail). + let mut frames = Vec::new(); + while let Ok(Some(body)) = + tokio::time::timeout(std::time::Duration::from_millis(250), out_rx.recv()).await + { + frames.push(serde_json::from_str::(&body).expect("json frame")); + } + + let snapshot = frames + .iter() + .find(|f| f["params"]["event"]["kind"].as_str() == Some("snapshot")) + .expect("a single ordered snapshot event is emitted"); + let segments = snapshot["params"]["event"]["segments"] + .as_array() + .expect("the snapshot carries an ordered segments array"); + let tool_call = segments + .iter() + .find(|s| s["kind"].as_str() == Some("tool_call")) + .expect("the settled tool call is in the ordered snapshot, not lost"); + assert_eq!(tool_call["tool_call_id"].as_str(), Some("tc-win")); + assert_eq!(tool_call["status"].as_str(), Some("completed")); + assert_eq!( + tool_call["result"]["content"][0]["text"].as_str(), + Some("3 tasks"), + "the settled call carries the model-received result (A4)" + ); + } + + /// No-duplication on lag recovery (review F2): after the re-snapshot the + /// forwarder `resubscribe()`s, so the stale retained buffer is DISCARDED — + /// the resumed tail carries only events published strictly AFTER recovery, + /// never a replay of buffered deltas the snapshot already covered. Forces + /// Lagged with `BUFFERED` deltas, then proves the FIRST post-snapshot tail + /// frame is a later `SENTINEL`, not a re-delivered `BUFFERED`. + #[tokio::test] + async fn forwarder_lag_resubscribes_without_replaying_the_buffer() { let pool = memory_pool().await; - // Seed a run so the Lagged arm's re-snapshot read has a valid run_id. let run_id = seed_cancelled_run(&pool).await; - // Overflow a cap-8 channel before the forwarder drains: 9 buffered - // events on a capacity-8 broadcast guarantees the receiver is past - // capacity, so its next `recv()` yields `Lagged`. + // Overflow a cap-8 channel BEFORE the forwarder drains, so its first + // `recv()` yields `Lagged` and it takes the re-snapshot + resubscribe arm. let (event_tx, event_rx) = broadcast::channel::(8); for _ in 0..9 { event_tx - .send(RunEvent::TextDelta { delta: "x".to_string() }) + .send(RunEvent::TextDelta { + delta: "BUFFERED".to_string(), + }) .expect("buffer a tail event"); } let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); - spawn_tail_forwarder(run_id, event_rx, out_tx, pool.clone()); + spawn_tail_forwarder( + run_id, + hub::new_hubs(), + RunTail::from_parts(event_rx, Arc::new(tokio::sync::Mutex::new(()))), + out_tx, + pool.clone(), + ); + + // Frame 1 is the re-snapshot (recovery). Draining it proves the Lagged + // arm ran AND the forwarder resubscribed (send happens after resubscribe). + let snapshot = tokio::time::timeout(std::time::Duration::from_secs(5), out_rx.recv()) + .await + .expect("re-snapshot frame within timeout") + .expect("re-snapshot frame present"); + assert!( + !snapshot.contains("BUFFERED"), + "the re-snapshot is the persisted timeline, never the broadcast buffer — frame: {snapshot}" + ); + + // A NEW event after recovery: the fresh receiver (at the tail) delivers + // THIS, never one of the 9 discarded `BUFFERED` deltas. + event_tx + .send(RunEvent::TextDelta { + delta: "SENTINEL".to_string(), + }) + .expect("publish a post-recovery event"); + let next = tokio::time::timeout(std::time::Duration::from_secs(5), out_rx.recv()) + .await + .expect("post-recovery tail frame within timeout") + .expect("post-recovery tail frame present"); + assert!( + next.contains("SENTINEL") && !next.contains("BUFFERED"), + "the resumed tail delivers only post-recovery events, never a replayed buffer — frame: {next}" + ); + } + + /// Cross-generation re-attach (review R10 #2): an old tail whose channel + /// closes NON-terminally (park→resume / errored→retry drained the old + /// generation) while the run is RUNNING under a NEW generation must re-attach + /// to it — fresh ordered snapshot, then the new tail — never misreport the + /// live resumed run as worker-disconnected. + #[tokio::test] + async fn forwarder_reattaches_to_a_new_generation_on_nonterminal_close() { + let pool = memory_pool().await; + let run_id = seed_running_run(&pool).await; + + // The OLD generation's channel, with the forwarder tailing it. + let (old_tx, old_rx) = broadcast::channel::(8); + let hubs = hub::new_hubs(); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + spawn_tail_forwarder( + run_id, + hubs.clone(), + RunTail::from_parts(old_rx, Arc::new(tokio::sync::Mutex::new(()))), + out_tx, + pool.clone(), + ); + + // A NEW generation activates (registered hub), then the old generation's + // channel closes without a terminal event — the park/retry drain shape. + let next = hub::register(&hubs, run_id).expect("new generation registers"); + drop(old_tx); - // Pump the forwarder: the Lagged arm re-emits the persisted snapshot as - // a `text_delta`, so awaiting one out frame proves it processed Lagged. - let _ = tokio::time::timeout(std::time::Duration::from_secs(5), out_rx.recv()) + // Frame 1: the re-attach snapshot (proves the forwarder attached to the + // new generation instead of synthesizing worker-disconnected). + let snapshot = tokio::time::timeout(std::time::Duration::from_secs(5), out_rx.recv()) .await - .expect("forwarder emits a re-snapshot frame within timeout"); + .expect("re-attach snapshot within timeout") + .expect("frame present"); + assert!( + snapshot.contains("\"snapshot\""), + "the old tail re-attached with an ordered snapshot — frame: {snapshot}" + ); - let events = captured.lock().unwrap(); - let lagged = events - .iter() - .find(|e| e.event.as_deref() == Some("subscribe.forwarder_lagged")) - .expect("subscribe.forwarder_lagged was emitted on broadcast overflow"); + // The new generation's events now flow through the SAME subscriber. + next.send(RunEvent::TextDelta { + delta: "resumed".to_string(), + }); + let tail_frame = tokio::time::timeout(std::time::Duration::from_secs(5), out_rx.recv()) + .await + .expect("new-generation tail frame within timeout") + .expect("frame present"); + assert!( + tail_frame.contains("resumed"), + "the new generation's tail reaches the old subscriber — frame: {tail_frame}" + ); + } + + /// The boot-recovery zombie stays bounded (review R11 #2): `running` with NO + /// live generation — confirmed by the one re-read the new close-loop takes — + /// still closes with worker-disconnected (never hangs, never loops). + #[tokio::test] + async fn forwarder_close_on_running_zombie_reports_disconnect_bounded() { + let pool = memory_pool().await; + let run_id = seed_running_run(&pool).await; + + let (old_tx, old_rx) = broadcast::channel::(8); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + spawn_tail_forwarder( + run_id, + hub::new_hubs(), + RunTail::from_parts(old_rx, Arc::new(tokio::sync::Mutex::new(()))), + out_tx, + pool.clone(), + ); + drop(old_tx); + + let frame = tokio::time::timeout(std::time::Duration::from_secs(5), out_rx.recv()) + .await + .expect("the zombie close settles within the bound") + .expect("frame present"); + let v: serde_json::Value = serde_json::from_str(&frame).expect("json"); + assert_eq!(v["params"]["event"]["kind"].as_str(), Some("error")); assert_eq!( - lagged.level, - Level::WARN, - "forwarder lag is a tolerated degradation — WARN, not ERROR" + v["params"]["event"]["message"].as_str(), + Some(crate::worker::WORKER_DISCONNECTED_MESSAGE), + "a genuine no-generation running run is a lost Worker" ); + } + + /// The no-hub subscribe branch re-checks a RUNNING status (review R11 #2): + /// with no generation appearing across the confirming re-read, the boot + /// zombie closes with Error — the pre-existing contract, now via the + /// bounded recheck path. + #[tokio::test] + async fn no_hub_running_zombie_subscribe_closes_with_error() { + let pool = memory_pool().await; + let run_id = seed_running_run(&pool).await; + let hubs = hub::new_hubs(); + + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + handle(&pool, &hubs, serde_json::json!(11), SubscribeParams { run_id }, &out_tx).await; + + let resp: serde_json::Value = + serde_json::from_str(&out_rx.recv().await.expect("subscribe response")).unwrap(); + assert_eq!(resp["result"]["status"].as_str(), Some("running")); + let snapshot: serde_json::Value = + serde_json::from_str(&out_rx.recv().await.expect("snapshot")).unwrap(); + assert_eq!(snapshot["params"]["event"]["kind"].as_str(), Some("snapshot")); + let terminal: serde_json::Value = + serde_json::from_str(&out_rx.recv().await.expect("terminal")).unwrap(); + assert_eq!(terminal["params"]["event"]["kind"].as_str(), Some("error")); assert_eq!( - lagged.run_id.as_deref(), - Some(run_id.to_string().as_str()), - "the lag event carries the canonical top-level run_id" + terminal["params"]["event"]["message"].as_str(), + Some(crate::worker::WORKER_DISCONNECTED_MESSAGE), ); } -} +} \ No newline at end of file diff --git a/crates/core/src/runs/thread_get.rs b/crates/core/src/runs/thread_get.rs index cabe0b1e..a1759c25 100644 --- a/crates/core/src/runs/thread_get.rs +++ b/crates/core/src/runs/thread_get.rs @@ -10,7 +10,7 @@ use sqlx::SqlitePool; use tokio::sync::mpsc::UnboundedSender; use super::handler::{self, HandlerError}; -use crate::db::{self, MessageSegment}; +use crate::db; use crate::protocol::{MessageView, Segment, ThreadGetParams, ThreadGetResult}; pub(super) async fn handle( @@ -35,42 +35,10 @@ pub(super) async fn handle( run_id: row.run_id, terminal_reason: row.terminal_reason, // Map each db-side timeline item to its wire `Segment` variant, - // preserving order (ADR-0045). The variants are 1:1. - segments: row - .segments - .into_iter() - .map(|segment| match segment { - MessageSegment::Text { text } => Segment::Text { text }, - MessageSegment::ToolCall { name, status, arg } => { - Segment::ToolCall { name, status, arg } - } - MessageSegment::Proposal { - proposal_id, - mutation_kind, - status, - entity_id, - } => Segment::Proposal { - proposal_id, - mutation_kind, - status, - entity_id, - }, - MessageSegment::Reasoning { text, duration_ms } => { - Segment::Reasoning { text, duration_ms } - } - MessageSegment::Attachment { - media_id, - mime, - width, - height, - } => Segment::Attachment { - media_id, - mime, - width, - height, - }, - }) - .collect(), + // preserving order (ADR-0045). The `From` impl (db::threads) is + // shared with the `run/subscribe` snapshot so both assemble + // identically. + segments: row.segments.into_iter().map(Segment::from).collect(), }) .collect(); diff --git a/crates/core/src/runs/ticktick.rs b/crates/core/src/runs/ticktick.rs new file mode 100644 index 00000000..966135a5 --- /dev/null +++ b/crates/core/src/runs/ticktick.rs @@ -0,0 +1,50 @@ +//! `ticktick/status` + `ticktick/tasks/list` handlers (external-task-views A2). +//! Both read the boot-read connection (`crate::ticktick::connection`) — Core +//! holds no task state, so `tasks/list` fetches + normalizes per call. + +use tokio::sync::mpsc::UnboundedSender; + +use super::handler::{self, HandlerError}; +use crate::protocol::TickTickStatusResult; + +/// `ticktick/status` (A2/A5): the connection state + opaque boot-scoped +/// connection ID the Web keys its task query on. `connected` iff a credential +/// loaded at boot; the ID is present only then. Params are ignored. +pub(super) async fn handle_status( + id: serde_json::Value, + params: serde_json::Value, + out_tx: &UnboundedSender, +) { + handler::handle(id, params, out_tx, |_p: serde_json::Value| async move { + Ok::<_, HandlerError>(match crate::ticktick::connection() { + Some(conn) => TickTickStatusResult::Connected { + connection_id: conn.connection_id.clone(), + }, + None => TickTickStatusResult::NotConnected, + }) + }) + .await; +} + +/// `ticktick/tasks/list` (A2): the two-read OpenAPI fetch + normalization, +/// returning `{tasks, source_limit_reached}`. Not connected → `-32004` +/// (mirrors the run-creation provider gate) so the Web shows the disconnected +/// state; a transport/HTTP/decode failure (incl. a 401 from an expired +/// credential — A5) rides `Internal` so the query lands in its error state. +pub(super) async fn handle_tasks_list( + id: serde_json::Value, + params: serde_json::Value, + out_tx: &UnboundedSender, +) { + handler::handle(id, params, out_tx, |_p: serde_json::Value| async move { + let Some(conn) = crate::ticktick::connection() else { + return Err(HandlerError::ProviderNotConnected { + provider: "ticktick".to_string(), + }); + }; + crate::ticktick::client::fetch_tasks(&conn.access_token) + .await + .map_err(HandlerError::Internal) + }) + .await; +} diff --git a/crates/core/src/shutdown.rs b/crates/core/src/shutdown.rs new file mode 100644 index 00000000..493b9329 --- /dev/null +++ b/crates/core/src/shutdown.rs @@ -0,0 +1,41 @@ +//! Process-level degraded shutdown, owned by Core's server task. Worker tasks +//! request shutdown here; `main` stops the listener and each WebSocket observes +//! the same sticky signal before the process exits nonzero. + +use std::sync::LazyLock; + +use tokio::sync::watch; + +pub(crate) type Receiver = watch::Receiver; + +static REQUEST: LazyLock> = LazyLock::new(|| watch::channel(false).0); + +pub(crate) fn subscribe() -> Receiver { + REQUEST.subscribe() +} + +pub(crate) fn request() { + let _ = REQUEST.send_replace(true); +} + +pub(crate) async fn wait(mut receiver: Receiver) { + let _ = receiver.wait_for(|requested| *requested).await; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn request_is_sticky_for_late_observers() { + let receiver = subscribe(); + request(); + + wait(receiver.clone()).await; + + assert!( + *receiver.borrow(), + "a WebSocket created after the request must close immediately" + ); + } +} diff --git a/crates/core/src/start_run.rs b/crates/core/src/start_run.rs index b4bae405..bbbb9b79 100644 --- a/crates/core/src/start_run.rs +++ b/crates/core/src/start_run.rs @@ -225,8 +225,11 @@ pub enum StartRunError { ProviderNotConnected(String), /// `RetryCas` only: the guarded `errored → running` CAS lost (the Run raced /// out of `errored` since the shell's advisory read). Nothing was cleared, - /// no hub was created, nothing spawned — the caller reports its own - /// vocabulary (retry: `not_errored`). + /// no hub is left registered, nothing spawned — the caller reports its own + /// vocabulary (retry: `not_errored`). Also returned when the activation slot + /// is owned by a LIVE producer (activation waits out a mid-drain slot first + /// — review R9 #1): a live producer means the Run's status cannot be this + /// attempt's from-state, so its CAS was going to lose anyway. PersistRaceLost, /// A DB or credential-store fault. Internal(anyhow::Error), @@ -280,17 +283,26 @@ where // errored Run. Fail loud so the Client can prompt "connect it". ensure_provider_connected(&workflow.provider)?; - // 3. Persist: the variant's own transactional shape, against the resolved - // Workflow. A RetryCas that loses its guarded flip returns - // PersistRaceLost here — before the hub exists and before any spawn, so - // the hub-only-after-a-committed-persist invariant holds by position. - persist_step - .execute(pool, thread_id, &workflow, &prompt) - .await?; - - // 4. Hub BEFORE spawn (ADR-0022): a subscribe arriving right after the - // response can't find a missing hub. - let run_hub = hub::create(hubs, run_id); + // 3+4. Activate: register the hub, THEN run the variant's transactional + // persist — the shared registry operation (review R8 #1, same as resume). + // Hub-before-CAS closes the retry race: `RetryCas` flips errored→running, + // and a `run/cancel` that reads `running` must find the producer's hub to + // signal it — flipping first left an unsignallable window. First-wins + // registration also backs a concurrent retry off (`None` → the CAS it + // would have run was going to lose anyway → PersistRaceLost), and a + // finishing old Worker never shadows it: a drain is one gated section, so + // activation waits it out and then runs the CAS (review R9 #1). A failed + // or lost persist deregisters — no producerless hub leaks. The hub also + // still precedes the spawn (ADR-0022): a subscribe arriving right after + // the response can't find a missing hub. + let run_hub = hub::activate(hubs, run_id, || async { + persist_step + .execute(pool, thread_id, &workflow, &prompt) + .await + .map(|()| true) + }) + .await? + .ok_or(StartRunError::PersistRaceLost)?; // 5. History BEFORE spawn (ADR-0018): prior-Run conversation history, // excluding the Run just persisted. A read failure is non-fatal: fall @@ -574,9 +586,9 @@ mod tests { } // 5. PersistStep::RetryCas on a RUNNING (not errored) Run: the guarded - // errored→running CAS loses → Err(PersistRaceLost), NO hub, NO spawn - // (panic-in-closure pins "never called"), and the Run is untouched — - // the hub-only-after-a-committed-persist invariant. + // errored→running CAS loses → Err(PersistRaceLost), NO hub left behind + // (activate deregisters its own registration on the lost CAS), NO spawn + // (panic-in-closure pins "never called"), and the Run is untouched. #[tokio::test] async fn retry_cas_lost_race_returns_persist_race_lost_no_hub_no_spawn() { let _cred = credentials_dir(true); @@ -692,8 +704,9 @@ mod tests { // gate satisfied, but PersistStep::FreshRun targets a thread_id with no // threads row, so the insert violates the (immediate) runs.thread_id FK // → Err(Internal). AFTER the failure: zero runs rows, NO hub, NO spawn - // (panic-in-closure). This kills the hub-before-persist mutation — were - // hub::create hoisted above the persist, the hub would leak here. + // (panic-in-closure). Pins activate's failure contract: the hub is + // registered BEFORE the persist, so a failed persist MUST deregister it + // — a leak here would leave a producerless hub. #[tokio::test] async fn persist_failure_leaves_no_hub() { let _cred = credentials_dir(true); diff --git a/crates/core/src/ticktick/client.rs b/crates/core/src/ticktick/client.rs new file mode 100644 index 00000000..7ca31fd7 --- /dev/null +++ b/crates/core/src/ticktick/client.rs @@ -0,0 +1,175 @@ +//! The concrete TickTick OpenAPI client (external-task-views A2): two reads +//! (`GET /open/v1/project`, `POST /open/v1/task/filter {"status":[0]}`) against +//! a compile-time-const base URL (test-only override), decoded into the private +//! `wire` transport types and normalized into `TickTickTaskRow`s. No provider +//! interface, no Core task cache — one read per call. + +use std::sync::OnceLock; + +use crate::protocol::TickTickTasksListResult; + +use super::wire::{self, RawProject, RawTask}; + +/// TickTick's OpenAPI base. The boot-read bearer token is sent here, so the +/// `INKSTONE_TICKTICK_API_URL` override — which points the fake-HTTP-server +/// harness at a local server — is honored ONLY for a loopback host: a +/// stray/hostile env var can never redirect the credential to an arbitrary +/// origin. Non-loopback → log + fall back to the const. +const OPENAPI_BASE: &str = "https://api.ticktick.com"; + +/// The process-wide HTTP client, built once so its connection pool is reused +/// across calls. The base URL AND the A7 timeout are applied per-request (never +/// baked into the client), so the boot-resolved config knob and the loopback +/// test override both take effect (review R12 #6). `build` fails only on a +/// system-TLS init fault — a boot-level invariant, not a per-call error. +fn http_client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| reqwest::Client::new()) +} + +fn base_url() -> String { + super::guarded_override( + crate::config::get().ticktick_api_url_override.clone(), + OPENAPI_BASE, + "ticktick.api_url_override_rejected", + ) +} + +/// Fetch + normalize the open-task view for the Web lane (A2). Runs the two +/// reads against `token`, decodes into private transport types, and returns the +/// `{tasks, source_limit_reached}` envelope. Any transport/HTTP/decode failure +/// (including a 401 from an expired credential — A5: no re-read, restart to +/// change) rides `anyhow::Error`; the verb maps it to the read's error state. +pub async fn fetch_tasks(token: &str) -> anyhow::Result { + let base = base_url(); + let client = http_client(); + // A7's per-request bound, boot-resolved (`INKSTONE_TICKTICK_TIMEOUT_MS`, + // default 30s — review R12 #6); applied per request so tests can exercise + // the stalled path with a tiny bound. + let timeout = crate::config::get().ticktick_timeout; + + // The two reads are independent (list of projects + open-task page), so + // run them concurrently — one round-trip's latency, not two. + let projects_req = client + .get(format!("{base}/open/v1/project")) + .timeout(timeout) + .bearer_auth(token) + .send(); + let tasks_req = client + .post(format!("{base}/open/v1/task/filter")) + .timeout(timeout) + .bearer_auth(token) + .json(&serde_json::json!({ "status": [0] })) + .send(); + let (projects_resp, tasks_resp) = tokio::try_join!(projects_req, tasks_req)?; + + let projects: Vec = projects_resp.error_for_status()?.json().await?; + let raw_tasks: Vec = tasks_resp.error_for_status()?.json().await?; + + let (tasks, source_limit_reached) = wire::normalize(&projects, &raw_tasks); + Ok(TickTickTasksListResult { + tasks, + source_limit_reached, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// LIVE OpenAPI contract smoke (review R10 #3): drives the PRODUCTION decode + /// path — [`fetch_tasks`] is the real reqwest → `wire.rs` serde decode → + /// `normalize` pipeline — against the live service, so upstream drift that + /// would break production decoding fails HERE, not in a hand-mirrored + /// validator. `#[ignore]`d: the scheduled `ticktick-live-smoke` workflow + /// opts in with `--ignored`; the PR gate never runs it. NON-CAPTURING: + /// prints counts only. The smoke account must stage representative rows — + /// ≥1 all-day, ≥1 timed (with zone), ≥1 tagged, ≥1 checklist, ≥1 repeating + /// — or the assertions fail: wire.rs defaults absent fields, so an account + /// without them can't detect an upstream field removal (review R11 #3). + #[tokio::test] + #[ignore = "live network + credential; run by .github/workflows/ticktick-live-smoke.yml"] + async fn live_openapi_contract_smoke() { + let token = std::env::var("TICKTICK_ACCESS_TOKEN") + .expect("TICKTICK_ACCESS_TOKEN must be set (full-scope repo secret)"); + let result = fetch_tasks(&token) + .await + .expect("both live reads decode through the production wire types"); + + let tasks = &result.tasks; + assert!( + !tasks.is_empty(), + "smoke account must keep ≥1 open task so decode drift is detectable" + ); + let with_due = tasks.iter().filter(|t| t.due.is_some()).count(); + let all_day = tasks + .iter() + .filter(|t| t.due.as_ref().is_some_and(|d| d.is_all_day)) + .count(); + let with_tags = tasks.iter().filter(|t| !t.tags.is_empty()).count(); + let with_checklist = tasks + .iter() + .filter(|t| !t.checklist_items.is_empty()) + .count(); + let with_repeat = tasks.iter().filter(|t| t.repeat_flag.is_some()).count(); + let list_resolved = tasks.iter().filter(|t| t.list_name.is_some()).count(); + println!( + "live smoke: tasks={} with_due={all_day}/{with_due} (all_day/total) \ + with_tags={with_tags} with_checklist={with_checklist} \ + with_repeat={with_repeat} list_resolved={list_resolved} \ + at_page_cap={}", + tasks.len(), + result.source_limit_reached, + ); + // Representative-shape counters (review R10 #3 / R11 #3): EVERY optional + // decode branch must be exercised by live rows — wire.rs defaults absent + // fields, so an upstream field REMOVAL stays green unless a staged row + // asserts the branch. + assert!(with_due >= 1, "stage ≥1 due-bearing task in the smoke account"); + assert!(all_day >= 1, "stage ≥1 ALL-DAY task in the smoke account"); + let timed_with_zone = tasks + .iter() + .filter(|t| { + t.due + .as_ref() + .is_some_and(|d| !d.is_all_day && !d.time_zone.is_empty()) + }) + .count(); + assert!( + timed_with_zone >= 1, + "stage ≥1 TIMED task; its due tuple must carry a non-empty time_zone \ + (upstream dropping timeZone would default to \"\" and stay green otherwise)" + ); + assert!(with_tags >= 1, "stage ≥1 tagged task in the smoke account"); + assert!( + with_checklist >= 1, + "stage ≥1 checklist task in the smoke account" + ); + assert!( + with_repeat >= 1, + "stage ≥1 repeating task in the smoke account" + ); + assert!( + list_resolved >= 1, + "≥1 task resolves a /project list name (projects read + join)" + ); + } + + #[test] + fn base_url_override_is_loopback_only() { + // A loopback override (the fake HTTP server) is honored; a non-loopback + // one falls back to the const, so the bearer token never leaves for it. + for (url, expected) in [ + ("http://127.0.0.1:8123", "http://127.0.0.1:8123"), + ("http://localhost:9", "http://localhost:9"), + ("https://evil.example.com", OPENAPI_BASE), + ("http://169.254.169.254", OPENAPI_BASE), + ] { + let _guard = crate::config::test_override::install(crate::config::Config { + ticktick_api_url_override: Some(url.to_string()), + ..Default::default() + }); + assert_eq!(base_url(), expected, "override {url}"); + } + } +} diff --git a/crates/core/src/ticktick/mod.rs b/crates/core/src/ticktick/mod.rs new file mode 100644 index 00000000..0620d084 --- /dev/null +++ b/crates/core/src/ticktick/mod.rs @@ -0,0 +1,102 @@ +//! TickTick connection state (external-task-views A5/A7): Core owns the +//! TickTick credential for both read lanes (Web → OpenAPI in S2; Worker → MCP +//! via the spawn manifest). The endpoint is a compile-time constant with a +//! test-only override; there is no task-source config file. + +pub mod client; +pub(crate) mod token; +mod wire; + +pub use token::{connection, init}; + +/// TickTick's official MCP service (streamable HTTP). +const MCP_ENDPOINT: &str = "https://mcp.ticktick.com/"; + +/// The MCP endpoint Core ships in the spawn manifest — with the bearer token +/// beside it, sent as `Authorization: Bearer` to this URL by the Worker. The +/// `INKSTONE_TICKTICK_MCP_URL` override exists ONLY to point the fake-MCP +/// fixture at a local server, so it is honored ONLY for a loopback host: a +/// stray/hostile env var can never redirect the real credential to an +/// arbitrary origin. A non-loopback override is logged and ignored (falls back +/// to the const), making the "test-only" contract code shape, not prose. +pub fn mcp_endpoint() -> String { + guarded_override( + crate::config::get().ticktick_mcp_url_override.clone(), + MCP_ENDPOINT, + "ticktick.mcp_url_override_rejected", + ) +} + +/// Resolve a token-bearing endpoint override to a concrete URL (A5/A7): an +/// override is honored ONLY for a loopback host, so a stray/hostile env var can +/// never redirect the real credential to an arbitrary origin. A non-loopback +/// override is logged under `event` and ignored — `fallback` stands. The ONE +/// place the loopback guard lives; both lanes (MCP here, OpenAPI base in +/// `client`) route through it. +pub(super) fn guarded_override( + override_url: Option, + fallback: &str, + event: &'static str, +) -> String { + let Some(override_url) = override_url else { + return fallback.to_string(); + }; + if is_loopback_url(&override_url) { + return override_url; + } + tracing::warn!(event = event, reason = "non-loopback host"); + fallback.to_string() +} + +/// Whether `url` targets a loopback host (127.0.0.0/8, ::1, or `localhost`) — +/// the only hosts a token may be sent to via a test-only override. Shared by +/// both lane overrides (MCP endpoint here, OpenAPI base in `client`). +pub(super) fn is_loopback_url(url: &str) -> bool { + let Ok(parsed) = url::Url::parse(url) else { + return false; + }; + match parsed.host() { + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + Some(url::Host::Domain(host)) => host == "localhost", + None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mcp_endpoint_override_is_loopback_only() { + // Loopback overrides (the fake-MCP fixture) are honored. + for url in [ + "http://127.0.0.1:9/mcp", + "http://localhost:52100/mcp", + "http://[::1]:9/mcp", + ] { + let _guard = crate::config::test_override::install(crate::config::Config { + ticktick_mcp_url_override: Some(url.to_string()), + ..Default::default() + }); + assert_eq!(mcp_endpoint(), url, "loopback override {url} is honored"); + } + + // A non-loopback override is ignored — the token never leaves for it. + for url in [ + "https://evil.example.com/mcp", + "http://169.254.169.254/mcp", + "not a url", + ] { + let _guard = crate::config::test_override::install(crate::config::Config { + ticktick_mcp_url_override: Some(url.to_string()), + ..Default::default() + }); + assert_eq!( + mcp_endpoint(), + MCP_ENDPOINT, + "non-loopback override {url} falls back to the const" + ); + } + } +} diff --git a/crates/core/src/ticktick/token.rs b/crates/core/src/ticktick/token.rs new file mode 100644 index 00000000..64f57d1f --- /dev/null +++ b/crates/core/src/ticktick/token.rs @@ -0,0 +1,278 @@ +//! Boot-read TickTick credential (external-task-views A5). The token file is +//! manually provisioned (0600, `/ticktick.json`) and read +//! EXACTLY ONCE at boot — never re-read, so a swapped file cannot change +//! accounts under a live connection ID; credential changes require a Core +//! restart. + +use std::fs::File; +use std::io::{self, Read}; +use std::path::Path; +use std::sync::OnceLock; + +use serde::Deserialize; + +/// The provider id / on-disk filename stem, mirroring +/// [`crate::credentials::OPENAI_CODEX`]. +const TICKTICK: &str = "ticktick"; + +/// The token file's shape. Only `access_token` is consumed; scope/lifetime +/// metadata stays on disk (the A5 expiry-proximity hint is the named S5 +/// candidate, not built). +#[derive(Deserialize)] +struct TokenFile { + access_token: String, +} + +/// The boot-read TickTick connection: one boot, one credential, one ID. +pub struct Connection { + /// The `tasks:read tasks:write` bearer token spanning both lanes (S1: one + /// token authorizes OpenAPI and MCP; MCP rejects read-only scope). + pub access_token: String, + /// Opaque, boot-scoped connection identity (A5): random per boot, never + /// token-derived — nothing about the secret leaks into query keys, and no + /// cross-boot equality is implied. Served by `ticktick/status`; the Web + /// uses it as the SOLE task-query key, so a query key can never span two + /// accounts, and a restart mints a new ID that the A2 reconnect protocol + /// uses to clear stale task data. + pub connection_id: String, +} + +static CONNECTION: OnceLock> = OnceLock::new(); + +/// Read the credential once at Core boot. Missing file = not connected; a +/// present-but-unreadable file is logged and treated as not connected (Core +/// must still boot so the Web can render the disconnected state). +pub fn init() { + let _ = CONNECTION.set(load()); +} + +/// The boot-read connection, or `None` when no credential loaded (including +/// before `init` in unit tests — a test installs one via [`test_override`]). +pub fn connection() -> Option<&'static Connection> { + #[cfg(test)] + if let Some(over) = test_override::current() { + return over; + } + CONNECTION.get().and_then(Option::as_ref) +} + +enum CredentialOpenError { + Io(io::Error), + Custody(&'static str), + Mode(u32), +} + +#[cfg(unix)] +fn open_credential(path: &Path) -> Result { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(|error| { + if error.raw_os_error() == Some(libc::ELOOP) { + CredentialOpenError::Custody("symlink") + } else { + CredentialOpenError::Io(error) + } + })?; + let metadata = file.metadata().map_err(CredentialOpenError::Io)?; + if !metadata.is_file() { + return Err(CredentialOpenError::Custody("not a regular file")); + } + let mode = metadata.permissions().mode() & 0o777; + if mode & 0o077 != 0 { + return Err(CredentialOpenError::Mode(mode)); + } + Ok(file) +} + +#[cfg(not(unix))] +fn open_credential(path: &Path) -> Result { + // There is no portable O_NOFOLLOW equivalent. Preserve the pre-open link + // rejection on non-Unix platforms, then validate the opened descriptor too. + let metadata = std::fs::symlink_metadata(path).map_err(CredentialOpenError::Io)?; + if !metadata.is_file() { + return Err(CredentialOpenError::Custody("not a regular file")); + } + let file = File::open(path).map_err(CredentialOpenError::Io)?; + if !file.metadata().map_err(CredentialOpenError::Io)?.is_file() { + return Err(CredentialOpenError::Custody("not a regular file")); + } + Ok(file) +} + +fn load() -> Option { + let path = match crate::credentials::credential_path(TICKTICK) { + Ok(path) => path, + Err(e) => { + tracing::warn!(event = "ticktick.credential_path_failed", error = ?e); + return None; + } + }; + let mut file = match open_credential(&path) { + Err(CredentialOpenError::Io(error)) if error.kind() == io::ErrorKind::NotFound => { + return None; + } + Err(CredentialOpenError::Io(error)) => { + tracing::warn!(event = "ticktick.credential_unreadable", error = ?error); + return None; + } + Err(CredentialOpenError::Custody(reason)) => { + tracing::warn!(event = "ticktick.credential_custody_rejected", reason); + return None; + } + Err(CredentialOpenError::Mode(mode)) => { + tracing::warn!( + event = "ticktick.credential_custody_rejected", + reason = "group/world-accessible mode", + mode = format!("{mode:o}") + ); + return None; + } + Ok(file) => file, + }; + let mut body = String::new(); + if let Err(error) = file.read_to_string(&mut body) { + tracing::warn!(event = "ticktick.credential_unreadable", error = ?error); + return None; + } + match serde_json::from_str::(&body) { + Ok(token) if token.access_token.trim().is_empty() => { + tracing::warn!( + event = "ticktick.credential_custody_rejected", + reason = "empty access_token" + ); + None + } + Ok(token) => Some(Connection { + access_token: token.access_token, + connection_id: uuid::Uuid::now_v7().to_string(), + }), + Err(e) => { + tracing::warn!(event = "ticktick.credential_unparsable", error = ?e); + None + } + } +} + +/// Thread-local test override, mirroring [`crate::config::test_override`]: a +/// unit test installs a leaked `Connection` (or an explicit disconnected +/// `None`) for its own thread only, restored on guard drop. +#[cfg(test)] +pub(crate) mod test_override { + use std::cell::Cell; + + use super::Connection; + + thread_local! { + static OVERRIDE: Cell>> = const { Cell::new(None) }; + } + + pub(crate) fn current() -> Option> { + OVERRIDE.with(|o| o.get()) + } + + #[must_use = "the override is removed when the guard drops"] + pub(crate) struct ConnectionGuard { + prev: Option>, + } + + impl Drop for ConnectionGuard { + fn drop(&mut self) { + let prev = self.prev; + OVERRIDE.with(|o| o.set(prev)); + } + } + + /// A test [`Connection`] with a fixed id (helper for the verb tests). + pub(crate) fn test_connection(token: &str, id: &str) -> Connection { + Connection { + access_token: token.to_string(), + connection_id: id.to_string(), + } + } + + /// Install `connection` for this thread. `Some(conn)` leaks the boxed + /// Connection (test-scoped, negligible); `None` pins a disconnected state. + pub(crate) fn install(connection: Option) -> ConnectionGuard { + let leaked: Option<&'static Connection> = + connection.map(|conn| &*Box::leak(Box::new(conn))); + let prev = OVERRIDE.with(|o| o.replace(Some(leaked))); + ConnectionGuard { prev } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A missing file is a clean `None`; an unparseable file degrades to `None` + /// (Core still boots so the Web can render the disconnected state). + #[test] + fn load_reads_token_file_and_degrades_cleanly() { + let guard = crate::credentials::test_credentials_dir(); + std::fs::create_dir_all(guard.dir()).expect("mk credentials dir"); + + // Missing file: not connected. + assert!(load().is_none(), "no file → None"); + + // The real provisioned shape (extra fields ignored). Provisioning is + // 0600 (A5); mirror it — the custody gate rejects looser modes. + let write_0600 = |body: &str| { + let path = guard.dir().join("ticktick.json"); + std::fs::write(&path, body).expect("write token file"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .expect("chmod 0600"); + } + }; + write_0600( + r#"{"access_token":"tok_ticktick","token_type":"bearer","scope":"tasks:read tasks:write","obtained_at":"2026-08-14T19:17:10.894Z"}"#, + ); + let conn = load().expect("token file loads"); + assert_eq!(conn.access_token, "tok_ticktick"); + assert!( + !conn.connection_id.contains("tok_ticktick") && conn.connection_id.len() == 36, + "the connection id is an opaque uuid, never token-derived" + ); + // Two loads mint DIFFERENT ids (boot-scoped, no cross-boot equality). + assert_ne!(conn.connection_id, load().expect("reload").connection_id); + + // An unparseable file degrades to None rather than failing boot. + write_0600("not json"); + assert!(load().is_none(), "corrupt file → None (Core still boots)"); + + // Custody (review R12 #5): a group/world-readable token file is REJECTED + // — the tasks:write secret follows the repo's 0600 policy at read time. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let path = guard.dir().join("ticktick.json"); + write_0600(r#"{"access_token":"tok_ticktick"}"#); + assert!(load().is_some(), "0600 loads"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) + .expect("loosen"); + assert!(load().is_none(), "world-readable → not connected"); + } + + // A symlinked credential is rejected (custody must not follow links). + #[cfg(unix)] + { + let real = guard.dir().join("elsewhere.json"); + std::fs::write(&real, r#"{"access_token":"tok_ticktick"}"#).expect("write target"); + let link = guard.dir().join("ticktick.json"); + std::fs::remove_file(&link).expect("clear"); + std::os::unix::fs::symlink(&real, &link).expect("symlink"); + assert!(load().is_none(), "symlink → not connected"); + std::fs::remove_file(&link).expect("clear link"); + } + + // An empty token is rejected — never a connected state with no secret. + write_0600(r#"{"access_token":" "}"#); + assert!(load().is_none(), "empty access_token → not connected"); + } +} diff --git a/crates/core/src/ticktick/wire.rs b/crates/core/src/ticktick/wire.rs new file mode 100644 index 00000000..5c843f86 --- /dev/null +++ b/crates/core/src/ticktick/wire.rs @@ -0,0 +1,314 @@ +//! TickTick OpenAPI transport decode + normalization (external-task-views A2). +//! Private `Raw*` types decode TickTick's JSON; [`normalize`] turns a +//! `/project` list + a `/task/filter` page into `TickTickTaskRow`s and the +//! truncation flag. Pure over its inputs — the unit tests drive it straight +//! from small hand-authored wire values, no HTTP. + +use serde::Deserialize; + +use crate::protocol::{TickTickChecklistItem, TickTickDue, TickTickTaskRow}; + +/// TickTick's `/open/v1/task/filter` hard ceiling (S1: the fixed +/// `{"status":[0]}` read returns at most 200 open entries, no cursor). A +/// response AT the cap may be truncated, so the flag is computed on the RAW +/// count before kind filtering (a 200-row page can normalize to fewer visible +/// rows — the signal must survive NOTE removal). +const SOURCE_LIMIT: usize = 200; + +/// The synthetic list name for the Inbox (S1a outcome 1): TickTick's Inbox +/// `projectId` is `inbox`-prefixed and never appears in `/project`. +const INBOX_PREFIX: &str = "inbox"; + +/// `/open/v1/project` row — only `id`/`name` are consumed. +#[derive(Debug, Deserialize)] +pub(super) struct RawProject { + pub id: String, + pub name: String, +} + +/// One checklist sub-item of a raw task. +#[derive(Debug, Deserialize)] +pub(super) struct RawChecklistItem { + #[serde(default)] + pub title: String, + #[serde(default)] + pub status: i64, +} + +/// `/open/v1/task/filter` row. Per-kind-absent fields are `#[serde(default)]` +/// (S1 nullability). The due tuple is `due_date`/`is_all_day`/`time_zone`; +/// `start_date` is intentionally NOT decoded (S1a: it always equals due). +/// `rename_all` maps every snake_case field to TickTick's camelCase key. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RawTask { + pub id: String, + #[serde(default)] + pub project_id: String, + #[serde(default)] + pub title: String, + #[serde(default = "default_task_kind")] + pub kind: String, + #[serde(default)] + pub priority: i64, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub due_date: Option, + #[serde(default)] + pub is_all_day: bool, + #[serde(default)] + pub time_zone: String, + #[serde(default)] + pub repeat_flag: Option, + #[serde(default)] + pub items: Vec, +} + +/// TickTick omits `kind` on plain tasks in some responses (the field is +/// optional in its schema): an absent kind IS a plain task, so default `TEXT` +/// rather than `""` (which `normalize`'s TEXT/CHECKLIST filter would drop). +fn default_task_kind() -> String { + "TEXT".to_string() +} + +/// Resolve a task's `project_id` to a display list name (A2): the `^inbox` +/// sentinel → synthetic `"Inbox"` (S1a outcome 1); a `/project` row's name; +/// else `None` (rendered "unnamed list"). The sentinel check comes FIRST +/// because Inbox is never in `/project`. +fn list_name(project_id: &str, projects: &[RawProject]) -> Option { + if project_id.starts_with(INBOX_PREFIX) { + return Some("Inbox".to_string()); + } + projects + .iter() + .find(|p| p.id == project_id) + .map(|p| p.name.clone()) +} + +/// Normalize a `/project` list + a `/task/filter` page into the Web-lane +/// envelope (A2): keep only `TEXT`/`CHECKLIST` (NOTE discarded), map each row's +/// list name, collapse the due tuple, and flag truncation on the RAW count. +pub(super) fn normalize( + projects: &[RawProject], + raw_tasks: &[RawTask], +) -> (Vec, bool) { + let source_limit_reached = raw_tasks.len() >= SOURCE_LIMIT; + let tasks = raw_tasks + .iter() + .filter(|t| t.kind == "TEXT" || t.kind == "CHECKLIST") + .map(|t| TickTickTaskRow { + id: t.id.clone(), + list_name: list_name(&t.project_id, projects), + title: t.title.clone(), + kind: t.kind.clone(), + priority: t.priority, + tags: t.tags.clone(), + due: t.due_date.as_ref().map(|date| TickTickDue { + date: date.clone(), + is_all_day: t.is_all_day, + time_zone: t.time_zone.clone(), + }), + // An empty repeatFlag ("") means "not recurring" — normalize to None. + repeat_flag: t.repeat_flag.as_ref().filter(|r| !r.is_empty()).cloned(), + checklist_items: t + .items + .iter() + .map(|i| TickTickChecklistItem { + title: i.title.clone(), + done: i.status == 1, + }) + .collect(), + }) + .collect(); + (tasks, source_limit_reached) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decode_projects(body: &serde_json::Value) -> Vec { + serde_json::from_value(body.clone()).expect("projects decode") + } + + fn decode_tasks(body: &serde_json::Value) -> Vec { + serde_json::from_value(body.clone()).expect("tasks decode") + } + + fn raw_task(id: &str, title: &str, kind: &str) -> RawTask { + RawTask { + id: id.to_string(), + project_id: "list-1".to_string(), + title: title.to_string(), + kind: kind.to_string(), + priority: 0, + tags: vec![], + due_date: None, + is_all_day: false, + time_zone: String::new(), + repeat_flag: None, + items: vec![], + } + } + + #[test] + fn full_page_filters_note_and_flags_truncation() { + let mut raw = (0..199) + .map(|i| raw_task(&format!("task-{i}"), &format!("Task {i}"), "TEXT")) + .collect::>(); + raw.push(raw_task("note-1", "Hidden note", "NOTE")); + + let (tasks, source_limit_reached) = normalize(&[], &raw); + assert!(source_limit_reached, "a 200-row page flags truncation"); + assert_eq!(tasks.len(), 199, "the one NOTE row is discarded"); + assert!( + tasks + .iter() + .all(|t| t.kind == "TEXT" || t.kind == "CHECKLIST"), + "only TEXT/CHECKLIST survive" + ); + } + + /// TickTick's `kind` is optional: a row WITHOUT the field is a plain task + /// — it must decode as `TEXT` and survive normalization, not default to + /// `""` and vanish (CodeRabbit #336). + #[test] + fn absent_kind_defaults_to_text_and_survives() { + let raw = decode_tasks(&serde_json::json!([ + { "id": "task-1", "projectId": "list-1", "title": "Plain task" } + ])); + assert_eq!(raw[0].kind, "TEXT", "absent kind decodes as TEXT"); + let (tasks, _) = normalize(&[], &raw); + assert_eq!(tasks.len(), 1, "the kindless row is kept"); + assert_eq!(tasks[0].kind, "TEXT"); + } + + /// A short page (below the cap) does NOT flag truncation. + #[test] + fn short_page_does_not_flag_truncation() { + let mut task = raw_task("t1", "one", "TEXT"); + task.project_id = "missing-list".to_string(); + task.repeat_flag = Some(String::new()); + + let (tasks, source_limit_reached) = normalize(&[], &[task]); + assert!(!source_limit_reached); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].repeat_flag, None, "empty repeatFlag → None"); + assert_eq!(tasks[0].list_name, None, "unmatched id → unnamed list"); + } + + #[test] + fn contract_cases_normalize_to_expected_rows() { + let projects = decode_projects(&serde_json::json!([ + { "id": "list-1", "name": "Work" } + ])); + let raw = decode_tasks(&serde_json::json!([ + { + "id": "all-day", + "projectId": "list-1", + "title": "All-day task", + "kind": "TEXT", + "dueDate": "2026-08-20T07:00:00.000+0000", + "isAllDay": true, + "timeZone": "America/Los_Angeles" + }, + { + "id": "timed", + "projectId": "list-1", + "title": "Timed task", + "kind": "TEXT", + "tags": ["advanced"], + "dueDate": "2026-08-20T17:30:00.000+0000", + "isAllDay": false, + "timeZone": "America/Los_Angeles" + }, + { + "id": "recurring", + "projectId": "list-1", + "title": "Recurring task", + "kind": "TEXT", + "repeatFlag": "RRULE:FREQ=DAILY;INTERVAL=1" + }, + { + "id": "checklist", + "projectId": "list-1", + "title": "Checklist task", + "kind": "CHECKLIST", + "items": [ + { "title": "Open item", "status": 0 }, + { "title": "Done item", "status": 1 } + ] + } + ])); + let (tasks, _) = normalize(&projects, &raw); + let by_title = |title: &str| { + tasks + .iter() + .find(|t| t.title == title) + .unwrap_or_else(|| panic!("missing {title}")) + .clone() + }; + + let all_day = by_title("All-day task"); + assert_eq!( + all_day.due, + Some(TickTickDue { + date: "2026-08-20T07:00:00.000+0000".to_string(), + is_all_day: true, + time_zone: "America/Los_Angeles".to_string(), + }) + ); + let timed = by_title("Timed task"); + assert_eq!( + timed.due, + Some(TickTickDue { + date: "2026-08-20T17:30:00.000+0000".to_string(), + is_all_day: false, + time_zone: "America/Los_Angeles".to_string(), + }) + ); + assert_eq!(timed.list_name.as_deref(), Some("Work")); + assert_eq!(timed.tags, vec!["advanced".to_string()]); + assert_eq!( + by_title("Recurring task").repeat_flag.as_deref(), + Some("RRULE:FREQ=DAILY;INTERVAL=1") + ); + let checklist = by_title("Checklist task"); + assert_eq!(checklist.kind, "CHECKLIST"); + assert_eq!( + checklist.checklist_items, + vec![ + TickTickChecklistItem { + title: "Open item".to_string(), + done: false + }, + TickTickChecklistItem { + title: "Done item".to_string(), + done: true + }, + ] + ); + } + + #[test] + fn inbox_sentinel_maps_to_inbox_list() { + let raw = decode_tasks(&serde_json::json!([{ + "id": "inbox-task", + "projectId": "inbox-account-suffix", + "title": "Inbox task", + "kind": "TEXT" + }])); + let (tasks, _) = normalize(&[], &raw); + + let inbox_task = tasks + .iter() + .find(|t| t.title == "Inbox task") + .expect("the inbox task"); + assert_eq!( + inbox_task.list_name.as_deref(), + Some("Inbox"), + "the ^inbox sentinel maps to the synthetic Inbox list" + ); + } +} diff --git a/crates/core/src/tools/mod.rs b/crates/core/src/tools/mod.rs index 33e848db..81ceb5d2 100644 --- a/crates/core/src/tools/mod.rs +++ b/crates/core/src/tools/mod.rs @@ -74,6 +74,19 @@ fn no_arg(_params: &Value) -> Option { None } +/// The reserved name prefix of EXTERNAL tools — Worker-executed MCP tools the +/// model sees as `ticktick_*` (external-task-views A3/A4). Reserved in this +/// registry (see `registry_reserves_the_external_prefix`), so the prefix is +/// unambiguous at every consumer: the frame handler, the interrupted settle, +/// resume, and the Web's no-grouping rule all key off it — no parallel boolean +/// to sync while a call is in flight. +pub const EXTERNAL_TOOL_PREFIX: &str = "ticktick_"; + +/// Whether `name` is an external (Worker-executed MCP) tool. +pub fn is_external(name: &str) -> bool { + name.starts_with(EXTERNAL_TOOL_PREFIX) +} + /// Every registered tool, in manifest (descriptor) order. const REGISTRY: &[ToolEntry] = &[ ToolEntry { @@ -317,6 +330,26 @@ mod tests { ); } + /// The `ticktick_` prefix is RESERVED for external tools (A3/A4): no Core + /// tool may register under it (the prefix marks a call external at every + /// consumer), and `is_external` classifies by it. The registry is a const + /// table, so this test IS the registration guard. + #[test] + fn registry_reserves_the_external_prefix() { + for entry in REGISTRY { + assert!( + !entry.name.starts_with(EXTERNAL_TOOL_PREFIX), + "{:?} must not register under the reserved external prefix {EXTERNAL_TOOL_PREFIX:?}", + entry.name + ); + } + assert!(is_external("ticktick_filter_tasks")); + assert!(!is_external("read_thread")); + // An external name is never registered, so the dispatch gate rejects it + // even if a Workflow allowlists it. + assert!(!is_allowed(&["ticktick_filter_tasks".to_string()], "ticktick_filter_tasks")); + } + #[test] fn run_descriptors_appends_ambient_load_skill_once() { // An empty Workflow allowlist still ships load_skill (ambient). diff --git a/crates/core/src/worker/child.rs b/crates/core/src/worker/child.rs index 137a8447..83730e66 100644 --- a/crates/core/src/worker/child.rs +++ b/crates/core/src/worker/child.rs @@ -9,7 +9,7 @@ use tokio::process::{Child, ChildStdin, ChildStdout, Command}; use uuid::Uuid; use super::port::WorkerPort; -use crate::protocol::{ToolResult, WorkerStdout}; +use crate::protocol::{ExternalToolAck, ToolResult, WorkerStdout}; /// A spawned Worker child process with its stdio framed as NDJSON. Holds the /// `Child` so the process stays alive for the Run; spawned `kill_on_drop(true)`, @@ -101,52 +101,78 @@ impl ChildWorker { } } +async fn write_frame( + stdin: &mut Option, + run_id: Uuid, + frame: &T, + serialize_event: &'static str, + write_event: &'static str, +) -> Result<(), ()> { + let Some(stdin) = stdin.as_mut() else { + return Err(()); + }; + let mut line = serde_json::to_string(frame).map_err(|e| { + tracing::error!(event = serialize_event, %run_id, error = ?e); + })?; + line.push('\n'); + stdin.write_all(line.as_bytes()).await.map_err(|e| { + tracing::error!(event = write_event, %run_id, error = ?e); + })?; + stdin.flush().await.map_err(|e| { + tracing::error!(event = write_event, %run_id, error = ?e); + }) +} + impl WorkerPort for ChildWorker { - async fn recv(&mut self) -> Option { - loop { - match self.lines.next_line().await { - Ok(Some(line)) => match serde_json::from_str::(&line) { - Ok(msg) => return Some(msg), - Err(e) => { - tracing::warn!( - event = "worker.unknown_line", - run_id = %self.run_id, - line_preview = %line_preview(&line), - error = ?e - ); - continue; - } - }, - Ok(None) => return None, + async fn recv(&mut self) -> Result, ()> { + match self.lines.next_line().await { + Ok(Some(line)) => match serde_json::from_str::(&line) { + Ok(msg) => Ok(Some(msg)), Err(e) => { - tracing::error!(event = "worker.stdout_read_failed", run_id = %self.run_id, error = ?e); - return None; + tracing::warn!( + event = "worker.unknown_line", + run_id = %self.run_id, + line_preview = %line_preview(&line), + error = ?e + ); + Err(()) } + }, + Ok(None) => Ok(None), + Err(e) => { + tracing::error!( + event = "worker.stdout_read_failed", + run_id = %self.run_id, + error = ?e + ); + Err(()) } } } async fn send_tool_result(&mut self, result: ToolResult) { - let Some(stdin) = self.stdin.as_mut() else { - return; - }; - match serde_json::to_string(&result) { - Ok(mut line) => { - line.push('\n'); - if let Err(e) = stdin.write_all(line.as_bytes()).await { - tracing::error!(event = "worker.tool_result_write_failed", run_id = %self.run_id, error = ?e); - } - let _ = stdin.flush().await; - } - Err(e) => { - tracing::error!(event = "worker.tool_result_serialize_failed", run_id = %self.run_id, error = ?e) - } - } + let _ = write_frame( + &mut self.stdin, + self.run_id, + &result, + "worker.tool_result_serialize_failed", + "worker.tool_result_write_failed", + ) + .await; + } + + async fn send_external_tool_ack(&mut self, ack: ExternalToolAck) -> Result<(), ()> { + write_frame( + &mut self.stdin, + self.run_id, + &ack, + "worker.external_ack_serialize_failed", + "worker.external_ack_write_failed", + ) + .await } async fn shutdown(&mut self) { - // Drop stdin → EOF: the Worker (blocked awaiting a tool_result, or done) - // exits and closes stdout, ending the read loop. self.stdin = None; } } diff --git a/crates/core/src/worker/external.rs b/crates/core/src/worker/external.rs new file mode 100644 index 00000000..e0dd2b20 --- /dev/null +++ b/crates/core/src/worker/external.rs @@ -0,0 +1,968 @@ +//! External tool-call lifecycle (external-task-views A4): the coherent owner of +//! an MCP call's durable transitions — begin (persist the pending row + publish +//! `Started`), finish (resolve the row + publish the terminal event), and the +//! interrupted-settlement publication a Run termination emits for still-pending +//! calls. Each is ONE gated critical section so it is atomic w.r.t. the cancel +//! path's gated `Cancelled` (review #1). `run_loop` (worker/run.rs) is left as +//! frame orchestration: it pairs started↔finished frames and routes them here; +//! `run/cancel` publishes interrupted settlements through `publish_interrupted`. + +use sqlx::SqlitePool; +use uuid::Uuid; + +use super::port::WorkerPort; +use crate::db; +use crate::hub::RunHub; +use crate::protocol::{ + ExternalToolAck, ExternalToolPhase, RunEvent, ToolCallStatus, TranscriptToolResult, +}; + +pub(super) const EXTERNAL_PERSIST_FAILED_MESSAGE: &str = + "core could not persist an external tool call"; +pub(super) const WORKER_PROTOCOL_FAILED_MESSAGE: &str = + "worker emitted an invalid external tool lifecycle frame"; +pub(super) const RUN_NO_LONGER_ACTIVE_MESSAGE: &str = + "external tool call could not start because the run is no longer active"; + +pub(super) enum FrameFailure { + Cancelled, + Terminal(&'static str), +} + +async fn send_ack( + worker: &mut P, + tool_call_id: &str, + phase: ExternalToolPhase, + ok: bool, +) -> Result<(), ()> { + worker + .send_external_tool_ack(ExternalToolAck { + kind: "external_tool_ack", + tool_call_id: tool_call_id.to_string(), + phase, + ok, + }) + .await +} + +async fn reject( + worker: &mut P, + tool_call_id: &str, + phase: ExternalToolPhase, + failure: FrameFailure, +) -> FrameFailure { + let _ = send_ack(worker, tool_call_id, phase, false).await; + failure +} + +pub(super) async fn handle_started( + worker: &mut P, + pool: &SqlitePool, + run_id: Uuid, + run_hub: &RunHub, + tool_call_id: &str, + name: &str, + arguments: &serde_json::Value, +) -> Result<(), FrameFailure> { + if !crate::tools::is_external(name) { + tracing::error!( + event = "worker.external_frame_name_unreserved", + %run_id, + tool_call_id, + name + ); + return Err(reject( + worker, + tool_call_id, + ExternalToolPhase::Started, + FrameFailure::Terminal(WORKER_PROTOCOL_FAILED_MESSAGE), + ) + .await); + } + + match begin_external_and_publish( + pool, + run_id, + run_hub, + tool_call_id, + name, + &arguments.to_string(), + ) + .await + { + Ok(db::Moved::Won) => send_ack(worker, tool_call_id, ExternalToolPhase::Started, true) + .await + .map_err(|()| FrameFailure::Terminal(WORKER_PROTOCOL_FAILED_MESSAGE)), + Ok(db::Moved::Lost) => { + let failure = if run_hub.is_cancelled() { + FrameFailure::Cancelled + } else { + FrameFailure::Terminal(RUN_NO_LONGER_ACTIVE_MESSAGE) + }; + Err(reject(worker, tool_call_id, ExternalToolPhase::Started, failure).await) + } + Err(error) => { + tracing::error!( + event = "worker.begin_external_tool_call_failed", + %run_id, + tool_call_id, + error = ?error + ); + let message = if error + .as_database_error() + .is_some_and(|db| db.is_unique_violation()) + { + WORKER_PROTOCOL_FAILED_MESSAGE + } else { + EXTERNAL_PERSIST_FAILED_MESSAGE + }; + Err(reject( + worker, + tool_call_id, + ExternalToolPhase::Started, + FrameFailure::Terminal(message), + ) + .await) + } + } +} + +/// Persist an external call's started row AND publish its `Started` event as ONE +/// gated critical section (external-task-views A4, review #1). Holding the +/// per-run gate ACROSS the commit and the publish makes the pair atomic w.r.t. +/// the cancel path's gated `Cancelled` — a concurrent cancel can only interleave +/// wholly before or wholly after, never between the committed row and its event. +/// The insert is guarded on the Run still being `running`; the finished frame +/// re-pairs by resolving the persisted row (review M1), so no id is tracked here. +/// A DB fault is `Err` — the caller STOPS the Worker (review R12 #2): letting the +/// model proceed on an unpersisted call would diverge live from reload and +/// resume. +pub(super) async fn begin_external_and_publish( + pool: &SqlitePool, + run_id: Uuid, + run_hub: &RunHub, + tool_call_id: &str, + name: &str, + request_payload: &str, +) -> sqlx::Result { + let guard = run_hub.gate().await; + let outcome = db::begin_external_tool_call( + pool, + run_id, + tool_call_id, + name, + request_payload, + db::now_ms(), + ) + .await?; + if outcome.won() { + run_hub.send(RunEvent::ToolCall { + tool_call_id: tool_call_id.to_string(), + name: name.to_string(), + status: ToolCallStatus::Started, + arg: None, + result: None, + }); + } + drop(guard); + Ok(outcome) +} + +/// Resolve an external call's row AND publish its terminal event as ONE gated +/// critical section (external-task-views A4, review #1). The resolve is scoped to +/// `status='pending'`, so a finish that races a cancel/EOF settle LOSES and emits +/// nothing; and because the commit and the publish share the gate, a won finish's +/// event is ordered BEFORE any `Cancelled` — a live tail can never close on +/// `Cancelled` while its already-committed result sits unpublished (live == +/// reload). +/// A DB fault is `Err` — the caller STOPS the Worker (review R12 #2): a result +/// the model already consumed but whose row stayed `pending` would reload (and +/// resume) as "not executed". +pub(super) async fn finish_external_and_publish( + pool: &SqlitePool, + run_id: Uuid, + run_hub: &RunHub, + tool_call_id: &str, + result: TranscriptToolResult, +) -> sqlx::Result { + let status = if result.is_error { + "errored" + } else { + "completed" + }; + let payload = serde_json::to_string(&result).expect("TranscriptToolResult serializes"); + let guard = run_hub.gate().await; + let outcome = + db::finish_external_tool_call(pool, run_id, tool_call_id, status, &payload, db::now_ms()) + .await?; + if let db::ExternalToolFinish::Resolved(ref name) = outcome { + run_hub.send(RunEvent::ToolCall { + tool_call_id: tool_call_id.to_string(), + name: name.clone(), + status: if result.is_error { + ToolCallStatus::Error + } else { + ToolCallStatus::Completed + }, + arg: None, + result: Some(result), + }); + } + drop(guard); + Ok(outcome) +} + +pub(super) async fn handle_finished( + worker: &mut P, + pool: &SqlitePool, + run_id: Uuid, + run_hub: &RunHub, + tool_call_id: &str, + result: TranscriptToolResult, +) -> Result<(), FrameFailure> { + match finish_external_and_publish(pool, run_id, run_hub, tool_call_id, result).await { + Ok(db::ExternalToolFinish::Resolved(_)) => { + send_ack(worker, tool_call_id, ExternalToolPhase::Finished, true) + .await + .map_err(|()| FrameFailure::Terminal(WORKER_PROTOCOL_FAILED_MESSAGE)) + } + Ok(db::ExternalToolFinish::AlreadySettled) if run_hub.is_cancelled() => Err(reject( + worker, + tool_call_id, + ExternalToolPhase::Finished, + FrameFailure::Cancelled, + ) + .await), + Ok(db::ExternalToolFinish::AlreadySettled | db::ExternalToolFinish::Missing) => { + tracing::error!( + event = "worker.external_finished_unpaired", + %run_id, + tool_call_id + ); + Err(reject( + worker, + tool_call_id, + ExternalToolPhase::Finished, + FrameFailure::Terminal(WORKER_PROTOCOL_FAILED_MESSAGE), + ) + .await) + } + Err(error) => { + tracing::error!( + event = "worker.finish_external_tool_call_failed", + %run_id, + tool_call_id, + error = ?error + ); + Err(reject( + worker, + tool_call_id, + ExternalToolPhase::Finished, + FrameFailure::Terminal(EXTERNAL_PERSIST_FAILED_MESSAGE), + ) + .await) + } + } +} + +/// Publish the interrupted `tool_call {status: error, result}` event for each +/// external call a terminal transition settled (external-task-views A4) — +/// after that tx committed, before the terminal Run Event. Shared by the +/// loop's own terminal branch and `run/cancel`'s post-response publish. +pub(crate) fn publish_interrupted(run_hub: &RunHub, interrupted: Vec) { + for call in interrupted { + run_hub.send(RunEvent::ToolCall { + tool_call_id: call.tool_call_id, + name: call.name, + status: ToolCallStatus::Error, + arg: None, + result: Some(TranscriptToolResult::interrupted()), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::test_support::memory_pool; + use crate::protocol::WorkerStdout; + use crate::worker::port::Exit; + use crate::worker::run::run_loop; + use crate::worker::test_support::*; + + fn ack(id: &str, phase: ExternalToolPhase, ok: bool) -> ExternalToolAck { + ExternalToolAck { + kind: "external_tool_ack", + tool_call_id: id.to_string(), + phase, + ok, + } + } + + /// An external call's two frames persist one row — pending on `started`, + /// resolved on `finished` with `tool_calls.status` DERIVED from + /// `result.is_error` — and publish started + terminal `tool_call` events, + /// the terminal one CARRYING the result (A4: the live expandable row must + /// match reload). A failed call persists as an error, never success-shaped. + #[tokio::test] + async fn external_frames_persist_rows_and_publish_result_bearing_events() { + let pool = memory_pool().await; + let wf = test_workflow(&[]); + let (run_id, _t, amid) = seed_run(&pool, &wf).await; + let (hubs, run_hub) = fixtures(run_id); + let mut rx = run_hub.subscribe_raw(); + let (worker, sent, _sd, acks) = ScriptedWorker::new_with_acks(vec![ + external_started("tc-ok", "ticktick_filter_tasks"), + external_finished("tc-ok", "1 task found", false), + external_started("tc-bad", "ticktick_search_task"), + external_finished("tc-bad", "Missing required parameter", true), + WorkerStdout::Done, + ]); + + let exit = run_loop( + worker, + run_id, + wf, + pool.clone(), + amid, + hubs, + run_hub.clone(), + ) + .await; + + assert_eq!(exit, Exit::Done); + // Core only observes — no Tool Protocol round-trip happened. + assert!(sent.lock().unwrap().is_empty(), "no tool_result was sent"); + + assert_eq!( + *acks.lock().unwrap(), + vec![ + ack("tc-ok", ExternalToolPhase::Started, true), + ack("tc-ok", ExternalToolPhase::Finished, true), + ack("tc-bad", ExternalToolPhase::Started, true), + ack("tc-bad", ExternalToolPhase::Finished, true), + ], + "each durable transition is acknowledged in source order" + ); + + // Persisted rows: status derives from result.is_error; the payload is + // the TranscriptToolResult JSON. + let (name, status, payload) = tool_call_row(&pool, "tc-ok").await.expect("tc-ok row"); + assert_eq!(name, "ticktick_filter_tasks"); + assert_eq!(status, "completed"); + assert_eq!( + serde_json::from_str::(&payload.expect("payload")).unwrap(), + TranscriptToolResult::text("1 task found", false) + ); + let (_, status, payload) = tool_call_row(&pool, "tc-bad").await.expect("tc-bad row"); + assert_eq!(status, "errored", "a failed call persists as an error"); + assert_eq!( + serde_json::from_str::(&payload.expect("payload")).unwrap(), + TranscriptToolResult::text("Missing required parameter", true) + ); + + // Events: started (no result) then terminal (carrying the result), per + // call, in source order. + let events = drain(&mut rx); + let calls: Vec<(&str, &ToolCallStatus, bool)> = events + .iter() + .filter_map(|e| match e { + RunEvent::ToolCall { + tool_call_id, + status, + result, + .. + } => Some((tool_call_id.as_str(), status, result.is_some())), + _ => None, + }) + .collect(); + assert_eq!( + calls, + vec![ + ("tc-ok", &ToolCallStatus::Started, false), + ("tc-ok", &ToolCallStatus::Completed, true), + ("tc-bad", &ToolCallStatus::Started, false), + ("tc-bad", &ToolCallStatus::Error, true), + ], + "started omits result; the terminal event carries it" + ); + } + + /// A cancel signalled just before a finished frame trips the loop's + /// post-recv cancel check, which breaks BEFORE the finished arm runs — so + /// the row stays `pending` for `run/cancel`'s settle to claim, and no + /// completion event escapes. (The DB-level guard that stops a finish which + /// races an ALREADY-COMMITTED settle is exercised directly by + /// `finish_external_tool_call_loses_to_a_committed_settle`.) + #[tokio::test] + async fn cancel_racing_finished_frame_leaves_row_pending_for_the_settle() { + let pool = memory_pool().await; + let wf = test_workflow(&[]); + let (run_id, _t, amid) = seed_run(&pool, &wf).await; + let (hubs, run_hub) = fixtures(run_id); + let mut tail = run_hub.subscribe_raw(); + // started (idx 0), then cancel flips before the finished frame (idx 1). + let (worker, _sent, _sd) = CancelingWorker::new( + vec![ + external_started("tc-race", "ticktick_filter_tasks"), + external_finished("tc-race", "1 task found", false), + ], + run_hub.clone(), + 1, + ); + + let exit = run_loop( + worker, + run_id, + wf, + pool.clone(), + amid, + hubs, + run_hub.clone(), + ) + .await; + + assert_eq!(exit, Exit::Cancelled); + // The row stayed pending — the finished arm skipped its resolve, so the + // cancel transition's settle (run in run/cancel, not here) will catch it. + let (_, status, payload) = tool_call_row(&pool, "tc-race").await.expect("row"); + assert_eq!(status, "pending", "the finished frame did not resolve the row"); + assert_eq!(payload, None, "no success-shaped result clobbered the settle"); + // No completion event was published for the raced finished frame (only + // the started event precedes the cancel). + let events = drain(&mut tail); + assert!( + !events.iter().any(|e| matches!( + e, + RunEvent::ToolCall { status: ToolCallStatus::Completed, .. } + )), + "no completed tool_call event escaped the cancel guard: {events:?}" + ); + } + + /// The DB-level guard (external-task-views A4, finding #1): once a terminal + /// settle has committed the row (`cancel_running_run` → interrupted), a + /// LATER `finish_external_tool_call` LOSES (`WHERE status='pending'` matches + /// 0 rows) and leaves the interrupted result intact — a late success result + /// can never clobber the settle. And `begin_external_tool_call` LOSES once + /// the Run is no longer `running`, so a started frame that races the + /// terminal inserts no phantom row. + #[tokio::test] + async fn finish_external_tool_call_loses_to_a_committed_settle() { + let pool = memory_pool().await; + let wf = test_workflow(&[]); + let (run_id, _t, _amid) = seed_run(&pool, &wf).await; + + // A started external call lands its pending row while running. + assert!( + db::begin_external_tool_call( + &pool, + run_id, + "tc-x", + "ticktick_filter_tasks", + "{}", + db::now_ms(), + ) + .await + .unwrap() + .won() + ); + + // The cancel transition settles it as interrupted + flips the Run. + assert!( + db::cancel_running_run(&pool, run_id, db::now_ms()) + .await + .unwrap() + .won() + ); + + // A finish now LOSES — the row is no longer pending. + let finished = db::finish_external_tool_call( + &pool, + run_id, + "tc-x", + "completed", + r#"{"content":[{"type":"text","text":"late"}],"is_error":false}"#, + db::now_ms(), + ) + .await + .unwrap(); + assert!( + matches!(finished, db::ExternalToolFinish::AlreadySettled), + "a finish racing a committed settle reports the settled row" + ); + let (_, status, payload) = tool_call_row(&pool, "tc-x").await.expect("row"); + assert_eq!(status, "errored", "the interrupted settle stands"); + assert_eq!( + serde_json::from_str::(&payload.unwrap()).unwrap(), + TranscriptToolResult::interrupted(), + "the late success result did not clobber the settle" + ); + + // A started frame arriving after the Run went terminal inserts nothing. + let began = db::begin_external_tool_call( + &pool, + run_id, + "tc-late", + "ticktick_search_task", + "{}", + db::now_ms(), + ) + .await + .unwrap(); + assert!(!began.won(), "begin loses once the Run is not running"); + assert!( + tool_call_row(&pool, "tc-late").await.is_none(), + "no phantom row for a started frame that raced the terminal" + ); + } + + /// Barrier (external-task-views A4, review #1): `finish_external_and_publish` + /// commits the row INSIDE the gate, so while another holder owns the gate the + /// resolve cannot land — the row stays `pending` and no event escapes. This is + /// the anti-divergence property: the publish can never be observed before its + /// commit, and (symmetrically) a won finish's event orders before a gated + /// `Cancelled`. On a current-thread runtime a single `yield_now` parks the + /// spawned finish squarely on `gate().await`; before the fix (commit BEFORE + /// the gate) the row would already read `completed` here. + #[tokio::test] + async fn finish_external_persist_and_publish_are_one_gated_unit() { + let pool = memory_pool().await; + let wf = test_workflow(&[]); + let (run_id, _t, _amid) = seed_run(&pool, &wf).await; + let (_hubs, run_hub) = fixtures(run_id); + let mut tail = run_hub.subscribe_raw(); + assert!( + db::begin_external_tool_call(&pool, run_id, "tc-g", "ticktick_filter_tasks", "{}", db::now_ms()) + .await + .unwrap() + .won() + ); + + // Hold the gate, then spawn the finish: it parks on `gate().await`. + let guard = run_hub.gate().await; + let finish = tokio::spawn({ + let pool = pool.clone(); + let hub = run_hub.clone(); + async move { + finish_external_and_publish( + &pool, + run_id, + &hub, + "tc-g", + TranscriptToolResult::text("1 task", false), + ) + .await + .expect("finish persists"); + } + }); + tokio::task::yield_now().await; + + // Gate held → the resolve has not committed and no event leaked. + let (_, status, _) = tool_call_row(&pool, "tc-g").await.expect("row"); + assert_eq!(status, "pending", "the commit is inside the gate we hold"); + assert!(tail.try_recv().is_err(), "no event before the gate frees"); + + // Release: the finish commits and publishes as a unit. + drop(guard); + finish.await.expect("finish task joins"); + let (_, status, _) = tool_call_row(&pool, "tc-g").await.expect("row"); + assert_eq!(status, "completed", "the resolve lands once the gate frees"); + assert!( + matches!( + drain(&mut tail).as_slice(), + [RunEvent::ToolCall { status: ToolCallStatus::Completed, result: Some(_), .. }] + ), + "exactly the completed tool_call event, carrying its result" + ); + } + + /// A mixed Core + external batch lands in SOURCE order in `run_steps` — + /// the durable timeline the reload renders (A4: sequential mode makes + /// frame order == source order by contract). + #[tokio::test] + async fn mixed_core_and_external_batch_lands_in_source_order() { + let pool = memory_pool().await; + let wf = test_workflow(&["read_thread"]); + let (run_id, thread_id, amid) = seed_run(&pool, &wf).await; + let (hubs, run_hub) = fixtures(run_id); + let (worker, _sent, _sd) = ScriptedWorker::new(vec![ + WorkerStdout::ToolRequest { + run_id: String::new(), + tool_call_id: "tc-core".to_string(), + name: "read_thread".to_string(), + params: serde_json::json!({ "thread_id": thread_id.to_string() }), + }, + external_started("tc-ext", "ticktick_filter_tasks"), + external_finished("tc-ext", "1 task found", false), + WorkerStdout::TextDelta { + delta: "done".to_string(), + }, + WorkerStdout::Done, + ]); + + let exit = run_loop( + worker, + run_id, + wf, + pool.clone(), + amid, + hubs, + run_hub.clone(), + ) + .await; + + assert_eq!(exit, Exit::Done); + let timeline = run_steps_kinds_and_content(&pool, run_id).await; + assert_eq!( + timeline, + vec![ + ("message".to_string(), "prompt".to_string()), + ("tool_call".to_string(), "read_thread".to_string()), + ("tool_call".to_string(), "ticktick_filter_tasks".to_string()), + ("message".to_string(), "done".to_string()), + ], + "Core and external calls interleave in source order" + ); + } + + /// Worker EOF after `external_tool_started` (the Worker died mid-call): + /// the terminal transition settles the row as an ERROR carrying the + /// Core-generated interrupted result, and the interrupted `tool_call` + /// event publishes BEFORE the loop finishes (EOF's terminal signal is the + /// hub closing — no Done/Error event follows it). + #[tokio::test] + async fn worker_eof_after_started_settles_interrupted() { + let pool = memory_pool().await; + let wf = test_workflow(&[]); + let (run_id, _t, amid) = seed_run(&pool, &wf).await; + let (hubs, run_hub) = fixtures(run_id); + let mut rx = run_hub.subscribe_raw(); + // started, then the script is exhausted → recv None (EOF, no finished). + let (worker, _sent, _sd) = + ScriptedWorker::new(vec![external_started("tc-hang", "ticktick_search_task")]); + + let exit = run_loop( + worker, + run_id, + wf, + pool.clone(), + amid, + hubs, + run_hub.clone(), + ) + .await; + + assert_eq!(exit, Exit::Disconnected); + assert_eq!( + db::run_status(&pool, run_id) + .await + .unwrap() + .map(db::RunStatus::as_str), + Some("errored") + ); + let (_, status, payload) = tool_call_row(&pool, "tc-hang").await.expect("row"); + assert_eq!(status, "errored"); + assert_eq!( + serde_json::from_str::(&payload.expect("payload")).unwrap(), + TranscriptToolResult::interrupted(), + "the settle wrote the Core-generated interrupted result" + ); + + // Live path: started, then the interrupted error event carrying the + // SAME result reload will render — published before the hub closes. + let events = drain(&mut rx); + let calls: Vec<(&str, &ToolCallStatus, Option<&TranscriptToolResult>)> = events + .iter() + .filter_map(|e| match e { + RunEvent::ToolCall { + tool_call_id, + status, + result, + .. + } => Some((tool_call_id.as_str(), status, result.as_ref())), + _ => None, + }) + .collect(); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0], ("tc-hang", &ToolCallStatus::Started, None)); + assert_eq!( + calls[1], + ( + "tc-hang", + &ToolCallStatus::Error, + Some(&TranscriptToolResult::interrupted()) + ) + ); + // EOF publishes the interrupted `tool_call` BEFORE the terminal Run + // Event, and that terminal event is `Error` (NOT `Done`, NOT absent) — + // the Worker died, so the live tail sees the failure. + let interrupted_idx = events.iter().position(|e| { + matches!(e, RunEvent::ToolCall { status: ToolCallStatus::Error, .. }) + }); + let terminal_idx = events + .iter() + .position(|e| matches!(e, RunEvent::Error { .. })); + assert!( + matches!((interrupted_idx, terminal_idx), (Some(i), Some(t)) if i < t), + "interrupted tool_call precedes the terminal Error, got {events:?}" + ); + assert!( + !events.iter().any(|e| matches!(e, RunEvent::Done)), + "EOF is not reported as done" + ); + } + + /// Two same-name external calls stay two distinct rows with distinct + /// results (A4: per-call identity — the Web never groups external calls). + #[tokio::test] + async fn two_same_name_external_calls_persist_two_rows() { + let pool = memory_pool().await; + let wf = test_workflow(&[]); + let (run_id, _t, amid) = seed_run(&pool, &wf).await; + let (hubs, run_hub) = fixtures(run_id); + let (worker, _sent, _sd) = ScriptedWorker::new(vec![ + external_started("tc-1", "ticktick_search_task"), + external_finished("tc-1", "first result", false), + external_started("tc-2", "ticktick_search_task"), + external_finished("tc-2", "second result", false), + WorkerStdout::Done, + ]); + + let exit = run_loop( + worker, + run_id, + wf, + pool.clone(), + amid, + hubs, + run_hub.clone(), + ) + .await; + + assert_eq!(exit, Exit::Done); + let (_, _, first) = tool_call_row(&pool, "tc-1").await.expect("tc-1"); + let (_, _, second) = tool_call_row(&pool, "tc-2").await.expect("tc-2"); + assert_eq!( + serde_json::from_str::(&first.unwrap()).unwrap(), + TranscriptToolResult::text("first result", false) + ); + assert_eq!( + serde_json::from_str::(&second.unwrap()).unwrap(), + TranscriptToolResult::text("second result", false) + ); + } + + async fn drive_protocol_frames( + frames: Vec, + ) -> (SqlitePool, Uuid, Exit, Vec, u32) { + let pool = memory_pool().await; + let wf = test_workflow(&[]); + let (run_id, _thread_id, assistant_id) = seed_run(&pool, &wf).await; + let (hubs, run_hub) = fixtures(run_id); + let (worker, _sent, shutdowns, acks) = ScriptedWorker::new_with_acks(frames); + + let exit = run_loop( + worker, + run_id, + wf, + pool.clone(), + assistant_id, + hubs, + run_hub, + ) + .await; + let recorded_acks = acks.lock().unwrap().clone(); + let shutdown_count = *shutdowns.lock().unwrap(); + (pool, run_id, exit, recorded_acks, shutdown_count) + } + + #[tokio::test] + async fn unreserved_external_start_is_nacked_and_terminates_the_run() { + let (pool, run_id, exit, acks, shutdowns) = drive_protocol_frames(vec![ + external_started("tc-unreserved", "filter_tasks"), + WorkerStdout::Done, + ]) + .await; + + assert_eq!( + exit, + Exit::Errored(WORKER_PROTOCOL_FAILED_MESSAGE.to_string()) + ); + assert_eq!( + acks, + vec![ack("tc-unreserved", ExternalToolPhase::Started, false)] + ); + assert!(shutdowns >= 1); + assert!(tool_call_row(&pool, "tc-unreserved").await.is_none()); + assert_eq!( + db::run_status(&pool, run_id) + .await + .unwrap() + .map(db::RunStatus::as_str), + Some("errored") + ); + } + + #[tokio::test] + async fn terminal_race_does_not_blame_the_worker() { + let pool = memory_pool().await; + let wf = test_workflow(&[]); + let (run_id, _thread_id, _assistant_id) = seed_run(&pool, &wf).await; + let (_hubs, run_hub) = fixtures(run_id); + let (mut worker, _sent, _shutdowns, acks) = + ScriptedWorker::new_with_acks(Vec::new()); + + assert!( + db::cancel_running_run(&pool, run_id, db::now_ms()) + .await + .expect("cancel transition") + .won() + ); + assert!( + !run_hub.is_cancelled(), + "the durable transition wins before the in-memory signal lands" + ); + + let result = handle_started( + &mut worker, + &pool, + run_id, + &run_hub, + "tc-raced", + "ticktick_filter_tasks", + &serde_json::json!({}), + ) + .await; + + assert!(matches!( + result, + Err(FrameFailure::Terminal(RUN_NO_LONGER_ACTIVE_MESSAGE)) + )); + assert_eq!( + acks.lock().unwrap().as_slice(), + &[ack("tc-raced", ExternalToolPhase::Started, false)] + ); + } + + #[tokio::test] + async fn duplicate_external_start_is_nacked_and_terminates_the_run() { + let (pool, _run_id, exit, acks, _shutdowns) = drive_protocol_frames(vec![ + external_started("tc-duplicate", "ticktick_filter_tasks"), + external_started("tc-duplicate", "ticktick_filter_tasks"), + WorkerStdout::Done, + ]) + .await; + + assert_eq!( + exit, + Exit::Errored(WORKER_PROTOCOL_FAILED_MESSAGE.to_string()) + ); + assert_eq!( + acks, + vec![ + ack("tc-duplicate", ExternalToolPhase::Started, true), + ack("tc-duplicate", ExternalToolPhase::Started, false), + ] + ); + let (_, status, payload) = tool_call_row(&pool, "tc-duplicate").await.unwrap(); + assert_eq!(status, "errored"); + assert_eq!( + serde_json::from_str::(&payload.unwrap()).unwrap(), + TranscriptToolResult::interrupted() + ); + } + + #[tokio::test] + async fn finish_without_start_is_nacked_and_terminates_the_run() { + let (pool, _run_id, exit, acks, _shutdowns) = drive_protocol_frames(vec![ + external_finished("tc-missing", "result", false), + WorkerStdout::Done, + ]) + .await; + + assert_eq!( + exit, + Exit::Errored(WORKER_PROTOCOL_FAILED_MESSAGE.to_string()) + ); + assert_eq!( + acks, + vec![ack("tc-missing", ExternalToolPhase::Finished, false)] + ); + assert!(tool_call_row(&pool, "tc-missing").await.is_none()); + } + + #[tokio::test] + async fn duplicate_external_finish_is_nacked_without_clobbering_the_result() { + let (pool, _run_id, exit, acks, _shutdowns) = drive_protocol_frames(vec![ + external_started("tc-finished", "ticktick_search_task"), + external_finished("tc-finished", "first", false), + external_finished("tc-finished", "second", false), + WorkerStdout::Done, + ]) + .await; + + assert_eq!( + exit, + Exit::Errored(WORKER_PROTOCOL_FAILED_MESSAGE.to_string()) + ); + assert_eq!( + acks, + vec![ + ack("tc-finished", ExternalToolPhase::Started, true), + ack("tc-finished", ExternalToolPhase::Finished, true), + ack("tc-finished", ExternalToolPhase::Finished, false), + ] + ); + let (_, status, payload) = tool_call_row(&pool, "tc-finished").await.unwrap(); + assert_eq!(status, "completed"); + assert_eq!( + serde_json::from_str::(&payload.unwrap()).unwrap(), + TranscriptToolResult::text("first", false) + ); + } + + /// Fault injection: a DB fault on the external BEGIN stops the Worker, then + /// every terminal write fails against the same closed pool. The loop reports + /// fatal persistence and retains the hub for the process-level recovery path. + #[tokio::test] + async fn begin_persist_fault_stops_the_worker() { + let pool = memory_pool().await; + let wf = test_workflow(&[]); + let (run_id, _t, amid) = seed_run(&pool, &wf).await; + let (hubs, run_hub) = fixtures(run_id); + let (worker, _sent, shutdowns) = ScriptedWorker::new(vec![ + external_started("tc-fault", "ticktick_filter_tasks"), + WorkerStdout::TextDelta { + delta: "never persisted".to_string(), + }, + WorkerStdout::Done, + ]); + + pool.close().await; + let exit = run_loop( + worker, + run_id, + wf, + pool.clone(), + amid, + hubs.clone(), + run_hub.clone(), + ) + .await; + + assert_eq!( + exit, + Exit::FatalPersistence, + "a terminal write failure is process-fatal" + ); + assert!( + crate::hub::get(&hubs, run_id).is_some(), + "the undrained generation remains registered" + ); + assert!( + *shutdowns.lock().unwrap() >= 1, + "the Worker was shut down at the fault" + ); + } +} \ No newline at end of file diff --git a/crates/core/src/worker/liveness.rs b/crates/core/src/worker/liveness.rs index fc082ef2..839af9c0 100644 --- a/crates/core/src/worker/liveness.rs +++ b/crates/core/src/worker/liveness.rs @@ -78,28 +78,37 @@ pub(crate) async fn probe(provider: &str, model: &str) -> ProviderTestResult { role: crate::launch::Role::Worker, }, probe_timeout(), - |worker| Box::pin(async move { - loop { - match worker.recv().await { - // Any output — a streamed delta or a clean finish — proves the - // provider answered. - Some(crate::protocol::WorkerStdout::TextDelta { .. }) - | Some(crate::protocol::WorkerStdout::ReasoningDelta { .. }) - | Some(crate::protocol::WorkerStdout::Done) => return alive(), - // An explicit error frame is a failed turn: dead, carrying the - // provider's message (the auth/rate/model detail the user needs). - Some(crate::protocol::WorkerStdout::Error { message }) => return dead(message), - // The probe ships no tools; a tool_request is an unexpected turn. - Some(crate::protocol::WorkerStdout::ToolRequest { .. }) => { - return dead( - "worker requested a tool during the liveness probe".to_string(), - ); + |worker| { + Box::pin(async move { + loop { + match worker.recv().await { + // Any output — a streamed delta or a clean finish — proves the + // provider answered. + Ok(Some(crate::protocol::WorkerStdout::TextDelta { .. })) + | Ok(Some(crate::protocol::WorkerStdout::ReasoningDelta { .. })) + | Ok(Some(crate::protocol::WorkerStdout::Done)) => return alive(), + // An explicit error frame is a failed turn: dead, carrying the + // provider's message (the auth/rate/model detail the user needs). + Ok(Some(crate::protocol::WorkerStdout::Error { message })) => { + return dead(message); + } + // The probe ships no tools; any tool frame is an unexpected turn. + Ok(Some(crate::protocol::WorkerStdout::ToolRequest { .. })) + | Ok(Some(crate::protocol::WorkerStdout::ExternalToolStarted { .. })) + | Ok(Some(crate::protocol::WorkerStdout::ExternalToolFinished { + .. + })) => { + return dead( + "worker requested a tool during the liveness probe".to_string(), + ); + } + // EOF before any reply: the Worker died without answering. + Ok(None) => return dead("worker closed without a reply".to_string()), + Err(()) => return dead("worker emitted an invalid frame".to_string()), } - // EOF before any reply: the Worker died without answering. - None => return dead("worker closed without a reply".to_string()), } - } - }), + }) + }, ) .await; diff --git a/crates/core/src/worker/mod.rs b/crates/core/src/worker/mod.rs index 6b372e8a..cd562a23 100644 --- a/crates/core/src/worker/mod.rs +++ b/crates/core/src/worker/mod.rs @@ -12,24 +12,36 @@ mod liveness; mod oneshot; mod port; mod run; +mod external; +#[cfg(test)] +mod test_support; mod title; pub use title::spawn_title_generation; // The provider/test handler (`crate::runs::provider`) drives the synchronous // liveness probe (ADR-0062) — a one-shot non-Run Worker, sibling to the titler. pub(crate) use liveness::probe as probe_liveness; +// The interrupted-external-call publisher (external-task-views A4), shared by +// the run loop's terminal branch and `run/cancel`'s post-response publish. +pub(crate) use external::publish_interrupted; +pub(crate) use run::WORKER_DISCONNECTED_MESSAGE; use sqlx::SqlitePool; +use std::future::Future; +use std::time::Duration; use tracing::Instrument; use uuid::Uuid; use crate::db; use crate::hub::{self, Hubs, RunHub}; -use crate::protocol::{ManifestAttachment, ManifestMessage, WorkerManifest, WorkflowManifest}; +use crate::protocol::{ + ExternalToolsManifest, ManifestAttachment, ManifestMessage, WorkerManifest, WorkflowManifest, +}; use crate::workflow::Workflow; use crate::launch::{self, Role}; use child::ChildWorker; +use port::Exit; use run::run_loop; /// Resolve the Worker launch command (ADR-0041): the `INKSTONE_WORKER_CMD` @@ -94,13 +106,13 @@ pub fn spawn(m: SpawnManifest) { // token resolution the build performs. `drive`'s own entry check // re-tests after the build. if run_hub.is_cancelled() { - hub::remove(&hubs, run_id); + hub::retire(&hubs, run_id, &run_hub).await; return; } let Some(line) = fresh_manifest_line(run_id, &workflow, &prompt, &history, attachments).await else { - finalize_error(&pool, &hubs, run_id).await; + terminate_if_fatal(run_id, finalize_error(&pool, &hubs, run_id, &run_hub).await); return; }; drive( @@ -119,8 +131,9 @@ pub fn spawn(m: SpawnManifest) { } /// Resume a parked Run after its Proposal is decided (ADR-0025). Reconstructs -/// the transcript, flips `parked → running` (self-guarded — bails if another -/// resume won the race), creates a fresh per-run hub, and hands the pre-built +/// the transcript, registers a fresh per-run hub THEN flips `parked → running` +/// (hub::activate — a concurrent resume backs off; a lost CAS deregisters), and +/// hands the pre-built /// `mode:"resume"` manifest to [`drive`]. /// /// Errors only on a pre-spawn failure (assistant message missing). The atomic @@ -155,15 +168,23 @@ pub async fn resume(run_id: Uuid, pool: &SqlitePool, hubs: &Hubs) -> anyhow::Res anyhow::bail!("resume manifest build failed for run {run_id} (token resolution)"); }; - // Flip parked → running before creating the hub/spawning. Self-guarded on - // `status = 'parked'`: if 0 rows matched, another resume won the race — - // bail so exactly one resume Worker runs. - let flipped = db::mark_run_running(pool, run_id).await?; - if !flipped.won() { + // Activate through the shared registry operation (review R8 #1): register + // the hub FIRST (first-wins — a concurrent resume backs off instead of + // replacing this registration), THEN the parked→running CAS. A Run is + // observably `running` only while its producer's hub is reachable, so a + // concurrent `run/cancel` that reads `running` always finds THIS hub and + // signals it — the no-hub branch can never apply to a live Run. On a lost + // CAS (a cancel flipped the parked Run first) or a CAS fault, `activate` + // removes the registration identity-checked and this resume backs off; the + // CAS stays the exactly-one-resume choke. + let activated = hub::activate(hubs, run_id, || async { + Ok::<_, anyhow::Error>(db::mark_run_running(pool, run_id).await?.won()) + }) + .await?; + let Some(run_hub) = activated else { return Ok(()); - } + }; - let run_hub = hub::create(hubs, run_id); let pool = pool.clone(); let hubs = hubs.clone(); // Correlation span (ADR-0038), mirroring `spawn`: `run_id` reaches the @@ -213,21 +234,19 @@ async fn drive( run_hub: RunHub, ) { if run_hub.is_cancelled() { - hub::remove(&hubs, run_id); + hub::retire(&hubs, run_id, &run_hub).await; return; } let Some(cmd) = resolve_worker_cmd(run_id) else { - finalize_error(&pool, &hubs, run_id).await; + terminate_if_fatal(run_id, finalize_error(&pool, &hubs, run_id, &run_hub).await); return; }; - match ChildWorker::spawn(run_id, &cmd.program, &cmd.args, line).await { + + let exit = match ChildWorker::spawn(run_id, &cmd.program, &cmd.args, line).await { Ok(worker) => { - // Post-spawn cancel check (the union addition for resume): the - // cancel won while the child was spawning, so drop it before the - // loop ever runs — `kill_on_drop` reaps it, no orphan Worker. if run_hub.is_cancelled() { drop(worker); - hub::remove(&hubs, run_id); + hub::retire(&hubs, run_id, &run_hub).await; return; } run_loop( @@ -239,17 +258,62 @@ async fn drive( hubs, run_hub, ) - .await; + .await + } + Err(()) => finalize_error(&pool, &hubs, run_id, &run_hub).await, + }; + terminate_if_fatal(run_id, exit); +} + +/// Terminal persistence is a process invariant: after every write attempt +/// fails, ask the server owner to close sockets and stop the process. Recovery +/// sees the durable `running` row; no in-memory hub survives the restart. +fn terminate_if_fatal(run_id: Uuid, exit: Exit) { + if exit == Exit::FatalPersistence { + tracing::error!(event = "worker.fatal_terminal_persistence", %run_id); + crate::shutdown::request(); + } +} + +const TERMINAL_PERSIST_RETRY_DELAYS: [Duration; 2] = + [Duration::from_millis(50), Duration::from_millis(200)]; + +/// Try the intended terminal transition twice, then its generic error fallback. +/// The bounded pauses give transient SQLite write contention time to clear. +pub(super) async fn persist_terminal_with_retry( + run_id: Uuid, + mut persist: F, + fallback: G, +) -> sqlx::Result +where + F: FnMut() -> Fut, + Fut: Future>, + G: FnOnce() -> GFut, + GFut: Future>, +{ + for (attempt, delay) in TERMINAL_PERSIST_RETRY_DELAYS.into_iter().enumerate() { + match persist().await { + Ok(terminal) => return Ok(terminal), + Err(error) => tracing::error!( + event = "worker.terminal_tx_failed", + %run_id, + attempt = attempt + 1, + retry_in_ms = delay.as_millis() as u64, + error = ?error + ), } - // A pre-loop spawn failure finalizes the Run `errored`. For a fresh - // Run this is the ordinary pre-spawn failure path. For a resume it is - // the rare residual case — a post-flip spawn failure (the realistic - // token/manifest failure is handled before the flip): the decide RPC - // already reported success, so re-parking would leave a decided card - // over a silently hung turn. Finalizing `errored` keeps the failure - // visible and the user can re-send. - Err(()) => finalize_error(&pool, &hubs, run_id).await, + tokio::time::sleep(delay).await; } + + fallback().await.map_err(|error| { + tracing::error!( + event = "worker.terminal_persist_lost", + %run_id, + attempt = 3, + error = ?error + ); + error + }) } /// Build the fresh-spawn manifest line (ADR-0018): Workflow fields, prompt, @@ -279,6 +343,7 @@ async fn fresh_manifest_line( // skill; resume uses the plain `augmented_system_prompt`. Bound here to outlive // the borrowing manifest. let system_prompt = crate::skills::augmented_system_prompt_with_trigger(workflow, prompt); + let endpoint = external_tools_endpoint(workflow); let manifest = WorkerManifest { run_id, workflow: workflow_manifest(workflow, &system_prompt), @@ -287,6 +352,7 @@ async fn fresh_manifest_line( mode: None, access_token: access_token.as_deref(), attachments: (!attachments.is_empty()).then_some(attachments), + external_tools: external_tools_manifest(&endpoint), }; Some(serialize_manifest(&manifest)) } @@ -301,6 +367,7 @@ async fn resume_manifest_line( let messages: Vec = transcript.iter().map(crate::resume::Block::as_message).collect(); let access_token = resolve_token(run_id, workflow).await?; let system_prompt = crate::skills::augmented_system_prompt(workflow); + let endpoint = external_tools_endpoint(workflow); let manifest = WorkerManifest { run_id, workflow: workflow_manifest(workflow, &system_prompt), @@ -312,10 +379,37 @@ async fn resume_manifest_line( // only the CURRENT turn's attachments ever reach a model, and a resumed // Run's turn already started without them. attachments: None, + // Resume keeps the external tools reachable (the resumed model may call + // more of them); the auth comes from the SAME boot-read state (A5). + external_tools: external_tools_manifest(&endpoint), }; Some(serialize_manifest(&manifest)) } +/// The MCP endpoint for this spawn, resolved only when the Workflow opts into +/// external tools (external-task-views A3) — owned by the caller so the +/// borrowing manifest can reference it. +fn external_tools_endpoint(workflow: &Workflow) -> Option { + workflow.external_tools.then(crate::ticktick::mcp_endpoint) +} + +/// The manifest's external-tool config (A3/A5): present iff the Workflow opted +/// in AND a TickTick credential loaded at boot — a dark lane never ships +/// endpoint or auth. +fn external_tools_manifest<'a>(endpoint: &'a Option) -> Option> { + let endpoint = endpoint.as_deref()?; + let connection = crate::ticktick::connection()?; + Some(ExternalToolsManifest { + endpoint, + access_token: &connection.access_token, + timeout_ms: crate::config::get() + .ticktick_timeout + .as_millis() + .try_into() + .expect("TickTick timeout milliseconds fit u64"), + }) +} + /// Build the `WorkflowManifest` (ADR-0018). `system_prompt` is passed in (not /// taken from `workflow`) because it carries the per-spawn `` /// injection (ADR-0036) and must be owned by the caller to outlive this borrow. @@ -365,14 +459,29 @@ async fn pre_spawn_delay_if_configured() { } } -/// Pre-loop spawn-failure path: the Worker produced no output, so terminate the -/// Run as `worker_disconnected` (ADR-0017) and remove the hub so a subscriber -/// falls through to the persisted snapshot + `done`. -async fn finalize_error(pool: &SqlitePool, hubs: &Hubs, run_id: Uuid) { - if let Err(e) = db::error_run(pool, run_id, db::now_ms()).await { - tracing::error!(event = "worker.error_run_failed", %run_id, error = ?e); +/// Pre-loop spawn-failure path. The terminal write and hub drain use the same +/// lifecycle → hub-gate order as the run loop. A repeated persistence failure +/// retains the hub while the driver requests Core's degraded shutdown. +async fn finalize_error(pool: &SqlitePool, hubs: &Hubs, run_id: Uuid, run_hub: &RunHub) -> Exit { + let lifecycle = hub::lifecycle(hubs, run_id).await; + let guard = run_hub.gate().await; + let now_ms = db::now_ms(); + let result = persist_terminal_with_retry( + run_id, + || db::error_run(pool, run_id, now_ms), + || db::error_run(pool, run_id, now_ms), + ) + .await; + + if result.is_err() { + drop(guard); + drop(lifecycle); + return Exit::FatalPersistence; } - crate::hub::remove(hubs, run_id); + hub::remove_own(hubs, run_id, run_hub, &lifecycle); + drop(guard); + drop(lifecycle); + Exit::Disconnected } #[cfg(test)] @@ -388,9 +497,32 @@ mod tests { system_prompt: "Base prompt.".to_string(), thinking_level: Some("off".to_string()), tools: tools.iter().map(|s| s.to_string()).collect(), + external_tools: false, } } + #[tokio::test] + async fn finalize_error_keeps_the_hub_when_terminal_persistence_fails() { + let pool = crate::db::test_support::memory_pool().await; + let wf = super::test_support::test_workflow(&[]); + let (run_id, _thread_id, _assistant_id) = super::test_support::seed_run(&pool, &wf).await; + let (hubs, run_hub) = super::test_support::fixtures(run_id); + + pool.close().await; + let started = tokio::time::Instant::now(); + let exit = finalize_error(&pool, &hubs, run_id, &run_hub).await; + + assert_eq!(exit, Exit::FatalPersistence); + assert!( + started.elapsed() >= Duration::from_millis(250), + "the retry ladder includes its 50ms and 200ms backoffs" + ); + assert!( + hub::get(&hubs, run_id).is_some(), + "a failed terminal write must not drain the live generation" + ); + } + /// The manifest builder injects the scanned skills' name+description into the /// `system_prompt` AND ships `load_skill` in `tools`, even when the Workflow's /// own allowlist omits it — the two ADR-0036 gates, asserted together on the @@ -483,7 +615,7 @@ mod tests { .expect("seed run"); let hubs = hub::new_hubs(); - let run_hub = hub::create(&hubs, run_id); + let run_hub = hub::register(&hubs, run_id).expect("fresh run registers"); // The cancel wins BEFORE the driver reaches the child spawn. run_hub.cancel(); @@ -514,6 +646,65 @@ mod tests { ); } + /// The manifest carries `external_tools` (endpoint + auth) IFF the + /// Workflow opted in AND a TickTick credential loaded at boot + /// (external-task-views A3/A5) — a dark lane never ships endpoint or auth, + /// and resume uses the SAME boot-read state as fresh. + #[tokio::test] + async fn manifest_ships_external_tools_iff_flag_and_credential() { + let config = crate::config::test_override::install(crate::config::Config { + ticktick_mcp_url_override: Some("http://127.0.0.1:1/mcp".to_string()), + ..Default::default() + }); + + let manifest_json = |line: Option| -> serde_json::Value { + serde_json::from_str(line.expect("manifest builds").trim_end()) + .expect("manifest line is JSON") + }; + + // Flag ON + credential present → endpoint (the test override) + token. + let connected = crate::ticktick::token::test_override::install(Some( + crate::ticktick::token::test_override::test_connection("tok_ticktick", "conn-e2e"), + )); + let wf = Workflow { + external_tools: true, + ..workflow(&[]) + }; + let line = fresh_manifest_line(Uuid::now_v7(), &wf, "hi", &[], Vec::new()).await; + let manifest = manifest_json(line); + assert_eq!( + manifest["external_tools"], + serde_json::json!({ + "endpoint": "http://127.0.0.1:1/mcp", + "access_token": "tok_ticktick", + "timeout_ms": 30_000 + }) + ); + // Resume ships the same config from the same boot-read state. + let line = resume_manifest_line(Uuid::now_v7(), &wf, &[]).await; + let manifest = manifest_json(line); + assert_eq!(manifest["external_tools"]["access_token"], "tok_ticktick"); + + // Flag OFF (credential still present) → absent. + let dark = workflow(&[]); + let line = fresh_manifest_line(Uuid::now_v7(), &dark, "hi", &[], Vec::new()).await; + assert!( + manifest_json(line).get("external_tools").is_none(), + "a Workflow that did not opt in ships no endpoint/auth" + ); + drop(connected); + + // Flag ON but NO credential → absent. + let disconnected = crate::ticktick::token::test_override::install(None); + let line = fresh_manifest_line(Uuid::now_v7(), &wf, "hi", &[], Vec::new()).await; + assert!( + manifest_json(line).get("external_tools").is_none(), + "no boot-read credential → no external tools" + ); + drop(disconnected); + drop(config); + } + /// With no skills dir, the prompt is left untouched (no empty block) but /// `load_skill` is still ambiently shipped — disclosure degrades, activation /// does not. diff --git a/crates/core/src/worker/oneshot.rs b/crates/core/src/worker/oneshot.rs index a825fcfc..a4c25ca9 100644 --- a/crates/core/src/worker/oneshot.rs +++ b/crates/core/src/worker/oneshot.rs @@ -105,8 +105,9 @@ where messages: vec![], mode: None, access_token: spec.access_token, - // One-shot workers (titler, liveness probe) are text-only. + // One-shot workers (titler, liveness probe) are text-only, no tools. attachments: None, + external_tools: None, }; let manifest_line = super::serialize_manifest(&manifest); diff --git a/crates/core/src/worker/port.rs b/crates/core/src/worker/port.rs index 94ea0ebf..01f94a61 100644 --- a/crates/core/src/worker/port.rs +++ b/crates/core/src/worker/port.rs @@ -8,7 +8,7 @@ use std::future::Future; -use crate::protocol::{ToolResult, WorkerStdout}; +use crate::protocol::{ExternalToolAck, ToolResult, WorkerStdout}; /// Which terminal branch the run loop took, so callers and tests can assert the /// outcome. The loop commits the matching terminal transaction itself, except @@ -29,20 +29,29 @@ pub(crate) enum Exit { /// Core accepted cancellation and signalled the live Worker. The terminal /// transaction and `cancelled` event were owned by `run/cancel`. Cancelled, + /// Every attempt to persist the terminal transition failed. The hub remains + /// registered and the driver terminates Core so boot recovery can settle it. + FatalPersistence, } /// Everything Core's run loop needs from a spawned Worker (ADR-0026). Futures /// are `Send` so the generic loop can run inside `tokio::spawn`. pub(crate) trait WorkerPort { - /// The next Worker stdout frame, or `None` once stdout closes (EOF) or - /// faults. The adapter skips frames that fail to decode, so the loop only - /// sees well-formed [`WorkerStdout`] values. - fn recv(&mut self) -> impl Future> + Send; + /// The next Worker stdout frame, `Ok(None)` on EOF, or `Err(())` when + /// stdout faults or a frame violates the protocol. Invalid frames terminate + /// the Run; they are never skipped. + fn recv(&mut self) -> impl Future, ()>> + Send; /// Write a Tool Result back over the Worker's kept-open stdin (ADR-0013). /// A no-op once the Worker has been shut down. fn send_tool_result(&mut self, result: ToolResult) -> impl Future + Send; + /// Acknowledge one external lifecycle frame after its durable transition. + fn send_external_tool_ack( + &mut self, + ack: ExternalToolAck, + ) -> impl Future> + Send; + /// Shut the Worker down — drop stdin so the Worker sees EOF and exits /// (ADR-0013). Idempotent. fn shutdown(&mut self) -> impl Future + Send; diff --git a/crates/core/src/worker/run.rs b/crates/core/src/worker/run.rs index 3040de8f..14ff93f4 100644 --- a/crates/core/src/worker/run.rs +++ b/crates/core/src/worker/run.rs @@ -7,6 +7,7 @@ use sqlx::SqlitePool; use uuid::Uuid; +use super::external::{self, FrameFailure, publish_interrupted}; use super::port::{Exit, WorkerPort}; use crate::db; use crate::db::TerminalReason; @@ -16,6 +17,11 @@ use crate::protocol::{ }; use crate::workflow::Workflow; +/// The live `RunEvent::Error` message published when the Worker's stdout closed +/// without a `done` (it died/was killed/hung up). Mirrors the persisted +/// `error_message` (`error_run`) so a live tail and a reload agree. +pub(crate) const WORKER_DISCONNECTED_MESSAGE: &str = "worker exited without emitting done event"; + /// Drive a spawned Worker to a terminal state. Appends each `text_delta` under /// the per-run gate (ADR-0022), executes or parks `tool_request`s /// (ADR-0018/0025), commits the terminal tx unless the Run parked, publishes @@ -46,26 +52,32 @@ pub(super) async fn run_loop( // both at `None`, so the post-resume reply opens its own segment. let mut open_text_part: Option = None; let mut open_reasoning_part: Option = None; - if *cancel_rx.borrow() { worker.shutdown().await; cancelled_by_core = true; } while !cancelled_by_core { - let Some(msg) = (tokio::select! { + let received = tokio::select! { changed = cancel_rx.changed() => { if changed.is_ok() && *cancel_rx.borrow() { worker.shutdown().await; cancelled_by_core = true; - None + Ok(None) } else { continue; } } msg = worker.recv() => msg, - }) else { - break; + }; + let msg = match received { + Ok(Some(msg)) => msg, + Ok(None) => break, + Err(()) => { + worker_error = Some(external::WORKER_PROTOCOL_FAILED_MESSAGE.to_string()); + worker.shutdown().await; + break; + } }; if *cancel_rx.borrow() { worker.shutdown().await; @@ -142,9 +154,21 @@ pub(super) async fn run_loop( worker.shutdown().await; break; } + let lifecycle = crate::hub::lifecycle(&hubs, run_id).await; let guard = run_hub.gate().await; + if *cancel_rx.borrow() { + drop(guard); + drop(lifecycle); + worker.shutdown().await; + cancelled_by_core = true; + break; + } parked = park_on_proposal(&pool, run_id, &tool_call_id, &name, ¶ms).await; + if parked { + crate::hub::remove_own(&hubs, run_id, &run_hub, &lifecycle); + } drop(guard); + drop(lifecycle); worker.shutdown().await; break; } @@ -166,6 +190,7 @@ pub(super) async fn run_loop( name: name.clone(), status: ToolCallStatus::Started, arg: arg.clone(), + result: None, }); drop(guard); @@ -187,6 +212,7 @@ pub(super) async fn run_loop( ToolOutcome::Err { .. } => ToolCallStatus::Error, }, arg, + result: None, }); drop(guard); @@ -203,60 +229,123 @@ pub(super) async fn run_loop( } worker.send_tool_result(result).await; } + // External MCP execution is a durable request/ack protocol. pi + // awaits each event sink, so the started ACK gates execution and the + // finished ACK gates the next model turn. + WorkerStdout::ExternalToolStarted { + tool_call_id, + name, + arguments, + } => { + open_text_part = None; + open_reasoning_part = None; + if let Err(failure) = external::handle_started( + &mut worker, + &pool, + run_id, + &run_hub, + &tool_call_id, + &name, + &arguments, + ) + .await + { + match failure { + FrameFailure::Cancelled => cancelled_by_core = true, + FrameFailure::Terminal(message) => worker_error = Some(message.to_string()), + } + worker.shutdown().await; + break; + } + } + WorkerStdout::ExternalToolFinished { + tool_call_id, + result, + } => { + if let Err(failure) = external::handle_finished( + &mut worker, + &pool, + run_id, + &run_hub, + &tool_call_id, + result, + ) + .await + { + match failure { + FrameFailure::Cancelled => cancelled_by_core = true, + FrameFailure::Terminal(message) => worker_error = Some(message.to_string()), + } + worker.shutdown().await; + break; + } + } } } - // Terminal-state tx (ADR-0017 atomic recovery). A worker-emitted `error` - // takes precedence over EOF-without-done and carries its message. Park - // (ADR-0025) short-circuits this entirely (it is non-terminal). + // Terminal-state tx (ADR-0017 atomic recovery). A lifecycle guard pins this + // generation while the hub gate makes settle + publish + removal atomic with + // subscribers. Park and cancellation are drained by their owning paths. if !parked && !cancelled_by_core { + let lifecycle = crate::hub::lifecycle(&hubs, run_id).await; + let guard = run_hub.gate().await; let now_ms = db::now_ms(); - let result = if let Some(ref message) = worker_error { - db::error_run_with_message( - &pool, - run_id, - TerminalReason::Errored, - "worker_error", - message, - now_ms, - ) - .await - } else if saw_done { - db::complete_run(&pool, run_id, now_ms).await - } else { - db::error_run(&pool, run_id, now_ms).await + let persist = || async { + if let Some(ref message) = worker_error { + db::error_run_with_message( + &pool, + run_id, + TerminalReason::Errored, + "worker_error", + message, + now_ms, + ) + .await + } else if saw_done { + db::complete_run(&pool, run_id, now_ms).await + } else { + db::error_run(&pool, run_id, now_ms).await + } }; - if let Err(ref e) = result { - tracing::error!(event = "worker.terminal_tx_failed", %run_id, error = ?e); - } - // Publish the terminal Run Event ONLY AFTER this loop's terminal tx - // wins. If cancellation already committed, the guarded transition loses - // and `run/cancel` owns the terminal `cancelled` event. Ungated - // `RunHub::send`: the commit itself orders this publish (a subscriber - // snapshotting concurrently reads the terminal status from tier 2). - match result { - Ok(moved) if moved.won() => match (&worker_error, saw_done) { - (Some(message), _) => { - run_hub.send(RunEvent::Error { - message: message.clone(), - }); - } - (None, true) => { - run_hub.send(RunEvent::Done); - } - (None, false) => {} - }, - _ => {} + let result = super::persist_terminal_with_retry(run_id, persist, || { + db::error_run(&pool, run_id, now_ms) + }) + .await; + + let terminal = match result { + Ok(terminal) => terminal, + Err(_) => { + // Keep this generation registered while Core closes sockets. + // Restart recovery settles the durable `running` row. + drop(guard); + drop(lifecycle); + return Exit::FatalPersistence; + } + }; + + if let db::Terminal::Won { interrupted } = terminal { + publish_interrupted(&run_hub, interrupted); + match (&worker_error, saw_done) { + (Some(message), _) => run_hub.send(RunEvent::Error { + message: message.clone(), + }), + (None, true) => run_hub.send(RunEvent::Done), + (None, false) => run_hub.send(RunEvent::Error { + message: WORKER_DISCONNECTED_MESSAGE.to_string(), + }), + } } + crate::hub::remove_own(&hubs, run_id, &run_hub, &lifecycle); + drop(guard); + drop(lifecycle); + } else { + // Cancellation owns its durable transition; parking removed the hub in + // the guarded park block. Identity-checked retirement is a no-op if the + // owning path already drained this generation. + crate::hub::retire(&hubs, run_id, &run_hub).await; } - // Remove the hub after publishing the terminal event so attached - // subscribers observe the channel close once they have drained the tail. - // `worker` drops on return; the child is `kill_on_drop`, so no orphan - // outlives the Run. - crate::hub::remove(&hubs, run_id); - if cancelled_by_core { Exit::Cancelled } else if parked { @@ -270,6 +359,7 @@ pub(super) async fn run_loop( } } + /// Handle one Tool Request (ADR-0018): enforce the Workflow's allowlist, /// persist the call, dispatch to the tool registry, persist the outcome, and /// return the `ToolOutcome`. A tool not allowlisted (or not registered) is @@ -468,149 +558,9 @@ async fn park_on_proposal( #[cfg(test)] mod tests { use crate::db::test_support::memory_pool; - use std::collections::VecDeque; - use std::sync::{Arc, Mutex}; - use tokio::sync::broadcast; use super::*; - - fn test_workflow(tools: &[&str]) -> Workflow { - Workflow { - name: "test".to_string(), - version: "1".to_string(), - provider: "faux".to_string(), - model: Some("m".to_string()), - system_prompt: "sp".to_string(), - thinking_level: Some("off".to_string()), - tools: tools.iter().map(|s| s.to_string()).collect(), - } - } - - /// Seed a Thread + initial Run (so an assistant row at seq 0 exists for - /// `run_loop` to append into). Returns `(run_id, thread_id, assistant_id)`. - async fn seed_run(pool: &SqlitePool, workflow: &Workflow) -> (Uuid, Uuid, Uuid) { - let thread_id = Uuid::now_v7(); - let run_id = Uuid::now_v7(); - let user_message_id = Uuid::now_v7(); - let assistant_message_id = Uuid::now_v7(); - db::persist_thread_with_first_run( - pool, - thread_id, - run_id, - user_message_id, - assistant_message_id, - workflow, - "prompt", - &[], - "t", - 1, - ) - .await - .expect("seed run"); - (run_id, thread_id, assistant_message_id) - } - - /// In-memory [`WorkerPort`]: yields scripted frames in order and records - /// the `tool_call_id` of every Tool Result sent back. `sent`/`shutdowns` - /// are shared so the test can inspect them after `run_loop` consumes it. - struct ScriptedWorker { - inbound: VecDeque, - sent: Arc>>, - shutdowns: Arc>, - } - - impl ScriptedWorker { - fn new(frames: Vec) -> (Self, Arc>>, Arc>) { - let sent = Arc::new(Mutex::new(Vec::new())); - let shutdowns = Arc::new(Mutex::new(0)); - let worker = Self { - inbound: frames.into(), - sent: sent.clone(), - shutdowns: shutdowns.clone(), - }; - (worker, sent, shutdowns) - } - } - - impl WorkerPort for ScriptedWorker { - async fn recv(&mut self) -> Option { - self.inbound.pop_front() - } - - async fn send_tool_result(&mut self, result: ToolResult) { - self.sent.lock().unwrap().push(result.tool_call_id); - } - - async fn shutdown(&mut self) { - *self.shutdowns.lock().unwrap() += 1; - } - } - - /// A [`WorkerPort`] that flips the run's cancel signal just before yielding - /// the frame at index `cancel_before`, forcing the loop's post-recv cancel - /// check to trip — the live-cancel-mid-stream race. Otherwise behaves like - /// [`ScriptedWorker`]. - struct CancelingWorker { - inbound: VecDeque, - hub: crate::hub::RunHub, - cancel_before: usize, - idx: usize, - sent: Arc>>, - shutdowns: Arc>, - } - - impl CancelingWorker { - fn new( - frames: Vec, - hub: crate::hub::RunHub, - cancel_before: usize, - ) -> (Self, Arc>>, Arc>) { - let sent = Arc::new(Mutex::new(Vec::new())); - let shutdowns = Arc::new(Mutex::new(0)); - let worker = Self { - inbound: frames.into(), - hub, - cancel_before, - idx: 0, - sent: sent.clone(), - shutdowns: shutdowns.clone(), - }; - (worker, sent, shutdowns) - } - } - - impl WorkerPort for CancelingWorker { - async fn recv(&mut self) -> Option { - if self.idx == self.cancel_before { - self.hub.cancel(); - } - self.idx += 1; - self.inbound.pop_front() - } - - async fn send_tool_result(&mut self, result: ToolResult) { - self.sent.lock().unwrap().push(result.tool_call_id); - } - - async fn shutdown(&mut self) { - *self.shutdowns.lock().unwrap() += 1; - } - } - - /// Drain a broadcast receiver into a Vec without blocking. - fn drain(rx: &mut broadcast::Receiver) -> Vec { - let mut events = Vec::new(); - while let Ok(event) = rx.try_recv() { - events.push(event); - } - events - } - - fn fixtures(run_id: Uuid) -> (Hubs, crate::hub::RunHub) { - let hubs = crate::hub::new_hubs(); - let run_hub = crate::hub::create(&hubs, run_id); - (hubs, run_hub) - } + use crate::worker::test_support::*; #[tokio::test] async fn done_marks_completed_and_persists_text() { @@ -644,11 +594,7 @@ mod tests { .map(db::RunStatus::as_str), Some("completed") ); - let snap = db::select_run_snapshot(&pool, run_id) - .await - .unwrap() - .unwrap(); - assert_eq!(snap.text, "hi"); + assert_eq!(run_text(&pool, run_id).await, "hi"); } #[tokio::test] @@ -688,6 +634,7 @@ mod tests { let wf = test_workflow(&[]); let (run_id, _t, amid) = seed_run(&pool, &wf).await; let (hubs, run_hub) = fixtures(run_id); + let mut tail = run_hub.subscribe_raw(); // One delta, then the script is exhausted → recv returns None (EOF). let (worker, _sent, _sd) = ScriptedWorker::new(vec![WorkerStdout::TextDelta { delta: "x".to_string(), @@ -712,6 +659,53 @@ mod tests { .map(db::RunStatus::as_str), Some("errored") ); + // EOF publishes a terminal `Error` (NOT nothing → NOT reported as + // `done` by subscribe): the Worker died, so a live tail sees the failure. + let events = drain(&mut tail); + assert!( + events.iter().any(|e| matches!(e, RunEvent::Error { .. })), + "EOF-without-done publishes a terminal Error event, got {events:?}" + ); + assert!( + !events.iter().any(|e| matches!(e, RunEvent::Done)), + "EOF is not reported as done" + ); + } + + #[tokio::test] + async fn invalid_worker_frame_terminates_instead_of_becoming_clean_eof() { + let pool = memory_pool().await; + let wf = test_workflow(&[]); + let (run_id, _thread_id, assistant_id) = seed_run(&pool, &wf).await; + let (hubs, run_hub) = fixtures(run_id); + let (worker, _sent, shutdowns, _acks) = ScriptedWorker::new_with_results(vec![Err(())]); + + let exit = run_loop( + worker, + run_id, + wf, + pool.clone(), + assistant_id, + hubs, + run_hub, + ) + .await; + + assert_eq!( + exit, + Exit::Errored(external::WORKER_PROTOCOL_FAILED_MESSAGE.to_string()) + ); + assert_eq!( + db::run_status(&pool, run_id) + .await + .unwrap() + .map(db::RunStatus::as_str), + Some("errored") + ); + assert!( + *shutdowns.lock().unwrap() >= 1, + "a protocol violation shuts the Worker down" + ); } #[tokio::test] @@ -750,41 +744,6 @@ mod tests { /// `message` step resolves its text from the specific `(message_id, part_seq)` /// part (ADR-0045); a `tool_call` step resolves the tool name. Read straight /// from tier 2 so the test pins the durable timeline, not a wire projection. - async fn run_steps_kinds_and_content(pool: &SqlitePool, run_id: Uuid) -> Vec<(String, String)> { - let rows: Vec<(String, Option, Option, Option)> = sqlx::query_as( - "SELECT rs.kind, rs.message_id, rs.part_seq, tc.name \ - FROM run_steps rs \ - LEFT JOIN tool_calls tc ON tc.id = rs.tool_call_id \ - WHERE rs.run_id = ?1 ORDER BY rs.seq", - ) - .bind(run_id.to_string()) - .fetch_all(pool) - .await - .expect("read run_steps"); - - let mut out = Vec::with_capacity(rows.len()); - for (kind, message_id, part_seq, tc_name) in rows { - match kind.as_str() { - "message" => { - let message_id = message_id.expect("message step has a message_id"); - let part_seq = part_seq.expect("message step resolves a specific text part"); - let text: String = sqlx::query_scalar( - "SELECT text FROM message_parts WHERE message_id = ?1 AND seq = ?2", - ) - .bind(&message_id) - .bind(part_seq) - .fetch_one(pool) - .await - .expect("message step's part exists"); - out.push(("message".to_string(), text)); - } - "tool_call" => out.push(("tool_call".to_string(), tc_name.unwrap_or_default())), - other => panic!("unexpected run_step kind {other:?}"), - } - } - out - } - /// Within ONE Run, assistant text emitted AFTER a tool call is sequenced /// AFTER that tool call in `run_steps` (ADR-0045). A scripted Run streams /// text, calls a tool, then streams more text: the durable timeline must read @@ -842,11 +801,7 @@ mod tests { // The wire shape is unchanged: the snapshot/thread-get concat read still // returns the assistant's full reply across both parts, in order. - let snap = db::select_run_snapshot(&pool, run_id) - .await - .unwrap() - .unwrap(); - assert_eq!(snap.text, "let me look found it"); + assert_eq!(run_text(&pool, run_id).await, "let me look found it"); } /// One `run_steps` `message` row resolved to `(message_parts.type, text)`, @@ -963,11 +918,7 @@ mod tests { ); // The text-only concat read (snapshot) excludes reasoning entirely. - let snap = db::select_run_snapshot(&pool, run_id) - .await - .unwrap() - .unwrap(); - assert_eq!(snap.text, "Plan: Done"); + assert_eq!(run_text(&pool, run_id).await, "Plan: Done"); } /// `text → reasoning → text → reasoning` with NO tool between: the type-switch @@ -1031,11 +982,7 @@ mod tests { ); // Reply text is the two text runs concatenated; reasoning excluded. - let snap = db::select_run_snapshot(&pool, run_id) - .await - .unwrap() - .unwrap(); - assert_eq!(snap.text, "AC"); + assert_eq!(run_text(&pool, run_id).await, "AC"); } #[tokio::test] @@ -1404,12 +1351,9 @@ mod tests { drive_to_errored_with_partial(&pool, run_id, amid, &wf).await; // The failed attempt persisted partial text. - let before = db::select_run_snapshot(&pool, run_id) - .await - .unwrap() - .unwrap(); assert_eq!( - before.text, "half ", + run_text(&pool, run_id).await, + "half ", "the failed attempt streamed partial text" ); @@ -1444,12 +1388,9 @@ mod tests { // The failed parts are GONE — snapshot text empty, no stale message_parts / // run_steps for the assistant message. - let after = db::select_run_snapshot(&pool, run_id) - .await - .unwrap() - .unwrap(); assert_eq!( - after.text, "", + run_text(&pool, run_id).await, + "", "the failed partial text was cleared, not carried" ); @@ -1702,11 +1643,11 @@ mod tests { // But the failed attempt's partial assistant text IS cleared, and the Run is // back to running with a streaming assistant Message. - let snap = db::select_run_snapshot(&pool, run_id) - .await - .unwrap() - .unwrap(); - assert_eq!(snap.text, "", "the failed partial text was cleared"); + assert_eq!( + run_text(&pool, run_id).await, + "", + "the failed partial text was cleared" + ); assert_eq!( db::run_status(&pool, run_id) .await @@ -1763,12 +1704,9 @@ mod tests { .map(db::RunStatus::as_str), Some("completed") ); - let snap = db::select_run_snapshot(&pool, run_id) - .await - .unwrap() - .unwrap(); assert_eq!( - snap.text, "full answer", + run_text(&pool, run_id).await, + "full answer", "only the retry's text, not concatenated" ); assert_eq!(message_role_counts(&pool, run_id).await, (1, 1)); diff --git a/crates/core/src/worker/test_support.rs b/crates/core/src/worker/test_support.rs new file mode 100644 index 00000000..fb6e6809 --- /dev/null +++ b/crates/core/src/worker/test_support.rs @@ -0,0 +1,280 @@ +//! Shared `#[cfg(test)]` scaffolding for the Worker module: the scripted/cancel +//! `WorkerPort` fakes and the seed/inspect helpers used by BOTH the run-loop +//! frame-orchestration tests (worker/run.rs) and the external-call lifecycle +//! tests (worker/external.rs), so neither file re-derives them (review #3). +#![cfg(test)] + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use sqlx::SqlitePool; +use tokio::sync::broadcast; +use uuid::Uuid; + +use super::port::WorkerPort; +use crate::db; +use crate::hub::Hubs; +use crate::protocol::{ExternalToolAck, RunEvent, ToolResult, TranscriptToolResult, WorkerStdout}; +use crate::workflow::Workflow; + +pub(crate) fn test_workflow(tools: &[&str]) -> Workflow { + Workflow { + name: "test".to_string(), + version: "1".to_string(), + provider: "faux".to_string(), + model: Some("m".to_string()), + system_prompt: "sp".to_string(), + thinking_level: Some("off".to_string()), + tools: tools.iter().map(|s| s.to_string()).collect(), + external_tools: false, + } +} + +/// Seed a Thread + initial Run (so an assistant row at seq 0 exists for +/// `run_loop` to append into). Returns `(run_id, thread_id, assistant_id)`. +pub(crate) async fn seed_run(pool: &SqlitePool, workflow: &Workflow) -> (Uuid, Uuid, Uuid) { + let thread_id = Uuid::now_v7(); + let run_id = Uuid::now_v7(); + let user_message_id = Uuid::now_v7(); + let assistant_message_id = Uuid::now_v7(); + db::persist_thread_with_first_run( + pool, + thread_id, + run_id, + user_message_id, + assistant_message_id, + workflow, + "prompt", + &[], + "t", + 1, + ) + .await + .expect("seed run"); + (run_id, thread_id, assistant_message_id) +} + +/// In-memory [`WorkerPort`]: yields scripted frames in order and records +/// the `tool_call_id` of every Tool Result sent back. `sent`/`shutdowns` +/// are shared so the test can inspect them after `run_loop` consumes it. +pub(crate) struct ScriptedWorker { + inbound: VecDeque>, + sent: Arc>>, + shutdowns: Arc>, + acks: Arc>>, +} + +impl ScriptedWorker { + pub(crate) fn new( + frames: Vec, + ) -> (Self, Arc>>, Arc>) { + let (worker, sent, shutdowns, _acks) = Self::new_with_acks(frames); + (worker, sent, shutdowns) + } + + pub(crate) fn new_with_acks( + frames: Vec, + ) -> ( + Self, + Arc>>, + Arc>, + Arc>>, + ) { + Self::new_with_results(frames.into_iter().map(Ok).collect()) + } + + pub(crate) fn new_with_results( + frames: Vec>, + ) -> ( + Self, + Arc>>, + Arc>, + Arc>>, + ) { + let sent = Arc::new(Mutex::new(Vec::new())); + let shutdowns = Arc::new(Mutex::new(0)); + let acks = Arc::new(Mutex::new(Vec::new())); + let worker = Self { + inbound: frames.into(), + sent: sent.clone(), + shutdowns: shutdowns.clone(), + acks: acks.clone(), + }; + (worker, sent, shutdowns, acks) + } +} + +impl WorkerPort for ScriptedWorker { + async fn recv(&mut self) -> Result, ()> { + match self.inbound.pop_front() { + Some(frame) => frame.map(Some), + None => Ok(None), + } + } + + async fn send_tool_result(&mut self, result: ToolResult) { + self.sent.lock().unwrap().push(result.tool_call_id); + } + + async fn send_external_tool_ack(&mut self, ack: ExternalToolAck) -> Result<(), ()> { + self.acks.lock().unwrap().push(ack); + Ok(()) + } + + async fn shutdown(&mut self) { + *self.shutdowns.lock().unwrap() += 1; + } +} + +/// A [`WorkerPort`] that flips the run's cancel signal just before yielding +/// the frame at index `cancel_before`, forcing the loop's post-recv cancel +/// check to trip — the live-cancel-mid-stream race. Otherwise behaves like +/// [`ScriptedWorker`]. +pub(crate) struct CancelingWorker { + inbound: VecDeque, + hub: crate::hub::RunHub, + cancel_before: usize, + idx: usize, + sent: Arc>>, + shutdowns: Arc>, + acks: Arc>>, +} + +impl CancelingWorker { + pub(crate) fn new( + frames: Vec, + hub: crate::hub::RunHub, + cancel_before: usize, + ) -> (Self, Arc>>, Arc>) { + let sent = Arc::new(Mutex::new(Vec::new())); + let shutdowns = Arc::new(Mutex::new(0)); + let worker = Self { + inbound: frames.into(), + hub, + cancel_before, + idx: 0, + sent: sent.clone(), + shutdowns: shutdowns.clone(), + acks: Arc::new(Mutex::new(Vec::new())), + }; + (worker, sent, shutdowns) + } +} + +impl WorkerPort for CancelingWorker { + async fn recv(&mut self) -> Result, ()> { + if self.idx == self.cancel_before { + self.hub.cancel(); + } + self.idx += 1; + Ok(self.inbound.pop_front()) + } + + async fn send_tool_result(&mut self, result: ToolResult) { + self.sent.lock().unwrap().push(result.tool_call_id); + } + + async fn send_external_tool_ack(&mut self, ack: ExternalToolAck) -> Result<(), ()> { + self.acks.lock().unwrap().push(ack); + Ok(()) + } + + async fn shutdown(&mut self) { + *self.shutdowns.lock().unwrap() += 1; + } +} + +/// Drain a broadcast receiver into a Vec without blocking. +pub(crate) fn drain(rx: &mut broadcast::Receiver) -> Vec { + let mut events = Vec::new(); + while let Ok(event) = rx.try_recv() { + events.push(event); + } + events +} + +pub(crate) fn fixtures(run_id: Uuid) -> (Hubs, crate::hub::RunHub) { + let hubs = crate::hub::new_hubs(); + let run_hub = crate::hub::register(&hubs, run_id).expect("fresh run registers"); + (hubs, run_hub) +} + +pub(crate) fn external_started(id: &str, name: &str) -> WorkerStdout { + WorkerStdout::ExternalToolStarted { + tool_call_id: id.to_string(), + name: name.to_string(), + arguments: serde_json::json!({ "filter": { "status": [0] } }), + } +} + +pub(crate) fn external_finished(id: &str, text: &str, is_error: bool) -> WorkerStdout { + WorkerStdout::ExternalToolFinished { + tool_call_id: id.to_string(), + result: TranscriptToolResult::text(text, is_error), + } +} + +pub(crate) async fn tool_call_row( + pool: &SqlitePool, + id: &str, +) -> Option<(String, String, Option)> { + sqlx::query_as("SELECT name, status, result_payload FROM tool_calls WHERE id = ?1") + .bind(id) + .fetch_optional(pool) + .await + .expect("read tool_call row") +} + +/// The run's assistant text — all `message_parts` text concatenated in `seq` +/// order. `select_run_snapshot` returned this before the ordered segment +/// `Snapshot` superseded it (review P1 #2); tests still assert run_loop's +/// persisted text through it. +pub(crate) async fn run_text(pool: &SqlitePool, run_id: Uuid) -> String { + sqlx::query_scalar::<_, Option>( + "SELECT group_concat(text, '') FROM ( \ + SELECT mp.text FROM message_parts mp \ + JOIN messages m ON m.id = mp.message_id \ + WHERE m.run_id = ?1 AND m.role = 'assistant' AND mp.type = 'text' \ + ORDER BY mp.seq )", + ) + .bind(run_id.to_string()) + .fetch_one(pool) + .await + .expect("read run text") + .unwrap_or_default() +} + +pub(crate) async fn run_steps_kinds_and_content(pool: &SqlitePool, run_id: Uuid) -> Vec<(String, String)> { + let rows: Vec<(String, Option, Option, Option)> = sqlx::query_as( + "SELECT rs.kind, rs.message_id, rs.part_seq, tc.name \ + FROM run_steps rs \ + LEFT JOIN tool_calls tc ON tc.id = rs.tool_call_id \ + WHERE rs.run_id = ?1 ORDER BY rs.seq", + ) + .bind(run_id.to_string()) + .fetch_all(pool) + .await + .expect("read run_steps"); + + let mut out = Vec::with_capacity(rows.len()); + for (kind, message_id, part_seq, tc_name) in rows { + match kind.as_str() { + "message" => { + let message_id = message_id.expect("message step has a message_id"); + let part_seq = part_seq.expect("message step resolves a specific text part"); + let text: String = sqlx::query_scalar( + "SELECT text FROM message_parts WHERE message_id = ?1 AND seq = ?2", + ) + .bind(&message_id) + .bind(part_seq) + .fetch_one(pool) + .await + .expect("message step's part exists"); + out.push(("message".to_string(), text)); + } + "tool_call" => out.push(("tool_call".to_string(), tc_name.unwrap_or_default())), + other => panic!("unexpected run_step kind {other:?}"), + } + } + out +} diff --git a/crates/core/src/worker/title.rs b/crates/core/src/worker/title.rs index 3cb99d68..80a0fb0e 100644 --- a/crates/core/src/worker/title.rs +++ b/crates/core/src/worker/title.rs @@ -93,29 +93,40 @@ pub fn spawn_title_generation( role: crate::launch::Role::Titler, }, title_timeout(), - |worker| Box::pin(async move { - let mut acc = String::new(); - loop { - match worker.recv().await { - Some(crate::protocol::WorkerStdout::TextDelta { delta }) => { - acc.push_str(&delta) + |worker| { + Box::pin(async move { + let mut acc = String::new(); + loop { + match worker.recv().await { + Ok(Some(crate::protocol::WorkerStdout::TextDelta { delta })) => { + acc.push_str(&delta) + } + // Reasoning deltas (ADR-0045 reasoning amendment, #202) + // are not title text — skip them and keep collecting. + Ok(Some(crate::protocol::WorkerStdout::ReasoningDelta { + .. + })) => {} + Ok(Some(crate::protocol::WorkerStdout::Done)) => return Some(acc), + // The titler has no tools and an explicit error is a + // failed turn: in both cases discard the partial output + // and keep the placeholder. + Ok(Some(crate::protocol::WorkerStdout::Error { .. })) + | Ok(Some(crate::protocol::WorkerStdout::ToolRequest { .. })) + | Ok(Some(crate::protocol::WorkerStdout::ExternalToolStarted { + .. + })) + | Ok(Some(crate::protocol::WorkerStdout::ExternalToolFinished { + .. + })) + | Err(()) => { + return None; + } + // EOF without `done`: use whatever was accumulated. + Ok(None) => return Some(acc), } - // Reasoning deltas (ADR-0045 reasoning amendment, #202) - // are not title text — skip them and keep collecting. - Some(crate::protocol::WorkerStdout::ReasoningDelta { .. }) => {} - Some(crate::protocol::WorkerStdout::Done) => return Some(acc), - // The titler has no tools and an explicit error is a - // failed turn: in both cases discard the partial output - // and keep the placeholder. - Some(crate::protocol::WorkerStdout::Error { .. }) - | Some(crate::protocol::WorkerStdout::ToolRequest { .. }) => { - return None; - } - // EOF without `done`: use whatever was accumulated. - None => return Some(acc), } - } - }), + }) + }, ) .await; diff --git a/crates/core/src/workflow.rs b/crates/core/src/workflow.rs index a6589e63..7692e073 100644 --- a/crates/core/src/workflow.rs +++ b/crates/core/src/workflow.rs @@ -40,6 +40,12 @@ pub struct Workflow { pub thinking_level: Option, #[serde(default)] pub tools: Vec, + /// Whether this Workflow may reach the external (Worker-executed MCP) + /// `ticktick_*` tools (external-task-views A3). Off until the S4 cutover + /// flips the default Workflow; the spawn manifest carries endpoint+auth + /// only when this is set AND a TickTick credential loaded at boot. + #[serde(default)] + pub external_tools: bool, } impl Workflow { diff --git a/crates/core/tests/decouple.rs b/crates/core/tests/decouple.rs index 840b080a..0ca1f70f 100644 --- a/crates/core/tests/decouple.rs +++ b/crates/core/tests/decouple.rs @@ -42,10 +42,11 @@ async fn post_message(ws: &mut Ws, id: u32) -> String { .to_string() } -/// Send `run/subscribe(run_id)`, read the subscribe RESPONSE and the snapshot -/// `text_delta`, and return the cumulative snapshot text (the reassembly base). -/// The snapshot may be `""` or a partial chunk depending on the gate race, so -/// callers must not hard-assert its content. +/// Send `run/subscribe(run_id)`, read the subscribe RESPONSE and the ordered +/// segment `snapshot` (review P1 #2), and return the cumulative snapshot text +/// (its Text segments concatenated) as the reassembly base. The snapshot may be +/// empty or a partial chunk depending on the gate race, so callers must not +/// hard-assert its content. async fn subscribe_and_read_snapshot(ws: &mut Ws, id: u32, run_id: &str) -> String { let subscribe = format!( r#"{{"jsonrpc":"2.0","id":{id},"method":"run/subscribe","params":{{"run_id":"{run_id}"}}}}"# @@ -72,13 +73,18 @@ async fn subscribe_and_read_snapshot(ws: &mut Ws, id: u32, run_id: &str) -> Stri .unwrap_or_else(|e| panic!("snapshot is JSON: {e} — body: {snapshot_body}")); assert_eq!( snapshot["params"]["event"]["kind"], - serde_json::json!("text_delta"), - "snapshot is a text_delta — body: {snapshot_body}" + serde_json::json!("snapshot"), + "snapshot is an ordered segment snapshot — body: {snapshot_body}" ); - snapshot["params"]["event"]["delta"] - .as_str() - .unwrap_or_else(|| panic!("snapshot text_delta carries a string — body: {snapshot_body}")) - .to_string() + // Reassembly base = the snapshot's Text segments concatenated (its reply + // text; tool_call/reasoning segments carry none). Tail `text_delta`s append. + snapshot["params"]["event"]["segments"] + .as_array() + .unwrap_or_else(|| panic!("snapshot carries a segments array — body: {snapshot_body}")) + .iter() + .filter(|seg| seg["kind"] == serde_json::json!("text")) + .map(|seg| seg["text"].as_str().unwrap_or_default()) + .collect::() } /// Drain `ws`'s tail from `base`, appending each incremental `text_delta` until diff --git a/crates/core/tests/end_to_end.rs b/crates/core/tests/end_to_end.rs index 3cbb5349..42a1b5c6 100644 --- a/crates/core/tests/end_to_end.rs +++ b/crates/core/tests/end_to_end.rs @@ -115,6 +115,19 @@ fn end_to_end_post_message_streams_text_delta_then_done() { "event run_id matches — body: {body}" ); match v["params"]["event"]["kind"].as_str() { + // The ordered snapshot (review P1 #2) opens the stream: fold its Text + // segments into the reassembly base; tail `text_delta`s then append. + Some("snapshot") => { + for seg in v["params"]["event"]["segments"] + .as_array() + .into_iter() + .flatten() + { + if seg["kind"] == serde_json::json!("text") { + assembled.push_str(seg["text"].as_str().unwrap_or_default()); + } + } + } Some("text_delta") => { assembled.push_str( v["params"]["event"]["delta"] diff --git a/crates/core/tests/faux_run.rs b/crates/core/tests/faux_run.rs index bd7e99e1..91f1a64e 100644 --- a/crates/core/tests/faux_run.rs +++ b/crates/core/tests/faux_run.rs @@ -80,6 +80,19 @@ fn faux_completion_streams_through_core() { let v: serde_json::Value = serde_json::from_str(&body) .unwrap_or_else(|e| panic!("event is JSON: {e} — body: {body}")); match v["params"]["event"]["kind"].as_str() { + // The ordered snapshot (review P1 #2) opens the stream: fold its + // Text segments into the base; tail `text_delta`s then append. + Some("snapshot") => { + for seg in v["params"]["event"]["segments"] + .as_array() + .into_iter() + .flatten() + { + if seg["kind"] == serde_json::json!("text") { + assembled.push_str(seg["text"].as_str().unwrap_or_default()); + } + } + } Some("text_delta") => { assembled.push_str( v["params"]["event"]["delta"] diff --git a/crates/core/tests/fixtures/bad-line-worker.ts b/crates/core/tests/fixtures/bad-line-worker.ts index f995856a..b208e3a9 100644 --- a/crates/core/tests/fixtures/bad-line-worker.ts +++ b/crates/core/tests/fixtures/bad-line-worker.ts @@ -4,12 +4,10 @@ // `INKSTONE_WORKER_CMD`), but deliberately writes ONE malformed, non-NDJSON // line to stdout BEFORE its real frames. Core's `child.rs` stdout reader fails // to deserialize that line as a `WorkerStdout`, hits the "worker emitted unknown -// line" arm — `tracing::warn!(event="worker.unknown_line", …)` — and `continue`s -// to the next line. The fixture then emits a normal `text_delta` + `done`, so -// the Run still completes; the test drives the Run to `done` (which can only -// arrive AFTER the bad line was read and skipped), then reads the trail and -// asserts the `worker.unknown_line` event carries the Run's `run_id` as a -// top-level field — proving run_id correlation reaches a child.rs site +// line" arm — `tracing::warn!(event="worker.unknown_line", …)` — and terminates +// the Run. The test waits for Core's error event, then asserts the diagnostic +// carries the Run's `run_id` as a top-level field, proving correlation reaches +// a child.rs site // (threaded into `ChildWorker::spawn`; the `worker_run` span is retained for // transitive dep events). See ADR-0038. // @@ -28,12 +26,10 @@ const main = async (): Promise => { const inbound = JSON.parse(line) as { prompt: string }; // The malformed line: valid UTF-8, but NOT a JSON `WorkerStdout` frame, so - // `serde_json::from_str::` fails and Core logs + skips it. + // `serde_json::from_str::` fails and Core terminates the Run. process.stdout.write("this is not a worker frame\n"); - // A real frame + terminal done so the Run completes normally. Because Core's - // reader is sequential, `done` is only delivered after the bad line above was - // read and skipped — making the test's wait-for-done a deterministic barrier. + // These frames prove Core does not continue after the protocol violation. emit({ kind: "text_delta", delta: `echo: ${inbound.prompt}` }); emit({ kind: "done" }); }; diff --git a/crates/core/tests/persistence_stream.rs b/crates/core/tests/persistence_stream.rs index b451c1ea..a10efdd2 100644 --- a/crates/core/tests/persistence_stream.rs +++ b/crates/core/tests/persistence_stream.rs @@ -62,6 +62,17 @@ fn text_delta_appends_to_message_parts() { "frame is a run/event — body: {body}" ); match v["params"]["event"]["kind"].as_str() { + Some("snapshot") => { + for seg in v["params"]["event"]["segments"] + .as_array() + .into_iter() + .flatten() + { + if seg["kind"] == serde_json::json!("text") { + assembled.push_str(seg["text"].as_str().unwrap_or_default()); + } + } + } Some("text_delta") => { assembled.push_str( v["params"]["event"]["delta"] diff --git a/crates/core/tests/proposal_cancel.rs b/crates/core/tests/proposal_cancel.rs index 5621d74f..8fecdce8 100644 --- a/crates/core/tests/proposal_cancel.rs +++ b/crates/core/tests/proposal_cancel.rs @@ -79,8 +79,8 @@ fn cancel_parked_run() { .unwrap_or_else(|e| panic!("snapshot is JSON: {e} — body: {snapshot_body}")); assert_eq!( snapshot["params"]["event"]["kind"].as_str(), - Some("text_delta"), - "cancelled subscribe sends text snapshot first — body: {snapshot_body}" + Some("snapshot"), + "cancelled subscribe sends its ordered segment snapshot first — body: {snapshot_body}" ); let terminal_body = next_text(&mut ws).await; diff --git a/crates/core/tests/run_cancel.rs b/crates/core/tests/run_cancel.rs index 8ac4d640..192fbf5b 100644 --- a/crates/core/tests/run_cancel.rs +++ b/crates/core/tests/run_cancel.rs @@ -125,18 +125,29 @@ fn cancel_running_run_wins_and_suppresses_late_worker_done() { assert_eq!( event["method"].as_str(), Some("run/event"), - "expected run/event while waiting for first delta — body: {event}" - ); - assert_eq!( - event["params"]["event"]["kind"].as_str(), - Some("text_delta"), - "expected text_delta before cancel — body: {event}" - ); - streamed.push_str( - event["params"]["event"]["delta"] - .as_str() - .unwrap_or_else(|| panic!("delta is string — body: {event}")), + "expected run/event while waiting for first text — body: {event}" ); + // The ordered `snapshot` opens the stream (review P1 #2), then tail + // `text_delta`s; take text from either until we have some. + match event["params"]["event"]["kind"].as_str() { + Some("snapshot") => { + for seg in event["params"]["event"]["segments"] + .as_array() + .into_iter() + .flatten() + { + if seg["kind"] == serde_json::json!("text") { + streamed.push_str(seg["text"].as_str().unwrap_or_default()); + } + } + } + Some("text_delta") => streamed.push_str( + event["params"]["event"]["delta"] + .as_str() + .unwrap_or_else(|| panic!("delta is string — body: {event}")), + ), + other => panic!("expected snapshot/text_delta before cancel — got {other:?}: {event}"), + } } let cancel = serde_json::json!({ @@ -283,8 +294,8 @@ fn cancel_before_worker_start_prevents_worker_output() { let snapshot = next_json(&mut ws).await; assert_eq!( snapshot["params"]["event"]["kind"].as_str(), - Some("text_delta"), - "cancelled run still sends a text snapshot — body: {snapshot}" + Some("snapshot"), + "cancelled run still sends its ordered segment snapshot — body: {snapshot}" ); let terminal = next_json(&mut ws).await; assert_eq!( diff --git a/crates/core/tests/subscribe.rs b/crates/core/tests/subscribe.rs index 1a916c6b..c183d593 100644 --- a/crates/core/tests/subscribe.rs +++ b/crates/core/tests/subscribe.rs @@ -115,8 +115,9 @@ fn subscribe_snapshot_then_tail() { "subscribe response is a response, not a notification — body: {sub_resp_body}" ); - // The SNAPSHOT: a cumulative text_delta. Its content may be "" or - // "echo: " depending on the gate race — do NOT hard-assert it. + // The SNAPSHOT: an ordered segment `snapshot` (review P1 #2). Its content + // may be empty or "echo: " depending on the gate race — do NOT hard-assert + // it; the reassembly base is its Text segments concatenated. let snapshot_body = next_text(&mut ws).await; let snapshot: serde_json::Value = serde_json::from_str(&snapshot_body) .unwrap_or_else(|e| panic!("snapshot is JSON: {e} — body: {snapshot_body}")); @@ -132,13 +133,16 @@ fn subscribe_snapshot_then_tail() { ); assert_eq!( snapshot["params"]["event"]["kind"], - serde_json::json!("text_delta"), - "snapshot is a text_delta — body: {snapshot_body}" + serde_json::json!("snapshot"), + "snapshot is an ordered segment snapshot — body: {snapshot_body}" ); - let mut assembled = snapshot["params"]["event"]["delta"] - .as_str() - .unwrap_or_else(|| panic!("snapshot text_delta carries a string — body: {snapshot_body}")) - .to_string(); + let mut assembled = snapshot["params"]["event"]["segments"] + .as_array() + .unwrap_or_else(|| panic!("snapshot carries a segments array — body: {snapshot_body}")) + .iter() + .filter(|seg| seg["kind"] == serde_json::json!("text")) + .map(|seg| seg["text"].as_str().unwrap_or_default()) + .collect::(); // Trip the gate so the worker emits chunk 2 + done. std::fs::write(&gate_path, b"go").expect("create gate file"); @@ -274,6 +278,17 @@ fn late_subscribe_after_terminal_still_gets_done() { "B frame is a run/event — body: {body}" ); match v["params"]["event"]["kind"].as_str() { + Some("snapshot") => { + for seg in v["params"]["event"]["segments"] + .as_array() + .into_iter() + .flatten() + { + if seg["kind"] == serde_json::json!("text") { + assembled.push_str(seg["text"].as_str().unwrap_or_default()); + } + } + } Some("text_delta") => { assembled.push_str( v["params"]["event"]["delta"] diff --git a/crates/core/tests/ticktick_web_lane.rs b/crates/core/tests/ticktick_web_lane.rs new file mode 100644 index 00000000..4bb42576 --- /dev/null +++ b/crates/core/tests/ticktick_web_lane.rs @@ -0,0 +1,303 @@ +//! `ticktick/status` + `ticktick/tasks/list` end-to-end (external-task-views +//! S2): a fake TickTick OpenAPI server serves small hand-authored wire +//! responses. Core reads them through the real `TickTickClient` + +//! normalization and answers the two verbs over the WS. Also covers the +//! not-connected gate. + +use std::io::{Read, Write}; +use std::net::TcpListener; + +use futures_util::SinkExt; +use tokio_tungstenite::tungstenite::Message; + +mod common; +use common::{next_text, rt, Workspace, Ws}; + +fn project_response() -> String { + serde_json::json!([{ "id": "list-1", "name": "Work" }]).to_string() +} + +fn task_response() -> String { + let mut tasks = vec![serde_json::json!({ + "id": "timed", + "projectId": "list-1", + "title": "Timed task", + "kind": "TEXT", + "priority": 0, + "tags": ["advanced"], + "dueDate": "2026-08-20T17:30:00.000+0000", + "isAllDay": false, + "timeZone": "America/Los_Angeles" + })]; + tasks.extend((0..198).map(|i| { + serde_json::json!({ + "id": format!("task-{i}"), + "projectId": "list-1", + "title": format!("Task {i}"), + "kind": "TEXT", + "priority": 0, + "tags": [] + }) + })); + tasks.push(serde_json::json!({ + "id": "note-1", + "projectId": "list-1", + "title": "Hidden note", + "kind": "NOTE" + })); + serde_json::to_string(&tasks).expect("task response serializes") +} + +/// A blocking HTTP/1.1 fake of TickTick's OpenAPI on a background thread. +fn start_fake_ticktick(expected_requests: usize) -> (String, std::thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake ticktick"); + let addr = listener.local_addr().expect("addr"); + let projects = project_response(); + let tasks = task_response(); + + let handle = std::thread::spawn(move || { + // Two reads per fetch (projects, then filter): serve EXACTLY the test's + // request count so the thread returns (joinable) instead of blocking in + // `accept()` past the test — a detached thread + listener leak. + for _ in 0..expected_requests { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap_or(0); + let head = String::from_utf8_lossy(&buf[..n]); + let body = if head.starts_with("GET /open/v1/project") { + &projects + } else { + &tasks + }; + let resp = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + } + }); + (format!("http://{addr}"), handle) +} + +async fn call(ws: &mut Ws, id: u64, method: &str) -> serde_json::Value { + let req = format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"{method}","params":{{}}}}"#); + ws.send(Message::Text(req.into())).await.expect("send"); + let body = next_text(ws).await; + serde_json::from_str(&body).expect("json") +} + +#[test] +fn tasks_list_normalizes_a_full_page_and_flags_truncation() { + // One `ticktick/tasks/list` = two OpenAPI reads (projects + filter). + let (api_url, server) = start_fake_ticktick(2); + let workspace = Workspace::new(); + let creds_dir = workspace.path().join("credentials"); + std::fs::create_dir_all(&creds_dir).expect("mk creds dir"); + let cred_path = creds_dir.join("ticktick.json"); + std::fs::write( + &cred_path, + r#"{"access_token":"tok_e2e","token_type":"bearer","scope":"tasks:read tasks:write"}"#, + ) + .expect("write ticktick credential"); + // The custody gate (review R12 #5) rejects group/world-readable tokens. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&cred_path, std::fs::Permissions::from_mode(0o600)) + .expect("chmod 0600"); + } + + let core = workspace + .core() + .no_seeded_credential() + .env("INKSTONE_CREDENTIALS_DIR", &creds_dir) + .env("INKSTONE_TICKTICK_API_URL", &api_url) + .spawn(); + + rt().block_on(async { + let mut ws = core.connect().await; + + let status = call(&mut ws, 1, "ticktick/status").await; + assert_eq!(status["result"]["state"], serde_json::json!("connected")); + let connection_id = status["result"]["connection_id"] + .as_str() + .expect("connected status carries a connection_id"); + assert!(!connection_id.is_empty()); + + let list = call(&mut ws, 2, "ticktick/tasks/list").await; + let tasks = list["result"]["tasks"].as_array().expect("tasks array"); + assert_eq!(tasks.len(), 199, "200 raw rows minus the one NOTE"); + assert_eq!( + list["result"]["source_limit_reached"], + serde_json::json!(true), + "a 200-row page flags truncation (raw count, pre-filter)" + ); + assert!( + tasks + .iter() + .all(|t| t["kind"] == "TEXT" || t["kind"] == "CHECKLIST"), + "NOTE rows are discarded" + ); + assert!( + tasks + .iter() + .any(|t| t["title"] == "Timed task" && t["list_name"] == "Work"), + "project ids resolve to list names" + ); + }); + // Storage claim (A2/A6): Core persists NO task authority or cache. This is + // structural, not a runtime check — the `ticktick/status` + + // `ticktick/tasks/list` handlers take no `pool` (crates/core/src/runs/ + // ticktick.rs), so they cannot write task state; the read is computed per + // call from TickTick's OpenAPI. No task/cache table exists to assert over. + + // The server thread served its exact request budget — joining proves it + // exited (no detached thread/listener outlives the test). + server.join().expect("fake TickTick server thread exits"); +} + +/// Not connected (no credential file): `ticktick/status` reports +/// `not_connected` with no id, and `ticktick/tasks/list` is rejected `-32004` +/// (the provider-not-connected code) so the Web shows the disconnected state. +#[test] +fn not_connected_reports_state_and_rejects_tasks_list() { + let workspace = Workspace::new(); + let creds_dir = workspace.path().join("credentials"); + + let core = workspace + .core() + .no_seeded_credential() + .env("INKSTONE_CREDENTIALS_DIR", &creds_dir) + .spawn(); + + rt().block_on(async { + let mut ws = core.connect().await; + + let status = call(&mut ws, 1, "ticktick/status").await; + assert_eq!( + status["result"]["state"], + serde_json::json!("not_connected") + ); + assert!(status["result"]["connection_id"].is_null()); + + let list = call(&mut ws, 2, "ticktick/tasks/list").await; + assert_eq!( + list["error"]["code"], + serde_json::json!(-32004), + "tasks/list on a disconnected account is provider-not-connected" + ); + }); +} + +/// A 401 from TickTick (expired/revoked credential — A5: no re-read, restart to +/// change) surfaces as the verb's internal error, never a hang or a false empty +/// list (review R12 #6). +#[test] +fn upstream_401_surfaces_as_an_error() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind fake"); + let addr = listener.local_addr().expect("addr"); + let server = std::thread::spawn(move || { + for _ in 0..2 { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let _ = stream.write_all( + b"HTTP/1.1 401 Unauthorized\r\ncontent-length: 0\r\nconnection: close\r\n\r\n", + ); + } + }); + + let workspace = Workspace::new(); + let creds_dir = workspace.path().join("credentials"); + std::fs::create_dir_all(&creds_dir).expect("mk creds dir"); + let cred_path = creds_dir.join("ticktick.json"); + std::fs::write(&cred_path, r#"{"access_token":"tok_expired"}"#).expect("write cred"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&cred_path, std::fs::Permissions::from_mode(0o600)) + .expect("chmod"); + } + + let core = workspace + .core() + .no_seeded_credential() + .env("INKSTONE_CREDENTIALS_DIR", &creds_dir) + .env("INKSTONE_TICKTICK_API_URL", format!("http://{addr}")) + .spawn(); + + rt().block_on(async { + let mut ws = core.connect().await; + let list = call(&mut ws, 1, "ticktick/tasks/list").await; + assert!( + list["error"]["code"].is_i64(), + "a 401 read maps to an error response — body: {list}" + ); + }); + server.join().expect("fake 401 server exits"); +} + +/// A STALLED upstream is bounded by the A7 timeout knob +/// (`INKSTONE_TICKTICK_TIMEOUT_MS`, review R12 #6): with a tiny bound the read +/// errors promptly instead of hanging the verb for the default 30s. +#[test] +fn stalled_upstream_is_bounded_by_the_timeout_knob() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind fake"); + let addr = listener.local_addr().expect("addr"); + let server = std::thread::spawn(move || { + // Accept both reads, read the requests, respond to NEITHER — hold the + // sockets past the client's 250ms bound, then exit. + let mut held = Vec::new(); + for _ in 0..2 { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + held.push(stream); + } + std::thread::sleep(std::time::Duration::from_millis(1_500)); + drop(held); + }); + + let workspace = Workspace::new(); + let creds_dir = workspace.path().join("credentials"); + std::fs::create_dir_all(&creds_dir).expect("mk creds dir"); + let cred_path = creds_dir.join("ticktick.json"); + std::fs::write(&cred_path, r#"{"access_token":"tok_stall"}"#).expect("write cred"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&cred_path, std::fs::Permissions::from_mode(0o600)) + .expect("chmod"); + } + + let core = workspace + .core() + .no_seeded_credential() + .env("INKSTONE_CREDENTIALS_DIR", &creds_dir) + .env("INKSTONE_TICKTICK_API_URL", format!("http://{addr}")) + .env("INKSTONE_TICKTICK_TIMEOUT_MS", "250") + .spawn(); + + rt().block_on(async { + let mut ws = core.connect().await; + let started = std::time::Instant::now(); + let list = call(&mut ws, 1, "ticktick/tasks/list").await; + assert!( + list["error"]["code"].is_i64(), + "a stalled read maps to an error response — body: {list}" + ); + assert!( + started.elapsed() < std::time::Duration::from_secs(1), + "the knob bounds the stall (not the 30s default)" + ); + }); + server.join().expect("fake stall server exits"); +} diff --git a/crates/core/tests/worker_logging.rs b/crates/core/tests/worker_logging.rs index c36cc3a2..df4f837f 100644 --- a/crates/core/tests/worker_logging.rs +++ b/crates/core/tests/worker_logging.rs @@ -64,15 +64,13 @@ fn worker_unknown_line_carries_run_id() { .expect("send subscribe frame"); let _sub_response = next_text(&mut ws).await; - // Drive the Run to `done`. Because the worker's stdout is read - // sequentially, `done` can only arrive AFTER the malformed line was read - // and skipped — a deterministic barrier that the trail now holds the - // `worker.unknown_line` event. + // The malformed frame is terminal. Waiting for Core's error event is the + // deterministic barrier that the unknown-line diagnostic was emitted. loop { let body = next_text(&mut ws).await; let v: serde_json::Value = serde_json::from_str(&body) .unwrap_or_else(|e| panic!("event is JSON: {e} — body: {body}")); - if v["params"]["event"]["kind"].as_str() == Some("done") { + if v["params"]["event"]["kind"].as_str() == Some("error") { break; } } diff --git a/docs/adr/0027-worker-interpreter-transport-seam.md b/docs/adr/0027-worker-interpreter-transport-seam.md index 68dfd431..814421b5 100644 --- a/docs/adr/0027-worker-interpreter-transport-seam.md +++ b/docs/adr/0027-worker-interpreter-transport-seam.md @@ -46,7 +46,7 @@ This change builds the seam and converts `main` to `Effect.gen`. It does **not** - **[ADR-0026](./0026-worker-transport-seam.md):** the Worker-side counterpart. 0026 put Core's end of the stdio pipe behind `WorkerPort` (pull); this puts the Worker's end behind `WorkerTransport` (push). Opposite ends of the same pipe, same seam idea, mirror-image shape and a different DI mechanism (generic dispatch vs `Layer`). - **[ADR-0006](./0006-run-events-vs-tool-protocol.md):** `emit` (Run Events) and `callTool` (Tool Protocol) are the two logical channels, kept as separate concepts via two methods on the one transport. -- **[ADR-0013](./0013-worker-process-lifecycle-and-transport.md):** unchanged in substance — manifest-first stdin, kept-open stdin for `tool_result` writes, stdout NDJSON. This pins how the Worker expresses that transport in code: behind `WorkerTransport`, with `StdioTransportLive` the sole `process.stdin`/`stdout` site. +- **[ADR-0013](./0013-worker-process-lifecycle-and-transport.md):** unchanged in substance — manifest-first stdin, kept-open stdin for typed `WorkerInbound` writes (`tool_result` and, as later extended, `external_tool_ack`), stdout NDJSON. This pins how the Worker expresses that transport in code: behind `WorkerTransport`, with `StdioTransportLive` the sole `process.stdin`/`stdout` site. - **[ADR-0018](./0018-workflow-and-tools-definition.md):** the generic interpreter stays the deep module; it now requires `WorkerTransport` from context instead of taking loose `emit`/`callTool` parameters. - **[ADR-0019](./0019-test-harness-architecture.md):** faux provider scripting via `depsFor`'s env branches stayed for the transport-seam change; it was **subsequently evicted** to a test-only Worker entry (`faux-worker.ts`) over a shared `runWorkerMain`, so the shipping `cli.ts` carries no test code — see ADR-0019's as-built amendment. - **[ADR-0020](./0020-effect-across-typescript.md):** `main` becomes `Effect.gen` entry-to-exit and the transport is a `Context.Tag` provided by a `Layer`, matching `ui-sdk`'s `WsClient`. Closes the gap where `cli.ts` was the one TypeScript module on raw promises and callbacks. @@ -56,14 +56,14 @@ This change builds the seam and converts `main` to `Effect.gen`. It does **not** Recorded after the four-slice implementation to keep the decision honest: - **Fixtures use a local helper, not `StdioTransportLive`.** The fixtures are deliberately standalone (Node-builtins-only, run via `tsx` through `INKSTONE_WORKER_CMD`), and two of them speak protocols the production transport can't express — `slow-worker` reads `{prompt}` with test-only incremental chunking + a mid-stream gate, and `propose-worker` emits a `tool_request` then parks (never receiving a Tool Result, so it can't use `callTool`, which always awaits one). Forcing reuse would break the standalone convention and pollute the production transport with test-only behavior. Instead the fixtures share a dependency-free helper (`crates/core/tests/fixtures/transport.ts`) mirroring the seam's stdout writer + stdin reader using Node builtins only. The de-duplication intent holds; the literal "reuse `StdioTransportLive`" does not. -- **`emit` is synchronous; `readManifest` is an `Effect`; `callTool` is a `Promise`.** The three operations have three shapes, dictated by their callers: `emit` is a fire-and-forget `(event) => void` called from `pi-agent-core`'s synchronous `onEvent` sink (it structurally cannot await — reinforcing ADR-0006's one-way Run Event channel); `callTool` returns a `Promise` (pi's tool `execute` is Promise-shaped); `readManifest` is an `Effect` awaited once from `main`'s `Effect.gen`. The "run the emit/callTool Effects on the runtime" phrasing above describes the bridge loosely — only `readManifest` is an `Effect`. +- **`emit` is synchronous; `readManifest` is an `Effect`; `callTool` and the later `syncExternalTool` are `Promise`s.** Their shapes follow their callers: `emit` is a fire-and-forget `(event) => void` called from `pi-agent-core`'s synchronous `onEvent` sink; `callTool` awaits a Tool Result; `syncExternalTool` awaits durable acceptance of one external lifecycle phase; `readManifest` is awaited once from `main`'s `Effect.gen`. External ACKs omit `run_id` because each Worker stdin pipe belongs to the one Run established by its manifest; `phase + tool_call_id` is the complete correlation key. - **`main`'s terminal-event guard is `catchAll` + `catchAllDefect`.** A typed `ManifestParseError` (from `readManifest`) is caught by `catchAll`; an unexpected throw (e.g. unknown provider in `getModel`) becomes a defect caught by `catchAllDefect`. Both emit a terminal `error` Run Event through the seam, preserving "a Run never ends without a terminal event." - **"Sole `process.stdin`/`stdout` site" is scoped to the Worker interpreter transport.** The Provider Helper (`packages/provider-helper/src/provider.ts`, ADR-0023) is a separate binary with its own stdio and is out of scope — exactly as ADR-0026's "sole `Command::spawn` site" excludes Core's provider spawns. ## Related - [ADR-0026](./0026-worker-transport-seam.md) — the Core-side seam this mirrors. -- [ADR-0006](./0006-run-events-vs-tool-protocol.md) — the Run Event / Tool Protocol split the two methods honor. +- [ADR-0006](./0006-run-events-vs-tool-protocol.md) — the Run Event / Tool Protocol split the transport honors. - [ADR-0013](./0013-worker-process-lifecycle-and-transport.md) — the stdio transport this expresses in code. - [ADR-0018](./0018-workflow-and-tools-definition.md) — the generic interpreter that depends on the seam. - [ADR-0019](./0019-test-harness-architecture.md) — the `depsFor` eviction, since completed (faux scripting moved to a test-only Worker entry). diff --git a/docs/design/protocol.md b/docs/design/protocol.md index 889c9978..56e72608 100644 --- a/docs/design/protocol.md +++ b/docs/design/protocol.md @@ -25,11 +25,16 @@ with `tool_result` on the post-manifest inbound stream. `params` and `json_schema` are opaque JSON forwarded verbatim (the Worker wraps `json_schema` in `Type.Unsafe`; Core re-validates `params`). The descriptor list ships in the WorkflowManifest. +External tools use a second duplex lifecycle on the same streams: the Worker +emits `external_tool_started` / `external_tool_finished`, and Core replies with +`external_tool_ack` only after durably accepting that phase. + ## packages/protocol/src/worker.ts — WorkerOutbound -`WorkerOutbound` (`WorkerRunEvent | ToolRequest`) mirrors Rust's `WorkerStdout` -in crates/core/src/protocol/worker.rs. +`WorkerOutbound` (`WorkerRunEvent | ToolRequest | ExternalToolStarted | +ExternalToolFinished`) mirrors Rust's `WorkerStdout` in +crates/core/src/protocol/worker.rs. ## packages/protocol/src/worker.ts — WorkerManifest (manifest overview) diff --git a/docs/design/worker-transport.md b/docs/design/worker-transport.md index a57c2a79..0c64fa8f 100644 --- a/docs/design/worker-transport.md +++ b/docs/design/worker-transport.md @@ -6,16 +6,17 @@ Design rationale extracted from code comments during cleanup — keep in sync wi The Worker-side transport seam (ADR-0027): the single service the generic interpreter (ADR-0018) talks to instead of touching `process.stdin`/`stdout` directly. Two `Layer`s satisfy it — a production `StdioTransportLive` and a test-only `InMemoryTransport` — so the interpreter's run-driving logic is unit-testable in-process. -This slice wires both logical channels of ADR-0006: +The seam exposes the two ADR-0006 channels plus the external lifecycle durability barrier: - `emit` (one-way, fire-and-forget Run Events); +- `syncExternalTool` (emit one external lifecycle frame and await Core's durable ACK); - `callTool` (the bidirectional Tool Protocol: a Tool Request paired with a Tool Result — NEVER fire-and-forget); - `readManifest` (read + decode the manifest once at startup; ADR-0013). -`emit` is intentionally a SYNCHRONOUS method: it is called from `pi-agent-core`'s synchronous `onEvent` sink, which runs outside the Effect context. `callTool` returns a `Promise` because `pi-agent-core`'s tool `execute` is a `Promise`-returning callback. `readManifest` is an `Effect` because it is awaited once from `main`'s Effect (ADR-0020). The interpreter obtains the transport once at the top of its Effect and closes over `emit` and `callTool` for those callbacks (ADR-0027 "push, not pull"). +`emit` is intentionally a SYNCHRONOUS method: it is called from `pi-agent-core`'s synchronous `onEvent` sink, which runs outside the Effect context. `syncExternalTool` and `callTool` return `Promise`s because both must await Core from pi's callbacks. `readManifest` is an `Effect` because it is awaited once from `main`'s Effect (ADR-0020). The interpreter obtains the transport once at the top of its Effect and closes over these operations (ADR-0027 "push, not pull"). ## transport-memory.ts — InMemoryTransport -Test `Layer` for `WorkerTransport` (ADR-0027). `emit` pushes each Run Event into the caller's `captured` array; `callTool` records the Tool Request into `tools.requests` and returns the scripted Tool Result from `tools.results` (the bidirectional Tool Protocol channel, ADR-0006). Both arrays plus the scripted table ARE the assertions — no process, no readline, no stdout capture. +Test `Layer` for `WorkerTransport` (ADR-0027). `emit` pushes each Run Event into the caller's `captured` array; `syncExternalTool` pushes its lifecycle frame there and optionally invokes a scripted durability barrier; `callTool` records the Tool Request into `tools.requests` and returns the scripted Tool Result from `tools.results`. The captured arrays, callback, and scripted table ARE the assertions — no process, no readline, no stdout capture. A chat-only run passes no `tools`; its manifest has no tool descriptors, so `callTool` is never invoked. If it ever is (a missing scripted result), the returned `Promise` rejects so the test fails loudly rather than hanging. @@ -23,12 +24,12 @@ A chat-only run passes no `tools`; its manifest has no tool descriptors, so `cal ## transport-stdio.ts — makeStdioService -Production transport (ADR-0027): the Worker's stdio behind the `WorkerTransport` seam. This is the sole module in the Worker's interpreter transport that touches `process.stdin`/`process.stdout` — the Provider Helper (`packages/provider-helper/src/provider.ts`, ADR-0023) is a separate binary with its own stdio and is out of scope here. Mirrors Core's `ChildWorker` as the sole `Command::spawn` site for the Worker (ADR-0026). It owns the single readline over stdin, the first-line manifest read (ADR-0013), the `tool_call_id` → resolver correlation map for the bidirectional Tool Protocol (ADR-0006), and the stdout NDJSON writer. +Production transport (ADR-0027): the Worker's stdio behind the `WorkerTransport` seam. This is the sole module in the Worker's interpreter transport that touches `process.stdin`/`process.stdout` — the Provider Helper (`packages/provider-helper/src/provider.ts`, ADR-0023) is a separate binary with its own stdio and is out of scope here. Mirrors Core's `ChildWorker` as the sole `Command::spawn` site for the Worker (ADR-0026). It owns the single readline over stdin, the first-line manifest read (ADR-0013), the pending Tool Result map keyed by `tool_call_id`, the pending external ACK map keyed by `phase + tool_call_id`, and the stdout NDJSON writer. Built over injected `Readable`/`Writable` streams so the adapter is testable with fakes; `StdioTransportLive` binds it to the real process streams. ### Bidirectional stdio framing (ADR-0013) -A single readline over stdin. The FIRST line is the manifest; every subsequent line is a `tool_result` Core writes back, dispatched to the pending tool call keyed by `tool_call_id`. +A single readline over stdin. The FIRST line is the manifest; every subsequent line is a `WorkerInbound` frame Core writes back: either a `tool_result` or an `external_tool_ack`. Each Worker process and stdin pipe belongs to exactly one Run, established by the manifest, so an external ACK does not repeat `run_id`; `tool_call_id` + lifecycle `phase` identify the pending acknowledgement. -Each post-manifest line is decoded STRICTLY against the single-source `ToolResult` schema (`S.decodeUnknownEither`), not waved through a truthiness check. A skewed frame (e.g. `outcome:{}`) no longer resolves the pending call with junk that later throws inside the proxy and reads as a tool error misattributed to the tool call — it fails loud at the seam: the correlation id is salvaged from the raw JSON (as with the manifest's `run_id`, #146) and the awaiting call is SETTLED with a `tool_result_decode_error` `err` outcome. The settle is what makes it loud — it stops the call hanging and hands the proxy a correctly-attributed decode error; the proxy throws on `err`, which pi turns into an error tool result fed back to the model (ADR-0018), so the Run continues rather than surfacing a mislabeled failure. A line that isn't JSON, or an undecodable line with no correlatable pending call, is logged and dropped (Core's single sequential flushed writer cannot produce such a line for a live pending call). +Each post-manifest line is decoded STRICTLY against the shared `WorkerInbound` union (`S.decodeUnknownEither`), not waved through a truthiness check. A skewed tool result (e.g. `outcome:{}`) no longer resolves the pending call with junk that later throws inside the proxy and reads as a tool error misattributed to the tool call — it fails loud at the seam: the correlation id is salvaged from the raw JSON (as with the manifest's `run_id`, #146) and the awaiting call is SETTLED with a `tool_result_decode_error` `err` outcome. An undecodable external ACK similarly rejects its uniquely correlatable pending lifecycle frame. These settlements stop calls hanging; a line that isn't JSON, or an undecodable line with no correlatable pending call, is logged and dropped. diff --git a/docs/plans/external-task-views-plan.md b/docs/plans/external-task-views-plan.md new file mode 100644 index 00000000..2913eeea --- /dev/null +++ b/docs/plans/external-task-views-plan.md @@ -0,0 +1,474 @@ +# External Task Views — TickTick owns tasks; inkstone reads, twice + +Date: 2026-08-15 · rev 33 · Status: S1+S1a GO — **S2 and S3 implemented (hidden); S4 cutover pending** + +## Decision ledger (read first) + +**Agreed** (user-directed, settled): +- TickTick is the sole task authority; inkstone is read-only over it; writes are a + separate future feature. +- Two independent read paths: Web → TanStack Query → Core → TickTick OpenAPI; + Worker → TickTick MCP directly. +- Core holds **no task state, no task cache, no shared snapshot**; no canonical task + rows or task-cache rows in SQLite. +- Native Todo retirement is total and destructive (entity, schema, recurrence, + references, editors, extraction, fixtures, prompts, vocabulary). +- Chat task capture dies at cutover (until a future write feature) — accepted. +- Project Review is owned by Project, not the retired Todo subsystem. +- No generic connector framework, provider abstraction, shared task model, or + second-provider speculation. +- Web and Worker may observe TickTick at different times (no same-fetched-data + claim). +- The Web lane accepts TickTick's 200-open-entry response ceiling. It makes no + completeness guarantee when the API returns 200 rows; accounts should keep + fewer than 200 unfinished entries. NOTE rows are discarded by `kind`. +- **MCP results are visible on demand in the Run transcript**: a collapsed + name + status row by default; expanding reveals the normalized + `TranscriptToolResult.content` the model received. Errors behave identically + (collapsed error row → expand shows error content). Never exposed: credentials, + raw MCP metadata, runtime `details`/`terminate`. + +**Proposed** (this plan's recommendations — reviewable, not yet user-approved): +- **Core owns TickTick connection configuration and credentials for both lanes** + (manually provisioned 0600 files; Worker receives MCP auth via the spawn + manifest). +- **The cross-lane invariant is the same configured TickTick account**, established + by S1's credential-compatibility check. +- **Build hidden, then retire + expose in one cutover** (S4); contract spike first + with a go/no-go gate (S1) — the slice ordering itself. +- Web refetch policy: `staleTime: 60_000`, refetch on focus + reconnect, no polling, + manual refresh (per-query override of the global `staleTime: Infinity`). +- Worker read-safety mechanics: static exact allowlist applied at discovery AND + before execution; `ticktick_*` namespacing. +- External-tool lifecycle frames (started ← pi `tool_execution_start`; finished ← + finalized `tool_execution_end`, carrying only `{result: TranscriptToolResult}` — + the error flag lives once, inside the result) as the transcript-integration + mechanism; `executionMode: "sequential"` in v1; **`TranscriptToolResult` + {content, is_error} as the single transcript result type for ALL tools** — no + union, no runtime `terminate`/`details` leak (see A4). +- Credential lifecycle: **read once at boot; changes require a Core restart** (no + runtime re-read — see A5). +- A concrete **`ticktick/status`** read (S2) as the Web's source for connection + state + connection ID, and the **reconnect protocol** that gates task reads on it + (see A2). +- The six demo proofs. + +**Resolved by S1/S1a:** +- **S1a date shape: COLLAPSED.** Staged distinct start 09:30 / due 10:30 collapse + to the due instant identically through create, explicit update, and the filter + read; OpenAPI never round-trips a distinct start. `TickTickTaskRow` carries ONE + due tuple (`dueDate`, `isAllDay`, `timeZone`); the distinct-start/due claim is + dropped. +- **S1a Inbox: outcome 1, literal sentinel.** The Inbox projectId is + `inbox`-prefixed (account suffix redacted); `GET /project` never lists it and it + is the aggregate filter's only unmatched projectId across a multi-list account. + Normalization maps `^inbox` ids to a synthetic **"Inbox"** list — an Inbox task + never renders as an unnamed list. The outcome-3 user decision is moot. +- One `tasks:read tasks:write` token authorizes both lanes; MCP rejects + `tasks:read` even for initialize. +- OpenAPI accepts a server-enforced read-only credential, but official MCP requires + write scope. Worker safety can only be stated as Inkstone's dual exact allowlist, + not a server-enforced guarantee. +- The fixed OpenAPI task filter has a silent 200-entry ceiling. This is an accepted + product limit, not a reason to add per-list fan-out. +- OpenAPI rows carry `kind`; Web normalization keeps `TEXT` and `CHECKLIST` and + discards `NOTE`. + +**Unresolved** (owned by a slice or by user decision, not assumed): +- Exact `TickTickTaskRow` fields (S2, derived from the first Tasks UI). +- Final refetch timing (start with the proposed policy, tune on real use). + +## The feature in one paragraph + +**Inkstone does not own or model Todos — as a product.** TickTick is the sole task +authority; inkstone reads it through two independent paths: the **Web** renders tasks +via TanStack Query over a Core verb backed by a concrete `TickTickClient` (OpenAPI, +two reads, normalize by `kind`, no Core cache), and the +**Worker** queries TickTick's official MCP server directly (streamable HTTP, +read-only tool allowlist, namespaced tools) so +the agent gets TickTick's real filtering instead of a local reimplementation. Core +owns connection credentials for both lanes and no task state. Web and Worker may +observe TickTick at different times; the invariant is the same configured account. +Native Todo retirement is committed and lands as one cutover after both hidden lanes +are proven. + +```text +Web ──► TanStack Query ──► Core (TickTickClient: reqwest, 2 reads, normalize) ──► TickTick OpenAPI + │ owns credentials (boot-read) · no task state/cache +Worker ──────────────────────► TickTick MCP (official, streamable HTTP) + read-only allowlist · namespaced tools · lifecycle frames → Core transcript +``` + +## Assumptions + +**A1 — Authority.** TickTick owns the complete task lifecycle. The Tasks surface +carries an app-level "open TickTick" action. After the cutover there is no native +task fallback and no dual surface. + +**A2 — Web lane: TanStack Query → Core verbs → OpenAPI, no Core cache.** +- **One concrete `TickTickClient` module in Core** (`crates/core/src/ticktick/`) — + no provider interface. Two reads (`GET /open/v1/project`, + `POST /open/v1/task/filter` `{"status":[0]}`) against a compile-time-const base + URL (test-only override). TickTick JSON decodes into **private Core transport + types**, then normalizes into **`TickTickTaskRow`** — fields limited to what the + first Tasks UI displays or filters (S2 derives them); TickTick ids preserved + verbatim; TickTick's "project" exposed as **`list`** (Project is an inkstone + outcome Entity); dates carried as **one due tuple — `dueDate` + `isAllDay` + + `timeZone` — never collapsed into a bare UTC instant**. There is no `startDate` + field: **S1a proved the collapse** (staged start 09:30 / due 10:30 came back as + the due instant through create, explicit update, and the filter read alike), so + a distinct start would be a fiction. +- **Kind boundary:** normalize only `TEXT` and `CHECKLIST`; discard `NOTE`. The + server applies its 200-entry ceiling before this local filtering, so NOTE rows + can consume result slots. +- **Inbox naming — resolved by S1a as outcome 1 (literal sentinel):** the Inbox + projectId is `inbox`-prefixed (account-identifying suffix, redacted as + `inbox`); `GET /project` never lists it, and it is the aggregate + filter's only unmatched projectId across a multi-list account. Normalization + maps `^inbox`-prefixed projectIds to a synthetic **"Inbox"** list. S2's test + asserts an Inbox task renders as "Inbox" and never "unnamed list". +- **Accepted coverage limit — surfaced, not silent:** the task filter has no + cursor/offset and silently truncates at 200. Inkstone does not add per-list + fan-out and makes no completeness guarantee when exactly 200 rows are returned. + **The truncation signal must survive NOTE filtering** (a 200-row response can + normalize to 199 visible tasks), so + `ticktick/tasks/list` returns an envelope: + `{ tasks: TickTickTaskRow[], sourceLimitReached: boolean }` with the flag + computed **on the raw row count, before kind filtering**. When it is true the + Tasks UI shows: **"TickTick returned its 200-item limit; this view may be + incomplete."** (S2; tested with a 200-raw/199-visible case.) The server + truncates silently; inkstone does not. +- **Two TickTick-named verbs:** + - **`ticktick/status`** — connection state (`connected | not_connected`) + the + opaque connection ID (A5). The Web calls it first; the ID keys every task + query. Required S2 infrastructure (the Web cannot construct a query key + without it), not polish. + - **`ticktick/tasks/list`** — the task read, backed by the two OpenAPI calls. +- **Reconnect protocol (account-key safety across Core restarts):** a Core restart + is exactly when the credential — and so the account — may have changed, and the + browser tab survives it with cached query data. On **every WebSocket + (re)connection** the Web: (1) resolves `ticktick/status` **first**; (2) **suspends + task reads** until it answers (task queries are `enabled:` only once the current + connection ID is known); (3) if the connection ID differs from the one in cache — + or the status is `not_connected` — **removes all task query data keyed by the old + ID** before any read resumes. Account B's rows can never land under account A's + key, and A's cached rows never render against B's connection. S2 tests the + restart-with-swapped-credential sequence end-to-end. +- **TanStack Query owns Web caching, dedup, stale rendering, refetching.** The + Core read is fixed (`{"status":[0]}` followed by kind filtering), so there is + **one task query per connection ID** — the query key is the connection ID alone, + never the token; + any list/tag/date filtering the Tasks UI offers is **display-only, applied + locally** over that one result. (If a Core-side filter parameter ever becomes + real, it gets defined on the verb first and only then joins the key.) Proposed + policy: + `staleTime: 60_000`, refetch on focus + reconnect, no polling, manual refresh + (per-query override of the global `staleTime: Infinity` in + `apps/web/src/main.tsx`, the pattern `useProviderStatus` already uses). +- **Failure semantics are the browser's:** a failed background refetch keeps + existing rows with an error/stale indication; a reload during an outage has no + rows and shows the fetch error (no local snapshot — accepted consequence). + +**A3 — Worker lane: direct MCP, read tools at two Inkstone gates.** +- The Worker connects to **TickTick's official MCP endpoint** (`mcp.ticktick.com`, + streamable HTTP — verified 2026-08-12: 401 Bearer challenge, RFC 9728 resource + metadata, scopes `tasks:read`/`tasks:write`) via a maintained MCP client library + (official TypeScript SDK). +- **A static, exact allowlist of read tools** (task-query + the list/tag read tools + the Workflow needs: `list_projects`, `list_tags`, `filter_tasks`, `search_task`, + `get_task_by_id`; names pinned by S1 from `tools/list`), applied **twice**: + filtering discovered tools before the model sees them, and again immediately + before executing any call. MCP annotations and schema-hiding are never the safety + mechanism. No create/update/complete/move/assign/comment-write/delete tool is + exposed or executed. +- **Credential limitation proven by S1:** the official MCP service requires + `tasks:write` even to initialize. There is no server-enforced read-only MCP + credential; the two exact Inkstone allowlist checks are the only write barrier. +- **Model-facing names are namespaced** (`ticktick_search_task`, …) against Core + registry collisions. **Discovery cost noted by S1:** the raw `tools/list` body is + ~171 KB per connection; the Worker performs discovery once per spawn and the + five-tool allowlist filter runs **before** anything reaches the model, so the + model-facing surface stays five small schemas — the 171 KB is a per-spawn network + cost only, never prompt content. +- **No local reimplementation of TickTick's filtering/ranking/query semantics.** + One S1 blind spot recorded: the advanced-query capture returned a small result, + so **`filter_tasks`'s own result ceiling was not probed**; if the MCP lane shows + truncation in practice it is the same accepted-limit class as the OpenAPI 200 cap + (the agent narrows its query), not a reason for fan-out. +- Workers stay ephemeral: Core passes the MCP endpoint + auth material in the spawn + manifest; Workers persist nothing. + +**A4 — Transcript integration: Core observes Worker-executed tools; fidelity is +specified end-to-end.** +Direct MCP execution bypasses the Tool Protocol, so the Worker emits **lifecycle +frames** on the existing outbound union, each sourced from its own pi event — not +hand-assembled state: +- `external_tool_started {tool_call_id, name (namespaced), arguments}` — from pi's + **`tool_execution_start`** event. +- `external_tool_finished {tool_call_id, result: TranscriptToolResult}` — one + terminal frame, from pi's **finalized `tool_execution_end`** event. **No outer + `is_error`**: the error flag lives once, inside `TranscriptToolResult`, and the + `tool_calls.status` column derives from `result.is_error` — a failed MCP call + persists as an error, not a success-shaped result, with one source of truth. +**Ordering:** pi executes a turn's tool calls in parallel and emits completions in +**completion order**, while the model-facing transcript keeps **source order**. +v1 sets **`executionMode: "sequential"`** explicitly — pi then runs the *entire +batch* sequentially, so frame order == source order **through the shipped +interface, by contract, not by luck**. Consequently there is no reverse-completion +case to test (it cannot occur under this mode); S3 instead tests **a multi-call +mixed Core + MCP batch arriving in source order** end-to-end (frames → `run_steps` +sequence → rendered timeline). Parallel-order handling is deferred until +parallelism is actually introduced. Core persists the call + result (+ error +status) into the existing `tool_calls` table (+ `run_steps` ordering), publishes +the existing live `tool_call` Run Events, and preserves ordering with assistant +text (ADR-0045 segment sealing). +**Resume fidelity — ONE transcript result type, no union, no runtime leak:** the +manifest's `tool_result.content` is a bare `S.String` today +(`packages/protocol/src/worker.ts` `ManifestMessage`), and `AgentToolResult` is +the wrong donor — its `terminate` is Worker-runtime control flow (and `details` a +runtime sidecar) that must not leak into a durable transcript. S3 defines the +protocol-owned + +```text +TranscriptToolResult { + content: [{type:"text", text} ...], // model-visible content blocks + is_error: bool, +} +``` + +as **the single currency of the transcript interface, for all tools**: the +`external_tool_finished` frame carries `{result: TranscriptToolResult}`; +`tool_calls.result_payload` persists it (with `tool_calls.status` derived from +`result.is_error`); and the resume manifest's `tool_result` block carries it — +**Core tool errors, Proposal Decisions, the not-executed placeholder, and MCP +results all migrate to this one shape** (replacing the string-reduction + +`is_error: None` in `crates/core/src/resume.rs` `render_result_content`). +`AgentToolResult` remains what it is — the live Worker-runtime shape — and the +Worker maps it to `TranscriptToolResult` at the frame boundary (drop +`terminate`/`details`, keep content + error). **Resume also restores the tool +NAME for external calls**: pi needs each replayed tool_result associated with its +call's name; resume derives it from the preceding persisted tool-call row (the +`tool_calls` row already pairs id + name) — carried explicitly in the manifest +block if pairing-by-position ever proves fragile. +**S1's job here is the MCP adapter mapping only** — how TickTick MCP result +payloads (text? structured content blocks?) map into `TranscriptToolResult.content` +— the wire shape itself is fixed by this plan, not by the spike. +**What chat shows (agreed):** external rows render as a **collapsed name + status +row** by default; **expanding reveals the normalized `TranscriptToolResult.content` +the model received** — errors identically (collapsed error row → expanded error +content). Live and after reload the row and its expansion are identical, which +requires BOTH paths to carry the result: +- **Live:** the `tool_call` Run Event today carries only + `{tool_call_id, name, status, arg?}` (`crates/core/src/protocol/run.rs` + `RunEvent::ToolCall`, mirrored in `packages/protocol/src/run.ts`) — **terminal + `tool_call` events gain an optional `result: TranscriptToolResult`**; started + events omit it. The Web store's merge (`apps/web/src/store/chat.ts` — today it + merges only `status` into an existing call) **merges the result too**. +- **Reload:** the Client-facing `Segment.tool_call` gains the same optional + `result` field, served from `tool_calls.result_payload`. +- **No grouping for result-bearing external calls:** `groupToolCalls` + (`apps/web/src/components/ToolActivity.tsx`) merges non-errored same-name calls + into one row, which would collapse two `ticktick_search_task` calls into one + ambiguous expandable — losing per-call identity. v1 rule: **external calls never + group; one expandable row per call**, keyed by `tool_call_id` (Core-tool grouping + is untouched). +- **Durable per-call identity:** the persisted `Segment.tool_call` carries no id + today (`crates/core/src/protocol/thread.rs`; hydration invents position-based + ids in `apps/web/src/store/hydrate.ts`) — **`Segment.tool_call` gains + `tool_call_id`**, served from the `tool_calls` row, so the reload row keys and + expands identically to the live one. +- **What marks a call external:** the **reserved `ticktick_` name prefix** — the + Core registry rejects registering any tool whose name starts with it (a + one-line guard + test), so the prefix is unambiguous at every consumer: the + Web's no-grouping rule, the frame handler, and resume all key off the name. + No parallel boolean to keep in sync while a call is still running. +- **Interrupted calls settle as errors, in both paths:** cancellation kills the + Worker (`crates/core/src/worker/run.rs`) and Worker death can land after + `external_tool_started` but before the finished frame — today the live path + settles the row (without a result) while reload *skips* pending calls + (`crates/core/src/db/threads.rs`), so the two would diverge. Contract, both + halves: + 1. **Persist:** when a Run terminates (cancelled, errored, Worker EOF), Core + settles every still-pending external `tool_calls` row with an explicit + interrupted result — `TranscriptToolResult {content: [{type:"text", + text:"interrupted"}], is_error: true}` — in the same transition that settles + the Run. + 2. **Publish:** the terminal paths emit only `cancelled`/`error`/`done` today + (`crates/core/src/cancel.rs`, `crates/core/src/worker/run.rs`) — the live tab + would settle the row without the result and diverge from reload. So **after + that transaction commits and before the terminal Run Event (and hub closure), + Core publishes a `tool_call {status: error, result: }` event for + each settled row.** For cancellation the order is pinned: **cancel verb + response → interrupted `tool_call` event(s) → `cancelled` Run Event**; for + Worker EOF: interrupted `tool_call` event(s) → the terminal event. + Note the content's provenance: **"interrupted" is Core-generated** — the one + case where an expansion shows Core-synthesized text rather than content the + model received (the model saw nothing; the Run died first). Every other + expansion remains exactly the model-received `TranscriptToolResult.content`. + Live and reload then agree by construction: the row renders as an error + ("interrupted") and expands to that content in both. **Verification is + `thread/get` rehydration, not Resume** — Resume applies only to parked Runs + (CONTEXT.md), and interrupted rows exist only on cancelled/errored Runs; Retry + deliberately drops unproposed tool calls, so the interrupted row is a rendered + record, never a replayed one. S3 tests both triggers: user cancellation after + `started`, and Worker EOF after `started` — asserting the live event arrives + before the terminal event, and the reloaded thread renders identically. +Never exposed: credentials, raw MCP metadata, runtime `details`/`terminate` +(already stripped at the frame boundary). No display argument in v1 (the reload +path derives `arg` via per-tool extractors that exist only for Core registry +tools). +**Storage claim, precisely:** Core persists no authoritative task state and no task +cache; task content appears incidentally inside the durable Run transcript +(`tool_calls.result_payload`), retained with the Run (cascade delete). + +**A5 — Credentials: manual provisioning, boot-read, restart to change.** +- The user provisions credential file(s) once (0600, under + `/inkstone/credentials/`). +- **Core reads credentials exactly once, at boot** (and hands the Worker its MCP + auth at spawn from that boot-read state). **There is no runtime re-read**: a 401 + from TickTick maps to the error state, and **credential changes require a Core + restart**. This closes the account-mixing hole (a 401-triggered re-read could + load a *different account's* token and fetch it under the old connection ID / + query key) and the silent-swap hole (replacing a still-valid token never 401s, so a + re-read path would never notice it anyway). Restart-to-change is the honest, + simple contract for a manually provisioned personal install. +- **Connection identity** = an **opaque, boot-scoped connection ID** (random, + generated at boot when a credential loads) — *not* a token-derived hash, so + nothing about the secret leaks into keys and no cross-boot equality is implied + (account alignment is proven separately, in S1). One boot, one credential, one + ID, so a query key can never span two accounts; a restart mints a new ID, and + the A2 reconnect protocol clears stale query data. Served by `ticktick/status`; + the token itself never appears in keys, transcripts, or logs. +- **"Disconnect" is a manual act:** delete/replace the file, restart. The Web's + not-connected state comes from `ticktick/status` after the restart. +- **One account, both lanes:** one `tasks:read tasks:write` token spans OpenAPI + + MCP, so one 0600 file establishes account alignment. TickTick reports an + approximately 180-day lifetime and no refresh token; reprovision manually at + expiry. The credential file records `obtained_at`; expiry lands as a hard 401 + → error state on both lanes at once, so **an "token likely expiring soon" + hint on `ticktick/status` (obtained_at + observed ~180d) is the named S5 + candidate** — polish, only if the first usable flow wants it. + +**A6 — No shared task model.** The two lanes share only Core's credential custody +and the same configured account. Web staleness is TanStack's; Worker errors are MCP +results the model reads. No snapshot type, no Core task cache, no generation, no +cross-lane consistency contract. + +**A7 — Config.** No task-source config file. Base URL and MCP endpoint are +compile-time constants (test-only overrides via the established env-seam pattern); +request timeouts follow the existing `parse_timeout_ms` env-knob pattern. No TTL — +there is no Core cache to tune. + +**A8 — S1 spike and the S1a addendum are complete.** The contract investigation +established: +- **OpenAPI:** the aggregate `{"status":[0]}` filter returns at most 200 entries and + may include NOTE rows. The product accepts that ceiling; normalization filters by + `kind`. Diagnostic project-data reads proved the staged account contained 208 + open tasks. Recurrence/checklist, all-day/timed semantics, and per-kind + nullability were observed. +- **MCP:** connection over streamable HTTP; the selected allowlist + namespacing; + the task-query tool answers a representative advanced question; error shapes. +- **Credentials:** one full-scope token authorizes both lanes; lifetime is about + 180 days without refresh; only OpenAPI accepts read-only scope, while MCP + requires write scope. +- **Payload reality:** representative response sizes (informs UI + prompt budgets). +- **S1a addendum:** the date-shape collapse (one due tuple) and the literal Inbox + sentinel (→ synthetic "Inbox" list). +The observed limits are accepted; **S2 and S3 are implemented under these +contracts** (both hidden until the S4 cutover). +Real TickTick responses and account data are not committed. Deterministic tests +use small hand-authored wire values; live service validation runs in the +credentialed `ticktick-live-smoke` workflow (scheduled + manually dispatched), +not the required PR gate. + +**A9 — Vocabulary.** **TickTick connection** — the configured account + credentials ++ endpoints. **`TickTickTaskRow`** — the Web lane's normalized wire row. **External +tool** — a model-facing MCP tool the Worker executes directly (`ticktick_*`), +observed by Core via lifecycle frames. **Tasks Topic** — the renamed GTD nav slot. +Avoid: Todo (retired at cutover), GTD Topic (renamed), Task Source / TaskSnapshot / +external read cache / projection / generation (deleted concepts), Connector, sync, +consolidated. + +## The product decision (retirement — the cutover slice) + +**What retires (S4):** the Todo Entity Type (mutation kinds, payload specs, +extraction prompts, proposal descriptor variants), Recurrence Rule + occurrence +generation (ADR-0037/0039), Todo Person References, the GTD todo processing views +(Inbox / Waiting / Scheduled / Today todo section), TodoEditor / DerivedTodoView / +GtdView web components, todo seeds/fixtures, and the CONTEXT.md task vocabulary. +Schema: tables dropped by editing migrations in place (AGENTS.md §5). Same slice: +GTD Topic → **Tasks** with the S2 surface linked; **Project Review relocates to the +Project surface**; the Worker's `ticktick_*` tools become reachable by the default +Workflow. No intermediate commit leaves the product task-less. + +**What survives:** Project (outcome Entity + review cadence, minus todo-ownership), +Person (minus Todo Person References), Journal/extraction for non-task entities. + +**Consequence (agreed):** chat task capture dies at cutover — "remind me to buy +milk" produces no tracked task anywhere; the agent's honest move is "add it in +TickTick" until a future write feature. ADRs 0037/0039/0055 get superseded-by notes. + +## Design (what changes where) + +```text +Browser ── ws verbs ──► Core ◄── stdio/manifest ──► Worker ──► LLM provider + │ │ │ + │ TanStack Query ├─ NEW crates/core/src/ticktick/ │ NEW: MCP client (official + │ (proposed: 60s │ client.rs reqwest: 2 reads, │ TS SDK) → mcp.ticktick.com + │ staleTime, focus/ │ const URL, timeouts │ · dual read-allowlist + │ reconnect refetch) │ wire.rs private transport │ · ticktick_* namespacing + │ │ types → TickTickTaskRow│ · endpoint+auth from manifest + │ │ token.rs 0600 file(s), read ONCE│ + │ │ at boot + opaque conn ID│ NEW: external-tool lifecycle + │ │ (restart to change) │ frames (pi finalized event) + │ │ + │ ├─ seam 1: ticktick/status + ticktick/tasks/list verbs (Web lane) + │ ├─ seam 2: manifest carries MCP endpoint + auth (Worker lane) + │ ├─ seam 3: lifecycle-frame handler → tool_calls (call + result) + + │ │ run_steps + live tool_call Run Events (terminal events carry + │ │ result) + resume reconstruction; chat renders collapsed rows + │ │ that expand to the model-received content (A4) + │ └─ S4 CUTOVER: retirement + GTD→Tasks + Project Review→Project +``` + +## Slices (each independently landable, gate-green) + +| # | Slice | Contents | Verify | +|---|-------|----------|--------| +| S1 | Contract spike (both lanes) — **complete for its original scope (GO with accepted limits)** | Manual probes covered the accepted 200-entry cap, kind-based NOTE exclusion, MCP `tools/list` + advanced query, one-token compatibility, scope limits, date/nullability/result/error shapes; the decision ledger retains the resulting adapter mapping, exact allowlist, and row candidates | rev 28 records accepted limits; raw responses and one-time capture tooling are not committed | +| S1a | Addendum probes — **COMPLETE (2026-08-15)** | A staged task with distinct start 09:30 / due 10:30 **collapsed to the due instant** through create, update, and filter read. The Inbox probe found a literal `inbox`-prefixed sentinel → synthetic "Inbox" list | `TickTickTaskRow` date shape is frozen to one due tuple; focused normalization tests pin both decisions | +| S2 | Hidden Web lane | `crates/core/src/ticktick/` (client, wire, token boot-read + opaque connection ID); **`ticktick/status`** + **`ticktick/tasks/list`** verbs; fixed 200-entry task read returning `{tasks, sourceLimitReached}` (flag computed pre-filter) with `TEXT`/`CHECKLIST` normalization; **date shape and Inbox labeling follow the resolved S1a contract** (one due tuple; `^inbox` sentinel → "Inbox"); TanStack Query integration (one task query per connection ID, display filtering local, reconnect protocol); Tasks UI built, **not linked in nav** | unit tests on small hand-authored wire values (kind-based NOTE exclusion, accepted cap behavior, all-day/timezone tuple normalization, absent-list-id → unnamed list); **date-shape tests assert the single due tuple** (S1a: collapse proven — no distinct start); **Inbox tests assert an Inbox task renders "Inbox", never "unnamed list"** (S1a outcome 1); **200-raw/199-visible case → `sourceLimitReached` true → "TickTick returned its 200-item limit; this view may be incomplete." renders**; fake-HTTP-server lifecycle (timeout, 401, stale/error behavior); account-swap reconnect e2e; no task/cache SQLite rows | +| S3 | Hidden Worker lane | Worker MCP client (official SDK) behind manifest-passed endpoint+auth; dual allowlist (discovery filter + pre-execution gate); `ticktick_*` namespacing; **`executionMode: "sequential"`** (whole batch — frame order == source order by contract); lifecycle frames (started ← `tool_execution_start`, finished ← finalized `tool_execution_end`) carrying **`{result: TranscriptToolResult}`** (no outer is_error; `tool_calls.status` derives from `result.is_error`); Worker maps AgentToolResult → TranscriptToolResult at the frame boundary (drops `terminate`/`details`); Core persists it; **resume migrates Core errors, Proposal Decisions, the not-executed placeholder, and MCP results to the same TranscriptToolResult AND restores each external call's tool name** (from the paired tool-call row); **expandable result UI: terminal `tool_call` Run Events AND `Segment.tool_call` gain optional `result: TranscriptToolResult` + `Segment.tool_call` gains `tool_call_id` (started events omit result); the Web store merges result into the existing call; collapsed name+status → expand shows content, errors identical; external calls NEVER group — one expandable row per call (Core-tool grouping untouched); `ticktick_` prefix reserved in the Core registry; Run termination settles pending external rows with the interrupted error result AND publishes `tool_call {status:error, result}` events after the tx, before the terminal Run Event** | fake-MCP-server e2e: advanced query → answer; **write tool absent from discovery AND rejected at the execution gate**; **failed MCP call persists as error, not success**; **a multi-call mixed Core/MCP batch lands in source order** (frames → run_steps → timeline); collapsed row + **expanded content identical live (pre-reload) and after reload**; **error expansion shows error content**; **two successful same-name external calls render as two rows with distinct results, live and reloaded**; **cancel-after-started AND Worker-EOF-after-started both settle the row as "interrupted" (is_error, Core-generated content): cancellation order pinned cancel-response → interrupted `tool_call` → `cancelled`; EOF: interrupted `tool_call` → terminal; `thread/get` rehydration renders identically (Resume is parked-only; Retry drops unproposed calls)**; no credentials/raw MCP metadata in any rendering; parked→resume replays a provider-valid transcript with the MCP call as TranscriptToolResult + its tool name — Core and external results decode through the one schema | +| S4 | Cutover: retire + expose | The retirement list; GTD Topic → Tasks with the S2 surface linked; Project Review → Project surface; `ticktick_*` tools reachable by the default Workflow; CONTEXT.md + ADR supersede notes | gate green; no `todo` mutation kind or tool surface; extraction e2e proposes no Todos; Project Review reachable + markable on Project; Tasks Topic live end-to-end; **no intermediate commit leaves the product task-less** | +| S5 | Polish (only if required) | Only what the first usable flow demands — named candidates: provisioning-guidance refinement; the `ticktick/status` expiry-proximity hint (A5); no snapshot/generation concepts return | web e2e for whatever ships; nothing else | + +ADR to open with S4 (drafted during S2–S3): **ADR-0064 "Task ownership moves to +TickTick: native Todo retirement + two external read paths"** — records the Agreed +ledger, the retirement list, A2/A3 (two lanes and why), A4 (lifecycle-frame seam + +the transcript-fidelity spec + the agreed expand-on-demand visibility), A5 (boot-read +credentials, restart-to-change, opaque connection ID), and the absence of writes. CONTEXT.md rewrites land +with S4. + +Definition of done per slice: `pnpm format && pnpm lint && pnpm check && pnpm -r test` +green, plus the Rust legs explicitly: `cargo check` and +`cargo test --manifest-path crates/core/Cargo.toml`. + +## The demo that proves it (post-S4) + +1. **No native Todo** model, table, recurrence code, editor, extraction behavior, or + task mutation remains. +2. **Web renders OpenAPI-backed TickTick rows** and follows the proposed + query/refetch behavior (stale indicator on failed background refetch; + reload-during-outage shows the fetch error). +3. **Worker answers an advanced task question through TickTick MCP**; the call + renders as a collapsed name + status row that **expands to the exact + `TranscriptToolResult.content` the model received** — identically live and + after transcript reload; errors expand the same way; the request + result + + tool name persist for provider-valid resume. +4. **Write tools are not exposed to the model, and the execution gate rejects + them.** This is an Inkstone-enforced guarantee only; S1 proved MCP requires a + write-scoped credential. +5. **SQLite contains no task authority or cache rows**; only ordinary Run transcript + records may contain returned task content. +6. **Web and Worker are demonstrably configured for the same TickTick account** + without requiring identical fetch timing. diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index a4f6a4ea..1ed62a97 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -10,4 +10,6 @@ export * from "./proposal.js"; export * from "./provider.js"; export * from "./run.js"; export * from "./thread.js"; +export * from "./ticktick.js"; +export * from "./transcript.js"; export * from "./worker.js"; diff --git a/packages/protocol/src/run.ts b/packages/protocol/src/run.ts index a3ae6e46..7cc5adea 100644 --- a/packages/protocol/src/run.ts +++ b/packages/protocol/src/run.ts @@ -3,6 +3,9 @@ import { Schema as S } from "effect"; +import { Segment } from "./thread.js"; +import { TranscriptToolResult } from "./transcript.js"; + /** `run/post_message` params: the target Thread + `prompt`, plus optional * `attachment_ids` — ids from prior `media/upload` calls to link to the user * Message (ADR-0058); omitted = no attachments. */ @@ -35,9 +38,15 @@ export const RunCancelParams = S.Struct({ run_id: S.String }); export type RunCancelParams = S.Schema.Type; -/** `run/cancel` result (ADR-0014): whether Core accepted the cancel command. */ +/** `run/cancel` result (ADR-0014): whether Core accepted the cancel command. + * `live_tail` (external-task-views A4): whether a terminal `cancelled` (plus any + * interrupted `tool_call` events) WILL arrive on the live subscribe stream — + * true only for a won running-cancel with a live hub. When false on an + * `accepted` cancel, the Client settles the bubble off this response (no stream + * event follows), instead of guessing with a timer. */ export const RunCancelResult = S.Struct({ outcome: S.Literal("accepted", "already_terminal", "unknown_run"), + live_tail: S.Boolean, }); export type RunCancelResult = S.Schema.Type; @@ -125,8 +134,19 @@ export const RunEvent = S.Union( name: S.String, status: S.Literal("started", "completed", "error"), arg: S.optional(S.String), + /** The normalized result the model received (external-task-views A4): + * carried on TERMINAL events of external (`ticktick_*`) calls so the live + * expandable row matches reload; started events (and Core-tool rows in v1) + * omit it. */ + result: S.optional(TranscriptToolResult), }), S.Struct({ kind: S.Literal("cancelled") }), + // The full ordered timeline as of the subscribe instant (external-task-views, + // review P1 #2): text / reasoning / tool_call / proposal segments in run_steps + // order, incl. a still-running call. Emitted ONCE as the snapshot; the Client + // ATOMICALLY REPLACES its segments with this list, so a reconnect renders the + // true interleaved order and never drops reasoning. + S.Struct({ kind: S.Literal("snapshot"), segments: S.Array(Segment) }), ); export type RunEvent = S.Schema.Type; diff --git a/packages/protocol/src/thread.ts b/packages/protocol/src/thread.ts index 1d0e7b84..fced4ead 100644 --- a/packages/protocol/src/thread.ts +++ b/packages/protocol/src/thread.ts @@ -3,6 +3,8 @@ import { Schema as S } from "effect"; +import { TranscriptToolResult } from "./transcript.js"; + /** `thread/create` params: the opening `prompt`, plus optional `attachment_ids` * — ids from prior `media/upload` calls to link to the user Message (ADR-0058); * omitted = no attachments. */ @@ -83,9 +85,16 @@ export const Segment = S.Union( S.Struct({ kind: S.Literal("text"), text: S.String }), S.Struct({ kind: S.Literal("tool_call"), + /** The durable per-call identity (external-task-views A4), served from the + * `tool_calls` row so the reload row keys and expands identically to the + * live one. */ + tool_call_id: S.String, name: S.String, status: S.String, arg: S.optional(S.String), + /** The normalized result the model received — populated for external + * (`ticktick_*`) calls in v1, so the collapsed row expands to it (A4). */ + result: S.optional(TranscriptToolResult), }), S.Struct({ kind: S.Literal("proposal"), diff --git a/packages/protocol/src/ticktick.ts b/packages/protocol/src/ticktick.ts new file mode 100644 index 00000000..a6a874b0 --- /dev/null +++ b/packages/protocol/src/ticktick.ts @@ -0,0 +1,68 @@ +// ticktick/* Web-lane wire schemas (external-task-views A2): the connection +// state the Web keys its task query on, and the normalized task rows. Core +// holds no task state — these are computed per read. Hand-mirror of Rust's +// serde shapes in crates/core/src/protocol/ticktick.rs (ADR-0009). + +import { Schema as S } from "effect"; + +/** `ticktick/status` result (A5): a discriminated union on `state`, so + * "connected" ALWAYS carries the opaque, boot-scoped connection ID and + * "not_connected" NEVER does — the two illegal shapes (connected-without-id, + * disconnected-with-id) are unrepresentable. The id is the SOLE task-query key. */ +export const TickTickStatusResult = S.Union( + S.Struct({ + state: S.Literal("connected"), + connection_id: S.String, + }), + S.Struct({ state: S.Literal("not_connected") }), +); + +export type TickTickStatusResult = S.Schema.Type; + +/** A task's single due tuple (S1a: start/due collapse, so no separate start). + * `date` is TickTick's UTC instant; `is_all_day` + `time_zone` carry the local + * meaning. Absent on an undated task. */ +export const TickTickDue = S.Struct({ + date: S.String, + is_all_day: S.Boolean, + time_zone: S.String, +}); + +export type TickTickDue = S.Schema.Type; + +/** One checklist sub-item of a CHECKLIST task. */ +export const TickTickChecklistItem = S.Struct({ + title: S.String, + done: S.Boolean, +}); + +export type TickTickChecklistItem = S.Schema.Type; + +/** A normalized TickTick task row for the Web Tasks surface (A2). `list_name` + * is the resolved list (a `/project` name, `"Inbox"` for the sentinel, or + * absent = "unnamed list"). Only `TEXT`/`CHECKLIST` kinds reach here. */ +export const TickTickTaskRow = S.Struct({ + id: S.String, + list_name: S.optional(S.String), + title: S.String, + kind: S.String, + priority: S.Number, + tags: S.Array(S.String), + due: S.optional(TickTickDue), + repeat_flag: S.optional(S.String), + checklist_items: S.Array(TickTickChecklistItem), +}); + +export type TickTickTaskRow = S.Schema.Type; + +/** `ticktick/tasks/list` result (A2): the normalized rows plus the truncation + * signal (`source_limit_reached`, computed on the RAW row count before kind + * filtering, so it survives NOTE removal). */ +export const TickTickTasksListResult = S.Struct({ + tasks: S.Array(TickTickTaskRow), + source_limit_reached: S.Boolean, +}); + +export type TickTickTasksListResult = S.Schema.Type< + typeof TickTickTasksListResult +>; diff --git a/packages/protocol/src/transcript.ts b/packages/protocol/src/transcript.ts new file mode 100644 index 00000000..04c9fb85 --- /dev/null +++ b/packages/protocol/src/transcript.ts @@ -0,0 +1,41 @@ +// The transcript result currency (external-task-views A4): shared by the +// Worker frame union (worker.ts), the Run Event stream (run.ts), and the +// thread/get Segment timeline (thread.ts). Its own module so those three can +// import it without a cycle. + +import { Schema as S } from "effect"; + +/** The only `content` modality Core produces today (image is out of scope). */ +export const ToolTextContent = S.Struct({ + type: S.Literal("text"), + text: S.String, +}); + +export type ToolTextContent = S.Schema.Type; + +/** The single transcript result type for ALL tools (external-task-views A4): + * the model-visible content blocks plus the ONE error flag. Carried by the + * `external_tool_finished` frame, persisted in `tool_calls.result_payload` for + * external (`ticktick_*`) calls, served on terminal `tool_call` Run Events and + * `Segment.tool_call`, and replayed in the resume manifest's `tool_result` + * blocks — Core tool results, Proposal Decisions, the not-executed placeholder, + * and MCP results all reduce to this shape. Deliberately no runtime + * `details`/`terminate`: those are Worker-runtime control flow, never durable. */ +export const TranscriptToolResult = S.Struct({ + content: S.Array(ToolTextContent), + is_error: S.Boolean, +}); + +export type TranscriptToolResult = S.Schema.Type; + +/** The reserved name prefix of EXTERNAL tools — Worker-executed MCP tools the + * model sees as `ticktick_*` (external-task-views A3/A4). The Core registry + * reserves it (crates/core/src/tools/mod.rs `EXTERNAL_TOOL_PREFIX`), so the + * prefix alone marks a call external at every consumer: the Worker's frame + * emission and the Web's no-grouping rule both key off it. */ +export const EXTERNAL_TOOL_PREFIX = "ticktick_"; + +/** Whether `name` is an external (Worker-executed MCP) tool. */ +export function isExternalToolName(name: string): boolean { + return name.startsWith(EXTERNAL_TOOL_PREFIX); +} diff --git a/packages/protocol/src/worker.ts b/packages/protocol/src/worker.ts index b2f8d8b2..6e867f51 100644 --- a/packages/protocol/src/worker.ts +++ b/packages/protocol/src/worker.ts @@ -4,16 +4,10 @@ import { Schema as S } from "effect"; import { WorkerRunEvent } from "./run.js"; +import { ToolTextContent, TranscriptToolResult } from "./transcript.js"; // tool protocol (ADR-0018): the Worker<->Core duplex for tool calls — see docs/design/protocol.md - -/** The only `content` modality Core produces today (image is out of scope). */ -export const ToolTextContent = S.Struct({ - type: S.Literal("text"), - text: S.String, -}); - -export type ToolTextContent = S.Schema.Type; +// (transcript.ts's own schemas reach the barrel via index.ts, not this file.) /** Hand-mirror of pi-agent-core's `AgentToolResult` (ADR-0018:201; no `isError`). */ export const AgentToolResult = S.Struct({ @@ -48,6 +42,22 @@ export const ToolResult = S.Struct({ export type ToolResult = S.Schema.Type; +/** Core → Worker: durable acceptance or rejection of one external lifecycle + * frame. The dedicated one-Run Worker pipe supplies run identity, so the ACK + * correlates by tool_call_id + phase. Core logs the failure detail. */ +export const ExternalToolAck = S.Struct({ + kind: S.Literal("external_tool_ack"), + tool_call_id: S.String, + phase: S.Literal("started", "finished"), + ok: S.Boolean, +}); + +export type ExternalToolAck = S.Schema.Type; + +/** Every post-manifest frame Core can write to the Worker. */ +export const WorkerInbound = S.Union(ToolResult, ExternalToolAck); +export type WorkerInbound = S.Schema.Type; + /** One tool the Workflow exposes; shipped in the WorkflowManifest. */ export const CoreToolDescriptor = S.Struct({ name: S.String, @@ -58,8 +68,37 @@ export const CoreToolDescriptor = S.Struct({ export type CoreToolDescriptor = S.Schema.Type; +/** Worker → Core: an EXTERNAL (Worker-executed MCP, `ticktick_*`) tool call + * began (external-task-views A4). Sourced from pi's `tool_execution_start` + * event — never hand-assembled state. `name` is the namespaced model-facing + * name; `arguments` the validated call args. */ +export const ExternalToolStarted = S.Struct({ + kind: S.Literal("external_tool_started"), + tool_call_id: S.String, + name: S.String, + arguments: S.Unknown, +}); + +export type ExternalToolStarted = S.Schema.Type; + +/** Worker → Core: an external call's ONE terminal frame, from pi's finalized + * `tool_execution_end` event. No outer error flag — `result.is_error` is the + * single source of truth; `tool_calls.status` derives from it. */ +export const ExternalToolFinished = S.Struct({ + kind: S.Literal("external_tool_finished"), + tool_call_id: S.String, + result: TranscriptToolResult, +}); + +export type ExternalToolFinished = S.Schema.Type; + /** What the Worker writes to stdout (mirrors Rust's `WorkerStdout` in crates/core/src/protocol/worker.rs). */ -export const WorkerOutbound = S.Union(WorkerRunEvent, ToolRequest); +export const WorkerOutbound = S.Union( + WorkerRunEvent, + ToolRequest, + ExternalToolStarted, + ExternalToolFinished, +); export type WorkerOutbound = S.Schema.Type; @@ -99,11 +138,14 @@ export const ManifestMessage = S.Union( text: S.optional(S.String), tool_calls: S.optional(S.Array(ManifestToolCall)), }), + // The paired result for a prior tool_call (ADR-0025), carried as the ONE + // transcript result type (external-task-views A4): Core tool results, + // Proposal Decisions, the not-executed placeholder, and MCP results all + // arrive through this same shape. S.Struct({ role: S.Literal("tool_result"), tool_call_id: S.String, - content: S.String, - is_error: S.optional(S.Boolean), + result: TranscriptToolResult, }), ); @@ -136,6 +178,19 @@ export const WorkerManifest = S.Struct({ attachments: S.optional( S.Array(S.Struct({ mime: S.String, data_base64: S.String })), ), + /** External (Worker-executed MCP) tool config (external-task-views A3/A5): + * the TickTick MCP endpoint + auth Core hands the Worker at spawn from its + * boot-read credential state. Absent = no external tools this Run. */ + external_tools: S.optional( + S.Struct({ + endpoint: S.String, + access_token: S.String, + timeout_ms: S.Number.pipe( + S.int({ description: undefined }), + S.greaterThanOrEqualTo(1, { description: undefined }), + ), + }), + ), }); export type WorkerManifest = S.Schema.Type; diff --git a/packages/protocol/test/index.test.ts b/packages/protocol/test/index.test.ts index 046e2402..ff6901c2 100644 --- a/packages/protocol/test/index.test.ts +++ b/packages/protocol/test/index.test.ts @@ -91,16 +91,18 @@ describe("RunCancelParams", () => { }); describe("RunCancelResult", () => { - it("decodes each outcome and encodes back unchanged", () => { + it("decodes each outcome + live_tail and encodes back unchanged", () => { for (const outcome of [ "accepted", "already_terminal", "unknown_run", ] as const) { - const wire = { outcome }; - const decoded = S.decodeUnknownSync(RunCancelResult)(wire); - expect(decoded).toEqual(wire); - expect(S.encodeSync(RunCancelResult)(decoded)).toEqual(wire); + for (const live_tail of [true, false]) { + const wire = { outcome, live_tail }; + const decoded = S.decodeUnknownSync(RunCancelResult)(wire); + expect(decoded).toEqual(wire); + expect(S.encodeSync(RunCancelResult)(decoded)).toEqual(wire); + } } }); @@ -602,6 +604,33 @@ describe("WorkerManifest", () => { expect(S.decodeUnknownSync(WorkerManifest)(valid)).toEqual(valid); }); + it("requires the external-tool timeout beside endpoint and auth", () => { + const external = { + ...valid, + external_tools: { + endpoint: "https://mcp.ticktick.com/", + access_token: "tok_ticktick", + timeout_ms: 250, + }, + }; + expect(S.decodeUnknownSync(WorkerManifest)(external)).toEqual(external); + const { timeout_ms: _omit, ...withoutTimeout } = external.external_tools; + expect(() => + S.decodeUnknownSync(WorkerManifest)({ + ...external, + external_tools: withoutTimeout, + }), + ).toThrow(); + for (const timeout_ms of [0, -1, 1.5]) { + expect(() => + S.decodeUnknownSync(WorkerManifest)({ + ...external, + external_tools: { ...external.external_tools, timeout_ms }, + }), + ).toThrow(); + } + }); + it("decodes a manifest without an access token (faux/env providers)", () => { const { access_token: _omit, ...noToken } = valid; expect(S.decodeUnknownSync(WorkerManifest)(noToken)).toEqual(noToken); @@ -639,18 +668,22 @@ describe("WorkerManifest", () => { ).toThrow(); }); - it("decodes mode: fresh and a tool_result carrying is_error", () => { + it("decodes mode: fresh and a tool_result carrying an error result", () => { const fresh = { ...valid, mode: "fresh" }; expect(S.decodeUnknownSync(WorkerManifest)(fresh)).toEqual(fresh); + // The ONE transcript result type (external-task-views A4): the error + // flag lives inside `result`, never as a sibling field. const withError = { ...valid, messages: [ { role: "tool_result", tool_call_id: "tc_9", - content: "boom", - is_error: true, + result: { + content: [{ type: "text", text: "boom" }], + is_error: true, + }, }, ], }; diff --git a/packages/ui-sdk/src/index.ts b/packages/ui-sdk/src/index.ts index 636b80ca..228b5bad 100644 --- a/packages/ui-sdk/src/index.ts +++ b/packages/ui-sdk/src/index.ts @@ -33,6 +33,8 @@ import { ThreadGetResult, ThreadListResult, ThreadMutateResult, + TickTickStatusResult, + TickTickTasksListResult, } from "@inkstone/protocol"; import { Cause, @@ -255,6 +257,20 @@ export const requestDescriptors = { toParams: () => ({}), result: ThreadListResult, }, + // ticktick/status + ticktick/tasks/list (external-task-views A2). Status is + // resolved FIRST on every (re)connection; its opaque connection_id is the + // SOLE key the Web uses for the task query (the reconnect protocol gates + // task reads on it). tasks/list is the two-read OpenAPI fetch, normalized. + tickTickStatus: { + method: "ticktick/status", + toParams: () => ({}), + result: TickTickStatusResult, + }, + tickTickTasksList: { + method: "ticktick/tasks/list", + toParams: () => ({}), + result: TickTickTasksListResult, + }, // run/get_history (ADR-0028 as-built): the recent-Runs feed, newest-first. // A `limit` is sent only when given; omitting it lets Core apply its // default (an undefined field serializes away to `{}`). diff --git a/packages/ui-sdk/test/index.test.ts b/packages/ui-sdk/test/index.test.ts index e3f796b2..9a78572e 100644 --- a/packages/ui-sdk/test/index.test.ts +++ b/packages/ui-sdk/test/index.test.ts @@ -184,15 +184,32 @@ describe("WsClient", () => { status: "complete", run_id: "01234567-89ab-7def-8012-345678901234", // The assistant turn's ordered segments[] (ADR-0045): tool rows, then - // the reply text — covers the text + tool_call (with/without arg) variants. + // the reply text — covers the text + tool_call (with/without arg, + // with an external result — external-task-views A4) variants. segments: [ { kind: "tool_call", + tool_call_id: "tc_1", name: "search_entities", status: "completed", arg: "Lev", }, - { kind: "tool_call", name: "read_thread", status: "completed" }, + { + kind: "tool_call", + tool_call_id: "tc_2", + name: "read_thread", + status: "completed", + }, + { + kind: "tool_call", + tool_call_id: "tc_3", + name: "ticktick_filter_tasks", + status: "completed", + result: { + content: [{ type: "text", text: "1 task found" }], + is_error: false, + }, + }, { kind: "text", text: "echo: hi" }, ], }, @@ -1224,6 +1241,30 @@ const cannedCases: Record = { params: {}, response: { threads: [{ id: "t-1", title: "T", last_activity_at: 1 }] }, }, + tickTickStatus: { + args: [], + method: "ticktick/status", + params: {}, + response: { state: "connected", connection_id: "conn-1" }, + }, + tickTickTasksList: { + args: [], + method: "ticktick/tasks/list", + params: {}, + response: { + source_limit_reached: false, + tasks: [ + { + id: "t1", + title: "buy milk", + kind: "TEXT", + priority: 0, + tags: [], + checklist_items: [], + }, + ], + }, + }, getRunHistory: { args: [2], method: "run/get_history", @@ -1324,7 +1365,7 @@ const cannedCases: Record = { args: ["r-1"], method: "run/cancel", params: { run_id: "r-1" }, - response: { outcome: "already_terminal" }, + response: { outcome: "already_terminal", live_tail: false }, }, retryRun: { args: ["r-1"], diff --git a/packages/worker/eval/run.ts b/packages/worker/eval/run.ts index 6bab240b..516ae5b3 100644 --- a/packages/worker/eval/run.ts +++ b/packages/worker/eval/run.ts @@ -216,7 +216,7 @@ function searchWorld( /** The eval transport for one fixture. Dispatches `callTool` by NAME (pi assigns * tool_call_ids at runtime, so we cannot pre-key by id like InMemoryTransport). * It records the captured propose call into `capture.current`, and Run Events - * into `events`. */ + * into `events`. The eval workflow ships no external tools. */ function evalTransport( fixture: Fixture, events: WorkerRunEvent[], @@ -224,9 +224,8 @@ function evalTransport( ): Layer.Layer { return Layer.succeed(WorkerTransport, { readManifest: Effect.succeed(null), - emit: (event) => { - events.push(event); - }, + emit: (event) => events.push(event), + syncExternalTool: () => Promise.resolve(), callTool: (_toolCallId, name, params) => { switch (name) { case "search_entities": diff --git a/packages/worker/package.json b/packages/worker/package.json index 70c1944f..426d5687 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -7,6 +7,7 @@ "@earendil-works/pi-agent-core": "0.80.2", "@earendil-works/pi-ai": "0.80.2", "@inkstone/protocol": "workspace:*", + "@modelcontextprotocol/sdk": "^1.30.0", "effect": "^3.21.2" }, "scripts": { diff --git a/packages/worker/src/external-tools.ts b/packages/worker/src/external-tools.ts new file mode 100644 index 00000000..181d1d42 --- /dev/null +++ b/packages/worker/src/external-tools.ts @@ -0,0 +1,307 @@ +import type { + AfterToolCallContext, + AfterToolCallResult, + AgentEvent, + AgentTool, + AgentToolResult, +} from "@earendil-works/pi-agent-core"; +import type { + ExternalToolFinished, + ExternalToolStarted, + WorkerManifest, +} from "@inkstone/protocol"; +import { EXTERNAL_TOOL_PREFIX, isExternalToolName } from "@inkstone/protocol"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +// External (Worker-executed MCP) tools — external-task-views A3/A4. The Worker +// connects DIRECTLY to TickTick's official MCP service (manifest-passed +// endpoint + auth), discovers once per spawn, and exposes ONLY the exact read +// allowlist below — applied twice (discovery filter + pre-execution gate). +// Server annotations/schema-hiding are never the safety mechanism, and there is +// no server-enforced read-only credential (S1: MCP demands `tasks:write`). + +/** The exact approved read allowlist: list/tag discovery, advanced server-side + * filtering, keyword search, and detail lookup. No + * create/update/complete/move/assign/comment-write/delete tool is ever exposed + * or executed. */ +export const EXTERNAL_READ_ALLOWLIST: readonly string[] = [ + "list_projects", + "list_tags", + "filter_tasks", + "search_task", + "get_task_by_id", +]; + +/** The details sidecar the external executor attaches so the interpreter's + * `afterToolCall` can carry the MCP result's own `isError` into pi's error flag + * (pi tools otherwise signal errors only by throwing). Worker-runtime only — + * dropped at the frame boundary, never durable. */ +export interface ExternalCallDetails { + external_is_error: boolean; +} + +/** One MCP content block we forward: text-only, matching the S1 adapter + * contract (copy `result.content` verbatim, keep `type:"text"` blocks). */ +interface McpTextBlock { + type: "text"; + text: string; +} + +/** Narrow a result's `content` to the text blocks we forward, COERCING each + * `text` via `String(...)` so a spec-loose server's non-string `text` can never + * leak through unstringified. The ONE text-block narrowing both `adaptMcpResult` + * and `externalFrameFor` use — previously the finished-frame mapping re-did it + * inline with a weaker (unchecked) guard (review M3). */ +function narrowTextBlocks(content: unknown): McpTextBlock[] { + const blocks = Array.isArray(content) ? content : []; + return blocks + .filter( + (block): block is { type: "text"; text: unknown } => + typeof block === "object" && + block !== null && + (block as { type?: unknown }).type === "text", + ) + .map((block): McpTextBlock => ({ type: "text", text: String(block.text) })); +} + +/** Adapt an MCP `tools/call` result to the model-visible content: copy the text + * blocks of `result.content` verbatim; DROP the duplicate `structuredContent` + * sidecar and every transport detail. Exported for the adapter unit tests. */ +export function adaptMcpResult(result: unknown): { + content: McpTextBlock[]; + isError: boolean; +} { + const record = ( + typeof result === "object" && result !== null ? result : {} + ) as { content?: unknown; isError?: unknown }; + return { + content: narrowTextBlocks(record.content), + isError: record.isError === true, + }; +} + +/** Map a pi tool-execution event to the external-call lifecycle frame it emits, + * or `undefined` for any non-external event (external-task-views A4). ONLY + * `ticktick_*` calls emit frames — Core-proxied tools reach Core through the + * Tool Protocol round-trip. The finished frame copies the FINALIZED result's + * text blocks through the shared `narrowTextBlocks` (review M3); the error flag + * lives once, inside the result. Lives here beside the seam it belongs to, not + * inline in `runInterpreter`. */ +export function externalFrameFor( + event: AgentEvent, +): ExternalToolStarted | ExternalToolFinished | undefined { + if ( + event.type === "tool_execution_start" && + isExternalToolName(event.toolName) + ) { + return { + kind: "external_tool_started", + tool_call_id: event.toolCallId, + name: event.toolName, + arguments: event.args, + }; + } + if ( + event.type === "tool_execution_end" && + isExternalToolName(event.toolName) + ) { + return { + kind: "external_tool_finished", + tool_call_id: event.toolCallId, + result: { + content: narrowTextBlocks( + (event.result as { content?: unknown })?.content, + ), + is_error: event.isError, + }, + }; + } + return undefined; +} + +/** pi `afterToolCall` hook: lift an external MCP result's own `isError` (carried + * in `details`, since pi tools otherwise signal errors only by throwing) into + * pi's error flag. Core tools and clean external results pass through untouched. + * Async to match pi's `afterToolCall` signature (review M3). */ +export async function liftExternalIsError( + ctx: AfterToolCallContext, +): Promise { + return isExternalToolName(ctx.toolCall.name) && + (ctx.result.details as ExternalCallDetails | undefined) + ?.external_is_error === true + ? { isError: true } + : undefined; +} + +/** The slice of the MCP client the executor needs — a seam so the gate + + * adapter are testable against a fake without a live connection. The result is + * `unknown` because the SDK's return union spans protocol revisions; the + * adapter narrows it. */ +export interface ExternalCaller { + callTool( + params: { + name: string; + arguments: Record; + }, + resultSchema?: undefined, + options?: { timeout?: number }, + ): Promise; +} + +interface DiscoveredExternalTool { + readonly name: string; + readonly description?: string; + readonly inputSchema: unknown; +} + +export interface ExternalDiscoveryCaller { + listTools( + params?: { cursor?: string }, + options?: { timeout?: number }, + ): Promise<{ + tools: DiscoveredExternalTool[]; + nextCursor?: string; + }>; +} + +/** A corrupt server must not keep a Worker in discovery forever even if every + * cursor is unique. This is deliberately far above the five-tool expected + * surface while still giving the loop a hard end. */ +const EXTERNAL_DISCOVERY_PAGE_LIMIT = 100; + +/** Execute one allowlisted external call: gate #2 (the exact allowlist again, + * immediately before execution — defense in depth on top of the discovery + * filter), then the server call, then the adapter. The MCP result's own + * `isError` rides `details` for the interpreter's `afterToolCall` to lift into + * pi's error flag. */ +export async function callExternalTool( + caller: ExternalCaller, + serverName: string, + params: unknown, + timeoutMs: number, +): Promise> { + if (!EXTERNAL_READ_ALLOWLIST.includes(serverName)) { + throw new Error(`external tool ${serverName} is not in the read allowlist`); + } + const result = await caller.callTool( + { + name: serverName, + arguments: (params ?? {}) as Record, + }, + undefined, + { timeout: timeoutMs }, + ); + const adapted = adaptMcpResult(result); + return { + content: adapted.content, + details: { external_is_error: adapted.isError }, + }; +} + +/** Discover every page under one timeout policy. Repeated cursors fail before + * another request; endlessly unique cursors hit a finite page ceiling. */ +export async function discoverExternalTools( + caller: ExternalDiscoveryCaller, + timeoutMs: number, +): Promise { + const discovered: DiscoveredExternalTool[] = []; + const seenCursors = new Set(); + let cursor: string | undefined; + + for ( + let pageNumber = 0; + pageNumber < EXTERNAL_DISCOVERY_PAGE_LIMIT; + pageNumber++ + ) { + const page = await caller.listTools( + cursor === undefined ? undefined : { cursor }, + { timeout: timeoutMs }, + ); + discovered.push(...page.tools); + + const nextCursor = page.nextCursor; + if (nextCursor === undefined) return discovered; + if (seenCursors.has(nextCursor)) { + throw new Error("MCP discovery returned a repeated cursor"); + } + seenCursors.add(nextCursor); + cursor = nextCursor; + } + + throw new Error( + `MCP discovery exceeded the ${EXTERNAL_DISCOVERY_PAGE_LIMIT}-page limit`, + ); +} + +/** Build the namespaced `AgentTool`s from a discovered tool list: gate #1 — + * only the exact allowlisted names survive, so the model never sees a write + * tool's schema. Exported for the discovery-filter unit tests. */ +export function buildExternalTools( + caller: ExternalCaller, + discovered: ReadonlyArray, + timeoutMs: number, +): AgentTool[] { + return discovered + .filter((tool) => EXTERNAL_READ_ALLOWLIST.includes(tool.name)) + .map((tool): AgentTool => { + const serverName = tool.name; + return { + name: `${EXTERNAL_TOOL_PREFIX}${serverName}`, + description: tool.description ?? "", + label: `TickTick ${serverName.replaceAll("_", " ")}`, + parameters: tool.inputSchema as AgentTool["parameters"], + execute: (_toolCallId, params) => + callExternalTool(caller, serverName, params, timeoutMs), + }; + }); +} + +/** Connect to the manifest's MCP endpoint, discover its tools ONCE per spawn, + * and build the allowlisted, namespaced `AgentTool`s. Returns the tools plus a + * `close` for the interpreter's end-of-run cleanup. A connect/discovery + * failure rejects — worker-main converts it into the Run's terminal `error` + * event (the Workflow opted into external tools; a broken dependency fails + * loud, not silently tool-less). */ +export async function connectExternalTools( + config: NonNullable, +): Promise<{ tools: AgentTool[]; close: () => Promise }> { + const client = new Client({ name: "inkstone-worker", version: "0.0.0" }); + const transport = new StreamableHTTPClientTransport( + new URL(config.endpoint), + { + requestInit: { + headers: { authorization: `Bearer ${config.access_token}` }, + }, + }, + ); + try { + await client.connect(transport, { timeout: config.timeout_ms }); + const discovered = await discoverExternalTools(client, config.timeout_ms); + + const names = discovered.map((tool) => tool.name); + if (new Set(names).size !== names.length) { + throw new Error("MCP discovery returned duplicate tool names"); + } + const tools = buildExternalTools(client, discovered, config.timeout_ms); + if (tools.length !== EXTERNAL_READ_ALLOWLIST.length) { + const offered = new Set(names); + const missing = EXTERNAL_READ_ALLOWLIST.filter( + (name) => !offered.has(name), + ); + throw new Error( + `MCP discovery is missing allowlisted tool(s): ${missing.join(", ")}`, + ); + } + + return { + tools, + close: () => client.close(), + }; + } catch (error) { + // The caller never receives `close` on any connect/discovery/validation + // failure, so this branch owns cleanup for all of them. + await client.close().catch(() => undefined); + throw error; + } +} diff --git a/packages/worker/src/faux/faux-worker.ts b/packages/worker/src/faux/faux-worker.ts index be96c6e8..97924e2d 100644 --- a/packages/worker/src/faux/faux-worker.ts +++ b/packages/worker/src/faux/faux-worker.ts @@ -389,33 +389,24 @@ function searchResultsFromToolResult(text: string): SearchResultRow[] { type AnyMessage = { role: string; content?: unknown; + /** Wire `tool_result` blocks carry the ONE transcript result type + * (external-task-views A4); pi runtime `toolResult`s keep flat `content`. */ + result?: { content?: unknown }; tool_call_id?: string; toolCallId?: string; }; +/** The flattened text of a tool result, across both transcript forms: the wire + * `tool_result`'s `result.content` blocks, or the pi `toolResult`'s `content`. */ +function toolResultText(m: AnyMessage): string { + return textOf(m.result?.content ?? m.content); +} + /** The tool-call id a tool_result message answers, across both transcript forms. */ function toolResultCallId(m: AnyMessage): string | undefined { return m.tool_call_id ?? m.toolCallId; } -/** Unwrap a tool_result content string to the tool's own inner payload text. - * In-process pi `toolResult`s flatten to the bare inner JSON, but a RESUME - * transcript carries Core's serialized `AgentToolResult` envelope verbatim - * (`{"content":[{"type":"text","text":""}],…}` — see resume.rs - * render_result_content). Peel that envelope so the parse helpers see the inner - * `{entries|results …}` either way; leave already-bare text untouched. */ -function unwrapToolResultText(text: string): string { - try { - const parsed = JSON.parse(text) as { content?: unknown }; - if (Array.isArray(parsed.content)) { - return textOf(parsed.content); - } - } catch { - // Not an envelope (already bare inner JSON) — fall through. - } - return text; -} - /** Newest-first scan for the latest tool_result content matching `predicate`. */ function latestToolResultText( messages: readonly AnyMessage[], @@ -424,7 +415,7 @@ function latestToolResultText( for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]; if (m.role !== "tool_result" && m.role !== "toolResult") continue; - const text = unwrapToolResultText(textOf(m.content)); + const text = toolResultText(m); if (predicate(text)) return text; } return undefined; @@ -490,7 +481,7 @@ function searchedEntityId( const m = messages[i]; if (m.role !== "tool_result" && m.role !== "toolResult") continue; if (toolResultCallId(m) !== searchCallId) continue; - const text = unwrapToolResultText(textOf(m.content)); + const text = toolResultText(m); if (!text.includes('"results"')) continue; return searchResultsFromToolResult(text)[0]?.id; } @@ -511,19 +502,24 @@ export function extractionPhase(manifest: WorkerManifest): ExtractionPhase { const decisions = manifest.messages.filter( (m): m is Extract => - m.role === "tool_result" && decisionOutcome(m.content) !== undefined, + m.role === "tool_result" && + decisionOutcome(textOf(m.result.content)) !== undefined, ); const latest = decisions.at(-1); - if (latest !== undefined && decisionOutcome(latest.content) === "declined") { + if ( + latest !== undefined && + decisionOutcome(textOf(latest.result.content)) === "declined" + ) { return "dismiss"; } const acceptedCreateOf = (kind: string) => - decisions.some((d) => acceptedCreate(d.content, kind)); + decisions.some((d) => acceptedCreate(textOf(d.result.content), kind)); // The Todo flow is a single create with no reference step, so an accepted // create_todo Decision means the flow is complete. if (acceptedCreateOf("Todo")) return "done"; - if (decisions.some((d) => acceptedReference(d.content))) return "done"; + if (decisions.some((d) => acceptedReference(textOf(d.result.content)))) + return "done"; if (acceptedCreateOf("Person") || acceptedCreateOf("Project")) return "after_create_entity"; if (acceptedCreateOf("Journal Entry")) return "after_journal"; @@ -802,7 +798,7 @@ function decisionFor( const m = messages[i]; if (m.role !== "tool_result" && m.role !== "toolResult") continue; if (toolResultCallId(m) !== callId) continue; - return decisionOutcome(textOf(m.content)); + return decisionOutcome(toolResultText(m)); } return undefined; } @@ -971,7 +967,9 @@ function setCaptureResponses( // after_create vs after_link — both resume into the enrichment leg. if (manifest.mode === "resume") { const todoCreated = manifest.messages.some( - (m) => m.role === "tool_result" && acceptedCreate(m.content, "Todo"), + (m) => + m.role === "tool_result" && + acceptedCreate(textOf(m.result.content), "Todo"), ); if (todoCreated) { setCaptureEnrichResponses(faux, manifest, scenario); @@ -1120,6 +1118,27 @@ export function fauxDepsFor(manifest: WorkerManifest): InterpreterDeps { faux.setResponses([ fauxAssistantMessage("", { stopReason: "error", errorMessage }), ]); + } else if (process.env.INKSTONE_FAUX_EXTERNAL !== undefined) { + // External tool-call mode (e2e, external-task-views A3/A4): one + // `ticktick_filter_tasks` call per comma-separated entry ("error" sends + // empty args so the fake MCP server fails the call), then a final turn + // echoing the LAST tool result — proving the model received the content. + const entries = process.env.INKSTONE_FAUX_EXTERNAL.split(","); + faux.setResponses([ + ...entries.map((entry, index) => + toolCallTurn( + "ticktick_filter_tasks", + entry === "error" ? {} : { filter: { status: [0] }, call: index + 1 }, + `tc_ext_${index + 1}`, + ), + ), + (context) => { + const toolResult = [...context.messages] + .reverse() + .find((m) => m.role === "toolResult"); + return textTurn(`external result: ${textOf(toolResult?.content)}`); + }, + ]); } else if (process.env.INKSTONE_FAUX_TOOL_CALL === "1") { // Tool-call mode (e2e): turn 1 read_thread on the pasted id, turn 2 echoes the result. faux.setResponses([ @@ -1178,7 +1197,11 @@ export function fauxDepsFor(manifest: WorkerManifest): InterpreterDeps { .reverse() .find((message) => message.role === "tool_result"); faux.setResponses([ - textTurn(journalConfirmation(textOf(toolResult?.content))), + textTurn( + journalConfirmation( + toolResult === undefined ? "" : textOf(toolResult.result.content), + ), + ), ]); } else { setProposePlaybackResponses(faux, manifest, scenario); diff --git a/packages/worker/src/interpreter.ts b/packages/worker/src/interpreter.ts index 657518d0..103fc6a7 100644 --- a/packages/worker/src/interpreter.ts +++ b/packages/worker/src/interpreter.ts @@ -17,6 +17,11 @@ import type { import { builtinModels } from "@earendil-works/pi-ai/providers/all"; import type { WorkerManifest } from "@inkstone/protocol"; import { Effect } from "effect"; +import { + connectExternalTools, + externalFrameFor, + liftExternalIsError, +} from "./external-tools.js"; import { manifestCodec } from "./manifest-codec.js"; import { makeProxyTools } from "./tool-proxy.js"; import { WorkerTransport } from "./transport.js"; @@ -73,7 +78,7 @@ export function runInterpreter( ): Effect.Effect { return Effect.gen(function* () { // Both channels feed pi's callbacks, which run outside the Effect context (ADR-0027). - const { emit, callTool } = yield* WorkerTransport; + const { emit, syncExternalTool, callTool } = yield* WorkerTransport; const model = deps.resolveModel(manifest.workflow); // Current-turn images ride the manifest as raw base64 (never data:-prefixed @@ -98,10 +103,22 @@ export function runInterpreter( timestamp: Date.now(), }; - const tools = + const proxyTools = manifest.workflow.tools.length > 0 ? makeProxyTools(manifest.workflow.tools, callTool) : []; + // External (Worker-executed MCP) tools — external-task-views A3: connect + // + discover ONCE per spawn when the manifest carries the config; the + // dual read-allowlist lives in external-tools.ts. A connect/discovery + // failure throws — worker-main's catchAllDefect turns it into the Run's + // terminal `error` event (the Workflow opted in; fail loud). + const externalConfig = manifest.external_tools; + const external = + externalConfig !== undefined + ? yield* Effect.promise(() => connectExternalTools(externalConfig)) + : undefined; + const tools = + external === undefined ? proxyTools : [...proxyTools, ...external.tools]; // Inject the OAuth access token (if present) as the provider apiKey (ADR-0023). const streamFn: StreamFn = (model_, context, options) => @@ -128,6 +145,14 @@ export function runInterpreter( const config = { model, ...(reasoning !== undefined ? { reasoning } : {}), + // v1 pins the WHOLE batch sequential (external-task-views A4): pi then + // finalizes each call before the next starts, so frame order equals + // source order by contract — pi's default is "parallel", making this + // explicit setting load-bearing. + toolExecution: "sequential" as const, + // Lift an external result's own `isError` into pi's error flag — the + // hook lives in external-tools.ts beside the seam it serves (review M3). + afterToolCall: liftExternalIsError, convertToLlm: (messages: AgentMessage[]) => messages.filter( (m): m is Message => @@ -136,7 +161,16 @@ export function runInterpreter( m.role === "toolResult", ), }; - const onEvent: AgentEventSink = (event) => { + const onEvent: AgentEventSink = async (event) => { + // External-call lifecycle frames (external-task-views A4): sourced from + // pi's own tool-execution events — never hand-assembled state. The + // mapping (and its text-block narrowing) lives in external-tools.ts; + // only `ticktick_*` calls yield a frame (review M3). + const externalFrame = externalFrameFor(event); + if (externalFrame !== undefined) { + await syncExternalTool(externalFrame); + return; + } if ( event.type === "message_update" && event.assistantMessageEvent.type === "text_delta" @@ -176,16 +210,26 @@ export function runInterpreter( } }; - if (manifest.mode === "resume") { - // Resume (ADR-0025): transcript is already the context; continue without a new prompt. - yield* Effect.promise(() => - runAgentLoopContinue(context, config, onEvent, signal, streamFn), - ); - } else { - yield* Effect.promise(() => - runAgentLoop([prompt], context, config, onEvent, signal, streamFn), - ); - } + // Resume (ADR-0025): transcript is already the context; continue without + // a new prompt. + const loop = + manifest.mode === "resume" + ? Effect.promise(() => + runAgentLoopContinue(context, config, onEvent, signal, streamFn), + ) + : Effect.promise(() => + runAgentLoop([prompt], context, config, onEvent, signal, streamFn), + ); + // `ensuring` closes the MCP connection on every exit (done, defect, + // interruption) so its transport never outlives the run — production + // exits the process anyway; in-process tests must not leak. + yield* external === undefined + ? loop + : loop.pipe( + Effect.ensuring( + Effect.promise(() => external.close().catch(() => undefined)), + ), + ); if (errorMessage !== undefined) { // A model/provider-reported run failure: worker-main's catchAll never sees diff --git a/packages/worker/src/manifest-codec.ts b/packages/worker/src/manifest-codec.ts index afecbfbf..534c956b 100644 --- a/packages/worker/src/manifest-codec.ts +++ b/packages/worker/src/manifest-codec.ts @@ -10,6 +10,18 @@ import type { WorkerManifest } from "@inkstone/protocol"; /** Map the manifest's assembled history into pi `Message[]` — see docs/design/worker.md (ADR-0025). */ function toAgentMessages(manifest: WorkerManifest): AgentMessage[] { const now = Date.now(); + // Restore each tool_result's tool NAME from its paired assistant tool_call + // (external-task-views A4): pi associates a replayed result with its call by + // id, but providers also want the name; the manifest's assistant blocks + // already carry it, so no extra wire field is needed. + const toolNames = new Map(); + for (const m of manifest.messages) { + if (m.role === "assistant") { + for (const tc of m.tool_calls ?? []) { + toolNames.set(tc.id, tc.name); + } + } + } const history: Message[] = manifest.messages.map((m): Message => { if (m.role === "user") { return { role: "user", content: m.text, timestamp: now }; @@ -18,9 +30,12 @@ function toAgentMessages(manifest: WorkerManifest): AgentMessage[] { return { role: "toolResult", toolCallId: m.tool_call_id, - toolName: "", - content: [{ type: "text", text: m.content }], - isError: m.is_error ?? false, + toolName: toolNames.get(m.tool_call_id) ?? "", + // Copy the decoded (readonly) blocks into the mutable array pi + // expects; the ONE transcript result type carries content + + // is_error for every tool kind (external-task-views A4). + content: m.result.content.map((c) => ({ ...c })), + isError: m.result.is_error, timestamp: now, }; } diff --git a/packages/worker/src/transport-memory.ts b/packages/worker/src/transport-memory.ts index 27ab7efd..d0cbe912 100644 --- a/packages/worker/src/transport-memory.ts +++ b/packages/worker/src/transport-memory.ts @@ -1,7 +1,10 @@ -import type { WorkerRunEvent } from "@inkstone/protocol"; import { Effect, Layer } from "effect"; import type { ToolCallResponse } from "./tool-proxy.js"; -import { WorkerTransport } from "./transport.js"; +import { + type ExternalToolFrame, + type WorkerEmit, + WorkerTransport, +} from "./transport.js"; /** One outbound Tool Request recorded by the in-memory seam so a test can assert what the model asked Core to run. */ export interface CapturedToolRequest { @@ -20,14 +23,19 @@ export interface InMemoryToolChannel { /** Test `Layer` for {@link WorkerTransport} (ADR-0027): `captured`/`tools` arrays are the assertions, no real stdio. See docs/design/worker-transport.md. */ export const InMemoryTransport = ( - captured: WorkerRunEvent[], + captured: WorkerEmit[], tools?: InMemoryToolChannel, + syncExternal?: (frame: ExternalToolFrame) => Promise, ): Layer.Layer => Layer.succeed(WorkerTransport, { readManifest: Effect.succeed(null), emit: (event) => { captured.push(event); }, + syncExternalTool: (frame) => { + captured.push(frame); + return syncExternal?.(frame) ?? Promise.resolve(); + }, callTool: (toolCallId, name, params) => { tools?.requests.push({ toolCallId, name, params }); const result = tools?.results[toolCallId]; diff --git a/packages/worker/src/transport-stdio.ts b/packages/worker/src/transport-stdio.ts index 3a1b8efe..57831eba 100644 --- a/packages/worker/src/transport-stdio.ts +++ b/packages/worker/src/transport-stdio.ts @@ -1,15 +1,39 @@ import { createInterface } from "node:readline"; import type { Readable, Writable } from "node:stream"; import { - ToolResult, + type ExternalToolAck, + WorkerInbound, WorkerManifest, type WorkerOutbound, } from "@inkstone/protocol"; -import { Effect, Either, Layer, Schema as S } from "effect"; +import { Effect, Either, Layer, ParseResult, Schema as S } from "effect"; import type { ToolCallResponse } from "./tool-proxy.js"; import { ManifestParseError, WorkerTransport } from "./transport.js"; import { logWorkerFault } from "./worker-log.js"; +/** A VALUE-FREE manifest parse failure message (review R10 #1). The manifest + * carries secrets (`external_tools.access_token`, `access_token`), and both + * default failure texts embed input: Effect Schema's TreeFormatter prints the + * ACTUAL value at each issue, and Node's `JSON.parse` SyntaxError quotes a + * source snippet. This message flows into a terminal `error` Run Event and is + * PERSISTED by Core as the run's error_message — so it is built from issue + * PATHS + tags only, never values or input. */ +const sanitizedManifestError = (e: unknown): string => { + if (ParseResult.isParseError(e)) { + const issues = ParseResult.ArrayFormatter.formatErrorSync(e) + .map( + (issue) => + `${issue.path.map(String).join(".") || ""}: ${issue._tag}`, + ) + .join("; "); + return `manifest failed WorkerManifest schema validation (${issues})`; + } + if (e instanceof SyntaxError) { + return "manifest line is not valid JSON"; + } + return `manifest parse failed: ${e instanceof Error ? e.name : "unknown"}`; +}; + /** Best-effort run_id from a raw manifest line, for when schema decode fails but * the JSON parsed (#146): keeps the failure's diagnostic line joinable to * core.jsonl. `undefined` on a JSON syntax error or a non-string run_id. */ @@ -22,20 +46,34 @@ const rawRunId = (line: string): string | undefined => { } }; -/** Best-effort tool_call_id from a raw inbound line, for when a `tool_result` - * parsed as JSON but failed the {@link ToolResult} schema: lets the seam settle - * the awaiting call LOUD (mirrors {@link rawRunId}). `undefined` on a JSON syntax - * error or a non-string tool_call_id. */ -const rawToolCallId = (line: string): string | undefined => { - try { - const id = (JSON.parse(line) as { tool_call_id?: unknown }).tool_call_id; - return typeof id === "string" ? id : undefined; - } catch { - return undefined; - } +interface RawInboundCorrelation { + readonly kind: string | undefined; + readonly toolCallId: string | undefined; + readonly phase: string | undefined; +} + +const rawInboundCorrelation = (value: unknown): RawInboundCorrelation => { + const record = + typeof value === "object" && value !== null + ? (value as Record) + : {}; + return { + kind: typeof record.kind === "string" ? record.kind : undefined, + toolCallId: + typeof record.tool_call_id === "string" ? record.tool_call_id : undefined, + phase: typeof record.phase === "string" ? record.phase : undefined, + }; }; -const decodeToolResult = S.decodeUnknownEither(ToolResult); +const decodeWorkerInbound = S.decodeUnknownEither(WorkerInbound); +const EXTERNAL_ACK_REJECTED = "Core rejected an external tool lifecycle frame"; +const EXTERNAL_ACK_INVALID = + "Core sent an invalid external tool acknowledgement"; +const EXTERNAL_ACK_CLOSED = + "Core closed before acknowledging an external tool frame"; +type ExternalPhase = ExternalToolAck["phase"]; +const externalAckKey = (toolCallId: string, phase: ExternalPhase): string => + `${phase}\u0000${toolCallId}`; /** Production transport (ADR-0027): the Worker's stdio behind the {@link WorkerTransport} seam, over injected streams for testability. See docs/design/worker-transport.md. */ const makeStdioService = ( @@ -49,8 +87,13 @@ const makeStdioService = ( output.write(`${JSON.stringify(frame)}\n`); }; - // Bidirectional stdio: first stdin line is the manifest, rest are tool_result frames — see docs/design/worker-transport.md. + // Bidirectional stdio: first stdin line is the manifest; subsequent lines are + // tool results or external lifecycle acknowledgements. const pendingTools = new Map void>(); + const pendingExternal = new Map< + string, + { resolve: () => void; reject: (error: Error) => void } + >(); let resolveManifest!: (line: string | null) => void; const manifestLine = new Promise((resolve) => { resolveManifest = resolve; @@ -76,32 +119,72 @@ const makeStdioService = ( }); return; } - // Strict decode against the single-source ToolResult schema: a skewed frame - // (e.g. outcome:{}) no longer slips past a truthiness guard to resolve the - // call with junk that later throws inside the proxy and reads as a - // misattributed tool error — it fails loud here, at the seam. - const decoded = decodeToolResult(parsed); + const decoded = decodeWorkerInbound(parsed); if (Either.isRight(decoded)) { - const result = decoded.right; - const pending = pendingTools.get(result.tool_call_id); - if (pending) { - pendingTools.delete(result.tool_call_id); - pending(result.outcome); + const inbound = decoded.right; + if (inbound.kind === "external_tool_ack") { + const key = externalAckKey(inbound.tool_call_id, inbound.phase); + const pending = pendingExternal.get(key); + if (pending === undefined) { + logWorkerFault("worker.external_ack_no_pending", runId, { + tool_call_id: inbound.tool_call_id, + phase: inbound.phase, + }); + return; + } + pendingExternal.delete(key); + if (inbound.ok) { + pending.resolve(); + } else { + pending.reject(new Error(EXTERNAL_ACK_REJECTED)); + } + return; + } + + const pending = pendingTools.get(inbound.tool_call_id); + if (pending !== undefined) { + pendingTools.delete(inbound.tool_call_id); + pending(inbound.outcome); } else { // A tool_result arrived with no awaiting call — silently dropped before. logWorkerFault("worker.tool_result_no_pending", runId, { - tool_call_id: result.tool_call_id, + tool_call_id: inbound.tool_call_id, }); } return; } + + const raw = rawInboundCorrelation(parsed); + if (raw.kind === "external_tool_ack" && raw.toolCallId !== undefined) { + const phases: readonly ExternalPhase[] = + raw.phase === "started" || raw.phase === "finished" + ? [raw.phase] + : ["started", "finished"]; + const matches = phases.flatMap((phase) => { + const key = externalAckKey(raw.toolCallId as string, phase); + const pending = pendingExternal.get(key); + return pending === undefined ? [] : [{ key, pending }]; + }); + if (matches.length === 1) { + const [{ key, pending }] = matches; + pendingExternal.delete(key); + pending.reject(new Error(EXTERNAL_ACK_INVALID)); + } + logWorkerFault("worker.external_ack_undecodable", runId, { + tool_call_id: raw.toolCallId, + ...(raw.phase === undefined ? {} : { phase: raw.phase }), + preview: line.slice(0, 200), + }); + return; + } + // Parsed as JSON but failed the ToolResult schema. Salvage the correlation // id and SETTLE the awaiting call with an `err` outcome: the proxy throws on // `err`, so the model sees a correctly-attributed decode failure (which pi // feeds back as an error tool result, ADR-0018) instead of a truthiness guard // waving junk through. The settle is what makes it fail loud — it stops the // call hanging; the fault log makes it observable. - const toolCallId = rawToolCallId(line); + const toolCallId = raw.toolCallId; const pending = toolCallId === undefined ? undefined : pendingTools.get(toolCallId); if (toolCallId !== undefined && pending) { @@ -137,6 +220,10 @@ const makeStdioService = ( gotManifest = true; resolveManifest(null); } + for (const pending of pendingExternal.values()) { + pending.reject(new Error(EXTERNAL_ACK_CLOSED)); + } + pendingExternal.clear(); }); return { @@ -147,9 +234,10 @@ const makeStdioService = ( try: () => S.decodeUnknownSync(WorkerManifest)(JSON.parse(line)), catch: (e) => new ManifestParseError({ - message: `worker could not parse manifest: ${ - e instanceof Error ? e.message : String(e) - }`, + // Sanitized (review R10 #1): this string reaches the terminal + // `error` Run Event and Core's persisted error_message — it must + // never embed the manifest's values (the bearer token). + message: sanitizedManifestError(e), // Salvage run_id from the raw JSON so a schema-skew failure (#146) // still logs a joinable run_id — undefined only on a JSON syntax error. runId: rawRunId(line), @@ -159,6 +247,18 @@ const makeStdioService = ( return manifest; }), emit: (event) => writeLine(event), + syncExternalTool: (event) => + new Promise((resolve, reject) => { + const phase: ExternalPhase = + event.kind === "external_tool_started" ? "started" : "finished"; + const key = externalAckKey(event.tool_call_id, phase); + if (pendingExternal.has(key)) { + reject(new Error(EXTERNAL_ACK_INVALID)); + return; + } + pendingExternal.set(key, { resolve, reject }); + writeLine(event); + }), callTool: (toolCallId, name, params) => new Promise((resolve) => { pendingTools.set(toolCallId, resolve); diff --git a/packages/worker/src/transport.ts b/packages/worker/src/transport.ts index 02659ef2..8ae75236 100644 --- a/packages/worker/src/transport.ts +++ b/packages/worker/src/transport.ts @@ -1,7 +1,19 @@ -import type { WorkerManifest, WorkerRunEvent } from "@inkstone/protocol"; +import type { + ExternalToolFinished, + ExternalToolStarted, + WorkerManifest, + WorkerRunEvent, +} from "@inkstone/protocol"; import { Context, Data, type Effect } from "effect"; import type { CallTool } from "./tool-proxy.js"; +export type ExternalToolFrame = ExternalToolStarted | ExternalToolFinished; + +/** What the interpreter emits through the seam: Run Events plus the two + * external-tool lifecycle frames (external-task-views A4) — everything on + * `WorkerOutbound` except the proxy-owned `tool_request`. */ +export type WorkerEmit = WorkerRunEvent | ExternalToolFrame; + /** The manifest line on stdin was present but not a valid {@link WorkerManifest}. * `runId` is the best-effort run_id salvaged from the raw JSON when the line * parsed as JSON but failed schema validation (e.g. Rust↔TS mirror skew, #146) — @@ -23,8 +35,11 @@ export class WorkerTransport extends Context.Tag( WorkerManifest | null, ManifestParseError >; - /** Emit one Run Event (fire-and-forget; ADR-0006 Run Event channel). */ + /** Emit one fire-and-forget Run Event (ADR-0006 Run Event channel). */ readonly emit: (event: WorkerRunEvent) => void; + /** Emit one external lifecycle frame and wait until Core durably accepts + * that exact phase + call id. */ + readonly syncExternalTool: (event: ExternalToolFrame) => Promise; /** Round-trip one Tool Request to Core and await its Tool Result (bidirectional Tool Protocol; ADR-0006). Same shape as the proxy's {@link CallTool}. */ readonly callTool: CallTool; } diff --git a/packages/worker/test/external-tools.test.ts b/packages/worker/test/external-tools.test.ts new file mode 100644 index 00000000..2580ff80 --- /dev/null +++ b/packages/worker/test/external-tools.test.ts @@ -0,0 +1,647 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { + fauxAssistantMessage, + fauxProvider, + fauxToolCall, +} from "@earendil-works/pi-ai"; +import type { WorkerManifest } from "@inkstone/protocol"; +import { Effect } from "effect"; +import { afterEach, describe, expect, it } from "vitest"; +import { + adaptMcpResult, + buildExternalTools, + callExternalTool, + connectExternalTools, + discoverExternalTools, + EXTERNAL_READ_ALLOWLIST, +} from "../src/external-tools.js"; +import { fauxInterpreterDeps } from "../src/faux/faux-deps.js"; +import { runInterpreter } from "../src/interpreter.js"; +import type { WorkerEmit } from "../src/transport.js"; +import { InMemoryTransport } from "../src/transport-memory.js"; + +// External-tool lane tests (external-task-views A3/A4): the dual read +// allowlist, the MCP→transcript adapter, and the lifecycle frames — driven +// end-to-end through the REAL interpreter + MCP SDK client against an +// in-process fake TickTick MCP server (stateless streamable HTTP). + +/** The approved tool surface: the five read tools the allowlist admits plus a + * write tool and an extra read tool it must exclude. */ +const SERVER_TOOLS = [ + ...EXTERNAL_READ_ALLOWLIST.map((name) => ({ + name, + description: `TickTick ${name}`, + inputSchema: { type: "object" as const }, + })), + { + name: "create_task", + description: "WRITE tool — must never be exposed", + inputSchema: { type: "object" as const }, + }, + { + name: "list_habits", + description: "read tool outside the exact allowlist", + inputSchema: { type: "object" as const }, + }, +]; + +interface FakeCall { + name: string; + args: unknown; +} + +/** A minimal stateless streamable-HTTP MCP server: initialize / initialized / + * tools/list / tools/call over plain JSON POST responses. Records every call + + * authorization header for assertions. */ +function startFakeMcp( + onCall: (call: FakeCall) => { + content: unknown[]; + isError?: boolean; + structuredContent?: unknown; + }, +): Promise<{ + url: string; + calls: FakeCall[]; + authorizations: string[]; + close: () => Promise; +}> { + const calls: FakeCall[] = []; + const authorizations: string[] = []; + const server: Server = createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + authorizations.push(String(req.headers.authorization ?? "")); + if (req.method !== "POST") { + // The SDK may probe GET (server-push SSE) — not offered here. + res.writeHead(405).end(); + return; + } + const msg = JSON.parse(body) as { + id?: number; + method: string; + params?: { name?: string; arguments?: unknown }; + }; + const respond = (result: unknown) => { + res + .writeHead(200, { "content-type": "application/json" }) + .end(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result })); + }; + switch (msg.method) { + case "initialize": + respond({ + protocolVersion: "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name: "fake-ticktick", version: "0.0.0" }, + }); + return; + case "tools/list": + respond({ tools: SERVER_TOOLS }); + return; + case "tools/call": { + const call = { + name: msg.params?.name ?? "", + args: msg.params?.arguments, + }; + calls.push(call); + respond(onCall(call)); + return; + } + default: + // notifications (e.g. initialized) take a 202 ack. + res.writeHead(202).end(); + } + }); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as AddressInfo; + resolve({ + url: `http://127.0.0.1:${port}/mcp`, + calls, + authorizations, + close: () => + new Promise((done) => { + server.close(() => done()); + }), + }); + }); + }); +} + +function startStalledMcp(): Promise<{ + url: string; + close: () => Promise; +}> { + const server = createServer((req) => { + // Consume the initialize request but never answer it. + req.resume(); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as AddressInfo; + resolve({ + url: `http://127.0.0.1:${port}/mcp`, + close: () => + new Promise((done) => { + server.closeAllConnections(); + server.close(() => done()); + }), + }); + }); + }); +} + +let cleanup: (() => Promise) | undefined; +afterEach(async () => { + await cleanup?.(); + cleanup = undefined; +}); + +function externalManifest(url: string): WorkerManifest { + return { + run_id: "01900000-0000-7000-8000-00000000ext1", + workflow: { + name: "default", + version: "1.0.0", + provider: "faux", + model: "faux-1", + system_prompt: "You are a test assistant.", + thinking_level: "off", + tools: [], + }, + prompt: "how many tasks tomorrow?", + messages: [], + external_tools: { + endpoint: url, + access_token: "tok_test", + timeout_ms: 30_000, + }, + }; +} + +describe("adaptMcpResult", () => { + it("copies text content verbatim, drops structuredContent and non-text blocks", () => { + const adapted = adaptMcpResult({ + content: [ + { type: "text", text: "1 task found: Timed task" }, + { type: "image", data: "…", mimeType: "image/png" }, + ], + structuredContent: { result: [{ title: "Timed task" }] }, + isError: false, + }); + expect(adapted).toEqual({ + content: [{ type: "text", text: "1 task found: Timed task" }], + isError: false, + }); + }); + + it("maps isError === true and defaults anything else to false", () => { + expect( + adaptMcpResult({ + content: [{ type: "text", text: "boom" }], + isError: true, + }).isError, + ).toBe(true); + expect(adaptMcpResult({ content: [] }).isError).toBe(false); + expect(adaptMcpResult("garbage").isError).toBe(false); + }); +}); + +describe("dual read-allowlist", () => { + it("pins the exact five read tools", () => { + expect(EXTERNAL_READ_ALLOWLIST).toEqual([ + "list_projects", + "list_tags", + "filter_tasks", + "search_task", + "get_task_by_id", + ]); + }); + + it("gate #1: discovery filters to the allowlist, namespaced for the model", () => { + const tools = buildExternalTools( + { callTool: () => Promise.resolve({ content: [] }) }, + SERVER_TOOLS, + 30_000, + ); + expect(tools.map((t) => t.name)).toEqual([ + "ticktick_list_projects", + "ticktick_list_tags", + "ticktick_filter_tasks", + "ticktick_search_task", + "ticktick_get_task_by_id", + ]); + expect(tools.some((t) => t.name.includes("create_task"))).toBe(false); + }); + + it("gate #2: the executor rejects a non-allowlisted tool BEFORE calling the server", async () => { + const calls: FakeCall[] = []; + const caller = { + callTool: (params: { + name: string; + arguments: Record; + }) => { + calls.push({ name: params.name, args: params.arguments }); + return Promise.resolve({ content: [] }); + }, + }; + await expect( + callExternalTool(caller, "create_task", {}, 30_000), + ).rejects.toThrow(/not in the read allowlist/); + expect(calls).toEqual([]); + }); + + it("applies the manifest timeout to an allowlisted tool call", async () => { + let timeout: number | undefined; + await callExternalTool( + { + callTool: (_params, _schema, options) => { + timeout = options?.timeout; + return Promise.resolve({ content: [] }); + }, + }, + "filter_tasks", + {}, + 321, + ); + expect(timeout).toBe(321); + }); +}); + +describe("external discovery pagination", () => { + it("applies the timeout to every page and rejects a repeated cursor", async () => { + const timeouts: Array = []; + let calls = 0; + await expect( + discoverExternalTools( + { + listTools: (_params, options) => { + timeouts.push(options?.timeout); + calls += 1; + return Promise.resolve({ + tools: [], + nextCursor: "same-cursor", + }); + }, + }, + 456, + ), + ).rejects.toThrow(/repeated cursor/); + expect(calls).toBe(2); + expect(timeouts).toEqual([456, 456]); + }); + + it("rejects an endless sequence of unique cursors at a finite page cap", async () => { + let calls = 0; + await expect( + discoverExternalTools( + { + listTools: () => { + calls += 1; + return Promise.resolve({ + tools: [], + nextCursor: `cursor-${calls}`, + }); + }, + }, + 789, + ), + ).rejects.toThrow(/page limit/); + expect(calls).toBeGreaterThan(1); + expect(calls).toBeLessThan(1_000); + }); +}); + +describe("interpreter with external tools (fake MCP server)", () => { + async function runExternalChat( + onCall: Parameters[0], + responses: Parameters["setResponses"]>[0], + ) { + const fake = await startFakeMcp(onCall); + cleanup = fake.close; + const faux = fauxProvider({ provider: "faux" }); + faux.setResponses(responses); + const events: WorkerEmit[] = []; + const requests: { toolCallId: string; name: string; params: unknown }[] = + []; + await Effect.runPromise( + runInterpreter( + externalManifest(fake.url), + fauxInterpreterDeps(faux), + ).pipe( + Effect.provide(InMemoryTransport(events, { results: {}, requests })), + ), + ); + return { fake, events, requests }; + } + + it("emits started/finished frames from pi's events; no Tool Protocol round-trip", async () => { + const { fake, events, requests } = await runExternalChat( + () => ({ + content: [{ type: "text", text: "1 task found: S1 timed" }], + structuredContent: { result: [{ title: "S1 timed" }] }, + isError: false, + }), + [ + fauxAssistantMessage( + [ + fauxToolCall( + "ticktick_filter_tasks", + { filter: { status: [0] } }, + { id: "tc_ext_1" }, + ), + ], + { stopReason: "toolUse" }, + ), + (context) => { + const result = [...context.messages] + .reverse() + .find((m) => m.role === "toolResult"); + const text = + result && Array.isArray(result.content) + ? result.content.map((c) => ("text" in c ? c.text : "")).join("") + : ""; + return fauxAssistantMessage(`answer: ${text}`); + }, + ], + ); + + // The lifecycle frames, in order, sourced from pi's tool events (A4). + const frames = events.filter( + (e) => + e.kind === "external_tool_started" || + e.kind === "external_tool_finished", + ); + expect(frames).toEqual([ + { + kind: "external_tool_started", + tool_call_id: "tc_ext_1", + name: "ticktick_filter_tasks", + arguments: { filter: { status: [0] } }, + }, + { + kind: "external_tool_finished", + tool_call_id: "tc_ext_1", + result: { + content: [{ type: "text", text: "1 task found: S1 timed" }], + is_error: false, + }, + }, + ]); + // Direct execution: the server saw the call; Core saw NO tool_request. + expect(fake.calls).toEqual([ + { name: "filter_tasks", args: { filter: { status: [0] } } }, + ]); + expect(requests).toEqual([]); + // The Bearer token reached the server on every request. + expect(new Set(fake.authorizations)).toEqual(new Set(["Bearer tok_test"])); + // The model actually received the content (its reply echoes it). + const text = events + .filter( + (e): e is { kind: "text_delta"; delta: string } => + e.kind === "text_delta", + ) + .map((e) => e.delta) + .join(""); + expect(text).toBe("answer: 1 task found: S1 timed"); + expect(events.at(-1)).toEqual({ kind: "done" }); + }); + + it("a failed MCP call finishes with is_error: true and an error transcript", async () => { + let sawIsError: boolean | undefined; + const { events } = await runExternalChat( + () => ({ + content: [{ type: "text", text: "Missing required parameter: filter" }], + isError: true, + }), + [ + fauxAssistantMessage( + [fauxToolCall("ticktick_filter_tasks", {}, { id: "tc_err" })], + { stopReason: "toolUse" }, + ), + (context) => { + const result = [...context.messages] + .reverse() + .find((m) => m.role === "toolResult"); + sawIsError = (result as { isError?: boolean } | undefined)?.isError; + return fauxAssistantMessage("noted the failure"); + }, + ], + ); + + const finished = events.find((e) => e.kind === "external_tool_finished"); + expect(finished).toEqual({ + kind: "external_tool_finished", + tool_call_id: "tc_err", + result: { + content: [{ type: "text", text: "Missing required parameter: filter" }], + is_error: true, + }, + }); + // pi's transcript carries the error flag too (afterToolCall lifted it). + expect(sawIsError).toBe(true); + }); + + it("a two-call batch emits start/finish pairs in source order (sequential mode)", async () => { + const { events } = await runExternalChat( + (call) => ({ + content: [{ type: "text", text: `result of ${call.name}` }], + isError: false, + }), + [ + fauxAssistantMessage( + [ + fauxToolCall( + "ticktick_search_task", + { query: "a" }, + { id: "tc_a" }, + ), + fauxToolCall( + "ticktick_search_task", + { query: "b" }, + { id: "tc_b" }, + ), + ], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("both done"), + ], + ); + + const frameOrder = events + .filter( + (e) => + e.kind === "external_tool_started" || + e.kind === "external_tool_finished", + ) + .map( + (e) => + `${e.kind === "external_tool_started" ? "start" : "end"}:${e.tool_call_id}`, + ); + expect(frameOrder).toEqual([ + "start:tc_a", + "end:tc_a", + "start:tc_b", + "end:tc_b", + ]); + }); + + it("waits for the started ACK before executing the MCP call", async () => { + const fake = await startFakeMcp(() => ({ + content: [{ type: "text", text: "one task" }], + isError: false, + })); + cleanup = fake.close; + + const faux = fauxProvider({ provider: "faux" }); + faux.setResponses([ + fauxAssistantMessage( + [ + fauxToolCall( + "ticktick_filter_tasks", + { filter: { status: [0] } }, + { id: "tc-start-gate" }, + ), + ], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("done"), + ]); + + let releaseStart = (): void => {}; + const startGate = new Promise((resolve) => { + releaseStart = resolve; + }); + let observeStart = (): void => {}; + const startObserved = new Promise((resolve) => { + observeStart = resolve; + }); + const events: WorkerEmit[] = []; + const requests: { + toolCallId: string; + name: string; + params: unknown; + }[] = []; + const run = Effect.runPromise( + runInterpreter( + externalManifest(fake.url), + fauxInterpreterDeps(faux), + ).pipe( + Effect.provide( + InMemoryTransport( + events, + { results: {}, requests }, + async (frame) => { + if (frame.kind === "external_tool_started") { + observeStart(); + await startGate; + } + }, + ), + ), + ), + ); + + await startObserved; + expect(fake.calls).toEqual([]); + releaseStart(); + await run; + expect(fake.calls).toEqual([ + { name: "filter_tasks", args: { filter: { status: [0] } } }, + ]); + }); + + it("waits for the finished ACK before starting the next model turn", async () => { + const fake = await startFakeMcp(() => ({ + content: [{ type: "text", text: "one task" }], + isError: false, + })); + cleanup = fake.close; + + let nextTurnStarted = false; + const faux = fauxProvider({ provider: "faux" }); + faux.setResponses([ + fauxAssistantMessage( + [ + fauxToolCall( + "ticktick_filter_tasks", + { filter: { status: [0] } }, + { id: "tc-finish-gate" }, + ), + ], + { stopReason: "toolUse" }, + ), + () => { + nextTurnStarted = true; + return fauxAssistantMessage("done"); + }, + ]); + + let releaseFinish = (): void => {}; + const finishGate = new Promise((resolve) => { + releaseFinish = resolve; + }); + let observeFinish = (): void => {}; + const finishObserved = new Promise((resolve) => { + observeFinish = resolve; + }); + const events: WorkerEmit[] = []; + const run = Effect.runPromise( + runInterpreter( + externalManifest(fake.url), + fauxInterpreterDeps(faux), + ).pipe( + Effect.provide( + InMemoryTransport(events, undefined, async (frame) => { + if (frame.kind === "external_tool_finished") { + observeFinish(); + await finishGate; + } + }), + ), + ), + ); + + await finishObserved; + expect(fake.calls).toHaveLength(1); + expect(nextTurnStarted).toBe(false); + releaseFinish(); + await run; + expect(nextTurnStarted).toBe(true); + }); + + it("bounds MCP initialization with the manifest timeout", async () => { + const fake = await startStalledMcp(); + cleanup = fake.close; + const started = performance.now(); + + await expect( + connectExternalTools({ + endpoint: fake.url, + access_token: "tok_test", + timeout_ms: 50, + }), + ).rejects.toThrow(); + expect(performance.now() - started).toBeLessThan(1_000); + }); + + it("a connect failure rejects (worker-main maps it to the terminal error)", async () => { + const faux = fauxProvider({ provider: "faux" }); + faux.setResponses([fauxAssistantMessage("never reached")]); + const events: WorkerEmit[] = []; + // A closed port: nothing listens. + const manifest = externalManifest("http://127.0.0.1:9/mcp"); + await expect( + Effect.runPromise( + runInterpreter(manifest, fauxInterpreterDeps(faux)).pipe( + Effect.provide(InMemoryTransport(events)), + ), + ), + ).rejects.toThrow(); + expect(events.some((e) => e.kind === "done")).toBe(false); + }); +}); diff --git a/packages/worker/test/faux/faux-worker.test.ts b/packages/worker/test/faux/faux-worker.test.ts index 883168df..aedc07cf 100644 --- a/packages/worker/test/faux/faux-worker.test.ts +++ b/packages/worker/test/faux/faux-worker.test.ts @@ -246,24 +246,23 @@ const decisionResult = ( ): ManifestMessage => ({ role: "tool_result", tool_call_id, - content, + result: { content: [{ type: "text", text: content }], is_error: false }, }); -// Core serializes a tool's AgentToolResult into the transcript verbatim, so a -// RESUME tool_result's `content` is the envelope `{content:[{text:""}],…}` -// (see resume.rs render_result_content), NOT the bare inner JSON. Fixtures must -// match that shape so the worker's unwrap path is exercised as in production. +// Core reduces every persisted payload to the ONE transcript result type +// (external-task-views A4; resume.rs transcript_result), so a RESUME +// tool_result carries the tool's bare inner text as content blocks — no +// AgentToolResult envelope. Fixtures match that production shape. const resumeToolResult = ( tool_call_id: string, inner: unknown, ): ManifestMessage => ({ role: "tool_result", tool_call_id, - content: JSON.stringify({ + result: { content: [{ type: "text", text: JSON.stringify(inner) }], - details: null, - terminate: null, - }), + is_error: false, + }, }); const searchResult = ( diff --git a/packages/worker/test/interpreter.test.ts b/packages/worker/test/interpreter.test.ts index cd1bc8e7..4ab44b9f 100644 --- a/packages/worker/test/interpreter.test.ts +++ b/packages/worker/test/interpreter.test.ts @@ -173,7 +173,12 @@ describe("generic interpreter (faux provider)", () => { { role: "tool_result", tool_call_id: "tc_1", - content: "Accepted. Created Journal Entry.", + result: { + content: [ + { type: "text", text: "Accepted. Created Journal Entry." }, + ], + is_error: false, + }, }, ], }); diff --git a/packages/worker/test/manifest-codec.test.ts b/packages/worker/test/manifest-codec.test.ts index c72ed111..75ca9319 100644 --- a/packages/worker/test/manifest-codec.test.ts +++ b/packages/worker/test/manifest-codec.test.ts @@ -48,11 +48,18 @@ describe("manifestCodec.toAgentMessages", () => { expect(typeof msg.timestamp).toBe("number"); }); - it("maps a tool_result to a pi toolResult paired by id, defaulting isError", () => { + it("maps a tool_result's TranscriptToolResult to a pi toolResult paired by id", () => { const [msg] = asRecords( manifestCodec.toAgentMessages( manifest([ - { role: "tool_result", tool_call_id: "tc_1", content: "Accepted." }, + { + role: "tool_result", + tool_call_id: "tc_1", + result: { + content: [{ type: "text", text: "Accepted." }], + is_error: false, + }, + }, ]), ), ); @@ -62,15 +69,17 @@ describe("manifestCodec.toAgentMessages", () => { expect(msg.isError).toBe(false); }); - it("honors an explicit is_error on a tool_result", () => { + it("carries the result's is_error into the pi toolResult", () => { const [msg] = asRecords( manifestCodec.toAgentMessages( manifest([ { role: "tool_result", tool_call_id: "tc_1", - content: "boom", - is_error: true, + result: { + content: [{ type: "text", text: "boom" }], + is_error: true, + }, }, ]), ), @@ -78,6 +87,45 @@ describe("manifestCodec.toAgentMessages", () => { expect(msg.isError).toBe(true); }); + it("restores each tool_result's tool NAME from its paired assistant call", () => { + // external-task-views A4: pi replays a provider-valid transcript only if + // the toolResult carries its call's name — derived from the manifest's + // assistant tool_calls, no extra wire field. + const out = asRecords( + manifestCodec.toAgentMessages( + manifest([ + { + role: "assistant", + tool_calls: [ + { + id: "tc_ext", + name: "ticktick_filter_tasks", + arguments: { filter: { status: [0] } }, + }, + ], + }, + { + role: "tool_result", + tool_call_id: "tc_ext", + result: { + content: [{ type: "text", text: "1 task found" }], + is_error: false, + }, + }, + { + role: "tool_result", + tool_call_id: "tc_unknown", + result: { content: [], is_error: false }, + }, + ]), + ), + ); + const results = out.filter((m) => m.role === "toolResult"); + expect(results[0].toolName).toBe("ticktick_filter_tasks"); + // An unpaired result degrades to the empty name rather than throwing. + expect(results[1].toolName).toBe(""); + }); + it("synthesizes an assistant message carrying the workflow model, text, and tool calls", () => { const [msg] = asRecords( manifestCodec.toAgentMessages( @@ -162,7 +210,11 @@ describe("manifestCodec.toAgentMessages", () => { role: "assistant", tool_calls: [{ id: "tc_1", name: "read_thread", arguments: {} }], }, - { role: "tool_result", tool_call_id: "tc_1", content: "ok" }, + { + role: "tool_result", + tool_call_id: "tc_1", + result: { content: [{ type: "text", text: "ok" }], is_error: false }, + }, ]), ); expect(roles(out)).toEqual(["user", "assistant", "toolResult"]); diff --git a/packages/worker/test/transport-stdio.test.ts b/packages/worker/test/transport-stdio.test.ts index 5380ad72..4f4767f7 100644 --- a/packages/worker/test/transport-stdio.test.ts +++ b/packages/worker/test/transport-stdio.test.ts @@ -88,6 +88,74 @@ describe("StdioTransportLive (over injected streams)", () => { expect(resp).toEqual({ ok: { content: [{ type: "text", text: "ok" }] } }); }); + it("round-trips external lifecycle ACKs and rejects a NACK without Core detail", async () => { + const input = new PassThrough(); + const { output, written } = capturingWritable(); + input.write(`${manifestJson}\n`); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const t = yield* WorkerTransport; + yield* t.readManifest; + + const started = t.syncExternalTool({ + kind: "external_tool_started", + tool_call_id: "tc-ext", + name: "ticktick_filter_tasks", + arguments: { filter: { status: [0] } }, + }); + input.write( + `${JSON.stringify({ + kind: "external_tool_ack", + tool_call_id: "tc-ext", + phase: "started", + ok: true, + })}\n`, + ); + yield* Effect.promise(() => started); + + const finished = t.syncExternalTool({ + kind: "external_tool_finished", + tool_call_id: "tc-ext", + result: { + content: [{ type: "text", text: "one task" }], + is_error: false, + }, + }); + input.write( + `${JSON.stringify({ + kind: "external_tool_ack", + tool_call_id: "tc-ext", + phase: "finished", + ok: false, + })}\n`, + ); + return yield* Effect.promise(() => + finished.then( + () => ({ rejected: false, message: "" }), + (error: unknown) => ({ + rejected: true, + message: error instanceof Error ? error.message : String(error), + }), + ), + ); + }).pipe(Effect.provide(makeStdioTransport(input, output))), + ); + + expect(result).toEqual({ + rejected: true, + message: "Core rejected an external tool lifecycle frame", + }); + const frames = written() + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(frames.map((frame) => frame.kind)).toEqual([ + "external_tool_started", + "external_tool_finished", + ]); + }); + it("settles the pending call LOUD when an inbound tool_result fails schema decode", async () => { const input = new PassThrough(); const { output } = capturingWritable(); @@ -154,6 +222,58 @@ describe("StdioTransportLive (over injected streams)", () => { } }); + it("never leaks manifest secrets into a schema-failure message (review R10 #1)", async () => { + const input = new PassThrough(); + const { output } = capturingWritable(); + // A REAL token beside a malformed field: Effect Schema's default tree + // formatter would print the actual `external_tools` object — token + // included — and this message becomes the persisted run error_message. + const token = "tok_SECRET_do_not_leak"; + input.write( + `${JSON.stringify({ + run_id: "01900000-0000-7000-8000-0000000000aa", + external_tools: { endpoint: 123, access_token: token }, + })}\n`, + ); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const t = yield* WorkerTransport; + return yield* Effect.either(t.readManifest); + }).pipe(Effect.provide(makeStdioTransport(input, output))), + ); + + expect(result._tag).toBe("Left"); + if (result._tag === "Left") { + expect(result.left.message).not.toContain(token); + expect(result.left.message).not.toContain("tok_"); + // Still diagnosable: the failing paths are named (values are not). + expect(result.left.message).toContain("schema validation"); + } + }); + + it("never leaks the raw line into a JSON-syntax failure message (review R10 #1)", async () => { + const input = new PassThrough(); + const { output } = capturingWritable(); + // Node's JSON.parse SyntaxError quotes a snippet of its input — which + // here contains the token — so the message must be fully static. + const token = "tok_SECRET_do_not_leak"; + input.write(`{"access_token": "${token}", not json\n`); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const t = yield* WorkerTransport; + return yield* Effect.either(t.readManifest); + }).pipe(Effect.provide(makeStdioTransport(input, output))), + ); + + expect(result._tag).toBe("Left"); + if (result._tag === "Left") { + expect(result.left.message).not.toContain(token); + expect(result.left.message).toBe("manifest line is not valid JSON"); + } + }); + it("salvages run_id onto ManifestParseError when the JSON parses but fails schema (#146)", async () => { const input = new PassThrough(); const { output } = capturingWritable(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90ad5504..5aafe29b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,7 +328,7 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: 0.80.2 - version: 0.80.2(ws@8.21.0)(zod@4.4.3) + version: 0.80.2(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) '@inkstone/protocol': specifier: workspace:* version: link:../protocol @@ -381,13 +381,16 @@ importers: dependencies: '@earendil-works/pi-agent-core': specifier: 0.80.2 - version: 0.80.2(ws@8.21.0)(zod@4.4.3) + version: 0.80.2(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) '@earendil-works/pi-ai': specifier: 0.80.2 - version: 0.80.2(ws@8.21.0)(zod@4.4.3) + version: 0.80.2(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) '@inkstone/protocol': specifier: workspace:* version: link:../protocol + '@modelcontextprotocol/sdk': + specifier: ^1.30.0 + version: 1.30.0(zod@4.4.3) effect: specifier: ^3.21.2 version: 3.21.2 @@ -958,6 +961,12 @@ packages: '@modelcontextprotocol/sdk': optional: true + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -982,6 +991,16 @@ packages: '@opentelemetry/api': optional: true + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} cpu: [arm64] @@ -1622,10 +1641,25 @@ packages: '@vitest/utils@4.1.7': resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1673,6 +1707,10 @@ packages: bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} @@ -1684,10 +1722,18 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} @@ -1748,12 +1794,40 @@ packages: engines: {node: '>=18'} hasBin: true + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -1796,6 +1870,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1824,6 +1902,9 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@3.21.2: resolution: {integrity: sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg==} @@ -1833,6 +1914,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + enhanced-resolve@5.22.1: resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==} engines: {node: '>=10.13.0'} @@ -1869,6 +1954,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -1879,10 +1967,32 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.6.2: + resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1890,6 +2000,12 @@ packages: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -1910,6 +2026,10 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-my-way-ts@0.1.6: resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} @@ -1921,6 +2041,14 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1995,6 +2123,10 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hono@4.13.2: + resolution: {integrity: sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==} + engines: {node: '>=16.9.0'} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -2002,6 +2134,10 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -2014,6 +2150,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ignore@7.0.5: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} @@ -2022,9 +2162,20 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -2048,14 +2199,23 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + isbot@5.1.40: resolution: {integrity: sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==} engines: {node: '>=18'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.8: + resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2080,6 +2240,12 @@ packages: resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} engines: {node: '>=16'} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2241,6 +2407,14 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -2329,10 +2503,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -2355,6 +2537,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -2374,9 +2560,24 @@ packages: nwsapi@2.2.23: resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openai@6.26.0: resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} hasBin: true @@ -2399,6 +2600,10 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} @@ -2406,6 +2611,13 @@ packages: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2416,6 +2628,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + playwright-core@1.60.0: resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} @@ -2450,6 +2666,10 @@ packages: resolution: {integrity: sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==} engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2457,6 +2677,18 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-dom@19.2.6: resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} peerDependencies: @@ -2499,6 +2731,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + reselect@5.2.0: resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} @@ -2511,6 +2747,10 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + rrweb-cssom@0.7.1: resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} @@ -2537,6 +2777,10 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + seroval-plugins@1.5.4: resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} engines: {node: '>=10'} @@ -2547,10 +2791,41 @@ packages: resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} engines: {node: '>=10'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + shell-quote@1.8.3: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -2564,6 +2839,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -2637,6 +2916,10 @@ packages: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -2666,6 +2949,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typebox@1.1.38: resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} @@ -2698,6 +2985,10 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unplugin@3.0.0: resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2716,6 +3007,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -2834,6 +3129,11 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -2843,6 +3143,9 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -3350,9 +3653,9 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} - '@earendil-works/pi-agent-core@0.80.2(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-agent-core@0.80.2(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-ai': 0.80.2(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.80.2(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) ignore: 7.0.5 typebox: 1.1.38 yaml: 2.9.0 @@ -3364,11 +3667,11 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.80.2(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.80.2(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)) '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) '@opentelemetry/api': 1.9.0 '@smithy/node-http-handler': 4.7.3 @@ -3503,17 +3806,23 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@google/genai@1.52.0': + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))': dependencies: google-auth-library: 10.6.2 p-retry: 4.6.2 protobufjs: 7.6.2 ws: 8.21.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate + '@hono/node-server@2.1.1(hono@4.13.2)': + dependencies: + hono: 4.13.2 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3545,6 +3854,28 @@ snapshots: - bufferutil - utf-8-validate + '@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 2.1.1(hono@4.13.2) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1 + express-rate-limit: 8.6.2(express@5.2.1) + hono: 4.13.2 + jose: 6.2.8 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true @@ -4078,8 +4409,24 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -4117,6 +4464,20 @@ snapshots: bignumber.js@9.3.1: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + bowser@2.14.1: {} browserslist@4.28.2: @@ -4129,11 +4490,18 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + bytes@3.1.2: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + caniuse-lite@1.0.30001793: {} ccount@2.0.1: {} @@ -4190,10 +4558,31 @@ snapshots: tree-kill: 1.2.2 yargs: 17.7.2 + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + convert-source-map@2.0.0: {} cookie-es@3.1.1: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + css.escape@1.5.1: {} cssesc@3.0.0: {} @@ -4224,6 +4613,8 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -4248,6 +4639,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + ee-first@1.1.1: {} + effect@3.21.2: dependencies: '@standard-schema/spec': 1.1.0 @@ -4257,6 +4650,8 @@ snapshots: emoji-regex@8.0.0: {} + encodeurl@2.0.0: {} + enhanced-resolve@5.22.1: dependencies: graceful-fs: 4.2.11 @@ -4312,6 +4707,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@5.0.0: {} estree-util-is-identifier-name@3.0.0: {} @@ -4320,14 +4717,67 @@ snapshots: dependencies: '@types/estree': 1.0.9 + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + expect-type@1.3.0: {} + express-rate-limit@8.6.2(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.5.0 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend@3.0.2: {} fast-check@3.23.2: dependencies: pure-rand: 6.1.0 + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.5: {} + fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 @@ -4349,6 +4799,17 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-my-way-ts@0.1.6: {} form-data@4.0.5: @@ -4363,6 +4824,10 @@ snapshots: dependencies: fetch-blob: 3.2.0 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.2: optional: true @@ -4462,12 +4927,22 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hono@4.13.2: {} + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 html-url-attributes@3.0.1: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -4486,12 +4961,22 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ignore@7.0.5: {} indent-string@4.0.0: {} + inherits@2.0.4: {} + inline-style-parser@0.2.7: {} + ip-address@10.5.0: {} + + ipaddr.js@1.9.1: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -4509,10 +4994,16 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + isbot@5.1.40: {} + isexe@2.0.0: {} + jiti@2.7.0: {} + jose@6.2.8: {} + js-tokens@4.0.0: {} jsdom@25.0.1: @@ -4554,6 +5045,10 @@ snapshots: '@babel/runtime': 7.29.7 ts-algebra: 2.0.0 + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json5@2.2.3: {} jwa@2.0.1: @@ -4793,6 +5288,10 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -4986,10 +5485,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + min-indent@1.0.1: {} ms@2.1.3: {} @@ -5014,6 +5519,8 @@ snapshots: nanoid@3.3.12: {} + negotiator@1.0.0: {} + node-domexception@1.0.0: {} node-fetch@3.3.2: @@ -5031,8 +5538,20 @@ snapshots: nwsapi@2.2.23: {} + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + obug@2.1.1: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + openai@6.26.0(ws@8.21.0)(zod@4.4.3): optionalDependencies: ws: 8.21.0 @@ -5057,16 +5576,24 @@ snapshots: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + partial-json@0.1.7: {} path-expression-matcher@1.5.0: {} + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} picocolors@1.1.1: {} picomatch@4.0.4: {} + pkce-challenge@5.0.1: {} + playwright-core@1.60.0: {} playwright@1.60.0: @@ -5111,10 +5638,29 @@ snapshots: '@types/node': 24.12.4 long: 5.3.2 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + punycode@2.3.1: {} pure-rand@6.1.0: {} + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + react-dom@19.2.6(react@19.2.6): dependencies: react: 19.2.6 @@ -5185,6 +5731,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + reselect@5.2.0: {} retry@0.13.1: {} @@ -5210,6 +5758,16 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.1 '@rolldown/binding-win32-x64-msvc': 1.0.1 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + rrweb-cssom@0.7.1: {} rrweb-cssom@0.8.0: {} @@ -5230,14 +5788,75 @@ snapshots: semver@6.3.1: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + seroval-plugins@1.5.4(seroval@1.5.4): dependencies: seroval: 1.5.4 seroval@1.5.4: {} + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + shell-quote@1.8.3: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -5246,6 +5865,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} string-width@4.2.3: @@ -5312,6 +5933,8 @@ snapshots: dependencies: tldts-core: 6.1.86 + toidentifier@1.0.1: {} + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -5336,6 +5959,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + typebox@1.1.38: {} typescript@7.0.2: @@ -5398,6 +6027,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unpipe@1.0.0: {} + unplugin@3.0.0: dependencies: '@jridgewell/remapping': 2.3.5 @@ -5416,6 +6047,8 @@ snapshots: util-deprecate@1.0.2: {} + vary@1.1.2: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -5535,6 +6168,10 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -5546,6 +6183,8 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrappy@1.0.2: {} + ws@8.21.0: {} xml-name-validator@5.0.0: {} diff --git a/scripts/ticktick-live-smoke.mjs b/scripts/ticktick-live-smoke.mjs new file mode 100644 index 00000000..187a1e3d --- /dev/null +++ b/scripts/ticktick-live-smoke.mjs @@ -0,0 +1,151 @@ +// Live TickTick MCP contract smoke — the Worker-lane half of the credentialed +// validation the plan pins (docs/plans/external-task-views-plan.md). The Web +// lane (OpenAPI) is smoked through the PRODUCTION Rust decoder instead — +// crates/core/src/ticktick/client.rs `live_openapi_contract_smoke` (review R10 +// #3) — so nothing here mirrors wire.rs. This script asserts only that the live +// MCP service still initializes and offers every tool in the Worker's exact +// read allowlist. NON-CAPTURING: prints counts/booleans only — no response +// body, no header value, and never the token; even parse failures rethrow +// static lane-named messages (a raw SyntaxError quotes its input). +// +// Requires TICKTICK_ACCESS_TOKEN (full scope — S1: MCP rejects tasks:read). + +const MCP_URL = "https://mcp.ticktick.com/"; +const MCP_PROTOCOL_VERSION = "2025-06-18"; + +// The Worker's exact read allowlist (packages/worker/src/external-tools.ts +// EXTERNAL_READ_ALLOWLIST). Deliberately re-spelled here: the smoke asserts the +// LIVE server still offers these names, independent of the code that filters on +// them — deriving one from the other would hide exactly the drift this detects. +const ALLOWLIST = [ + "list_projects", + "list_tags", + "filter_tasks", + "search_task", + "get_task_by_id", +]; + +const token = process.env.TICKTICK_ACCESS_TOKEN; +if (!token) { + console.error( + "TICKTICK_ACCESS_TOKEN is not set (repo secret; full-scope token)", + ); + process.exit(1); +} + +/** Fetch with the bearer + a bound; non-2xx throws with the STATUS only (never + * the body — it could carry account data). */ +async function request(url, init = {}) { + const response = await fetch(url, { + ...init, + headers: { + accept: "application/json, text/event-stream", + authorization: `Bearer ${token}`, + "content-type": "application/json", + ...init.headers, + }, + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + throw new Error( + `${init.method ?? "GET"} ${new URL(url).pathname}: HTTP ${response.status}`, + ); + } + return response; +} + +/** JSON.parse whose failure names the lane only — a raw SyntaxError message + * quotes a prefix of its input, which would put response content into the + * public workflow log (CodeRabbit #336). */ +function parseJson(text) { + try { + return JSON.parse(text); + } catch { + throw new Error("mcp: response is not valid JSON"); + } +} + +/** Parse an MCP response body: plain JSON or an SSE stream. Spec-conformant + * enough for a smoke (review R12 #7): line breaks may be CRLF/CR/LF (normalize + * first), an event's `data` is every `data:` line joined WITH `\n` (the spec's + * mandated joiner — concatenating loses multiline JSON), and the optional + * single space after the colon is stripped (`data:x` and `data: x` are equal). */ +async function mcpBody(response) { + const text = await response.text(); + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("text/event-stream")) { + return parseJson(text); + } + const messages = []; + const normalized = text.replace(/\r\n|\r/g, "\n"); + for (const block of normalized.split("\n\n")) { + const data = block + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).replace(/^ /, "")) + .join("\n"); + if (data) messages.push(parseJson(data)); + } + return messages.length === 1 ? messages[0] : messages; +} + +async function mcpLane() { + // initialize → session id → notifications/initialized → tools/list (paged). + let nextId = 1; + let sessionId = null; + const send = async (method, params, notification = false) => { + const headers = { "mcp-protocol-version": MCP_PROTOCOL_VERSION }; + if (sessionId) headers["mcp-session-id"] = sessionId; + const response = await request(MCP_URL, { + method: "POST", + headers, + body: JSON.stringify({ + jsonrpc: "2.0", + ...(notification ? {} : { id: nextId++ }), + method, + ...(params === undefined ? {} : { params }), + }), + }); + sessionId = response.headers.get("mcp-session-id") ?? sessionId; + if (notification) return null; + const body = await mcpBody(response); + const messages = Array.isArray(body) ? body : [body]; + const reply = messages.find((m) => m?.id === nextId - 1); + if (!reply) throw new Error(`${method}: no JSON-RPC reply`); + if (reply.error) throw new Error(`${method}: JSON-RPC ${reply.error.code}`); + return reply; + }; + + await send("initialize", { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "inkstone-live-smoke", version: "0.0.0" }, + }); + await send("notifications/initialized", undefined, true); + + const names = []; + let cursor; + do { + const reply = await send("tools/list", cursor ? { cursor } : {}); + names.push(...(reply.result?.tools ?? []).map((tool) => tool.name)); + cursor = reply.result?.nextCursor; + } while (cursor); + + const missing = ALLOWLIST.filter((name) => !names.includes(name)); + if (missing.length > 0) { + throw new Error( + `MCP tools/list no longer offers allowlisted tool(s): ${missing.join(", ")}`, + ); + } + console.log( + `mcp: ok (tools=${names.length}, allowlist_present=${ALLOWLIST.length}/${ALLOWLIST.length})`, + ); +} + +try { + await mcpLane(); + console.log("mcp live smoke: PASS"); +} catch (error) { + console.error(`mcp live smoke: FAIL — ${error.message}`); + process.exit(1); +} diff --git a/tests/contract/fixtures/structs/authored/worker_stdout.external_tool_finished.json b/tests/contract/fixtures/structs/authored/worker_stdout.external_tool_finished.json new file mode 100644 index 00000000..a9983fb1 --- /dev/null +++ b/tests/contract/fixtures/structs/authored/worker_stdout.external_tool_finished.json @@ -0,0 +1,13 @@ +{ + "kind": "external_tool_finished", + "tool_call_id": "tc_ext_1", + "result": { + "content": [ + { + "type": "text", + "text": "1 task found: S1 timed" + } + ], + "is_error": false + } +} diff --git a/tests/contract/fixtures/structs/authored/worker_stdout.external_tool_started.json b/tests/contract/fixtures/structs/authored/worker_stdout.external_tool_started.json new file mode 100644 index 00000000..4f755152 --- /dev/null +++ b/tests/contract/fixtures/structs/authored/worker_stdout.external_tool_started.json @@ -0,0 +1,11 @@ +{ + "kind": "external_tool_started", + "tool_call_id": "tc_ext_1", + "name": "ticktick_filter_tasks", + "arguments": { + "filter": { + "status": [0], + "tag": ["s1-advanced"] + } + } +} diff --git a/tests/contract/fixtures/structs/emitted/external_tool_ack.finished_nack.json b/tests/contract/fixtures/structs/emitted/external_tool_ack.finished_nack.json new file mode 100644 index 00000000..cf8cf094 --- /dev/null +++ b/tests/contract/fixtures/structs/emitted/external_tool_ack.finished_nack.json @@ -0,0 +1,6 @@ +{ + "kind": "external_tool_ack", + "ok": false, + "phase": "finished", + "tool_call_id": "tc_ext" +} diff --git a/tests/contract/fixtures/structs/emitted/external_tool_ack.started.json b/tests/contract/fixtures/structs/emitted/external_tool_ack.started.json new file mode 100644 index 00000000..c79fb6b0 --- /dev/null +++ b/tests/contract/fixtures/structs/emitted/external_tool_ack.started.json @@ -0,0 +1,6 @@ +{ + "kind": "external_tool_ack", + "ok": true, + "phase": "started", + "tool_call_id": "tc_ext" +} diff --git a/tests/contract/fixtures/structs/emitted/run_cancel_result.json b/tests/contract/fixtures/structs/emitted/run_cancel_result.json index bf2a780c..ae333746 100644 --- a/tests/contract/fixtures/structs/emitted/run_cancel_result.json +++ b/tests/contract/fixtures/structs/emitted/run_cancel_result.json @@ -1,3 +1,4 @@ { + "live_tail": true, "outcome": "accepted" } diff --git a/tests/contract/fixtures/structs/emitted/run_event.snapshot.json b/tests/contract/fixtures/structs/emitted/run_event.snapshot.json new file mode 100644 index 00000000..bd285c6b --- /dev/null +++ b/tests/contract/fixtures/structs/emitted/run_event.snapshot.json @@ -0,0 +1,29 @@ +{ + "kind": "snapshot", + "segments": [ + { + "kind": "text", + "text": "Bought milk. " + }, + { + "kind": "tool_call", + "name": "ticktick_filter_tasks", + "result": { + "content": [ + { + "text": "1 task found", + "type": "text" + } + ], + "is_error": false + }, + "status": "completed", + "tool_call_id": "tc_06" + }, + { + "duration_ms": 1500, + "kind": "reasoning", + "text": "Checking the list…" + } + ] +} diff --git a/tests/contract/fixtures/structs/emitted/run_event.tool_call.external_completed.json b/tests/contract/fixtures/structs/emitted/run_event.tool_call.external_completed.json new file mode 100644 index 00000000..3ac2922f --- /dev/null +++ b/tests/contract/fixtures/structs/emitted/run_event.tool_call.external_completed.json @@ -0,0 +1,15 @@ +{ + "kind": "tool_call", + "name": "ticktick_filter_tasks", + "result": { + "content": [ + { + "text": "1 task found", + "type": "text" + } + ], + "is_error": false + }, + "status": "completed", + "tool_call_id": "tc_04" +} diff --git a/tests/contract/fixtures/structs/emitted/run_event.tool_call.external_interrupted.json b/tests/contract/fixtures/structs/emitted/run_event.tool_call.external_interrupted.json new file mode 100644 index 00000000..081a9927 --- /dev/null +++ b/tests/contract/fixtures/structs/emitted/run_event.tool_call.external_interrupted.json @@ -0,0 +1,15 @@ +{ + "kind": "tool_call", + "name": "ticktick_search_task", + "result": { + "content": [ + { + "text": "interrupted", + "type": "text" + } + ], + "is_error": true + }, + "status": "error", + "tool_call_id": "tc_05" +} diff --git a/tests/contract/fixtures/structs/emitted/thread_get_result.json b/tests/contract/fixtures/structs/emitted/thread_get_result.json index 5c83156a..c469a133 100644 --- a/tests/contract/fixtures/structs/emitted/thread_get_result.json +++ b/tests/contract/fixtures/structs/emitted/thread_get_result.json @@ -28,12 +28,29 @@ "arg": "Lev", "kind": "tool_call", "name": "search_entities", - "status": "completed" + "status": "completed", + "tool_call_id": "tc_01" }, { "kind": "tool_call", "name": "read_thread", - "status": "completed" + "status": "completed", + "tool_call_id": "tc_02" + }, + { + "kind": "tool_call", + "name": "ticktick_filter_tasks", + "result": { + "content": [ + { + "text": "1 task found", + "type": "text" + } + ], + "is_error": false + }, + "status": "completed", + "tool_call_id": "tc_03" }, { "entity_id": "0190d3c1-0000-7000-8000-000000000002", diff --git a/tests/contract/fixtures/structs/emitted/ticktick_status_result.connected.json b/tests/contract/fixtures/structs/emitted/ticktick_status_result.connected.json new file mode 100644 index 00000000..cc857d86 --- /dev/null +++ b/tests/contract/fixtures/structs/emitted/ticktick_status_result.connected.json @@ -0,0 +1,4 @@ +{ + "connection_id": "conn-01900000", + "state": "connected" +} diff --git a/tests/contract/fixtures/structs/emitted/ticktick_status_result.not_connected.json b/tests/contract/fixtures/structs/emitted/ticktick_status_result.not_connected.json new file mode 100644 index 00000000..6656ec1b --- /dev/null +++ b/tests/contract/fixtures/structs/emitted/ticktick_status_result.not_connected.json @@ -0,0 +1,3 @@ +{ + "state": "not_connected" +} diff --git a/tests/contract/fixtures/structs/emitted/ticktick_tasks_list_result.json b/tests/contract/fixtures/structs/emitted/ticktick_tasks_list_result.json new file mode 100644 index 00000000..700ef0d9 --- /dev/null +++ b/tests/contract/fixtures/structs/emitted/ticktick_tasks_list_result.json @@ -0,0 +1,35 @@ +{ + "source_limit_reached": true, + "tasks": [ + { + "checklist_items": [ + { + "done": true, + "title": "2%" + } + ], + "due": { + "date": "2026-08-20T17:30:00.000+0000", + "is_all_day": false, + "time_zone": "America/Los_Angeles" + }, + "id": "t1", + "kind": "CHECKLIST", + "list_name": "Inbox", + "priority": 3, + "repeat_flag": "RRULE:FREQ=DAILY;INTERVAL=1", + "tags": [ + "errand" + ], + "title": "buy milk" + }, + { + "checklist_items": [], + "id": "t2", + "kind": "TEXT", + "priority": 0, + "tags": [], + "title": "think" + } + ] +} diff --git a/tests/contract/fixtures/structs/emitted/worker_manifest.json b/tests/contract/fixtures/structs/emitted/worker_manifest.json index 95df44e8..dfcae975 100644 --- a/tests/contract/fixtures/structs/emitted/worker_manifest.json +++ b/tests/contract/fixtures/structs/emitted/worker_manifest.json @@ -6,6 +6,11 @@ "mime": "image/png" } ], + "external_tools": { + "access_token": "tok_ticktick", + "endpoint": "https://mcp.ticktick.com/", + "timeout_ms": 30000 + }, "messages": [ { "role": "user", @@ -24,7 +29,15 @@ ] }, { - "content": "Accepted.", + "result": { + "content": [ + { + "text": "Accepted.", + "type": "text" + } + ], + "is_error": false + }, "role": "tool_result", "tool_call_id": "tc_1" } diff --git a/tests/contract/src/structs.registry.ts b/tests/contract/src/structs.registry.ts index e6765154..8ccd5656 100644 --- a/tests/contract/src/structs.registry.ts +++ b/tests/contract/src/structs.registry.ts @@ -25,6 +25,7 @@ import { EntityListResult, EntityMutateParams, EntityMutateResult, + ExternalToolAck, JournalEntryRescanParams, JournalEntryRescanResult, MediaUploadParams, @@ -79,6 +80,8 @@ import { ThreadRenameParams, ThreadTitledNotification, ThreadUnarchiveParams, + TickTickStatusResult, + TickTickTasksListResult, ToolResult, WorkerManifest, WorkerOutbound, @@ -709,6 +712,24 @@ export const fixtures: readonly FixtureEntry[] = [ schema: RunEvent, dir: "emitted", }, + { + message: "RunEvent", + file: "run_event.tool_call.external_completed.json", + schema: RunEvent, + dir: "emitted", + }, + { + message: "RunEvent", + file: "run_event.tool_call.external_interrupted.json", + schema: RunEvent, + dir: "emitted", + }, + { + message: "RunEvent", + file: "run_event.snapshot.json", + schema: RunEvent, + dir: "emitted", + }, { message: "RunEvent", file: "run_event.done.json", @@ -745,6 +766,18 @@ export const fixtures: readonly FixtureEntry[] = [ schema: ToolResult, dir: "emitted", }, + { + message: "ExternalToolAck", + file: "external_tool_ack.started.json", + schema: ExternalToolAck, + dir: "emitted", + }, + { + message: "ExternalToolAck", + file: "external_tool_ack.finished_nack.json", + schema: ExternalToolAck, + dir: "emitted", + }, { message: "WorkerManifest", file: "worker_manifest.json", @@ -757,11 +790,31 @@ export const fixtures: readonly FixtureEntry[] = [ schema: WorkerManifest, dir: "emitted", }, - // WorkerStdout: Rust deser-only (5 variants); decoded against the TS - // WorkerOutbound = WorkerRunEvent | ToolRequest union, its exact 1:1 mirror - // (RunEvent's Core-synthesized cancelled/tool_call kinds are excluded from + { + message: "TickTickStatusResult", + file: "ticktick_status_result.connected.json", + schema: TickTickStatusResult, + dir: "emitted", + }, + { + message: "TickTickStatusResult", + file: "ticktick_status_result.not_connected.json", + schema: TickTickStatusResult, + dir: "emitted", + }, + { + message: "TickTickTasksListResult", + file: "ticktick_tasks_list_result.json", + schema: TickTickTasksListResult, + dir: "emitted", + }, + // WorkerStdout: Rust deser-only (7 variants); decoded against the TS + // WorkerOutbound = WorkerRunEvent | ToolRequest | ExternalToolStarted | + // ExternalToolFinished union, its exact 1:1 mirror (RunEvent's + // Core-synthesized cancelled/tool_call kinds are excluded from // WorkerRunEvent). text_delta/reasoning_delta/done/error decode as - // WorkerRunEvent members; tool_request as the ToolRequest member. + // WorkerRunEvent members; tool_request and the two external-tool lifecycle + // frames (external-task-views A4) as their own members. { message: "WorkerStdout", file: "worker_stdout.text_delta.json", @@ -792,6 +845,18 @@ export const fixtures: readonly FixtureEntry[] = [ schema: WorkerOutbound, dir: "authored", }, + { + message: "WorkerStdout", + file: "worker_stdout.external_tool_started.json", + schema: WorkerOutbound, + dir: "authored", + }, + { + message: "WorkerStdout", + file: "worker_stdout.external_tool_finished.json", + schema: WorkerOutbound, + dir: "authored", + }, // ProviderHelperLine: Rust deser-only (3 variants) — one NDJSON line of the // Provider Helper's stdout (ADR-0023). Hand-authored because Core only ever // deserializes them (the WorkerStdout situation); the same files are parsed @@ -893,9 +958,10 @@ export const CANONICAL_MESSAGES: readonly string[] = [ "ModelCatalogResult", "SettingsResult", "ProviderTestResult", - // slice 4 — worker↔core protocol (4 messages) + // slice 4 — worker↔core protocol (5 messages) "RunEvent", "ToolResult", + "ExternalToolAck", "WorkerManifest", "WorkerStdout", // provider-helper stdout protocol (ADR-0023) @@ -905,6 +971,9 @@ export const CANONICAL_MESSAGES: readonly string[] = [ // chat-image-attachments slice 1 — media/upload (ADR-0058) "MediaUploadParams", "MediaUploadResult", + // external-task-views S2 — the Web lane's two read verbs (A2) + "TickTickStatusResult", + "TickTickTasksListResult", ]; /** Expected fixture count per tagged-union message (grilling Q10). A union must @@ -916,14 +985,19 @@ export const UNION_VARIANTS: Readonly> = { // tool_call spans 3 ToolCallStatus values) raises the total. A dropped variant // fixture drops the count and reds the lock. // - // RunEvent (6 variants): text_delta, tool_call ×3 statuses, done, cancelled, - // error, reasoning_delta = 8 fixtures. - RunEvent: 8, + // RunEvent (7 variants): text_delta, tool_call ×3 statuses ×2 external + // result legs (external-task-views A4: completed-with-result + + // interrupted-error-with-result), snapshot (review P1 #2 — also locks the + // nested Segment union through this leg), done, cancelled, error, + // reasoning_delta = 11 fixtures. + RunEvent: 11, // ToolResult carries the ToolOutcome union (ok / err) = 2 fixtures. ToolResult: 2, - // WorkerStdout (5 variants): text_delta, done, error, tool_request, - // reasoning_delta = 5. - WorkerStdout: 5, + // ExternalToolAck covers both phase literals and both acceptance outcomes. + ExternalToolAck: 2, + // WorkerStdout (7 variants): text_delta, done, error, tool_request, + // reasoning_delta, external_tool_started, external_tool_finished = 7. + WorkerStdout: 7, // WorkerManifest: maximal (all 3 ManifestMessage variants in one fixture) + // bare = 2 fixtures; the per-ManifestMessage-variant coverage is asserted // structurally in the Rust self-lock, not by fixture count. diff --git a/tests/e2e/src/external-tools.spec.ts b/tests/e2e/src/external-tools.spec.ts new file mode 100644 index 00000000..7ee2b753 --- /dev/null +++ b/tests/e2e/src/external-tools.spec.ts @@ -0,0 +1,293 @@ +import { createServer, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { expect, test as harness } from "./fixtures.js"; +import { FAUX_WORKER_CMD, spawnCore } from "./spawnCore.js"; + +/** + * External-tool lane, full stack (external-task-views A3/A4): Core seeds the + * boot-read TickTick credential + the Workflow's `external_tools` flag, ships + * endpoint+auth in the spawn manifest, the REAL Worker MCP client connects to + * a fake TickTick MCP server, executes the allowlisted call directly, and the + * lifecycle frames land in the transcript — rendered as one collapsed + * name+status row per call that expands to the exact + * `TranscriptToolResult.content` the model received, identically live and + * after reload; errors identically; a Stop mid-call settles the row as the + * Core-generated "interrupted" error. + */ + +interface FakeTickTickMcp { + readonly url: string; + /** Release every held `tools/call` response (the Stop-mid-call window). */ + release(): void; + close(): Promise; +} + +/** A stateless streamable-HTTP fake of TickTick's MCP service (the S1 + * transport shape): initialize / tools/list / one `filter_tasks` read tool. + * `hold` parks `tools/call` responses until `release()` so a spec can act + * while the call is in flight. Empty-args calls fail (`isError: true`) with + * the S1-pinned single-text-block error shape. */ +function startFakeTickTickMcp( + opts: { hold?: boolean } = {}, +): Promise { + const held: Array<() => void> = []; + let released = false; + const server: Server = createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + if (req.method !== "POST") { + res.writeHead(405).end(); + return; + } + const msg = JSON.parse(body) as { + id?: number; + method: string; + params?: { name?: string; arguments?: Record }; + }; + const respond = (res2: ServerResponse, result: unknown) => { + res2 + .writeHead(200, { "content-type": "application/json" }) + .end(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result })); + }; + switch (msg.method) { + case "initialize": + respond(res, { + protocolVersion: "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name: "fake-ticktick", version: "0.0.0" }, + }); + return; + case "tools/list": + // The FULL pinned read allowlist + a write tool: discovery now + // requires every allowlisted tool before starting (review R12 + // #3), and the write tool pins the filter. + respond(res, { + tools: [ + "list_projects", + "list_tags", + "filter_tasks", + "search_task", + "get_task_by_id", + ] + .map((name) => ({ + name, + description: `TickTick ${name}`, + inputSchema: { type: "object" }, + })) + .concat([ + { + name: "create_task", + description: "WRITE tool — the allowlist must exclude it", + inputSchema: { type: "object" }, + }, + ]), + }); + return; + case "tools/call": { + const args = msg.params?.arguments ?? {}; + const send = () => { + if (args.filter === undefined) { + respond(res, { + content: [ + { type: "text", text: "Missing required parameter: filter" }, + ], + isError: true, + }); + return; + } + const label = typeof args.call === "number" ? ` #${args.call}` : ""; + respond(res, { + content: [ + { type: "text", text: `1 task found${label}: S1 timed` }, + ], + structuredContent: { result: [{ title: "S1 timed" }] }, + isError: false, + }); + }; + if (opts.hold === true && !released) { + held.push(send); + } else { + send(); + } + return; + } + default: + res.writeHead(202).end(); + } + }); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as AddressInfo; + resolve({ + url: `http://127.0.0.1:${port}/mcp`, + release: () => { + released = true; + for (const send of held.splice(0)) send(); + }, + close: () => + new Promise((done) => { + server.close(() => done()); + }), + }); + }); + }); +} + +/** The harness `core` fixture rebuilt around a per-test fake MCP server: the + * server's runtime URL must reach `spawnCore`, which static `test.use` + * `coreOptions` cannot carry. `mcpHold` parks `tools/call` responses (the + * Stop-mid-call window) — a server-side behavior, so its own option. */ +const test = harness.extend<{ fakeMcp: FakeTickTickMcp; mcpHold: boolean }>({ + mcpHold: [false, { option: true }], + fakeMcp: async ({ mcpHold }, use) => { + const fake = await startFakeTickTickMcp({ hold: mcpHold }); + await use(fake); + await fake.close(); + }, + core: async ({ coreOptions, fakeMcp }, use) => { + const core = await spawnCore({ + ...coreOptions, + workerCmd: FAUX_WORKER_CMD, + ticktickMcpUrl: fakeMcp.url, + fauxExternalCalls: coreOptions.fauxExternalCalls ?? ["ok"], + }); + await use(core); + await core.shutdown(); + }, +}); + +test.describe("advanced query", () => { + test("renders one expandable row whose expansion is identical live and after reload", async ({ + chat, + }) => { + await chat.goto(); + await chat.send("how many tasks tomorrow?"); + + // The model received the content — its reply echoes it. + await chat.waitForAssistantText(/external result: 1 task found #1/); + + // One collapsed external row, settled completed. + const row = chat.page.getByTestId("tool-call"); + await expect(row).toHaveCount(1); + await expect(row).toHaveAttribute("data-status", "completed"); + await expect(row).toHaveAttribute("data-external", "true"); + await expect(row).toContainText("TickTick · filter tasks"); + await expect(chat.page.getByTestId("tool-call-result")).toHaveCount(0); + + // Expand: the exact model-received content. + await row.getByRole("button").click(); + const liveExpansion = chat.page.getByTestId("tool-call-result"); + await expect(liveExpansion).toHaveText("1 task found #1: S1 timed"); + // Never exposed: raw MCP metadata (the structuredContent sidecar). + await expect(liveExpansion).not.toContainText("structuredContent"); + + // Cold reload: the row + its expansion rehydrate identically. + await chat.reload(); + const reloadedRow = chat.page.getByTestId("tool-call"); + await expect(reloadedRow).toHaveCount(1); + await expect(reloadedRow).toHaveAttribute("data-status", "completed"); + await reloadedRow.getByRole("button").click(); + await expect(chat.page.getByTestId("tool-call-result")).toHaveText( + "1 task found #1: S1 timed", + ); + }); +}); + +test.describe("two same-name calls", () => { + test.use({ coreOptions: { fauxExternalCalls: ["ok", "ok"] } }); + + test("render as two rows with distinct results, live and reloaded", async ({ + chat, + }) => { + await chat.goto(); + await chat.send("check twice"); + await chat.waitForAssistantText(/external result: 1 task found #2/); + + const assertTwoDistinctRows = async () => { + const rows = chat.page.getByTestId("tool-call"); + await expect(rows).toHaveCount(2); + await rows.nth(0).getByRole("button").click(); + await rows.nth(1).getByRole("button").click(); + const expansions = chat.page.getByTestId("tool-call-result"); + await expect(expansions).toHaveCount(2); + await expect(expansions.nth(0)).toHaveText("1 task found #1: S1 timed"); + await expect(expansions.nth(1)).toHaveText("1 task found #2: S1 timed"); + }; + + await assertTwoDistinctRows(); + await chat.reload(); + await assertTwoDistinctRows(); + }); +}); + +test.describe("failed call", () => { + test.use({ coreOptions: { fauxExternalCalls: ["error"] } }); + + test("renders an error row that expands to the error content, identically after reload", async ({ + chat, + }) => { + await chat.goto(); + await chat.send("bad query"); + await chat.waitForAssistantText(/external result: Missing required/); + + const assertErrorRow = async () => { + const row = chat.page.getByTestId("tool-call"); + await expect(row).toHaveAttribute("data-status", "error"); + await expect(row).toContainText("failed"); + await row.getByRole("button").click(); + await expect(chat.page.getByTestId("tool-call-result")).toHaveText( + "Missing required parameter: filter", + ); + }; + + await assertErrorRow(); + await chat.reload(); + await assertErrorRow(); + }); +}); + +test.describe("stop mid-call", () => { + test.use({ mcpHold: true }); + + test("settles the row as the Core-generated interrupted error, identically after reload", async ({ + chat, + fakeMcp, + }) => { + await chat.goto(); + await chat.send("query that hangs"); + + // The call is in flight: a running external row. + const row = chat.page.getByTestId("tool-call"); + await expect(row).toHaveAttribute("data-status", "running", { + timeout: 15_000, + }); + + // Stop while the MCP call is held open: the cancel transition settles the + // pending row and publishes interrupted → cancelled, in that order. + await chat.stop(); + + const assertInterrupted = async () => { + const settled = chat.page.getByTestId("tool-call"); + await expect(settled).toHaveAttribute("data-status", "error", { + timeout: 15_000, + }); + await settled.getByRole("button").click(); + await expect(chat.page.getByTestId("tool-call-result")).toHaveText( + "interrupted", + ); + }; + await assertInterrupted(); + // A deliberate Stop is calm (ADR-0014), not a failure alert. + await expect(chat.assistantStopped()).toBeVisible(); + + await chat.reload(); + await assertInterrupted(); + + // Unstick the held response so shutdown is not left waiting on it. + fakeMcp.release(); + }); +}); diff --git a/tests/e2e/src/spawnCore.ts b/tests/e2e/src/spawnCore.ts index 6192a3f6..d6a89eea 100644 --- a/tests/e2e/src/spawnCore.ts +++ b/tests/e2e/src/spawnCore.ts @@ -140,6 +140,14 @@ export interface SpawnCoreOptions { }; /** Gate-fixture chunk count: `chunks` > 1 splits `echo: ` into deltas and pauses after chunk 1 until the gate file exists. */ readonly chunks?: number; + /** Bind a FIXED port instead of an ephemeral one (`INKSTONE_PORT`). Only + * needed for a same-tab Core restart (the account-swap e2e): the browser's + * WebSocket reconnects to the same origin after Core comes back. */ + readonly port?: number; + /** Reuse an existing Workspace tempdir instead of minting a fresh one — the + * respawn half of a restart test (same DB, credentials, and boot-read state + * dir as the first spawn). */ + readonly reuseWorkspaceDir?: string; /** * Whether the Workspace boots with a provider already connected (ADR-0023): * seeds a credential file so `provider/status` reports Connected and the chat @@ -179,6 +187,26 @@ export interface SpawnCoreOptions { readonly extractParamsFile?: string; /** Faux direct-capture scenario (`INKSTONE_FAUX_CAPTURE_PARAMS`): `{ intent, todo?, project?, person?, enrich? }` JSON file the capture mode reads. */ readonly captureParamsFile?: string; + /** External-tool lane (external-task-views A3): points Core's TickTick MCP + * endpoint override (`INKSTONE_TICKTICK_MCP_URL`) at a fake server, seeds a + * `ticktick.json` credential, and flips `external_tools = true` in the + * generated faux Workflow — so the spawn manifest carries endpoint + auth + * and the Worker connects for real. */ + readonly ticktickMcpUrl?: string; + /** Drive the faux provider in external tool-call mode + * (`INKSTONE_FAUX_EXTERNAL`): each entry is one `ticktick_filter_tasks` call + * turn (`"error"` scripts an args-less failing call); a final turn echoes + * the last tool result. Requires {@link ticktickMcpUrl}. */ + readonly fauxExternalCalls?: readonly ("ok" | "error")[]; + /** Web lane (external-task-views A2): point Core's TickTick OpenAPI base + * (`INKSTONE_TICKTICK_API_URL`) at a fake server and seed a `ticktick.json` + * credential, so `ticktick/status` reports connected and `ticktick/tasks/list` + * reads from the fake. Omitted = the Web lane is not connected. */ + readonly ticktickApiUrl?: string; + /** The bearer token written to `ticktick.json` (default: a fixed e2e token). + * A restart test passes DIFFERENT tokens per spawn so the fake server can + * serve different accounts by `Authorization` header. */ + readonly ticktickToken?: string; } export interface SpawnedCore { @@ -192,8 +220,10 @@ export interface SpawnedCore { readonly logDir: string; /** Release the gate so the fixture streams its remaining chunks + done. */ tripGate(): void; - /** SIGTERM Core, wait for exit, and remove the tempdir Workspace. */ - shutdown(): Promise; + /** SIGTERM Core, wait for exit, and remove the tempdir Workspace — + * `preserveWorkspace` keeps that dir for a restart test's `reuseWorkspaceDir` + * respawn (the respawned Core's own shutdown removes it). */ + shutdown(opts?: { preserveWorkspace?: boolean }): Promise; } /** Resolve once Core prints `INKSTONE_LISTENING `, or reject on timeout/exit. */ @@ -258,7 +288,11 @@ function awaitListening( export async function spawnCore( opts: SpawnCoreOptions = {}, ): Promise { - const workspaceDir = mkdtempSync(path.join(tmpdir(), "inkstone-test-")); + // A restart test reuses the first spawn's Workspace (same DB + credentials + + // boot-read state dir); otherwise mint a fresh hermetic tempdir. + const workspaceDir = + opts.reuseWorkspaceDir ?? + mkdtempSync(path.join(tmpdir(), "inkstone-test-")); const dbPath = path.join(workspaceDir, "db.sqlite"); // Pin the Diagnostic Log dir (ADR-0038) into the tempdir so e2e runs don't // write core.jsonl/worker.jsonl into the dev/CI OS data dir — and so a test @@ -283,14 +317,45 @@ export async function spawnCore( // Pin the media root (ADR-0058) into the tempdir so `media/upload` bytes // land hermetically — never in the dev/CI OS data dir. INKSTONE_MEDIA_DIR: path.join(workspaceDir, "media"), - // Ephemeral OS-assigned port (avoids cross-test collisions). - INKSTONE_PORT: "0", + // Ephemeral OS-assigned port (avoids cross-test collisions), unless a + // fixed port is requested for a same-tab restart. + INKSTONE_PORT: opts.port !== undefined ? String(opts.port) : "0", INKSTONE_WEB_DIR: WEB_DIST, INKSTONE_LOG_DIR: logDir, INKSTONE_SKILLS_DIR: skillsDir, INKSTONE_CREDENTIALS_DIR: credentialsDir, }; + // TickTick lanes (external-task-views A2/A3): Web reads OpenAPI, Worker reads + // MCP — both from the ONE boot-read `ticktick.json`, so there is a single + // credential shape honoring `ticktickToken`, not a per-lane copy that + // clobbers when both URLs are set (review M10). Each endpoint override is set + // when given, UNCONDITIONALLY — `ticktickMcpUrl` alone (no faux mode) now + // takes effect instead of being a silent no-op trapped in the faux block. + const seedTickTickCredential = (token: string) => { + mkdirSync(credentialsDir, { recursive: true }); + writeFileSync( + path.join(credentialsDir, "ticktick.json"), + JSON.stringify({ + access_token: token, + token_type: "bearer", + scope: "tasks:read tasks:write", + obtained_at: "2026-08-15T00:00:00.000Z", + }), + // Core's custody gate rejects group/world-readable tokens (R12 #5). + { mode: 0o600 }, + ); + }; + if (opts.ticktickApiUrl !== undefined) { + env.INKSTONE_TICKTICK_API_URL = opts.ticktickApiUrl; + } + if (opts.ticktickMcpUrl !== undefined) { + env.INKSTONE_TICKTICK_MCP_URL = opts.ticktickMcpUrl; + } + if (opts.ticktickApiUrl !== undefined || opts.ticktickMcpUrl !== undefined) { + seedTickTickCredential(opts.ticktickToken ?? "e2e-ticktick-web-token"); + } + // Seed a connected provider by default: the chat surface gates the welcome + // composer on `provider/status` (a usable Core has a provider connected), so // without a credential every send-driven spec would land on the first-run @@ -341,6 +406,7 @@ export async function spawnCore( "INKSTONE_FAUX_EXTRACT_PARAMS", "INKSTONE_FAUX_CAPTURE", "INKSTONE_FAUX_CAPTURE_PARAMS", + "INKSTONE_FAUX_EXTERNAL", "INKSTONE_FAUX_ECHO_HISTORY", "INKSTONE_PROPOSE_PARAMS_FILE", ]) { @@ -427,7 +493,8 @@ export async function spawnCore( opts.fauxError !== undefined || opts.fauxToolCall || opts.fauxLoadSkill !== undefined || - opts.faux !== undefined + opts.faux !== undefined || + opts.fauxExternalCalls !== undefined ) { const workflowsDir = path.join(workspaceDir, "workflows"); mkdirSync(workflowsDir, { recursive: true }); @@ -451,6 +518,10 @@ export async function spawnCore( // Deliberately NO `thinking_level` — regression guard for resume's `resolve_effective_workflow`; see docs/design/e2e-tests.md 'system_prompt = "You are a test assistant."', `tools = ${tools}`, + // External-tool opt-in (external-task-views A3): the manifest ships + // endpoint+auth only when the Workflow flips this AND a ticktick + // credential loaded at boot — both seeded below with ticktickMcpUrl. + ...(opts.ticktickMcpUrl !== undefined ? ["external_tools = true"] : []), "", ].join("\n"), ); @@ -474,7 +545,11 @@ export async function spawnCore( }), ); } - if (opts.fauxToolCall) { + // (The MCP endpoint override + ticktick credential are seeded + // unconditionally above — no longer trapped in this faux block, review M10.) + if (opts.fauxExternalCalls !== undefined) { + env.INKSTONE_FAUX_EXTERNAL = opts.fauxExternalCalls.join(","); + } else if (opts.fauxToolCall) { env.INKSTONE_FAUX_TOOL_CALL = "1"; } else if (opts.fauxLoadSkill !== undefined) { env.INKSTONE_FAUX_LOAD_SKILL = opts.fauxLoadSkill; @@ -542,7 +617,7 @@ export async function spawnCore( } writeFileSync(gatePath, "go"); }, - async shutdown() { + async shutdown(opts?: { preserveWorkspace?: boolean }) { await new Promise((resolve) => { if (child.exitCode !== null || child.signalCode !== null) { resolve(); @@ -567,7 +642,13 @@ export async function spawnCore( resolve(); } }); - rmSync(workspaceDir, { recursive: true, force: true }); + // `preserveWorkspace` keeps the Workspace dir for a restart test + // (CodeRabbit #336): the respawn passes it as `reuseWorkspaceDir`, so + // the SAME DB/credentials/boot-state carry over; the second Core's own + // (unpreserved) shutdown then removes the directory. + if (!opts?.preserveWorkspace) { + rmSync(workspaceDir, { recursive: true, force: true }); + } if (binDir) rmSync(binDir, { recursive: true, force: true }); }, }; diff --git a/tests/e2e/src/ticktick-web.spec.ts b/tests/e2e/src/ticktick-web.spec.ts new file mode 100644 index 00000000..5764d041 --- /dev/null +++ b/tests/e2e/src/ticktick-web.spec.ts @@ -0,0 +1,257 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { createServer as createNetServer } from "node:net"; +import { expect, test as harness } from "./fixtures.js"; +import { type SpawnedCore, spawnCore } from "./spawnCore.js"; + +/** + * The hidden Web lane, full stack (external-task-views A2/S2): Core reads + * TickTick's OpenAPI through the real `TickTickClient` against a fake server + * serving small hand-authored wire responses, and the dev-flagged `/library/tasks` + * route renders the normalized rows via the reconnect-protocol hook. Proves the + * whole wiring end-to-end — status-first → the connection-ID key → the two reads + * → normalization (NOTE discarded, 200→199) → the truncation warning — that the + * unit + integration tests exercise in pieces. + * + * The account-swap-across-restart variant (a Core restart swapping the token → + * a new connection ID → the tab's old-ID task data cleared) is proven + * end-to-end in the second section below; its deterministic unit lives in + * apps/web/test/lib/hooks/useTickTick.test.tsx. + */ + +function taskResponse(): string { + const tasks = [ + { + id: "timed", + projectId: "list-1", + title: "Timed task", + kind: "TEXT", + priority: 0, + tags: ["advanced"], + dueDate: "2026-08-20T17:30:00.000+0000", + isAllDay: false, + timeZone: "America/Los_Angeles", + }, + ...Array.from({ length: 198 }, (_, index) => ({ + id: `task-${index}`, + projectId: "list-1", + title: `Task ${index}`, + kind: "TEXT", + priority: 0, + tags: [], + })), + { + id: "note-1", + projectId: "list-1", + title: "Hidden note", + kind: "NOTE", + }, + ]; + return JSON.stringify(tasks); +} + +/** A fake TickTick OpenAPI server with one project and a 200-row task page. */ +function startFakeOpenApi(): Promise<{ + url: string; + close: () => Promise; +}> { + const projects = JSON.stringify([{ id: "list-1", name: "Work" }]); + const tasks = taskResponse(); + const server: Server = createServer((req, res) => { + // The task filter is a POST with a body; drain it before replying. + req.on("data", () => {}); + req.on("end", () => { + const body = req.url?.startsWith("/open/v1/project") ? projects : tasks; + res.writeHead(200, { "content-type": "application/json" }).end(body); + }); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as AddressInfo; + resolve({ + url: `http://127.0.0.1:${port}`, + close: () => + new Promise((done) => { + server.close(() => done()); + }), + }); + }); + }); +} + +const test = harness.extend<{ + fakeApi: { url: string; close: () => Promise }; +}>({ + // biome-ignore lint/correctness/noEmptyPattern: Playwright's empty-destructure idiom for a dependency-free fixture. + fakeApi: async ({}, use) => { + const fake = await startFakeOpenApi(); + await use(fake); + await fake.close(); + }, + core: async ({ coreOptions, fakeApi }, use) => { + // Spread coreOptions so a `test.use({ coreOptions })` here is honored, not + // silently dropped (review M11) — matching external-tools.spec's fixture. + const core = await spawnCore({ + ...coreOptions, + ticktickApiUrl: fakeApi.url, + }); + await use(core); + await core.shutdown(); + }, +}); + +test("renders the normalized tasks and the truncation warning at /library/tasks", async ({ + chat, +}) => { + // The route is dev-flagged (not in nav), reachable only by URL. + await chat.gotoPath("/library/tasks"); + + // The rows rendered — the fake's 200-row page normalizes to 199 visible + // (the one NOTE discarded). + const rows = chat.page.getByTestId("ticktick-task"); + await expect(rows.first()).toBeVisible({ timeout: 15_000 }); + await expect(rows).toHaveCount(199); + + // The 200-row page tripped the truncation ceiling → the warning renders. + await expect( + chat.page.getByTestId("ticktick-truncation-warning"), + ).toContainText("200-item limit"); + + // The representative task is present with its list resolved from /project. + await expect(chat.page.getByText("Timed task")).toBeVisible(); +}); + +// ── Account swap across a Core restart (external-task-views A2, review #2) ──── + +/** A free TCP port (bind :0, read it, release) — Core then binds it for real. */ +function freePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createNetServer(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const { port } = srv.address() as AddressInfo; + srv.close(() => resolve(port)); + }); + }); +} + +/** A fake OpenAPI server that serves a DIFFERENT single task per account, + * keyed by the `Authorization` bearer token — so a token swap changes the + * account's tasks. `/project` is empty (the task carries an inbox sentinel so + * it renders under "Inbox"). */ +function startPerTokenServer(tasksByToken: Record): Promise<{ + url: string; + close: () => Promise; +}> { + const server: Server = createServer((req, res) => { + req.on("data", () => {}); + req.on("end", () => { + const auth = String(req.headers.authorization ?? "").replace( + /^Bearer /, + "", + ); + if (req.url?.startsWith("/open/v1/project")) { + res.writeHead(200, { "content-type": "application/json" }).end("[]"); + return; + } + const title = tasksByToken[auth] ?? "unknown-account"; + const task = { + id: `task-${auth}`, + projectId: "inbox1234", + title, + kind: "TEXT", + priority: 0, + tags: [], + status: 0, + }; + res + .writeHead(200, { "content-type": "application/json" }) + .end(JSON.stringify([task])); + }); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as AddressInfo; + resolve({ + url: `http://127.0.0.1:${port}`, + close: () => + new Promise((done) => { + server.close(() => done()); + }), + }); + }); + }); +} + +// This test manages its own Core lifecycle (two spawns on a fixed port), so it +// opts OUT of the per-test `core`/`fakeApi` fixtures via the base harness. +harness( + "swapping the account across a Core restart clears account A's tasks (A2 reconnect)", + async ({ page }) => { + const api = await startPerTokenServer({ + "token-A": "Account A task", + "token-B": "Account B task", + }); + let core: SpawnedCore | undefined; + let port = 0; + try { + // Boot #1: account A. `freePort` releases the port before Core binds it, + // so another process can steal it — retry with a FRESH port (bounded); + // only the first boot may change ports (the restart must keep the same + // origin for the tab's WebSocket reconnect). + for (let attempt = 0; ; attempt++) { + port = await freePort(); + try { + core = await spawnCore({ + port, + ticktickApiUrl: api.url, + ticktickToken: "token-A", + }); + break; + } catch (error) { + if (attempt >= 2) throw error; + } + } + await page.goto(`${core.url}/library/tasks`); + await expect(page.getByText("Account A task")).toBeVisible({ + timeout: 20_000, + }); + + // Restart Core on the SAME port + Workspace, swapped to account B — + // a new boot mints a new connection_id (A5). The tab survives with A's + // cached query data. Preserve the Workspace so the respawn genuinely + // reuses the same DB/boot-state (a plain shutdown deletes the dir and + // the "restart" would silently be a fresh boot — CodeRabbit #336); the + // second Core's own shutdown in `finally` cleans it up. + const workspaceDir = core.workspaceDir; + await core.shutdown({ preserveWorkspace: true }); + // The restart retries the SAME port (a origin change would break the + // tab's reconnect); the just-released port can need a beat to rebind. + for (let attempt = 0; ; attempt++) { + try { + core = await spawnCore({ + port, + reuseWorkspaceDir: workspaceDir, + ticktickApiUrl: api.url, + ticktickToken: "token-B", + }); + break; + } catch (error) { + if (attempt >= 2) throw error; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + + // The tab's WebSocket reconnects → status-first re-resolves the new + // connection_id → the old-ID task data is cleared and B's task fetched. + await expect(page.getByText("Account B task")).toBeVisible({ + timeout: 30_000, + }); + // A's task never renders against B's connection (no account mixing). + await expect(page.getByText("Account A task")).toHaveCount(0); + } finally { + await core?.shutdown(); + await api.close(); + } + }, +);