diff --git a/docs/docs/data-engineering/guides/using-with-claude-code.md b/docs/docs/data-engineering/guides/using-with-claude-code.md index 48533ce8b6..08032f933b 100644 --- a/docs/docs/data-engineering/guides/using-with-claude-code.md +++ b/docs/docs/data-engineering/guides/using-with-claude-code.md @@ -1,6 +1,6 @@ --- title: "Using Altimate with Claude Code" -description: "Run Altimate's data engineering tools alongside Claude Code via the /altimate command or MCP server integration." +description: "Hand data engineering work from Claude Code to Altimate Code with the Altimate Code plugin, and use an Altimate workspace's skills, knowledge and memory from Claude Code." --- # Using altimate with Claude Code @@ -80,9 +80,29 @@ altimate: ## Setup -1. Install altimate globally: `npm install -g altimate-code` -2. Configure warehouse connections in your project -3. Run `/configure-claude` inside altimate to set up the integration: +### The Altimate Code plugin (recommended) + +1. Install Altimate Code (Node.js 20 or later): `npm install -g altimate-code` +2. Install the plugin from Anthropic's plugin directory: + + ```bash + claude plugin install altimate-code + ``` + + Or add Altimate's own marketplace and install from it: + + ```bash + claude plugin marketplace add AltimateAI/altimate-claude-plugin + claude plugin install altimate-code@altimate-claude-plugin + ``` + +3. Start a new Claude Code session in your project. Ask for dbt or warehouse work as usual, or start a request with `/altimate-code:altimate` to hand it over yourself. (Claude Code names plugin commands `:`; the bare `/altimate` below comes from `/configure-claude`.) + +The plugin runs `altimate-code run` in your project for each task, with the model provider Altimate Code is set up with. If you have not set one up, run `altimate-code` once and choose a provider, or sign in to Altimate (below). + +### The `/altimate` command, without the plugin + +Run `/configure-claude` inside altimate to write the command: ```bash altimate @@ -92,6 +112,36 @@ altimate This creates `~/.claude/commands/altimate.md`. You can now use `/altimate` in any Claude Code session. +## Use an Altimate workspace from Claude Code + +Claude Code reaches a workspace through Altimate Code: every task the plugin hands over runs in your project, so it gets the workspace the project is linked to — its skills, its knowledge, your team's shared memory and its integrations. Nothing in Claude Code itself needs configuring. + +1. **Sign in to Altimate.** In a terminal: + + ```bash + altimate-code auth login -p altimate-backend --method "Altimate LLM Gateway" + ``` + + It opens your browser; sign in and return to the terminal. (Inside the TUI, `/connect` does the same.) The command also works from Claude Code: type it after `!` in the prompt. + +2. **Link the project.** In the project folder: + + ```bash + altimate-code link + ``` + + Pick the workspace, or create one. The picker needs an interactive terminal, so it does not run from Claude Code's `!` prompt or a script. There, list the workspaces with `altimate-code workspace list` and link with `altimate-code link --workspace `, or create one with `altimate-code link --create`. Moving a linked project to a different workspace also needs `--yes`. Claude can run these for you: ask it to link the project to a workspace. + +3. **Check it.** `altimate-code workspace status` shows the linked workspace and when its skills last synced. + +4. **Ask Claude.** Questions about your team's conventions, best practices or knowledge are answered from the workspace when they reach Altimate Code. Start them with `/altimate-code:altimate` to be sure they do: + + ``` + /altimate-code:altimate what are our naming conventions for staging models? + ``` + +What you teach Altimate Code during these tasks is saved to the workspace's memory and shared with your team in the CLI, the IDE and Studio. If an upload fails, the task ends with a line saying so; `altimate-code workspace sync` sends what is left. Skills added to the workspace sync into `.altimate-code/skill/_workspace/` at the start of the next task. + ## When to use which | Task | Use Claude Code | Use altimate | diff --git a/docs/docs/usage/cli.md b/docs/docs/usage/cli.md index 8cfce97e50..11b2d60124 100644 --- a/docs/docs/usage/cli.md +++ b/docs/docs/usage/cli.md @@ -40,7 +40,7 @@ altimate --agent analyst | `import` | Import session data | | `session` | Session management | | `link` | Link this project to an Altimate workspace | -| `workspace` | Show, refresh, sync or unlink this project's workspace from a shell | +| `workspace` | List workspaces, or show, refresh, sync or unlink this project's workspace from a shell | | `trace` | List and view session traces (recordings of agent sessions) | | `github` | GitHub integration | | `pr` | Pull request tools | @@ -57,6 +57,7 @@ With workspaces on: - `altimate-code link` links the current project to a workspace (or creates one), then shows what the workspace provides (its skills, knowledge, integrations and whether memory is on) and a few prompts to start with. The sidebar then names the workspace and shows how many memories are not yet synced and when skills last synced. - From a shell, script or CI job, without the TUI: - `altimate-code link --workspace ` links to an existing workspace (an id is matched before a name), and `altimate-code link --create [name]` creates one (named after the repo by default) and links to it. Neither prompts. If the project is already linked to a different workspace they refuse unless `--yes` is passed. Running `--create` again when the project is already linked to a workspace of that name creates nothing, so it is safe in a devcontainer's setup command; if another workspace already has that name it refuses and names its id, unless `--allow-duplicate` is passed. Linking to the workspace the project is already linked to confirms that link on this machine, which a fresh clone needs before `workspace sync` sends its memory. + - `altimate-code workspace list [--json]` lists the workspaces your account can link to, with their ids, for `link --workspace`. - `altimate-code workspace status [--json]` shows the linked workspace and, when known, how many saved memories have not reached it and when skills last synced. - `altimate-code workspace refresh` pulls the workspace's skills (memory is loaded by the next session started in the project); `altimate-code workspace sync` sends memory the workspace has not received; `altimate-code workspace unlink [--yes]` detaches the project (`--yes` is required without a terminal). - These commands accept `--directory`. Exit codes: `0` done, `1` failed (including when the workspace service could not be reached), `2` a request to change (not signed in, a re-link or unlink without `--yes` and no terminal to confirm on, or a `sync` to a link not yet confirmed on this machine), `3` the project is not linked. With `--json`, `ok` is `true` exactly when the exit code is `0`. @@ -189,6 +190,8 @@ altimate --print-logs --log-level DEBUG run "test query" altimate run --no-trace "quick question" ``` +`run` reads piped input to the end, however long the command before the pipe takes. One exception: when `run` already has a message or `--command` and its stdin is a socket, which is what an agent's shell tool or a Node `spawn` hands a child, it waits only 3 seconds for the first byte and then continues without stdin. Set `ALTIMATE_RUN_STDIN_WAIT_MS` to change that wait, or to `0` to always wait. A caller that never writes to or closes stdin, and whose stdin is not a socket (for example a pipe on Windows), should redirect it from an empty source: `< /dev/null`, or `< NUL` in `cmd.exe`. + For CI pipelines and headless automation, see [CI & Automation](ci-headless.md). ## Trace diff --git a/packages/opencode/src/altimate/cli/run-stdin.ts b/packages/opencode/src/altimate/cli/run-stdin.ts new file mode 100644 index 0000000000..d800eb812d --- /dev/null +++ b/packages/opencode/src/altimate/cli/run-stdin.ts @@ -0,0 +1,89 @@ +// altimate_change - new file +import { fstatSync } from "fs" + +/** How long `run` waits for the first byte on a socket stdin. */ +export const STDIN_WAIT_MS = 3000 + +/** The longest delay `setTimeout` honours; a larger one fires at once. */ +const MAX_TIMER_MS = 2_147_483_647 + +/** `ALTIMATE_RUN_STDIN_WAIT_MS` overrides the wait, for a caller that writes its stdin late; 0 waits for EOF. */ +export function stdinWaitMs(env: Record = process.env): number { + const raw = env["ALTIMATE_RUN_STDIN_WAIT_MS"]?.trim() + if (!raw) return STDIN_WAIT_MS + const ms = Number(raw) + if (!Number.isFinite(ms) || ms < 0) return STDIN_WAIT_MS + return Math.min(ms, MAX_TIMER_MS) +} + +export interface StdinSource { + /** True when fd 0 is a regular file (`run < prompt.txt`), which always reaches EOF. */ + isFile(): boolean + /** True when fd 0 is a socket: what an agent's shell tool or a Node `spawn` hands a child. A shell + * pipeline (`cmd | altimate-code run`) is a FIFO instead. */ + isSocket(): boolean + readAll(): Promise + stream(): ReadableStream +} + +export const bunStdin: StdinSource = { + isFile() { + try { + return fstatSync(0).isFile() + } catch { + return false + } + }, + isSocket() { + try { + return fstatSync(0).isSocket() + } catch { + return false + } + }, + readAll: () => Bun.stdin.text(), + stream: () => Bun.stdin.stream(), +} + +/** + * Read text piped into `altimate-code run`, without waiting on a stdin that never sends anything. + * + * Agents that shell out to `run` (Claude Code's Bash tool, a Node `spawn` with default stdio) hand + * it a socket they never write to or close. Reading that to EOF blocks forever, so a delegated task + * hangs before it starts. A socket is read only if its first chunk arrives within `waitMs`, and then + * to EOF. Anything else (a file, or a shell pipeline whose producer may take minutes, like + * `dbt build | altimate-code run`) is read to EOF as before. So is a socket when `bounded` is false + * (the caller passed nothing else to run, so stdin is the prompt) or `waitMs` is 0. + */ +export async function readRunStdin( + source: StdinSource = bunStdin, + waitMs: number = STDIN_WAIT_MS, + bounded = true, +): Promise<{ text: string; timedOut: boolean }> { + if (!bounded || waitMs <= 0 || source.isFile() || !source.isSocket()) + return { text: await source.readAll(), timedOut: false } + const reader = source.stream().getReader() + let timer: ReturnType | undefined + try { + const first = await Promise.race([ + reader.read(), + new Promise<"timeout">((resolve) => { + timer = setTimeout(() => resolve("timeout"), waitMs) + }), + ]) + if (first === "timeout") return { text: "", timedOut: true } + if (first.done) return { text: "", timedOut: false } + const decoder = new TextDecoder() + let text = decoder.decode(first.value, { stream: true }) + while (true) { + const next = await reader.read() + if (next.done) break + text += decoder.decode(next.value, { stream: true }) + } + return { text: text + decoder.decode(), timedOut: false } + } finally { + if (timer) clearTimeout(timer) + // Releases the stream so an idle stdin does not keep the process alive after the run. + reader.cancel().catch(() => {}) + } +} diff --git a/packages/opencode/src/altimate/cli/unsent-memory.ts b/packages/opencode/src/altimate/cli/unsent-memory.ts new file mode 100644 index 0000000000..2df148013e --- /dev/null +++ b/packages/opencode/src/altimate/cli/unsent-memory.ts @@ -0,0 +1,55 @@ +// altimate_change - new file +import * as Manage from "@/altimate/workspace/manage" +import { lastMirrorTarget, type MirrorTarget } from "@/altimate/workspace/memory-sync" + +/** How long `run`'s exit waits for the count. It is read from local files, so this only bounds a stalled disk. */ +export const UNSENT_CHECK_MS = 5000 + +/** + * What `run` prints at exit about memory on this machine that the workspace has not received, or null + * when there is nothing to say. `unsynced` is `workspace status`'s count for the project, not only this + * run's: an upload that failed, was deferred or declined by the service, or was still in flight when the + * exit flush gave up is pending there; a block deleted since is not. A workspace with memory turned off + * keeps its blocks here by design and is not reported. + */ +export function unsentMemoryNotice(unsynced: number | null): string | null { + if (unsynced === null) + return "Could not confirm that the memory saved here reached the workspace. Run `altimate-code workspace status` to check." + if (unsynced === 0) return null + return ( + `${unsynced === 1 ? "1 memory on this machine is" : `${unsynced} memories on this machine are`} not in the workspace yet. ` + + `Run \`altimate-code workspace sync\` to send ${unsynced === 1 ? "it" : "them"}.` + ) +} + +export interface UnsentMemoryDeps { + target(): MirrorTarget | undefined + status(directory: string, binding: MirrorTarget["binding"]): Promise +} + +const defaultDeps: UnsentMemoryDeps = { + target: lastMirrorTarget, + // Passing the binding the upload used keeps `Manage.status` off the network. + status: (directory, binding) => Manage.status(directory, { binding }), +} + +/** Counted only when this process tried to upload memory to a linked workspace, so other runs pay nothing. + * A count that cannot be read, or not within `timeoutMs`, is reported as unknown. */ +export async function unsentMemoryNoticeAfterRun( + fallbackDirectory: string, + timeoutMs: number = UNSENT_CHECK_MS, + deps: UnsentMemoryDeps = defaultDeps, +): Promise { + const target = deps.target() + if (!target) return null + let timer: ReturnType | undefined + const report = await Promise.race([ + deps.status(target.directory ?? fallbackDirectory, target.binding).catch(() => null), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs) + }), + ]).finally(() => clearTimeout(timer)) + if (!report || report.memoryUnreadable) return unsentMemoryNotice(null) + if (!report.binding || !report.memory) return null + return unsentMemoryNotice(report.memory.unsynced) +} diff --git a/packages/opencode/src/altimate/workspace/empty-record-path.ts b/packages/opencode/src/altimate/workspace/empty-record-path.ts new file mode 100644 index 0000000000..7ec41cfb4f --- /dev/null +++ b/packages/opencode/src/altimate/workspace/empty-record-path.ts @@ -0,0 +1,13 @@ +// altimate_change - new file +import path from "path" +import { createHash } from "node:crypto" +import { Global } from "@/global" + +/** Where a sync records that this project's workspace has no skills. Keyed by the resolved + * directory, so the TUI thread and the prompt worker agree on one file. Under the app's state + * directory, so nothing is written into the user's repository. Kept out of `skill-sync` so the + * per-turn gate in `session/prompt` can check it without loading that module. */ +export function emptyRecordPath(canon: string): string { + const id = createHash("sha256").update(canon).digest("hex").slice(0, 32) + return path.join(Global.Path.state, "altimate-workspace-empty", id) +} diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 5599837e27..63cff1314f 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -907,6 +907,25 @@ function serialize(scope: "global" | "project", blockId: string, op: () => Pr * `skill-sync.flushPendingSyncs` holds it for a cold skill sync. */ const mirrorsInFlight = syncState.mirrorsInFlight +/** The linked workspace this process last tried to upload a block to, and the directory it was bound from. + * A one-shot `run` reads it at exit and, only then, counts the memory still not in the workspace + * (`Manage.status`, given this binding so the count stays local): nothing in `run` retries an upload, so + * without the check a block left behind stays on this machine unannounced. Not set when the workspace has + * memory turned off, since that block stays here by design. Process-global for the same two-module-graph + * reason as `syncState`. */ +const mirrorRequests: { last?: MirrorTarget } = ((globalThis as unknown as Record)[ + Symbol.for("altimate.memory-sync.mirror-requests") +] ??= {}) + +export interface MirrorTarget { + directory: string | undefined + binding: CachedBinding +} + +export function lastMirrorTarget(): MirrorTarget | undefined { + return mirrorRequests.last +} + /** Hold a task in `mirrorsInFlight` for its lifetime. */ async function tracked(task: Promise): Promise { mirrorsInFlight.add(task) @@ -953,7 +972,18 @@ export async function mirrorBlock(block: MemoryBlock, directory?: string): Promi // binding governs only whether we upload at all. const binding = await currentBinding(directory) if (!binding) return - if (!(await memoryEnabled(binding))) return + // Recorded before the memory-setting lookup, which can wait on the service: an exit while it is pending + // leaves this block unsent too. Taken back only on a confirmed "disabled", and only while it is still + // this call's record: other blocks mirror concurrently and may have recorded since. + const previous = mirrorRequests.last + const recorded: MirrorTarget = { directory, binding } + mirrorRequests.last = recorded + const memory = await memoryStatus(binding) + if (memory === "disabled") { + if (mirrorRequests.last === recorded) mirrorRequests.last = previous + return + } + if (memory !== "enabled") return await push(block, binding, undefined, directory) }) return tracked(task) diff --git a/packages/opencode/src/altimate/workspace/skill-sync.ts b/packages/opencode/src/altimate/workspace/skill-sync.ts index e7af76124a..ba7da39fac 100644 --- a/packages/opencode/src/altimate/workspace/skill-sync.ts +++ b/packages/opencode/src/altimate/workspace/skill-sync.ts @@ -38,10 +38,9 @@ // detection is per-skill ``updated_at`` and the only integrity check available // is byte length. import fs from "fs/promises" -import { createHash } from "node:crypto" import { statSync } from "node:fs" import path from "path" -import { Global } from "@/global" +import { emptyRecordPath } from "./empty-record-path" import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" import { Log } from "@/altimate/util/log" import { AltimateApi } from "@/altimate/api/client" @@ -655,16 +654,10 @@ export async function recentlySynced(directory: string): Promise { /** Which account each project's snapshot was last fetched for. */ const syncedFor = store.syncedFor -/** Where the record lives that a project's workspace had no custom skills at the last sync - * (account key + workspace id). On disk rather than on `globalThis`, which is per thread: an - * unlink in the TUI thread has to clear what the prompt worker's sync recorded, and the other way - * round. Under the app's state directory, so nothing is written into the user's repository. */ -function emptyRecordPath(canon: string): string { - const id = createHash("sha256").update(canon).digest("hex").slice(0, 32) - return path.join(Global.Path.state, "altimate-workspace-empty", id) -} - -/** The record as written: `\n`. The write id tells one sync's record from another +/** The record as written: `\n`, at `emptyRecordPath` (./empty-record-path), saying the + * project's workspace had no custom skills at the last sync (account key + workspace id). On disk rather + * than on `globalThis`, which is per thread: an unlink in the TUI thread has to clear what the prompt + * worker's sync recorded, and the other way round. The write id tells one sync's record from another * sync's record for the same key, so a sync only ever withdraws the record it wrote itself. */ async function readEmptyRecordRaw(canon: string): Promise { try { diff --git a/packages/opencode/src/cli/cmd/link.ts b/packages/opencode/src/cli/cmd/link.ts index dbfaa92241..9d402fd070 100644 --- a/packages/opencode/src/cli/cmd/link.ts +++ b/packages/opencode/src/cli/cmd/link.ts @@ -54,6 +54,11 @@ import { stripBidiControls, } from "@/altimate/workspace/workspace-name" +/** The browser sign-in from a plain shell: the Altimate LLM Gateway method of the `altimate-backend` + * provider saves the credentials every workspace command reads. `--method` skips the method picker, + * so the command also works where stdin is not a terminal (an agent's shell, Claude Code's `!`). */ +export const SIGN_IN_COMMAND = 'altimate-code auth login -p altimate-backend --method "Altimate LLM Gateway"' + const CREATE_NEW_SENTINEL = "__create_new__" const SET_UP_IN_BROWSER_SENTINEL = "__browser_handoff__" @@ -238,8 +243,10 @@ export const LinkCommand = cmd({ if (!process.stdin.isTTY) { UI.error( "`altimate-code link` needs an interactive terminal (stdin must be a TTY). " + - "Run it directly in a shell, or use the TUI palette command " + - '"Link this project to a workspace".', + "Run it directly in a shell, use the TUI palette command " + + '"Link this project to a workspace", or link without prompts: ' + + "`altimate-code link --workspace ` for an existing workspace (`altimate-code workspace list` shows them), " + + "`altimate-code link --create` for a new one.", ) process.exitCode = 1 return @@ -247,7 +254,7 @@ export const LinkCommand = cmd({ if (!(await AltimateApi.isConfigured())) { UI.error( - "Not signed in to Altimate. Run the TUI (altimate-code) and sign in first, then re-run `altimate-code link`.", + `Not signed in to Altimate. Sign in with \`${SIGN_IN_COMMAND}\` (it opens your browser), then re-run \`altimate-code link\`.`, ) process.exitCode = 1 return @@ -1051,7 +1058,9 @@ export async function linkHeadless( return } if (!(await deps.isConfigured())) { - deps.printError("Not signed in to Altimate. Run altimate-code, sign in, then re-run `altimate-code link`.") + deps.printError( + `Not signed in to Altimate. Sign in with \`${SIGN_IN_COMMAND}\` (it opens your browser), then re-run \`altimate-code link\`.`, + ) process.exitCode = EXIT_USAGE return } diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 5c2a26edfd..521acce43e 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -561,8 +561,20 @@ export const RunCommand = cmd({ // Earlier revision used `!process.stdin?.isTTY`, which turned the crash // into a stall: undefined stdin satisfied the guard and we then awaited // Bun.stdin.text() on a stream that would never EOF. Skip the read - // entirely when there is no stdin to read from. - if (process.stdin && !process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) + // entirely when there is no stdin to read from. An agent's shell tool can + // also hand `run` a socket stdin it never writes to or closes, so that + // read is bounded when there is a message or command to run without it: + // see `readRunStdin`. + if (process.stdin && !process.stdin.isTTY) { + const { readRunStdin, stdinWaitMs } = await import("../../altimate/cli/run-stdin") + const waitMs = stdinWaitMs() + const piped = await readRunStdin(undefined, waitMs, message.trim().length > 0 || !!args.command) + if (piped.timedOut) + process.stderr.write( + `No input on stdin after ${waitMs / 1000}s; continuing without it. Redirect stdin from /dev/null to skip the wait, or set ALTIMATE_RUN_STDIN_WAIT_MS=0 to wait for it.${EOL}`, + ) + message += "\n" + piped.text + } // altimate_change end if (message.trim().length === 0 && !args.command) { @@ -1528,6 +1540,10 @@ You are speaking to a non-technical business executive. Follow these rules stric // flush takes; what is already on the wire is what gets the 2s. eventAbort.abort() const drains: Promise[] = [] + // Loaded during the drain, so only the count itself runs after it. + const unsent = CoreFlag.ALTIMATE_DISABLE_WORKSPACE + ? undefined + : import("../../altimate/cli/unsent-memory").catch(() => undefined) if (!CoreFlag.ALTIMATE_DISABLE_WORKSPACE) drains.push(import("../../altimate/workspace/memory-sync").then((m) => m.flushPendingMirrors(2_000))) if (captureEnabled) @@ -1538,8 +1554,24 @@ You are speaking to a non-technical business executive. Follow these rules stric void Promise.race([ Promise.allSettled(drains), new Promise((resolve) => { timer = setTimeout(resolve, 2_000) }), - ]).finally(() => { + ]).finally(async () => { clearTimeout(timer) + // An interrupted upload is the likeliest to stay on this machine, so say so here too. The count + // is local; a second signal still exits at once. + const notice = await unsent + ?.then((m) => m?.unsentMemoryNoticeAfterRun(process.cwd(), 1_000)) + .catch(() => null) + // process.exit does not wait for a pending write to a piped stderr; wait briefly, never for a + // reader that has stopped. + if (notice) + await new Promise((resolve) => { + setTimeout(resolve, 500) + try { + process.stderr.write(notice + EOL, () => resolve()) + } catch { + resolve() + } + }) process.exit(code) }) } @@ -2107,6 +2139,12 @@ You are speaking to a non-technical business executive. Follow these rules stric .then((m) => m.flushPendingMirrors()) .catch(() => {}), ]) + // This process does not retry an upload, and nothing else tells the user + // that memory stayed on this machine. + const notice = await import("../../altimate/cli/unsent-memory") + .then((m) => m.unsentMemoryNoticeAfterRun(process.cwd())) + .catch(() => null) + if (notice) process.stderr.write(notice + EOL) } // altimate_change end diff --git a/packages/opencode/src/cli/cmd/workspace.ts b/packages/opencode/src/cli/cmd/workspace.ts index 240ecc9700..2238a71882 100644 --- a/packages/opencode/src/cli/cmd/workspace.ts +++ b/packages/opencode/src/cli/cmd/workspace.ts @@ -1,6 +1,6 @@ // altimate_change - new file // -// `altimate-code workspace status|refresh|sync|unlink`: the `/workspace` menu's actions for a shell, +// `altimate-code workspace list|status|refresh|sync|unlink`: the `/workspace` menu's actions for a shell, // so a setup script, a devcontainer, CI or another agent can manage a project's workspace without // the TUI. Thin wrappers over `altimate/workspace/manage.ts` — the same code the menu and the // `serve` routes run — with plain-text output, `--json` for scripts, and exit codes a caller can @@ -15,8 +15,9 @@ import * as prompts from "@clack/prompts" import { cmd } from "./cmd" import { bootstrap } from "../bootstrap" import { UI } from "../ui" -import { stripControlChars } from "./link" +import { SIGN_IN_COMMAND, stripControlChars } from "./link" import { AltimateApi } from "@/altimate/api/client" +import { WorkspaceApi, type DatamateRef } from "@/altimate/workspace/api-client" import * as Manage from "@/altimate/workspace/manage" import { resolveBindingOutcome, type BindingOutcome, type CachedBinding } from "@/altimate/workspace/state" @@ -120,6 +121,7 @@ export function describeRefresh(report: Manage.RefreshReport): { lines: string[] * guards can be checked without a service, a terminal or a project. */ export interface WorkspaceDeps { isConfigured(): Promise + list(): Promise resolve(directory: string): Promise status(directory: string, binding: CachedBinding): Promise refresh(directory: string): Promise @@ -135,6 +137,7 @@ export interface WorkspaceDeps { const defaultDeps: WorkspaceDeps = { isConfigured: () => AltimateApi.isConfigured().catch(() => false), + list: () => WorkspaceApi.listDatamates(), resolve: (directory) => resolveBindingOutcome(directory), status: (directory, binding) => Manage.status(directory, { poll: true, binding }), refresh: (directory) => Manage.refresh(directory), @@ -162,7 +165,7 @@ function failure(deps: WorkspaceDeps, json: boolean, code: number, message: stri } /** Checked first in every subcommand, so each fails the same way with the same exit code. */ -const NOT_SIGNED_IN = "Not signed in to Altimate. Run altimate-code, sign in, then try again." +const NOT_SIGNED_IN = `Not signed in to Altimate. Sign in with \`${SIGN_IN_COMMAND}\` (it opens your browser), then try again.` const NOT_LINKED_LINES = ["This project is not linked to a workspace.", "Link it with `altimate-code link`."] /** @@ -187,6 +190,26 @@ async function linkOf( } } +/** The workspaces this account can link to, so a caller without a terminal for the `link` picker can + * choose one and pass it to `link --workspace`. */ +export async function runList(json: boolean, deps: WorkspaceDeps = defaultDeps): Promise { + if (!(await deps.isConfigured())) return failure(deps, json, EXIT.USAGE, NOT_SIGNED_IN) + let list: DatamateRef[] + try { + list = await deps.list() + } catch (err) { + return failure(deps, json, EXIT.FAILED, `Could not load workspaces: ${messageOf(err)}`) + } + const workspaces = list.map((w) => ({ id: w.id, name: w.name })) + const lines = workspaces.length + ? [ + ...workspaces.map((w) => `${w.id} ${stripControlChars(w.name)}`), + "Link this project to one with `altimate-code link --workspace `.", + ] + : ["No workspaces yet. Create one for this project with `altimate-code link --create`."] + return report(deps, json, EXIT.OK, { workspaces }, lines) +} + export async function runStatus(directory: string, json: boolean, deps: WorkspaceDeps = defaultDeps): Promise { if (!(await deps.isConfigured())) return failure(deps, json, EXIT.USAGE, NOT_SIGNED_IN) try { @@ -303,6 +326,16 @@ async function inProject(directory: string, json: boolean, run: () => Promise yargs.option("json", JSON_FLAG), + handler: async (args) => { + process.exitCode = await runList(args.json) + }, +}) + const StatusCommand = cmd({ command: "status", describe: "show which workspace this project is linked to, and what has not synced", @@ -349,8 +382,14 @@ const UnlinkCommand = cmd({ export const WorkspaceCommand = cmd({ command: "workspace", - describe: "manage this project's Altimate workspace (status, refresh, sync, unlink)", + describe: "manage this project's Altimate workspace (list, status, refresh, sync, unlink)", builder: (yargs) => - yargs.command(StatusCommand).command(RefreshCommand).command(SyncCommand).command(UnlinkCommand).demandCommand(), + yargs + .command(ListCommand) + .command(StatusCommand) + .command(RefreshCommand) + .command(SyncCommand) + .command(UnlinkCommand) + .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1c137dc78d..db1270e185 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -9,6 +9,8 @@ import { SessionID, MessageID, PartID } from "./schema" // altimate_change start — module-scope echo transport predicate import { MessageV2, isEchoTransportError } from "./message-v2" // altimate_change end +// altimate_change — a linked workspace with no skills yet still waits for the skill sync; see `prompt` +import { emptyRecordPath } from "../altimate/workspace/empty-record-path" import { Log } from "../util/log" import { SessionRevert } from "./revert" import { Session } from "." @@ -420,11 +422,14 @@ export namespace SessionPrompt { } else { // Workspaces are on by default, so this branch runs on every user's turn. Awaiting it puts // event-loop ticks (and up to WORKSPACE_SKILL_WAIT_MS) before `createUserMessage` — the - // reordering described above. Only a project that already has a workspace snapshot can have - // skills to land on this turn, so only that case waits; everyone else starts the sync in the - // background and keeps the synchronous path. The background sync does not refresh the skill - // registry when it lands, so its skills reach the next turn (which finds the snapshot and - // refreshes before it starts), never part-way through this one. + // reordering described above. Only a project a sync has already seen linked can have skills + // to land on this turn, so only that case waits: one with a workspace snapshot, or one whose + // workspace had no skills at the last sync. Without the second, the first skill added to an + // empty workspace missed the next `run` entirely — each is a fresh process, so the skill was + // named in the workspace prompt but the skill tool could not load it. Everyone + // else starts the sync in the background and keeps the synchronous path. The background sync + // does not refresh the skill registry when it lands, so its skills reach the next turn (which + // finds the snapshot and refreshes before it starts), never part-way through this one. const workspaceSkillTurn = async (background: boolean) => { try { const skillSync = await import("../altimate/workspace/skill-sync") @@ -493,7 +498,11 @@ export namespace SessionPrompt { log.warn("workspace skill sync failed", { err: String(err) }) } } - if (existsSync(path.join(Instance.directory, ".altimate-code", "skill", "_workspace"))) await workspaceSkillTurn(false) + if ( + existsSync(path.join(Instance.directory, ".altimate-code", "skill", "_workspace")) || + existsSync(emptyRecordPath(path.resolve(Instance.directory))) + ) + await workspaceSkillTurn(false) // Registered now, synchronously, so a `run` that ends at once still flushes it on exit. else PendingTurns.track(workspaceSkillTurn(true)) } diff --git a/packages/opencode/test/altimate/cli/unsent-memory.test.ts b/packages/opencode/test/altimate/cli/unsent-memory.test.ts new file mode 100644 index 0000000000..30dcc95e63 --- /dev/null +++ b/packages/opencode/test/altimate/cli/unsent-memory.test.ts @@ -0,0 +1,78 @@ +// altimate_change - new file +import { describe, expect, test } from "bun:test" +import { unsentMemoryNotice, unsentMemoryNoticeAfterRun, type UnsentMemoryDeps } from "../../../src/altimate/cli/unsent-memory" +import type { MirrorTarget } from "../../../src/altimate/workspace/memory-sync" + +const binding = { datamateId: 46, datamateName: "Growth" } as any + +function deps(over: Partial = {}): UnsentMemoryDeps & { calls: number; dirs: string[] } { + const d = { + calls: 0, + dirs: [] as string[], + target: (): MirrorTarget | undefined => ({ directory: "/bound", binding }), + status: async (directory: string, given: MirrorTarget["binding"]) => { + expect(given).toBe(binding) + d.dirs.push(directory) + d.calls++ + return { binding, memory: { local: 3, unsynced: 2 }, skillsEnabled: true, skillsSyncedAt: null } as any + }, + ...over, + } + return d +} + +describe("unsentMemoryNotice", () => { + test("says nothing when every memory reached the workspace", () => { + expect(unsentMemoryNotice(0)).toBeNull() + }) + + test("counts what is pending and points at workspace sync", () => { + expect(unsentMemoryNotice(1)).toBe( + "1 memory on this machine is not in the workspace yet. Run `altimate-code workspace sync` to send it.", + ) + expect(unsentMemoryNotice(3)).toBe( + "3 memories on this machine are not in the workspace yet. Run `altimate-code workspace sync` to send them.", + ) + }) + + test("an unknown count is reported as unknown, never as zero", () => { + expect(unsentMemoryNotice(null)).toBe( + "Could not confirm that the memory saved here reached the workspace. Run `altimate-code workspace status` to check.", + ) + }) +}) + +describe("unsentMemoryNoticeAfterRun", () => { + test("a run that uploaded no memory to a linked workspace does not check at all", async () => { + const d = deps({ target: () => undefined }) + expect(await unsentMemoryNoticeAfterRun("/p", 1000, d)).toBeNull() + expect(d.calls).toBe(0) + }) + + test("a run that saved memory reports the pending count of the directory it uploaded from", async () => { + const d = deps() + expect(await unsentMemoryNoticeAfterRun("/p", 1000, d)).toContain("2 memories on this machine") + expect(d.dirs).toEqual(["/bound"]) + const unscoped = deps({ target: () => ({ directory: undefined, binding }) }) + await unsentMemoryNoticeAfterRun("/p", 1000, unscoped) + expect(unscoped.dirs).toEqual(["/p"]) + }) + + test("an unreadable local count, a failed check or a slow one is reported as unknown", async () => { + const unreadable = deps({ status: async () => ({ binding, memory: null, memoryUnreadable: true }) as any }) + expect(await unsentMemoryNoticeAfterRun("/p", 1000, unreadable)).toContain("Could not confirm") + const failed = deps({ status: async () => Promise.reject(new Error("down")) }) + expect(await unsentMemoryNoticeAfterRun("/p", 1000, failed)).toContain("Could not confirm") + const slow = deps({ status: () => new Promise(() => {}) }) + const started = Date.now() + expect(await unsentMemoryNoticeAfterRun("/p", 20, slow)).toContain("Could not confirm") + expect(Date.now() - started).toBeLessThan(1000) + }) + + test("an unlinked project, or memory off in this build, says nothing", async () => { + const unlinked = deps({ status: async () => ({ binding: null, memory: { local: 1, unsynced: 0 } }) as any }) + expect(await unsentMemoryNoticeAfterRun("/p", 1000, unlinked)).toBeNull() + const off = deps({ status: async () => ({ binding, memory: null }) as any }) + expect(await unsentMemoryNoticeAfterRun("/p", 1000, off)).toBeNull() + }) +}) diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index 28def0c195..c2a0e3c8d2 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -45,6 +45,7 @@ const { isEnabled, memoryEnabledCached, mirrorBlock, + lastMirrorTarget, flushPendingMirrors, overlayBlocks, refresh, @@ -81,6 +82,8 @@ let createResult: any = [{ id: "mem-new" }] let workspaces: any[] = [{ id: 42, name: "acme", memory_enabled: true }] /** When set, GET /list fails -- used to prove a failed lookup never creates. */ let listFails = false +/** When set, POST /datamates/memory/ fails, as a create that times out does. */ +let createFails = false /** When set, a PATCH to this record id fails. */ let failPatchOf: string | undefined @@ -92,6 +95,9 @@ function stubFetch() { if (listFails && url.includes("/datamates/memory/list")) { return new Response(JSON.stringify({ detail: "boom" }), { status: 500 }) } + if (createFails && method === "POST" && url.includes("/datamates/memory/")) { + return new Response(JSON.stringify({ detail: "boom" }), { status: 500 }) + } if (failPatchOf && method === "PATCH" && url.endsWith(`/datamates/memory/${failPatchOf}`)) { return new Response(JSON.stringify({ detail: "boom" }), { status: 500 }) } @@ -169,6 +175,7 @@ beforeEach(() => { captured = [] listResponse = [] listFails = false + createFails = false failPatchOf = undefined createResult = [{ id: "mem-new" }] workspaces = [{ id: 42, name: "acme", memory_enabled: true }] @@ -176,6 +183,9 @@ beforeEach(() => { stubCreds("acme", "https://api.example.com") stubFetch() resetOverlay() + // Process-global (see `lastMirrorTarget`), so an earlier test's mirror would otherwise satisfy a later one. + ;(globalThis as unknown as Record)[Symbol.for("altimate.memory-sync.mirror-requests")].last = + undefined syncInternals.resolveBinding = async () => BINDING as any // The store is not on disk in these tests; the delete-race guard has its // own coverage below. @@ -439,8 +449,71 @@ describe("blockTitle", () => { }) }) +/** Holds the workspace-list request (the memory-setting lookup) open until `release`; `started` resolves + * once it is waiting. */ +function holdWorkspaceLookup() { + const inner = globalThis.fetch + let release!: () => void + let entered!: () => void + const held = new Promise((resolve) => (release = resolve)) + const started = new Promise((resolve) => (entered = resolve)) + globalThis.fetch = (async (input: any, init?: any) => { + if (String(input).includes("/datamates/") && !String(input).includes("/memory")) { + entered() + await held + } + return inner(input, init) + }) as typeof fetch + return { started, release, restore: () => (globalThis.fetch = inner) } +} + // ── write path ────────────────────────────────────────────────────────────── describe("mirrorBlock", () => { + test("a mirror to a linked workspace is recorded, failed or not, so `run`'s exit checks what stayed local", async () => { + expect(lastMirrorTarget()).toBeUndefined() + createFails = true + await expect(mirrorBlock(block({ id: "unsent" }), "/repo")).rejects.toThrow() + createFails = false + expect(lastMirrorTarget()).toEqual({ directory: "/repo", binding: BINDING as any }) + }) + + test("the mirror is recorded while the memory-setting lookup is still pending, so an exit then still checks", async () => { + const lookup = holdWorkspaceLookup() + const mirror = mirrorBlock(block({ id: "pending-lookup" }), "/repo") + try { + await lookup.started + expect(lastMirrorTarget()).toEqual({ directory: "/repo", binding: BINDING as any }) + } finally { + lookup.release() + await mirror.catch(() => {}) + lookup.restore() + } + }) + + test("a disabled lookup takes back only its own record, not one a concurrent mirror made since", async () => { + workspaces = [{ id: 42, name: "acme", memory_enabled: false }] + const lookup = holdWorkspaceLookup() + const mirror = mirrorBlock(block({ id: "disabled-slow" }), "/repo") + const newer = { directory: "/other", binding: BINDING as any } + try { + await lookup.started + // Another block's mirror records its own target while this lookup is pending. + ;(globalThis as unknown as Record)[Symbol.for("altimate.memory-sync.mirror-requests")].last = newer + } finally { + lookup.release() + await mirror.catch(() => {}) + lookup.restore() + } + expect(lastMirrorTarget()).toBe(newer) + }) + + test("a workspace with memory turned off is not recorded: the block stays local by design", async () => { + workspaces = [{ id: 42, name: "acme", memory_enabled: false }] + await mirrorBlock(block({ id: "kept-local" }), "/repo") + expect(lastMirrorTarget()).toBeUndefined() + expect(captured.some((c) => c.method === "POST")).toBe(false) + }) + test("flushPendingMirrors waits for a mirror a short-lived process would abandon (#1332)", async () => { // `MemoryStore.write` fires the mirror and forgets it; a one-shot `run` exits // when the turn ends, routinely before the upload lands. The flush holds the diff --git a/packages/opencode/test/cli/run/fixtures/read-run-stdin.ts b/packages/opencode/test/cli/run/fixtures/read-run-stdin.ts new file mode 100644 index 0000000000..2b161f0830 --- /dev/null +++ b/packages/opencode/test/cli/run/fixtures/read-run-stdin.ts @@ -0,0 +1,6 @@ +// altimate_change - new file +// Reads this process's real stdin with `readRunStdin`, as `run` does, and prints the result. +import { readRunStdin, stdinWaitMs } from "../../../../src/altimate/cli/run-stdin" + +const result = await readRunStdin(undefined, stdinWaitMs(), process.argv[2] !== "unbounded") +process.stdout.write(JSON.stringify(result)) diff --git a/packages/opencode/test/cli/run/run-stdin-process.test.ts b/packages/opencode/test/cli/run/run-stdin-process.test.ts new file mode 100644 index 0000000000..2fc06710d0 --- /dev/null +++ b/packages/opencode/test/cli/run/run-stdin-process.test.ts @@ -0,0 +1,44 @@ +// altimate_change - new file +// `readRunStdin` on a real stdin: the other tests inject a fake source, so only these check that +// `fstatSync(0)` sees a socket for a Node `spawn` and a FIFO for a shell pipeline. +import { describe, expect, test } from "bun:test" +import { spawn } from "node:child_process" +import path from "node:path" + +const FIXTURE = path.join(import.meta.dir, "fixtures", "read-run-stdin.ts") +const env = { ...process.env, ALTIMATE_RUN_STDIN_WAIT_MS: "200" } + +function collect(child: ReturnType): Promise<{ code: number | null; out: string; ms: number }> { + const started = Date.now() + let out = "" + child.stdout!.on("data", (chunk) => (out += chunk)) + // `close`, not `exit`: it fires only after stdout is drained. + return new Promise((resolve) => child.on("close", (code) => resolve({ code, out, ms: Date.now() - started }))) +} + +describe("readRunStdin on a real stdin", () => { + test("a socket stdin that is never written or closed times out, and the process exits on its own", async () => { + const child = spawn(process.execPath, [FIXTURE], { env }) + const result = await collect(child) + expect(result.code).toBe(0) + expect(JSON.parse(result.out)).toEqual({ text: "", timedOut: true }) + expect(result.ms).toBeLessThan(10_000) + }) + + test("a socket stdin that is written and closed comes through", async () => { + const child = spawn(process.execPath, [FIXTURE], { env }) + child.stdin!.end("from the caller\n") + expect(JSON.parse((await collect(child)).out)).toEqual({ text: "from the caller\n", timedOut: false }) + }) + + test("with nothing else to run, a socket written after the wait still comes through", async () => { + const child = spawn(process.execPath, [FIXTURE, "unbounded"], { env }) + setTimeout(() => child.stdin!.end("the prompt\n"), 600) + expect(JSON.parse((await collect(child)).out)).toEqual({ text: "the prompt\n", timedOut: false }) + }) + + test.skipIf(process.platform === "win32")("a shell pipeline is read to EOF even when its first byte comes after the wait", async () => { + const child = spawn("sh", ["-c", `sleep 0.6; echo piped | "${process.execPath}" "${FIXTURE}"`], { env }) + expect(JSON.parse((await collect(child)).out)).toEqual({ text: "piped\n", timedOut: false }) + }) +}) diff --git a/packages/opencode/test/cli/run/run-stdin.test.ts b/packages/opencode/test/cli/run/run-stdin.test.ts new file mode 100644 index 0000000000..ba08deeaac --- /dev/null +++ b/packages/opencode/test/cli/run/run-stdin.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test" +import { readRunStdin, stdinWaitMs, STDIN_WAIT_MS, type StdinSource } from "../../../src/altimate/cli/run-stdin" + +const encoder = new TextEncoder() + +/** A socket stdin (unless `file` or `fifo` is set) whose stream yields `chunks` (the first at once, each later one after `delayMs`), then ends unless `open` is set. Its read-to-EOF path returns `file`, `fifo` or `late` after `readAllMs`. */ +function source(opts: { + chunks?: string[] + delayMs?: number + open?: boolean + file?: string + fifo?: string + late?: string + readAllMs?: number +}): StdinSource & { + cancelled: () => boolean +} { + let cancelled = false + return { + cancelled: () => cancelled, + isFile: () => opts.file !== undefined, + isSocket: () => opts.file === undefined && opts.fifo === undefined, + readAll: () => Bun.sleep(opts.readAllMs ?? 0).then(() => opts.file ?? opts.fifo ?? opts.late ?? ""), + stream: () => + new ReadableStream({ + async start(controller) { + for (const [i, chunk] of (opts.chunks ?? []).entries()) { + if (i > 0) await Bun.sleep(opts.delayMs ?? 0) + if (cancelled) return + controller.enqueue(encoder.encode(chunk)) + } + if (!opts.open) controller.close() + }, + cancel() { + cancelled = true + }, + }), + } +} + +describe("readRunStdin", () => { + test("reads a file in full without the wait", async () => { + const result = await readRunStdin(source({ file: "from a file\n" }), 10) + expect(result).toEqual({ text: "from a file\n", timedOut: false }) + }) + + test("a shell pipeline (not a socket) is read to EOF however long its producer takes", async () => { + const result = await readRunStdin(source({ fifo: "dbt build output\n", readAllMs: 60 }), 10) + expect(result).toEqual({ text: "dbt build output\n", timedOut: false }) + }) + + test("a socket is read to EOF when there is nothing else to run, however late it is written", async () => { + const result = await readRunStdin(source({ chunks: [], open: true, late: "the prompt\n", readAllMs: 60 }), 10, false) + expect(result).toEqual({ text: "the prompt\n", timedOut: false }) + }) + + test("a wait of 0 reads a socket to EOF", async () => { + const result = await readRunStdin(source({ chunks: [], open: true, late: "late\n", readAllMs: 60 }), 0) + expect(result).toEqual({ text: "late\n", timedOut: false }) + }) + + test("reads piped text to EOF", async () => { + const result = await readRunStdin(source({ chunks: ["hello ", "world\n"] }), 1000) + expect(result).toEqual({ text: "hello world\n", timedOut: false }) + }) + + test("keeps reading after the first chunk even past the wait", async () => { + // The first chunk is immediate, so only the later ones (160ms in all) outlast the 50ms wait. + const result = await readRunStdin(source({ chunks: ["a", "b", "c"], delayMs: 80 }), 50) + expect(result).toEqual({ text: "abc", timedOut: false }) + }) + + test("an empty stdin that closes returns at once", async () => { + const started = Date.now() + const result = await readRunStdin(source({ chunks: [] }), 5000) + expect(result).toEqual({ text: "", timedOut: false }) + expect(Date.now() - started).toBeLessThan(1000) + }) + + test("a stdin left open with no data gives up after the wait and releases the stream", async () => { + const stdin = source({ chunks: [], open: true }) + const started = Date.now() + const result = await readRunStdin(stdin, 50) + expect(result).toEqual({ text: "", timedOut: true }) + expect(Date.now() - started).toBeLessThan(1000) + await Bun.sleep(0) + expect(stdin.cancelled()).toBe(true) + }) +}) + +describe("stdinWaitMs", () => { + test("defaults to the 3s wait, and ALTIMATE_RUN_STDIN_WAIT_MS overrides it", () => { + expect(stdinWaitMs({})).toBe(STDIN_WAIT_MS) + expect(stdinWaitMs({ ALTIMATE_RUN_STDIN_WAIT_MS: "10000" })).toBe(10000) + expect(stdinWaitMs({ ALTIMATE_RUN_STDIN_WAIT_MS: " 0 " })).toBe(0) + }) + + test("a wait longer than a timer can hold is capped, not turned into an immediate timeout", () => { + expect(stdinWaitMs({ ALTIMATE_RUN_STDIN_WAIT_MS: "99999999999" })).toBe(2_147_483_647) + }) + + test("an unusable value keeps the default", () => { + for (const raw of ["", " ", "soon", "-1", "Infinity"]) + expect(stdinWaitMs({ ALTIMATE_RUN_STDIN_WAIT_MS: raw })).toBe(STDIN_WAIT_MS) + }) +}) diff --git a/packages/opencode/test/cli/workspace-command.test.ts b/packages/opencode/test/cli/workspace-command.test.ts index fc0ac55432..c91130fce5 100644 --- a/packages/opencode/test/cli/workspace-command.test.ts +++ b/packages/opencode/test/cli/workspace-command.test.ts @@ -6,13 +6,19 @@ import { describeRefresh, describeStatus, describeSync, + runList, runRefresh, runStatus, runSync, runUnlink, + WorkspaceCommand, type WorkspaceDeps, } from "../../src/cli/cmd/workspace" -import { linkHeadless, matchWorkspace, type LinkHeadlessDeps } from "../../src/cli/cmd/link" +import { SIGN_IN_COMMAND, linkHeadless, matchWorkspace, type LinkHeadlessDeps } from "../../src/cli/cmd/link" +import { AltimateAuthPlugin } from "../../src/altimate/plugin/altimate" +import { AltimateApi } from "../../src/altimate/api/client" +import { WorkspaceApi } from "../../src/altimate/workspace/api-client" +import yargs from "yargs" const binding = { datamateId: 35, @@ -22,6 +28,17 @@ const binding = { linkedAt: 0, } as any +describe("SIGN_IN_COMMAND", () => { + test("names a login method the altimate-backend provider has, so --method skips the picker", async () => { + const hooks = await AltimateAuthPlugin({} as any) + expect(hooks.auth?.provider).toBe("altimate-backend") + const labels = hooks.auth!.methods.map((m) => m.label) + const named = SIGN_IN_COMMAND.match(/--method "([^"]+)"/)?.[1] + expect(named).toBeDefined() + expect(labels).toContain(named!) + }) +}) + describe("workspace status wording", () => { test("an unlinked project says so and how to link", () => { const lines = describeStatus({ binding: null, memory: null, skillsEnabled: true, skillsSyncedAt: null }) @@ -131,6 +148,7 @@ function fakeDeps(over: Partial = {}) { const json: any[] = [] const deps: WorkspaceDeps = { isConfigured: async () => true, + list: async () => (calls.push("list"), [{ id: 35, name: "Growth" }, { id: 9, name: "Fin\u001b]8;;x\u0007ance" }]), resolve: async () => ({ status: "bound", binding }), status: async () => (calls.push("status"), statusReport), refresh: async () => (calls.push("refresh"), { skillsChanged: false, skillsSkipped: [], errors: [] } as any), @@ -149,6 +167,7 @@ function fakeDeps(over: Partial = {}) { describe("workspace subcommands", () => { test("not signed in: every subcommand exits 2 and touches nothing", async () => { for (const run of [ + (d: WorkspaceDeps) => runList(false, d), (d: WorkspaceDeps) => runStatus("/p", false, d), (d: WorkspaceDeps) => runRefresh("/p", false, d), (d: WorkspaceDeps) => runSync("/p", false, d), @@ -160,6 +179,24 @@ describe("workspace subcommands", () => { } }) + test("list prints each workspace's id and name, and how to link to one", async () => { + const f = fakeDeps() + expect(await runList(false, f.deps)).toBe(EXIT.OK) + expect(f.out).toEqual(["35 Growth", "9 Fin]8;;xance", "Link this project to one with `altimate-code link --workspace `."]) + const j = fakeDeps() + expect(await runList(true, j.deps)).toBe(EXIT.OK) + expect(j.json[0]).toEqual({ ok: true, workspaces: [{ id: 35, name: "Growth" }, { id: 9, name: "Fin\u001b]8;;x\u0007ance" }] }) + }) + + test("list with no workspaces points at --create; a failed request exits 1", async () => { + const empty = fakeDeps({ list: async () => [] }) + expect(await runList(false, empty.deps)).toBe(EXIT.OK) + expect(empty.out.join("\n")).toContain("link --create") + const down = fakeDeps({ list: async () => Promise.reject(new Error("503")) }) + expect(await runList(true, down.deps)).toBe(EXIT.FAILED) + expect(down.json[0]).toMatchObject({ ok: false, error: "Could not load workspaces: 503" }) + }) + test("a service that cannot be reached is a failure (1), never 'not linked' (3)", async () => { for (const run of [ (d: WorkspaceDeps) => runStatus("/p", true, d), @@ -417,3 +454,31 @@ describe("link --workspace / --create without prompting", () => { expect(f.calls).toEqual(["create:Marketing"]) }) }) + +describe("workspace list through the command parser", () => { + const original = { isConfigured: AltimateApi.isConfigured, listDatamates: WorkspaceApi.listDatamates, write: process.stdout.write } + let priorExitCode: typeof process.exitCode + beforeEach(() => { + priorExitCode = process.exitCode + }) + afterEach(() => { + ;(AltimateApi as any).isConfigured = original.isConfigured + ;(WorkspaceApi as any).listDatamates = original.listDatamates + process.stdout.write = original.write + process.exitCode = priorExitCode ?? 0 + }) + + test("`workspace list --json` is registered and prints the account's workspaces", async () => { + ;(AltimateApi as any).isConfigured = async () => true + ;(WorkspaceApi as any).listDatamates = async () => [{ id: 35, name: "Growth", memory_enabled: true }] + let written = "" + process.stdout.write = ((chunk: string) => { + written += chunk + return true + }) as typeof process.stdout.write + await yargs(["workspace", "list", "--json"]).command(WorkspaceCommand).strict().fail(false).parseAsync() + process.stdout.write = original.write + expect(JSON.parse(written)).toEqual({ ok: true, workspaces: [{ id: 35, name: "Growth" }] }) + expect(process.exitCode).toBe(0) + }) +}) diff --git a/packages/opencode/test/release-validation/question-937-codex.test.ts b/packages/opencode/test/release-validation/question-937-codex.test.ts index d389e04524..3a7deeb244 100644 --- a/packages/opencode/test/release-validation/question-937-codex.test.ts +++ b/packages/opencode/test/release-validation/question-937-codex.test.ts @@ -345,10 +345,20 @@ describe("release validation PR #937 source-level env plumbing", () => { expect(source).not.toContain('process.env["ALTIMATE_NON_INTERACTIVE"] === undefined') }) - test("run command guards Bun.stdin.text() behind an existing non-TTY stdin", async () => { + test("run command guards the stdin read behind an existing non-TTY stdin", async () => { const source = await Bun.file(new URL("../../src/cli/cmd/run.ts", import.meta.url)).text() - expect(source).toContain("if (process.stdin && !process.stdin.isTTY) message +=") + const guard = "if (process.stdin && !process.stdin.isTTY) {" + const start = source.indexOf(guard) + expect(start).toBeGreaterThan(-1) + // The guard's block, up to its matching brace, must contain the read. + let depth = 0 + let end = start + guard.length - 1 + for (; end < source.length; end++) { + if (source[end] === "{") depth++ + else if (source[end] === "}" && --depth === 0) break + } + expect(source.slice(start, end)).toContain("readRunStdin(") expect(source).not.toContain("if (!process.stdin?.isTTY)") }) diff --git a/packages/opencode/test/release-validation/question-937.test.ts b/packages/opencode/test/release-validation/question-937.test.ts index f61748810f..21849c3142 100644 --- a/packages/opencode/test/release-validation/question-937.test.ts +++ b/packages/opencode/test/release-validation/question-937.test.ts @@ -386,8 +386,8 @@ describe("run.ts ALTIMATE_NON_INTERACTIVE guard contract", () => { // --------------------------------------------------------------------------- // Gap #9 — null-safe stdin read does not stall when process.stdin is undefined. // -// The guard is inline in src/cli/cmd/run.ts:463 and not exported: -// if (process.stdin && !process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) +// The guard is inline in src/cli/cmd/run.ts and not exported: +// if (process.stdin && !process.stdin.isTTY) { ... await readRunStdin() ... } // Helper mirrors that predicate and the append behavior so the regression // (undefined stdin satisfied `!stdin?.isTTY` and stalled awaiting EOF) is // locked: undefined stdin must NOT trigger the read. diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index f250e5f098..b69066c2ce 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1885,6 +1885,71 @@ it.instance( ), 30_000, ) + +// A linked workspace that had no skills at the last sync leaves no snapshot, only the empty record. The +// first skill added to it must reach the next `run`, which is a fresh process, so that turn waits for the +// sync like a project with a snapshot does, before the turn's message is written. +let emptyRecordFile: string | undefined +it.instance( + "a turn waits for the workspace sync when the linked workspace had no skills at the last sync", + () => + withWorkspacesOn( + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const { prompt, chat } = yield* boot() + const skillSync = yield* Effect.promise(() => import("../../src/altimate/workspace/skill-sync")) + let landedAt: number | undefined + let messageAt: number | undefined + // `prompt` touches the session right after it creates the user message, so this marks when the + // turn's message was written. + const realTouch = LegacySession.touch + const touch = spyOn(LegacySession, "touch").mockImplementation(((sessionID: SessionID) => { + messageAt ??= performance.now() + return realTouch(sessionID) + }) as typeof LegacySession.touch) + const recent = spyOn(skillSync, "recentlySynced").mockResolvedValue(false) + const sync = spyOn(skillSync, "syncSkills").mockImplementation( + () => + new Promise((resolve) => + setTimeout(() => { + landedAt = performance.now() + resolve({ changed: false, skipped: [] } as unknown as Awaited>) + }, 500), + ), + ) + yield* Effect.gen(function* () { + yield* llm.text("done") + yield* prompt.prompt({ + sessionID: chat.id, agent: "build", model: promptRef, + parts: [{ type: "text", text: "Hello." }], + }) + expect(sync).toHaveBeenCalled() + expect(landedAt).toBeDefined() + expect(messageAt).toBeDefined() + expect(landedAt!).toBeLessThan(messageAt!) + }).pipe( + Effect.ensuring( + Effect.sync(() => { + recent.mockRestore() + sync.mockRestore() + touch.mockRestore() + if (emptyRecordFile) require("fs").rmSync(emptyRecordFile, { force: true }) + }), + ), + ) + }), + ), + { + init: (directory: string) => + Effect.promise(async () => { + const { emptyRecordPath } = await import("../../src/altimate/workspace/empty-record-path") + emptyRecordFile = emptyRecordPath(path.resolve(directory)) + require("fs").mkdirSync(path.dirname(emptyRecordFile), { recursive: true }) + require("fs").writeFileSync(emptyRecordFile, "key\nwrite-id") + }), + }, + 30_000, +) // altimate_change end it.instance.todo( "running task tool preserves metadata after tool-call transition",