From 0863d4bba42576675d42fae83c1bbd4d0ac6a6d6 Mon Sep 17 00:00:00 2001 From: milind-soni Date: Fri, 28 Aug 2026 12:14:35 +0530 Subject: [PATCH] feat(routines): create and manage routines from chat --- server/decision-log.ts | 6 +- server/drivers/agents-proxy.test.ts | 156 ++++- server/drivers/agents-proxy.ts | 207 +++++- server/index.test.ts | 159 +++++ server/index.ts | 268 +++++++- server/routine-requests.test.ts | 858 +++++++++++++++++++++++++ server/routine-requests.ts | 932 ++++++++++++++++++++++++++++ server/routines.test.ts | 104 +++- server/routines.ts | 280 ++++++++- server/store.test.ts | 37 +- server/store.ts | 39 ++ shared/routine-request.ts | 47 ++ src/components/ApprovalCard.test.ts | 129 ++++ src/components/ApprovalCard.tsx | 35 +- src/components/CallView.tsx | 60 +- src/components/GroupCallView.tsx | 66 +- src/components/PendingApproval.tsx | 66 +- src/state/store.tsx | 10 +- 18 files changed, 3376 insertions(+), 83 deletions(-) create mode 100644 server/routine-requests.test.ts create mode 100644 server/routine-requests.ts create mode 100644 shared/routine-request.ts create mode 100644 src/components/ApprovalCard.test.ts diff --git a/server/decision-log.ts b/server/decision-log.ts index a25ebca5b..ea0f44d4c 100644 --- a/server/decision-log.ts +++ b/server/decision-log.ts @@ -36,12 +36,14 @@ export type DecisionKind = /** Who or what produced the decision. The AutoVerdictSource values carry * straight through from auto-approve.ts; `question` marks cards a rule may - * never answer, `auto-fallback` a card shown after delivery failed, `user` - * the human's answer, and auto-review sources the isolated model reviewer. */ + * never answer, `auto-fallback` a card shown after delivery failed, `routine` + * a durable chat scheduling proposal, `user` the human's answer, and + * auto-review sources the isolated model reviewer. */ export type DecisionSource = | AutoVerdictSource | "question" | "auto-fallback" + | "routine" | "user" | "auto-review" | "auto-review-shadow"; diff --git a/server/drivers/agents-proxy.test.ts b/server/drivers/agents-proxy.test.ts index b686cc754..8f6172441 100644 --- a/server/drivers/agents-proxy.test.ts +++ b/server/drivers/agents-proxy.test.ts @@ -22,6 +22,21 @@ let lastDelegateBody: any = null; let delegateResponse: unknown = { queued: true, message: "Delegation queued." }; let lastCreateBody: any = null; let lastCredentialBody: any = null; +let lastRoutineQuery = ""; +let routinesResponse: unknown = { + now: "2026-08-28T10:30:00.000Z", + timeZone: "Asia/Kolkata", + routines: [ + { + id: "routine-1", + name: "Morning brief", + enabled: true, + schedule: { type: "daily", time: "09:00", weekdays: [1, 2, 3, 4, 5] }, + nextRunAt: "2026-08-31T03:30:00.000Z", + }, + ], +}; +let lastRoutineRequestBody: any = null; let child: ChildProcess; const pending = new Map void>(); @@ -94,6 +109,21 @@ beforeAll(async () => { }); return; } + if (req.method === "GET" && req.url?.startsWith("/api/internal/routines?")) { + lastRoutineQuery = req.url; + res.writeHead(200, { "content-type": "application/json" }); + return res.end(JSON.stringify(routinesResponse)); + } + if (req.method === "POST" && req.url === "/api/internal/routine-requests") { + let data = ""; + req.on("data", (c) => (data += c)); + req.on("end", () => { + lastRoutineRequestBody = JSON.parse(data); + res.writeHead(201, { "content-type": "application/json" }); + res.end(JSON.stringify({ requestId: "routine-request-1", summary: "Weekdays at 09:00 (Asia/Kolkata)" })); + }); + return; + } res.writeHead(404, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "unknown" })); }); @@ -132,7 +162,7 @@ afterAll(async () => { }); describe("agents-proxy MCP surface", () => { - it("answers the MCP handshake and lists all five tools", async () => { + it("answers the MCP handshake and lists all eight tools", async () => { const init = await rpc("initialize", { protocolVersion: "2024-11-05" }); expect(init.result.serverInfo.name).toContain("agents"); const list = await rpc("tools/list"); @@ -142,7 +172,35 @@ describe("agents-proxy MCP surface", () => { "delegate_bot", "create_bot", "request_credential", + "list_routines", + "propose_routine", + "propose_routine_action", + ]); + }); + + it("publishes explicit, bounded routine schedule schemas", async () => { + const list = await rpc("tools/list"); + const create = list.result.tools.find((t: { name: string }) => t.name === "propose_routine"); + expect(create.inputSchema.required).toEqual(["name", "instructions", "schedule"]); + expect(create.inputSchema.properties.schedule.oneOf).toEqual( + expect.arrayContaining([ + expect.objectContaining({ required: ["type", "at"] }), + expect.objectContaining({ required: ["type", "time", "weekdays"] }), + ]), + ); + const weekly = create.inputSchema.properties.schedule.oneOf.find( + (option: any) => option.properties.type.const === "weekly", + ); + expect(weekly.properties.weekdays.items.enum).toEqual([ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", ]); + expect(create.description).toContain("does NOT enable"); }); it("list_bots renders the roster and authenticates with the shared token", async () => { @@ -245,6 +303,102 @@ describe("agents-proxy MCP surface", () => { expect(lastCredentialBody).toBeNull(); }); + it("lists only the current bot's routines with authoritative time context", async () => { + routinesResponse = { + now: "2026-08-28T10:30:00.000Z", + timeZone: "Asia/Kolkata", + routines: [{ id: "routine-1", name: "Morning brief", enabled: true }], + }; + const res = await callTool("list_routines", {}); + expect(res.result.content[0].text).toContain("routine-1"); + expect(res.result.content[0].text).toContain("Asia/Kolkata"); + const query = new URL(lastRoutineQuery, "http://localhost").searchParams; + expect(query.get("fromBotId")).toBe("bot-asker"); + expect(query.get("fromThreadId")).toBe("thread-asker-routine"); + expect(lastAuth).toBe(`Bearer ${TOKEN}`); + }); + + it("proposes a weekly routine through a confirmation-only request", async () => { + lastRoutineRequestBody = null; + const res = await callTool("propose_routine", { + name: "Morning brief", + instructions: "Summarize today's priorities.", + schedule: { type: "weekly", time: "09:00", weekdays: ["monday", "friday"] }, + run_on: "maus", + duration_minutes: 45, + }); + expect(lastRoutineRequestBody).toEqual({ + fromBotId: "bot-asker", + fromThreadId: "thread-asker-routine", + action: "create", + routine: { + name: "Morning brief", + instructions: "Summarize today's priorities.", + schedule: { type: "weekly", time: "09:00", weekdays: ["monday", "friday"] }, + runOn: "maus", + durationMinutes: 45, + }, + }); + expect(res.result.content[0].text).toContain("confirmation card"); + expect(res.result.content[0].text).toContain("has not been applied"); + expect(res.result.content[0].text).toContain("do not claim"); + expect(res.result.isError).toBeFalsy(); + }); + + it("proposes a one-time routine with the explicit-offset timestamp intact", async () => { + await callTool("propose_routine", { + name: "Send follow-up", + instructions: "Draft the follow-up for review.", + schedule: { type: "once", at: "2026-09-01T09:00:00+05:30" }, + }); + expect(lastRoutineRequestBody.routine.schedule).toEqual({ + type: "once", + at: "2026-09-01T09:00:00+05:30", + }); + }); + + it("proposes routine updates and destructive actions without applying them", async () => { + const update = await callTool("propose_routine_action", { + routine_id: "routine-1", + action: "update", + changes: { name: "Weekday brief", duration_minutes: 60 }, + }); + expect(lastRoutineRequestBody).toEqual({ + fromBotId: "bot-asker", + fromThreadId: "thread-asker-routine", + action: "update", + routineId: "routine-1", + changes: { name: "Weekday brief", durationMinutes: 60 }, + }); + expect(update.result.content[0].text).toContain("has not been applied"); + + await callTool("propose_routine_action", { routine_id: "routine-1", action: "delete" }); + expect(lastRoutineRequestBody).toEqual({ + fromBotId: "bot-asker", + fromThreadId: "thread-asker-routine", + action: "delete", + routineId: "routine-1", + }); + }); + + it("rejects malformed routine proposals before calling the harness", async () => { + lastRoutineRequestBody = null; + const missing = await callTool("propose_routine", { + name: "No schedule", + instructions: "This cannot be scheduled yet.", + }); + expect(missing.result.isError).toBe(true); + expect(lastRoutineRequestBody).toBeNull(); + + const badUpdate = await callTool("propose_routine_action", { + routine_id: "routine-1", + action: "update", + changes: {}, + }); + expect(badUpdate.result.isError).toBe(true); + expect(lastRoutineRequestBody).toBeNull(); + }); + it("rejects unknown tools with -32602", async () => { const res = await rpc("tools/call", { name: "made_up", arguments: {} }); expect(res.error.code).toBe(-32602); diff --git a/server/drivers/agents-proxy.ts b/server/drivers/agents-proxy.ts index c949db741..b0adb45dc 100644 --- a/server/drivers/agents-proxy.ts +++ b/server/drivers/agents-proxy.ts @@ -1,5 +1,5 @@ // Agent-to-agent comms MCP proxy — spawned as an MCP server inside a bot's -// agent process (via the "agents" integration). Exposes five tools that +// agent process (via the "agents" integration). Exposes eight tools that // let one bot talk to another, routed back through the harness so the // harness stays the single owner of turns, permissions, and recursion // limits: @@ -13,6 +13,9 @@ // create_bot(name, role, instructions) → Chiefs can add a specialist to // their own section // request_credential(id, reason?) → show a secure, allowlisted key card +// list_routines() → inspect this bot's scheduled work +// propose_routine(...) → show a confirmation card for a new routine +// propose_routine_action(...) → show a confirmation card for a routine change // // Speaks raw JSON-RPC 2.0 over stdio (no MCP SDK — house style, matches // computer-proxy / permission-proxy). All state comes from env, injected by @@ -33,6 +36,77 @@ const DEPTH = Number(process.env.OMB_TURN_DEPTH ?? "0") || 0; const MAX_CREATED_PER_TURN = 4; let createdThisTurn = 0; +const WEEKDAYS = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", +] as const; + +const ROUTINE_SCHEDULE_SCHEMA = { + oneOf: [ + { + type: "object", + additionalProperties: false, + properties: { + type: { type: "string", const: "once" }, + at: { + type: "string", + format: "date-time", + description: + "Future RFC3339 date-time with an explicit timezone offset, for example 2026-09-01T09:00:00+05:30 or 2026-09-01T03:30:00Z.", + }, + }, + required: ["type", "at"], + }, + { + type: "object", + additionalProperties: false, + properties: { + type: { type: "string", const: "weekly" }, + time: { + type: "string", + pattern: "^(?:[01]\\d|2[0-3]):[0-5]\\d$", + description: "Local computer time in 24-hour HH:MM format.", + }, + weekdays: { + type: "array", + minItems: 1, + uniqueItems: true, + items: { type: "string", enum: WEEKDAYS }, + description: "Days on which the routine should run in the computer's local timezone.", + }, + }, + required: ["type", "time", "weekdays"], + }, + ], +} as const; + +const ROUTINE_FIELDS_SCHEMA = { + name: { type: "string", minLength: 1, maxLength: 80, description: "Short name shown in Routines." }, + instructions: { + type: "string", + minLength: 1, + maxLength: 20_000, + description: "The complete instructions the bot should follow each time the routine runs.", + }, + schedule: ROUTINE_SCHEDULE_SCHEMA, + run_on: { + type: "string", + enum: ["maus", "cloud"], + description: "Where the routine runs. Defaults to maus (this OpenMausBot setup).", + }, + duration_minutes: { + type: "integer", + minimum: 15, + maximum: 240, + description: "Maximum run duration in minutes. Defaults to 30.", + }, +} as const; + const TOOLS = [ { name: "list_bots", @@ -101,9 +175,52 @@ const TOOLS = [ required: ["credential_id"], }, }, + { + name: "list_routines", + description: + "List routines owned by this bot, including their ids, schedules, status, and next run. The result includes the computer's authoritative current time and timezone; use those when interpreting relative dates. Only call this when the user asks about routines or wants to change one.", + inputSchema: { type: "object", additionalProperties: false, properties: {} }, + }, + { + name: "propose_routine", + description: + "Prepare a new routine after the user explicitly asks to schedule recurring or future work. Call list_routines first for relative dates or times so you use its authoritative current time and timezone. This only creates a durable confirmation card; it does NOT enable the routine. Resolve ambiguous dates, times, timezone, destination, or instructions with the user first, and always give one-time schedules an explicit RFC3339 offset. After calling it, end the turn and do not claim the routine exists until the user confirms the card.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: ROUTINE_FIELDS_SCHEMA, + required: ["name", "instructions", "schedule"], + }, + }, + { + name: "propose_routine_action", + description: + "Prepare a user-requested change to one of this bot's existing routines. This only creates a durable confirmation card; it does NOT apply the change. Use list_routines first to get the routine id. After calling it, end the turn and do not claim the action completed until the user confirms the card.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + routine_id: { type: "string", minLength: 1, description: "Routine id from list_routines." }, + action: { + type: "string", + enum: ["update", "pause", "resume", "run_now", "delete"], + description: "The requested action. Supply changes only for update.", + }, + changes: { + type: "object", + additionalProperties: false, + properties: ROUTINE_FIELDS_SCHEMA, + description: "Fields to change when action is update. Omit for every other action.", + }, + }, + required: ["routine_id", "action"], + }, + }, ]; type Json = Record; +type RoutineAction = "update" | "pause" | "resume" | "run_now" | "delete"; + const send = (msg: Json) => process.stdout.write(JSON.stringify(msg) + "\n"); const ok = (id: unknown, result: unknown) => send({ jsonrpc: "2.0", id, result }); const rpcErr = (id: unknown, code: number, message: string) => send({ jsonrpc: "2.0", id, error: { code, message } }); @@ -120,6 +237,35 @@ async function api(path: string, init?: RequestInit): Promise { return body; } +function jsonRecord(value: unknown): value is Json { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function routineAction(value: unknown): RoutineAction | null { + return value === "update" || value === "pause" || value === "resume" || value === "run_now" || value === "delete" + ? value + : null; +} + +function routineFields(args: Json): Json { + const fields: Json = {}; + if (typeof args.name === "string") fields.name = args.name.trim(); + if (typeof args.instructions === "string") fields.instructions = args.instructions.trim(); + if (args.schedule && typeof args.schedule === "object" && !Array.isArray(args.schedule)) { + fields.schedule = args.schedule; + } + if (typeof args.run_on === "string") fields.runOn = args.run_on; + if (typeof args.duration_minutes === "number") fields.durationMinutes = args.duration_minutes; + return fields; +} + +function confirmationResult(r: Json, fallback: string): { text: string } { + const summary = typeof r.summary === "string" && r.summary.trim() ? `\n\n${r.summary.trim()}` : ""; + return { + text: `A confirmation card is now visible to the user for ${fallback}.${summary}\n\nThis change has not been applied yet. End this turn and wait for the user to confirm or deny the card; do not claim the routine was created or changed before confirmation.`, + }; +} + async function callTool(name: string, args: Json): Promise<{ text: string; isError?: boolean }> { if (name === "list_bots") { const r = await api(`/api/internal/agents?self=${encodeURIComponent(BOT_ID)}`); @@ -210,6 +356,65 @@ async function callTool(name: string, args: Json): Promise<{ text: string; isErr text: `A secure ${r.label ?? CREDENTIAL_TARGETS[credentialId].label} card is now visible to the user. End this turn; OpenMausBot will resume the task after they save or decline. Never ask them to paste the key into chat.`, }; } + if (name === "list_routines") { + const query = new URLSearchParams({ fromBotId: BOT_ID, fromThreadId: THREAD_ID }); + const r = await api(`/api/internal/routines?${query.toString()}`); + const routines = Array.isArray(r.routines) ? r.routines : []; + const now = typeof r.now === "string" ? r.now : new Date().toISOString(); + const timeZone = typeof r.timeZone === "string" && r.timeZone ? r.timeZone : "local computer timezone"; + if (!routines.length) { + return { text: `This bot has no routines. Current time: ${now}. Timezone: ${timeZone}.` }; + } + return { + text: `This bot's routines (current time: ${now}; timezone: ${timeZone}):\n${JSON.stringify(routines, null, 2)}`, + }; + } + if (name === "propose_routine") { + const routine = routineFields(args); + if (!routine.name || !routine.instructions || !routine.schedule) { + return { text: "propose_routine needs name, instructions, and schedule.", isError: true }; + } + const r = await api("/api/internal/routine-requests", { + method: "POST", + body: JSON.stringify({ + fromBotId: BOT_ID, + fromThreadId: THREAD_ID, + action: "create", + routine, + }), + }); + return confirmationResult(r, `the new routine “${routine.name}”`); + } + if (name === "propose_routine_action") { + const routineId = String(args.routine_id ?? "").trim(); + const action = routineAction(args.action); + if (!routineId || !action) { + return { text: "propose_routine_action needs a routine_id and supported action.", isError: true }; + } + const body: Json = { + fromBotId: BOT_ID, + fromThreadId: THREAD_ID, + action, + routineId, + }; + if (action === "update") { + if (!jsonRecord(args.changes)) { + return { text: "The update action needs at least one field in changes.", isError: true }; + } + const changes = routineFields(args.changes); + if (!Object.keys(changes).length) { + return { text: "The update action needs at least one supported field in changes.", isError: true }; + } + body.changes = changes; + } else if (args.changes !== undefined) { + return { text: `The ${action} action does not accept changes.`, isError: true }; + } + const r = await api("/api/internal/routine-requests", { + method: "POST", + body: JSON.stringify(body), + }); + return confirmationResult(r, `${action.replace("_", " ")} on routine ${routineId}`); + } return { text: `Unknown tool: ${name}`, isError: true }; } diff --git a/server/index.test.ts b/server/index.test.ts index ea3c444c2..ff9fd8762 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -10,6 +10,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { z } from "zod"; import { removeTempDir, waitForExit } from "./testing/cleanup.ts"; import { openSse } from "./testing/sse.ts"; @@ -2288,6 +2289,164 @@ describe("harness HTTP API", () => { } }); + it("keeps chat-created routines inert until their durable card is confirmed", async () => { + const bot = (await api("POST", "/api/bots", {})).body.bot; + let routineId = ""; + let legacyRoutineId = ""; + try { + const selected = await api("PATCH", `/api/bots/${bot.id}`, { + modelSelection: { instanceId: "claude", model: "claude-sonnet-5" }, + }); + expect(selected.status).toBe(200); + + rmSync(fakeClaudeDump, { force: true }); + expect((await api("POST", `/api/bots/${bot.id}/messages`, { text: "prepare a routine" })).status).toBe(202); + await expect.poll(() => existsSync(fakeClaudeDump), { timeout: 5_000 }).toBe(true); + const dump = JSON.parse(readFileSync(fakeClaudeDump, "utf8")); + const token = dump.mcpConfig.mcpServers.agents.env.OMB_COMMS_TOKEN; + expect(token).toMatch(/^[a-f0-9]{48}$/); + const internalHeaders = { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }; + + const before = await fetch( + `${BASE}/api/internal/routines?fromBotId=${encodeURIComponent(bot.id)}&fromThreadId=${encodeURIComponent(bot.threadId)}`, + { headers: internalHeaders }, + ); + expect(before.status).toBe(200); + expect(z.object({ routines: z.array(z.unknown()) }).parse(await before.json()).routines).toEqual([]); + + const unavailableCloud = await fetch(`${BASE}/api/internal/routine-requests`, { + method: "POST", + headers: internalHeaders, + body: JSON.stringify({ + fromBotId: bot.id, + fromThreadId: bot.threadId, + action: "create", + routine: { + name: "Cloud brief", + instructions: "Summarize today's priorities in the Cloud VM.", + schedule: { type: "weekly", time: "09:00", weekdays: ["monday"] }, + runOn: "cloud", + }, + }), + }); + expect(unavailableCloud.status).toBe(409); + expect(await unavailableCloud.json()).toMatchObject({ + error: expect.stringMatching(/Box API key|Cloud VM runner/i), + }); + + const proposed = await fetch(`${BASE}/api/internal/routine-requests`, { + method: "POST", + headers: internalHeaders, + body: JSON.stringify({ + fromBotId: bot.id, + fromThreadId: bot.threadId, + action: "create", + routine: { + name: "Weekday brief", + instructions: "Summarize the priorities for today.", + schedule: { + type: "weekly", + time: "09:00", + weekdays: ["monday", "tuesday", "wednesday", "thursday", "friday"], + }, + runOn: "maus", + durationMinutes: 30, + }, + }), + }); + expect(proposed.status).toBe(201); + const proposal = z.object({ requestId: z.string() }).passthrough().parse(await proposed.json()); + + const stillInert = await api("GET", "/api/routines"); + expect(stillInert.body.routines.filter((routine: { botId: string }) => routine.botId === bot.id)).toEqual([]); + const state = (await api("GET", "/api/bots")).body; + const card = state.bots + .find((candidate: { id: string }) => candidate.id === bot.id) + ?.messages.find((message: { card?: { requestId?: string } }) => message.card?.requestId === proposal.requestId); + expect(card?.card).toMatchObject({ + tool: "schedule_routine", + routineRequest: { botId: bot.id, threadId: bot.threadId }, + }); + expect(card?.card.answered).toBeUndefined(); + + const confirmed = await api("POST", `/api/threads/${bot.threadId}/respond`, { + requestId: proposal.requestId, + behavior: "allow", + }); + expect(confirmed).toMatchObject({ status: 200, body: { outcome: "allowed-once", routineAction: "create" } }); + routineId = confirmed.body.resultId; + await expect.poll(async () => { + const decisions = (await api("GET", "/api/decisions")).body.decisions; + return decisions + .filter((decision: { requestId?: string }) => decision.requestId === proposal.requestId) + .map((decision: { decision: string; source: string }) => `${decision.decision}:${decision.source}`) + .sort(); + }).toEqual(["card-shown:routine", "user-approved:user"]); + + const after = await api("GET", "/api/routines"); + expect(after.body.routines.filter((routine: { botId: string }) => routine.botId === bot.id)).toHaveLength(1); + const duplicate = await api("POST", `/api/threads/${bot.threadId}/respond`, { + requestId: proposal.requestId, + behavior: "allow", + }); + expect(duplicate.body.alreadySettled).toBe(true); + expect((await api("GET", "/api/routines")).body.routines + .filter((routine: { botId: string }) => routine.botId === bot.id)).toHaveLength(1); + + // Calendar-created routines may predate chat-card redaction. Listing + // them to a model must redact the whole prompt before returning its + // bounded preview, and tell the model when that preview is incomplete. + const fakeSecret = `Bearer ${"a".repeat(24)}`; + const fakeNameSecret = `sk-proj-${"b".repeat(24)}`; + const legacy = await api("POST", "/api/routines", { + name: `Legacy ${fakeNameSecret}`, + prompt: `${fakeSecret}\n${"Review the archive. ".repeat(180)}`, + botId: bot.id, + runOn: "maus", + enabled: false, + schedule: { type: "daily", time: "10:00", weekdays: [1] }, + }); + legacyRoutineId = legacy.body.routine.id; + const listed = await fetch( + `${BASE}/api/internal/routines?fromBotId=${encodeURIComponent(bot.id)}&fromThreadId=${encodeURIComponent(bot.threadId)}`, + { headers: internalHeaders }, + ); + expect(listed.status).toBe(200); + const listedBody = z.object({ + routines: z.array(z.object({ + id: z.string(), + instructions: z.string(), + instructionsTruncated: z.boolean(), + }).passthrough()), + }).parse(await listed.json()); + const legacyResult = listedBody.routines.find((routine) => routine.id === legacyRoutineId)!; + expect(legacyResult.instructions).not.toContain(fakeSecret); + expect(legacyResult.name).not.toContain(fakeNameSecret); + expect(legacyResult.instructions).toContain("redacted"); + expect(legacyResult.instructionsTruncated).toBe(true); + + const wrongThread = await fetch(`${BASE}/api/internal/routine-requests`, { + method: "POST", + headers: internalHeaders, + body: JSON.stringify({ + fromBotId: bot.id, + fromThreadId: "not-this-bots-thread", + action: "pause", + routineId, + }), + }); + expect(wrongThread.status).toBe(403); + } finally { + if (legacyRoutineId) await api("DELETE", `/api/routines/${legacyRoutineId}`); + if (routineId) await api("DELETE", `/api/routines/${routineId}`); + await api("POST", `/api/bots/${bot.id}/interrupt`); + await api("DELETE", `/api/bots/${bot.id}`); + } + }); + it("validates the non-secret VPS alias and keeps old bots on Box by default", async () => { const before = await api("GET", "/api/bots"); const bot = before.body.bots[0]; diff --git a/server/index.ts b/server/index.ts index 98d990ece..315e69dae 100644 --- a/server/index.ts +++ b/server/index.ts @@ -139,8 +139,10 @@ import { readCuaConnection } from "./local-computer.ts"; import { LocalVmIdleTimer } from "./local-vm-idle.ts"; import { LocalVmLease, LocalVmLeasePool } from "./local-vm-lease.ts"; import { RepeatDetector, callKey } from "./repeat-detector.ts"; +import { redactSecretsInText } from "./redact.ts"; import * as vps from "./vps-computer.ts"; import { RoutineManager, type RoutineRunOn, type RoutineRunTrigger } from "./routines.ts"; +import { RoutineRequestService } from "./routine-requests.ts"; import { fetchBotDirectory, matchDirectoryBots, type MatchedDirectoryBot } from "./bot-directory.ts"; import { scoutProject, suggestTeam } from "./project-scout.ts"; import { fetchGithubTeam, fetchLibraryTeam, fetchTeamCatalog } from "./team-library.ts"; @@ -263,6 +265,22 @@ const computerControl = new ComputerControl((botId, snapshot) => { broadcast({ kind: "computer-control", botId, held: snapshot.held, helpReason: snapshot.helpReason }); }); const controlLeaseIdSchema = z.string().min(16).max(120).regex(/^[A-Za-z0-9_-]+$/); +const routineRequestSourceSchema = { + fromBotId: z.string().min(1).max(128), + fromThreadId: z.string().min(1).max(128), +}; +const routineRequestEnvelopeSchema = z.discriminatedUnion("action", [ + z.object({ ...routineRequestSourceSchema, action: z.literal("create"), routine: z.unknown() }).strict(), + z.object({ + ...routineRequestSourceSchema, + action: z.literal("update"), + routineId: z.unknown(), + changes: z.unknown(), + }).strict(), + ...(["pause", "resume", "run_now", "delete"] as const).map((action) => + z.object({ ...routineRequestSourceSchema, action: z.literal(action), routineId: z.unknown() }).strict() + ), +]); /** The loopback endpoint a bot's computer proxy polls before acting. */ function controlIntegration(botId: string) { @@ -509,6 +527,9 @@ store.onChange((change) => { case "thread": broadcast({ kind: "thread", threadId: change.threadId, activeLeafId: change.activeLeafId }); break; + case "thread.deleted": + routines?.forgetRoutineRequestReceiptsForThread(change.threadId); + break; case "bot": { const bot = store.bot(change.botId); if (bot) broadcast({ kind: "bot", bot: wireBot(bot) }); @@ -719,10 +740,10 @@ async function answerRequest( return outcome; } -/** Close every approval still open on a thread. Interrupting a turn kills the - * process that raised its questions, so those cards can never be answered — - * and a pending approval owns the composer, so one left open blocks the - * conversation behind a question with nobody left to hear the answer. */ +/** Close every provider-owned approval still open on a thread. Interrupting a + * turn kills the process that raised its questions, so those cards can never + * be answered. Routine proposals are harness-owned and durable, so they stay + * actionable even after the proposing turn has stopped. */ function closeOpenApprovals(threadId: string): void { // Peer approvals also hold an in-memory promise. Resolve those first; merely // patching their cards would leave the delegation queue waiting 15 minutes. @@ -730,6 +751,7 @@ function closeOpenApprovals(threadId: string): void { for (const message of store.messagesFor(threadId)) { const card = message.card; if (!card?.requestId || card.answered || card.dismissed) continue; + if (card.routineRequest) continue; store.patchMessage(threadId, message.id, { card: { ...card, answered: "unavailable", dismissed: true } }); askMessageByRequest.delete(`${threadId}:${card.requestId}`); } @@ -2011,6 +2033,9 @@ async function startTurn( const credentialPrompt = integrations.agents ? " If a supported API key is missing, use request_credential to show the secure in-app card. Never ask the user to paste credentials into chat." : ""; + const routinePrompt = integrations.agents + ? " If the user explicitly asks to list or review, schedule, run, or change routines, use list_routines and propose_routine or propose_routine_action. A proposal is not applied until the user confirms its in-app card, so never claim the action completed before that confirmation." + : ""; // (activeVpsThreads was already claimed above, before the provision or // reuse await, so the backend guards saw this turn the whole time.) @@ -2053,6 +2078,7 @@ async function startTurn( : "") + (coordinationPrompt ? ` ${coordinationPrompt}` : "") + credentialPrompt + + routinePrompt + sectionContextSystemPrompt(bot.section) + (privateWorkspace ? memorySystemPrompt(bot.id) + skillsSystemPrompt(bot.id) : "") + skillInstructions + @@ -2134,8 +2160,138 @@ routines = new RoutineManager({ notify(buildNotification("routine-failed", bot, run.threadId ?? bot.threadId, detail)); }, }); +const recoveryOwners = routines.routineRequestReceiptOwners(); +if (recoveryOwners.length > 0) { + // A normal launch has no crash-gap receipts, so it must not eagerly load + // every historical transcript. Inspect only the distinct threads named by + // a surviving receipt; reconciliation then removes any whose card vanished. + const recoveryThreads = [...new Set(recoveryOwners.map((owner) => owner.threadId))]; + routines.reconcileRoutineRequestReceipts( + recoveryThreads.flatMap((threadId) => + store.messagesFor(threadId).flatMap((message) => { + const request = message.card?.routineRequest; + return request && !message.card?.answered && !message.card?.dismissed + ? [{ requestId: request.requestId, messageId: message.id, botId: request.botId, threadId: request.threadId }] + : []; + }), + ), + ); +} routines.start(); +// Chat tools can prepare routine changes, but the harness applies them only +// after the user confirms a durable card. Keeping this beside the scheduler +// makes the card resolvable after an app restart without involving the model. +async function cloudRoutineReadiness(): Promise<{ ready: boolean; reason?: string }> { + if (!box.boxConfigured(cfg)) { + return { + ready: false, + reason: "Cloud VM needs a working Box API key in App Settings before this routine can run.", + }; + } + const instance = registry.instances().find((candidate) => candidate.driverKind === "boxAgent"); + if (!instance) { + return { ready: false, reason: "The Cloud VM runner is unavailable. Restart OpenMausBot and try again." }; + } + try { + const snapshot = await instance.snapshot(); + return snapshot.state === "available" + ? { ready: true } + : { ready: false, reason: snapshot.reason || "The Cloud VM runner is not ready." }; + } catch (error) { + return { + ready: false, + reason: `The Cloud VM runner could not be checked: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} +const routineRequests = new RoutineRequestService({ + store, + routines, + cloudReady: cloudRoutineReadiness, + canPersist: routineProposalPersistence, +}); +const ROUTINE_WEEKDAY_NAMES = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"] as const; +const routineTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; +const agentRoutine = (routine: ReturnType[number]) => { + // Routines created in the calendar predate chat-card redaction and may + // contain a credential in their instructions. The list result is handed + // back to the model, so scrub the complete value before taking its preview. + const safeInstructions = redactSecretsInText(routine.prompt); + const safeName = redactSecretsInText(routine.name); + return { + id: routine.id, + name: safeName, + instructions: safeInstructions.slice(0, 2_000), + instructionsTruncated: safeInstructions.length > 2_000, + enabled: routine.enabled, + runOn: routine.runOn, + durationMinutes: routine.durationMinutes, + schedule: routine.schedule.type === "once" + ? { type: "once" as const, at: new Date(routine.schedule.at).toISOString() } + : { + type: "weekly" as const, + time: routine.schedule.time, + weekdays: routine.schedule.weekdays.map((day) => ROUTINE_WEEKDAY_NAMES[day]), + }, + nextRunAt: routine.nextRunAt === null ? null : new Date(routine.nextRunAt).toISOString(), + }; +}; +function sendRoutineResolution( + res: ServerResponse, + result: ReturnType, +): boolean { + if (!result.claimed) return false; + if (result.state === "invalid") { + json(res, result.status, { error: result.error }); + return true; + } + if (result.state === "already_settled") { + json(res, 200, { + ok: true, + outcome: result.behavior === "allow" ? "allowed-once" : result.behavior === "deny" ? "rejected" : "unavailable", + alreadySettled: true, + }); + return true; + } + if (result.state === "denied") { + json(res, 200, { ok: true, outcome: "rejected" }); + return true; + } + json(res, 200, { + ok: true, + outcome: "allowed-once", + routineAction: result.action, + resultId: result.resultId, + }); + return true; +} +function resolveAndSendRoutine( + res: ServerResponse, + args: { botId: string; botName?: string; threadId: string; requestId: string; behavior: string }, +): boolean { + const card = store.messagesFor(args.threadId).find( + (message) => message.card?.requestId === args.requestId && message.card.routineRequest, + )?.card; + const result = routineRequests.resolve(args); + if ( + result.claimed && + (result.state === "applied" || result.state === "denied") + ) { + appendDecision(DATA_DIR, { + threadId: args.threadId, + requestId: args.requestId, + botId: args.botId, + botName: args.botName, + tool: card?.tool, + summary: card?.subtitle, + decision: result.state === "applied" ? "user-approved" : "user-denied", + source: "user", + }); + } + return sendRoutineResolution(res, result); +} + // Webhook definitions are independent from calendar schedules, but every // delivery joins the same RoutineManager queue. That keeps unattended work // ordered behind a busy MAUS and gives webhook runs the same durable receipts. @@ -2332,6 +2488,8 @@ async function runGroupMemberTurn( `Reply as yourself, briefly and conversationally. To bring a teammate in, mention them like @Name — they'll see the conversation and respond.`, integrations.agents && "If a supported API key is missing, use request_credential to show the secure in-app card. Never ask the user to paste credentials into chat.", + integrations.agents && + "If the user explicitly asks to list or review, schedule, run, or change routines, use list_routines and propose_routine or propose_routine_action. A proposal is not applied until the user confirms its in-app card, so never claim the action completed before that confirmation.", ] .filter(Boolean) .join("\n"); @@ -2583,6 +2741,26 @@ function connectorThread(botId: string, threadId: string) { return null; } +function routineProposalPersistence(botId: string, threadId: string) { + if (!store.bot(botId)) { + return { ok: false as const, status: 403, error: "unknown sender" }; + } + if (!connectorThread(botId, threadId)) { + return { ok: false as const, status: 403, error: "source conversation does not belong to sender" }; + } + // Only cards on the visible branch can be acted on from the composer. + // Abandoned branches must not permanently consume the proposal quota. + const openRequests = store.activePath(threadId).filter( + (message) => + message.card?.routineRequest?.botId === botId && + !message.card.answered && + !message.card.dismissed, + ).length; + return openRequests >= 8 + ? { ok: false as const, status: 429, error: "confirm or cancel an existing routine proposal first" } + : { ok: true as const }; +} + function connectorMessage(botId: string, threadId: string, messageId: string) { if (!connectorThread(botId, threadId)) return null; const message = store.messagesFor(threadId).find((candidate) => candidate.id === messageId); @@ -3092,6 +3270,63 @@ const server = createServer(async (req, res) => { })); return json(res, 200, { bots }); } + if (method === "GET" && path === "/api/internal/routines") { + const fromBotId = String(url.searchParams.get("fromBotId") ?? ""); + const from = store.bot(fromBotId); + if (!from) return json(res, 403, { error: "unknown sender" }); + const fromThreadId = String(url.searchParams.get("fromThreadId") ?? from.threadId); + if (!connectorThread(from.id, fromThreadId)) { + return json(res, 403, { error: "source conversation does not belong to sender" }); + } + return json(res, 200, { + now: new Date().toISOString(), + timeZone: routineTimeZone(), + routines: routines!.listRoutines() + .filter((routine) => routine.botId === from.id) + .slice(0, 100) + .map(agentRoutine), + }); + } + if (method === "POST" && path === "/api/internal/routine-requests") { + const parsed = routineRequestEnvelopeSchema.safeParse(await readBody(req)); + if (!parsed.success) return json(res, 400, { error: "invalid routine proposal" }); + const body = parsed.data; + const fromBotId = body.fromBotId; + const from = store.bot(fromBotId); + if (!from) return json(res, 403, { error: "unknown sender" }); + const fromThreadId = body.fromThreadId; + const owner = connectorThread(from.id, fromThreadId); + if (!owner) return json(res, 403, { error: "source conversation does not belong to sender" }); + const persistence = routineProposalPersistence(from.id, fromThreadId); + if (!persistence.ok) { + return json(res, persistence.status, { error: persistence.error }); + } + const proposedInput = body.action === "create" + ? { action: body.action, routine: body.routine } + : body.action === "update" + ? { action: body.action, routineId: body.routineId, changes: body.changes } + : { action: body.action, routineId: body.routineId }; + const proposed = await routineRequests.propose({ + botId: from.id, + threadId: fromThreadId, + proposal: proposedInput, + from: owner.group ? { botId: from.id, name: from.name, color: from.color } : undefined, + }); + const proposedCard = store.messagesFor(fromThreadId).find((message) => message.id === proposed.messageId)?.card; + appendDecision(DATA_DIR, { + threadId: fromThreadId, + requestId: proposed.requestId, + botId: from.id, + botName: from.name, + tool: proposedCard?.tool, + // Audit what the human was actually shown, not the shorter tool + // response returned to the model. + summary: proposedCard?.subtitle ?? proposed.summary, + decision: "card-shown", + source: "routine", + }); + return json(res, 201, proposed); + } if (method === "POST" && path === "/api/internal/ask-bot") { const body = await readBody(req); const fromBotId = String(body.fromBotId ?? ""); @@ -5030,6 +5265,13 @@ const server = createServer(async (req, res) => { const body = await readBody(req); const behavior = requestBehavior(body.behavior); if (!behavior) return json(res, 400, { error: "behavior must be allow, deny, or answer" }); + if (resolveAndSendRoutine(res, { + botId: bot.id, + botName: bot.name, + threadId: bot.threadId, + requestId: String(body.requestId), + behavior, + })) return; // peer-approval intercept: harness-native cards carry a requestId // that lives in peer-approval's pending map. Resolve them here so // the provider adapter never sees a request it didn't raise. @@ -5049,6 +5291,24 @@ const server = createServer(async (req, res) => { const behavior = requestBehavior(body.behavior); if (!behavior) return json(res, 400, { error: "behavior must be allow, deny, or answer" }); const requestId = String(body.requestId); + const routineCard = store.messagesFor(threadId).find( + (message) => message.card?.requestId === requestId && message.card.routineRequest, + ); + if (routineCard?.card?.routineRequest) { + // Derive the owner from the conversation, not from the executable + // payload being authorized. Room cards carry their trusted sender; + // one-to-one tasks resolve through the store's thread ownership. + const routineBotId = routineCard.from?.botId ?? store.botByThread(threadId)?.id; + if (!routineBotId) return json(res, 400, { error: "this routine request has no valid owner" }); + const routineOwner = store.bot(routineBotId); + if (resolveAndSendRoutine(res, { + botId: routineBotId, + botName: routineOwner?.name, + threadId, + requestId, + behavior, + })) return; + } // peer-approval intercept (see /api/bots/:id/respond above). A peer card // belongs to the bus rather than to a speaker, so resolve it before we go // looking for one — a room between turns has no speaker to find. diff --git a/server/routine-requests.test.ts b/server/routine-requests.test.ts new file mode 100644 index 000000000..3f972810c --- /dev/null +++ b/server/routine-requests.test.ts @@ -0,0 +1,858 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + RoutineRequestError, + RoutineRequestService, + routineRequestFingerprint, + type RoutineProposalInput, + type RoutineRequestMessage, + type RoutineRequestOptionCard, + type RoutineRequestStore, + type RoutineToolDefinitionInput, +} from "./routine-requests.ts"; +import { RoutineManager } from "./routines.ts"; +import type { JsonValue } from "./schema.ts"; + +class MemoryStore implements RoutineRequestStore { + readonly threads = new Map(); + private sequence = 0; + + messagesFor(threadId: string): RoutineRequestMessage[] { + return this.threads.get(threadId) ?? []; + } + + appendMessage( + threadId: string, + message: { role: "bot"; kind: "options"; card: RoutineRequestOptionCard }, + ): RoutineRequestMessage { + const stored = { id: `message-${++this.sequence}`, card: message.card }; + const messages = this.threads.get(threadId) ?? []; + messages.push(stored); + this.threads.set(threadId, messages); + return stored; + } + + patchMessage( + threadId: string, + messageId: string, + patch: { card: RoutineRequestOptionCard }, + ): RoutineRequestMessage | null { + const message = this.messagesFor(threadId).find((candidate) => candidate.id === messageId); + if (!message) return null; + message.card = patch.card; + return message; + } +} + +const tempDirs: string[] = []; +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function harness( + start = Date.parse("2026-08-28T10:00:00Z"), + cloudReady?: () => Promise<{ ready: boolean; reason?: string }>, + canPersist?: ( + botId: string, + threadId: string, + ) => { ok: true } | { ok: false; status: number; error: string }, +) { + const clock = { now: start }; + const dir = mkdtempSync(join(tmpdir(), "omb-routine-request-")); + tempDirs.push(dir); + const routines = new RoutineManager({ + file: join(dir, "routines.json"), + now: () => clock.now, + botState: (botId) => (botId === "missing" ? "missing" : "busy"), + createTask: () => null, + startTurn: async () => {}, + }); + const store = new MemoryStore(); + const service = new RoutineRequestService({ + store, + routines, + now: () => clock.now, + timeZone: () => "Asia/Kolkata", + cloudReady, + canPersist, + }); + return { clock, routines, service, store }; +} + +function createProposal(overrides: Partial = {}): RoutineProposalInput { + return { + action: "create", + routine: { + name: "Morning brief", + instructions: "Summarize the overnight support queue.", + schedule: { type: "weekly", time: "09:00", weekdays: ["monday", "wednesday"] }, + ...overrides, + }, + }; +} + +function malformedProposal(value: JsonValue): RoutineProposalInput { + // SAFETY: These values are deliberately malformed to exercise the runtime + // Zod boundary; production callers receive the schema-derived type. + return value as RoutineProposalInput; +} + +function cardFingerprint(card: RoutineRequestOptionCard, messageId: string): string { + if (!card.routineRequest) throw new Error("missing routine request payload"); + return routineRequestFingerprint(card.routineRequest, messageId); +} + +describe("RoutineRequestService", () => { + it("normalizes weekly input, scrubs hidden payload text, and creates a durable confirmation card", async () => { + const { service, store, routines } = harness(); + const secret = "sk-proj-abcdefghijklmnopqrstuv"; + const proposed = await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: createProposal({ instructions: `Use ${secret} to prepare the brief.` }), + }); + + expect(routines.listRoutines()).toHaveLength(0); + expect(proposed.timeZone).toBe("Asia/Kolkata"); + expect(proposed.summary).toContain("Monday, Wednesday at 09:00 (Asia/Kolkata)"); + const card = store.messagesFor("thread-a")[0]!.card!; + expect(card.requestId).toBe(proposed.requestId); + expect(card.tool).toBe("schedule_routine"); + expect(card.options).toEqual(["Confirm", "Cancel"]); + expect(card.routineRequest?.operation).toMatchObject({ + action: "create", + routine: { schedule: { type: "daily", time: "09:00", weekdays: [1, 3] } }, + }); + expect(JSON.stringify(card)).not.toContain(secret); + expect(JSON.stringify(card)).toContain("redacted"); + }); + + it("canonicalizes receipt fingerprints and binds them to the card's conversation", async () => { + const { service, store } = harness(); + await service.propose({ botId: "bot-a", threadId: "thread-a", proposal: createProposal() }); + const messageId = store.messagesFor("thread-a")[0]!.id; + const original = store.messagesFor("thread-a")[0]!.card!.routineRequest!; + if (original.operation.action !== "create") throw new Error("Expected a create proposal"); + const routine = original.operation.routine; + const reordered: typeof original = { + operation: { + routine: { + durationMinutes: routine.durationMinutes, + schedule: routine.schedule.type === "once" + ? { at: routine.schedule.at, type: "once" } + : { weekdays: [...routine.schedule.weekdays], time: routine.schedule.time, type: "daily" }, + instructions: routine.instructions, + runOn: routine.runOn, + name: routine.name, + }, + action: "create", + }, + createdAt: original.createdAt, + threadId: original.threadId, + botId: original.botId, + requestId: original.requestId, + version: 1, + }; + + expect(routineRequestFingerprint(reordered, messageId)).toBe(routineRequestFingerprint(original, messageId)); + expect(routineRequestFingerprint({ ...reordered, threadId: "thread-b" }, messageId)) + .not.toBe(routineRequestFingerprint(original, messageId)); + expect(routineRequestFingerprint(reordered, "another-message")) + .not.toBe(routineRequestFingerprint(original, messageId)); + }); + + it("shows the exact action, name, and complete executable instructions in the approval detail", async () => { + const { service, store } = harness(); + const instructions = `BEGIN-${"work carefully. ".repeat(120)}-END`; + const proposed = await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: createProposal({ name: "Full fidelity brief", instructions }), + }); + const message = store.messagesFor("thread-a")[0]!; + const card = message.card!; + card.held = "Temporary persistence error"; + + expect(proposed.summary).toContain("Schedule “Full fidelity brief”"); + expect(proposed.summary).not.toContain(instructions); + expect(proposed.detail).toBe(card.subtitle); + expect(card.subtitle).toContain("Action: Create routine"); + expect(card.subtitle).toContain("Name: Full fidelity brief"); + expect(card.subtitle).toContain(`Instructions:\n${instructions}`); + expect(card.subtitle).toContain("-END"); + }); + + it("never returns an existing routine's credential-shaped text to the proposing bot", async () => { + const { service, routines, store } = harness(); + const secret = "sk-proj-existingroutineabcdefghijkl"; + const nameSecret = "sk-proj-existingnameabcdefghijkl"; + const routine = routines.create({ + botId: "bot-a", + name: `Existing ${nameSecret}`, + prompt: `Use ${secret} and then prepare the brief.`, + schedule: { type: "daily", time: "10:00", weekdays: [1] }, + }); + const proposed = await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { action: "run_now", routineId: routine.id }, + }); + + expect(proposed.detail).not.toContain(secret); + expect(proposed.title).not.toContain(nameSecret); + expect(proposed.summary).not.toContain(nameSecret); + expect(proposed.detail).not.toContain(nameSecret); + expect(proposed.detail).toContain("redacted"); + expect(JSON.stringify(store.messagesFor("thread-a")[0]!.card)).not.toContain(secret); + expect(JSON.stringify(store.messagesFor("thread-a")[0]!.card)).not.toContain(nameSecret); + }); + + it("rejects ambiguous, invalid, and stale one-time schedules", async () => { + const { service, clock } = harness(); + const proposal = (at: string) => + service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: createProposal({ schedule: { type: "once", at } }), + }); + + await expect(proposal("2026-08-29T09:00:00")).rejects.toThrow(/explicit timezone offset/); + await expect(proposal("not-a-date")).rejects.toThrow(/explicit timezone offset/); + await expect(proposal("2026-02-30T09:00:00Z")).rejects.toThrow(/valid RFC3339/); + await expect(proposal(new Date(clock.now - 1).toISOString())).rejects.toThrow(/future/); + await expect( + service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: createProposal({ durationMinutes: 5 }), + }), + ).rejects.toThrow(/15 to 240/); + await expect( + service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: malformedProposal({ + action: "create", + routine: { + name: "Morning brief", + instructions: "Do it", + schedule: { type: "weekly", time: "09:00", weekdays: ["monday"] }, + surprise: true, + }, + }), + }), + ).rejects.toThrow(/Unrecognized key.*surprise/); + await expect( + service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: malformedProposal({ + action: "create", + routine: { + name: "Morning brief", + instructions: "Do it", + schedule: { type: "weekly", time: "09:00", weekdays: ["monday"], timezone: "UTC" }, + }, + }), + }), + ).rejects.toThrow(/Unrecognized key.*timezone/); + await expect( + service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: createProposal({ name: `${"n".repeat(64)} token=abcdefgh` }), + }), + ).rejects.toThrow(/80 characters or fewer after credentials are removed/); + const secretPrefix = "token=abcdefgh "; + const secretAtLimit = `${secretPrefix}${"x".repeat(20_000 - secretPrefix.length)}`; + await expect( + service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: createProposal({ instructions: secretAtLimit }), + }), + ).rejects.toThrow(/20,000 characters or fewer after credentials are removed/); + }); + + it("refuses a cloud routine before creating a card when cloud execution is not ready", async () => { + const { service, store } = harness(undefined, async () => ({ + ready: false, + reason: "Connect or provision a cloud computer first.", + })); + + await expect(service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: createProposal({ runOn: "cloud" }), + })).rejects.toThrow(/Connect or provision/); + expect(store.messagesFor("thread-a")).toHaveLength(0); + }); + + it("checks effective cloud destinations while allowing safe moves away and non-running actions", async () => { + let checks = 0; + const { service, routines, store } = harness(undefined, async () => { + checks += 1; + return { ready: false, reason: "Cloud is offline" }; + }); + const routine = routines.create({ + botId: "bot-a", + name: "Cloud routine", + prompt: "Use the cloud computer", + runOn: "cloud", + enabled: false, + schedule: { type: "daily", time: "10:00", weekdays: [1] }, + }); + + await expect(service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { action: "update", routineId: routine.id, changes: { name: "Still cloud" } }, + })).rejects.toThrow(/Cloud is offline/); + expect(checks).toBe(1); + + await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { action: "update", routineId: routine.id, changes: { runOn: "maus" } }, + }); + await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { action: "pause", routineId: routine.id }, + }); + await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { action: "delete", routineId: routine.id }, + }); + expect(checks).toBe(1); + expect(store.messagesFor("thread-a")).toHaveLength(3); + }); + + it("does not persist a stale card when a routine changes during cloud readiness", async () => { + let mutateDuringCheck = () => {}; + const { service, routines, store } = harness(undefined, async () => { + mutateDuringCheck(); + return { ready: true }; + }); + const routine = routines.create({ + botId: "bot-a", + name: "Cloud routine", + prompt: "Original instructions", + runOn: "cloud", + schedule: { type: "daily", time: "10:00", weekdays: [1] }, + }); + mutateDuringCheck = () => { + routines.update(routine.id, { prompt: "Changed while checking Cloud" }); + }; + + await expect(service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { action: "run_now", routineId: routine.id }, + })).rejects.toThrow(/changed after this confirmation card/); + expect(store.messagesFor("thread-a")).toHaveLength(0); + }); + + it("revalidates conversation ownership after an asynchronous cloud check", async () => { + let finishCloudCheck!: (value: { ready: boolean }) => void; + const cloudCheck = new Promise<{ ready: boolean }>((resolve) => { + finishCloudCheck = resolve; + }); + let ownsConversation = true; + const { service, store } = harness( + undefined, + () => cloudCheck, + () => ownsConversation + ? { ok: true } + : { ok: false, status: 403, error: "source conversation does not belong to sender" }, + ); + + const proposal = service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: createProposal({ runOn: "cloud" }), + }); + ownsConversation = false; + finishCloudCheck({ ready: true }); + + await expect(proposal).rejects.toMatchObject({ status: 403 }); + expect(store.messagesFor("thread-a")).toHaveLength(0); + }); + + it("denies without changing the scheduler and claims duplicate answers", async () => { + const { service, routines, store } = harness(); + const proposal = await service.propose({ botId: "bot-a", threadId: "thread-a", proposal: createProposal() }); + + expect(service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: proposal.requestId, + behavior: "answer", + })).toMatchObject({ claimed: true, state: "invalid" }); + expect(store.messagesFor("thread-a")[0]!.card!.answered).toBeUndefined(); + + expect(service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: proposal.requestId, + behavior: "deny", + })).toEqual({ claimed: true, state: "denied" }); + expect(routines.listRoutines()).toHaveLength(0); + expect(service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: proposal.requestId, + behavior: "allow", + })).toEqual({ claimed: true, state: "already_settled", behavior: "deny" }); + expect(service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: "provider-request", + behavior: "allow", + })).toEqual({ claimed: false, state: "not_found" }); + }); + + it("creates only after confirmation, pins ownership, and is durable-idempotent", async () => { + const { service, routines, store, clock } = harness(); + const proposal = await service.propose({ botId: "bot-a", threadId: "thread-a", proposal: createProposal() }); + + // Model a crash after routines.json was atomically written but before the + // transcript card was settled. + const message = store.messagesFor("thread-a")[0]!; + const card = message.card!; + const committed = routines.create({ + botId: "bot-a", + name: "Morning brief", + prompt: "Summarize the overnight support queue.", + runOn: "maus", + enabled: true, + schedule: { type: "daily", time: "09:00", weekdays: [1, 3] }, + durationMinutes: 30, + }, { + requestId: proposal.requestId, + messageId: message.id, + botId: "bot-a", + threadId: "thread-a", + action: "create", + fingerprintVersion: 1, + fingerprint: cardFingerprint(card, message.id), + }); + const receipt = routines.routineRequestReceipt(proposal.requestId); + expect(receipt?.resultId).toBe(committed.id); + clock.now += 60_000; + + const first = service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: proposal.requestId, + behavior: "allow", + }); + expect(first).toMatchObject({ claimed: true, state: "applied", action: "create" }); + if (first.state !== "applied") throw new Error("Expected the routine to be applied"); + expect(routines.listRoutines()).toMatchObject([{ botId: "bot-a", name: "Morning brief", enabled: true }]); + expect(routines.routineRequestReceipt(proposal.requestId)).toBeNull(); + expect(store.messagesFor("thread-a")[0]!.card).toMatchObject({ + held: undefined, + routineRequest: { appliedAt: receipt?.appliedAt }, + }); + + // Once the durable card is settled, duplicate clicks are claimed by the + // card itself and the compact recovery receipt can be removed. + const second = service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: proposal.requestId, + behavior: "allow", + }); + expect(second).toMatchObject({ claimed: true, state: "already_settled", behavior: "allow" }); + expect(routines.listRoutines()).toHaveLength(1); + + expect(service.resolve({ + botId: "bot-b", + threadId: "thread-a", + requestId: proposal.requestId, + behavior: "allow", + })).toMatchObject({ claimed: true, state: "invalid", status: 403 }); + }); + + it("applies update, pause, resume, run-now, and delete only to the owning bot", async () => { + const { service, routines, store } = harness(); + const routine = routines.create({ + botId: "bot-a", + name: "Old name", + prompt: "Old instructions", + schedule: { type: "daily", time: "10:00", weekdays: [1] }, + durationMinutes: 30, + }); + + await expect(service.propose({ + botId: "bot-b", + threadId: "thread-b", + proposal: { action: "pause", routineId: routine.id }, + })).rejects.toThrow(RoutineRequestError); + + const apply = async (proposal: RoutineProposalInput) => { + const card = await service.propose({ botId: "bot-a", threadId: "thread-a", proposal }); + const result = service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: card.requestId, + behavior: "allow", + }); + expect(result.state).toBe("applied"); + return { card, result }; + }; + + await apply({ + action: "update", + routineId: routine.id, + changes: { name: "New name", instructions: "New instructions", durationMinutes: 45 }, + }); + expect(routines.listRoutines()[0]).toMatchObject({ + name: "New name", + prompt: "New instructions", + durationMinutes: 45, + }); + + await apply({ action: "pause", routineId: routine.id }); + expect(routines.listRoutines()[0]!.enabled).toBe(false); + await apply({ action: "resume", routineId: routine.id }); + expect(routines.listRoutines()[0]!.enabled).toBe(true); + + const runNow = await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { action: "run_now", routineId: routine.id }, + }); + const runMessage = store.messagesFor("thread-a").find( + (message) => message.card?.requestId === runNow.requestId, + )!; + const runCard = runMessage.card!; + routines.runNow(routine.id, { + requestId: runNow.requestId, + messageId: runMessage.id, + botId: "bot-a", + threadId: "thread-a", + action: "run_now", + fingerprintVersion: 1, + fingerprint: cardFingerprint(runCard, runMessage.id), + }); + expect(service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: runNow.requestId, + behavior: "allow", + })).toMatchObject({ claimed: true, state: "applied" }); + expect(routines.listRuns()).toHaveLength(1); + expect(routines.routineRequestReceipt(runNow.requestId)).toBeNull(); + expect(service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: runNow.requestId, + behavior: "allow", + })).toMatchObject({ claimed: true, state: "already_settled" }); + expect(routines.listRuns()).toHaveLength(1); + + await apply({ action: "delete", routineId: routine.id }); + expect(routines.listRoutines()).toHaveLength(0); + }); + + it("captures and enforces the routine revision for every manage confirmation", async () => { + const { service, routines, store } = harness(); + const routine = routines.create({ + botId: "bot-a", + name: "Mutable routine", + prompt: "Original instructions", + schedule: { type: "daily", time: "10:00", weekdays: [1] }, + }); + const proposed = await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { action: "pause", routineId: routine.id }, + }); + expect(store.messagesFor("thread-a")[0]!.card?.routineRequest?.operation).toMatchObject({ + action: "pause", + expectedUpdatedAt: routine.updatedAt, + }); + + const changed = routines.update(routine.id, { name: "Changed elsewhere" })!; + expect(changed.updatedAt).toBeGreaterThan(routine.updatedAt); + expect(service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: proposed.requestId, + behavior: "allow", + })).toMatchObject({ claimed: true, state: "invalid", status: 409 }); + expect(routines.listRoutines()[0]).toMatchObject({ name: "Changed elsewhere", enabled: true }); + expect(store.messagesFor("thread-a")[0]!.card?.held).toMatch(/changed after this confirmation card/); + }); + + it("settles manage cards whose requested mutation already committed before a crash", async () => { + const { service, routines, store } = harness(); + const routine = routines.create({ + botId: "bot-a", + name: "Before", + prompt: "Original instructions", + schedule: { type: "daily", time: "10:00", weekdays: [1] }, + }); + const update = await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { action: "update", routineId: routine.id, changes: { name: "After" } }, + }); + + // Model a crash after routines.json was atomically written but before the + // transcript card was settled. A retry recognizes the exact end state. + const updateMessage = store.messagesFor("thread-a")[0]!; + const updateCard = updateMessage.card!; + const committed = routines.update(routine.id, { name: "After" }, { + requestId: update.requestId, + messageId: updateMessage.id, + botId: "bot-a", + threadId: "thread-a", + action: "update", + fingerprintVersion: 1, + fingerprint: cardFingerprint(updateCard, updateMessage.id), + })!; + expect(service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: update.requestId, + behavior: "allow", + })).toMatchObject({ claimed: true, state: "applied", resultId: routine.id }); + expect(routines.listRoutines()[0]!.updatedAt).toBe(committed.updatedAt); + expect(store.messagesFor("thread-a")[0]!.card?.answered).toBe("allow"); + expect(routines.routineRequestReceipt(update.requestId)).toBeNull(); + + const deletion = await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { action: "delete", routineId: routine.id }, + }); + const deleteMessage = store.messagesFor("thread-a")[1]!; + const deleteCard = deleteMessage.card!; + routines.remove(routine.id, { + requestId: deletion.requestId, + messageId: deleteMessage.id, + botId: "bot-a", + threadId: "thread-a", + action: "delete", + fingerprintVersion: 1, + fingerprint: cardFingerprint(deleteCard, deleteMessage.id), + }); + expect(service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: deletion.requestId, + behavior: "allow", + })).toMatchObject({ claimed: true, state: "applied", resultId: routine.id }); + expect(store.messagesFor("thread-a")[1]!.card?.answered).toBe("allow"); + expect(routines.routineRequestReceipt(deletion.requestId)).toBeNull(); + }); + + it("never mistakes an unrelated matching state for the card's committed operation", async () => { + const { service, routines, store } = harness(); + const routine = routines.create({ + botId: "bot-a", + name: "Before", + prompt: "Safe instructions", + schedule: { type: "daily", time: "10:00", weekdays: [1] }, + }); + const proposal = await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { action: "update", routineId: routine.id, changes: { name: "Reviewed" } }, + }); + + routines.update(routine.id, { name: "Reviewed", prompt: "Unrelated changed instructions" }); + expect(service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: proposal.requestId, + behavior: "allow", + })).toMatchObject({ claimed: true, state: "invalid", status: 409 }); + expect(store.messagesFor("thread-a")[0]!.card?.answered).toBeUndefined(); + }); + + it("rejects a malformed persisted action instead of falling through to delete", async () => { + const { service, routines, store } = harness(); + const routine = routines.create({ + botId: "bot-a", + name: "Keep me", + prompt: "Never delete on malformed input", + schedule: { type: "daily", time: "10:00", weekdays: [1] }, + }); + const proposed = await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { action: "delete", routineId: routine.id }, + }); + const message = store.messagesFor("thread-a")[0]!; + const card = message.card!; + Object.assign(card.routineRequest!.operation, { action: "destroy" }); + + expect(service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: proposed.requestId, + behavior: "allow", + })).toMatchObject({ claimed: true, state: "invalid", status: 400 }); + expect(routines.listRoutines()).toMatchObject([{ id: routine.id, name: "Keep me" }]); + expect(card.answered).toBeUndefined(); + + expect(service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: proposed.requestId, + behavior: "deny", + })).toEqual({ claimed: true, state: "denied" }); + expect(store.messagesFor("thread-a")[0]!.card?.answered).toBe("deny"); + }); + + it("reports an already-committed malformed card as applied instead of cancelled", async () => { + const { service, routines, store } = harness(); + const routine = routines.create({ + botId: "bot-a", + name: "Before", + prompt: "Keep the result truthful", + schedule: { type: "daily", time: "10:00", weekdays: [1] }, + }); + const proposed = await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { action: "update", routineId: routine.id, changes: { name: "After" } }, + }); + const message = store.messagesFor("thread-a")[0]!; + const card = message.card!; + routines.update(routine.id, { name: "After" }, { + requestId: proposed.requestId, + messageId: message.id, + botId: "bot-a", + threadId: "thread-a", + action: "update", + fingerprintVersion: 1, + fingerprint: cardFingerprint(card, message.id), + }); + Object.assign(card.routineRequest!.operation, { action: "future_schema_action" }); + + expect(service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: proposed.requestId, + behavior: "deny", + })).toMatchObject({ claimed: true, state: "applied", action: "update", resultId: routine.id }); + expect(store.messagesFor("thread-a")[0]!.card?.answered).toBe("allow"); + expect(routines.listRoutines()[0]!.name).toBe("After"); + expect(routines.routineRequestReceipt(proposed.requestId)).toBeNull(); + }); + + it("lets Cancel close a semantically corrupted card when no action committed", async () => { + const { service, routines, store } = harness(); + const proposed = await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: createProposal(), + }); + const card = store.messagesFor("thread-a")[0]!.card!; + card.routineRequest!.requestId = "nested-wrong-id"; + + expect(service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: proposed.requestId, + behavior: "deny", + })).toEqual({ claimed: true, state: "denied" }); + expect(store.messagesFor("thread-a")[0]!.card?.answered).toBe("deny"); + expect(routines.listRoutines()).toHaveLength(0); + }); + + it("shows no next run when an update leaves a paused routine paused", async () => { + const { service, routines, store } = harness(); + const routine = routines.create({ + botId: "bot-a", + name: "Paused routine", + prompt: "Stay paused", + enabled: false, + schedule: { type: "daily", time: "10:00", weekdays: [1] }, + }); + const proposed = await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { action: "update", routineId: routine.id, changes: { name: "Still paused" } }, + }); + + expect(proposed.nextRunAt).toBeNull(); + expect(proposed.summary).toContain("Remains paused"); + const card = store.messagesFor("thread-a")[0]!.card!; + expect(card.subtitle).toContain("Action: Update routine"); + expect(card.subtitle).toContain("Name: Still paused"); + expect(card.subtitle).toContain("Next run: None — this routine remains paused"); + expect(card.subtitle).toContain("Instructions:\nStay paused"); + }); + + it("refuses a one-time update that became stale while awaiting confirmation", async () => { + const { service, routines, clock, store } = harness(); + const routine = routines.create({ + botId: "bot-a", + name: "One time", + prompt: "Do it", + schedule: { type: "daily", time: "10:00", weekdays: [1] }, + }); + const scheduledAt = clock.now + 60_000; + const proposal = await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { + action: "update", + routineId: routine.id, + changes: { schedule: { type: "once", at: new Date(scheduledAt).toISOString() } }, + }, + }); + clock.now = scheduledAt + 1; + + expect(service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: proposal.requestId, + behavior: "allow", + })).toMatchObject({ claimed: true, state: "invalid", status: 409 }); + expect(routines.listRoutines()[0]!.schedule.type).toBe("daily"); + expect(store.messagesFor("thread-a")[0]!.card?.held).toMatch(/now in the past/); + }); + + it("never resumes a one-time routine with no future occurrence", async () => { + const { service, routines, clock } = harness(); + const future = clock.now + 60_000; + const routine = routines.create({ + botId: "bot-a", + name: "One time", + prompt: "Do it", + enabled: false, + schedule: { type: "once", at: future }, + }); + const proposal = await service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { action: "resume", routineId: routine.id }, + }); + clock.now = future + 1; + expect(service.resolve({ + botId: "bot-a", + threadId: "thread-a", + requestId: proposal.requestId, + behavior: "allow", + })).toMatchObject({ claimed: true, state: "invalid", status: 409 }); + expect(routines.listRoutines()[0]).toMatchObject({ enabled: false, nextRunAt: null }); + + await expect(service.propose({ + botId: "bot-a", + threadId: "thread-a", + proposal: { action: "resume", routineId: routine.id }, + })).rejects.toThrow(/new future time/); + }); +}); diff --git a/server/routine-requests.ts b/server/routine-requests.ts new file mode 100644 index 000000000..a8eff0e32 --- /dev/null +++ b/server/routine-requests.ts @@ -0,0 +1,932 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +import { newId } from "./contracts.ts"; +import { redactSecretsInText } from "./redact.ts"; +import { parseJson, schemaIssue, type JsonObject, type JsonValue } from "./schema.ts"; +import { + nextOccurrence, + type Routine, + type RoutineInput, + type RoutineManager, + type RoutineRequestCommit, + type RoutineSchedule, +} from "./routines.ts"; +import type { + RoutineRequestCardData, + RoutineRequestChanges, + RoutineRequestDefinition, + RoutineRequestOperation, + RoutineRequestRunOn, + RoutineRequestSchedule, +} from "../shared/routine-request.ts"; + +const WEEKDAY_NUMBER = { + sunday: 0, + monday: 1, + tuesday: 2, + wednesday: 3, + thursday: 4, + friday: 5, + saturday: 6, +} as const; + +const WEEKDAY_LABEL = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; +const RFC3339_WITH_OFFSET = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|[+-](\d{2}):(\d{2}))$/i; +const TIME = /^(?:[01]\d|2[0-3]):[0-5]\d$/; +const ROUTINE_ID = /^[A-Za-z0-9_-]{1,128}$/; +const ACTION_COPY = { + create: { title: "Schedule", detail: "Create routine" }, + update: { title: "Update", detail: "Update routine" }, + pause: { title: "Pause", detail: "Pause routine" }, + resume: { title: "Resume", detail: "Resume routine" }, + run_now: { title: "Run now", detail: "Run routine now" }, + delete: { title: "Delete", detail: "Delete routine" }, +} as const satisfies Record; +const ROUTINE_REQUEST_FINGERPRINT_VERSION = 1 as const; +const jsonObjectSchema = z.record(z.string(), z.custom()); + +const routineToolScheduleSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("once"), at: z.string().max(64) }).strict(), + z.object({ + type: z.literal("weekly"), + time: z.string().max(5), + weekdays: z.array(z.string().max(9)).min(1).max(7), + }).strict(), +]); + +const routineToolDefinitionSchema = z.object({ + name: z.string().max(80), + instructions: z.string().max(20_000), + schedule: routineToolScheduleSchema, + runOn: z.enum(["maus", "cloud"]).optional(), + durationMinutes: z.number().optional(), +}).strict(); + +const routineToolChangesSchema = routineToolDefinitionSchema.partial().refine( + (changes) => Object.values(changes).some((value) => value !== undefined), + "Choose at least one routine field to update", +); + +const routineProposalSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("create"), routine: routineToolDefinitionSchema }).strict(), + z.object({ action: z.literal("update"), routineId: z.string().max(128), changes: routineToolChangesSchema }).strict(), + z.object({ action: z.literal("pause"), routineId: z.string().max(128) }).strict(), + z.object({ action: z.literal("resume"), routineId: z.string().max(128) }).strict(), + z.object({ action: z.literal("run_now"), routineId: z.string().max(128) }).strict(), + z.object({ action: z.literal("delete"), routineId: z.string().max(128) }).strict(), +]); + +const storedWeekdaysSchema = z.array(z.number().int().min(0).max(6)).min(1).max(7).refine( + (weekdays) => new Set(weekdays).size === weekdays.length, + "Stored routine weekdays must be unique", +); +const storedScheduleSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("once"), at: z.number().int().nonnegative() }).strict(), + z.object({ + type: z.literal("daily"), + time: z.string().regex(TIME), + weekdays: storedWeekdaysSchema, + }).strict(), +]); +const storedDefinitionSchema = z.object({ + name: z.string().trim().min(1).max(80), + instructions: z.string().trim().min(1).max(20_000), + schedule: storedScheduleSchema, + runOn: z.enum(["maus", "cloud"]), + durationMinutes: z.number().int().min(15).max(240), +}).strict(); +const storedChangesSchema = storedDefinitionSchema.partial().refine( + (changes) => Object.values(changes).some((value) => value !== undefined), + "Stored routine update must change at least one field", +); +const storedManageBase = { + routineId: z.string().regex(ROUTINE_ID), + expectedUpdatedAt: z.number().int().nonnegative(), +}; +const storedOperationSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("create"), routine: storedDefinitionSchema }).strict(), + z.object({ action: z.literal("update"), ...storedManageBase, changes: storedChangesSchema }).strict(), + z.object({ action: z.literal("pause"), ...storedManageBase }).strict(), + z.object({ action: z.literal("resume"), ...storedManageBase }).strict(), + z.object({ action: z.literal("run_now"), ...storedManageBase }).strict(), + z.object({ action: z.literal("delete"), ...storedManageBase }).strict(), +]); +const routineRequestCardDataSchema = z.object({ + version: z.literal(1), + requestId: z.string().min(1).max(128), + botId: z.string().min(1).max(128), + threadId: z.string().min(1).max(128), + createdAt: z.number().int().nonnegative(), + operation: storedOperationSchema, + appliedAt: z.number().int().nonnegative().optional(), + resultId: z.string().min(1).max(128).optional(), +}).strict(); + +export type RoutineToolScheduleInput = z.infer; +export type RoutineToolDefinitionInput = z.infer; +export type RoutineToolChangesInput = z.infer; +export type RoutineProposalInput = z.input; +type ParsedRoutineProposal = z.output; + +export interface RoutineRequestOptionCard { + title: string; + subtitle: string; + options: string[]; + answered?: string; + dismissed?: boolean; + requestId?: string; + tool?: string; + held?: string; + routineRequest?: RoutineRequestCardData; +} + +export interface RoutineRequestMessage { + id: string; + card?: RoutineRequestOptionCard; +} + +/** Kept narrow so the domain can be tested without constructing the full app store. */ +export interface RoutineRequestStore { + messagesFor(threadId: string): RoutineRequestMessage[]; + appendMessage( + threadId: string, + message: { + role: "bot"; + kind: "options"; + card: RoutineRequestOptionCard; + from?: { botId: string; name: string; color: string }; + }, + ): RoutineRequestMessage; + patchMessage( + threadId: string, + messageId: string, + patch: { card: RoutineRequestOptionCard }, + ): RoutineRequestMessage | null; +} + +export interface RoutineRequestServiceOptions { + store: RoutineRequestStore; + routines: RoutineManager; + now?: () => number; + timeZone?: () => string; + /** Harness-owned readiness check for proposals that would execute in cloud. */ + cloudReady?: () => Promise<{ ready: boolean; reason?: string }>; + /** Revalidates conversation ownership and capacity synchronously, directly + * before the card append. This closes races across an async cloud probe. */ + canPersist?: ( + botId: string, + threadId: string, + ) => { ok: true } | { ok: false; status: number; error: string }; +} + +export interface ProposeRoutineRequestArgs { + botId: string; + threadId: string; + /** Untrusted model output; normalized by routineProposalSchema in propose(). */ + proposal: unknown; + /** Room cards retain the member attribution used by every other bot message. */ + from?: { botId: string; name: string; color: string }; +} + +export interface RoutineProposalResult { + requestId: string; + messageId: string; + title: string; + /** Short response returned to the proposing agent. */ + summary: string; + /** Exact approval text persisted in the card. */ + detail: string; + nextRunAt: number | null; + timeZone: string; +} + +interface RoutineCardCopy { + title: string; + summary: string; + detail: string; + nextRunAt: number | null; + tool: "schedule_routine" | "manage_routine"; +} + +export type ResolveRoutineRequestResult = + | { claimed: false; state: "not_found" } + | { claimed: true; state: "invalid"; error: string; status: number } + | { claimed: true; state: "already_settled"; behavior: string } + | { claimed: true; state: "denied" } + | { + claimed: true; + state: "applied"; + action: RoutineRequestOperation["action"]; + resultId: string; + }; + +export class RoutineRequestError extends Error { + readonly status: number; + + constructor(message: string, status = 400) { + super(message); + this.name = "RoutineRequestError"; + this.status = status; + } +} + +function text(value: string, field: string, max: number): string { + const trimmed = value.trim(); + if (!trimmed) throw new RoutineRequestError(`${field} is required`); + if (trimmed.length > max) throw new RoutineRequestError(`${field} must be ${max.toLocaleString("en-US")} characters or fewer`); + // This payload is hidden under the visible card fields, so the store's + // shallow card redaction cannot reach it. Scrub before it is persisted. + const redacted = redactSecretsInText(trimmed); + if (redacted.length > max) { + throw new RoutineRequestError( + `${field} must remain ${max.toLocaleString("en-US")} characters or fewer after credentials are removed`, + ); + } + return redacted; +} + +function runOn(value: RoutineRequestRunOn | undefined): RoutineRequestRunOn { + return value ?? "maus"; +} + +function duration(value: number | undefined): number { + const normalized = value ?? 30; + if (!Number.isInteger(normalized) || normalized < 15 || normalized > 240) { + throw new RoutineRequestError("durationMinutes must be a whole number from 15 to 240"); + } + return normalized; +} + +function normalizeSchedule(schedule: RoutineToolScheduleInput, now: number): RoutineRequestSchedule { + if (schedule.type === "once") { + const parts = RFC3339_WITH_OFFSET.exec(schedule.at); + if (!parts) { + throw new RoutineRequestError("One-time schedules need an RFC3339 date-time with an explicit timezone offset"); + } + const year = Number(parts[1]); + const month = Number(parts[2]); + const day = Number(parts[3]); + const hour = Number(parts[4]); + const minute = Number(parts[5]); + const second = Number(parts[6]); + const offsetHour = Number(parts[7] ?? 0); + const offsetMinute = Number(parts[8] ?? 0); + const daysInMonth = month >= 1 && month <= 12 + ? new Date(Date.UTC(year, month, 0)).getUTCDate() + : 0; + if ( + day < 1 || + day > daysInMonth || + hour > 23 || + minute > 59 || + second > 59 || + offsetHour > 23 || + offsetMinute > 59 + ) { + throw new RoutineRequestError("Choose a valid RFC3339 date and time"); + } + const at = Date.parse(schedule.at); + if (!Number.isFinite(at)) throw new RoutineRequestError("Choose a valid date and time"); + if (at <= now) throw new RoutineRequestError("The scheduled date and time must be in the future"); + return { type: "once", at }; + } + if (!TIME.test(schedule.time)) { + throw new RoutineRequestError("Weekly schedule time must use 24-hour HH:MM"); + } + if (schedule.weekdays.length === 0) throw new RoutineRequestError("Choose at least one weekday"); + const weekdays = schedule.weekdays.map((day) => { + // SAFETY: every key in WEEKDAY_NUMBER is lower-case; membership is + // checked immediately below before the numeric value is retained. + const number = WEEKDAY_NUMBER[day.toLowerCase() as keyof typeof WEEKDAY_NUMBER]; + if (number === undefined) throw new RoutineRequestError(`Unsupported weekday: ${day}`); + return number; + }); + return { type: "daily", time: schedule.time, weekdays: [...new Set(weekdays)].sort() }; +} + +function normalizeDefinition(input: RoutineToolDefinitionInput, now: number): RoutineRequestDefinition { + return { + name: text(input.name, "name", 80), + instructions: text(input.instructions, "instructions", 20_000), + schedule: normalizeSchedule(input.schedule, now), + runOn: runOn(input.runOn), + durationMinutes: duration(input.durationMinutes), + }; +} + +function normalizeChanges(input: RoutineToolChangesInput, now: number): RoutineRequestChanges { + const changes: RoutineRequestChanges = {}; + if (input.name !== undefined) changes.name = text(input.name, "name", 80); + if (input.instructions !== undefined) changes.instructions = text(input.instructions, "instructions", 20_000); + if (input.schedule !== undefined) changes.schedule = normalizeSchedule(input.schedule, now); + if (input.runOn !== undefined) changes.runOn = runOn(input.runOn); + if (input.durationMinutes !== undefined) changes.durationMinutes = duration(input.durationMinutes); + return changes; +} + +function routineId(value: string): string { + if (!ROUTINE_ID.test(value)) throw new RoutineRequestError("Choose a valid routine id"); + return value; +} + +function ownedRoutine(manager: RoutineManager, id: string, botId: string): Routine | null { + return manager.listRoutines().find((routine) => routine.id === id && routine.botId === botId) ?? null; +} + +function normalizedOperation( + manager: RoutineManager, + botId: string, + validated: ParsedRoutineProposal, + now: number, +): RoutineRequestOperation { + if (validated.action === "create") { + return { action: "create", routine: normalizeDefinition(validated.routine, now) }; + } + const id = routineId(validated.routineId); + const current = ownedRoutine(manager, id, botId); + if (!current) throw new RoutineRequestError("That routine does not exist", 404); + if (validated.action === "update") { + return { + action: "update", + routineId: id, + expectedUpdatedAt: current.updatedAt, + changes: normalizeChanges(validated.changes, now), + }; + } + if (validated.action === "resume" && nextOccurrence(current.schedule, now) === null) { + throw new RoutineRequestError( + "That one-time routine's scheduled time has passed. Update it to a new future time before resuming.", + 409, + ); + } + return { action: validated.action, routineId: id, expectedUpdatedAt: current.updatedAt }; +} + +function asSchedule(schedule: RoutineRequestSchedule): RoutineSchedule { + return schedule.type === "once" + ? { type: "once", at: schedule.at } + : { type: "daily", time: schedule.time, weekdays: [...schedule.weekdays] }; +} + +function nextForOperation(operation: RoutineRequestOperation, manager: RoutineManager, now: number): number | null { + if (operation.action === "create") return nextOccurrence(asSchedule(operation.routine.schedule), now); + const current = manager.listRoutines().find((routine) => routine.id === operation.routineId); + if (!current) return null; + if (operation.action === "pause" || operation.action === "delete") return null; + if (operation.action === "run_now") return now; + if (operation.action === "resume") return nextOccurrence(current.schedule, now); + if (!("changes" in operation)) return null; + if (!current.enabled) return null; + const schedule = operation.changes.schedule ? asSchedule(operation.changes.schedule) : current.schedule; + return nextOccurrence(schedule, now); +} + +function formatInstant(at: number, timeZone: string): string { + try { + return new Intl.DateTimeFormat("en-US", { + timeZone, + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(at)); + } catch { + return new Date(at).toISOString(); + } +} + +function scheduleText(schedule: RoutineRequestSchedule, timeZone: string): string { + if (schedule.type === "once") return `${formatInstant(schedule.at, timeZone)} (${timeZone})`; + const days = schedule.weekdays.map((day) => WEEKDAY_LABEL[day]).join(", "); + return `${days} at ${schedule.time} (${timeZone})`; +} + +function effectiveDefinition(operation: RoutineRequestOperation, manager: RoutineManager): RoutineRequestDefinition | null { + if (operation.action === "create") return operation.routine; + const existing = manager.listRoutines().find((routine) => routine.id === operation.routineId); + if (!existing) return null; + const base: RoutineRequestDefinition = { + name: existing.name, + instructions: existing.prompt, + schedule: { ...existing.schedule }, + runOn: existing.runOn, + durationMinutes: existing.durationMinutes, + }; + return operation.action === "update" ? { ...base, ...operation.changes } : base; +} + +function cardCopy( + operation: RoutineRequestOperation, + manager: RoutineManager, + timeZone: string, + now: number, +): RoutineCardCopy { + const definition = effectiveDefinition(operation, manager); + const actionCopy = ACTION_COPY[operation.action]; + const actionLabel = actionCopy.title; + const name = redactSecretsInText(definition?.name ?? "routine"); + const title = `${actionLabel} “${name}”?`; + if (!definition) { + return { + title, + summary: title, + detail: `Action: ${actionCopy.detail}\nName: ${name}`, + nextRunAt: null, + tool: "manage_routine", + }; + } + const nextRunAt = nextForOperation(operation, manager, now); + const when = operation.action === "run_now" ? "Now" : scheduleText(definition.schedule, timeZone); + const destination = definition.runOn === "cloud" ? "Cloud VM" : "This OpenMausBot setup"; + const current = operation.action === "create" + ? null + : manager.listRoutines().find((routine) => routine.id === operation.routineId) ?? null; + const remainsPaused = operation.action === "update" && current?.enabled === false; + const nextDescription = nextRunAt !== null + ? formatInstant(nextRunAt, timeZone) + : remainsPaused + ? "None — this routine remains paused" + : operation.action === "pause" + ? "None — this routine will be paused" + : operation.action === "delete" + ? "None — this routine will be deleted" + : "None"; + const status = remainsPaused ? " · Remains paused" : ""; + // Existing routines may predate nested-card redaction. The approval still + // shows every instruction, but credential-shaped values never travel back + // through the bot's MCP response or into the transcript. + const visibleInstructions = redactSecretsInText(definition.instructions); + return { + title, + summary: `${actionLabel} “${name}” · ${when} · ${destination} · ${definition.durationMinutes} min${status}`, + detail: [ + `Action: ${actionCopy.detail}`, + `Name: ${name}`, + `Schedule: ${when}`, + `Next run: ${nextDescription}`, + `Runs on: ${destination}`, + `Maximum duration: ${definition.durationMinutes} minutes`, + "", + "Instructions:", + visibleInstructions, + ].join("\n"), + nextRunAt, + tool: operation.action === "create" ? "schedule_routine" : "manage_routine", + }; +} + +function inputFromDefinition(definition: RoutineRequestDefinition, botId: string): RoutineInput { + return { + name: definition.name, + prompt: definition.instructions, + botId, + runOn: definition.runOn, + enabled: true, + schedule: asSchedule(definition.schedule), + durationMinutes: definition.durationMinutes, + }; +} + +function updateFromChanges(changes: RoutineRequestChanges): Partial { + const patch: Partial = {}; + if (changes.name !== undefined) patch.name = changes.name; + if (changes.instructions !== undefined) patch.prompt = changes.instructions; + if (changes.schedule !== undefined) patch.schedule = asSchedule(changes.schedule); + if (changes.runOn !== undefined) patch.runOn = changes.runOn; + if (changes.durationMinutes !== undefined) patch.durationMinutes = changes.durationMinutes; + return patch; +} + +function canonicalValue(value: JsonValue): JsonValue { + if (Array.isArray(value)) return value.map(canonicalValue); + const parsedObject = jsonObjectSchema.safeParse(value); + if (!parsedObject.success) return value; + const sorted: JsonObject = {}; + for (const key of Object.keys(parsedObject.data).sort()) { + const child = parsedObject.data[key]; + if (child !== undefined) sorted[key] = canonicalValue(child); + } + return sorted; +} + +/** Bind exact-once recovery to the immutable card owner as well as its + * operation. Recursive key sorting keeps old receipts valid if a future + * schema refactor changes object construction order. */ +export function routineRequestFingerprint( + payload: Pick, + messageId: string, +): string { + const document = parseJson(JSON.stringify({ + fingerprintVersion: ROUTINE_REQUEST_FINGERPRINT_VERSION, + cardVersion: payload.version, + requestId: payload.requestId, + messageId, + botId: payload.botId, + threadId: payload.threadId, + operation: payload.operation, + })); + return createHash("sha256").update(JSON.stringify(canonicalValue(document))).digest("hex"); +} + +function verifyManageSnapshot( + operation: Exclude, + manager: RoutineManager, + botId: string, +): Routine { + const current = ownedRoutine(manager, operation.routineId, botId); + if (!current) throw new RoutineRequestError("That routine no longer exists", 404); + if (current.updatedAt !== operation.expectedUpdatedAt) { + throw new RoutineRequestError( + "That routine changed after this confirmation card was prepared. Ask the bot to review it and propose the action again.", + 409, + ); + } + return current; +} + +function requestCommit(payload: RoutineRequestCardData, messageId: string): RoutineRequestCommit { + return { + requestId: payload.requestId, + messageId, + botId: payload.botId, + threadId: payload.threadId, + action: payload.operation.action, + fingerprintVersion: ROUTINE_REQUEST_FINGERPRINT_VERSION, + fingerprint: routineRequestFingerprint(payload, messageId), + }; +} + +function revalidateOperation(operation: RoutineRequestOperation, manager: RoutineManager, botId: string, now: number): void { + const current = operation.action === "create" + ? null + : verifyManageSnapshot(operation, manager, botId); + const schedule = operation.action === "create" + ? operation.routine.schedule + : operation.action === "update" + ? operation.changes.schedule + : undefined; + if (schedule?.type === "once" && schedule.at <= now) { + throw new RoutineRequestError("That one-time schedule is now in the past. Ask the bot to propose a new time.", 409); + } + if (operation.action === "resume") { + if (!current) throw new RoutineRequestError("That routine no longer exists", 404); + if (nextOccurrence(current.schedule, now) === null) { + throw new RoutineRequestError( + "That one-time routine's scheduled time has passed. Update it to a new future time before resuming.", + 409, + ); + } + } +} + +export class RoutineRequestService { + private readonly store: RoutineRequestStore; + private readonly routines: RoutineManager; + private readonly now: () => number; + private readonly timeZone: () => string; + private readonly cloudReady?: () => Promise<{ ready: boolean; reason?: string }>; + private readonly canPersist?: RoutineRequestServiceOptions["canPersist"]; + + constructor(options: RoutineRequestServiceOptions) { + this.store = options.store; + this.routines = options.routines; + this.now = options.now ?? Date.now; + this.timeZone = options.timeZone ?? (() => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"); + this.cloudReady = options.cloudReady; + this.canPersist = options.canPersist; + } + + async propose(args: ProposeRoutineRequestArgs): Promise { + const botId = text(args.botId, "botId", 128); + const threadId = text(args.threadId, "threadId", 128); + const at = this.now(); + const parsedProposal = routineProposalSchema.safeParse(args.proposal); + if (!parsedProposal.success) { + throw new RoutineRequestError(schemaIssue(parsedProposal.error, "Invalid routine proposal")); + } + const operation = normalizedOperation(this.routines, botId, parsedProposal.data, at); + await this.requireCloudReadiness(operation); + // The readiness probe is asynchronous. Another request can edit or + // delete the routine while it is in flight, so re-check the captured + // revision before rendering and persisting the confirmation snapshot. + const cardAt = this.now(); + revalidateOperation(operation, this.routines, botId, cardAt); + const requestId = newId(); + const payload: RoutineRequestCardData = { + version: 1, + requestId, + botId, + threadId, + createdAt: cardAt, + operation, + }; + const timeZone = this.timeZone(); + const copy = cardCopy(operation, this.routines, timeZone, cardAt); + const messageInput: Parameters[1] = { + role: "bot", + kind: "options", + card: { + title: copy.title, + subtitle: copy.detail, + options: ["Confirm", "Cancel"], + requestId, + tool: copy.tool, + routineRequest: payload, + }, + }; + if (args.from) messageInput.from = args.from; + // This check and append are deliberately adjacent and synchronous. JS + // cannot interleave another completed proposal between the capacity / + // ownership decision and the durable transcript write. + const persistence = this.canPersist?.(botId, threadId); + if (persistence && !persistence.ok) { + throw new RoutineRequestError(persistence.error, persistence.status); + } + const message = this.store.appendMessage(threadId, messageInput); + return { + requestId, + messageId: message.id, + title: copy.title, + summary: copy.summary, + detail: copy.detail, + nextRunAt: copy.nextRunAt, + timeZone, + }; + } + + private async requireCloudReadiness(operation: RoutineRequestOperation): Promise { + if (!this.cloudReady || operation.action === "pause" || operation.action === "delete") return; + const definition = effectiveDefinition(operation, this.routines); + if (definition?.runOn !== "cloud") return; + let readiness: { ready: boolean; reason?: string }; + try { + readiness = await this.cloudReady(); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new RoutineRequestError(`Could not verify cloud readiness: ${detail}`, 503); + } + if (readiness.ready) return; + throw new RoutineRequestError( + readiness.reason?.trim() || "Cloud execution is not configured yet. Set up a cloud computer first.", + 409, + ); + } + + /** + * Claims a routine card even after it was settled. That distinction is + * important: duplicate clicks must never fall through to a provider that + * did not create the request id. + */ + resolve(args: { + botId: string; + threadId: string; + requestId: string; + behavior: string | undefined; + }): ResolveRoutineRequestResult { + const message = this.store + .messagesFor(args.threadId) + .find((candidate) => candidate.card?.requestId === args.requestId && candidate.card.routineRequest); + const card = message?.card; + const rawPayload = card?.routineRequest; + if (!message || !card || !rawPayload) return { claimed: false, state: "not_found" }; + if (args.behavior !== "allow" && args.behavior !== "deny") { + return { + claimed: true, + state: "invalid", + error: "Routine confirmations must be confirmed or cancelled", + status: 400, + }; + } + const parsedPayload = routineRequestCardDataSchema.safeParse(rawPayload); + if (!parsedPayload.success) { + if (card.answered) return { claimed: true, state: "already_settled", behavior: card.answered }; + const recovered = this.settleCommittedReceipt(args, message.id, card); + if (recovered) return recovered; + // Cancelling is always safe and must remain possible even if an older + // persisted payload no longer passes today's schema. Otherwise that + // durable card would own the composer forever with no escape hatch. + if (args.behavior === "deny") { + this.store.patchMessage(args.threadId, message.id, { card: { ...card, answered: "deny", held: undefined } }); + return { claimed: true, state: "denied" }; + } + const detail = schemaIssue(parsedPayload.error, "This routine request is invalid"); + this.store.patchMessage(args.threadId, message.id, { + card: { ...card, held: redactSecretsInText(detail).slice(0, 500) }, + }); + return { claimed: true, state: "invalid", error: detail, status: 400 }; + } + const payload: RoutineRequestCardData = parsedPayload.data; + if (payload.requestId !== args.requestId) { + const recovered = this.settleCommittedReceipt(args, message.id, card); + if (recovered) return recovered; + if (args.behavior === "deny") { + this.store.patchMessage(args.threadId, message.id, { card: { ...card, answered: "deny", held: undefined } }); + return { claimed: true, state: "denied" }; + } + return { claimed: true, state: "invalid", error: "This routine request id does not match its confirmation card", status: 400 }; + } + if (payload.botId !== args.botId || payload.threadId !== args.threadId) { + const recovered = this.settleCommittedReceipt(args, message.id, card); + if (recovered) return recovered; + if (args.behavior === "deny") { + this.store.patchMessage(args.threadId, message.id, { card: { ...card, answered: "deny", held: undefined } }); + return { claimed: true, state: "denied" }; + } + return { claimed: true, state: "invalid", error: "This routine request belongs to another conversation", status: 403 }; + } + if (card.answered) { + this.forgetSettledReceipt(payload, message.id); + return { claimed: true, state: "already_settled", behavior: card.answered }; + } + + try { + const fingerprint = routineRequestFingerprint(payload, message.id); + const receipt = this.routines.routineRequestReceipt(payload.requestId); + if (receipt) { + if ( + receipt.botId !== payload.botId || + receipt.threadId !== payload.threadId || + receipt.messageId !== message.id || + receipt.action !== payload.operation.action || + receipt.fingerprintVersion !== ROUTINE_REQUEST_FINGERPRINT_VERSION || + receipt.fingerprint !== fingerprint + ) { + throw new RoutineRequestError("This routine request does not match its durable commit receipt", 409); + } + return this.settleApplied( + args.threadId, + message.id, + card, + payload, + receipt.resultId, + receipt.appliedAt, + ); + } + if (args.behavior === "deny") { + this.store.patchMessage(args.threadId, message.id, { card: { ...card, answered: "deny", held: undefined } }); + return { claimed: true, state: "denied" }; + } + revalidateOperation(payload.operation, this.routines, payload.botId, this.now()); + const resultId = this.apply(payload, message.id, fingerprint); + return this.settleApplied(args.threadId, message.id, card, payload, resultId); + } catch (error) { + const status = error instanceof RoutineRequestError ? error.status : 400; + const detail = error instanceof Error ? error.message : String(error); + this.store.patchMessage(args.threadId, message.id, { + card: { ...card, held: redactSecretsInText(detail).slice(0, 500) }, + }); + return { + claimed: true, + state: "invalid", + error: detail, + status, + }; + } + } + + private settleApplied( + threadId: string, + messageId: string, + card: RoutineRequestOptionCard, + payload: RoutineRequestCardData, + resultId: string, + appliedAt = this.now(), + ): ResolveRoutineRequestResult { + const applied: RoutineRequestCardData = { + ...payload, + appliedAt, + resultId, + }; + const settled = this.store.patchMessage(threadId, messageId, { + card: { ...card, answered: "allow", held: undefined, routineRequest: applied }, + }); + if (!settled) throw new RoutineRequestError("This routine confirmation card is no longer available", 409); + this.forgetSettledReceipt(payload, messageId); + return { claimed: true, state: "applied", action: payload.operation.action, resultId }; + } + + private forgetSettledReceipt(payload: RoutineRequestCardData, messageId: string): void { + this.forgetReceipt(requestCommit(payload, messageId)); + } + + private settleCommittedReceipt( + args: { botId: string; threadId: string; requestId: string }, + messageId: string, + card: RoutineRequestOptionCard, + ): ResolveRoutineRequestResult | null { + const receipt = this.routines.routineRequestReceipt(args.requestId); + if (!receipt) return null; + if (receipt.botId !== args.botId || receipt.threadId !== args.threadId || receipt.messageId !== messageId) { + return { + claimed: true, + state: "invalid", + error: "This committed routine request belongs to another conversation", + status: 403, + }; + } + const settled = this.store.patchMessage(args.threadId, messageId, { + card: { ...card, answered: "allow", held: undefined }, + }); + if (!settled) { + return { + claimed: true, + state: "invalid", + error: "This routine confirmation card is no longer available", + status: 409, + }; + } + this.forgetReceipt(receipt); + return { + claimed: true, + state: "applied", + action: receipt.action, + resultId: receipt.resultId, + }; + } + + private forgetReceipt(request: RoutineRequestCommit): void { + try { + this.routines.forgetRoutineRequestReceipt(request); + } catch { + // The transcript is already durably settled. Retaining a redundant + // receipt after a cleanup write failure is safe and a later duplicate + // response will retry this cleanup. + } + } + + private apply(payload: RoutineRequestCardData, messageId: string, fingerprint: string): string { + const operation = payload.operation; + switch (operation.action) { + case "create": + return this.routines.create(inputFromDefinition(operation.routine, payload.botId), { + requestId: payload.requestId, + messageId, + botId: payload.botId, + threadId: payload.threadId, + action: "create", + fingerprintVersion: ROUTINE_REQUEST_FINGERPRINT_VERSION, + fingerprint, + }).id; + case "update": { + verifyManageSnapshot(operation, this.routines, payload.botId); + const updated = this.routines.update(operation.routineId, updateFromChanges(operation.changes), { + requestId: payload.requestId, + messageId, + botId: payload.botId, + threadId: payload.threadId, + action: "update", + fingerprintVersion: ROUTINE_REQUEST_FINGERPRINT_VERSION, + fingerprint, + }); + if (!updated) throw new RoutineRequestError("That routine no longer exists", 404); + return updated.id; + } + case "pause": + case "resume": { + verifyManageSnapshot(operation, this.routines, payload.botId); + const updated = this.routines.update(operation.routineId, { enabled: operation.action === "resume" }, { + requestId: payload.requestId, + messageId, + botId: payload.botId, + threadId: payload.threadId, + action: operation.action, + fingerprintVersion: ROUTINE_REQUEST_FINGERPRINT_VERSION, + fingerprint, + }); + if (!updated) throw new RoutineRequestError("That routine no longer exists", 404); + return updated.id; + } + case "run_now": { + verifyManageSnapshot(operation, this.routines, payload.botId); + const run = this.routines.runNow(operation.routineId, { + requestId: payload.requestId, + messageId, + botId: payload.botId, + threadId: payload.threadId, + action: "run_now", + fingerprintVersion: ROUTINE_REQUEST_FINGERPRINT_VERSION, + fingerprint, + }); + if (!run) throw new RoutineRequestError("That routine no longer exists", 404); + return run.id; + } + case "delete": + verifyManageSnapshot(operation, this.routines, payload.botId); + if (!this.routines.remove(operation.routineId, { + requestId: payload.requestId, + messageId, + botId: payload.botId, + threadId: payload.threadId, + action: "delete", + fingerprintVersion: ROUTINE_REQUEST_FINGERPRINT_VERSION, + fingerprint, + })) { + throw new RoutineRequestError("That routine no longer exists", 404); + } + return operation.routineId; + default: + throw new RoutineRequestError("Unsupported persisted routine action"); + } + } +} diff --git a/server/routines.test.ts b/server/routines.test.ts index df94448fc..5b57a278b 100644 --- a/server/routines.test.ts +++ b/server/routines.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -112,6 +112,108 @@ describe("RoutineManager", () => { ]); }); + it("persists confirmation receipts with the scheduler mutation and removes them after settlement", () => { + const h = harness(); + const routine = h.manager.create({ + name: "Before", + prompt: "Review the queue", + botId: "maus-1", + schedule: { type: "daily", time: "09:00", weekdays: [1] }, + }); + const request = { + requestId: "request-update-1", + messageId: "message-1", + botId: "maus-1", + threadId: "thread-1", + action: "update" as const, + fingerprintVersion: 1 as const, + fingerprint: "a".repeat(64), + }; + h.manager.update(routine.id, { name: "After" }, request); + + const reloaded = new RoutineManager(h.options); + expect(reloaded.routineRequestReceipt(request.requestId)).toMatchObject({ + ...request, + resultId: routine.id, + }); + expect(reloaded.routineRequestReceiptOwners()).toEqual([{ + requestId: request.requestId, + messageId: request.messageId, + botId: request.botId, + threadId: request.threadId, + }]); + expect(() => reloaded.update(routine.id, { name: "Never applied" }, { + ...request, + fingerprint: "b".repeat(64), + })).toThrow(/does not match/); + expect(reloaded.listRoutines()[0]!.name).toBe("After"); + + expect(reloaded.reconcileRoutineRequestReceipts([request])).toBe(0); + expect(reloaded.forgetRoutineRequestReceipt(request)).toBe(true); + expect(new RoutineManager(h.options).routineRequestReceipt(request.requestId)).toBeNull(); + }); + + it("removes unreachable recovery receipts when their conversation is deleted", () => { + const h = harness(); + const routine = h.manager.create({ + name: "Cleanup", + prompt: "Clean unreachable confirmations", + botId: "maus-1", + schedule: { type: "daily", time: "09:00", weekdays: [1] }, + }); + const request = { + requestId: "request-orphaned-thread", + messageId: "message-orphaned-thread", + botId: "maus-1", + threadId: "thread-deleted", + action: "pause" as const, + fingerprintVersion: 1 as const, + fingerprint: "d".repeat(64), + }; + h.manager.update(routine.id, { enabled: false }, request); + + expect(h.manager.forgetRoutineRequestReceiptsForThread("another-thread")).toBe(0); + expect(h.manager.forgetRoutineRequestReceiptsForThread("thread-deleted")).toBe(1); + expect(new RoutineManager(h.options).routineRequestReceipt(request.requestId)).toBeNull(); + }); + + it("rolls back an uncommitted confirmation when the atomic file write fails", () => { + const h = harness(); + const file = h.options.file!; + // A directory at the destination makes the final atomic rename fail + // after the temporary file has been written. + mkdirSync(file); + const request = { + requestId: "request-create-write-failure", + messageId: "message-write-failure", + botId: "maus-1", + threadId: "thread-1", + action: "create" as const, + fingerprintVersion: 1 as const, + fingerprint: "c".repeat(64), + }; + const input = { + name: "Retry safely", + prompt: "Check the queue", + botId: "maus-1", + schedule: { type: "daily" as const, time: "09:00", weekdays: [1] }, + }; + + expect(() => h.manager.create(input, request)).toThrow(); + expect(h.manager.listRoutines()).toEqual([]); + expect(h.manager.routineRequestReceipt(request.requestId)).toBeNull(); + expect(h.emitted).toEqual([]); + + rmSync(file, { recursive: true, force: true }); + rmSync(`${file}.tmp`, { force: true }); + const routine = h.manager.create(input, request); + expect(h.manager.listRoutines()).toHaveLength(1); + expect(h.manager.routineRequestReceipt(request.requestId)).toMatchObject({ + ...request, + resultId: routine.id, + }); + }); + it("queues behind a busy bot, then dispatches into a detached task", async () => { const h = harness(); h.setBot("busy"); diff --git a/server/routines.ts b/server/routines.ts index ca0f485c7..2e9f28fed 100644 --- a/server/routines.ts +++ b/server/routines.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { DATA_DIR } from "./config.ts"; import type { RuntimeEvent } from "./contracts.ts"; +import type { RoutineRequestOperation } from "../shared/routine-request.ts"; export type RoutineSchedule = | { type: "once"; at: number } @@ -66,6 +67,32 @@ export interface RoutineRun { seenAt?: number; } +export interface RoutineRequestReceipt { + requestId: string; + messageId: string; + botId: string; + threadId: string; + action: RoutineRequestOperation["action"]; + fingerprintVersion: 1; + /** SHA-256 of the strict normalized operation carried by the card. */ + fingerprint: string; + resultId: string; + appliedAt: number; +} + +export interface RoutineRequestCommit { + requestId: string; + messageId: string; + botId: string; + threadId: string; + action: RoutineRequestOperation["action"]; + fingerprintVersion: 1; + fingerprint: string; +} + +type RoutineRequestCommitFor = + Omit & { action: Action }; + export interface RoutineInput { name: string; prompt: string; @@ -80,6 +107,14 @@ interface RoutineFile { version: 1; routines: Routine[]; runs: RoutineRun[]; + /** Durable commit receipts for cross-file confirmation recovery. */ + routineRequestReceipts?: RoutineRequestReceipt[]; +} + +export type RoutineRequestOwner = Pick; + +function routineRequestOwnerKey(owner: RoutineRequestOwner): string { + return JSON.stringify([owner.requestId, owner.messageId, owner.botId, owner.threadId]); } export interface RoutineManagerOptions { @@ -105,6 +140,18 @@ export interface RoutineManagerOptions { const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6]; const CATCH_UP_MS = 12 * 60 * 60_000; const MAX_RUNS = 2_000; +const ROUTINE_REQUEST_ACTIONS = new Set([ + "create", + "update", + "pause", + "resume", + "run_now", + "delete", +]); + +function isRoutineRequestAction(value: unknown): value is RoutineRequestOperation["action"] { + return typeof value === "string" && ROUTINE_REQUEST_ACTIONS.has(value as RoutineRequestOperation["action"]); +} function cleanDays(days: unknown): number[] { if (!Array.isArray(days)) return ALL_DAYS; @@ -166,6 +213,7 @@ export class RoutineManager { private readonly options: RoutineManagerOptions; private routines: Routine[] = []; private runs: RoutineRun[] = []; + private routineRequestReceipts: RoutineRequestReceipt[] = []; private timer: ReturnType | null = null; private ticking = false; @@ -181,9 +229,23 @@ export class RoutineManager { this.runs = Array.isArray(disk.runs) ? disk.runs.map((run) => ({ ...run, runOn: run.runOn ?? "maus" })) : []; + this.routineRequestReceipts = Array.isArray(disk.routineRequestReceipts) + ? disk.routineRequestReceipts.filter((receipt): receipt is RoutineRequestReceipt => + typeof receipt?.requestId === "string" && + typeof receipt?.messageId === "string" && + typeof receipt?.botId === "string" && + typeof receipt?.threadId === "string" && + isRoutineRequestAction(receipt?.action) && + receipt?.fingerprintVersion === 1 && + typeof receipt?.fingerprint === "string" && /^[a-f0-9]{64}$/.test(receipt.fingerprint) && + typeof receipt?.resultId === "string" && + Number.isFinite(receipt?.appliedAt) + ) + : []; } catch { this.routines = []; this.runs = []; + this.routineRequestReceipts = []; } // A local process cannot still own these turns after a full restart. const recovered: RoutineRun[] = []; @@ -219,13 +281,75 @@ export class RoutineManager { return run ? { ...run } : null; } + routineRequestReceipt(requestId: string): RoutineRequestReceipt | null { + const receipt = this.routineRequestReceipts.find((candidate) => candidate.requestId === requestId); + return receipt ? { ...receipt } : null; + } + + /** Small startup index used to locate only transcripts that may need + * cross-file commit recovery. Most launches have no receipts and therefore + * do not read or cache any transcript for this feature. */ + routineRequestReceiptOwners(): RoutineRequestOwner[] { + return this.routineRequestReceipts.map(({ requestId, messageId, botId, threadId }) => ({ + requestId, + messageId, + botId, + threadId, + })); + } + + /** Once the transcript card is durably settled, its scheduler receipt is + * redundant. Unsettled receipts are intentionally never count-evicted: an + * actionable card may survive indefinitely and must retain its exact-once + * recovery record for the same lifetime. */ + forgetRoutineRequestReceipt(request: RoutineRequestCommit): boolean { + const receipt = this.matchingRoutineRequestReceipt(request); + if (!receipt) return false; + const index = this.routineRequestReceipts.indexOf(receipt); + this.commitMutation(() => { + this.routineRequestReceipts.splice(index, 1); + }); + return true; + } + + forgetRoutineRequestReceiptsForThread(threadId: string): number { + const kept = this.routineRequestReceipts.filter((receipt) => receipt.threadId !== threadId); + const removed = this.routineRequestReceipts.length - kept.length; + if (removed === 0) return 0; + this.commitMutation(() => { + this.routineRequestReceipts = kept; + }); + return removed; + } + + /** Drop only receipts whose confirmation transcript no longer exists. + * Reachable open cards retain exact-once recovery for their full lifetime. */ + reconcileRoutineRequestReceipts(reachable: readonly RoutineRequestOwner[]): number { + const keys = new Set(reachable.map(routineRequestOwnerKey)); + const kept = this.routineRequestReceipts.filter((receipt) => keys.has(routineRequestOwnerKey(receipt))); + const removed = this.routineRequestReceipts.length - kept.length; + if (removed === 0) return 0; + this.commitMutation(() => { + this.routineRequestReceipts = kept; + }); + return removed; + } + isActiveThread(threadId: string): boolean { return this.runs.some( (run) => run.threadId === threadId && ["running", "waiting"].includes(run.status), ); } - create(input: RoutineInput): Routine { + create(input: RoutineInput, request?: RoutineRequestCommitFor<"create">): Routine { + if (request) { + const receipt = this.matchingRoutineRequestReceipt(request); + if (receipt) { + const committed = this.routines.find((routine) => routine.id === receipt.resultId); + if (committed) return { ...committed, schedule: { ...committed.schedule } }; + throw new Error("This routine request was already applied"); + } + } const clean = sanitizeInput(input); if (this.options.botState(clean.botId) === "missing") throw new Error("That bot no longer exists"); const at = this.now(); @@ -236,15 +360,29 @@ export class RoutineManager { createdAt: at, updatedAt: at, }; - this.routines.unshift(routine); - this.save(); + this.commitMutation(() => { + this.routines.unshift(routine); + if (request) this.rememberRoutineRequest(request, routine.id, at); + }); this.emitRoutine(routine); return { ...routine, schedule: { ...routine.schedule } }; } - update(id: string, patch: Partial): Routine | null { + update( + id: string, + patch: Partial, + request?: RoutineRequestCommitFor<"update" | "pause" | "resume">, + ): Routine | null { + if (request) { + const receipt = this.matchingRoutineRequestReceipt(request); + if (receipt) { + const committed = this.routines.find((routine) => routine.id === receipt.resultId); + return committed ? { ...committed, schedule: { ...committed.schedule } } : null; + } + } const routine = this.routines.find((r) => r.id === id); if (!routine) return null; + const now = this.now(); const clean = sanitizeInput({ name: patch.name ?? routine.name, prompt: patch.prompt ?? routine.prompt, @@ -255,36 +393,51 @@ export class RoutineManager { durationMinutes: patch.durationMinutes ?? routine.durationMinutes, }); if (this.options.botState(clean.botId) === "missing") throw new Error("That bot no longer exists"); - Object.assign(routine, clean, { - nextRunAt: clean.enabled ? this.initialOccurrence(clean.schedule, this.now()) : null, - updatedAt: this.now(), - }); - if (patch.enabled === false) { - for (const run of this.runs) { - if (run.routineId !== routine.id || run.status !== "queued") continue; - run.status = "cancelled"; - run.finishedAt = this.now(); - run.error = "The routine was paused before this run started"; - this.emitRun(run); + const cancelledRuns: RoutineRun[] = []; + this.commitMutation(() => { + Object.assign(routine, clean, { + nextRunAt: clean.enabled ? this.initialOccurrence(clean.schedule, now) : null, + // `updatedAt` doubles as the optimistic revision on durable routine + // confirmation cards. Keep it monotonic even for two writes in one ms. + updatedAt: Math.max(now, routine.updatedAt + 1), + }); + if (patch.enabled === false) { + for (const run of this.runs) { + if (run.routineId !== routine.id || run.status !== "queued") continue; + run.status = "cancelled"; + run.finishedAt = this.now(); + run.error = "The routine was paused before this run started"; + cancelledRuns.push(run); + } } - } - this.save(); + if (request) this.rememberRoutineRequest(request, routine.id, now); + }); + for (const run of cancelledRuns) this.emitRun(run); this.emitRoutine(routine); return { ...routine, schedule: { ...routine.schedule } }; } - remove(id: string): boolean { + remove(id: string, request?: RoutineRequestCommitFor<"delete">): boolean { + if (request) { + const receipt = this.matchingRoutineRequestReceipt(request); + if (receipt) { + return true; + } + } const at = this.routines.findIndex((r) => r.id === id); if (at === -1) return false; - this.routines.splice(at, 1); - for (const run of this.runs) { - if (run.routineId === id && run.status === "queued") { + const cancelledRuns: RoutineRun[] = []; + this.commitMutation(() => { + this.routines.splice(at, 1); + for (const run of this.runs) { + if (run.routineId !== id || run.status !== "queued") continue; run.status = "cancelled"; run.finishedAt = this.now(); - this.emitRun(run); + cancelledRuns.push(run); } - } - this.save(); + if (request) this.rememberRoutineRequest(request, id, this.now()); + }); + for (const run of cancelledRuns) this.emitRun(run); this.options.emit?.({ kind: "routine.deleted", routineId: id }); return true; } @@ -295,7 +448,7 @@ export class RoutineManager { if (routine.botId !== botId || !routine.enabled) continue; routine.enabled = false; routine.nextRunAt = null; - routine.updatedAt = this.now(); + routine.updatedAt = Math.max(this.now(), routine.updatedAt + 1); this.emitRoutine(routine); changed = true; } @@ -311,11 +464,21 @@ export class RoutineManager { if (changed) this.save(); } - runNow(id: string): RoutineRun | null { + runNow(id: string, request?: RoutineRequestCommitFor<"run_now">): RoutineRun | null { + if (request) { + const receipt = this.matchingRoutineRequestReceipt(request); + if (receipt) { + const committed = this.runs.find((run) => run.id === receipt.resultId); + return committed ? { ...committed } : null; + } + } const routine = this.routines.find((r) => r.id === id); if (!routine) return null; - const run = this.newRun(routine, this.now(), true); - this.save(); + let run!: RoutineRun; + this.commitMutation(() => { + run = this.newRun(routine, this.now(), true); + if (request) this.rememberRoutineRequest(request, run.id, this.now()); + }); this.emitRun(run); queueMicrotask(() => void this.tick()); return { ...run }; @@ -437,7 +600,7 @@ export class RoutineManager { routine.nextRunAt = routine.schedule.type === "once" ? null : nextOccurrence(routine.schedule, Math.max(now, scheduledFor)); if (routine.schedule.type === "once") routine.enabled = false; - routine.updatedAt = now; + routine.updatedAt = Math.max(now, routine.updatedAt + 1); this.emitRoutine(routine); changed = true; } @@ -577,10 +740,67 @@ export class RoutineManager { this.options.emit?.({ kind: "routine.run", run: { ...run } }); } + private matchingRoutineRequestReceipt(request: RoutineRequestCommit): RoutineRequestReceipt | null { + const receipt = this.routineRequestReceipts.find((candidate) => candidate.requestId === request.requestId); + if (!receipt) return null; + if ( + receipt.action !== request.action || + receipt.messageId !== request.messageId || + receipt.botId !== request.botId || + receipt.threadId !== request.threadId || + receipt.fingerprintVersion !== request.fingerprintVersion || + receipt.fingerprint !== request.fingerprint + ) { + throw new Error("Routine request receipt does not match this confirmation card"); + } + return receipt; + } + + private rememberRoutineRequest( + request: RoutineRequestCommit, + resultId: string, + appliedAt: number, + ) { + const existing = this.matchingRoutineRequestReceipt(request); + if (existing) { + if (existing.resultId !== resultId) throw new Error("Routine request receipt has another result"); + return; + } + this.routineRequestReceipts.unshift({ ...request, resultId, appliedAt }); + } + + /** + * A confirmation receipt is only true once the scheduler mutation and its + * receipt reached the same atomic file. Restore the complete in-memory + * state if writing or renaming that file fails so a retry cannot mistake an + * uncommitted action for a durable one. + */ + private commitMutation(mutate: () => void): void { + const before = { + routines: this.routines.map((routine) => ({ ...routine, schedule: { ...routine.schedule } })), + runs: this.runs.map((run) => ({ ...run, denials: run.denials ? [...run.denials] : undefined })), + receipts: this.routineRequestReceipts.map((receipt) => ({ ...receipt })), + }; + try { + mutate(); + this.save(); + } catch (error) { + this.routines = before.routines; + this.runs = before.runs; + this.routineRequestReceipts = before.receipts; + throw error; + } + } + private save() { mkdirSync(dirname(this.file), { recursive: true }); const temp = `${this.file}.tmp`; - writeFileSync(temp, JSON.stringify({ version: 1, routines: this.routines, runs: this.runs } satisfies RoutineFile, null, 2)); + writeFileSync(temp, JSON.stringify({ + version: 1, + routines: this.routines, + runs: this.runs, + routineRequestReceipts: this.routineRequestReceipts, + } satisfies RoutineFile, null, 2)); renameSync(temp, this.file); } } diff --git a/server/store.test.ts b/server/store.test.ts index 7c5d4de36..1a0565e83 100644 --- a/server/store.test.ts +++ b/server/store.test.ts @@ -518,6 +518,7 @@ describe("Store change stream", () => { expect(events.every((e) => e.type === "bot" && e.botId === bot.id)).toBe(true); expect(events).toHaveLength(7); store.deleteBot(bot.id); + expect(events).toContainEqual({ type: "thread.deleted", threadId: bot.threadId }); expect(events.at(-1)).toEqual({ type: "bot.deleted", botId: bot.id }); }); @@ -534,6 +535,7 @@ describe("Store change stream", () => { expect(events.map((e) => e.type)).toEqual(["group", "group"]); expect(store.group(g.id)?.unread).toBe(true); store.deleteGroup(g.id); + expect(events).toContainEqual({ type: "thread.deleted", threadId: g.threadId }); expect(events.at(-1)).toEqual({ type: "group.deleted", groupId: g.id }); }); @@ -609,9 +611,42 @@ describe("Store redacts bot-authored secrets on write", () => { const card = store.appendMessage(bot.threadId, { role: "bot", kind: "options", - card: { title: "Run this?", summary: `curl -H "Authorization: Bearer ${key}"`, options: [], requestId: "r1", tool: "Bash" } as never, + card: { title: "Run this?", summary: `curl -H "Authorization: Bearer ${key}"`, held: `Blocked ${key}`, options: [], requestId: "r1", tool: "Bash" } as never, }); expect((card.card as { summary?: string }).summary).not.toContain(key); + expect(card.card?.held).not.toContain(key); + const routineCard = store.appendMessage(bot.threadId, { + role: "bot", + kind: "options", + card: { + title: "Confirm routine", + subtitle: "Every morning", + options: ["Confirm", "Cancel"], + requestId: "routine-request", + tool: "schedule_routine", + routineRequest: { + version: 1, + requestId: "routine-request", + botId: bot.id, + threadId: bot.threadId, + createdAt: 1, + operation: { + action: "create", + routine: { + name: `Use ${key}`, + instructions: `Send a request with ${key}`, + schedule: { type: "daily", time: "09:00", weekdays: [1] }, + runOn: "maus", + durationMinutes: 30, + }, + }, + }, + }, + }); + expect(routineCard.card?.routineRequest?.operation.action).toBe("create"); + if (routineCard.card?.routineRequest?.operation.action !== "create") throw new Error("missing routine payload"); + expect(routineCard.card.routineRequest.operation.routine.name).not.toContain(key); + expect(routineCard.card.routineRequest.operation.routine.instructions).not.toContain(key); const secretCard = store.appendMessage(bot.threadId, { role: "bot", kind: "secret", diff --git a/server/store.ts b/server/store.ts index c4896fd86..2b703d7f5 100644 --- a/server/store.ts +++ b/server/store.ts @@ -14,6 +14,7 @@ import { newId, type CloudBackend, type ModelSelection, type ThreadId } from "./ import { pickBotName } from "./names.ts"; import { redactSecretsInText } from "./redact.ts"; import { botAvatarProfile, type BotAvatarCrop } from "../shared/bot-avatar.ts"; +import type { RoutineRequestCardData } from "../shared/routine-request.ts"; export type MausColor = | "green" @@ -51,6 +52,9 @@ export interface OptionCardData { allowKey?: string; /** Local actions never share remembered grants with cloud/tool approvals. */ approvalScope?: "local-computer"; + /** A durable chat-created routine proposal. The scheduler only applies it + * after this card is explicitly confirmed by the user. */ + routineRequest?: RoutineRequestCardData; } export interface ConnectorCardData { @@ -241,6 +245,39 @@ function redactBotAuthored & { at?: number card.title = redactSecretsInText(card.title); if (typeof card.subtitle === "string") card.subtitle = redactSecretsInText(card.subtitle); if (typeof card.summary === "string") card.summary = redactSecretsInText(card.summary); + if (typeof card.held === "string") card.held = redactSecretsInText(card.held); + // Routine definitions are executable bot-authored text stored behind the + // visible summary. Scrub the durable payload too so nesting it on a card + // cannot bypass the transcript's secret-redaction boundary. + if (card.routineRequest) { + const operation = card.routineRequest.operation; + card.routineRequest = { + ...card.routineRequest, + operation: operation.action === "create" + ? { + ...operation, + routine: { + ...operation.routine, + name: redactSecretsInText(operation.routine.name), + instructions: redactSecretsInText(operation.routine.instructions), + }, + } + : operation.action === "update" + ? { + ...operation, + changes: { + ...operation.changes, + ...(typeof operation.changes.name === "string" + ? { name: redactSecretsInText(operation.changes.name) } + : {}), + ...(typeof operation.changes.instructions === "string" + ? { instructions: redactSecretsInText(operation.changes.instructions) } + : {}), + }, + } + : { ...operation }, + }; + } out.card = card; } if (out.connector) { @@ -277,6 +314,7 @@ export type StoreChange = | { type: "message"; threadId: string; message: Message } | { type: "message.patch"; threadId: string; message: Message } | { type: "thread"; threadId: string; activeLeafId: string } + | { type: "thread.deleted"; threadId: string } | { type: "bot"; botId: string } | { type: "bot.deleted"; botId: string } | { type: "group"; groupId: string } @@ -758,6 +796,7 @@ export class Store { unlinkSync(file); } catch {} } + this.emit({ type: "thread.deleted", threadId }); } deleteGroup(id: string): boolean { diff --git a/shared/routine-request.ts b/shared/routine-request.ts new file mode 100644 index 000000000..3709fa721 --- /dev/null +++ b/shared/routine-request.ts @@ -0,0 +1,47 @@ +/** + * Durable payload carried by a chat routine confirmation card. + * + * Tool input is normalized before it reaches this shape: timestamps are + * milliseconds, weekly day names are the scheduler's numeric weekday values, + * and every text field has already been scrubbed for credential-shaped data. + * Keeping the normalized operation on the card lets a confirmation survive an + * app restart without asking the model to interpret the request again. + */ + +export type RoutineRequestRunOn = "maus" | "cloud"; + +export type RoutineRequestSchedule = + | { type: "once"; at: number } + | { type: "daily"; time: string; weekdays: number[] }; + +export interface RoutineRequestDefinition { + name: string; + instructions: string; + schedule: RoutineRequestSchedule; + runOn: RoutineRequestRunOn; + durationMinutes: number; +} + +export type RoutineRequestChanges = Partial; + +export type RoutineRequestOperation = + | { action: "create"; routine: RoutineRequestDefinition } + | { action: "update"; routineId: string; expectedUpdatedAt: number; changes: RoutineRequestChanges } + | { action: "pause"; routineId: string; expectedUpdatedAt: number } + | { action: "resume"; routineId: string; expectedUpdatedAt: number } + | { action: "run_now"; routineId: string; expectedUpdatedAt: number } + | { action: "delete"; routineId: string; expectedUpdatedAt: number }; + +export interface RoutineRequestCardData { + version: 1; + /** Also used as the scheduler's idempotency key after confirmation. */ + requestId: string; + /** Authority is fixed when the card is created; an agent cannot redirect it later. */ + botId: string; + threadId: string; + createdAt: number; + operation: RoutineRequestOperation; + /** Written after a successful confirmation. Useful for support/debugging. */ + appliedAt?: number; + resultId?: string; +} diff --git a/src/components/ApprovalCard.test.ts b/src/components/ApprovalCard.test.ts new file mode 100644 index 000000000..46ada2cf1 --- /dev/null +++ b/src/components/ApprovalCard.test.ts @@ -0,0 +1,129 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { ApprovalCard } from "./ApprovalCard"; +import { spokenApprovalPrompt, type Pending } from "./PendingApproval"; +import type { Message } from "@/state/store"; + +const routineRequest = { + version: 1 as const, + requestId: "routine-request", + botId: "bot-1", + threadId: "thread-1", + createdAt: 1, +}; + +const createRoutineOperation = { + action: "create" as const, + routine: { + name: "Backlog review", + instructions: "Review every item in the backlog.", + schedule: { type: "daily" as const, time: "09:00", weekdays: [1, 2, 3, 4, 5] }, + runOn: "maus" as const, + durationMinutes: 30, + }, +}; + +describe("ApprovalCard routine proposals", () => { + it("describes a chat-created routine as scheduling rather than a raw tool call", () => { + const message: Message = { + id: "routine-card", + role: "bot", + kind: "options", + at: 1, + card: { + title: "Confirm routine", + subtitle: "Weekdays at 09:00", + options: ["Confirm", "Cancel"], + requestId: "routine-request", + tool: "schedule_routine", + routineRequest: { ...routineRequest, operation: createRoutineOperation }, + }, + }; + + const markup = renderToStaticMarkup(createElement(ApprovalCard, { message })); + expect(markup).toContain("Wants to schedule a routine"); + expect(markup).toContain("Weekdays at 09:00"); + }); + + it("records the exact routine action after confirmation", () => { + const message: Message = { + id: "routine-delete-card", + role: "bot", + kind: "options", + at: 1, + card: { + title: "Delete “Daily inbox”?", + subtitle: "Delete “Daily inbox”?\nWhen: Weekdays at 09:00", + options: ["Confirm", "Cancel"], + answered: "allow", + requestId: "routine-request", + tool: "manage_routine", + routineRequest: { + ...routineRequest, + operation: { action: "delete", routineId: "routine-1", expectedUpdatedAt: 1 }, + }, + }, + }; + + const markup = renderToStaticMarkup(createElement(ApprovalCard, { message })); + expect(markup).toContain("Delete “Daily inbox”?"); + expect(markup).toContain("Routine deleted"); + }); + + it("does not imply a run-now request has already started", () => { + const message: Message = { + id: "routine-run-card", + role: "bot", + kind: "options", + at: 1, + card: { + title: "Run now “Daily inbox”?", + subtitle: "Action: Run routine now\nName: Daily inbox", + options: ["Confirm", "Cancel"], + answered: "allow", + requestId: "routine-request", + tool: "manage_routine", + routineRequest: { + ...routineRequest, + operation: { action: "run_now", routineId: "routine-1", expectedUpdatedAt: 1 }, + }, + }, + }; + + const markup = renderToStaticMarkup(createElement(ApprovalCard, { message })); + expect(markup).toContain("Routine run queued"); + expect(markup).not.toContain("Routine started"); + }); + + it("speaks a routine's concise title instead of narrating all instructions", () => { + const instructions = "Review every item in the backlog. ".repeat(500); + const message: Message = { + id: "routine-voice-card", + role: "bot", + kind: "options", + at: 1, + card: { + title: "Schedule routine “Backlog review”?", + subtitle: `Action: Create routine\n\nInstructions:\n${instructions}`, + options: ["Confirm", "Cancel"], + requestId: "routine-request", + tool: "schedule_routine", + routineRequest: { ...routineRequest, operation: createRoutineOperation }, + }, + }; + const pending: Pending = { + message, + requestId: "routine-request", + tool: "schedule_routine", + detail: message.card!.subtitle, + }; + + const spoken = spokenApprovalPrompt(pending, "Mochi"); + expect(spoken).toContain("Schedule routine “Backlog review”?"); + expect(spoken).toContain("Review the schedule and instructions on screen"); + expect(spoken).not.toContain("Review every item in the backlog"); + expect(spoken.length).toBeLessThan(200); + }); +}); diff --git a/src/components/ApprovalCard.tsx b/src/components/ApprovalCard.tsx index eae752319..79128b2cc 100644 --- a/src/components/ApprovalCard.tsx +++ b/src/components/ApprovalCard.tsx @@ -12,6 +12,15 @@ interface ToolLabels { [tool: string]: string; } +const ROUTINE_SETTLED_LABEL = { + create: "Routine scheduled", + update: "Routine updated", + pause: "Routine paused", + resume: "Routine resumed", + run_now: "Routine run queued", + delete: "Routine deleted", +} as const; + /** The tool's own name is noise to a human: mcp__ogb__computer_batch is * "computer batch", Bash is "run a command". */ function toolLabel(tool?: string): string { @@ -24,6 +33,8 @@ function toolLabel(tool?: string): string { Edit: "edit a file", WebFetch: "fetch a web page", WebSearch: "search the web", + schedule_routine: "schedule a routine", + manage_routine: "change a routine", }; return nice[tool] ?? bare; } @@ -39,6 +50,12 @@ export function ApprovalCard({ const card = message.card; if (!card) return null; const settled = card.answered; + const isRoutineRequest = Boolean(card.routineRequest); + const routineAction = card.routineRequest?.operation.action; + const routineSettledLabel = routineAction ? ROUTINE_SETTLED_LABEL[routineAction] : undefined; + const displayTool = isRoutineRequest + ? routineAction === "create" ? "schedule_routine" : "manage_routine" + : card.tool; return (
{bot ? `${bot.name} wants to ` : "Wants to "} - {toolLabel(card.tool)} + {toolLabel(displayTool)}
- {card.tool && {card.tool}} + {displayTool && {displayTool}}
{/* what, exactly */} -
+      
         {card.subtitle}
       
