diff --git a/packages/opencode/src/altimate/workspace/manage.ts b/packages/opencode/src/altimate/workspace/manage.ts index 7ebe13c9d..342c58c21 100644 --- a/packages/opencode/src/altimate/workspace/manage.ts +++ b/packages/opencode/src/altimate/workspace/manage.ts @@ -27,6 +27,7 @@ import { WorkspaceApi, type ProjectIdentifier } from "./api-client" import { resolveProjectIdentifier } from "./detect" import * as MemorySync from "./memory-sync" import * as SkillSync from "./skill-sync" +import * as SyncState from "./sync-state" import { clearLocalBinding, currentScope, @@ -59,6 +60,11 @@ export interface StatusReport { * per-process, so a fresh session has not synced yet even for a project whose * snapshot is current on disk. Callers must not render it as "never synced". */ skillsSyncedAt: number | null + // altimate_change start — workspace sync state + /** When this project's workspace memory was last loaded, from the shared sync state, or + * null when no load has been recorded for this binding. */ + memoryLoadedAt?: number | null + // altimate_change end } export interface RefreshReport { @@ -156,6 +162,7 @@ export async function status( ...(memory === "unreadable" ? { memoryUnreadable: true as const } : {}), skillsEnabled: SkillSync.isEnabled(), skillsSyncedAt: await skillsSyncedAt(directory, binding), + memoryLoadedAt: binding ? (SyncState.read(directory, binding.datamateId)?.entities.memory?.lastCheckedAt ?? null) : null, } } diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 85e115146..a8742f4e7 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -1376,7 +1376,7 @@ type LoadOutcome = ( | { status: "unlinked" } | { status: "disabled" } | { status: "error" } -) & { epoch?: string; dir?: string | null } +) & { epoch?: string; dir?: string | null; /** The workspace the load read, once resolved. */ datamateId?: number } /** Read this project's workspace memory. Pure: it publishes nothing, so a slow * load that has been superseded cannot write over a newer result. */ @@ -1403,9 +1403,10 @@ async function loadWorkspaceMemory(directory?: string): Promise { vouched = epoch if (!stable) return { status: "error", epoch, dir } if (!binding) return { status: "unlinked", epoch, dir } + const datamateId = binding.datamateId const enabled = await memoryStatus(binding) - if (enabled === "error") return { status: "error", epoch, dir } - if (enabled === "disabled") return { status: "disabled", epoch, dir } + if (enabled === "error") return { status: "error", epoch, dir, datamateId } + if (enabled === "disabled") return { status: "disabled", epoch, dir, datamateId } const ownProjectKey = projectKeyFor(binding) const ownWorkspace = String(binding.datamateId) @@ -1441,7 +1442,7 @@ async function loadWorkspaceMemory(directory?: string): Promise { if (block.expires && new Date(block.expires) <= new Date()) continue blocks.push(block) } - return { status: "loaded", blocks, epoch, dir } + return { status: "loaded", blocks, epoch, dir, datamateId } } catch (err) { log.warn("workspace memory load failed", { err: String(err) }) // Stamped like any other outcome: without an epoch the session would reload (and make @@ -1461,6 +1462,9 @@ function commitLoad(sessionID: string, state: SessionMemory, outcome: LoadOutcom if (outcome.epoch !== epochFor(outcome.dir ?? null)) return state.loadedEpoch = outcome.epoch state.dir = outcome.dir ?? null + // altimate_change start — workspace sync state + recordSyncState(outcome) + // altimate_change end // An error keeps whatever the session had and is not retried every turn (as before). if (outcome.status === "error") return state.overlay = outcome.status === "loaded" ? outcome.blocks : [] @@ -1469,6 +1473,29 @@ function commitLoad(sessionID: string, state: SessionMemory, outcome: LoadOutcom } } +// altimate_change start — workspace sync state +/** Record a committed load in the workspace sync state. Memory is loaded per session but + * recorded per project, so a second session reading the same blocks is not a change. + * Detached: the load is already committed and nothing waits on status metadata. A load + * that could not resolve a workspace, or found memory off, records nothing. */ +function recordSyncState(outcome: LoadOutcome): void { + if (!outcome.dir || outcome.datamateId === undefined) return + if (outcome.status !== "loaded" && outcome.status !== "error") return + const { dir, datamateId } = outcome + const items = + outcome.status === "loaded" + ? Object.fromEntries( + outcome.blocks.map((block) => [block.id, { label: blockTitle(block), version: block.updated ?? "" }]), + ) + : undefined + void import("./sync-state") + .then((SyncState) => + SyncState.record(dir, datamateId, "memory", items ? { items } : { error: "could not load workspace memory" }), + ) + .catch((err) => log.warn("could not record the memory sync state", { err: String(err) })) +} +// altimate_change end + /** A session's cloud overlay. Returns a copy so a caller cannot mutate the * cached state in place. */ export function overlayBlocks(sessionID: string): RemoteMemoryBlock[] { diff --git a/packages/opencode/src/altimate/workspace/skill-sync.ts b/packages/opencode/src/altimate/workspace/skill-sync.ts index 5b5080204..721494735 100644 --- a/packages/opencode/src/altimate/workspace/skill-sync.ts +++ b/packages/opencode/src/altimate/workspace/skill-sync.ts @@ -101,6 +101,8 @@ export interface Manifest { interface RemoteSummary { publicId: string updatedAt: string + /** Display name, for the sync-state change list. Optional: sync never depends on it. */ + name?: string } /** ``CustomSkillDetail.files`` — ``CustomSkillFileMeta`` is ``{path, size}``. */ @@ -244,6 +246,39 @@ export function describeSyncProblems(result: SyncResult): { title: string; messa return { title: `${n} workspace skill${n === 1 ? "" : "s"} skipped`, message: lines.join("\n") } } +// altimate_change start — workspace sync state +/** Record this run in the workspace sync state. `rows` is the list the published snapshot + * describes, or null when the run left the snapshot as it was — the items are then + * unknown to this run and the previous record stands. Imported lazily: the state module + * reaches the bus, which this module must not load on the opted-out path. */ +async function recordSyncState( + directory: string, + datamateId: number, + result: SyncResult, + rows: RemoteSummary[] | null, + dropped: Set, +): Promise { + const problem = describeSyncProblems(result) + const items = rows + ? Object.fromEntries( + rows + .filter((row) => !dropped.has(row.publicId)) + .map((row) => [row.publicId, { label: displayId(row.name ?? row.publicId), version: row.updatedAt }]), + ) + : undefined + try { + const SyncState = await import("./sync-state") + await SyncState.record(directory, datamateId, "skills", { + ...(items ? { items } : {}), + ...(problem ? { error: `${problem.title}: ${problem.message.split("\n").join("; ")}` } : {}), + }) + } catch (err) { + // Status metadata: never the reason a sync fails. + log.warn("could not record the skill sync state", { err: String(err) }) + } +} +// altimate_change end + /** A fixed, user-facing reason for a skill that failed to sync. The raw error * can carry request URLs, server text or local paths — diagnostics for the * log, not for a toast. */ @@ -746,10 +781,14 @@ function parsePage( const rows: RemoteSummary[] = [] for (const row of p.items) { if (!row || typeof row !== "object") return null - const r = row as { public_id?: unknown; updated_at?: unknown } + const r = row as { public_id?: unknown; updated_at?: unknown; name?: unknown } if (typeof r.public_id !== "string" || !r.public_id) return null if (typeof r.updated_at !== "string" || !r.updated_at) return null - rows.push({ publicId: r.public_id, updatedAt: r.updated_at }) + rows.push({ + publicId: r.public_id, + updatedAt: r.updated_at, + ...(typeof r.name === "string" && r.name ? { name: r.name } : {}), + }) } return { rows, pages: rawPages, total } } @@ -1109,6 +1148,15 @@ export async function syncSkills(directory: string): Promise { // Set once the workspace's list has actually been read. Only then has this // project been "checked", and only then should the poll interval start. let sawRemote = false + // altimate_change start — workspace sync state + // What the sync-state record needs: the workspace checked, the list it read, the skills + // that list named but this run could not install (and had no previous copy of), and + // whether the run left a snapshot describing that list. + let checkedDatamateId: number | undefined + let remoteRows: RemoteSummary[] | null = null + const dropped = new Set() + let published = false + // altimate_change end const run = (async () => { // Checked BEFORE the binding: `resolveBinding` needs credentials too, so a // disconnected client would otherwise return on a null binding and never @@ -1212,6 +1260,7 @@ export async function syncSkills(directory: string): Promise { return } const binding = outcome.binding + checkedDatamateId = binding.datamateId // Refuse to touch a directory we did not create. Everything below either // deletes this tree or replaces it wholesale, so without this a user's own @@ -1279,6 +1328,7 @@ export async function syncSkills(directory: string): Promise { return } sawRemote = true + remoteRows = remote syncedFor.set(canon, accountKeyOf(creds.altimateInstanceName, creds.altimateUrl, account)) // The workspace has skills now, even if installing them fails below. if (remote.length > 0) await clearEmptyRecordFor(canon, currentEmptyKey) @@ -1289,6 +1339,7 @@ export async function syncSkills(directory: string): Promise { // live by then — another process can swap a partial snapshot in // between, and the marker would vouch for a sync this run never made. validated = manifest + published = true return } @@ -1302,6 +1353,7 @@ export async function syncSkills(directory: string): Promise { if (written && (after?.status !== "bound" || after.binding.datamateId !== binding.datamateId)) await withdrawEmptyRecord(canon, written) changed = true + published = true log.info("workspace has no custom skills; removed the local snapshot") return } @@ -1354,6 +1406,7 @@ export async function syncSkills(directory: string): Promise { failed = true log.warn("skipping a workspace skill with an unusable id", { skill: summary.publicId }) skippedSkills.push({ skill: displayId(summary.publicId), reason: "its id is not usable as a folder name" }) + dropped.add(summary.publicId) continue } try { @@ -1435,6 +1488,7 @@ export async function syncSkills(directory: string): Promise { } if (!carried) { await fs.rm(path.join(staging, summary.publicId), { recursive: true, force: true }).catch(() => {}) + dropped.add(summary.publicId) } log.warn("skipping a workspace skill; the rest of the snapshot still publishes", { skill: summary.publicId, @@ -1516,6 +1570,7 @@ export async function syncSkills(directory: string): Promise { await fs.rm(retired, { recursive: true, force: true }).catch(() => {}) await clearEmptyRecordFor(canon, currentEmptyKey) changed = true + published = true log.info("workspace skills synced", { datamateId: binding.datamateId, skills: remote.length, @@ -1566,7 +1621,11 @@ export async function syncSkills(directory: string): Promise { // too — otherwise a problem it fixed would stay latched, and its return // would never be announced. if (skippedSkills.length === 0 && !syncError) store.announced.delete(canon) - return { changed, skipped: skippedSkills, error: syncError } + const result: SyncResult = { changed, skipped: skippedSkills, error: syncError } + // altimate_change start — workspace sync state + if (checkedDatamateId !== undefined) await recordSyncState(canon, checkedDatamateId, result, published ? remoteRows : null, dropped) + // altimate_change end + return result })() inFlight.set(canon, settled) try { diff --git a/packages/opencode/src/altimate/workspace/sync-state.ts b/packages/opencode/src/altimate/workspace/sync-state.ts new file mode 100644 index 000000000..8b5c8fa7f --- /dev/null +++ b/packages/opencode/src/altimate/workspace/sync-state.ts @@ -0,0 +1,212 @@ +// altimate_change start — workspace sync state +// +// One record per synced entity kind (skills, memory, …), kept per project, so every client +// can say when each kind was last checked, when it last changed and what changed — the +// TUI sidebar, and the IDE extension through `GET /altimate/workspace/status` and the +// `altimate.workspace.sync.changed` event. +// +// Written by the existing sync paths only; nothing here polls. A new entity kind records +// its items through `record` and gets the same metadata and change detection. +// +// Persisted rather than held in memory: in the TUI the per-turn syncs run in the server +// worker while the sidebar renders on the main thread, and the two share no module state +// (see `SkillSync.lastSuccessfulSyncAt`). A file is what both sides see. +import { createHash } from "node:crypto" +import { readFileSync } from "node:fs" +import path from "node:path" +import z from "zod" +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { Global } from "@/global" +import { Log } from "@/altimate/util/log" +import { Filesystem } from "@/util/filesystem" +import { canonicalDirectory } from "./state" + +const log = Log.create({ service: "altimate-workspace-sync-state" }) + +export const KINDS = ["skills", "memory"] as const +export type SyncKind = (typeof KINDS)[number] + +/** What a kind holds: a stable id mapped to a display label and a version that moves when + * the item's content does. Diffs are computed from this alone, so every kind compares the + * same way. */ +export type SyncItems = Record + +const Changes = z.object({ + added: z.array(z.string()), + removed: z.array(z.string()), + updated: z.array(z.string()), +}) +export type SyncChanges = z.infer + +const EntityState = z.object({ + kind: z.enum(KINDS), + status: z.enum(["ok", "error"]), + /** Items held after the last check; null when it has never been read successfully. */ + count: z.number().int().nonnegative().nullable(), + /** Epoch ms of the last completed check, successful or not. */ + lastCheckedAt: z.number(), + /** Epoch ms of the last check that found a difference; null when none has. */ + lastChangedAt: z.number().nullable(), + /** What that last change was, by label. Kept until the next change replaces it. */ + changes: Changes.nullable(), + /** User-facing reason, set while `status` is "error". */ + error: z.string().optional(), +}) +export type EntityState = z.infer + +export const Event = { + /** Published only when a check found a difference, or when a kind's error state changed + * (a new problem, a different problem, or recovery) — never for a check that found + * nothing, so a client can surface every event without being noisy. */ + Changed: BusEvent.define( + "altimate.workspace.sync.changed", + z.object({ + directory: z.string(), + datamateId: z.number(), + kind: z.enum(KINDS), + state: EntityState, + /** This check's own difference; null for an error-state-only event. */ + changes: Changes.nullable(), + }), + ), +} + +export interface WorkspaceSyncState { + datamateId: number + entities: Partial> +} + +interface StoredEntity extends EntityState { + /** Absent until a check has read the items, so the first read is a baseline. */ + items?: SyncItems +} + +interface StoredFile { + version: 1 + directory: string + datamateId: number + entities: Partial> +} + +function filePath(directory: string): string { + const key = createHash("sha256").update(canonicalDirectory(directory)).digest("hex").slice(0, 32) + return path.join(Global.Path.state, "altimate-workspace-sync", `${key}.json`) +} + +function readStored(directory: string): StoredFile | null { + try { + const parsed = JSON.parse(readFileSync(filePath(directory), "utf8")) as StoredFile + if (parsed?.version !== 1 || typeof parsed.datamateId !== "number" || !parsed.entities) return null + // A hash collision or a moved file must not attribute another tree's state to this one. + if (parsed.directory !== canonicalDirectory(directory)) return null + return parsed + } catch { + return null + } +} + +/** The project's sync state for the workspace it is bound to now. Null when nothing has + * been recorded for that workspace — after a rebind the previous workspace's state is not + * this one's. */ +export function read(directory: string, datamateId: number): WorkspaceSyncState | null { + const stored = readStored(directory) + if (!stored || stored.datamateId !== datamateId) return null + const entities: WorkspaceSyncState["entities"] = {} + for (const kind of KINDS) { + const entity = stored.entities[kind] + if (!entity) continue + const { items: _items, ...state } = entity + entities[kind] = state + } + return { datamateId, entities } +} + +/** Added, removed and updated labels between two item sets. Pure. */ +export function diff(previous: SyncItems, next: SyncItems): SyncChanges { + const added: string[] = [] + const updated: string[] = [] + for (const [id, item] of Object.entries(next)) { + const before = previous[id] + if (!before) added.push(item.label) + else if (before.version !== item.version) updated.push(item.label) + } + const removed = Object.entries(previous) + .filter(([id]) => !(id in next)) + .map(([, item]) => item.label) + return { added: added.sort(), removed: removed.sort(), updated: updated.sort() } +} + +function isEmpty(changes: SyncChanges): boolean { + return changes.added.length === 0 && changes.removed.length === 0 && changes.updated.length === 0 +} + +/** A completed check. `items` is what the check read, absent when it could not read them + * (the previous set is kept); `error` is a problem to report, which a partial read can + * carry alongside the items it did get. */ +export interface Outcome { + items?: SyncItems + error?: string +} + +/** Writes are chained per file so two kinds settling at once cannot drop each other's update. */ +const writes = new Map>() + +/** Record a completed check of one kind and publish when it changed something. + * + * Never throws: this is status metadata, and a failure here must not fail the sync that + * reported it. Resolves once the state is written and any event published. */ +export function record(directory: string, datamateId: number, kind: SyncKind, outcome: Outcome): Promise { + const file = filePath(directory) + const run = (writes.get(file) ?? Promise.resolve()).then(() => apply(directory, datamateId, kind, outcome)) + const settled = run.catch((err) => log.warn("could not record workspace sync state", { kind, err: String(err) })) + writes.set(file, settled) + void settled.finally(() => { + if (writes.get(file) === settled) writes.delete(file) + }) + return settled +} + +async function apply(directory: string, datamateId: number, kind: SyncKind, outcome: Outcome): Promise { + const canon = canonicalDirectory(directory) + const stored = readStored(directory) + // Another workspace's state describes nothing about this one: start over. + const file: StoredFile = + stored && stored.datamateId === datamateId ? stored : { version: 1, directory: canon, datamateId, entities: {} } + const previous = file.entities[kind] + const now = Date.now() + + let changes: SyncChanges | null = null + if (outcome.items) { + // The first check this project has ever recorded is a baseline, not a change: reporting + // every existing item as "added" would announce the whole workspace on first use. + const found = previous?.items ? diff(previous.items, outcome.items) : null + changes = found && !isEmpty(found) ? found : null + } + // A check that could not read the items keeps what was last known: a failure says + // nothing about them. + const items = outcome.items ?? previous?.items + const next: StoredEntity = { + kind, + status: outcome.error ? "error" : "ok", + count: items ? Object.keys(items).length : null, + lastCheckedAt: now, + lastChangedAt: changes ? now : (previous?.lastChangedAt ?? null), + changes: changes ?? previous?.changes ?? null, + ...(outcome.error ? { error: outcome.error } : {}), + ...(items ? { items } : {}), + } + file.entities[kind] = next + Filesystem.writeJsonAtomic(filePath(directory), file) + + const errorChanged = (previous?.status ?? "ok") !== next.status || previous?.error !== next.error + if (!changes && !errorChanged) return + const { items: _items, ...state } = next + // Needs an instance (a turn, a server route). A sync started outside one — a bind from + // the CLI — has no client listening anyway; the state above is what it leaves behind. + await Bus.publish(Event.Changed, { directory: canon, datamateId, kind, state, changes }).catch((err) => + log.info("workspace sync change not published", { kind, err: String(err) }), + ) +} + +// altimate_change end diff --git a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx index 4e8400bf1..7f06b74f6 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx @@ -307,6 +307,11 @@ function View(props: { api: TuiPluginApi }) { {(at) => {`skills synced ${describeAge(at())}`}} + {/* altimate_change start — workspace sync state */} + + {(at) => {`memory loaded ${describeAge(at())}`}} + + {/* altimate_change end */} {/* altimate_change end */} {/* ``pinned via --workspace`` means "this SESSION was launched * with --workspace and it resolved to this id". It does NOT diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 19d3fb696..688298541 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1141,6 +1141,34 @@ export namespace Server { } }) // altimate_change end + // altimate_change start — GET /altimate/workspace/status + // The recorded sync state for the request's directory: per entity kind, when it was last + // checked and changed, and what changed. Read-only and network-free past the binding + // resolution (memoized), so a client may call it whenever it renders. Changes after this + // read arrive as `altimate.workspace.sync.changed` on `/event`. + .get("/altimate/workspace/status", async (c) => { + const refused = workspaceRouteRefusal( + c.req.header("origin"), + c.req.header("host"), + undefined, + c.req.header("sec-fetch-site"), + ) + if (refused) return c.json(refused.body, refused.status) + try { + const { resolveBindingOutcome } = await import("../altimate/workspace/state") + const SyncState = await import("../altimate/workspace/sync-state") + const outcome = await resolveBindingOutcome(Instance.directory) + if (outcome.status !== "bound") return c.json({ ok: true as const, datamateId: null, entities: {} }) + const datamateId = outcome.binding.datamateId + const state = SyncState.read(Instance.directory, datamateId) + return c.json({ ok: true as const, datamateId, entities: state?.entities ?? {} }) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + log.error("workspace status: failed", { error }) + return c.json({ ok: false, error }, 500) + } + }) + // altimate_change end // altimate_change start — GET /altimate/skill/publishable, POST /altimate/skill/publish // The CLI's `skill publish ` for the IDE extension. Only `serve` holds the extension's // pin, so publishing here targets the workspace selected in the panel — through the same diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index eb3740424..3b2b0d1a9 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -2053,3 +2053,48 @@ describe("binding resolution for the mirror", () => { expect(posts.length).toBe(0) }) }) + +// altimate_change start — workspace sync state +describe("sync state", () => { + const note = (blockId: string, title: string, updated = "2026-01-01T00:00:00.000Z") => ({ + id: `mem-${blockId}`, + memory: `# ${title}\nbody`, + metadata: { source: MIRROR_SOURCE, block_id: blockId, block_scope: "global", block_updated: updated }, + }) + + /** The record is written detached from the load, so poll for it. */ + async function memoryState(dir: string, predicate: (e: any) => boolean = () => true) { + const SyncState = await import("../../../src/altimate/workspace/sync-state") + for (let i = 0; i < 50; i++) { + const entity = SyncState.read(dir, BINDING.datamateId)?.entities.memory + if (entity && predicate(entity)) return entity + await new Promise((r) => setTimeout(r, 10)) + } + return SyncState.read(dir, BINDING.datamateId)?.entities.memory + } + + test("loads are recorded per project; a later load records what changed, by title", async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "memory-sync-state-")) + listResponse = [note("a", "Naming conventions"), note("b", "Warehouse sizing")] + await refresh(`${SES}-state-1`, dir) + expect(await memoryState(dir)).toMatchObject({ kind: "memory", status: "ok", count: 2, changes: null }) + + // Another session reading the same blocks is not a change. + await refresh(`${SES}-state-2`, dir) + expect((await memoryState(dir))?.changes).toBeNull() + + listResponse = [note("a", "Naming conventions", "2026-02-01T00:00:00.000Z"), note("c", "Cost alerts")] + await refresh(`${SES}-state-3`, dir) + const entity = await memoryState(dir, (e) => e.changes !== null) + expect(entity?.changes).toEqual({ added: ["Cost alerts"], removed: ["Warehouse sizing"], updated: ["Naming conventions"] }) + expect(entity?.count).toBe(2) + }) + + test("a load for an unbound project records nothing", async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "memory-sync-state-off-")) + syncInternals.resolveBinding = async () => null + await refresh(`${SES}-state-off`, dir) + expect(await memoryState(dir)).toBeUndefined() + }) +}) +// altimate_change end diff --git a/packages/opencode/test/altimate/workspace/skill-sync.test.ts b/packages/opencode/test/altimate/workspace/skill-sync.test.ts index a59668130..762751e24 100644 --- a/packages/opencode/test/altimate/workspace/skill-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/skill-sync.test.ts @@ -2545,3 +2545,57 @@ describe("shouldAnnounce", () => { expect(shouldAnnounce("/tmp/other", problem)).toBe(true) }) }) + +// altimate_change start — workspace sync state +describe("sync state", () => { + const read = async () => (await import("@/altimate/workspace/sync-state")).read(project, 1)?.entities.skills + + test("the first sync is a baseline and a later one records what changed, by name", async () => { + serve({ "pub-1": { "SKILL.md": "one" } }) + await syncSkills(project) + expect(await read()).toMatchObject({ status: "ok", count: 1, changes: null, lastChangedAt: null }) + + serve({ "pub-1": { "SKILL.md": "one" }, "pub-2": { "SKILL.md": "two" } }) + await syncSkills(project) + const entity = await read() + expect(entity).toMatchObject({ status: "ok", count: 2, changes: { added: ["pub-2"], removed: [], updated: [] } }) + expect(entity?.lastChangedAt).toBeNumber() + }) + + test("an edited skill is recorded as updated", async () => { + serve({ "pub-1": { "SKILL.md": "one" } }, "2026-01-01T00:00:00Z") + await syncSkills(project) + serve({ "pub-1": { "SKILL.md": "one, edited" } }, "2026-02-01T00:00:00Z") + await syncSkills(project) + expect((await read())?.changes).toEqual({ added: [], removed: [], updated: ["pub-1"] }) + }) + + test("a failed list is an error that keeps the last known count", async () => { + serve({ "pub-1": { "SKILL.md": "one" } }) + await syncSkills(project) + globalThis.fetch = (async () => { + throw new Error("offline") + }) as unknown as typeof fetch + await syncSkills(project) + expect(await read()).toMatchObject({ status: "error", count: 1, error: expect.stringContaining("skill list") }) + }) + + test("a skill that could not be installed is not counted, and the problem is recorded", async () => { + serve({ "pub-1": { "SKILL.md": "one" } }) + await syncSkills(project) + + serve({ "pub-1": { "SKILL.md": "one" }, "pub-2": { "SKILL.md": "two" } }, "2026-03-01T00:00:00Z") + const inner = globalThis.fetch + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + if (String(input).includes("/pub-2")) throw new Error("offline") + return inner(input as never, init as never) + }) as unknown as typeof fetch + await syncSkills(project) + + const entity = await read() + expect(entity).toMatchObject({ status: "error", count: 1, error: expect.stringContaining("pub-2") }) + // pub-1 moved to a new version; pub-2 never arrived, so it is not reported as added. + expect(entity?.changes).toEqual({ added: [], removed: [], updated: ["pub-1"] }) + }) +}) +// altimate_change end diff --git a/packages/opencode/test/altimate/workspace/sync-state.test.ts b/packages/opencode/test/altimate/workspace/sync-state.test.ts new file mode 100644 index 000000000..61476d03a --- /dev/null +++ b/packages/opencode/test/altimate/workspace/sync-state.test.ts @@ -0,0 +1,152 @@ +// altimate_change - new file +// +// Unit coverage for the workspace sync state (src/altimate/workspace/sync-state.ts): +// what a check records, what counts as a change, and when the change event is published. +// The state file is real, in a sandboxed state directory; only the bus is spied, because a +// publish needs an instance the unit under test does not own. +import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs" +import os from "node:os" +import path from "node:path" + +// Global.Path.state resolves at module load, so the sandbox must exist first. +const ORIGINAL_XDG_STATE_HOME = process.env.XDG_STATE_HOME +const SANDBOX = path.join(os.tmpdir(), `altimate-sync-state-${process.pid}-${Date.now()}`) +mkdirSync(path.join(SANDBOX, "state"), { recursive: true }) +process.env.XDG_STATE_HOME = path.join(SANDBOX, "state") + +afterAll(() => { + if (ORIGINAL_XDG_STATE_HOME === undefined) delete process.env.XDG_STATE_HOME + else process.env.XDG_STATE_HOME = ORIGINAL_XDG_STATE_HOME + rmSync(SANDBOX, { recursive: true, force: true }) +}) + +const { Bus } = await import("../../../src/bus") +const SyncState = await import("../../../src/altimate/workspace/sync-state") + +let project: string +let published: Array<{ type: string; properties: any }> +let publish: ReturnType + +beforeEach(() => { + project = realpathSync(mkdtempSync(path.join(SANDBOX, "project-"))) + published = [] + publish = spyOn(Bus, "publish").mockImplementation((async (def: { type: string }, properties: unknown) => { + published.push({ type: def.type, properties }) + }) as never) +}) + +afterEach(() => { + publish.mockRestore() +}) + +const skill = (label: string, version = "v1") => ({ label, version }) + +describe("diff", () => { + test("reports added, removed and updated labels, sorted", () => { + const changes = SyncState.diff( + { a: skill("Alpha"), b: skill("Beta"), c: skill("Gamma") }, + { a: skill("Alpha"), b: skill("Beta", "v2"), d: skill("Delta") }, + ) + expect(changes).toEqual({ added: ["Delta"], removed: ["Gamma"], updated: ["Beta"] }) + }) + + test("identical sets have no changes", () => { + expect(SyncState.diff({ a: skill("Alpha") }, { a: skill("Alpha") })).toEqual({ added: [], removed: [], updated: [] }) + }) +}) + +describe("record", () => { + test("the first check is a baseline: recorded, not announced", async () => { + await SyncState.record(project, 7, "skills", { items: { a: skill("Alpha"), b: skill("Beta") } }) + + const state = SyncState.read(project, 7) + expect(state?.entities.skills).toMatchObject({ kind: "skills", status: "ok", count: 2, lastChangedAt: null, changes: null }) + expect(state?.entities.skills?.lastCheckedAt).toBeNumber() + expect(published).toEqual([]) + }) + + test("a later check that finds a difference records and publishes it", async () => { + await SyncState.record(project, 7, "skills", { items: { a: skill("Alpha") } }) + await SyncState.record(project, 7, "skills", { items: { a: skill("Alpha", "v2"), b: skill("Beta") } }) + + const entity = SyncState.read(project, 7)?.entities.skills + expect(entity?.changes).toEqual({ added: ["Beta"], removed: [], updated: ["Alpha"] }) + expect(entity?.lastChangedAt).toBe(entity!.lastCheckedAt) + expect(published).toHaveLength(1) + expect(published[0]).toMatchObject({ + type: "altimate.workspace.sync.changed", + properties: { datamateId: 7, kind: "skills", changes: { added: ["Beta"], removed: [], updated: ["Alpha"] } }, + }) + // The event carries what the status route serves — never the raw item map. + expect(published[0].properties.state.items).toBeUndefined() + }) + + test("a check that finds nothing new publishes nothing and keeps the last change", async () => { + await SyncState.record(project, 7, "skills", { items: { a: skill("Alpha") } }) + await SyncState.record(project, 7, "skills", { items: { b: skill("Beta") } }) + published = [] + await SyncState.record(project, 7, "skills", { items: { b: skill("Beta") } }) + + expect(published).toEqual([]) + expect(SyncState.read(project, 7)?.entities.skills?.changes).toEqual({ added: ["Beta"], removed: ["Alpha"], updated: [] }) + }) + + test("an error is published once, keeps the last items, and recovery is published", async () => { + await SyncState.record(project, 7, "skills", { items: { a: skill("Alpha") } }) + await SyncState.record(project, 7, "skills", { error: "could not fetch the workspace's skill list" }) + await SyncState.record(project, 7, "skills", { error: "could not fetch the workspace's skill list" }) + + const failed = SyncState.read(project, 7)?.entities.skills + expect(failed).toMatchObject({ status: "error", count: 1, error: "could not fetch the workspace's skill list" }) + expect(published).toHaveLength(1) + expect(published[0].properties.changes).toBeNull() + + // Recovery with the same items: no difference, but the error state cleared. + await SyncState.record(project, 7, "skills", { items: { a: skill("Alpha") } }) + expect(published).toHaveLength(2) + expect(published[1].properties.state).toMatchObject({ status: "ok" }) + expect(published[1].properties.state.error).toBeUndefined() + }) + + test("a failure before anything was read leaves the next read as the baseline", async () => { + await SyncState.record(project, 7, "memory", { error: "could not load workspace memory" }) + published = [] + await SyncState.record(project, 7, "memory", { items: { m1: skill("Naming conventions") } }) + + expect(SyncState.read(project, 7)?.entities.memory?.changes).toBeNull() + // The recovery is still news; the existing block is not "added". + expect(published).toHaveLength(1) + expect(published[0].properties.changes).toBeNull() + }) + + test("kinds are recorded independently", async () => { + await Promise.all([ + SyncState.record(project, 7, "skills", { items: { a: skill("Alpha") } }), + SyncState.record(project, 7, "memory", { items: { m1: skill("One"), m2: skill("Two") } }), + ]) + const state = SyncState.read(project, 7) + expect(state?.entities.skills?.count).toBe(1) + expect(state?.entities.memory?.count).toBe(2) + }) + + test("another workspace's state is not this one's", async () => { + await SyncState.record(project, 7, "skills", { items: { a: skill("Alpha") } }) + expect(SyncState.read(project, 8)).toBeNull() + + // A rebind starts over: the new workspace's first check is again a baseline. + await SyncState.record(project, 8, "skills", { items: { z: skill("Zeta") } }) + expect(SyncState.read(project, 8)?.entities.skills?.changes).toBeNull() + expect(SyncState.read(project, 7)).toBeNull() + expect(published).toEqual([]) + }) + + test("a publish failure does not fail the record", async () => { + publish.mockImplementation((async () => { + throw new Error("no instance") + }) as never) + await SyncState.record(project, 7, "skills", { items: { a: skill("Alpha") } }) + await SyncState.record(project, 7, "skills", { items: { b: skill("Beta") } }) + expect(SyncState.read(project, 7)?.entities.skills?.changes).toEqual({ added: ["Beta"], removed: ["Alpha"], updated: [] }) + }) +}) diff --git a/packages/opencode/test/server/altimate-workspace-routes.test.ts b/packages/opencode/test/server/altimate-workspace-routes.test.ts index c5637eede..30cfa3a38 100644 --- a/packages/opencode/test/server/altimate-workspace-routes.test.ts +++ b/packages/opencode/test/server/altimate-workspace-routes.test.ts @@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { Server } from "../../src/server/server" import * as Manage from "../../src/altimate/workspace/manage" +import * as State from "../../src/altimate/workspace/state" +import * as SyncState from "../../src/altimate/workspace/sync-state" import { Session } from "../../src/session" import { NotFoundError } from "../../src/storage/db" import { resetDatabase } from "./db" @@ -230,6 +232,45 @@ describe("POST /altimate/workspace/sync", () => { }) }) +// altimate_change start — GET /altimate/workspace/status +describe("GET /altimate/workspace/status", () => { + const get = (headers: Record = {}) => Server.Default().request("/altimate/workspace/status", { headers }) + + test("returns the recorded state for the bound workspace", async () => { + spies.push( + spyOn(State, "resolveBindingOutcome").mockResolvedValue({ + status: "bound", + binding: { datamateId: 5, datamateName: "Analytics", repoRemote: null, projectPath: null, linkedAt: 1 }, + } as never), + ) + await SyncState.record(process.cwd(), 5, "skills", { items: { a: { label: "Alpha", version: "v1" } } }) + + const response = await get() + expect(response.status).toBe(200) + const body = (await response.json()) as { ok: boolean; datamateId: number; entities: Record } + expect(body.ok).toBe(true) + expect(body.datamateId).toBe(5) + expect(body.entities.skills).toMatchObject({ kind: "skills", status: "ok", count: 1 }) + // The item map is internal; the route serves the metadata only. + expect(body.entities.skills.items).toBeUndefined() + }) + + test("answers an unbound project with no workspace and no state", async () => { + spies.push(spyOn(State, "resolveBindingOutcome").mockResolvedValue({ status: "unbound" } as never)) + expect(await (await get()).json()).toEqual({ ok: true, datamateId: null, entities: {} }) + }) + + test("is refused under the kill switch", async () => { + process.env.ALTIMATE_DISABLE_WORKSPACE = "1" + expect((await get()).status).toBe(409) + }) + + test("refuses a browser origin on an unsecured server", async () => { + expect((await get({ origin: "https://evil.test" })).status).toBe(403) + }) +}) +// altimate_change end + describe("origin policy with a server password set", () => { // The password flag is read once at module load, so the policy is exercised directly with one. test("lets a native client (no Origin) and this server's own page through", () => {