diff --git a/docs/docs/usage/cli.md b/docs/docs/usage/cli.md index ccf2e4365..77657810f 100644 --- a/docs/docs/usage/cli.md +++ b/docs/docs/usage/cli.md @@ -40,6 +40,7 @@ altimate --agent analyst | `import` | Import session data | | `session` | Session management | | `link` | Link this project to an Altimate workspace (pilot, requires `ALTIMATE_WORKSPACE=1`) | +| `workspace` | Show, refresh, sync or unlink this project's workspace from a shell (pilot, requires `ALTIMATE_WORKSPACE=1`) | | `trace` | List and view session traces (recordings of agent sessions) | | `github` | GitHub integration | | `pr` | Pull request tools | @@ -52,6 +53,11 @@ altimate --agent analyst Workspace features are off unless `ALTIMATE_WORKSPACE=1` is set. With it: - `altimate-code link` links the current project to a workspace (or creates one). 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 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`. - `/workspace` in the TUI opens a menu: **Refresh** pulls the workspace's skills and memory into this project, **Sync** re-sends local memory the workspace never received, **Open in browser** (when a web URL is available) shows the workspace on the web, **Switch workspace** relinks the project, **Unlink** detaches it. In a project that is not linked yet, it offers **Link to a workspace** instead. - `altimate-code skill publish ` uploads a project skill to the linked workspace; see [Skills](../configure/skills.md#cli-commands). - In an ordinary session the agent is told every turn which workspace the project is linked to — or that none is, or that the link could not be verified just now. In an extension-pinned session it is told the pinned workspace instead, and that it differs from the project's own link. Either way "which workspace am I in?" has an answer, and the agent is told not to confuse it with a Databricks workspace or an IDE workspace folder. diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index 92a925c13..47c4a9a67 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -378,9 +378,9 @@ export { req as altimateRequest } export namespace WorkspaceApi { /** Server-authoritative pre-check by git remote. Returns null on 404. */ - export async function getBindingForRemote(remote: string): Promise { + export async function getBindingForRemote(remote: string, actAs?: ActAs): Promise { try { - return await req("GET", "/by-remote", { query: { repo_remote: remote } }) + return await req("GET", "/by-remote", { query: { repo_remote: remote }, ...(actAs ? { actAs } : {}) }) } catch (err) { if (err instanceof NotFoundError) return null throw err @@ -389,9 +389,9 @@ export namespace WorkspaceApi { /** Symmetric pre-check by absolute project directory path (for projects * without a git remote). Returns null on 404. */ - export async function getBindingForPath(projectPath: string): Promise { + export async function getBindingForPath(projectPath: string, actAs?: ActAs): Promise { try { - return await req("GET", "/by-path", { query: { project_path: projectPath } }) + return await req("GET", "/by-path", { query: { project_path: projectPath }, ...(actAs ? { actAs } : {}) }) } catch (err) { if (err instanceof NotFoundError) return null throw err @@ -403,13 +403,13 @@ export namespace WorkspaceApi { * picks the right endpoint even if the current identifier's remote has * changed since the binding was created (M3). Both fields on the * identifier are optional but at least one must be present. */ - export async function getBindingForProject(id: ProjectIdentifier): Promise { + export async function getBindingForProject(id: ProjectIdentifier, actAs?: ActAs): Promise { if (id.repoRemote) { - const hit = await getBindingForRemote(id.repoRemote) + const hit = await getBindingForRemote(id.repoRemote, actAs) if (hit) return { ...hit, matchedBy: "remote" } } if (id.projectPath) { - const hit = await getBindingForPath(id.projectPath) + const hit = await getBindingForPath(id.projectPath, actAs) if (hit) return { ...hit, matchedBy: "path" } } return null diff --git a/packages/opencode/src/altimate/workspace/manage.ts b/packages/opencode/src/altimate/workspace/manage.ts index ebf133e5d..7ebe13c9d 100644 --- a/packages/opencode/src/altimate/workspace/manage.ts +++ b/packages/opencode/src/altimate/workspace/manage.ts @@ -50,6 +50,9 @@ export interface StatusReport { * sidebar, when the service could not be reached. Rendering that as * 0 would tell the user their memory is current when nobody knows. */ memory: { local: number; unsynced: number | null } | null + /** Set when memory is on but the local store or its index could not be read: `memory` is then null, which + * alone would read as "off". */ + memoryUnreadable?: true skillsEnabled: boolean /** When workspace skills last synced successfully, or null if they have not in * this process. Null is genuinely "unknown", not "never" — the store is @@ -85,7 +88,14 @@ export interface SyncReport { * and only one of them is the workspace's memory toggle; a toast that said * "memory is off" for a failed local read sent the user to a setting that was * fine. */ - gatedBecause?: "flag-off" | "no-binding" | "pin-unresolved" | "memory-off" | "read-failed" | "setting-unavailable" + gatedBecause?: + | "flag-off" + | "no-binding" + | "pin-unresolved" + | "memory-off" + | "read-failed" + | "setting-unavailable" + | "not-approved" sent: number failed: number /** Already present in the workspace at their current payload. */ @@ -139,9 +149,11 @@ export async function status( ? await resolveBinding(directory).catch(() => null) : ((await readLocalBinding(directory).catch(() => null)) ?? (await resolveBinding(directory).catch(() => null))) + const memory = await memoryCounts(directory, binding, opts.poll === true) return { binding, - memory: await memoryCounts(directory, binding, opts.poll === true), + memory: memory === "unreadable" ? null : memory, + ...(memory === "unreadable" ? { memoryUnreadable: true as const } : {}), skillsEnabled: SkillSync.isEnabled(), skillsSyncedAt: await skillsSyncedAt(directory, binding), } @@ -223,7 +235,13 @@ export async function refresh(directory: string, sessionID?: string): Promise { +export async function sync( + directory: string, + /** A binding the caller has already verified. Used as given rather than re-read from the shared cache, which another + * process can replace between the caller's check and the sweep; one never approved on this machine is refused here, + * where the memory is actually sent. */ + opts: { binding?: CachedBinding } = {}, +): Promise { const gated = (why: NonNullable): SyncReport => ({ gated: true, gatedBecause: why, @@ -240,9 +258,15 @@ export async function sync(directory: string): Promise { // unpinned session keeps the cache-only read, and a pin that cannot be honoured stays gated — // under its own reason, since "nothing is linked" would misdescribe a workspace that exists — // rather than falling through to the project's link. - const pinned = await resolvePinnedBindingForRouting(directory).catch(() => ({ status: "unknown" as const })) - if (pinned && pinned.status !== "bound") return gated("pin-unresolved") - const binding = pinned ? pinned.binding : await readLocalBinding(directory).catch(() => null) + let binding: CachedBinding | null + if (opts.binding) { + if (opts.binding.adopted) return gated("not-approved") + binding = opts.binding + } else { + const pinned = await resolvePinnedBindingForRouting(directory).catch(() => ({ status: "unknown" as const })) + if (pinned && pinned.status !== "bound") return gated("pin-unresolved") + binding = pinned ? pinned.binding : await readLocalBinding(directory).catch(() => null) + } if (!binding) return gated("no-binding") const blocks = await MemoryStore.listAll({ directory }).catch((err) => { @@ -300,7 +324,7 @@ async function memoryCounts( directory: string, binding: CachedBinding | null, poll: boolean, -): Promise<{ local: number; unsynced: number | null } | null> { +): Promise<{ local: number; unsynced: number | null } | null | "unreadable"> { if (!MemorySync.isEnabled()) return null try { const blocks = await MemoryStore.listAll({ directory }) @@ -317,7 +341,7 @@ async function memoryCounts( return { local: blocks.length, unsynced: await MemorySync.pendingCount(blocks, binding, { network: false }) } } catch (err) { log.warn("could not count local memory for the workspace status", { err: String(err) }) - return null + return "unreadable" } } diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index 9724ac9f4..686c2517a 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -916,6 +916,21 @@ export function peekRowUnscoped(directory: string): UnscopedRow | null { } } +/** Whether this directory holds the approved (not adopted) row a `recordApprovedBinding` call wrote: `datamateId` + * under `account`, stamped with the `linkedAt` that call passed, so an approval recorded earlier does not count. + * `recordApprovedBinding` returns "account-changed" both before its write and after it (at the memory seed); + * this tells the caller which one happened. */ +export function isApprovedRow(directory: string, account: string, datamateId: number, linkedAt: number): boolean { + try { + const cache = readCache() + if (!cache || cache.account !== account) return false + const row = primaryRow(cache, directory) + return row?.datamateId === datamateId && row.linkedAt === linkedAt && !row.adopted + } catch { + return false + } +} + /** The row reads win for a directory: the canonical key, or failing that the * newest alias. */ function primaryRow(cache: CacheFile, directory: string): CachedBinding | undefined { diff --git a/packages/opencode/src/cli/cmd/link.ts b/packages/opencode/src/cli/cmd/link.ts index 587990a37..8c90a2e4e 100644 --- a/packages/opencode/src/cli/cmd/link.ts +++ b/packages/opencode/src/cli/cmd/link.ts @@ -43,7 +43,7 @@ import { resolveWorkspaceWebUrl, type HandoffResult, } from "@/altimate/workspace/browser-handoff" -import { accountDigest, credentialDigest, recordApprovedBinding } from "@/altimate/workspace/state" +import { accountDigest, credentialDigest, isApprovedRow, recordApprovedBinding } from "@/altimate/workspace/state" import type { SeedOutcome } from "@/altimate/workspace/memory-backfill" import { confirmsNamesake, @@ -186,13 +186,48 @@ export const LinkCommand = cmd({ command: "link", describe: "Link this project to an Altimate workspace", builder: (yargs) => - yargs.option("directory", { - alias: "d", - describe: "Project directory (defaults to cwd)", - type: "string", - default: process.cwd(), - }), + yargs + .option("directory", { + alias: "d", + describe: "Project directory (defaults to cwd)", + type: "string", + default: process.cwd(), + }) + // altimate_change start — non-interactive link for scripts, devcontainers and CI + .option("workspace", { + alias: "w", + describe: "Link to this existing workspace without prompting: an id, or an exact name (an id is matched first)", + type: "string", + }) + .option("create", { + describe: "Create a workspace with this name (defaults to the repo name) and link to it without prompting", + type: "string", + }) + .option("yes", { + alias: "y", + describe: "Allow replacing an existing link when --workspace or --create is used", + type: "boolean", + default: false, + }) + .option("allow-duplicate", { + describe: "With --create, create the workspace even if one with that name already exists", + type: "boolean", + default: false, + }), + // altimate_change end handler: async (args) => { + // altimate_change start — non-interactive link + if (args.workspace !== undefined || args.create !== undefined) { + await linkHeadless({ + directory: args.directory, + workspace: args.workspace, + create: args.create, + yes: args.yes, + allowDuplicate: args["allow-duplicate"], + }) + return + } + // altimate_change end // Fail fast on non-TTY stdin — the whole subcommand is a series of // ``@clack/prompts`` interactive selects (workspace picker, name prompt, // confirm), so a piped or redirected stdin (``altimate-code link < /dev/null``, @@ -253,7 +288,7 @@ export const LinkCommand = cmd({ process.exitCode = 1 return } - const spin = prompts.spinner() + const spin = spinner() spin.start("Loading workspaces...") let list: DatamateRef[] try { @@ -416,7 +451,7 @@ async function runBrowserHandoff( process.exitCode = 1 return } - const spin = prompts.spinner() + const spin = spinner() spin.start("Waiting for browser approval (up to 15 min)...") const result: HandoffResult = await openWorkspaceBrowserHandoff({ identifier, projectName }) if (!result.ok) { @@ -447,7 +482,7 @@ async function runBrowserHandoff( return } spin.stop(`Workspace approved. Linking it to this project...`) - const bindSpin = prompts.spinner() + const bindSpin = spinner() bindSpin.start("Linking workspace...") try { const res = await WorkspaceApi.bindExisting(result.workspaceId, identifier) @@ -544,6 +579,8 @@ export async function createThenBindOrRebind( name: string, directory: string, existing: ProjectBindingLookup | null, + /** False for a headless run: print the manage URL, but there is nobody at a browser to look at it. */ + opts: { openBrowser?: boolean } = {}, ): Promise { // The account this bind acts as; the seed refuses (account-changed) if it switches mid-way. // Unreadable credentials cannot link anyway, and must not leave the bind unguarded. @@ -553,7 +590,7 @@ export async function createThenBindOrRebind( process.exitCode = 1 return } - const spin = prompts.spinner() + const spin = spinner() spin.start(`Creating workspace "${name}"...`) // Discriminated on how the workspace was made, because the two creates return // genuinely different things: only `bound` carries a server binding row and a @@ -627,7 +664,7 @@ export async function createThenBindOrRebind( // its binding from the atomic create, so there is nothing left to do. let reboundBinding: Binding | null = null if (existing) { - const rebindSpin = prompts.spinner() + const rebindSpin = spinner() rebindSpin.start(`Repointing project at "${safeCreatedName}"...`) // The workspace exists on the account that was in effect a moment ago, and // its id means nothing anywhere else. Rebinding under a different account @@ -690,7 +727,9 @@ export async function createThenBindOrRebind( // delegates to the OS handler, so a rogue value could launch an unrelated // application. Log a warning and skip the auto-open rather than trusting // whatever protocol the URL parses to. - if (isSafeHttpUrl(manageUrl)) { + if (opts.openBrowser === false) { + // printed above; nothing to open + } else if (isSafeHttpUrl(manageUrl)) { await open(manageUrl).catch(() => undefined) } else { prompts.log.warn(`Skipped auto-open: manage_url is not an http/https URL.`) @@ -733,7 +772,7 @@ async function bindOrRebind( return } const isRebind = existing !== null - const spin = prompts.spinner() + const spin = spinner() spin.start(isRebind ? `Re-linking to workspace...` : `Linking to workspace...`) try { let res @@ -768,7 +807,7 @@ async function bindOrRebind( // the current identifier reproduces the M3 hazard on this fallback // path. (Kilo cycle 6.) spin.stop("This project is already linked to a workspace — re-linking it instead.") - const rebindSpin = prompts.spinner() + const rebindSpin = spinner() rebindSpin.start("Re-linking...") try { // detail.project_path present → the conflicting binding is @@ -852,22 +891,22 @@ async function bindOrRebind( export function seedMessage(seed: SeedOutcome | null): string { if (seed?.status === "incomplete") return seed.pending > 0 - ? `${seed.pending} saved memor${seed.pending === 1 ? "y" : "ies"} did not reach the workspace yet. Run /workspace → Sync in the TUI to retry.` - : "Saved memory could not be sent to the workspace yet. Run /workspace → Sync in the TUI to retry." + ? `${seed.pending} saved memor${seed.pending === 1 ? "y" : "ies"} did not reach the workspace yet. Run \`altimate-code workspace sync\` (or /workspace → Sync in the TUI) to retry.` + : "Saved memory could not be sent to the workspace yet. Run `altimate-code workspace sync` (or /workspace → Sync in the TUI) to retry." if (seed?.status === "seeded") return seed.sent > 0 ? `Sent ${seed.sent} saved memor${seed.sent === 1 ? "y" : "ies"} to the workspace.` : "Saved memory is in sync with the workspace." // `already` means the one-time bind seed ran before, not that every block is synced now. if (seed?.status === "already") - return "This machine's saved memory was sent when this workspace was first linked. To resend anything missed since, run /workspace → Sync in the TUI." + return "This machine's saved memory was sent when this workspace was first linked. To resend anything missed since, run `altimate-code workspace sync` (or /workspace → Sync in the TUI)." if (seed?.status === "off") return "Workspace memory is off, so saved memory stays on this machine." if (seed?.status === "account-changed") - return "Your Altimate account changed during linking, so saved memory was not sent. Run /workspace → Sync in the TUI to retry." + return "Your Altimate account changed during linking, so saved memory was not sent. Run `altimate-code workspace sync` (or /workspace → Sync in the TUI) to retry." if (seed?.status === "local-off") return "Memory sync is turned off on this machine (ALTIMATE_DISABLE_MEMORY or OPENCODE_DISABLE_MEMORY), so saved memory stays here." // null: the seed could not run here (no resolvable credentials), which is not "off". - return "Saved memory could not be checked against the workspace. Run /workspace → Sync in the TUI to retry." + return "Saved memory could not be checked against the workspace. Run `altimate-code workspace sync` (or /workspace → Sync in the TUI) to retry." } /** Pick the rebind endpoint that matches which identifier the pre-check @@ -906,3 +945,237 @@ async function rebindByMatchedIdentifier(input: { `Cannot re-link: the existing link was found by this project's ${input.matchedBy === "remote" ? "git remote" : "path"}, which the project no longer has.`, ) } + +// altimate_change start — non-interactive link +/** clack's spinner redraws in place; without a terminal every frame lands on one line of a CI + * log. Off a TTY, print the start and the outcome as plain lines instead. */ +function spinner(): { start(msg?: string): void; stop(msg?: string, code?: number): void; message(msg?: string): void } { + if (process.stdout.isTTY) return prompts.spinner() + return { + start: (msg) => msg && UI.println(msg), + message: () => {}, + stop: (msg, code) => { + if (!msg) return + if (code) UI.error(msg) + else UI.println(msg) + }, + } +} + +/** Exit code for a request the caller has to change, e.g. a re-link without --yes. */ +const EXIT_USAGE = 2 + +/** The workspace `--workspace` names: by numeric id first, then by exact name (case-insensitive). */ +export function matchWorkspace( + list: DatamateRef[], + wanted: string, +): { kind: "one"; workspace: DatamateRef } | { kind: "none" } | { kind: "many"; matches: DatamateRef[] } { + const trimmed = wanted.trim() + if (/^\d+$/.test(trimmed)) { + const byId = list.find((dm) => dm.id === Number(trimmed)) + if (byId) return { kind: "one", workspace: byId } + } + const matches = list.filter((dm) => dm.name.trim().toLowerCase() === trimmed.toLowerCase()) + if (matches.length === 1) return { kind: "one", workspace: matches[0] } + if (matches.length > 1) return { kind: "many", matches } + return { kind: "none" } +} + +/** What `linkHeadless` needs from the service; replaced in tests so its guards can be checked without one. */ +export interface LinkHeadlessDeps { + isConfigured(): Promise + getBindingForProject( + identifier: ProjectIdentifier, + actAs: NonNullable>>, + ): Promise + captureCredentials(): ReturnType + /** Record on this machine that the user approved a link the service already has; no server call. Runs as + * `actAs`, the credential the link was looked up with; false when nothing was recorded. */ + approve(identifier: ProjectIdentifier, existing: ProjectBindingLookup, actAs: ActAs): Promise + listDatamates(actAs: NonNullable>>): Promise + bindOrRebind( + identifier: ProjectIdentifier, + datamateId: number, + existing: ProjectBindingLookup | null, + actAs: NonNullable>>, + ): Promise + create(identifier: ProjectIdentifier, name: string, existing: ProjectBindingLookup | null): Promise + print(line: string): void + printError(line: string): void +} + +export async function linkHeadless( + args: { directory: string; workspace?: string; create?: string; yes: boolean; allowDuplicate?: boolean }, + deps: LinkHeadlessDeps = linkHeadlessDeps(args.directory), +): Promise { + if (args.workspace !== undefined && args.create !== undefined) { + deps.printError("Use either --workspace or --create, not both.") + process.exitCode = EXIT_USAGE + return + } + if (args.workspace !== undefined && !args.workspace.trim()) { + deps.printError("--workspace needs a workspace id or name.") + process.exitCode = EXIT_USAGE + return + } + if (!(await deps.isConfigured())) { + deps.printError("Not signed in to Altimate. Run altimate-code, sign in, then re-run `altimate-code link`.") + process.exitCode = EXIT_USAGE + return + } + const identifier = resolveProjectIdentifier(args.directory) + + // Same rule as the picker: the pre-check, the list and the bind all run as one captured credential, so an + // account switch in between cannot pair one tenant's link with another tenant's workspace ids. + const actAs = await deps.captureCredentials() + if (!actAs) { + deps.printError("Could not read your Altimate credentials. Check /connect and try again.") + process.exitCode = 1 + return + } + + // Unlike the interactive picker, a failed pre-check stops here: the picker can + // fall back to retrying a conflict as a re-link, which must not happen without + // the caller having asked for it. + let existing: ProjectBindingLookup | null + try { + existing = await deps.getBindingForProject(identifier, actAs) + } catch (err) { + deps.printError( + `Could not check which workspace this project is linked to, so nothing was changed: ${stripControlChars(err instanceof Error ? err.message : String(err))}`, + ) + process.exitCode = 1 + return + } + const currentName = existing ? stripControlChars(existing.datamate.name) : undefined + + // An explicit `link` for the workspace the service already has: record the user's approval here (a fresh clone + // only knows the link as discovered, which `workspace sync` refuses), without a redundant server rebind. + const alreadyLinked = async (lookup: ProjectBindingLookup): Promise => { + let recorded: boolean + try { + recorded = await deps.approve(identifier, lookup, actAs) + } catch (err) { + deps.printError( + `The link was not confirmed on this machine: ${stripControlChars(err instanceof Error ? err.message : String(err))}`, + ) + process.exitCode = 1 + return + } + if (!recorded) { + deps.printError( + "Your Altimate account changed while linking, so the link was not confirmed on this machine. Re-run the command.", + ) + process.exitCode = 1 + return + } + deps.print(`Already linked to "${stripControlChars(lookup.datamate.name)}" — link confirmed on this machine.`) + } + + // Re-running the same `link --create` (a devcontainer rebuild) finds the workspace it made last time; checked + // before listing workspaces, so a rebuild does not depend on the list being available. + const createName = + args.create === undefined + ? undefined + : args.create.trim() || + (identifier.repoRemote ? projectNameFromRemote(identifier.repoRemote) : projectNameFromPath(identifier.projectPath)) + if (createName !== undefined && !args.allowDuplicate && existing && findNamesakes([existing.datamate], createName, undefined).all.length > 0) { + await alreadyLinked(existing) + return + } + + let list: DatamateRef[] + try { + list = await deps.listDatamates(actAs) + } catch (err) { + deps.printError(`Could not load workspaces: ${stripControlChars(err instanceof Error ? err.message : String(err))}`) + process.exitCode = 1 + return + } + + if (createName !== undefined) { + const name = createName + if (existing && !args.yes) { + deps.printError(`This project is already linked to "${currentName}". Pass --yes to create "${stripControlChars(name)}" and re-link to it.`) + process.exitCode = EXIT_USAGE + return + } + // The picker asks before creating a second workspace with a name in use; headless cannot ask, so it refuses. + const twins = findNamesakes(list, name, undefined).all + if (twins.length > 0 && !args.allowDuplicate) { + const ids = twins.map((dm) => dm.id).join(", ") + deps.printError( + `A workspace named "${stripControlChars(name)}" already exists (id ${ids}). Link to it with --workspace ${twins[0].id}, ` + + `or pass --allow-duplicate to create another.`, + ) + process.exitCode = EXIT_USAGE + return + } + await deps.create(identifier, name, existing) + return + } + + const match = matchWorkspace(list, args.workspace ?? "") + if (match.kind === "none") { + const names = list.map((dm) => `${stripControlChars(dm.name)} (id ${dm.id})`).join(", ") + deps.printError( + `No workspace named or numbered "${stripControlChars(args.workspace ?? "")}". ${names ? `Available: ${names}.` : "This account has no workspaces."}`, + ) + process.exitCode = 1 + return + } + if (match.kind === "many") { + const ids = match.matches.map((dm) => dm.id).join(", ") + deps.printError(`More than one workspace is named "${stripControlChars(args.workspace ?? "")}" (ids ${ids}). Pass the id instead.`) + process.exitCode = EXIT_USAGE + return + } + const target = match.workspace + if (existing?.datamate.id === target.id) { + await alreadyLinked(existing) + return + } + if (existing && !args.yes) { + deps.printError(`This project is already linked to "${currentName}". Pass --yes to re-link it to "${stripControlChars(target.name)}".`) + process.exitCode = EXIT_USAGE + return + } + await deps.bindOrRebind(identifier, target.id, existing, actAs) +} + +function linkHeadlessDeps(directory: string): LinkHeadlessDeps { + return { + isConfigured: () => AltimateApi.isConfigured(), + getBindingForProject: (identifier, actAs) => WorkspaceApi.getBindingForProject(identifier, actAs), + captureCredentials: () => WorkspaceApi.captureCredentials(), + approve: async (identifier, existing, actAs) => { + // Pinned to the credential the link was looked up with: refused if the configured account is another one now. + const account = credentialDigest(actAs.url, actAs.instance, actAs.apiKey) + if ((await accountDigest()) !== account) return false + const key = identifier.projectPath ?? directory + const linkedAt = Date.now() + const seed = await recordApprovedBinding( + key, + { + datamateId: existing.datamate.id, + datamateName: existing.datamate.name, + repoRemote: existing.binding.repo_remote, + projectPath: existing.binding.project_path ?? identifier.projectPath ?? null, + linkedAt, + }, + { awaitBackfill: true, account }, + ) + // The row this call wrote, not the seed outcome, says whether the approval was recorded: "account-changed" also + // comes back after the write, when only the memory seed was skipped (seedMessage says so). + if (!isApprovedRow(key, account, existing.datamate.id, linkedAt)) return false + UI.println(seedMessage(seed)) + return true + }, + listDatamates: (actAs) => WorkspaceApi.listDatamates(actAs), + bindOrRebind: (identifier, datamateId, existing, actAs) => bindOrRebind(identifier, datamateId, existing, true, directory, actAs), + create: (identifier, name, existing) => createThenBindOrRebind(identifier, name, directory, existing, { openBrowser: false }), + print: (line) => UI.println(line), + printError: (line) => UI.error(line), + } +} +// altimate_change end diff --git a/packages/opencode/src/cli/cmd/workspace.ts b/packages/opencode/src/cli/cmd/workspace.ts new file mode 100644 index 000000000..240ecc970 --- /dev/null +++ b/packages/opencode/src/cli/cmd/workspace.ts @@ -0,0 +1,356 @@ +// altimate_change - new file +// +// `altimate-code workspace 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 +// branch on: +// +// 0 the action ran (status: the project is linked) +// 1 the action failed (service unreachable, credentials unreadable, a write refused) +// 2 a request to change: not signed in, a destructive action without `--yes` and no terminal to ask +// on, or a `sync` to a link not yet confirmed on this machine (`link --workspace ` confirms it) +// 3 the project is not linked to a workspace +import * as prompts from "@clack/prompts" +import { cmd } from "./cmd" +import { bootstrap } from "../bootstrap" +import { UI } from "../ui" +import { stripControlChars } from "./link" +import { AltimateApi } from "@/altimate/api/client" +import * as Manage from "@/altimate/workspace/manage" +import { resolveBindingOutcome, type BindingOutcome, type CachedBinding } from "@/altimate/workspace/state" + +export const EXIT = { OK: 0, FAILED: 1, USAGE: 2, NOT_LINKED: 3 } as const + +/** "6m ago" style, for a timestamp in ms; null when unknown. */ +export function ago(at: number | null, now = Date.now()): string | null { + if (at === null) return null + const ms = Math.max(0, now - at) + if (ms < 60_000) return "just now" + const minutes = Math.floor(ms / 60_000) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(ms / 3_600_000) + if (hours < 48) return `${hours}h ago` + return `${Math.floor(hours / 24)}d ago` +} + +/** Human-readable lines for `workspace status`. Pure, so the wording is testable. */ +export function describeStatus(report: Manage.StatusReport, now = Date.now()): string[] { + if (!report.binding) { + return ["This project is not linked to a workspace.", "Link it with `altimate-code link`."] + } + const b = report.binding + // Workspace names come from the service with no charset rules, so control sequences are stripped from + // everything printed for a person; `--json` keeps the raw values. + const lines = [`Linked to workspace "${stripControlChars(b.datamateName)}" (id ${b.datamateId}).`] + if (b.repoRemote) lines.push(`Matched by git remote: ${stripControlChars(b.repoRemote)}`) + else if (b.projectPath) lines.push(`Matched by path: ${stripControlChars(b.projectPath)}`) + if (report.memoryUnreadable) lines.push("Memory: the memory saved on this machine could not be read.") + else if (!report.memory) lines.push("Memory: off.") + else if (report.memory.unsynced === null) + lines.push(`Memory: ${report.memory.local} saved here; how many reached the workspace is not known right now.`) + else if (report.memory.unsynced === 0) lines.push(`Memory: ${report.memory.local} saved here, all in the workspace.`) + else + lines.push( + `Memory: ${report.memory.local} saved here, ${report.memory.unsynced} not yet in the workspace — run \`altimate-code workspace sync\`.`, + ) + if (!report.skillsEnabled) lines.push("Workspace skills: off.") + else { + const when = ago(report.skillsSyncedAt, now) + lines.push(when ? `Workspace skills: last synced ${when}.` : "Workspace skills: not synced by this process yet.") + } + return lines +} + +/** Lines and exit code for `workspace sync`. */ +export function describeSync(report: Manage.SyncReport): { lines: string[]; code: number } { + if (report.gated) { + switch (report.gatedBecause) { + case "no-binding": + return { lines: ["This project is not linked to a workspace, so there is nothing to sync."], code: EXIT.NOT_LINKED } + case "memory-off": + return { lines: ["Memory is off for this workspace, so nothing was sent."], code: EXIT.OK } + case "not-approved": + return { + lines: ["This project's link has not been confirmed on this machine, so nothing was sent. Run `altimate-code link --workspace ` to confirm it."], + code: EXIT.USAGE, + } + case "flag-off": + // With workspaces on, only the memory switch (ALTIMATE_DISABLE_MEMORY / OPENCODE_DISABLE_MEMORY) gets here. + return { lines: ["Memory is turned off on this machine, so nothing was sent."], code: EXIT.OK } + case "pin-unresolved": + return { + lines: ["The workspace selected for this folder could not be confirmed, so nothing was sent. Try again."], + code: EXIT.FAILED, + } + case "setting-unavailable": + return { + lines: ["Could not check whether this workspace has memory turned on, so nothing was sent. Try again."], + code: EXIT.FAILED, + } + default: + return { lines: ["Could not read the memory saved on this machine, so nothing was sent."], code: EXIT.FAILED } + } + } + const lines = [ + `${report.sent} sent, ${report.skipped} already in the workspace` + + (report.deferred ? `, ${report.deferred} held back` : "") + + (report.declined ? `, ${report.declined} refused by the workspace` : "") + + (report.failed ? `, ${report.failed} failed` : "") + + ".", + ] + if (report.deferred) lines.push("Held-back memories are retried on the next save or sync.") + return { lines, code: report.failed > 0 || report.declined > 0 ? EXIT.FAILED : EXIT.OK } +} + +/** Lines and exit code for `workspace refresh`. */ +export function describeRefresh(report: Manage.RefreshReport): { lines: string[]; code: number } { + const lines = [report.skillsChanged ? "Workspace skills updated." : "Workspace skills already up to date."] + if (report.skillsSkipped.length) { + lines.push(`${report.skillsSkipped.length} skill${report.skillsSkipped.length === 1 ? "" : "s"} skipped:`) + for (const s of report.skillsSkipped) lines.push(` - ${stripControlChars(s.skill)}: ${stripControlChars(s.reason)}`) + } + // From a shell there is no session to reload: memory is read fresh by the next session that starts here. + if (report.memory || report.memoryInvalidated) lines.push("Workspace memory loads in the next session started in this project.") + for (const e of report.errors) lines.push(`Error: ${stripControlChars(e)}`) + return { lines, code: report.errors.length ? EXIT.FAILED : EXIT.OK } +} + +/** What each subcommand needs from the outside world; replaced in tests so the exit codes and the `--yes` + * guards can be checked without a service, a terminal or a project. */ +export interface WorkspaceDeps { + isConfigured(): Promise + resolve(directory: string): Promise + status(directory: string, binding: CachedBinding): Promise + refresh(directory: string): Promise + sync(directory: string, binding: CachedBinding): Promise + unlink(directory: string): Promise + /** false when declined or cancelled. */ + confirm(message: string): Promise + isTTY(): boolean + print(line: string): void + printError(line: string): void + printJson(payload: unknown): void +} + +const defaultDeps: WorkspaceDeps = { + isConfigured: () => AltimateApi.isConfigured().catch(() => false), + resolve: (directory) => resolveBindingOutcome(directory), + status: (directory, binding) => Manage.status(directory, { poll: true, binding }), + refresh: (directory) => Manage.refresh(directory), + sync: (directory, binding) => Manage.sync(directory, { binding }), + unlink: (directory) => Manage.unlink(directory), + confirm: async (message) => { + const answer = await prompts.confirm({ message, initialValue: false }) + return !prompts.isCancel(answer) && answer === true + }, + isTTY: () => Boolean(process.stdin.isTTY), + print: (line) => UI.println(line), + printError: (line) => UI.error(line), + printJson: (payload) => process.stdout.write(JSON.stringify(payload, null, 2) + "\n"), +} + +/** `ok` is true exactly when the exit code is 0, for every subcommand. */ +function report(deps: WorkspaceDeps, json: boolean, code: number, payload: Record, lines: string[]): number { + if (json) deps.printJson({ ok: code === EXIT.OK, ...payload }) + else for (const line of lines) (code === EXIT.OK || code === EXIT.NOT_LINKED ? deps.print : deps.printError)(line) + return code +} + +function failure(deps: WorkspaceDeps, json: boolean, code: number, message: string): number { + return report(deps, json, code, { error: message }, [message]) +} + +/** 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_LINKED_LINES = ["This project is not linked to a workspace.", "Link it with `altimate-code link`."] + +/** + * The project's link as the service has it. A confirmed "not linked" is exit 3; a service that could not be asked + * is exit 1, never "not linked": a script branching on 3 would otherwise act on an outage. + */ +async function linkOf( + deps: WorkspaceDeps, + json: boolean, + directory: string, +): Promise<{ binding: CachedBinding; stale: boolean } | { code: number }> { + const outcome = await deps.resolve(directory) + if (outcome.status === "bound") return { binding: outcome.binding, stale: outcome.stale === true } + if (outcome.status === "unbound") { + // A "not linked" answered from the short-lived miss cache says so: a link made elsewhere in the last few + // minutes would not show yet. + const lines = outcome.stale ? [...NOT_LINKED_LINES, "(As of a check in the last few minutes.)"] : NOT_LINKED_LINES + return { code: report(deps, json, EXIT.NOT_LINKED, { linked: false, stale: outcome.stale === true }, lines) } + } + return { + code: failure(deps, json, EXIT.FAILED, "Could not reach the workspace service to check whether this project is linked. Try again."), + } +} + +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 { + const link = await linkOf(deps, json, directory) + if ("code" in link) return link.code + const status = await deps.status(directory, link.binding) + const lines = describeStatus(status) + if (link.stale) lines.push("(Last known link: the workspace service could not confirm it just now.)") + if (link.binding.adopted) + lines.push( + "(Found on the workspace service but not confirmed on this machine: `workspace sync` will not send this machine's " + + `memory until \`altimate-code link --workspace ${link.binding.datamateId}\` confirms it.)`, + ) + return report(deps, json, EXIT.OK, { linked: true, stale: link.stale, ...status }, lines) + } catch (err) { + return failure(deps, json, EXIT.FAILED, `Could not read the workspace status: ${messageOf(err)}`) + } +} + +export async function runRefresh(directory: string, json: boolean, deps: WorkspaceDeps = defaultDeps): Promise { + if (!(await deps.isConfigured())) return failure(deps, json, EXIT.USAGE, NOT_SIGNED_IN) + try { + const link = await linkOf(deps, json, directory) + if ("code" in link) return link.code + const result = await deps.refresh(directory) + const { lines, code } = describeRefresh(result) + return report(deps, json, code, { ...result }, lines) + } catch (err) { + return failure(deps, json, EXIT.FAILED, `Could not refresh the workspace: ${messageOf(err)}`) + } +} + +export async function runSync(directory: string, json: boolean, deps: WorkspaceDeps = defaultDeps): Promise { + if (!(await deps.isConfigured())) return failure(deps, json, EXIT.USAGE, NOT_SIGNED_IN) + try { + // Resolved first, like `status`: a fresh clone linked on the service is linked here too, and "not linked" + // is answered before any memory setting is consulted. + const link = await linkOf(deps, json, directory) + if ("code" in link) return link.code + // A link found on the service but never confirmed on this machine does not get this machine's earlier + // memory sent to it without the user saying so. + if (link.binding.adopted) + return failure( + deps, + json, + EXIT.USAGE, + `This project's link to "${stripControlChars(link.binding.datamateName)}" was found on the workspace service but not confirmed on this machine. ` + + `Run \`altimate-code link --workspace ${link.binding.datamateId}\` to confirm it, then sync.`, + ) + // A link the service could not confirm just now may have been detached or moved; memory is not sent to it. + if (link.stale) + return failure(deps, json, EXIT.FAILED, "Could not confirm this project's workspace link with the service, so nothing was sent. Try again.") + const result = await deps.sync(directory, link.binding) + const { lines, code } = describeSync(result) + return report(deps, json, code, { ...result }, lines) + } catch (err) { + return failure(deps, json, EXIT.FAILED, `Could not sync memory: ${messageOf(err)}`) + } +} + +export async function runUnlink(directory: string, json: boolean, yes: boolean, deps: WorkspaceDeps = defaultDeps): Promise { + if (!(await deps.isConfigured())) return failure(deps, json, EXIT.USAGE, NOT_SIGNED_IN) + try { + const link = await linkOf(deps, json, directory) + // Nothing is touched when the service says "not linked": `Manage.unlink` deletes server-side, and the + // answer may be a cached miss that predates a new link. A confirmed unbind already takes the workspace + // skills out of service on the next skill sync (every turn, or `workspace refresh`). + if ("code" in link) return link.code + const name = stripControlChars(link.binding.datamateName) + if (!yes) { + if (!deps.isTTY() || json) return failure(deps, json, EXIT.USAGE, `Unlinking from "${name}" needs confirmation: pass --yes.`) + const confirmed = await deps.confirm(`Unlink this project from "${name}"? Workspace skills are removed from this project.`) + // Declining is a choice, not a failure: exit 0 with nothing changed. + if (!confirmed) return report(deps, json, EXIT.OK, { unlinked: false }, ["No changes."]) + } + const result = await deps.unlink(directory) + const lines = [`Unlinked from "${stripControlChars(result.was?.datamateName ?? link.binding.datamateName)}".`] + if (!result.removedServerSide) lines.push("The workspace service had already removed this link; local state is cleared.") + if (result.skillsLeftBehind) { + // The link is gone but its skills still load into every session here: not a success. + lines.push("Workspace skills could not be removed from .altimate-code/skill/_workspace; delete that folder by hand.") + return report(deps, json, EXIT.FAILED, { unlinked: true, ...result }, lines) + } + return report(deps, json, EXIT.OK, { unlinked: true, ...result }, lines) + } catch (err) { + return failure(deps, json, EXIT.FAILED, `Could not unlink: ${messageOf(err)}`) + } +} + +function messageOf(err: unknown): string { + return stripControlChars(err instanceof Error ? err.message : String(err)) +} + +/** Runs a subcommand inside the project, and reports a failure to even open the project (a bad --directory) + * the same way as any other, JSON included. */ +async function inProject(directory: string, json: boolean, run: () => Promise): Promise { + let ran = false + try { + await bootstrap(directory, async () => { + process.exitCode = await run() + ran = true + }) + } catch (err) { + // After the command ran and reported, a failure closing the project must not print a second result or turn a + // success into a failure: it is noted on stderr only. + if (ran) { + UI.error(`The command finished, but closing the project failed: ${messageOf(err)}`) + return + } + process.exitCode = failure(defaultDeps, json, EXIT.FAILED, `Could not open the project at ${stripControlChars(directory)}: ${messageOf(err)}`) + } +} + +const DIRECTORY = { alias: "d", type: "string", describe: "Project directory (defaults to cwd)" } as const +const JSON_FLAG = { type: "boolean", default: false, describe: "Print the result as JSON" } as const + +const StatusCommand = cmd({ + command: "status", + describe: "show which workspace this project is linked to, and what has not synced", + builder: (yargs) => yargs.option("directory", DIRECTORY).option("json", JSON_FLAG), + handler: async (args) => { + const directory = args.directory ?? process.cwd() + await inProject(directory, args.json, () => runStatus(directory, args.json)) + }, +}) + +const RefreshCommand = cmd({ + command: "refresh", + describe: "pull the workspace's skills into this project (memory loads in the next session)", + builder: (yargs) => yargs.option("directory", DIRECTORY).option("json", JSON_FLAG), + handler: async (args) => { + const directory = args.directory ?? process.cwd() + await inProject(directory, args.json, () => runRefresh(directory, args.json)) + }, +}) + +const SyncCommand = cmd({ + command: "sync", + describe: "send memory saved on this machine that the workspace has not received", + builder: (yargs) => yargs.option("directory", DIRECTORY).option("json", JSON_FLAG), + handler: async (args) => { + const directory = args.directory ?? process.cwd() + await inProject(directory, args.json, () => runSync(directory, args.json)) + }, +}) + +const UnlinkCommand = cmd({ + command: "unlink", + describe: "detach this project from its workspace", + builder: (yargs) => + yargs + .option("directory", DIRECTORY) + .option("json", JSON_FLAG) + .option("yes", { alias: "y", type: "boolean", default: false, describe: "Skip the confirmation" }), + handler: async (args) => { + const directory = args.directory ?? process.cwd() + await inProject(directory, args.json, () => runUnlink(directory, args.json, args.yes)) + }, +}) + +export const WorkspaceCommand = cmd({ + command: "workspace", + describe: "manage this project's Altimate workspace (status, refresh, sync, unlink)", + builder: (yargs) => + yargs.command(StatusCommand).command(RefreshCommand).command(SyncCommand).command(UnlinkCommand).demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index ef7d01c2d..80386a096 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -50,6 +50,9 @@ import { LearnCommand } from "./cli/cmd/learn" // altimate_change end // altimate_change start — link: workspace-binding subcommand import { LinkCommand } from "./cli/cmd/link" +// altimate_change start — headless workspace commands (status, refresh, sync, unlink) +import { WorkspaceCommand } from "./cli/cmd/workspace" +// altimate_change end import { pilotOffCommand } from "./cli/cmd/workspace-pilot" // altimate_change end import { errorMessage } from "./util/error" @@ -90,7 +93,7 @@ const CLI_COMMAND_NAMES = new Set([ "learn", // altimate_change end // registered conditionally below (workspace / local-install builds) - "link", "workspace-serve", + "link", "workspace", "workspace-serve", ]) // altimate_change end let cli = yargs(args) @@ -236,8 +239,8 @@ let cli = yargs(args) // so the command doesn't show in --help for users who haven't opted in to the // workspaces feature via ALTIMATE_WORKSPACE=1 (M1 in the consensus review). // Off, a hidden stub takes its place and explains how to opt in. -if (Flag.ALTIMATE_WORKSPACE) cli = cli.command(LinkCommand) -else cli = cli.command(pilotOffCommand("link")) +if (Flag.ALTIMATE_WORKSPACE) cli = cli.command(LinkCommand).command(WorkspaceCommand) +else cli = cli.command(pilotOffCommand("link")).command(pilotOffCommand("workspace [action]")) // altimate_change end // altimate_change start — workspace-serve: register dev-only workspace serve command diff --git a/packages/opencode/test/altimate/workspace/state-account-scope.test.ts b/packages/opencode/test/altimate/workspace/state-account-scope.test.ts index 3f92a5781..3cf06b4ae 100644 --- a/packages/opencode/test/altimate/workspace/state-account-scope.test.ts +++ b/packages/opencode/test/altimate/workspace/state-account-scope.test.ts @@ -21,7 +21,7 @@ const SANDBOX = path.join(os.tmpdir(), `altimate-state-account-${process.pid}-${ mkdirSync(path.join(SANDBOX, "state"), { recursive: true }) process.env.OPENCODE_TEST_STATE_HOME = path.join(SANDBOX, "state") -const { recordApprovedBinding, readLocalBinding, clearLocalBinding, cachePath, credentialDigest } = +const { recordApprovedBinding, readLocalBinding, clearLocalBinding, cachePath, credentialDigest, isApprovedRow } = await import("../../../src/altimate/workspace/state") const { AltimateApi } = await import("../../../src/altimate/api/client") @@ -178,3 +178,47 @@ describe("binding cache is scoped to the account, not the tenant", () => { expect(a).not.toContain("key-A") }) }) + +describe("isApprovedRow: whether this call's approval was written", () => { + const A = credentialDigest(API_URL, TENANT, "key-a") + + test("the row this call wrote is approved", async () => { + asAccount("key-a") + const row = binding(35, "Growth") + await recordApprovedBinding(ROOT, row, { account: A, seed: false }) + expect(isApprovedRow(ROOT, A, 35, row.linkedAt)).toBe(true) + expect(isApprovedRow(ROOT, A, 36, row.linkedAt)).toBe(false) + }) + + test("a write refused because the account changed first leaves nothing approved", async () => { + asAccount("key-b") + const row = binding(35, "Growth") + expect((await recordApprovedBinding(ROOT, row, { account: A }))?.status).toBe("account-changed") + expect(isApprovedRow(ROOT, A, 35, row.linkedAt)).toBe(false) + }) + + test("an approval recorded earlier does not vouch for a later write that was refused", async () => { + asAccount("key-a") + await recordApprovedBinding(ROOT, { ...binding(35, "Growth"), linkedAt: Date.now() - 60_000 }, { account: A, seed: false }) + asAccount("key-b") + const attempt = binding(35, "Growth") + expect((await recordApprovedBinding(ROOT, attempt, { account: A }))?.status).toBe("account-changed") + expect(isApprovedRow(ROOT, A, 35, attempt.linkedAt)).toBe(false) + }) + + test("a row another account wrote is not this account's approval", async () => { + asAccount("key-b") + const B = credentialDigest(API_URL, TENANT, "key-b") + const row = binding(35, "Growth") + await recordApprovedBinding(ROOT, row, { account: B, seed: false }) + expect(isApprovedRow(ROOT, B, 35, row.linkedAt)).toBe(true) + expect(isApprovedRow(ROOT, A, 35, row.linkedAt)).toBe(false) + }) + + test("an adopted row for the same workspace is not an approval", async () => { + asAccount("key-a") + const row = { ...binding(35, "Growth"), adopted: true } + await recordApprovedBinding(ROOT, row, { account: A, seed: false }) + expect(isApprovedRow(ROOT, A, 35, row.linkedAt)).toBe(false) + }) +}) diff --git a/packages/opencode/test/cli/workspace-command.test.ts b/packages/opencode/test/cli/workspace-command.test.ts new file mode 100644 index 000000000..fc0ac5543 --- /dev/null +++ b/packages/opencode/test/cli/workspace-command.test.ts @@ -0,0 +1,419 @@ +// altimate_change - new file +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { + EXIT, + ago, + describeRefresh, + describeStatus, + describeSync, + runRefresh, + runStatus, + runSync, + runUnlink, + type WorkspaceDeps, +} from "../../src/cli/cmd/workspace" +import { linkHeadless, matchWorkspace, type LinkHeadlessDeps } from "../../src/cli/cmd/link" + +const binding = { + datamateId: 35, + datamateName: "Growth", + repoRemote: "git@github.com:acme/analytics.git", + projectPath: null, + linkedAt: 0, +} as any + +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 }) + expect(lines[0]).toBe("This project is not linked to a workspace.") + expect(lines.join("\n")).toContain("altimate-code link") + }) + + test("unknown is never rendered as zero, and off is not rendered as synced", () => { + const unknown = describeStatus({ binding, memory: { local: 4, unsynced: null }, skillsEnabled: true, skillsSyncedAt: null }) + expect(unknown.join("\n")).toContain("not known") + expect(unknown.join("\n")).not.toContain("all in the workspace") + expect(unknown.join("\n")).toContain("not synced by this process yet") + + const off = describeStatus({ binding, memory: null, skillsEnabled: false, skillsSyncedAt: null }) + expect(off).toContain("Memory: off.") + expect(off).toContain("Workspace skills: off.") + }) + + test("pending memory points at the sync command", () => { + const now = 1_000_000_000 + const lines = describeStatus( + { binding, memory: { local: 4, unsynced: 3 }, skillsEnabled: true, skillsSyncedAt: now - 6 * 60_000 }, + now, + ) + expect(lines[0]).toBe('Linked to workspace "Growth" (id 35).') + expect(lines).toContain("Matched by git remote: git@github.com:acme/analytics.git") + expect(lines.join("\n")).toContain("3 not yet in the workspace — run `altimate-code workspace sync`") + expect(lines).toContain("Workspace skills: last synced 6m ago.") + }) +}) + +describe("ago", () => { + test("boundaries", () => { + const now = 10_000_000_000 + expect(ago(null, now)).toBeNull() + expect(ago(now + 5_000, now)).toBe("just now") // clock skew is not negative time + expect(ago(now - 59_999, now)).toBe("just now") + expect(ago(now - 60_000, now)).toBe("1m ago") + expect(ago(now - 3_600_000, now)).toBe("1h ago") + expect(ago(now - 48 * 3_600_000, now)).toBe("2d ago") + }) +}) + +describe("workspace sync exit codes", () => { + const base = { gated: false, sent: 0, failed: 0, skipped: 0, declined: 0, deferred: 0 } + test("not linked is its own code; memory off is not a failure; unreadable state is", () => { + expect(describeSync({ ...base, gated: true, gatedBecause: "no-binding" }).code).toBe(EXIT.NOT_LINKED) + expect(describeSync({ ...base, gated: true, gatedBecause: "memory-off" }).code).toBe(EXIT.OK) + expect(describeSync({ ...base, gated: true, gatedBecause: "read-failed" }).code).toBe(EXIT.FAILED) + expect(describeSync({ ...base, gated: true, gatedBecause: "setting-unavailable" }).code).toBe(EXIT.FAILED) + expect(describeSync({ ...base, gated: true, gatedBecause: "pin-unresolved" }).code).toBe(EXIT.FAILED) + }) + test("a sweep with failures or refusals fails; held-back items alone do not", () => { + expect(describeSync({ ...base, sent: 2, skipped: 1 }).code).toBe(EXIT.OK) + expect(describeSync({ ...base, sent: 2, failed: 1 }).code).toBe(EXIT.FAILED) + expect(describeSync({ ...base, declined: 1 }).code).toBe(EXIT.FAILED) + const deferred = describeSync({ ...base, deferred: 2 }) + expect(deferred.code).toBe(EXIT.OK) + expect(deferred.lines.join("\n")).toContain("retried") + }) +}) + +describe("workspace refresh", () => { + test("errors fail the command; skipped skills are listed", () => { + const ok = describeRefresh({ skillsChanged: true, skillsSkipped: [{ skill: "a", reason: "it is too large for this client" }], errors: [] } as any) + expect(ok.code).toBe(EXIT.OK) + expect(ok.lines.join("\n")).toContain("a: it is too large for this client") + expect(describeRefresh({ skillsChanged: false, skillsSkipped: [], errors: ["offline"] } as any).code).toBe(EXIT.FAILED) + }) +}) + +describe("link --workspace matching", () => { + const list = [ + { id: 7, name: "Growth" }, + { id: 35, name: "growth " }, + { id: 9, name: "Finance" }, + { id: 12, name: "35" }, + ] as any + test("an id wins over a name that looks like an id", () => { + expect(matchWorkspace(list, "35")).toEqual({ kind: "one", workspace: list[1] }) + }) + test("a name matches case-insensitively and trimmed", () => { + expect(matchWorkspace(list, " finance ")).toEqual({ kind: "one", workspace: list[2] }) + }) + test("a name shared by two workspaces is ambiguous", () => { + const r = matchWorkspace(list, "GROWTH") + expect(r.kind).toBe("many") + }) + test("an id matches its workspace, and nothing matches otherwise", () => { + expect(matchWorkspace(list, "12")).toEqual({ kind: "one", workspace: list[3] }) + expect(matchWorkspace(list, "Marketing")).toEqual({ kind: "none" }) + expect(matchWorkspace(list, "")).toEqual({ kind: "none" }) + }) +}) + +// --------------------------------------------------------------------------- +// Handlers: exit codes, refusals, and that a refusal changes nothing +// --------------------------------------------------------------------------- + +const statusReport = { binding, memory: null, skillsEnabled: true, skillsSyncedAt: null } as any +const unlinkReport = { was: binding, removedServerSide: true, skillsPurged: true, skillsLeftBehind: false } as any + +function fakeDeps(over: Partial = {}) { + const calls: string[] = [] + const out: string[] = [] + const err: string[] = [] + const json: any[] = [] + const deps: WorkspaceDeps = { + isConfigured: async () => true, + resolve: async () => ({ status: "bound", binding }), + status: async () => (calls.push("status"), statusReport), + refresh: async () => (calls.push("refresh"), { skillsChanged: false, skillsSkipped: [], errors: [] } as any), + sync: async (_d, b) => (calls.push(`sync:${b.datamateId}`), { gated: false, sent: 1, failed: 0, skipped: 0, declined: 0, deferred: 0 }), + unlink: async () => (calls.push("unlink"), unlinkReport), + confirm: async () => (calls.push("confirm"), true), + isTTY: () => true, + print: (l) => void out.push(l), + printError: (l) => void err.push(l), + printJson: (p) => void json.push(p), + ...over, + } + return { deps, calls, out, err, json } +} + +describe("workspace subcommands", () => { + test("not signed in: every subcommand exits 2 and touches nothing", async () => { + for (const run of [ + (d: WorkspaceDeps) => runStatus("/p", false, d), + (d: WorkspaceDeps) => runRefresh("/p", false, d), + (d: WorkspaceDeps) => runSync("/p", false, d), + (d: WorkspaceDeps) => runUnlink("/p", false, true, d), + ]) { + const f = fakeDeps({ isConfigured: async () => false }) + expect(await run(f.deps)).toBe(EXIT.USAGE) + expect(f.calls).toEqual([]) + } + }) + + 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), + (d: WorkspaceDeps) => runRefresh("/p", true, d), + (d: WorkspaceDeps) => runSync("/p", true, d), + (d: WorkspaceDeps) => runUnlink("/p", true, true, d), + ]) { + const f = fakeDeps({ resolve: async () => ({ status: "unknown" }) }) + expect(await run(f.deps)).toBe(EXIT.FAILED) + expect(f.calls).toEqual([]) + expect(f.json[0]).toMatchObject({ ok: false }) + } + }) + + test("a confirmed 'not linked' is 3 for every subcommand, with ok false", async () => { + for (const run of [ + (d: WorkspaceDeps) => runStatus("/p", true, d), + (d: WorkspaceDeps) => runRefresh("/p", true, d), + (d: WorkspaceDeps) => runSync("/p", true, d), + (d: WorkspaceDeps) => runUnlink("/p", true, true, d), + ]) { + const f = fakeDeps({ resolve: async () => ({ status: "unbound" }) }) + expect(await run(f.deps)).toBe(EXIT.NOT_LINKED) + // Nothing is sent or changed: unlink included, since its delete could hit a link made after a cached miss. + expect(f.calls).toEqual([]) + expect(f.json[0]).toMatchObject({ ok: false, linked: false }) + } + }) + + test("status of a linked project exits 0; a link the service could not confirm says so", async () => { + const f = fakeDeps({ resolve: async () => ({ status: "bound", binding, stale: true }) }) + expect(await runStatus("/p", false, f.deps)).toBe(EXIT.OK) + expect(f.out.join("\n")).toContain("Last known link") + }) + + test("sync on a fresh clone uses the link found on the service, but refuses to send to one never confirmed here", async () => { + const fresh = fakeDeps() + expect(await runSync("/p", false, fresh.deps)).toBe(EXIT.OK) + // The binding just verified is what the sweep uses, not a re-read of the shared cache. + expect(fresh.calls).toEqual(["sync:35"]) + + const adopted = fakeDeps({ resolve: async () => ({ status: "bound", binding: { ...binding, adopted: true } }) }) + expect(await runSync("/p", false, adopted.deps)).toBe(EXIT.USAGE) + expect(adopted.calls).toEqual([]) + expect(adopted.err.join("\n")).toContain("altimate-code link --workspace 35") + }) + + test("unlink without --yes and without a terminal, or with --json, refuses and unlinks nothing", async () => { + const noTty = fakeDeps({ isTTY: () => false }) + expect(await runUnlink("/p", false, false, noTty.deps)).toBe(EXIT.USAGE) + expect(noTty.calls).toEqual([]) + const json = fakeDeps() + expect(await runUnlink("/p", true, false, json.deps)).toBe(EXIT.USAGE) + expect(json.calls).toEqual([]) + }) + + test("a declined unlink changes nothing and exits 0; a confirmed one unlinks", async () => { + const declined = fakeDeps({ confirm: async () => false }) + expect(await runUnlink("/p", false, false, declined.deps)).toBe(EXIT.OK) + expect(declined.calls).not.toContain("unlink") + const confirmed = fakeDeps() + expect(await runUnlink("/p", false, false, confirmed.deps)).toBe(EXIT.OK) + expect(confirmed.calls).toEqual(["confirm", "unlink"]) + }) + + test("an unlink that left the workspace's skills on disk is not a success", async () => { + const f = fakeDeps({ unlink: async () => ({ ...unlinkReport, skillsLeftBehind: true }) }) + expect(await runUnlink("/p", true, true, f.deps)).toBe(EXIT.FAILED) + expect(f.json[0]).toMatchObject({ ok: false, unlinked: true, skillsLeftBehind: true }) + }) + + test("a workspace name cannot rewrite the terminal; --json keeps it as sent", async () => { + const evil = { ...binding, datamateName: "Gro\u001b]8;;http://x\u0007wth" } + let asked = "" + const f = fakeDeps({ + resolve: async () => ({ status: "bound", binding: evil }), + unlink: async () => ({ ...unlinkReport, was: evil }), + confirm: async (m) => ((asked = m), true), + }) + await runUnlink("/p", false, false, f.deps) + expect(asked).toContain("Gro") + expect(asked).not.toContain("\u001b") + expect(f.out.join("\n")).not.toContain("\u001b") + const j = fakeDeps({ resolve: async () => ({ status: "bound", binding: evil }), status: async () => ({ ...statusReport, binding: evil }) }) + await runStatus("/p", true, j.deps) + expect(j.json[0].binding.datamateName).toBe(evil.datamateName) + }) + + test("sync refuses a link the service could not confirm just now, and sends nothing", async () => { + const f = fakeDeps({ resolve: async () => ({ status: "bound", binding, stale: true }) }) + expect(await runSync("/p", false, f.deps)).toBe(EXIT.FAILED) + expect(f.calls).toEqual([]) + }) + + test("a 'not linked' answered from the recent-miss cache says so", async () => { + const f = fakeDeps({ resolve: async () => ({ status: "unbound", stale: true }) }) + expect(await runStatus("/p", false, f.deps)).toBe(EXIT.NOT_LINKED) + expect(f.out.join("\n")).toContain("As of a check in the last few minutes") + }) + + test("status says when a link is not confirmed on this machine, and when memory could not be read", async () => { + const f = fakeDeps({ + resolve: async () => ({ status: "bound", binding: { ...binding, adopted: true } }), + status: async () => ({ ...statusReport, memoryUnreadable: true }), + }) + expect(await runStatus("/p", false, f.deps)).toBe(EXIT.OK) + const out = f.out.join("\n") + expect(out).toContain("not confirmed on this machine") + expect(out).toContain("Memory: the memory saved on this machine could not be read.") + expect(out).not.toContain("Memory: off.") + }) + + test("--json: ok is true exactly when the exit code is 0", async () => { + const f = fakeDeps({ sync: async () => ({ gated: false, sent: 0, failed: 1, skipped: 0, declined: 0, deferred: 0 }) }) + expect(await runSync("/p", true, f.deps)).toBe(EXIT.FAILED) + expect(f.json[0].ok).toBe(false) + }) +}) + +describe("link --workspace / --create without prompting", () => { + const dir = process.cwd() + const actAs = { token: "t" } as any + const growth = { id: 35, name: "Growth" } + function linkDeps(over: Partial = {}) { + const lookups: string[] = [] + const calls: string[] = [] + const out: string[] = [] + const err: string[] = [] + const deps: LinkHeadlessDeps = { + isConfigured: async () => true, + getBindingForProject: async (_i, a) => (lookups.push(a === actAs ? "pinned" : "other"), null), + captureCredentials: async () => actAs, + approve: async (_i, e, a) => (calls.push(`approve:${e.datamate.id}`), lookups.push(`approve:${a === actAs ? "pinned" : "other"}`), true), + listDatamates: async (a) => (lookups.push(`list:${a === actAs ? "pinned" : "other"}`), [growth, { id: 9, name: "Finance" }] as any), + bindOrRebind: async (_i, id, _e, a) => void (calls.push(`bind:${id}`), lookups.push(`bind:${a === actAs ? "pinned" : "other"}`)), + create: async (_i, name) => void calls.push(`create:${name}`), + print: (l) => void out.push(l), + printError: (l) => void err.push(l), + ...over, + } + return { deps, calls, out, err, lookups } + } + // The process exit code is shared with the rest of the run: each test restores what it found. + let priorExitCode: typeof process.exitCode + beforeEach(() => { + priorExitCode = process.exitCode + process.exitCode = 0 + }) + afterEach(() => { + process.exitCode = priorExitCode + }) + + test("both flags, an empty --workspace, or no sign-in: exit 2 and nothing changes", async () => { + for (const [args, over] of [ + [{ workspace: "35", create: "x" }, {}], + [{ workspace: " " }, {}], + [{ workspace: "35" }, { isConfigured: async () => false }], + ] as const) { + const f = linkDeps(over as any) + process.exitCode = 0 + await linkHeadless({ directory: dir, yes: false, ...(args as any) }, f.deps) + expect(process.exitCode).toBe(2) + expect(f.calls).toEqual([]) + } + }) + + test("a pre-check that fails stops with exit 1 and binds nothing", async () => { + const f = linkDeps({ getBindingForProject: async () => { throw new Error("offline") } }) + await linkHeadless({ directory: dir, workspace: "35", yes: true }, f.deps) + expect(process.exitCode).toBe(1) + expect(f.calls).toEqual([]) + }) + + test("already linked to the requested workspace: exit 0, nothing changed", async () => { + const f = linkDeps({ getBindingForProject: async () => ({ datamate: growth, binding: {} }) as any }) + await linkHeadless({ directory: dir, workspace: "35", yes: false }, f.deps) + expect(process.exitCode ?? 0).toBe(0) + // Confirmed on this machine (a fresh clone only knows the link as discovered), with no server rebind. + expect(f.calls).toEqual(["approve:35"]) + expect(f.out.join("\n")).toContain("link confirmed on this machine") + }) + + test("re-linking to a different workspace needs --yes", async () => { + const f = linkDeps({ getBindingForProject: async () => ({ datamate: growth, binding: {} }) as any }) + await linkHeadless({ directory: dir, workspace: "9", yes: false }, f.deps) + expect(process.exitCode).toBe(2) + expect(f.calls).toEqual([]) + process.exitCode = 0 + await linkHeadless({ directory: dir, workspace: "9", yes: true }, f.deps) + expect(f.calls).toEqual(["bind:9"]) + }) + + test("--create run again on a project linked to that name changes nothing (devcontainer rebuilds)", async () => { + let listed = false + const f = linkDeps({ + getBindingForProject: async () => ({ datamate: growth, binding: {} }) as any, + listDatamates: async () => { + listed = true + throw new Error("list unavailable") + }, + }) + await linkHeadless({ directory: dir, create: "growth", yes: true }, f.deps) + expect(process.exitCode ?? 0).toBe(0) + expect(f.calls).toEqual(["approve:35"]) + // Answered before the workspace list is needed, so a rebuild does not depend on it. + expect(listed).toBe(false) + }) + + test("--create refuses a name another workspace already has, unless --allow-duplicate", async () => { + const f = linkDeps() + await linkHeadless({ directory: dir, create: "Growth", yes: false }, f.deps) + expect(process.exitCode).toBe(2) + expect(f.calls).toEqual([]) + expect(f.err.join("\n")).toContain("--workspace 35") + process.exitCode = 0 + await linkHeadless({ directory: dir, create: "Growth", yes: false, allowDuplicate: true }, f.deps) + expect(f.calls).toEqual(["create:Growth"]) + }) + + test("the pre-check runs as the same captured credential as the list and the bind", async () => { + const f = linkDeps() + await linkHeadless({ directory: dir, workspace: "9", yes: false }, f.deps) + expect(f.lookups).toEqual(["pinned", "list:pinned", "bind:pinned"]) + }) + + test("confirming an existing link runs as the captured credential", async () => { + const f = linkDeps({ getBindingForProject: async () => ({ datamate: { id: 9, name: "Finance" }, binding: {} }) as any }) + await linkHeadless({ directory: dir, workspace: "9", yes: false }, f.deps) + expect(f.lookups).toContain("approve:pinned") + }) + + test("a link that could not be confirmed on this machine is a failure, not 'confirmed'", async () => { + for (const approve of [async () => false, async () => Promise.reject(new Error("keychain locked"))]) { + const f = linkDeps({ + getBindingForProject: async () => ({ datamate: { id: 9, name: "Finance" }, binding: {} }) as any, + approve, + }) + process.exitCode = 0 + await linkHeadless({ directory: dir, workspace: "9", yes: false }, f.deps) + expect(process.exitCode).toBe(1) + expect(f.out.join("\n")).not.toContain("link confirmed") + expect(f.err.join("\n")).toContain("not confirmed on this machine") + } + }) + + test("--allow-duplicate still creates even when the project is linked to a workspace of that name", async () => { + const f = linkDeps({ getBindingForProject: async () => ({ datamate: growth, binding: {} }) as any }) + await linkHeadless({ directory: dir, create: "Growth", yes: true, allowDuplicate: true }, f.deps) + expect(f.calls).toEqual(["create:Growth"]) + }) + + test("--create with a new name creates it", async () => { + const f = linkDeps() + await linkHeadless({ directory: dir, create: "Marketing", yes: false }, f.deps) + expect(f.calls).toEqual(["create:Marketing"]) + }) +})