From 88f267c863fbff19871f76dcf7ad670a90351c92 Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 8 Oct 2026 13:31:02 +0530 Subject: [PATCH] feat(workspace): record workspace sync state and publish changes to clients Every client needed to say when workspace skills and memory were last checked and what changed, but only the TUI sidebar could see part of it ("skills synced Xm ago"), and an IDE extension running `serve` saw nothing. - Add `altimate/workspace/sync-state.ts`: one record per synced entity kind per project (last checked, last changed, count, status, what changed), persisted under the state dir so the TUI main thread and the server worker share it. Diffs are generic over `{id: {label, version}}`, so a new kind records the same metadata. - Skill sync and memory loads record into it from their existing paths; no new timers. The first check is a baseline, not a change. - Publish `altimate.workspace.sync.changed` on the bus, only when a check found a difference or an error state changed. - Add `GET /altimate/workspace/status`, returning the recorded state for the request's workspace. - TUI sidebar shows "memory loaded Xm ago" from the same state. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../opencode/src/altimate/workspace/manage.ts | 7 + .../src/altimate/workspace/memory-sync.ts | 35 ++- .../src/altimate/workspace/skill-sync.ts | 65 +++++- .../src/altimate/workspace/sync-state.ts | 212 ++++++++++++++++++ .../plugin/tui/altimate/workspace-sidebar.tsx | 5 + packages/opencode/src/server/server.ts | 28 +++ .../altimate/workspace/memory-sync.test.ts | 45 ++++ .../altimate/workspace/skill-sync.test.ts | 54 +++++ .../altimate/workspace/sync-state.test.ts | 152 +++++++++++++ .../server/altimate-workspace-routes.test.ts | 41 ++++ 10 files changed, 637 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/src/altimate/workspace/sync-state.ts create mode 100644 packages/opencode/test/altimate/workspace/sync-state.test.ts 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", () => {