diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts new file mode 100644 index 0000000..a584835 --- /dev/null +++ b/sidecar/src/agent.ts @@ -0,0 +1,344 @@ +/** + * The agent's own tools, and nothing of the person's agent setup. The file tools work in the project folder only. + * + * Tools that act outside the store (`run`, `slice`) are replay-safe and keep a receipt per operation, under a stable + * operation id (the tool call's id). The action writes its receipt file the moment it ends, and the call records it in + * Pi before it returns, so a rerun after a crash hands back the result instead of acting again. A crash between the + * end of an action and its receipt file still repeats that one action, because no checkpoint can make an outside + * action run exactly once. `run` passes the id on as `M3D_OPERATION_ID`, for a script that wants to check for itself. + */ +import { createHash } from 'node:crypto' +import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readSync, realpathSync, renameSync, writeFileSync } from 'node:fs' +import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' +import type { Context, JsonValue } from '@earendil-works/chord' +import { + AssistantEntry, + configure, + defineExtension, + defineTool, + type ToolExecutionApi, + type ToolRegistration, +} from '@earendil-works/pi-durable' +import { err, FileError, type Result } from '@earendil-works/pi-durable/env' +import { NodeExecutionEnv } from '@earendil-works/pi-durable/env/node' +import { createEditTool, createReadTool, createWriteTool } from '@earendil-works/pi-durable/tools' +import { arr, int, obj, opt, str } from '@earendil-works/pi-schema' +import type { Host } from './host' +import { isLoginFailure } from './inference' +import { textOf } from './transcript' +import workflow from './workflow.md' with { type: 'text' } + +/** The instructions every turn runs with, and the version a turn records. */ +export const WORKFLOW = workflow +export const WORKFLOW_VERSION = createHash('sha256').update(workflow).digest('hex').slice(0, 12) + +/** Astra, the GPT helper: OpenAI's model on the person's ChatGPT plan. */ +export const ASTRA_MODEL = { provider: 'openai-codex', modelId: 'gpt-6-astra' } as const + +/** How much of a command's output the model gets back: the end, where errors are. */ +const OUTPUT_TAIL = 20_000 + +/** The only variables a command the agent runs gets. */ +const COMMAND_ENV = ['PATH', 'HOME', 'USER', 'LANG', 'TMPDIR'] as const + +/** + * Runs a command under a watch on the sidecar. Pi starts each command in a process group of its own, so it can end the + * whole tree on a timeout or a stop, but a sidecar killed outright ends nothing. The watch ends the group, command and + * all, within a second of the sidecar's end. The command writes its output to its log, never to the sidecar's pipe, so + * a sidecar's end cannot cut it off, and one that ends first leaves its exit code beside the log. So a command a crash + * cut off either finished, and a resumed turn takes its exit code and output, or was ended. + */ +const GUARD = [ + 'sidecar=$1; log=$2; exited=$3; shift 3', + '(while kill -0 "$sidecar" 2>/dev/null; do sleep 1; done; kill -KILL 0) /dev/null 2>&1 &', + 'watch=$!', + '"$@" >"$log" 2>&1', + 'status=$?', + 'kill "$watch" 2>/dev/null; wait "$watch" 2>/dev/null', + `printf '%d' "$status" > "$exited.new" && mv -f "$exited.new" "$exited"`, + 'exit "$status"', +].join('\n') + +/** How often a running command's log is read for its new output. */ +const LOG_POLL_MS = 200 + +/** The bytes of a file from `from` to its end, at most the last `OUTPUT_TAIL` of them, read without the rest. */ +function readEnd(path: string, from: number): { bytes: Uint8Array; start: number; end: number } { + const fd = openSync(path, 'r') + try { + const end = fstatSync(fd).size + const start = Math.max(from, end - OUTPUT_TAIL) + const bytes = new Uint8Array(Math.max(0, end - start)) + let read = 0 + while (read < bytes.length) { + const got = readSync(fd, bytes, read, bytes.length - read, start + read) + if (got === 0) break + read += got + } + return { bytes: bytes.subarray(0, read), start, end: start + read } + } finally { + closeSync(fd) + } +} + +/** The end of a command's log, where errors are. */ +export function tailOf(log: string): string { + return existsSync(log) ? new TextDecoder().decode(readEnd(log, 0).bytes) : '' +} + +/** + * A command's log as it grows. Each call gives what was added since the last, read from where that one stopped. A + * burst longer than the model ever sees is skipped to its newest `OUTPUT_TAIL` bytes. + */ +export function logFollower(log: string): () => string { + let shown = 0 + let decoder = new TextDecoder() + return () => { + if (!existsSync(log)) return '' + const { bytes, start, end } = readEnd(log, shown) + // A skip starts mid-stream, so a character cut there is not joined to the one before. + if (start > shown) decoder = new TextDecoder() + shown = end + return decoder.decode(bytes, { stream: true }) + } +} + +/** Longer than the watch takes to end a command after its sidecar ends. */ +const GUARD_GRACE_MS = 2000 + +/** How many symbolic links a path may pass through before it is refused, as the system refuses a loop. */ +const MAX_LINKS = 40 + +/** + * Where `path` really leads. Every symbolic link on the way is followed, a link whose target does not exist yet + * included, so a path that leads out of the project through one is seen to. A part that does not exist yet is + * where its folder really is. + */ +function realPath(path: string, links = 0): string { + if (links > MAX_LINKS) throw new Error(`${path} passes through too many links`) + try { + // The system's own answer: the JS version drops `..` before it follows the links ahead of it. + return realpathSync.native(path) + } catch { + let link = false + try { + link = lstatSync(path).isSymbolicLink() + } catch { + // Nothing is at this path yet. + } + if (link) { + // The target stays as written: `..` in it climbs from where the links before it lead, as the system climbs. + const target = readlinkSync(path) + return realPath(isAbsolute(target) ? target : `${dirname(path)}/${target}`, links + 1) + } + const up = dirname(path) + return up === path ? path : join(realPath(up, links), basename(path)) + } +} + +/** Whether a path relative to a folder leads out of it. A name that only starts with two dots, `..notes`, stays in. */ +export const leaves = (rel: string) => rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) + +/** One project's files and processes. A path that leads out of the project folder is refused, for every file tool. */ +export class ProjectEnv extends NodeExecutionEnv { + override async absolutePath(path: string, context: Context): Promise> { + const resolved = await super.absolutePath(path, context) + if (!resolved.ok) return resolved + let real: string + try { + real = realPath(resolved.value) + } catch (error) { + return err(new FileError('permission_denied', error instanceof Error ? error.message : String(error), path)) + } + const inside = relative(realpathSync.native(this.cwd), real) + if (leaves(inside)) { + return err(new FileError('permission_denied', `${path} is outside the project folder`, path)) + } + return resolved + } +} + +/** Pi's record of one operation's result, kept by its tool call. */ +export interface Memo { + get(): Promise + set(value: T): Promise +} + +/** + * The result of one outside action, done once per operation: the result Pi kept, else the receipt file the action + * left when it ended, else the action itself, whose receipt file is written the moment it ends. + */ +export async function once(memo: Memo, receipt: string, act: () => Promise): Promise { + const kept = await memo.get() + if (kept !== undefined) return kept + if (existsSync(receipt)) return memo.set(JSON.parse(readFileSync(receipt, 'utf8')) as T) + const result = await act() + mkdirSync(dirname(receipt), { recursive: true }) + writeFileSync(`${receipt}.new`, JSON.stringify(result)) + renameSync(`${receipt}.new`, receipt) + return memo.set(result) +} + +const text = (value: string) => [{ type: 'text' as const, text: value }] + +export interface AgentHost { + host: Host + /** The project a conversation belongs to. */ + projectOf(conversation: number): Promise + /** + * Resolves once the person is signed in to `provider`: at once when they are, after holding the turn with the fix + * named on its chat when they are not. With `after`, only a login saved after that many saves will do, as the one + * then saved was refused. A stop ends the wait. + */ + signedIn(conversation: number, provider: string, context: Context, after: number | undefined): Promise + /** How many times the provider's login was saved. */ + loginSaves(provider: string): Promise +} + +/** + * A call's operation id: a hash of its durable task and its call id. The task tells apart calls a provider gave the + * same id in different steps, and stays the same when a crash replays the call. The hash holds any character an id + * the model made can hold, and no two calls share a receipt. + */ +export const operationId = (taskId: string, callId: string) => createHash('sha256').update(`${taskId}\0${callId}`).digest('hex').slice(0, 32) + +/** A tool call's operation: its id, its receipt file in the project's hidden folder, and Pi's memo of it. */ +function operation(api: ToolExecutionApi, context: Context) { + const env = api.env + // Astra's conversation is not a project, so the folder is the one its tools work in, which it shares. + if (!(env instanceof ProjectEnv)) throw new Error('this conversation has no project folder') + const id = operationId(String(api.taskId), api.callId) + const memo: Memo = { get: () => api.memo('receipt', context), set: (value) => api.memo('receipt', value, context) } + return { env, id, memo, receipt: join(env.cwd, '.m3d', 'ops', `${id}.json`) } +} + +/** The agent's tools, in the order the model is offered them. */ +export function agentTools({ host, projectOf, signedIn, loginSaves }: AgentHost) { + const read = createReadTool() + const write = { ...createWriteTool(), replay: 'safe' as const } + const edit = createEditTool() + + const run = defineTool({ + name: 'run', + description: + 'Run uv in the project folder, for the build and check scripts. Pass the arguments after `uv`, for example ' + + '["run", "--with", "manifold3d", "--with", "numpy", "python", "build/model.py"]. Returns the exit code and the end of the output.', + parameters: obj({ args: arr(str(), { minItems: 1 }), timeoutSeconds: opt(int({ minimum: 1, maximum: 3600 })) }), + replay: 'safe', + execute: async ({ args, timeoutSeconds }, api, context) => { + const op = operation<{ exitCode: number; output: string }>(api, context) + const { env } = op + const vars: Record = { M3D_OPERATION_ID: op.id } + for (const name of COMMAND_ENV) { + const value = process.env[name] + if (value !== undefined) vars[name] = value + } + const log = `${op.receipt}.log` + const exited = `${op.receipt}.exit` + // A command a crash cut off may still be ending: wait until its watch has ended it or it left its exit code. + if ((await api.memo('started', context)) === true && !existsSync(op.receipt) && !existsSync(exited)) { + await Bun.sleep(GUARD_GRACE_MS) + } + const receipt = await once(op.memo, op.receipt, async () => { + if (existsSync(exited)) return { exitCode: Number(readFileSync(exited, 'utf8')), output: tailOf(log) } + mkdirSync(dirname(op.receipt), { recursive: true }) + await api.memo('started', true, context) + const added = logFollower(log) + const show = () => { + const text = added() + if (text !== '') api.output(text) + } + const poll = setInterval(show, LOG_POLL_MS) + const ran = await env + .exec(['/bin/sh', '-c', GUARD, 'm3d-run', String(process.pid), log, exited, 'uv', ...args], { env: vars, inheritEnv: false, timeout: timeoutSeconds ?? 600 }, context) + .finally(() => clearInterval(poll)) + show() + return ran.ok ? { exitCode: ran.value.exitCode, output: tailOf(log) } : { exitCode: -1, output: `${tailOf(log)}\n${ran.error.message}` } + }) + return { output: text(`exit code ${receipt.exitCode}\n${receipt.output}`), isError: receipt.exitCode !== 0 } + }, + }) + + const slice = defineTool({ + name: 'slice', + description: + 'Slice STL files in the project folder to one print-ready 3MF for the Bambu Lab P2S (0.4 mm nozzle, 0.20 mm layers, ' + + 'PETG) with the validated Bambu Studio. Paths are relative to the project folder; the output ends in .3mf.', + parameters: obj({ inputs: arr(str(), { minItems: 1 }), output: str() }), + replay: 'safe', + execute: async ({ inputs, output }, api, context) => { + type Sliced = { ok: true; parts: number; printSeconds: number; filamentGrams: number } | { ok: false; error: string } + const op = operation(api, context) + const project = await projectOf(api.conversationId) + const sliced = await once(op.memo, op.receipt, async () => { + try { + const { parts, printSeconds, filamentGrams } = await host.request('slice', { operation: op.id, project, inputs, output }) + return { ok: true, parts, printSeconds, filamentGrams } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + }) + if (!sliced.ok) return { output: text(`The slice failed: ${sliced.error}`), isError: true } + const minutes = Math.round(sliced.printSeconds / 60) + return { output: text(`Wrote ${output}: ${sliced.parts} part(s), about ${minutes} min and ${sliced.filamentGrams.toFixed(1)} g of filament.`) } + }, + }) + + const astra = defineTool({ + name: 'astra', + description: + 'Hand a task to Astra, the GPT helper, who works in this project folder with the same file tools and `run`. ' + + 'Astra cannot see this chat, so say everything: what to do, the files to read, and the file to write.', + parameters: obj({ task: str({ minLength: 1 }) }), + replay: 'safe', + execute: async ({ task }, api, context) => { + // Astra's conversation is owned by this call, so a rerun finds the same one and the same request. + const child = await api.commit(async (tx) => { + const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0] + if (existing !== undefined) return existing.id + const created = await tx.createConversation({ ownership: { kind: 'task', taskId: api.taskId } }) + await configure(tx, created.id, { model: ASTRA_MODEL, tools: [read, write, edit, run] }) + return created.id + }, context) + await api.details({ astra: child }, context) + const conversation = await api.conversation(child, context) + if (conversation === undefined) throw new Error("Astra's conversation is gone") + const { provider } = ASTRA_MODEL + // The login save each request goes out with, kept with the request, so a rerun after a crash finds the same one. + const savedWith = async (requestId: string) => api.memo(`login:${requestId}`, await loginSaves(provider), context) + // Astra runs on the person's ChatGPT sign-in. Without it the turn waits here, with the fix named, and goes on. + await signedIn(api.conversationId, provider, context, undefined) + const first = `astra:${api.taskId}` + let saves = await savedWith(first) + let submission = await conversation.submit({ type: 'input', content: task, requestId: first }, context) + // Each continue has its own request id, so a rerun after a crash finds the same one instead of sending another. + for (let resumed = 0; ; resumed++) { + const settled = await submission.wait(context) + if (settled.status === 'done' && settled.type !== 'write') { + const { answer } = settled + const entry = await api.commit((tx) => tx.entry(AssistantEntry, answer), context) + return { output: text(textOf(entry?.model?.[0]?.content) || 'Astra finished without a message.') } + } + const detail = typeof settled.detail === 'string' ? settled.detail : '' + if (settled.reason !== 'model_error' || !isLoginFailure(detail)) { + return { output: text(`Astra did not answer (${settled.reason}${detail === '' ? '' : `: ${detail}`}).`), isError: true } + } + // The sign-in was missing or refused, so Astra waits for a login saved after the one this request went out + // with, which may have landed while it was out, then goes on from where it stopped. A rerun after a crash takes + // a continue it already sent as it was, without waiting again. + const requestId = `astra:${api.taskId}:continue:${resumed}` + const sent = await api.commit((tx) => tx.submissionByRequest(child, requestId), context) + if (sent === undefined) await signedIn(api.conversationId, provider, context, saves) + saves = await savedWith(requestId) + submission = await conversation.submit({ type: 'continue', requestId }, context) + } + }, + }) + + return [read, write, edit, run, slice, astra] as const satisfies readonly ToolRegistration[] +} + +/** The one extension the app installs. Nothing else is registered, so the agent has these tools and no others. */ +export function agentExtension(tools: readonly ToolRegistration[]) { + return defineExtension({ name: 'materialize', tools }) +} diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts new file mode 100644 index 0000000..26228e2 --- /dev/null +++ b/sidecar/src/app.ts @@ -0,0 +1,95 @@ +import { parseArgs } from 'node:util' +import type { MutableModels } from '@earendil-works/pi-ai' +import { createRegistry } from '@earendil-works/pi-durable' +import { agentExtension, agentTools, ProjectEnv } from './agent' +import { Chats } from './chat' +import { Host } from './host' +import { inferenceModels, KeychainCredentials, Logins } from './inference' +import { handlers } from './methods' +import { EXIT, type SidecarEvent } from './protocol' +import { Store, StoreInUse } from './store' + +export interface SidecarOptions { + argv: string[] + /** Tests only: providers beside the app's three, such as a scripted model. */ + addProviders?(models: MutableModels): void +} + +/** The sidecar from start to quit: open the store, serve the pipe until stdin closes, then close the store. */ +export async function runSidecar({ argv, addProviders }: SidecarOptions): Promise { + // Stdout carries protocol lines only, so a library that logs cannot corrupt the pipe. + console.log = console.error + console.info = console.error + console.debug = console.error + + const { values } = parseArgs({ + args: argv, + options: { 'app-version': { type: 'string' }, data: { type: 'string' }, projects: { type: 'string' } }, + strict: true, + }) + const { data, projects } = values + if (data === undefined || projects === undefined) throw new Error('usage: --data --projects ') + const version = values['app-version'] ?? 'dev' + + const host = new Host((line) => process.stdout.write(line)) + // The credentials keep their index in the store, which needs the models to open, so the index is reached late. + const credentials = new KeychainCredentials(host, { + logins: () => store.logins(), + setLogin: (provider, kind) => store.setLogin(provider, kind), + }) + const models = inferenceModels(credentials) + addProviders?.(models) + + const registry = createRegistry() + const tools = agentTools({ + host, + projectOf: (conversation) => store.projectOf(conversation), + signedIn: (conversation, provider, context, after) => chats.loginFor(conversation, provider, context, after), + loginSaves: (provider) => store.loginSaves(provider), + }) + const extension = agentExtension(tools) + registry.install(extension) + + let store: Store + try { + store = await Store.open({ + dataDir: data, + projectsDir: projects, + models, + registry, + env: ({ cwd }) => { + if (cwd === undefined) throw new Error('this conversation has no project folder') + return new ProjectEnv({ cwd }) + }, + }) + } catch (error) { + if (!(error instanceof StoreInUse)) throw error + console.error(error.message) + process.exit(EXIT.storeInUse) + } + // A store that fails is not used again. Rust starts a new sidecar, which reopens it from what was committed. + void store.harness.closed.then((end) => { + if (end.kind !== 'failed') return + console.error(`the store failed: ${end.error instanceof Error ? end.error.message : String(end.error)}`) + process.exit(1) + }) + + const emit = (event: SidecarEvent) => { + host.emit(event) + if (event.type === 'login' && event.step === 'done') { + chats.signedIn(event.provider).catch((error: unknown) => console.error(`resuming turns after a sign-in failed: ${String(error)}`)) + } + } + const chats = new Chats({ store, models, extension, tools, version, emit }) + const logins = new Logins(models, credentials, emit) + console.error(`agent tools: ${tools.map((tool) => tool.name).join(', ')}`) + + // The pipe opens before the reconcile, which asks Rust's Keychain whether a waited-on login is there now. + const serving = host.serve(handlers({ store, logins, chats, version }), process.stdin) + host.emit({ type: 'ready', version }) + await chats.reconcile() + await serving + await chats.close() + await store.close() + process.exit(EXIT.done) +} diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts new file mode 100644 index 0000000..520a619 --- /dev/null +++ b/sidecar/src/chat.ts @@ -0,0 +1,418 @@ +/** + * Chats: one Pi conversation per project. A send commits the message, the settings its turn runs with, and the + * draft's removal together. A turn that stops before its answer waits with the fix named, and continues once it is + * fixed. The window sees committed entries only. + */ +import { mkdtempSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { extname, isAbsolute, join, relative } from 'node:path' +import type { Context } from '@earendil-works/chord' +import { awaitWithContext, BACKGROUND_CONTEXT } from '@earendil-works/chord/context' +import type { ImageContent, Models, TextContent } from '@earendil-works/pi-ai' +import { + type Conversation, + ConversationBusy, + type ConversationId, + configure, + type EntryRecord, + type Extension, + LiveDoc, + type RunFailure, + type ToolRegistration, + type WatchHandle, +} from '@earendil-works/pi-durable' +import type { ConversationView } from '@earendil-works/pi-durable' +import { leaves, WORKFLOW, WORKFLOW_VERSION } from './agent' +import type { ChatEntry, ChatView, SidecarEvent, Turn, Waiting } from './protocol' +import { isLoginFailure } from './inference' +import { DraftsDoc, LoginSavesDoc, SettingsDoc, type Store, TurnDoc, WaitingDoc } from './store' +import { chatEntry } from './transcript' + +const context: Context = BACKGROUND_CONTEXT + +const IMAGE_TYPES: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' } +/** Models read pictures up to about 5 MB. Larger ones, and HEIC photos, go as a smaller JPEG on macOS. */ +const MAX_IMAGE_BYTES = 3_500_000 + +export interface ChatsOptions { + store: Store + models: Models + extension: Extension + tools: readonly ToolRegistration[] + version: string + emit(event: SidecarEvent): void +} + +/** + * A path the window named for an attachment: a regular file inside the project folder where it really leads, links + * followed. Anything else, such as a pipe that would hold every read, is refused. + */ +function inside(folder: string, rel: string): string { + const path = join(folder, rel) + const back = relative(realpathSync.native(folder), realpathSync.native(path)) + if (back === '' || leaves(back) || !statSync(path).isFile()) throw new Error(`${rel} is not a file in the project folder`) + return path +} + +/** A picture the model can read: as it is, or on macOS as a JPEG made with `sips`. Undefined when neither works. */ +function picture(folder: string, rel: string): ImageContent | undefined { + const path = inside(folder, rel) + const type = IMAGE_TYPES[extname(rel).toLowerCase()] + if (type !== undefined && statSync(path).size <= MAX_IMAGE_BYTES) { + return { type: 'image', data: readFileSync(path).toString('base64'), mimeType: type } + } + if (process.platform !== 'darwin') return undefined + // The JPEG is made in a folder of its own outside the project, so no link in the project can steer where it is written. + const scratch = mkdtempSync(join(tmpdir(), 'm3d-send-')) + try { + const out = join(scratch, 'picture.jpg') + const converted = Bun.spawnSync(['sips', '-s', 'format', 'jpeg', '-Z', '2000', path, '--out', out]) + if (converted.exitCode !== 0) return undefined + return { type: 'image', data: readFileSync(out).toString('base64'), mimeType: 'image/jpeg' } + } finally { + rmSync(scratch, { recursive: true, force: true }) + } +} + +function userContent(folder: string, text: string, attachments: string[]): string | (TextContent | ImageContent)[] { + if (attachments.length === 0) return text + const images = attachments.flatMap((rel) => picture(folder, rel) ?? []) + const note = `\n\n(Pictures attached, saved in this folder as: ${attachments.join(', ')})` + return [{ type: 'text', text: `${text}${note}` }, ...images] +} + +export class Chats { + readonly #o: ChatsOptions + readonly #watches = new Map>() + /** The newest entry each watched chat has sent the window. */ + readonly #sent = new Map() + /** Tools waiting for a sign-in, by provider. */ + readonly #signIns = new Map void>>() + + constructor(options: ChatsOptions) { + this.#o = options + } + + async #conversation(project: string): Promise { + const { conversation } = await this.#o.store.project(project) + const found = await this.#o.store.harness.conversation(conversation as ConversationId, context) + if (found === undefined) throw new Error(`project ${project} has no conversation`) + return found + } + + async #entries(conversation: Conversation, after = 0): Promise { + const entries: ChatEntry[] = [] + let cursor: Parameters[2] + do { + const page = await conversation.entries({ order: 'ascending', minEntryId: (after + 1) as EntryRecord['id'] }, 500, cursor, context) + entries.push(...page.items.flatMap((entry) => chatEntry(entry) ?? [])) + cursor = page.next + } while (cursor !== undefined) + return entries + } + + async #state(conversation: Conversation): Promise<{ running: boolean; waiting: Waiting | null; turn: Turn | null; failure: RunFailure | undefined }> { + const { harness } = this.#o.store + const [live, waiting, turn] = await Promise.all([ + harness.snapshot(LiveDoc, conversation.id, context), + harness.snapshot(WaitingDoc, conversation.id, context), + harness.snapshot(TurnDoc, conversation.id, context), + ]) + return { running: live?.run !== undefined, waiting: waiting?.waiting ?? null, turn: turn?.turn ?? null, failure: live?.runFailure } + } + + /** The whole chat, and from now on its changes as `chat` events. */ + async open(project: string): Promise { + const conversation = await this.#conversation(project) + await this.#watch(project, conversation) + const entries = await this.#entries(conversation) + this.#sent.set(project, Math.max(this.#sent.get(project) ?? 0, entries.at(-1)?.id ?? 0)) + const { running, waiting, turn } = await this.#state(conversation) + return { project, entries, running, waiting, turn } + } + + async #watch(project: string, conversation: Conversation): Promise { + if (this.#watches.has(project)) return + const watch = await conversation.watch(context) + this.#watches.set(project, watch) + watch.start(async (call) => { + if (call.kind === 'value') await this.#changed(project, conversation) + }) + } + + async #changed(project: string, conversation: Conversation): Promise { + let state = await this.#state(conversation) + const recorded = (await this.#o.store.harness.snapshot(WaitingDoc, conversation.id, context))?.failure ?? null + if (state.failure !== undefined && state.failure.reason !== 'aborted' && state.failure.taskId !== recorded) { + // The waiting document is not part of the view a watch follows, so its commit wakes no one: read it here. + await this.#read(conversation, state.failure, state.turn) + state = await this.#state(conversation) + } + const entries = await this.#entries(conversation, this.#sent.get(project) ?? 0) + if (entries.length > 0) this.#sent.set(project, entries.at(-1)?.id ?? 0) + this.#o.emit({ type: 'chat', project, entries, running: state.running, waiting: state.waiting, turn: state.turn }) + } + + /** + * Reads why a run stopped: a provider with no login, or a refused one, waits for a sign-in; anything else for a retry. + * A login missing or refused when a newer one was saved since the turn went out goes on with the newer one at once, + * since the sign-in that saved it found no wait to let go. The login read here may be older than that save, so only + * the save count, read in the commit, decides. A turn from another version of the app is left to wait. + */ + async #read(conversation: Conversation, failure: RunFailure, turn: Turn | null): Promise { + const { models, store, version } = this.#o + const provider = turn?.model.provider ?? 'anthropic' + const name = models.getProvider(provider)?.name ?? provider + const auth = await models.getAuth(provider).catch(() => undefined) + const detail = typeof failure.detail === 'string' ? failure.detail : failure.reason + const login = auth === undefined || (failure.reason === 'model_error' && isLoginFailure(detail)) + const waiting: Waiting = + login + ? { kind: 'login', provider, name, fix: `Sign in to ${name}. The message picks up where it stopped.` } + : { kind: 'error', detail, fix: 'Try again' } + await store.harness.commit(async (tx) => { + // A send that came after this failure started a new turn, which this failure must not hold. + if ((await tx.doc(TurnDoc, conversation.id)).turn?.requestId !== turn?.requestId) return + const record = await tx.doc(WaitingDoc, conversation.id) + record.failure = failure.taskId + record.tool = false + // Read in this commit, so a sign-in either lands before it and is seen here, or after it and finds this wait. + const saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[provider] ?? 0 + if (login && record.saves !== null && saves > record.saves && turn?.app === version) { + await conversation.submit(tx, { type: 'continue' }) + record.waiting = null + record.saves = saves + return + } + record.waiting = waiting + }, context) + } + + /** + * Sends a message. The message, the settings its turn runs with, and the draft's removal commit together. A send + * whose `requestId` was already committed changes nothing, so the window can send again after any failure, even + * once a picture it sent is gone. + */ + async send(project: string, requestId: string, text: string, attachments: string[]): Promise { + const { store, extension, tools, version } = this.#o + const conversation = await this.#conversation(project) + if ((await store.harness.commit((tx) => tx.submissionByRequest(conversation.id, requestId), context)) !== undefined) { + await this.#watch(project, conversation) + return + } + const folder = store.projectFolder(project) + const content = userContent(folder, text, attachments) + try { + await store.harness.commit(async (tx) => { + if ((await tx.submissionByRequest(conversation.id, requestId)) !== undefined) return + const settings = await tx.doc(SettingsDoc, store.root.id) + const model = { provider: settings.model.provider, modelId: settings.model.modelId } + const thinking = settings.thinking + await conversation.submit(tx, { + type: 'input', + content, + requestId, + whenBusy: 'reject', + entry: { kind: 'm3d.user', data: { text, images: attachments } }, + }) + await configure(tx, conversation.id, { model, thinkingLevel: thinking, extensions: [extension], tools, instructions: WORKFLOW, cwd: folder }) + const turn: Turn = { requestId, model, thinking, tools: tools.map((t) => t.name), instructions: WORKFLOW_VERSION, app: version, startedMs: Date.now() } + ;(await tx.doc(TurnDoc, conversation.id)).turn = turn + delete (await tx.doc(DraftsDoc, store.root.id)).drafts[project] + const waiting = await tx.doc(WaitingDoc, conversation.id) + waiting.waiting = null + waiting.tool = false + waiting.saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[model.provider] ?? 0 + }, context) + } catch (error) { + if (error instanceof ConversationBusy) throw new Error('The agent is still working on the last message.') + throw error + } + await this.#watch(project, conversation) + } + + /** + * Goes on from where the turn stopped, with the model, tools, and instructions it started with, and clears what it + * waited on. The turn records the app version that goes on with it. + */ + async #continue(project: string): Promise { + const { store, version } = this.#o + const conversation = await this.#conversation(project) + await store.harness.commit(async (tx) => { + await conversation.submit(tx, { type: 'continue' }) + const waiting = await tx.doc(WaitingDoc, conversation.id) + waiting.waiting = null + const { turn } = await tx.doc(TurnDoc, conversation.id) + if (turn !== null) { + turn.app = version + waiting.saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[turn.model.provider] ?? 0 + } + }, context) + await this.#watch(project, conversation) + } + + /** + * Resolves once the person is signed in to `provider`, for a running tool: with `after`, signed in with a login saved + * after that many saves, as the one then saved was refused. Until then it holds the turn where it is, with the fix + * named on its chat, and goes on once they sign in. A stop ends the wait. A wait the tool held before a restart is + * let go once the sign-in is there. + */ + async loginFor(conversation: number, provider: string, waiting: Context, after: number | undefined): Promise { + const { models, store } = this.#o + const id = conversation as ConversationId + const name = models.getProvider(provider)?.name ?? provider + const project = await store.projectOf(conversation) + const hold = (wait: Waiting | null) => + store.harness.commit(async (tx) => { + const record = await tx.doc(WaitingDoc, id) + record.waiting = wait + record.tool = wait !== null + }, context) + const ready = async () => + after === undefined ? (await models.getAuth(provider).catch(() => undefined)) !== undefined : (await store.loginSaves(provider)) > after + const fix = + after === undefined ? `Sign in to ${name}. Astra picks up where it stopped.` : `${name} refused the saved sign-in. Sign in again. Astra picks up where it stopped.` + let held = (await store.harness.snapshot(WaitingDoc, id, context))?.tool ?? false + try { + for (;;) { + // The waiter is in place before the check, so a sign-in that lands between the two is not missed. + const { promise, resolve } = Promise.withResolvers() + const waiters = this.#signIns.get(provider) ?? new Set() + this.#signIns.set(provider, waiters.add(resolve)) + try { + if (await ready()) return + if (!held) { + held = true + await hold({ kind: 'login', provider, name, fix }) + await this.#show(project) + } + await awaitWithContext(promise, waiting) + } finally { + waiters.delete(resolve) + } + } + } finally { + if (held) { + await hold(null) + await this.#show(project) + } + } + } + + /** Sends the window a chat's state, for a change its watch does not see. */ + async #show(project: string): Promise { + await this.#changed(project, await this.#conversation(project)) + } + + async retry(project: string): Promise { + await this.#continue(project) + } + + async tools(project: string): Promise { + const agent = await (await this.#conversation(project)).agent(context) + return agent.tools.map((tool) => tool.name) + } + + /** Stops the turn. What it waited on is forgotten, so a later sign-in does not start it again. */ + async stop(project: string): Promise { + const conversation = await this.#conversation(project) + await conversation.abort(context) + await this.#o.store.harness.commit(async (tx) => { + const record = await tx.doc(WaitingDoc, conversation.id) + record.waiting = null + record.tool = false + }, context) + await this.#show(project) + } + + /** A turn that started on another version of the app waits for the person to go on with this one. */ + async #outdated(conversation: ConversationId): Promise { + await this.#o.store.harness.commit(async (tx) => { + const record = await tx.doc(WaitingDoc, conversation) + record.waiting = { kind: 'error', detail: `Materialize 3D was updated to ${this.#o.version} while this turn ran.`, fix: 'Go on' } + record.tool = false + }, context) + } + + /** + * After a sign-in: every tool and every turn that waited on that provider goes on, except a turn that started on + * another version of the app, which waits for the person to go on with this one. + */ + async signedIn(provider: string): Promise { + const { store, version } = this.#o + for (const resolve of this.#signIns.get(provider) ?? []) resolve() + for (const project of await store.projects()) { + const conversation = project.conversation as ConversationId + const waiting = await store.harness.snapshot(WaitingDoc, conversation, context) + if (waiting?.waiting?.kind !== 'login' || waiting.waiting.provider !== provider || waiting.tool) continue + const turn = (await store.harness.snapshot(TurnDoc, conversation, context))?.turn + if (turn !== null && turn !== undefined && turn.app !== version) { + await this.#outdated(conversation) + await this.#show(project.id) + } else { + await this.#continue(project.id) + } + } + } + + /** + * At start: a turn that ran on another version of the app stops, waiting for the person to go on with this one, + * instead of going on with code it did not start with. Then runs a crash cut off go on from their last committed + * step, every chat is watched, failures not yet read are read, and turns that waited on a login that is now there + * go on. + */ + async reconcile(): Promise { + const { store, models, version } = this.#o + const projects = await store.projects() + const outdated: Conversation[] = [] + for (const project of projects) { + const conversation = await this.#conversation(project.id) + const { running, turn } = await this.#state(conversation) + if (running && turn !== null && turn.app !== version) outdated.push(conversation) + } + await Promise.all( + outdated.map(async (conversation) => { + await conversation.abort(context) + await this.#outdated(conversation.id) + }), + ) + store.harness.resume() + for (const project of projects) { + const conversation = await this.#conversation(project.id) + await this.#watch(project.id, conversation) + const state = await this.#state(conversation) + const recorded = (await store.harness.snapshot(WaitingDoc, conversation.id, context))?.failure ?? null + if (state.failure !== undefined && state.failure.reason !== 'aborted' && state.failure.taskId !== recorded) { + await this.#read(conversation, state.failure, state.turn) + } + const record = await store.harness.snapshot(WaitingDoc, conversation.id, context) + const running = (await this.#state(conversation)).running + if (record?.tool && !running) { + // A tool held this wait, and its run is gone: nothing will let it go but this. + await store.harness.commit(async (tx) => { + const stale = await tx.doc(WaitingDoc, conversation.id) + stale.waiting = null + stale.tool = false + }, context) + continue + } + if (record === undefined || record.waiting?.kind !== 'login' || record.tool || running) continue + const waiting = record.waiting + if (state.turn !== null && state.turn.app !== version) await this.#outdated(conversation.id) + else if (await this.#savedSince(record, waiting.provider)) await this.#continue(project.id) + } + } + + /** + * Whether a login the turn did not go out with is there: one saved since it last went out, as a sign-in that a crash + * cut off before it let the turn go. The login it went out with was missing or refused, so trying it again cannot help. + */ + async #savedSince(record: { saves: number | null }, provider: string): Promise { + if (record.saves !== null) return (await this.#o.store.loginSaves(provider)) > record.saves + return (await this.#o.models.getAuth(provider).catch(() => undefined)) !== undefined + } + + async close(): Promise { + await Promise.all([...this.#watches.values()].map((watch) => watch.stop())) + } +} diff --git a/sidecar/src/inference.ts b/sidecar/src/inference.ts index 18e65b2..4b434f0 100644 --- a/sidecar/src/inference.ts +++ b/sidecar/src/inference.ts @@ -60,6 +60,14 @@ const credential = compile( ]), ) +/** + * A provider's answer that means the login itself is missing or refused, not that the request failed. A 403 alone is + * not one: it says the login works but lacks access, which a new sign-in to the same account does not change. + */ +const LOGIN_FAILURE = /not configured|\b401\b|unauthori[sz]ed|authentication|invalid (api )?(key|token)|expired/i + +export const isLoginFailure = (detail: string) => LOGIN_FAILURE.test(detail) + /** Which providers have a login. Not secret: it lives in the store. */ export interface LoginIndex { logins(): Promise> diff --git a/sidecar/src/main.ts b/sidecar/src/main.ts index e8ca4db..4578137 100644 --- a/sidecar/src/main.ts +++ b/sidecar/src/main.ts @@ -1,47 +1,3 @@ -import { parseArgs } from 'node:util' -import { createRegistry } from '@earendil-works/pi-durable' -import { Host } from './host' -import { inferenceModels, KeychainCredentials, Logins } from './inference' -import { handlers } from './methods' -import { EXIT } from './protocol' -import { Store, StoreInUse } from './store' +import { runSidecar } from './app' -// Stdout carries protocol lines only, so a library that logs cannot corrupt the pipe. -console.log = console.error -console.info = console.error -console.debug = console.error - -const { values } = parseArgs({ - options: { - 'app-version': { type: 'string' }, - data: { type: 'string' }, - projects: { type: 'string' }, - }, - strict: true, -}) -const { data, projects } = values -if (data === undefined || projects === undefined) throw new Error('usage: --data --projects ') -const version = values['app-version'] ?? 'dev' - -const host = new Host((line) => process.stdout.write(line)) -// The credentials keep their index in the store, which needs the models to open, so the index is reached late. -const credentials = new KeychainCredentials(host, { - logins: () => store.logins(), - setLogin: (provider, kind) => store.setLogin(provider, kind), -}) -const models = inferenceModels(credentials) - -let store: Store -try { - store = await Store.open({ dataDir: data, projectsDir: projects, models, registry: createRegistry() }) -} catch (error) { - if (!(error instanceof StoreInUse)) throw error - console.error(error.message) - process.exit(EXIT.storeInUse) -} - -const logins = new Logins(models, credentials, (event) => host.emit(event)) -host.emit({ type: 'ready', version }) -await host.serve(handlers(store, logins, version), process.stdin) -await store.close() -process.exit(EXIT.done) +await runSidecar({ argv: process.argv.slice(2) }) diff --git a/sidecar/src/methods.ts b/sidecar/src/methods.ts index 2b7e9b7..6e1beb5 100644 --- a/sidecar/src/methods.ts +++ b/sidecar/src/methods.ts @@ -1,8 +1,16 @@ import { arr, bool, compile, lit, obj, opt, str, union } from '@earendil-works/pi-schema' +import type { Chats } from './chat' import type { Handlers } from './host' import type { Logins } from './inference' import type { Store } from './store' +export interface Services { + store: Store + logins: Logins + chats: Chats + version: string +} + const step = union([lit('welcome'), lit('check'), lit('model'), lit('printer'), lit('ready')]) const thinking = union([lit('off'), lit('minimal'), lit('low'), lit('medium'), lit('high'), lit('xhigh'), lit('max')]) const model = obj({ provider: str(), modelId: str() }) @@ -10,7 +18,7 @@ const printer = obj({ machine: str(), process: str(), filament: str() }) const provider = union([lit('anthropic'), lit('openai-codex'), lit('opencode-go')]) /** Every method the window can call, each parsed at the pipe before it runs. */ -export function handlers(store: Store, logins: Logins, version: string): Handlers { +export function handlers({ store, logins, chats, version }: Services): Handlers { return { 'app.ping': { params: compile(obj({})), run: async () => ({ version }) }, 'state.get': { params: compile(obj({})), run: () => store.state() }, @@ -65,5 +73,28 @@ export function handlers(store: Store, logins: Logins, version: string): Handler return logins.status() }, }, + 'chat.open': { params: compile(obj({ project: str() })), run: ({ project }) => chats.open(project) }, + 'chat.send': { + params: compile(obj({ project: str(), requestId: str({ minLength: 1 }), text: str(), attachments: arr(str()) })), + run: async ({ project, requestId, text, attachments }) => { + await chats.send(project, requestId, text, attachments) + return null + }, + }, + 'chat.stop': { + params: compile(obj({ project: str() })), + run: async ({ project }) => { + await chats.stop(project) + return null + }, + }, + 'chat.retry': { + params: compile(obj({ project: str() })), + run: async ({ project }) => { + await chats.retry(project) + return null + }, + }, + 'agent.tools': { params: compile(obj({ project: str() })), run: ({ project }) => chats.tools(project) }, } } diff --git a/sidecar/src/protocol.ts b/sidecar/src/protocol.ts index 9f26d73..fa38cb5 100644 --- a/sidecar/src/protocol.ts +++ b/sidecar/src/protocol.ts @@ -48,6 +48,31 @@ export type LoginEvent = | { type: 'login'; provider: ProviderId; step: 'done' } | { type: 'login'; provider: ProviderId; step: 'failed'; detail: string } +/** One committed line of a chat. Tool calls fold under their assistant line; each result is its own line. */ +export type ChatEntry = + | { id: number; kind: 'user'; text: string; images: string[] } + | { id: number; kind: 'assistant'; text: string; calls: { id: string; name: string; args: unknown }[] } + | { id: number; kind: 'tool'; callId: string; name: string; text: string; failed: boolean } + +/** Why a turn stopped before its answer, and the one thing that lets it go on. */ +export type Waiting = + | { kind: 'login'; provider: string; name: string; fix: string } + | { kind: 'error'; detail: string; fix: string } + +/** What a turn runs with, frozen when it was sent. */ +export type Turn = { + requestId: string + model: ModelChoice + thinking: Thinking + tools: string[] + instructions: string + app: string + startedMs: number +} + +/** A chat as the window shows it: committed entries, and whether a turn runs, waits, or neither. */ +export type ChatView = { project: string; entries: ChatEntry[]; running: boolean; waiting: Waiting | null; turn: Turn | null } + export type AppState = { setup: Setup; settings: Settings; projects: Project[]; drafts: Record } /** What the window can ask the sidecar, by method name. */ @@ -64,6 +89,13 @@ export interface Methods { 'auth.login': { params: { provider: ProviderId }; result: null } 'auth.answer': { params: { provider: ProviderId; text: string }; result: null } 'auth.logout': { params: { provider: ProviderId }; result: Login[] } + 'chat.open': { params: { project: string }; result: ChatView } + /** `requestId` is the window's own id for this message: sending it again never sends twice. */ + 'chat.send': { params: { project: string; requestId: string; text: string; attachments: string[] }; result: null } + 'chat.stop': { params: { project: string }; result: null } + 'chat.retry': { params: { project: string }; result: null } + /** The tools a project's agent is offered, as Pi resolves them. */ + 'agent.tools': { params: { project: string }; result: string[] } } export type Method = keyof Methods @@ -78,8 +110,12 @@ export interface HostRequests { export type HostRequest = keyof HostRequests -/** Paths are relative to the project folder, which Rust resolves from the project id. */ +/** + * Paths are relative to the project folder, which Rust resolves from the project id. Rust slices once per operation: + * a request that repeats one waits for the first and gets its result. + */ export interface SliceParams { + operation: string project: string inputs: string[] output: string @@ -96,6 +132,7 @@ export interface SliceResult { export type SidecarEvent = | { type: 'ready'; version: string } | LoginEvent + | ({ type: 'chat' } & ChatView) | { type: 'engine'; state: 'restarting' | 'stopped'; detail: string } /** Rust to sidecar. */ diff --git a/sidecar/src/store.ts b/sidecar/src/store.ts index 39b3bf3..7ef1e85 100644 --- a/sidecar/src/store.ts +++ b/sidecar/src/store.ts @@ -9,10 +9,10 @@ import { join } from 'node:path' import { DatabaseSync } from 'node:sqlite' import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context' import type { Models } from '@earendil-works/pi-ai' -import { type Conversation, defineDoc, Harness, type Registry } from '@earendil-works/pi-durable' +import { type Conversation, defineDoc, type HarnessOptions, Harness, type Registry } from '@earendil-works/pi-durable' import { openNodeSqliteStorage } from '@earendil-works/pi-durable/storage/sqlite/node' import type { LoginIndex } from './inference' -import type { AppState, Draft, LoginKind, Project, Settings, Setup } from './protocol' +import type { AppState, Draft, LoginKind, Project, Settings, Setup, Turn, Waiting } from './protocol' const context = BACKGROUND_CONTEXT @@ -28,7 +28,7 @@ const SetupDoc = defineDoc({ checkpointWhen: always, }) -const SettingsDoc = defineDoc({ +export const SettingsDoc = defineDoc({ kind: 'm3d.settings', version: 1, scope: 'conversation', @@ -66,6 +66,44 @@ export const DraftsDoc = defineDoc<{ drafts: Record }>({ checkpointWhen: always, }) +/** What a project's current turn runs with, frozen in the commit that sent it. */ +export const TurnDoc = defineDoc<{ turn: Turn | null }>({ + kind: 'm3d.turn', + version: 1, + scope: 'conversation', + history: 'latest', + fork: 'initial', + initial: () => ({ turn: null }), + checkpointWhen: always, +}) + +/** + * Why a project's turn waits, and the run failure that was read, so one failure is read once. `tool` marks a wait a + * running tool holds, such as Astra's for a sign-in: the tool goes on by itself, so nothing continues the run. Cleared + * in the commit that sends, retries, or resumes, or by the tool once its wait ends. `saves` is how many times the + * turn's provider login was saved when the turn last went out, so a refusal can tell a login saved since then. + */ +export const WaitingDoc = defineDoc<{ waiting: Waiting | null; failure: number | null; tool: boolean; saves: number | null }>({ + kind: 'm3d.waiting', + version: 1, + scope: 'conversation', + history: 'latest', + fork: 'initial', + initial: () => ({ waiting: null, failure: null, tool: false, saves: null }), + checkpointWhen: always, +}) + +/** How many times each provider's login was saved, so a wait can tell a new sign-in from the one a provider refused. */ +export const LoginSavesDoc = defineDoc<{ saves: Record }>({ + kind: 'm3d.login-saves', + version: 1, + scope: 'conversation', + history: 'latest', + fork: 'initial', + initial: () => ({ saves: {} }), + checkpointWhen: always, +}) + /** Which providers have a login in the Keychain. Never the secret itself. */ const LoginsDoc = defineDoc<{ logins: Record }>({ kind: 'm3d.logins', @@ -114,6 +152,8 @@ export interface StoreOptions { projectsDir: string models: Models registry: Registry + /** The files and processes a conversation's tools work in. */ + env: NonNullable } export class Store implements LoginIndex { @@ -133,7 +173,7 @@ export class Store implements LoginIndex { mkdirSync(options.dataDir, { recursive: true }) const lock = own(options.dataDir) const storage = await openStorage(join(options.dataDir, 'store.sqlite')) - const harness = await Harness.open(storage, { models: options.models, registry: options.registry }, context) + const harness = await Harness.open(storage, { models: options.models, registry: options.registry, env: options.env }, context) const root = await harness.root(context) return new Store(lock, harness, root, options.projectsDir) } @@ -210,6 +250,22 @@ export class Store implements LoginIndex { return project } + async projects(): Promise { + return (await this.harness.snapshot(ProjectsDoc, this.root.id, context))?.projects ?? [] + } + + async project(id: string): Promise { + const project = (await this.projects()).find((p) => p.id === id) + if (project === undefined) throw new Error(`no project ${id}`) + return project + } + + async projectOf(conversation: number): Promise { + const project = (await this.projects()).find((p) => p.conversation === conversation) + if (project === undefined) throw new Error(`conversation ${conversation} is no project's`) + return project.id + } + async logins(): Promise> { return (await this.harness.snapshot(LoginsDoc, this.root.id, context))?.logins ?? {} } @@ -218,10 +274,19 @@ export class Store implements LoginIndex { await this.harness.commit(async (tx) => { const { logins } = await tx.doc(LoginsDoc, this.root.id) if (kind === undefined) delete logins[provider] - else logins[provider] = kind + else { + logins[provider] = kind + const { saves } = await tx.doc(LoginSavesDoc, this.root.id) + saves[provider] = (saves[provider] ?? 0) + 1 + } }, context) } + /** How many times the provider's login was saved. */ + async loginSaves(provider: string): Promise { + return (await this.harness.snapshot(LoginSavesDoc, this.root.id, context))?.saves[provider] ?? 0 + } + /** The project's folder, made if it is missing. */ projectFolder(id: string): string { const dir = join(this.projectsDir, id) diff --git a/sidecar/src/text.d.ts b/sidecar/src/text.d.ts new file mode 100644 index 0000000..876c62b --- /dev/null +++ b/sidecar/src/text.d.ts @@ -0,0 +1,4 @@ +declare module '*.md' { + const text: string + export default text +} diff --git a/sidecar/src/transcript.ts b/sidecar/src/transcript.ts new file mode 100644 index 0000000..d2dc46b --- /dev/null +++ b/sidecar/src/transcript.ts @@ -0,0 +1,42 @@ +/** + * The chat the window shows, read from committed entries only. A partial answer or running output is never shown, so + * nothing on screen can vanish after a crash. + */ +import type { EntryRecord } from '@earendil-works/pi-durable' +import type { ChatEntry } from './protocol' + +type Content = string | readonly ({ type: string; text?: string } | { type: 'toolCall'; id: string; name: string; arguments: unknown })[] + +/** The text of a message, whether it is a string or blocks. */ +export function textOf(content: Content | undefined): string { + if (content === undefined) return '' + if (typeof content === 'string') return content + return content.flatMap((block) => ('text' in block && typeof block.text === 'string' ? [block.text] : [])).join('\n') +} + +/** One committed entry as the chat shows it, or nothing for entries only the model reads. */ +export function chatEntry(entry: EntryRecord): ChatEntry | undefined { + const message = entry.model?.[0] + switch (entry.kind) { + case 'm3d.user': { + const data = entry.data as { text: string; images: string[] } + return { id: entry.id, kind: 'user', text: data.text, images: data.images } + } + case 'pi.assistant': { + if (message?.role !== 'assistant') return undefined + const calls = message.content.flatMap((block) => + block.type === 'toolCall' ? [{ id: block.id, name: block.name, args: block.arguments }] : [], + ) + const text = textOf(message.content) + // A request that failed leaves an empty answer behind. The waiting state says what happened. + if (text === '' && calls.length === 0) return undefined + return { id: entry.id, kind: 'assistant', text, calls } + } + case 'pi.tool-result': { + if (message?.role !== 'toolResult') return undefined + return { id: entry.id, kind: 'tool', callId: message.toolCallId, name: message.toolName, text: textOf(message.content), failed: message.isError } + } + default: + return undefined + } +} diff --git a/sidecar/src/workflow.md b/sidecar/src/workflow.md new file mode 100644 index 0000000..74c731c --- /dev/null +++ b/sidecar/src/workflow.md @@ -0,0 +1,31 @@ +# Materialize 3D + +You are working inside Materialize 3D, a desktop app. Ossie talks to you here to design parts he prints on his Bambu Lab P2S. The end of every job is a print-ready 3MF file. Everything else supports that. + +Your working directory is this chat's project folder, and your file tools reach only that folder. The app shows Ossie every file you write here: + +- An HTML file renders as a page in the app's preview panel, scripts included. Inline your scripts (for example three.js) or put them beside the page with relative paths. The preview blocks the internet, so a page loads only files in this folder and code inlined in it. +- An STL, 3MF, GLB, or glTF file opens in a 3D orbit viewer. +- PNG, JPG, and SVG files show as pictures. +- The newest file opens on its own, so write the file you want Ossie to look at last. + +Pictures Ossie drops into the chat are saved in `refs/` and attached to his message. Use them as references for shape and measurements. + +## Steps + +1. **Parameters.** Write the design as numbers in `params.json`. Mark a measurement you do not know as `"TBD"` with your best estimate beside it, so the person does not have to measure before they see anything. Ask only for what you cannot estimate. +2. **Model and fit checks.** Write Python build scripts in `build/` that read `params.json`. Build the parts with manifold3d, through the `run` tool: `run` with `["run", "--with", "manifold3d", "--with", "numpy", "python", "build/model.py"]`. Check that the parts are manifold, that parts that should not touch do not overlap, and that clearances match the parameters. Say what you checked and the numbers. +3. **Preview.** Export the parts to STL (or GLB) in the project folder so the person can turn them in the viewer. When they ask to see a model before the 3MF, this is the step they mean. +4. **HTML guide.** Write a self-contained HTML page: a technical drawing with dimensions, an interactive three.js assembly guide, or both, as the job needs. +5. **Slice to 3MF.** Slice the STL files with the `slice` tool into `print/`, for example `inputs: ["cube.stl"]`, `output: "print/cube_P2S_PETG.3mf"`. It slices with the validated Bambu Studio for the P2S with a 0.4 mm nozzle, standard 0.20 mm layers, and PETG, and says the print time and filament. If it says Bambu Studio is not installed, say so plainly and leave the STL files ready. + +Keep each step's script, so a change to `params.json` rebuilds everything. + +## Astra, the GPT helper + +You can hand a task to Astra with the `astra` tool. Astra suits drawings and second opinions. Astra works in this same folder with the same file tools, but cannot see this chat, so the task must say everything it needs: what to do, the files to read, and the file to write. Check what Astra writes before you show it. + +## Rules + +- Keep your messages short and plain. Lead with what changed and what Ossie should look at. +- Never invent measurements for parts Ossie owns. Estimate, mark it TBD, and say so. diff --git a/sidecar/test/agent.test.ts b/sidecar/test/agent.test.ts new file mode 100644 index 0000000..d03d26f --- /dev/null +++ b/sidecar/test/agent.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from 'bun:test' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ChatEntry, ChatView } from '../src/protocol' +import { spawnSidecar } from './sidecar' + +type App = ReturnType + +/** A scratch store, project root, and a `uv` that runs what follows `uv run`, so tests need neither uv nor Python. */ +function scratch() { + const dir = mkdtempSync(join(tmpdir(), 'm3d-agent-')) + const bin = join(dir, 'bin') + mkdirSync(bin) + writeFileSync(join(bin, 'uv'), '#!/bin/sh\n[ "$1" = run ] && shift\nexec "$@"\n') + chmodSync(join(bin, 'uv'), 0o755) + return { data: join(dir, 'state'), projects: join(dir, 'projects'), env: { PATH: `${bin}:${process.env.PATH}` } } +} + +function start(where: ReturnType, script: string): App { + return spawnSidecar(where.data, where.projects, new Map(), { script, env: where.env }) +} + +async function newProject(app: App, modelId = 'faux-a'): Promise { + await app.call('settings.set', { model: { provider: 'faux', modelId } }) + const { id } = (await app.call('projects.create', { title: 'A 20 mm cube' })) as { id: string } + await app.call('chat.open', { project: id }) + return id +} + +const hasAnswer = (entries: ChatEntry[], text: string) => entries.some((entry) => entry.kind === 'assistant' && entry.text === text) + +/** Waits until the chat holds this answer: already in the committed chat, or in a later change. */ +async function answered(app: App, project: string, text: string): Promise { + const view = (await app.call('chat.open', { project })) as ChatView + if (hasAnswer(view.entries, text)) return + await app.until((event) => event.type === 'chat' && event.project === project && hasAnswer(event.entries as ChatEntry[], text)) +} + +async function waitForFile(path: string, ms = 15_000) { + const deadline = Date.now() + ms + while (!existsSync(path)) { + if (Date.now() > deadline) throw new Error(`${path} never appeared`) + await Bun.sleep(25) + } +} + +const send = (app: App, project: string, requestId: string, text: string) => app.call('chat.send', { project, requestId, text, attachments: [] }) + +describe('a send', () => { + test('commits the message, the settings its turn runs with, and the draft removal together, and only once', async () => { + const where = scratch() + const app = start(where, 'models') + await app.next() + const project = await newProject(app) + await app.call('drafts.set', { key: project, text: 'A 20 mm cube', attachments: [] }) + + await send(app, project, 'r1', 'A 20 mm cube') + expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({}) + const view = (await app.call('chat.open', { project })) as ChatView + expect(view.entries[0]).toMatchObject({ kind: 'user', text: 'A 20 mm cube' }) + expect(view.turn).toMatchObject({ + requestId: 'r1', + model: { provider: 'faux', modelId: 'faux-a' }, + thinking: 'high', + tools: ['read', 'write', 'edit', 'run', 'slice', 'astra'], + app: '9.9.9', + }) + await answered(app, project, 'answered by faux-a') + + await send(app, project, 'r1', 'A 20 mm cube') + const again = (await app.call('chat.open', { project })) as ChatView + expect(again.entries.filter((entry) => entry.kind === 'user')).toHaveLength(1) + + // A send while a turn runs is refused whole: no message, and the draft stays. + await send(app, project, 'r2', 'please wait') + await waitForFile(join(where.projects, project, 'started')) + await app.call('drafts.set', { key: project, text: 'make it red', attachments: [] }) + await expect(send(app, project, 'r3', 'make it red')).rejects.toThrow('The agent is still working on the last message.') + expect(((await app.call('state.get')) as { drafts: Record }).drafts[project]).toEqual({ text: 'make it red', attachments: [] }) + const refused = (await app.call('chat.open', { project })) as ChatView + expect(refused.entries.filter((entry) => entry.kind === 'user').map((entry) => entry.kind === 'user' && entry.text)).toEqual(['A 20 mm cube', 'please wait']) + writeFileSync(join(where.projects, project, 'go'), '') + await answered(app, project, 'answered by faux-a') + await app.quit() + }) +}) + +describe('a crash mid-turn', () => { + test('the reopened sidecar finishes the turn and never repeats a finished tool call', async () => { + const where = scratch() + const first = start(where, 'ops') + await first.next() + const project = await newProject(first) + await send(first, project, 'r1', 'Build it') + const log = join(where.projects, project, 'ops.log') + await waitForFile(log) + while (!readFileSync(log, 'utf8').includes('op2')) await Bun.sleep(25) + first.child.kill('SIGKILL') + await first.child.exited + // The watch on op2 ends it within a second of the crash, before `go` would let it finish. + await Bun.sleep(1500) + + const reopened = start(where, 'ops') + await reopened.next() + await reopened.call('chat.open', { project }) + writeFileSync(join(where.projects, project, 'go'), '') + await answered(reopened, project, 'done') + + // op1 finished before the crash and ran once. op2 was cut off mid-run, so it ran again from its start. + expect(readFileSync(log, 'utf8').trim().split('\n')).toEqual(['op1', 'op2', 'op2']) + const view = (await reopened.call('chat.open', { project })) as ChatView + expect(view.entries.map((entry) => entry.kind)).toEqual(['user', 'assistant', 'tool', 'assistant', 'tool', 'assistant']) + expect(view.running).toBe(false) + await reopened.quit() + }, 20_000) +}) + +describe('settings', () => { + test('a change mid-turn leaves the running turn alone, and the next turn uses it', async () => { + const where = scratch() + const app = start(where, 'models') + await app.next() + const project = await newProject(app, 'faux-a') + await send(app, project, 'r1', 'please wait') + await waitForFile(join(where.projects, project, 'started')) + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-b' } }) + writeFileSync(join(where.projects, project, 'go'), '') + await answered(app, project, 'answered by faux-a') + expect(((await app.call('chat.open', { project })) as ChatView).turn?.model.modelId).toBe('faux-a') + + await send(app, project, 'r2', 'and now?') + await answered(app, project, 'answered by faux-b') + expect(((await app.call('chat.open', { project })) as ChatView).turn?.model.modelId).toBe('faux-b') + await app.quit() + }) +}) + +describe('the agent', () => { + test('is offered only the app tools, and they never reach outside the project folder', async () => { + const where = scratch() + const app = start(where, 'escape') + await app.next() + const project = await newProject(app) + expect(await app.call('agent.tools', { project })).toEqual(['read', 'write', 'edit', 'run', 'slice', 'astra']) + await send(app, project, 'r1', 'read the hostname') + await answered(app, project, 'tried') + const view = (await app.call('chat.open', { project })) as ChatView + const read = view.entries.find((entry) => entry.kind === 'tool') + expect(read).toMatchObject({ kind: 'tool', name: 'read', failed: true }) + expect(read?.kind === 'tool' && read.text).toContain('outside the project folder') + await app.quit() + }) +}) diff --git a/sidecar/test/fixtures/scripted-sidecar.ts b/sidecar/test/fixtures/scripted-sidecar.ts new file mode 100644 index 0000000..0001e5f --- /dev/null +++ b/sidecar/test/fixtures/scripted-sidecar.ts @@ -0,0 +1,128 @@ +/** + * The sidecar with a scripted model beside the app's providers, for tests: provider `faux`, models `faux-a` and + * `faux-b`. `M3D_SCRIPT` picks the script. Each answer depends only on the transcript, so a sidecar that restarts + * mid-turn goes on with the same script. OpenAI Codex, Astra's provider, is replaced by a scripted Astra that answers + * only once a key is saved for it, as the real one answers only once the person signs in. + */ +import { existsSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { type AssistantMessage, fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall, type Message, type Model } from '@earendil-works/pi-ai' +import type { JsonObject } from '@earendil-works/pi-durable' +import { runSidecar } from '../../src/app' + +/** The messages since the person's newest message, and that message's text. */ +function sinceUser(messages: Message[]) { + const at = messages.findLastIndex((m) => m.role === 'user') + const user = messages[at] + const text = user === undefined ? '' : typeof user.content === 'string' ? user.content : user.content.map((b) => ('text' in b ? b.text : '')).join('') + return { text, results: messages.slice(at + 1).filter((m) => m.role === 'toolResult') } +} + +/** A step that calls one tool. Its stop reason tells Pi the model waits for the result. */ +const call = (name: string, args: JsonObject) => fauxAssistantMessage(fauxToolCall(name, args), { stopReason: 'toolUse' }) +const run = (script: string) => call('run', { args: ['run', 'sh', '-c', script] }) +const resultText = (message: Message | undefined) => + message?.role === 'toolResult' ? message.content.map((b) => ('text' in b ? b.text : '')).join('') : '' + +const scripts: Record) => AssistantMessage> = { + /** Two outside actions, then an answer. The second waits for a `go` file, so a test can crash the sidecar in it. */ + ops: (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return run('echo op1 >> ops.log') + if (results.length === 1) return run('echo op2 >> ops.log; while [ ! -f go ]; do sleep 0.05; done') + return fauxAssistantMessage(fauxText('done')) + }, + /** A message with "wait" runs a tool that waits for `go`; every answer names the model that gave it. */ + models: (messages, model) => { + const { text, results } = sinceUser(messages) + if (text.includes('wait') && results.length === 0) return run('touch started; while [ ! -f go ]; do sleep 0.05; done') + return fauxAssistantMessage(fauxText(`answered by ${model.id}`)) + }, + /** Hands Astra a task, then repeats what Astra answered. */ + astra: (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return call('astra', { task: 'Draw it as an HTML page.' }) + return fauxAssistantMessage(fauxText(`parent saw: ${resultText(results[0])}`)) + }, + /** Shows the command's environment: whether the sidecar's own variables reach it, and its operation id. */ + env: (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return run('echo "secret=${M3D_SECRET:-none} op=${M3D_OPERATION_ID:+set}"') + return fauxAssistantMessage(fauxText(resultText(results[0]))) + }, + /** A command that marks its start, then runs for three seconds, long enough for a test to kill the sidecar first. */ + orphan: (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return run('touch started; sleep 3; touch finished') + return fauxAssistantMessage(fauxText('ran')) + }, + /** + * A command that ends a moment after it starts, so a sidecar killed at its start restarts while it still runs. It + * prints its result after that kill. + */ + quick: (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return run('touch started; sleep 0.5; echo ran >> ran.log; echo measured-value=42') + return fauxAssistantMessage(fauxText('ran')) + }, + /** A command that runs past its one-second timeout. */ + slow: (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return call('run', { args: ['run', 'sh', '-c', 'sleep 3; touch finished'], timeoutSeconds: 1 }) + return fauxAssistantMessage(fauxText('timed')) + }, + /** Reads, writes, and edits through `linked`, which a test points outside the project. */ + linked: (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return call('read', { path: 'linked/secret.txt' }) + if (results.length === 1) return call('write', { path: 'linked/new.txt', content: 'x' }) + if (results.length === 2) return call('edit', { path: 'linked/secret.txt', edits: [{ oldText: 'secret', newText: 'leaked' }] }) + if (results.length === 3) return call('write', { path: 'dangling', content: 'x' }) + if (results.length === 4) return call('write', { path: 'climb', content: 'x' }) + return fauxAssistantMessage(fauxText('tried')) + }, + /** A tool that waits for `go`, then a model request that fails. */ + 'fails-after': (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return run('touch started; while [ ! -f go ]; do sleep 0.05; done') + return fauxAssistantMessage([], { stopReason: 'error', errorMessage: 'the model broke' }) + }, + /** Tries to read a file outside the project folder, then reports what the tool said. */ + escape: (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return call('read', { path: '/etc/hostname' }) + return fauxAssistantMessage(fauxText('tried')) + }, +} + +const script = scripts[process.env.M3D_SCRIPT ?? ''] +if (script === undefined) throw new Error(`M3D_SCRIPT must be one of ${Object.keys(scripts).join(', ')}`) + +await runSidecar({ + argv: process.argv.slice(2), + addProviders: (models) => { + const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-a' }, { id: 'faux-b' }] }) + faux.setResponses(Array.from({ length: 200 }, () => (context, _options, _state, model) => script(context.messages, model))) + models.setProvider(faux.provider) + const astra = fauxProvider({ provider: 'openai-codex', models: [{ id: 'gpt-6-astra' }] }) + // Astra runs a command of its own first, in the project folder it shares. The provider refuses the key `refused`, + // and with M3D_ASTRA_GATE set, it holds that request open until the test writes `release` there. + const gate = process.env.M3D_ASTRA_GATE + astra.setResponses( + Array.from({ length: 50 }, () => async (context, options) => { + if (options?.apiKey === 'refused') { + if (gate !== undefined) { + writeFileSync(join(gate, 'asked'), '') + while (!existsSync(join(gate, 'release'))) await Bun.sleep(25) + } + return fauxAssistantMessage([], { stopReason: 'error', errorMessage: '401 Unauthorized: invalid token' }) + } + return sinceUser(context.messages).results.length === 0 ? run('echo astra-ran > astra.txt') : fauxAssistantMessage(fauxText('astra drew it')) + }), + ) + models.setProvider({ + ...astra.provider, + auth: { apiKey: { name: 'Astra test key', resolve: async ({ credential }) => (credential?.key === undefined ? undefined : { auth: { apiKey: credential.key } }) } }, + }) + }, +}) diff --git a/sidecar/test/inference.test.ts b/sidecar/test/inference.test.ts index 64e1c6b..ea792e5 100644 --- a/sidecar/test/inference.test.ts +++ b/sidecar/test/inference.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { InMemoryCredentialStore, type OAuthAuth } from '@earendil-works/pi-ai' -import { inferenceModels, KeychainCredentials, keyCredential, Logins } from '../src/inference' +import { inferenceModels, isLoginFailure, KeychainCredentials, keyCredential, Logins } from '../src/inference' import type { LoginEvent, LoginKind } from '../src/protocol' import { spawnSidecar } from './sidecar' @@ -21,7 +21,7 @@ describe('logins', () => { expect(JSON.parse(keychain.get('anthropic') ?? '{}')).toEqual({ type: 'api_key', key: TOKEN, env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317' } }) await app.quit() - const stderr = await new Response(app.child.stderr).text() + const stderr = await app.stderr() expect(stderr).not.toContain(TOKEN) const files = readdirSync(join(dir, 'state')) expect(files).toContain('store.sqlite') @@ -51,6 +51,17 @@ describe('logins', () => { }) }) +describe('a refused request', () => { + test('waits for a sign-in only when the login itself was refused, not when the login lacks access', () => { + expect(isLoginFailure('401 {"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}')).toBe(true) + expect(isLoginFailure('Anthropic is not configured')).toBe(true) + expect(isLoginFailure('OAuth token expired')).toBe(true) + expect( + isLoginFailure('403 {"type":"error","error":{"type":"permission_error","message":"Your API key does not have permission to use the specified resource."}}'), + ).toBe(false) + }) +}) + describe('subscription sign-ins', () => { /** The app's models with Anthropic's sign-in replaced by `login`, and every login event the window would get. */ function withSignIn(login: OAuthAuth['login']) { diff --git a/sidecar/test/pipe.test.ts b/sidecar/test/pipe.test.ts index 28bd6ef..b3b1d36 100644 --- a/sidecar/test/pipe.test.ts +++ b/sidecar/test/pipe.test.ts @@ -75,7 +75,7 @@ describe('the pipe to Rust', () => { input.write(`${JSON.stringify({ kind: 'answer', id: 2, ok: false, error: 'Keychain is locked' })}\n`) await expect(refused).rejects.toThrow('Keychain is locked') - const open = host.request('slice', { project: 'p', inputs: ['a.stl'], output: 'print/a.3mf' }) + const open = host.request('slice', { operation: 'op', project: 'p', inputs: ['a.stl'], output: 'print/a.3mf' }) input.end() await serving await expect(open).rejects.toThrow('the app closed the pipe') diff --git a/sidecar/test/sidecar.ts b/sidecar/test/sidecar.ts index 74d46e4..5f5a5d9 100644 --- a/sidecar/test/sidecar.ts +++ b/sidecar/test/sidecar.ts @@ -1,29 +1,29 @@ +type Message = Record + /** * The sidecar as Rust runs it: a child process spoken to over stdin and stdout, one JSON object per line. The test - * plays Rust: it answers the sidecar's Keychain requests from `keychain`, which stands in for the macOS Keychain. + * plays Rust. Like Rust, it reads stdout all the time, since the sidecar stops when no one drains it, and it answers + * the sidecar's Keychain requests from `keychain`, which stands in for the macOS Keychain. `gate` may hold a request's + * answer, read when the request came, until it calls `answer`, as a slow Keychain does. */ -export function spawnSidecar(dataDir: string, projectsDir: string, keychain = new Map()) { - const child = Bun.spawn(['bun', 'src/main.ts', '--app-version', '9.9.9', '--data', dataDir, '--projects', projectsDir], { +export function spawnSidecar( + dataDir: string, + projectsDir: string, + keychain = new Map(), + options: { script?: string; env?: Record; version?: string; gate?: (request: Message, answer: () => void) => void } = {}, +) { + const entry = options.script === undefined ? 'src/main.ts' : 'test/fixtures/scripted-sidecar.ts' + const child = Bun.spawn(['bun', entry, '--app-version', options.version ?? '9.9.9', '--data', dataDir, '--projects', projectsDir], { cwd: `${import.meta.dir}/..`, + env: { ...process.env, ...options.env, ...(options.script === undefined ? {} : { M3D_SCRIPT: options.script }) }, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }) - const lines = (async function* () { - let buffered = '' - for await (const chunk of child.stdout.pipeThrough(new TextDecoderStream())) { - buffered += chunk - let end = buffered.indexOf('\n') - while (end >= 0) { - const message = JSON.parse(buffered.slice(0, end)) as Record - buffered = buffered.slice(end + 1) - end = buffered.indexOf('\n') - if (message.kind === 'request') answer(message) - else yield message - } - } - })() - function answer(request: Record) { + const send = (message: unknown) => child.stdin.write(`${JSON.stringify(message)}\n`) + + /** Reads the Keychain now, and returns how to send that answer, now or later. */ + function answer(request: Message): () => void { const params = request.params as { account: string; secret?: string } const value = request.method === 'keychain.get' @@ -33,19 +33,59 @@ export function spawnSidecar(dataDir: string, projectsDir: string, keychain = ne : request.method === 'keychain.delete' ? (keychain.delete(params.account), null) : undefined - send(value === undefined ? { kind: 'answer', id: request.id, ok: false, error: `no ${String(request.method)} here` } : { kind: 'answer', id: request.id, ok: true, value }) + return () => + send( + value === undefined + ? { kind: 'answer', id: request.id, ok: false, error: `no ${String(request.method)} here` } + : { kind: 'answer', id: request.id, ok: true, value }, + ) + } + + // Every line the sidecar writes, but its requests, waits here until a test reads it. `undefined` marks the end. + const inbox: (Message | undefined)[] = [] + let wake: (() => void) | undefined + void (async () => { + let buffered = '' + for await (const chunk of child.stdout.pipeThrough(new TextDecoderStream())) { + buffered += chunk + for (let end = buffered.indexOf('\n'); end >= 0; end = buffered.indexOf('\n')) { + const message = JSON.parse(buffered.slice(0, end)) as Message + buffered = buffered.slice(end + 1) + if (message.kind === 'request') { + const reply = answer(message) + if (options.gate === undefined) reply() + else options.gate(message, reply) + } + else inbox.push(message) + } + wake?.() + } + inbox.push(undefined) + wake?.() + })() + let errors = '' + const stderrDone = (async () => { + for await (const chunk of child.stderr.pipeThrough(new TextDecoderStream())) errors += chunk + })() + + /** The next message, waiting for it when there is none yet. */ + async function take(): Promise { + while (inbox.length === 0) await new Promise((resolve) => (wake = resolve)) + const message = inbox.shift() + if (message === undefined) inbox.unshift(undefined) + return message } + /** Messages a call read past while it waited for its reply, in order, for `next`. */ - const skipped: Record[] = [] - const next = async () => skipped.shift() ?? (await lines.next()).value - const send = (message: unknown) => child.stdin.write(`${JSON.stringify(message)}\n`) + const skipped: Message[] = [] + const next = async () => skipped.shift() ?? (await take()) let calls = 0 /** Calls one method and returns its value, or throws its error. */ const call = async (method: string, params: unknown = {}) => { const id = ++calls send({ kind: 'call', id, method, params }) for (;;) { - const message = (await lines.next()).value + const message = await take() if (message === undefined) throw new Error(`the sidecar ended before it answered ${method}`) if (message.kind !== 'reply' || message.id !== id) { skipped.push(message) @@ -55,9 +95,25 @@ export function spawnSidecar(dataDir: string, projectsDir: string, keychain = ne return message.value } } + /** Reads events until one matches, or fails after `ms`. */ + const until = async (match: (event: Message) => boolean, ms = 15_000) => { + const deadline = Date.now() + ms + for (;;) { + const message = await Promise.race([next(), Bun.sleep(Math.max(0, deadline - Date.now())).then(() => 'timeout' as const)]) + if (message === 'timeout') throw new Error('no matching event in time') + if (message === undefined) throw new Error('the sidecar ended first') + if (message.kind === 'event' && match(message.event as Message)) return message.event as Message + } + } const quit = async () => { child.stdin.end() return child.exited } - return { child, next, send, call, quit } + /** Everything the sidecar logged, once it has exited. */ + const stderr = async () => { + await child.exited + await stderrDone + return errors + } + return { child, next, send, call, until, quit, stderr } } diff --git a/sidecar/test/store.test.ts b/sidecar/test/store.test.ts index d8d4ab2..6aed12f 100644 --- a/sidecar/test/store.test.ts +++ b/sidecar/test/store.test.ts @@ -33,7 +33,7 @@ describe('one writer', () => { const second = spawnSidecar(data, projects) expect(await second.child.exited).toBe(EXIT.storeInUse) - expect(await new Response(second.child.stderr).text()).toContain('Another copy of Materialize 3D owns the store.') + expect(await second.stderr()).toContain('Another copy of Materialize 3D owns the store.') first.child.kill('SIGKILL') await first.child.exited diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts new file mode 100644 index 0000000..68d060e --- /dev/null +++ b/sidecar/test/tools.test.ts @@ -0,0 +1,353 @@ +import { describe, expect, test } from 'bun:test' +import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { logFollower, once, operationId, tailOf } from '../src/agent' +import type { ChatEntry, ChatView } from '../src/protocol' +import { spawnSidecar } from './sidecar' + +type App = ReturnType + +function scratch(env: Record = {}) { + const dir = mkdtempSync(join(tmpdir(), 'm3d-tools-')) + const bin = join(dir, 'bin') + mkdirSync(bin) + writeFileSync(join(bin, 'uv'), '#!/bin/sh\n[ "$1" = run ] && shift\nexec "$@"\n') + chmodSync(join(bin, 'uv'), 0o755) + return { dir, data: join(dir, 'state'), projects: join(dir, 'projects'), env: { PATH: `${bin}:${process.env.PATH}`, ...env } } +} + +const start = (where: ReturnType, script: string, version?: string) => + spawnSidecar(where.data, where.projects, new Map(), { script, env: where.env, ...(version === undefined ? {} : { version }) }) + +async function newProject(app: App) { + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + const { id } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } + await app.call('chat.open', { project: id }) + return id +} + +const send = (app: App, project: string, requestId: string, text = 'go') => app.call('chat.send', { project, requestId, text, attachments: [] }) + +/** Waits until the chat holds this answer, already committed or in a later change. */ +async function answered(app: App, project: string, text: string) { + const has = (entries: ChatEntry[]) => entries.some((entry) => entry.kind === 'assistant' && entry.text === text) + if (has(((await app.call('chat.open', { project })) as ChatView).entries)) return + await app.until((event) => event.type === 'chat' && event.project === project && has(event.entries as ChatEntry[]), 30_000) +} + +const toolResults = async (app: App, project: string) => + ((await app.call('chat.open', { project })) as ChatView).entries.flatMap((entry) => (entry.kind === 'tool' ? [entry] : [])) + +describe('an outside action', () => { + test('runs once per operation: a rerun takes the result Pi kept, or else the receipt file the action left', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-once-')) + let kept: { n: number } | undefined + const memo = { get: async () => kept, set: async (value: { n: number }) => (kept = value) } + let acted = 0 + const act = async () => ({ n: ++acted }) + + expect(await once(memo, join(dir, 'op1.json'), act)).toEqual({ n: 1 }) + expect(JSON.parse(readFileSync(join(dir, 'op1.json'), 'utf8'))).toEqual({ n: 1 }) + expect(await once(memo, join(dir, 'op1.json'), act)).toEqual({ n: 1 }) + + // A crash after the action and its receipt file, before Pi kept the result. + kept = undefined + expect(await once(memo, join(dir, 'op1.json'), act)).toEqual({ n: 1 }) + expect(await memo.get()).toEqual({ n: 1 }) + expect(acted).toBe(1) + }) + + test('calls whose ids differ only in characters a file name cannot hold keep separate receipts', () => { + expect(operationId('t1', 'call/a')).not.toBe(operationId('t1', 'call:a')) + expect(operationId('t1', 'call/a')).toBe(operationId('t1', 'call/a')) + // Some providers number their calls in each message, so a call id comes back in a later step: its task tells them apart. + expect(operationId('t1', 'call_0')).not.toBe(operationId('t2', 'call_0')) + }) +}) + +describe('the run tool', () => { + test('gets none of the sidecar environment but what commands need, plus its operation id', async () => { + const where = scratch({ M3D_SECRET: 'leak' }) + const app = start(where, 'env') + await app.next() + const project = await newProject(app) + await send(app, project, 'r1') + await answered(app, project, 'exit code 0\nsecret=none op=set\n') + // One operation: its receipt, its log, and its exit code. + const files = readdirSync(join(where.projects, project, '.m3d', 'ops')) + expect(new Set(files.map((name) => name.split('.')[0])).size).toBe(1) + await app.quit() + }) + + test('stops a command at its timeout, in seconds', async () => { + const where = scratch() + const app = start(where, 'slow') + await app.next() + const project = await newProject(app) + await send(app, project, 'r1') + await answered(app, project, 'timed') + expect((await toolResults(app, project))[0]).toMatchObject({ name: 'run', failed: true }) + await Bun.sleep(2500) + expect(existsSync(join(where.projects, project, 'finished'))).toBe(false) + await app.quit() + }, 20_000) + + test('ends with its sidecar, so a command a crash cut off never runs on beside the rerun', async () => { + const where = scratch() + const app = start(where, 'orphan') + await app.next() + const project = await newProject(app) + await send(app, project, 'r1') + while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(25) + app.child.kill('SIGKILL') + await app.child.exited + await Bun.sleep(5000) + expect(existsSync(join(where.projects, project, 'finished'))).toBe(false) + }, 20_000) + + test('runs once, with its output, when the sidecar restarts at once while a command cut off by its crash still ends', async () => { + const where = scratch() + const first = start(where, 'quick') + await first.next() + const project = await newProject(first) + await send(first, project, 'r1') + while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(10) + first.child.kill('SIGKILL') + await first.child.exited + const second = start(where, 'quick') + await second.next() + await answered(second, project, 'ran') + await Bun.sleep(1500) + expect(readFileSync(join(where.projects, project, 'ran.log'), 'utf8')).toBe('ran\n') + expect((await toolResults(second, project))[0]).toMatchObject({ name: 'run', failed: false, text: 'exit code 0\nmeasured-value=42\n' }) + await second.quit() + }, 30_000) +}) + +describe("a command's log", () => { + test('is followed from where the last read stopped, and a long burst or a recovery gets only its end', () => { + const log = join(mkdtempSync(join(tmpdir(), 'm3d-log-')), 'op.json.log') + const added = logFollower(log) + expect(added()).toBe('') + writeFileSync(log, 'first\n') + expect(added()).toBe('first\n') + expect(added()).toBe('') + appendFileSync(log, 'second\n') + expect(added()).toBe('second\n') + const burst = `${'x'.repeat(1_000_000)}end\n` + appendFileSync(log, burst) + const shown = added() + expect(shown).toHaveLength(20_000) + expect(shown.endsWith('xend\n')).toBe(true) + expect(added()).toBe('') + expect(tailOf(log)).toBe(shown) + expect(tailOf(join(log, '..', 'missing.log'))).toBe('') + }) +}) + +describe('the file tools', () => { + test('never follow a symbolic link out of the project', async () => { + const where = scratch() + const outside = join(where.dir, 'outside') + mkdirSync(outside) + writeFileSync(join(outside, 'secret.txt'), 'secret') + const app = start(where, 'linked') + await app.next() + const project = await newProject(app) + symlinkSync(outside, join(where.projects, project, 'linked')) + // A link to a file that does not exist yet: writing through it would make the file outside. + symlinkSync(join(outside, 'made-outside.txt'), join(where.projects, project, 'dangling')) + // A link to a file not made yet, through `linked` and then up: the system climbs from where `linked` leads. + symlinkSync('linked/../climbed.txt', join(where.projects, project, 'climb')) + await send(app, project, 'r1') + await answered(app, project, 'tried') + const results = await toolResults(app, project) + expect(results.map((r) => [r.name, r.failed])).toEqual([['read', true], ['write', true], ['edit', true], ['write', true], ['write', true]]) + for (const result of results) expect(result.text).toContain('outside the project folder') + expect(readFileSync(join(outside, 'secret.txt'), 'utf8')).toBe('secret') + expect(existsSync(join(outside, 'new.txt'))).toBe(false) + expect(existsSync(join(outside, 'made-outside.txt'))).toBe(false) + expect(existsSync(join(where.dir, 'climbed.txt'))).toBe(false) + await app.quit() + }) +}) + +describe('a picture sent with a message', () => { + test('never reads a file outside the project through a link', async () => { + const where = scratch() + const app = start(where, 'models') + await app.next() + const project = await newProject(app) + writeFileSync(join(where.dir, 'secret.jpg'), 'secret') + mkdirSync(join(where.projects, project, 'refs'), { recursive: true }) + symlinkSync(join(where.dir, 'secret.jpg'), join(where.projects, project, 'refs', 'photo.jpg')) + await expect(app.call('chat.send', { project, requestId: 'r1', text: 'like this', attachments: ['refs/photo.jpg'] })).rejects.toThrow( + 'is not a file in the project folder', + ) + await app.quit() + }) + + test('may have a name that starts with two dots, and a send that repeats needs no file', async () => { + const where = scratch() + const app = start(where, 'models') + await app.next() + const project = await newProject(app) + const photo = join(where.projects, project, '..photo.png') + writeFileSync(photo, 'png') + await app.call('chat.send', { project, requestId: 'r1', text: 'like this', attachments: ['..photo.png'] }) + await answered(app, project, 'answered by faux-a') + rmSync(photo) + await app.call('chat.send', { project, requestId: 'r1', text: 'like this', attachments: ['..photo.png'] }) + await app.quit() + }) + + test('that is not a regular file is refused, and never holds the sidecar', async () => { + const where = scratch() + const app = start(where, 'models') + await app.next() + const project = await newProject(app) + expect(Bun.spawnSync(['mkfifo', join(where.projects, project, 'pipe.png')]).exitCode).toBe(0) + await expect(app.call('chat.send', { project, requestId: 'r1', text: 'like this', attachments: ['pipe.png'] })).rejects.toThrow( + 'is not a file in the project folder', + ) + await app.quit() + }) +}) + +describe('Astra', () => { + test('without its sign-in, the turn waits with the fix named, and goes on once the person signs in', async () => { + const where = scratch() + const app = start(where, 'astra') + await app.next() + const project = await newProject(app) + await send(app, project, 'r1') + const waiting = await app.until((event) => event.type === 'chat' && event.waiting !== null) + expect(waiting.waiting).toEqual({ + kind: 'login', + provider: 'openai-codex', + name: 'openai-codex', + fix: 'Sign in to openai-codex. Astra picks up where it stopped.', + }) + expect(waiting.running).toBe(true) + await app.call('auth.setKey', { provider: 'openai-codex', key: 'astra-key' }) + await answered(app, project, 'parent saw: astra drew it') + expect(((await app.call('chat.open', { project })) as ChatView).waiting).toBeNull() + expect(readFileSync(join(where.projects, project, 'astra.txt'), 'utf8')).toBe('astra-ran\n') + await app.quit() + }) + + test('a sign-in the provider refuses makes the turn wait for a new one, instead of asking again and again', async () => { + const where = scratch() + const app = start(where, 'astra') + await app.next() + const project = await newProject(app) + await app.call('auth.setKey', { provider: 'openai-codex', key: 'refused' }) + await send(app, project, 'r1') + const waiting = await app.until((event) => event.type === 'chat' && event.waiting !== null, 30_000) + expect(waiting.waiting).toMatchObject({ kind: 'login', provider: 'openai-codex' }) + expect(waiting.running).toBe(true) + await app.call('auth.setKey', { provider: 'openai-codex', key: 'astra-key' }) + await answered(app, project, 'parent saw: astra drew it') + expect(((await app.call('chat.open', { project })) as ChatView).waiting).toBeNull() + await app.quit() + }, 60_000) + + test('a sign-in saved while a request it replaces was still out is not missed when that request is refused', async () => { + const gate = mkdtempSync(join(tmpdir(), 'm3d-astra-gate-')) + const where = scratch({ M3D_ASTRA_GATE: gate }) + const app = start(where, 'astra') + await app.next() + const project = await newProject(app) + await app.call('auth.setKey', { provider: 'openai-codex', key: 'refused' }) + await send(app, project, 'r1') + while (!existsSync(join(gate, 'asked'))) await Bun.sleep(25) + await app.call('auth.setKey', { provider: 'openai-codex', key: 'astra-key' }) + writeFileSync(join(gate, 'release'), '') + await answered(app, project, 'parent saw: astra drew it') + await app.quit() + }, 60_000) + + test('a wait a crash cut off is let go once the sign-in is there, and the turn finishes', async () => { + const where = scratch() + const keychain = new Map() + const first = spawnSidecar(where.data, where.projects, keychain, { script: 'astra', env: where.env }) + await first.next() + const project = await newProject(first) + await send(first, project, 'r1') + await first.until((event) => event.type === 'chat' && event.waiting !== null) + first.child.kill('SIGKILL') + await first.child.exited + + // The sign-in lands in the Keychain while the app is closed. + keychain.set('openai-codex', JSON.stringify({ type: 'api_key', key: 'astra-key' })) + const second = spawnSidecar(where.data, where.projects, keychain, { script: 'astra', env: where.env }) + await second.next() + await answered(second, project, 'parent saw: astra drew it') + expect(((await second.call('chat.open', { project })) as ChatView).waiting).toBeNull() + await second.quit() + }) +}) + +describe('a restart', () => { + test('watches every chat, so a resumed run that then fails waits with its fix even if no one opened it', async () => { + const where = scratch() + const first = start(where, 'fails-after') + await first.next() + const project = await newProject(first) + await send(first, project, 'r1') + while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(25) + first.child.kill('SIGKILL') + await first.child.exited + + const second = start(where, 'fails-after') + await second.next() + writeFileSync(join(where.projects, project, 'go'), '') + const waiting = await second.until((event) => event.type === 'chat' && event.waiting !== null, 60_000) + expect(waiting).toMatchObject({ project, running: false, waiting: { kind: 'error', fix: 'Try again' } }) + await second.quit() + }) + + test('after an update, a turn that waited on a sign-in from the old version waits for the person to go on', async () => { + const where = scratch() + const first = start(where, 'models', '1.0.0') + await first.next() + await first.call('settings.set', { model: { provider: 'anthropic', modelId: 'claude-opus-5-5' } }) + const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } + await first.call('chat.open', { project }) + await send(first, project, 'r1') + await first.until((event) => event.type === 'chat' && event.waiting !== null) + await first.quit() + + const second = start(where, 'models', '2.0.0') + await second.next() + const view = (await second.call('chat.open', { project })) as ChatView + expect(view.waiting).toEqual({ kind: 'error', detail: 'Materialize 3D was updated to 2.0.0 while this turn ran.', fix: 'Go on' }) + await second.quit() + }) + + test('after an update stops a turn that ran on the old version, until the person goes on with the new one', async () => { + const where = scratch() + const first = start(where, 'models', '1.0.0') + await first.next() + const project = await newProject(first) + await send(first, project, 'r1', 'please wait') + while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(25) + first.child.kill('SIGKILL') + await first.child.exited + + const second = start(where, 'models', '2.0.0') + await second.next() + const view = (await second.call('chat.open', { project })) as ChatView + expect(view).toMatchObject({ running: false, waiting: { kind: 'error', detail: 'Materialize 3D was updated to 2.0.0 while this turn ran.', fix: 'Go on' } }) + expect(view.turn?.app).toBe('1.0.0') + + writeFileSync(join(where.projects, project, 'go'), '') + await second.call('chat.retry', { project }) + await answered(second, project, 'answered by faux-a') + const after = (await second.call('chat.open', { project })) as ChatView + expect(after.turn).toMatchObject({ app: '2.0.0', model: { modelId: 'faux-a' } }) + expect(after.waiting).toBeNull() + await second.quit() + }) +}) diff --git a/sidecar/test/waiting.test.ts b/sidecar/test/waiting.test.ts new file mode 100644 index 0000000..fed4464 --- /dev/null +++ b/sidecar/test/waiting.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ChatEntry, ChatView } from '../src/protocol' +import { spawnSidecar } from './sidecar' + +/** + * A proxy that speaks Anthropic's Messages API, as Ossie's Mac reaches Anthropic, and remembers who called. A request + * with a token in `refuse` waits for `release`, then is refused as a bad login. + */ +function anthropicProxy(reply: string, refuse: { token: string; release: Promise } | undefined = undefined) { + const seen: { authorization: string | null; path: string }[] = [] + const event = (name: string, data: unknown) => `event: ${name}\ndata: ${JSON.stringify(data)}\n\n` + const server = Bun.serve({ + port: 0, + async fetch(request) { + const authorization = request.headers.get('authorization') + seen.push({ authorization, path: new URL(request.url).pathname }) + if (refuse !== undefined && authorization === `Bearer ${refuse.token}`) { + await refuse.release + return Response.json({ type: 'error', error: { type: 'authentication_error', message: 'invalid x-api-key' } }, { status: 401 }) + } + const body = + event('message_start', { + type: 'message_start', + message: { id: 'msg_1', type: 'message', role: 'assistant', model: 'claude-opus-5-5', content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 10, output_tokens: 0 } }, + }) + + event('content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) + + event('content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: reply } }) + + event('content_block_stop', { type: 'content_block_stop', index: 0 }) + + event('message_delta', { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 3 } }) + + event('message_stop', { type: 'message_stop' }) + return new Response(body, { headers: { 'content-type': 'text/event-stream' } }) + }, + }) + return { url: `http://127.0.0.1:${server.port}`, seen, stop: () => server.stop(true) } +} + +describe('a turn whose provider is not signed in', () => { + test('waits with the fix named, through a restart, and goes on once the person signs in', async () => { + const proxy = anthropicProxy('Hello from the proxy') + const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) + const keychain = new Map() + const first = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) + await first.next() + const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } + await first.call('chat.open', { project }) + await first.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const waiting = await first.until((event) => event.type === 'chat' && event.waiting !== null) + expect(waiting.waiting).toEqual({ + kind: 'login', + provider: 'anthropic', + name: 'Anthropic', + fix: 'Sign in to Anthropic. The message picks up where it stopped.', + }) + await first.quit() + + const second = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) + await second.next() + const reopened = (await second.call('chat.open', { project })) as ChatView + expect(reopened.waiting?.kind).toBe('login') + expect(reopened.entries).toEqual([{ id: expect.any(Number), kind: 'user', text: 'A 20 mm cube', images: [] }]) + + await second.call('auth.setKey', { provider: 'anthropic', key: 'proxy-token', baseUrl: proxy.url }) + const answered = await second.until( + (event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant'), + ) + expect((answered.entries as ChatEntry[]).find((entry) => entry.kind === 'assistant')).toMatchObject({ text: 'Hello from the proxy' }) + expect(answered.waiting).toBeNull() + expect(proxy.seen).toEqual([{ authorization: 'Bearer proxy-token', path: '/v1/messages' }]) + + const view = (await second.call('chat.open', { project })) as ChatView + expect(view.entries.map((entry) => entry.kind)).toEqual(['user', 'assistant']) + await second.quit() + proxy.stop() + }) + + test('a request refused after a newer login was saved goes on with that login, without waiting', async () => { + const release = Promise.withResolvers() + const proxy = anthropicProxy('Hello with the new key', { token: 'old-token', release: release.promise }) + const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map()) + await app.next() + const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } + await app.call('chat.open', { project }) + await app.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) + await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + while (proxy.seen.length === 0) await Bun.sleep(10) + await app.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) + await Bun.sleep(200) + release.resolve() + const answered = await app.until( + (event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant'), + 10_000, + ) + expect((answered.entries as ChatEntry[]).find((entry) => entry.kind === 'assistant')).toMatchObject({ text: 'Hello with the new key' }) + expect(proxy.seen.map((call) => call.authorization)).toEqual(['Bearer old-token', 'Bearer new-token']) + await app.quit() + proxy.stop() + }, 20_000) + + test('a turn that failed with no login goes on when one is saved while the failure is read', async () => { + const proxy = anthropicProxy('Hello after the slow read') + const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) + // While `holding`, each Keychain read waits in `held` until the test answers it. + let holding = true + const held: (() => void)[] = [] + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map(), { + gate: (request, answer) => (holding && request.method === 'keychain.get' ? held.push(answer) : answer()), + }) + await app.next() + const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } + await app.call('chat.open', { project }) + const nextRead = async (count: number) => { + while (held.length < count) await Bun.sleep(10) + } + // The turn's request finds no login and fails. Its failure is read, and that read asks the Keychain again. + await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + await nextRead(1) + held[0]?.() + await nextRead(2) + // A key is saved, and its sign-in looks for waits, while the failure's read still waits on the Keychain. + holding = false + await app.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) + await Bun.sleep(200) + for (const answer of held.slice(1)) answer() + const answered = await app.until( + (event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant'), + 10_000, + ) + expect((answered.entries as ChatEntry[]).find((entry) => entry.kind === 'assistant')).toMatchObject({ text: 'Hello after the slow read' }) + await app.quit() + proxy.stop() + }, 20_000) + + test('a stopped turn that waited on a sign-in stays stopped when the person signs in', async () => { + const proxy = anthropicProxy('never') + const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map()) + await app.next() + const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } + await app.call('chat.open', { project }) + await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + await app.until((event) => event.type === 'chat' && event.waiting !== null) + await app.call('chat.stop', { project }) + await app.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) + await Bun.sleep(500) + expect(proxy.seen).toHaveLength(0) + expect(((await app.call('chat.open', { project })) as ChatView).waiting).toBeNull() + await app.quit() + proxy.stop() + }, 20_000) + + test('a restart does not try a refused login again, only one saved after it', async () => { + const proxy = anthropicProxy('Hello after the restart', { token: 'old-token', release: Promise.resolve() }) + const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) + const keychain = new Map() + const first = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) + await first.next() + const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } + await first.call('chat.open', { project }) + await first.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) + await first.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + await first.until((event) => event.type === 'chat' && event.waiting !== null) + await first.quit() + const second = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) + await second.next() + await Bun.sleep(500) + expect(proxy.seen).toHaveLength(1) + await second.call('chat.open', { project }) + await second.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) + await second.until( + (event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant'), + 10_000, + ) + expect(proxy.seen.map((call) => call.authorization)).toEqual(['Bearer old-token', 'Bearer new-token']) + await second.quit() + proxy.stop() + }, 30_000) + + test('a refused login with no newer one saved waits for a sign-in, and is not tried again', async () => { + const proxy = anthropicProxy('never', { token: 'old-token', release: Promise.resolve() }) + const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map()) + await app.next() + const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } + await app.call('chat.open', { project }) + await app.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) + await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const waiting = await app.until((event) => event.type === 'chat' && event.waiting !== null) + expect(waiting.waiting).toMatchObject({ kind: 'login', provider: 'anthropic' }) + await Bun.sleep(500) + expect(proxy.seen).toHaveLength(1) + await app.quit() + proxy.stop() + }, 20_000) +}) diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index baf8360..07c0775 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -9,7 +9,8 @@ use std::collections::HashMap; use std::path::PathBuf; use std::process::Stdio; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Mutex; +use std::future::Future; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use serde::de::DeserializeOwned; @@ -17,7 +18,7 @@ use serde::Deserialize; use serde_json::{json, Value}; use tauri::{AppHandle, Emitter, Manager, State}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::sync::{mpsc, oneshot, watch, Notify}; +use tokio::sync::{mpsc, oneshot, watch, Notify, OnceCell}; use crate::credentials; use crate::studio; @@ -54,6 +55,23 @@ pub struct Sidecar { alive: watch::Sender, /// Ends the running sidecar through its process handle, which never signals a reused pid. kill: Notify, + /// Each slice by its operation id, running or done, for as long as the app runs. + slices: Operations, +} + +/// Outside work by operation id: one result each, shared by every request that names the id. +type Operations = Mutex>>>>; + +/// Runs `work` once per operation id. A sidecar that crashed mid-slice leaves Rust's slice running, and its resumed +/// turn asks again with the same id: that request waits for the first and gets its result, so two slices never write +/// the same output, and a result that came after the crash is not lost. +async fn once_per_operation(operations: &Operations, id: String, work: F) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + let cell = operations.lock().unwrap_or_else(|e| e.into_inner()).entry(id).or_default().clone(); + cell.get_or_init(work).await.clone() } impl Default for Sidecar { @@ -65,6 +83,7 @@ impl Default for Sidecar { quitting: AtomicBool::new(false), alive: watch::Sender::new(false), kill: Notify::new(), + slices: Mutex::default(), } } } @@ -345,6 +364,14 @@ fn parse(method: &str, params: Value) -> Result serde_json::from_value(params).map_err(|e| format!("bad params for {method}: {e}")) } +/// A slice the agent asked for, under its tool call's operation id. +#[derive(Deserialize)] +struct SliceCall { + operation: String, + #[serde(flatten)] + request: studio::slice::Request, +} + #[derive(Deserialize)] struct Account { account: String, @@ -387,12 +414,15 @@ async fn answer(app: &AppHandle, method: &str, params: Value) -> Result { - let request: studio::slice::Request = parse(method, params)?; + let SliceCall { operation, request } = parse(method, params)?; let cache = app.path().app_cache_dir().map_err(|e| format!("finding the app's cache folder: {e}"))?.join("bambu-presets"); - let sliced = tauri::async_runtime::spawn_blocking(move || studio::slice::slice(&request, &cache)) - .await - .map_err(|e| format!("the slice task failed: {e}"))??; - serde_json::to_value(sliced).map_err(|e| e.to_string()) + once_per_operation(&app.state::().slices, operation, || async move { + let sliced = tauri::async_runtime::spawn_blocking(move || studio::slice::slice(&request, &cache)) + .await + .map_err(|e| format!("the slice task failed: {e}"))??; + serde_json::to_value(sliced).map_err(|e| e.to_string()) + }) + .await } other => Err(format!("no host request {other}")), } @@ -441,6 +471,37 @@ mod tests { assert!(names.iter().all(|name| ["PATH", "HOME", "USER", "LANG", "TMPDIR"].contains(name)), "{names:?}"); } + #[tokio::test] + async fn a_repeated_operation_waits_for_the_first_and_gets_its_result() { + let operations = Operations::default(); + let runs = AtomicU64::new(0); + let work = |answer: &'static str| { + let runs = &runs; + move || async move { + runs.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(50)).await; + Ok(json!(answer)) + } + }; + let (first, again) = tokio::join!( + once_per_operation(&operations, "op".to_owned(), work("first")), + once_per_operation(&operations, "op".to_owned(), work("again")), + ); + assert_eq!((first, again), (Ok(json!("first")), Ok(json!("first")))); + assert_eq!(once_per_operation(&operations, "op".to_owned(), work("later")).await, Ok(json!("first"))); + assert_eq!(runs.load(Ordering::SeqCst), 1); + assert_eq!(once_per_operation(&operations, "other".to_owned(), work("other")).await, Ok(json!("other"))); + assert_eq!(runs.load(Ordering::SeqCst), 2); + } + + #[test] + fn a_slice_call_carries_its_operation_id() { + let call: SliceCall = + serde_json::from_value(json!({ "operation": "abc", "project": "p", "inputs": ["a.stl"], "output": "out.3mf" })).expect("slice call"); + assert_eq!(call.operation, "abc"); + assert!(serde_json::from_value::(json!({ "project": "p", "inputs": ["a.stl"], "output": "out.3mf" })).is_err()); + } + #[test] fn a_protocol_line_parses_into_its_kind() { let request: Outbound = serde_json::from_str(r#"{"kind":"request","id":3,"method":"keychain.get","params":{"account":"anthropic"}}"#).expect("request");