@@ -71,15 +92,17 @@ export function ApprovalCard({
{settled === "allow" ? ( <> - Allowed + + {routineSettledLabel ?? (isRoutineRequest ? "Routine confirmed" : "Allowed")} ) : settled ? ( <> - Denied + {isRoutineRequest ? "Cancelled" : "Denied"} ) : ( <> - Waiting for your answer below + + {isRoutineRequest ? "Waiting for your confirmation below" : "Waiting for your answer below"} )}
diff --git a/src/components/CallView.tsx b/src/components/CallView.tsx index 98ae74723..b459330fc 100644 --- a/src/components/CallView.tsx +++ b/src/components/CallView.tsx @@ -26,7 +26,7 @@ import { speaker } from "@/lib/tts"; import { useSpeech } from "@/lib/tts/useSpeech"; import { usePushToTalk } from "@/lib/push-to-talk"; import { MausAvatar } from "./Avatar"; -import { pendingApprovals } from "./PendingApproval"; +import { isRoutineApproval, pendingApprovals, spokenApprovalPrompt } from "./PendingApproval"; import { cn } from "@/lib/cn"; import { track } from "@/lib/analytics"; import { useDesktopCapabilities } from "./DesktopCapabilities"; @@ -227,7 +227,7 @@ function Call({ bot }: { bot: Bot }) { // the approval we last asked about aloud, so a card that stays open // while the user thinks is not re-read every render - const askedApproval = useRef(null); + const askedApproval = useRef<{ requestId: string; routine: boolean; submitted: boolean } | null>(null); const askedQuestion = useRef<{ requestId: string; messageId: string } | null>(null); const phaseRef = useRef(initialPhase); const alive = useRef(true); @@ -315,17 +315,42 @@ function Call({ bot }: { bot: Bot }) { const open = askedApproval.current; if (open) { + if (open.submitted) { + move("working"); + hush(); + return; + } if (YES.test(said) || NO.test(said)) { const allow = YES.test(said); - askedApproval.current = null; + // Keep this request claimed until the server's durable card patch + // arrives. Clearing it here lets a render in that network gap read + // and submit the same approval again. + open.submitted = true; + move("working"); + hush(); + setHeard(""); dispatch({ type: "decideRequest", threadId: bot.threadId, - requestId: open, + requestId: open.requestId, behavior: allow ? "allow" : "deny", message: allow ? undefined : "Denied by the user, on a call.", + onError: (error: string) => { + const pending = askedApproval.current; + if ( + !alive.current || + currentCall() !== bot.id || + pending?.requestId !== open.requestId || + !pending.submitted + ) return; + pending.submitted = false; + const detail = error.trim().slice(0, 240); + const decision = open.routine ? "routine decision" : "approval"; + void sayThenListen( + `I couldn't save that ${decision}${detail ? `: ${detail}` : "."} Please try again.`, + ); + }, }); - move("working"); return; } // not a decision — leave the card up and say so rather than @@ -373,14 +398,16 @@ function Call({ bot }: { bot: Bot }) { // busy/approval are intentionally initial snapshots. Their live changes // are handled below without tearing down native event listeners. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [bot.id, bot.threadId, dispatch, listen, move, sayThenListen]); + }, [bot.id, bot.threadId, dispatch, hush, listen, move, sayThenListen]); // ── narrate the work, speak the answer, read the approvals ─────────── useEffect(() => { // The request may be resolved from the normal approval UI or by another // client while this call is open. Do not keep treating future speech as // an answer to a card that no longer exists. - if (askedApproval.current && approval?.requestId !== askedApproval.current) { + let resumeAfterRoutine = false; + if (askedApproval.current && approval?.requestId !== askedApproval.current.requestId) { + resumeAfterRoutine = askedApproval.current.routine && askedApproval.current.submitted; askedApproval.current = null; } if (askedQuestion.current && question?.card?.requestId !== askedQuestion.current.requestId) { @@ -390,10 +417,21 @@ function Call({ bot }: { bot: Bot }) { move("working"); hush(); } - if (approval && askedApproval.current !== approval.requestId && phase !== "speaking") { - askedApproval.current = approval.requestId; + if (resumeAfterRoutine && !approval && !question && !bot.busy) { + listen(); + return; + } + // Nothing may reopen capture or narrate new work while the server is + // durably settling this exact decision. + if (askedApproval.current?.submitted) return; + if (approval && askedApproval.current?.requestId !== approval.requestId && phase !== "speaking") { + askedApproval.current = { + requestId: approval.requestId, + routine: isRoutineApproval(approval), + submitted: false, + }; spokenIds.current.add(approval.message.id); - void sayThenListen(`${bot.name} wants to ${approval.tool}. ${approval.detail}. Should I allow it?`); + void sayThenListen(spokenApprovalPrompt(approval, bot.name)); return; } if ( @@ -425,7 +463,7 @@ function Call({ bot }: { bot: Bot }) { if (stillMine && phaseRef.current === "speaking") move("working"); }); } - }, [messages, approval, question, phase, bot.busy, bot.name, hush, move, say, sayThenListen]); + }, [messages, approval, question, phase, bot.busy, bot.name, hush, listen, move, say, sayThenListen]); // busy is the harness's word for "a turn is running" useEffect(() => { diff --git a/src/components/GroupCallView.tsx b/src/components/GroupCallView.tsx index d1212455b..994404f83 100644 --- a/src/components/GroupCallView.tsx +++ b/src/components/GroupCallView.tsx @@ -17,7 +17,7 @@ import { useStore, type Bot, type Group, type Message } from "@/state/store"; import { cn } from "@/lib/cn"; import { MausAvatar } from "./Avatar"; import { CallTargetButton } from "./CallView"; -import { pendingApprovals } from "./PendingApproval"; +import { isRoutineApproval, pendingApprovals, spokenApprovalPrompt } from "./PendingApproval"; const YES = /^(yes|yeah|yep|yup|sure|ok|okay|go ahead|do it|allow|approve|approved|fine|please do)\b/i; const NO = /^(no|nope|don'?t|do not|stop|deny|denied|cancel|never|skip it)\b/i; @@ -85,7 +85,12 @@ function GroupCall({ group, members }: { group: Group; members: Bot[] }) { for (const message of messages) spokenIds.current.add(message.id); } - const askedApproval = useRef<{ requestId: string; member?: Bot } | null>(null); + const askedApproval = useRef<{ + requestId: string; + member?: Bot; + routine: boolean; + submitted: boolean; + } | null>(null); const askedQuestion = useRef<{ requestId: string; member?: Bot } | null>(null); const phaseRef = useRef(initialPhase); const alive = useRef(true); @@ -212,18 +217,44 @@ function GroupCall({ group, members }: { group: Group; members: Bot[] }) { const openApproval = askedApproval.current; if (openApproval) { + if (openApproval.submitted) { + move("working"); + hush(); + return; + } if (YES.test(said) || NO.test(said)) { const allow = YES.test(said); - askedApproval.current = null; + // Hold this approval in-flight until its server patch arrives so a + // slow response cannot reopen the microphone and submit it twice. + openApproval.submitted = true; allowBargeIn.current = false; + move("working"); + hush(); + setHeard(""); dispatch({ type: "decideRequest", threadId: group.threadId, requestId: openApproval.requestId, behavior: allow ? "allow" : "deny", message: allow ? undefined : "Denied by the user, on a group call.", + onError: (error: string) => { + const pending = askedApproval.current; + if ( + !alive.current || + currentCall() !== group.id || + pending?.requestId !== openApproval.requestId || + !pending.submitted + ) return; + pending.submitted = false; + const detail = error.trim().slice(0, 240); + const decision = openApproval.routine ? "routine decision" : "approval"; + enqueueSpeech( + `I couldn't save that ${decision}${detail ? `: ${detail}` : "."} Please try again.`, + openApproval.member, + true, + ); + }, }); - move("working"); return; } enqueueSpeech("Sorry — is that a yes or a no?", openApproval.member, true); @@ -283,26 +314,37 @@ function GroupCall({ group, members }: { group: Group; members: Bot[] }) { }; // Live busy/card changes are handled below without restarting native capture. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dispatch, enqueueSpeech, group.id, group.threadId, listen, move, scheduleListen]); + }, [dispatch, enqueueSpeech, group.id, group.threadId, hush, listen, move, scheduleListen]); useEffect(() => { + let resumeAfterRoutine = false; if (askedApproval.current && approval?.requestId !== askedApproval.current.requestId) { + resumeAfterRoutine = askedApproval.current.routine && askedApproval.current.submitted; askedApproval.current = null; } if (askedQuestion.current && question?.card?.requestId !== askedQuestion.current.requestId) { askedQuestion.current = null; } + if (resumeAfterRoutine && !approval && !question && !group.busyBotId) { + scheduleListen(true); + return; + } + // Keep the voice queue and microphone closed until this exact decision + // is settled or its request reports an error. + if (askedApproval.current?.submitted) return; + if (approval && askedApproval.current?.requestId !== approval.requestId) { const member = members.find((candidate) => candidate.id === approval.message.from?.botId); - askedApproval.current = { requestId: approval.requestId, member }; + askedApproval.current = { + requestId: approval.requestId, + member, + routine: isRoutineApproval(approval), + submitted: false, + }; spokenIds.current.add(approval.message.id); const name = member?.name ?? approval.message.from?.name ?? "A channel member"; - enqueueSpeech( - name + " wants to " + approval.tool + ". " + approval.detail + ". Should I allow it?", - member, - true, - ); + enqueueSpeech(spokenApprovalPrompt(approval, name), member, true); } if (question?.card?.requestId && askedQuestion.current?.requestId !== question.card.requestId) { @@ -339,7 +381,7 @@ function GroupCall({ group, members }: { group: Group; members: Bot[] }) { enqueueSpeech(chip.tool.spoken, member); } } - }, [approval, enqueueSpeech, members, messages, question]); + }, [approval, enqueueSpeech, group.busyBotId, members, messages, question, scheduleListen]); useEffect(() => { const busy = Boolean(group.busyBotId); diff --git a/src/components/PendingApproval.tsx b/src/components/PendingApproval.tsx index 7ab8d2c2d..7acaebf2f 100644 --- a/src/components/PendingApproval.tsx +++ b/src/components/PendingApproval.tsx @@ -25,6 +25,12 @@ export interface Pending { held?: string; } +/** The persisted payload is the authoritative marker. Tool names are + * provider-authored display strings and can collide with ours. */ +export function isRoutineApproval(pending: Pending): boolean { + return Boolean(pending.message.card?.routineRequest); +} + /** Open approvals on a thread, oldest first — answered/dismissed drop out. */ export function pendingApprovals(messages: Message[]): Pending[] { return messages @@ -39,7 +45,24 @@ export function pendingApprovals(messages: Message[]): Pending[] { })); } -function label(tool: string): string { +/** Routine cards can carry every instruction the user asked for (up to + * 20,000 characters). Calls should announce the concise, visible title and + * let the user review those details on screen instead of reading them all. */ +export function spokenApprovalPrompt(pending: Pending, requester: string): string { + const isRoutineRequest = isRoutineApproval(pending); + if (!isRoutineRequest) { + return `${requester} wants to ${pending.tool}. ${pending.detail}. Should I allow it?`; + } + const title = pending.message.card?.title.trim() || "Confirm this routine?"; + return `${requester} asks: ${title}${/[.!?]$/.test(title) ? "" : "."} Review the schedule and instructions on screen. Should I confirm it?`; +} + +function label(pending: Pending): string { + if (isRoutineApproval(pending)) { + return pending.message.card?.routineRequest?.operation.action === "create" + ? "Confirm this routine" + : "Confirm this routine change"; + } const nice: ApprovalLabels = { Bash: "Command approval requested", shell: "Command approval requested", @@ -48,7 +71,7 @@ function label(tool: string): string { Edit: "File-change approval requested", edit: "File-change approval requested", }; - return nice[tool] ?? "Approval requested"; + return nice[pending.tool] ?? "Approval requested"; } export const PendingApprovalPanel = memo(function PendingApprovalPanel({ @@ -61,19 +84,33 @@ export const PendingApprovalPanel = memo(function PendingApprovalPanel({ index: number; }) { return ( -
-
+
+
Pending approval {count > 1 && ( {index + 1} of {count} )} - {label(pending.tool)} - {pending.tool} + {label(pending)} + + {isRoutineApproval(pending) + ? pending.message.card?.routineRequest?.operation.action === "create" + ? "schedule_routine" + : "manage_routine" + : pending.tool} +
{/* never truncated — long commands wrap and scroll */} -
+      
         {pending.detail}
       
{pending.held &&
{pending.held}
} @@ -94,6 +131,7 @@ export function PendingApprovalActions({ onCancelTurn: () => void; }) { const { dispatch } = useStore(); + const isRoutineRequest = isRoutineApproval(pending); const decide = (behavior: "allow" | "deny", always = false) => dispatch({ type: "decideRequest", @@ -107,16 +145,18 @@ export function PendingApprovalActions({ const base = "rounded-full px-3.5 py-1.5 text-[13.5px] transition-colors"; return (
- + {!isRoutineRequest && ( + + )} - {bot && pending.allowKey && ( + {!isRoutineRequest && bot && pending.allowKey && (
); diff --git a/src/state/store.tsx b/src/state/store.tsx index d9c7dc10c..ce9c28172 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -16,6 +16,7 @@ import { import type { CloudBackend, EffortLevel } from "../../server/contracts.ts"; import type { MausColor, MausMotion } from "@/lib/mascot"; import type { BotAvatarCrop } from "../../shared/bot-avatar"; +import type { RoutineRequestCardData } from "../../shared/routine-request"; import type { Routine, RoutineInput, RoutineRun } from "@/lib/routines"; import type { WebhookAttempt, WebhookIngressStatus, WebhookTrigger } from "@/lib/webhooks"; import { currentCall } from "@/lib/call"; @@ -41,6 +42,8 @@ export interface OptionCardData { /** the narrow grant "always allow" remembers, e.g. "Bash:git" */ allowKey?: string; approvalScope?: "local-computer"; + /** Persisted proposal used by the server when the user confirms it. */ + routineRequest?: RoutineRequestCardData; } export interface ConnectorCardData { @@ -487,6 +490,8 @@ export type Action = message?: string; /** remember this exact grant (the server's allowKey) for the bot */ alwaysAllow?: { botId: string; key: string }; + /** Local UI recovery hook for voice flows. Never sent to the server. */ + onError?: (message: string) => void; } | { type: "newTask"; botId: string } | { type: "switchTask"; botId: string; threadId: string } @@ -1372,7 +1377,10 @@ export function StoreProvider({ children }: { children: ReactNode }) { behavior: action.behavior, message: action.message, }), - }).catch(showError); + }).catch((error) => { + showError(error); + action.onError?.(error instanceof Error ? error.message : String(error)); + }); if (action.alwaysAllow) { const bot = stateRef.current.bots.find((b) => b.id === action.alwaysAllow!.botId); const next = [...new Set([...(bot?.alwaysAllow ?? []), action.alwaysAllow.key])];