-
Notifications
You must be signed in to change notification settings - Fork 134
feat(workspace): show memory and skill-sync state in the sidebar #1279
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/workspace-followups
Are you sure you want to change the base?
Changes from all commits
e058979
1789ce6
cd8afda
055e030
ddff6aa
cd2561a
11e3084
8638e34
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,8 +37,16 @@ export interface StatusReport { | |
| /** Blocks held locally for this project, and how many have not reached the | ||
| * workspace. `null` when memory is off — "not synced" and "not applicable" are | ||
| * different answers and a status line must not conflate them. */ | ||
| memory: { local: number; unsynced: number } | null | ||
| /** `unsynced: null` means the workspace's memory setting could not be | ||
| * resolved, so how much is outstanding is genuinely unknown. Rendering it as | ||
| * 0 would tell the user their memory is current when nobody knows. */ | ||
| memory: { local: number; unsynced: number | null } | null | ||
| 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 | ||
| * 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 | ||
| } | ||
|
|
||
| export interface RefreshReport { | ||
|
|
@@ -73,12 +81,27 @@ export interface SyncReport { | |
| * | ||
| * Cheap enough for a status line: one binding read from the local cache and, when | ||
| * memory is on, one index read. No network. */ | ||
| export async function status(directory: string): Promise<StatusReport> { | ||
| export async function status( | ||
| directory: string, | ||
| opts: { | ||
| /** Set false for pollers. Resolves the workspace's memory setting through the | ||
| * poller path, which asks the service at most once every few minutes when the | ||
| * answer is "no" and not at all once it is "yes" — rather than on every tick, | ||
| * which is what asking the positive-only cache directly would cost. | ||
| * | ||
| * It does NOT mean "never touch the network": an earlier version of this took | ||
| * that literally, and the result was a sidebar whose counts never appeared at | ||
| * all on a session where nothing else warmed the cache — the exact drift the | ||
| * line exists to surface. Bounded, not forbidden. */ | ||
| allowNetwork?: boolean | ||
| } = {}, | ||
| ): Promise<StatusReport> { | ||
| const binding = await readLocalBinding(directory).catch(() => null) | ||
| return { | ||
| binding, | ||
| memory: await memoryCounts(directory), | ||
| memory: await memoryCounts(directory, opts.allowNetwork !== false), | ||
| skillsEnabled: SkillSync.isEnabled(), | ||
| skillsSyncedAt: SkillSync.lastSuccessfulSyncAt(directory), | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -176,11 +199,29 @@ export async function sync(directory: string): Promise<SyncReport> { | |
| /** Local block count and how many have not reached the workspace, or null when | ||
| * memory is off. Best-effort: a status line must not fail because an index read | ||
| * did. */ | ||
| async function memoryCounts(directory: string): Promise<{ local: number; unsynced: number } | null> { | ||
| async function memoryCounts( | ||
| directory: string, | ||
| allowNetwork: boolean, | ||
| ): Promise<{ local: number; unsynced: number | null } | null> { | ||
| if (!MemorySync.isEnabled()) return null | ||
| try { | ||
| const blocks = await MemoryStore.listAll({ directory }) | ||
| const binding = await readLocalBinding(directory).catch(() => null) | ||
| // A poller resolves through the rate-limited path; everything else asks | ||
| // directly. Either way the answer is real, so the counts a status line shows | ||
| // agree with what a sweep would actually send. | ||
| if (!allowNetwork && binding) { | ||
| const status = await MemorySync.memoryEnabledForPoller(binding) | ||
| // "disabled" is a real answer: memory is off, so nothing is outstanding | ||
| // and 0 is the truth. "unknown" is not — the service could not be | ||
| // reached, and reporting 0 there claims the workspace is up to date on | ||
| // the strength of a failed request. Say how many blocks exist locally, | ||
| // and say nothing about sync. | ||
| if (status !== "enabled") { | ||
| const blocks = await MemoryStore.listAll({ directory }) | ||
| return { local: blocks.length, unsynced: status === "disabled" ? 0 : null } | ||
| } | ||
| } | ||
| const blocks = await MemoryStore.listAll({ directory }) | ||
| return { local: blocks.length, unsynced: await MemorySync.pendingCount(blocks, binding) } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: The poller still drips a When Reply with |
||
| } catch (err) { | ||
| log.warn("could not count local memory for the workspace status", { err: String(err) }) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ import { TRAINING_META_COMMENT } from "@/altimate/training/types" | |
| import { resolveBinding as resolveProjectBinding, type CachedBinding } from "./state" | ||
| import { indexKey, readIndex, readIndexEntry, recordIndexEntry } from "./memory-index" | ||
| import { WorkspaceApi } from "./api-client" | ||
| import { AltimateApi } from "@/altimate/api/client" | ||
| import { | ||
| LIST_LIMIT, | ||
| MemoryApi, | ||
|
|
@@ -616,6 +617,69 @@ async function runQueue<T>( | |
| return { ok, failed, declined, skipped } | ||
| } | ||
|
|
||
| /** How long a poller trusts either answer. Deliberately separate from | ||
| * `MEMORY_ENABLED_TTL_MS` and from the main cache: `memoryEnabled` stays | ||
| * positive-only with a 60s TTL so the WRITE path picks up a newly enabled | ||
| * workspace almost at once, which is the property that matters for not losing | ||
| * memory. A poller can afford to be a few minutes behind; what it cannot afford | ||
| * is a request every tick. | ||
| * | ||
| * Both answers are memoized, not just the "no". Reusing the write path's 60s | ||
| * positive meant that a minute after the first check an ENABLED workspace went | ||
| * back to the network on every other tick — a steady drip of `/datamates` | ||
| * requests for the life of the session. (cubic P2 on #1279.) */ | ||
| const POLL_TTL_MS = 5 * 60 * 1000 | ||
|
|
||
| /** Keyed by tenant and API URL as well as workspace id. Workspace ids are | ||
| * tenant-local, so a bare id let a same-numbered workspace in a NEWLY switched | ||
| * account inherit the previous tenant's answer and hide its unsynced count for | ||
| * the whole TTL. (cubic P2 on #1279.) */ | ||
| const pollMemo = new Map<string, { at: number; status: "enabled" | "disabled" }>() | ||
|
|
||
| async function pollMemoKey(binding: CachedBinding): Promise<string> { | ||
| try { | ||
| const creds = await AltimateApi.getCredentials() | ||
| return `${creds.altimateInstanceName}|${creds.altimateUrl}|${binding.datamateId}` | ||
| } catch { | ||
| // No credentials resolved: fall back to an id-only key. The caller is about | ||
| // to fail its lookup anyway, and a wrong-tenant hit is impossible when | ||
| // there is no tenant. | ||
| return `?|?|${binding.datamateId}` | ||
| } | ||
| } | ||
|
|
||
| /** Whether this workspace has memory on, for a caller that polls. | ||
| * | ||
| * Three-way on purpose. `memoryEnabled` folds "the service could not be | ||
| * reached" into `false`, which is right for the write path — it fails closed so | ||
| * an outage cannot leak a mirror — but wrong for a status line: rendering an | ||
| * unreachable service as "0 not synced" tells the user their memory is current | ||
| * when nobody knows. `memoryStatus` already draws that distinction; this used | ||
| * to throw it away and then memoize the result for five minutes. (cubic P2 on | ||
| * #1279.) | ||
| * | ||
| * An "unknown" is never memoized: the next tick should ask again rather than | ||
| * inherit a network blip. */ | ||
| export async function memoryEnabledForPoller( | ||
| binding: CachedBinding, | ||
| ): Promise<"enabled" | "disabled" | "unknown"> { | ||
| const cached = memoryEnabledCache.get(binding.datamateId) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: The tenant-unscoped The account-switch fix scoped Reply with |
||
| if (cached && Date.now() - cached.checkedAt < MEMORY_ENABLED_TTL_MS) return "enabled" | ||
| const key = await pollMemoKey(binding) | ||
| const memo = pollMemo.get(key) | ||
| if (memo && Date.now() - memo.at < POLL_TTL_MS) return memo.status | ||
| const status = await memoryStatus(binding) | ||
| if (status === "error") return "unknown" | ||
| pollMemo.set(key, { at: Date.now(), status }) | ||
| return status | ||
| } | ||
|
|
||
| /** Test seam: the poller memo is process-global and would otherwise leak between | ||
| * cases in the same file. */ | ||
| export function resetPollMemoForTests(): void { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION:
Reply with |
||
| pollMemo.clear() | ||
| } | ||
|
|
||
| /** Split blocks into those the workspace still needs and those already there at | ||
| * their current payload. | ||
| * | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -251,6 +251,18 @@ export async function flushPendingSyncs(timeoutMs = 30_000): Promise<void> { | |
| } | ||
| } | ||
|
|
||
| /** When this project's workspace skills last synced successfully, or null if | ||
| * they never have in this process. | ||
| * | ||
| * Exposed for the sidebar. `recentlySynced` answers a boolean against the poll | ||
| * interval, which cannot say "6 minutes ago" — and a status line whose whole job | ||
| * is to make staleness visible needs the age, not a threshold. Reads the | ||
| * process-global store, so the TUI plugin realm sees the same map the sync | ||
| * writes (see `STORE_KEY` above). */ | ||
| export function lastSuccessfulSyncAt(directory: string): number | null { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: The TUI (and this sidebar) renders on the main thread ( In a normal TUI session the line therefore either stays hidden forever (no main-thread sync has run) or freezes at link/refresh time and grows without bound while the worker actually re-syncs every 5 minutes — reporting false staleness from the one line whose job is to make staleness visible. Consider a disk-backed signal, e.g. the manifest mtime already read by Reply with |
||
| return lastSyncedAt.get(path.resolve(directory)) ?? null | ||
| } | ||
|
|
||
| /** Has this project's snapshot been checked within the poll interval? Callers | ||
| * on a per-message path use this to skip the network entirely. */ | ||
| export async function recentlySynced(directory: string): Promise<boolean> { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -403,6 +403,45 @@ export async function resolveBindingOutcome(directory: string): Promise<BindingO | |
|
|
||
| /** Drop a cached row the server no longer recognises, so later reads do not | ||
| * resurrect it from disk. */ | ||
| /** Listeners fired when THIS process changes a project's binding. | ||
| * | ||
| * Exists for the sidebar tile, which otherwise learns about a link or unlink | ||
| * only on its next 30s poll: the user hits Unlink, gets a success toast, and | ||
| * watches the pane next to it keep naming the workspace for up to half a | ||
| * minute. The stale half is the one that looks authoritative. | ||
| * | ||
| * Deliberately a plain listener set rather than an event bus. Every binding | ||
| * write already funnels through this module, so one hook here covers link, | ||
| * unlink and rebind; a bus would mean plumbing a dependency through each | ||
| * writer for a single subscriber. The poll stays as the backstop — it is what | ||
| * catches a change made by ANOTHER process, which no in-process notifier can | ||
| * see. */ | ||
| const bindingChangeListeners = new Set<() => void>() | ||
|
|
||
| export function onBindingChanged(listener: () => void): () => void { | ||
| bindingChangeListeners.add(listener) | ||
| return () => { | ||
| bindingChangeListeners.delete(listener) | ||
| } | ||
| } | ||
|
|
||
| /** Never throws: a listener is a UI refresh, and one bad subscriber must not | ||
| * fail the link or unlink that notified it. Iterates a copy so a listener that | ||
| * unsubscribes itself mid-notify cannot skip the next one. */ | ||
| function notifyBindingChanged(): void { | ||
| // Snapshot first: a listener may subscribe or unsubscribe while being | ||
| // notified, and iterating the live Set would then walk a collection that | ||
| // changed underneath us. | ||
| const listeners = Array.from(bindingChangeListeners) | ||
| for (const listener of listeners) { | ||
| try { | ||
| listener() | ||
| } catch (err) { | ||
| log.warn("a binding-change listener threw", { err: String(err) }) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** Drop a directory's row without checking which account the cache belongs to. | ||
| * | ||
| * Only for the no-credentials unlink path above. The scoped `forgetBinding` is | ||
|
|
@@ -421,14 +460,33 @@ function forgetBindingUnscoped(directory: string): void { | |
| } | ||
|
|
||
| function forgetBinding(directory: string, key: { tenant: string; apiUrl: string }): void { | ||
| let dropped = false | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: Lines 446/452/471 are leftovers from the earlier Reply with |
||
| try { | ||
| const cache = readCache() | ||
| if (!cache || cache.tenant !== key.tenant || cache.apiUrl !== key.apiUrl) return | ||
| delete cache.bindings[canonicalizeKey(directory)] | ||
| writeCache(cache) | ||
| dropped = true | ||
| } catch (err) { | ||
| log.warn("could not drop a binding the server no longer recognises", { err: String(err) }) | ||
| } | ||
| // Outside the try on purpose. A listener is a UI refresh; its failure is not | ||
| // a failed cache drop, and notifying from inside would log a throwing | ||
| // subscriber as "could not drop a binding" — a misleading line about a write | ||
| // that had already succeeded. | ||
| // | ||
| // Notified even when the write FAILED, which is not obvious. The server-side | ||
| // unlink has already happened by the time we get here, and the resolve path | ||
| // does not depend on this file having been rewritten: `clearLocalBinding` | ||
| // drops the revalidation stamp and records a lookup miss, so the next resolve | ||
| // asks the server, hears "unbound", and the tile updates. Skipping the | ||
| // notification on a failed write left the pane naming a workspace this | ||
| // project is no longer bound to until the next poll — the exact lag the | ||
| // notifier exists to remove, in the case where something is already wrong. | ||
| // (cubic P2 on #1279; an earlier version of this guarded on `dropped` and I | ||
| // wrongly called the difference unobservable.) | ||
| void dropped | ||
| notifyBindingChanged() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: The "notified even when the write failed" chain re-serves the stale row one refresh later The justification in the comment (the next resolve asks the server, hears unbound, and the tile updates) holds for exactly one resolve when Reply with |
||
| } | ||
|
|
||
| /** The server's answer for this project, with no cache consulted. */ | ||
|
|
@@ -563,13 +621,17 @@ export async function recordApprovedBinding( | |
| // synchronously on the `link` path, which awaits the seed. | ||
| let bindingChanged = true | ||
| let alreadySeeded = false | ||
| /** The name as it was on disk, so a rename can be detected even when the | ||
| * binding's identity is unchanged. `undefined` when there was no prior row. */ | ||
| let priorName: string | undefined | ||
| try { | ||
| const existing = readCache() | ||
| const cache: CacheFile = | ||
| existing && existing.tenant === key.tenant && existing.apiUrl === key.apiUrl | ||
| ? existing | ||
| : { version: CACHE_VERSION, tenant: key.tenant, apiUrl: key.apiUrl, bindings: {} } | ||
| const prior = cache.bindings[canonicalizeKey(directory)] | ||
| priorName = prior?.datamateName | ||
| bindingChanged = !prior || !sameBinding(prior, binding) | ||
| alreadySeeded = !bindingChanged && !!prior?.seededAt | ||
| // Carry the seed marker across a warm so a completed seed is not repeated. | ||
|
|
@@ -586,6 +648,17 @@ export async function recordApprovedBinding( | |
| }) | ||
| } | ||
|
|
||
| // Only when something a subscriber could render actually changed. A warm | ||
| // cache re-read must not wake the tile on every resolve. | ||
| // | ||
| // `bindingChanged` alone is not the right test: `sameBinding` compares | ||
| // identity (id, remote, path) because it also gates the memory seed, and | ||
| // widening it would re-seed a whole workspace every time someone renamed one. | ||
| // But the sidebar renders `datamateName`, so a rename is a visible change | ||
| // with an unchanged identity. Checked separately for that reason. (cubic P2 | ||
| // on #1279.) | ||
| if (bindingChanged || priorName !== binding.datamateName) notifyBindingChanged() | ||
|
|
||
| // altimate_change start - seed the workspace with the memory this machine | ||
| // already holds. Deliberately OUTSIDE the try above: a failed cache write | ||
| // must not skip the backfill, and a failed backfill must not read as a failed | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: When the poller returns
unknown, this line encodes it only asunsynced: null, but both status consumers render that as an ordinary memory count. Expose an explicit unknown state or renderunknownso an outage is distinguishable from disabled/current, as promised by the status contract.Prompt for AI agents