Skip to content

Commit abda8e7

Browse files
sahrizviclaude
andauthored
feat(workspace): tell the user which workspace skills a sync skipped, and why (#1374)
* feat(workspace): tell the user which workspace skills a sync skipped, and why A skill that failed to sync was a log line only, so "nothing arrived" looked exactly like "this workspace has no skills". Surface it instead: - `syncSkills` now returns `skipped` (each skill with a short reason) and `error` (set when the whole sync could not proceed: an unusable or foreign folder, unreadable credentials, an unconfirmed workspace, an unreachable skill list, or a failed publish). - The per-turn sync shows a warning toast naming the skipped skills. The same problem is announced once, not on every retry; a clean run clears it. - `/workspace` Refresh folds skipped skills into its "problems" line, and the `serve` refresh report carries `skillsSkipped` through to the IDE. - Reasons are fixed, user-facing strings. The raw error can carry request URLs, server text or local paths; it stays in the log. Offline is reported at the binding lookup, not the list fetch: the binding is re-resolved on the network, so an outage fails that first. Found in e2e — a unit test that bound the project locally reached the list and missed it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workspace): address review on the skipped-skill warnings - Headless `run` has no toast renderer, so print the warning to stderr there, the same way the workspace engine reports. - Clear the "already announced" latch inside `syncSkills` on any clean run, so a clean Refresh or IDE refresh resets it too. - Keep that latch in the process-global store, like the rest of this file's state, so a second module record cannot fork it. - Release the latch when the warning could not be shown (`notify` now reports whether it published), but only if it still holds that problem. - After a rebind or on a first sync, a failed pull no longer claims the previous skills were kept — there were none left to keep. - Cap the Refresh toast's skipped list the same way as the per-turn warning, by reusing `describeSyncProblems`. The IDE route still gets every entry. - Word a size mismatch as "its file size could not be verified": a valid binary bundle mismatches too, so "incomplete download" misdiagnoses it. - Test every skip reason, the kept-previous-copy suffix, the rebind wording, the latch lifecycle, and that the route passes `skillsSkipped` through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(workspace): answer the binding lookup in `serveSkills`, and pin the rebind reason `syncSkills` resolves the binding on the network every run. `serveSkills` did not answer that lookup, so the mock threw, the lookup was swallowed as "offline", and the new tests passed while running the stale-binding path instead of the bound one. It now confirms the workspace each test binds to. The rebind test also asserts the per-skill reason, so a regression that promised "kept the previous copy" after a rebind would fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workspace): address consensus review on the skipped-skill warnings - Show a skill id sanitised and capped. An id that failed `safePathComponent` is arbitrary text from the workspace service; control characters and line separators reached the toast and the serve JSON as-is. The raw id stays in the log. - Stay quiet when a binding lookup fails for a project with no sign of a workspace (no snapshot, no local binding). The server is always asked when there is no local row, so offline warned in every opted-in repo, including ones never linked. - Say "it could not be saved on this device" for local write failures (ENOSPC, EACCES, …) instead of blaming the download. - Keep the per-turn warning when the skill registry refresh fails; it had been skipped, for up to a poll interval after an account switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b4778e9 commit abda8e7

7 files changed

Lines changed: 564 additions & 15 deletions

File tree

‎packages/opencode/src/altimate/workspace/engine-probes.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,14 +274,21 @@ function pidAlive(pid: number): boolean {
274274
}
275275
}
276276

277-
export async function notify(toast: Toast): Promise<void> {
278-
if (syncInternals.notify) return syncInternals.notify(toast)
277+
/** Resolves `false` when the toast could not be published, so a caller that
278+
* remembers what it announced can forget it and try again. Never throws. */
279+
export async function notify(toast: Toast): Promise<boolean> {
280+
if (syncInternals.notify) {
281+
await syncInternals.notify(toast)
282+
return true
283+
}
279284
try {
280285
await AppRuntime.runPromise(
281286
EventV2Bridge.Service.use((events) => events.publish(TuiEvent.ToastShow, { ...toast, duration: 10000 })),
282287
)
288+
return true
283289
} catch (err) {
284290
log.warn("could not show the workspace engine toast", { err: String(err) })
291+
return false
285292
}
286293
}
287294

‎packages/opencode/src/altimate/workspace/manage.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ export interface RefreshReport {
6262
/** True when the skill snapshot on disk changed. The caller owns the registry
6363
* invalidation this implies; see the note at the top of the file. */
6464
skillsChanged: boolean
65+
/** Skills the re-sync dropped, with the reason for each. A partial sync
66+
* otherwise reads as success with fewer skills than the workspace has. */
67+
skillsSkipped: SkillSync.SkippedSkill[]
6568
/** Absent when workspace memory is off, or when no session was supplied. */
6669
memory?: MemorySync.RefreshResult
6770
/** Set when there was no session to reload in place, so the overlay was
@@ -168,8 +171,12 @@ export async function refresh(directory: string, sessionID?: string): Promise<Re
168171
const errors: string[] = []
169172

170173
let skillsChanged = false
174+
let skillsSkipped: SkillSync.SkippedSkill[] = []
171175
try {
172-
skillsChanged = (await SkillSync.syncSkills(directory)).changed
176+
const result = await SkillSync.syncSkills(directory)
177+
skillsChanged = result.changed
178+
skillsSkipped = result.skipped
179+
if (result.error) errors.push(`skills: ${result.error}`)
173180
} catch (err) {
174181
// `syncSkills` documents that it never throws. Caught anyway: this is the
175182
// user asking for a repair, and the one thing it must not do is fail the turn.
@@ -197,7 +204,7 @@ export async function refresh(directory: string, sessionID?: string): Promise<Re
197204
}
198205
}
199206

200-
return { skillsChanged, memory, memoryInvalidated, errors }
207+
return { skillsChanged, skillsSkipped, memory, memoryInvalidated, errors }
201208
}
202209

203210
/** Push: re-send local memory the workspace never received.

‎packages/opencode/src/altimate/workspace/skill-sync.ts‎

Lines changed: 143 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import path from "path"
4343
import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag"
4444
import { Log } from "@/altimate/util/log"
4545
import { AltimateApi } from "@/altimate/api/client"
46-
import { resolveBindingOutcome, type CachedBinding } from "./state"
46+
import { readLocalBinding, resolveBindingOutcome, type CachedBinding } from "./state"
4747
import { altimateRequest, WorkspaceApiError } from "./api-client"
4848

4949
const log = Log.create({ service: "altimate-workspace-skill-sync" })
@@ -139,11 +139,104 @@ const STAGING_LEASE_MS = 30 * 60 * 1000
139139

140140
const STORE_KEY = Symbol.for("altimate.workspace.skill-sync.store")
141141

142+
export interface SkippedSkill {
143+
skill: string
144+
reason: string
145+
}
146+
147+
export interface SyncResult {
148+
changed: boolean
149+
/** Skills this run dropped, each with a short, user-facing reason. Surfaced
150+
* on the turn so a missing skill has an explanation instead of silence. */
151+
skipped: SkippedSkill[]
152+
/** Set when the whole sync could not proceed (unusable folder, unreadable
153+
* credentials, unreachable list, or a failed publish). Per skill is
154+
* `skipped`; this is the run. */
155+
error?: string
156+
}
157+
158+
/** One toast's worth of what a sync dropped, or null when nothing was.
159+
* Pure — no UI imports — so the wording is testable; the caller shows it. */
160+
export function describeSyncProblems(result: SyncResult): { title: string; message: string } | null {
161+
const n = result.skipped.length
162+
if (n === 0) return result.error ? { title: "Workspace skills not synced", message: result.error } : null
163+
const lines = result.skipped.slice(0, 3).map((s) => `${s.skill}: ${s.reason}`)
164+
if (n > 3) lines.push(`…and ${n - 3} more`)
165+
// A skipped bundle and a failed publish are different news: the second means
166+
// the snapshot as a whole did not change.
167+
if (result.error) lines.push(result.error)
168+
return { title: `${n} workspace skill${n === 1 ? "" : "s"} skipped`, message: lines.join("\n") }
169+
}
170+
171+
/** A fixed, user-facing reason for a skill that failed to sync. The raw error
172+
* can carry request URLs, server text or local paths — diagnostics for the
173+
* log, not for a toast. */
174+
export function skipReason(err: unknown): string {
175+
// A local write failure is the user's disk, not the server — say so rather
176+
// than pointing them at the download. Network errors have codes too, so only
177+
// the filesystem ones are mapped.
178+
const code = (err as NodeJS.ErrnoException | null)?.code
179+
if (code && LOCAL_WRITE_ERRORS.has(code)) return "it could not be saved on this device"
180+
const msg = err instanceof Error ? err.message : ""
181+
// Not "incomplete": a binary file comes back decoded with replacement
182+
// characters but its raw byte size, so a valid bundle mismatches too.
183+
if (msg.startsWith("size mismatch")) return "its file size could not be verified"
184+
if (msg.startsWith("would exceed the client snapshot limit")) return "it is too large for this client"
185+
if (msg.startsWith("unrecognised")) return "the server sent an unexpected response for it"
186+
return "it could not be downloaded"
187+
}
188+
189+
const LOCAL_WRITE_ERRORS = new Set(["ENOSPC", "EDQUOT", "EACCES", "EPERM", "EROFS"])
190+
191+
/** A skill id as it may be shown. Ids come from the workspace service and one
192+
* that failed `safePathComponent` is arbitrary text; control characters and
193+
* line separators would reach the toast and the serve JSON as-is. The raw id
194+
* stays in the log. */
195+
export function displayId(id: string): string {
196+
// eslint-disable-next-line no-control-regex
197+
const clean = id.replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g, "")
198+
if (!clean) return "(unnamed skill)"
199+
return clean.length > 64 ? `${clean.slice(0, 63)}…` : clean
200+
}
201+
202+
function problemText(problem: { title: string; message: string }): string {
203+
return `${problem.title}\n${problem.message}`
204+
}
205+
206+
/** Whether a sync problem is new for this directory. The per-turn sync retries
207+
* a failed run every message, so without this an offline user or a
208+
* permanently bad bundle would be told the same thing on every turn — and
209+
* twice when concurrent turns join one run. Any clean `syncSkills` run clears
210+
* it (see `settled`), so the problem is announced again if it comes back. */
211+
export function shouldAnnounce(directory: string, problem: { title: string; message: string } | null): boolean {
212+
const key = path.resolve(directory)
213+
if (!problem) {
214+
store.announced.delete(key)
215+
return false
216+
}
217+
const text = problemText(problem)
218+
if (store.announced.get(key) === text) return false
219+
store.announced.set(key, text)
220+
return true
221+
}
222+
223+
/** Undo `shouldAnnounce` when the warning could not be shown, so a later turn
224+
* tries again. Only while it still holds THIS problem: a concurrent turn may
225+
* have latched a newer one, and that must survive. */
226+
export function forgetAnnouncement(directory: string, problem: { title: string; message: string }): void {
227+
const key = path.resolve(directory)
228+
if (store.announced.get(key) === problemText(problem)) store.announced.delete(key)
229+
}
230+
142231
interface SyncStore {
143-
inFlight: Map<string, Promise<{ changed: boolean }>>
232+
inFlight: Map<string, Promise<SyncResult>>
144233
lastSyncedAt: Map<string, number>
145234
registryAppliedAt: Map<string, number>
146235
syncedFor: Map<string, string>
236+
/** The sync problem last announced per directory. Here rather than a module
237+
* Map for the same reason as the rest of the store: a second module record
238+
* would otherwise keep its own copy, and the dedup would fork. */
239+
announced: Map<string, string>
147240
}
148241

149242
const globals = globalThis as unknown as Record<symbol, SyncStore | undefined>
@@ -152,6 +245,7 @@ const store: SyncStore = (globals[STORE_KEY] ??= {
152245
lastSyncedAt: new Map(),
153246
registryAppliedAt: new Map(),
154247
syncedFor: new Map(),
248+
announced: new Map(),
155249
})
156250

157251
/** In-flight sync per canonical project directory, so a bind and a session
@@ -677,6 +771,14 @@ async function hasManagedSnapshot(directory: string): Promise<boolean> {
677771
}
678772
}
679773

774+
/** Whether local state says this project has a workspace: a snapshot a sync
775+
* once published, or a cached binding. Decides if a failed binding lookup is
776+
* worth telling the user about. */
777+
async function hasBindingEvidence(directory: string): Promise<boolean> {
778+
if (await hasManagedSnapshot(directory)) return true
779+
return (await readLocalBinding(directory).catch(() => null)) !== null
780+
}
781+
680782
/** Take the snapshot out of service when this client is no longer entitled to
681783
* serve it — the account was disconnected, or the feature was switched off.
682784
*
@@ -710,7 +812,7 @@ async function removeManaged(directory: string): Promise<void> {
710812
* Never throws: skills must not be able to block a bind or a turn. Every
711813
* failure path leaves whatever is already on disk in place, except the
712814
* deliberate purge described below. */
713-
export async function syncSkills(directory: string): Promise<{ changed: boolean }> {
815+
export async function syncSkills(directory: string): Promise<SyncResult> {
714816
const canon = path.resolve(directory)
715817
// Joined BEFORE the flag is read, so the opt-out purge is serialised against
716818
// a sync too. Both paths write the same tree; with the purge outside this
@@ -721,7 +823,7 @@ export async function syncSkills(directory: string): Promise<{ changed: boolean
721823
if (existing) {
722824
// Report the joined run's real outcome. Returning a hard-coded `false` is a
723825
// false answer waiting for the next caller to trust it.
724-
return await existing.catch(() => ({ changed: false }))
826+
return await existing.catch(() => ({ changed: false, skipped: [] }))
725827
}
726828
if (!isEnabled()) {
727829
// Opting out has to actually take effect: a snapshot left behind keeps
@@ -732,7 +834,7 @@ export async function syncSkills(directory: string): Promise<{ changed: boolean
732834
const dropped = (await pathsAreReal(canon).catch(() => false))
733835
? await deactivate(canon, "the workspace feature is off").catch(() => false)
734836
: false
735-
return { changed: dropped }
837+
return { changed: dropped, skipped: [] }
736838
})()
737839
inFlight.set(canon, purge)
738840
try {
@@ -742,6 +844,9 @@ export async function syncSkills(directory: string): Promise<{ changed: boolean
742844
}
743845
}
744846
let changed = false
847+
// Named apart from the loop's `skipped` counter below, which it would shadow.
848+
const skippedSkills: SkippedSkill[] = []
849+
let syncError: string | undefined
745850
/** The manifest an unchanged run validated, so its marker describes that
746851
* snapshot rather than whatever is live when the stamp is written. */
747852
let validated: Manifest | null = null
@@ -772,6 +877,7 @@ export async function syncSkills(directory: string): Promise<{ changed: boolean
772877
path: managedRoot(canon),
773878
})
774879
failed = true
880+
syncError = "the workspace skill folder is not a real directory"
775881
return
776882
}
777883

@@ -806,6 +912,15 @@ export async function syncSkills(directory: string): Promise<{ changed: boolean
806912
if (outcome.status === "unbound") {
807913
if (await deactivate(canon, "this project is no longer bound to a workspace")) changed = true
808914
}
915+
// Unbound is a state, not a failure, and says nothing. Unknown is a failed
916+
// lookup — but only worth a warning when this project is known to have a
917+
// workspace. With a local binding row, offline resolves to a stale "bound"
918+
// and fails later at the list; without one, the server is always asked,
919+
// so offline lands here for EVERY opted-in project, including ones never
920+
// linked. A snapshot on disk (a server-side binding that synced before)
921+
// or a local row is that evidence; without either, stay quiet.
922+
if (outcome.status === "unknown" && (await hasBindingEvidence(canon)))
923+
syncError = "could not confirm this project's workspace (offline, or no access to it)"
809924
return
810925
}
811926
const binding = outcome.binding
@@ -818,6 +933,7 @@ export async function syncSkills(directory: string): Promise<{ changed: boolean
818933
"refusing to manage the workspace skill directory: it has contents this client did not write",
819934
{ path: managedRoot(canon) },
820935
)
936+
syncError = "the workspace skill folder has files this app did not create, so it was left alone"
821937
return
822938
}
823939
await sweepStaging(canon)
@@ -829,6 +945,7 @@ export async function syncSkills(directory: string): Promise<{ changed: boolean
829945
log.warn("could not read altimate credentials; keeping the existing snapshot", {
830946
err: String(err),
831947
})
948+
syncError = "could not read your Altimate credentials"
832949
return
833950
}
834951

@@ -854,7 +971,11 @@ export async function syncSkills(directory: string): Promise<{ changed: boolean
854971
}
855972

856973
const remote = await listAll(binding)
857-
if (!remote) return // error, not empty — keep what is on disk
974+
if (!remote) {
975+
// error, not empty — keep what is on disk
976+
syncError = "could not fetch the workspace's skill list"
977+
return
978+
}
858979
sawRemote = true
859980
syncedFor.set(canon, accountKeyOf(creds.altimateInstanceName, creds.altimateUrl))
860981

@@ -920,6 +1041,7 @@ export async function syncSkills(directory: string): Promise<{ changed: boolean
9201041
skipped += 1
9211042
failed = true
9221043
log.warn("skipping a workspace skill with an unusable id", { skill: summary.publicId })
1044+
skippedSkills.push({ skill: displayId(summary.publicId), reason: "its id is not usable as a folder name" })
9231045
continue
9241046
}
9251047
try {
@@ -1007,6 +1129,8 @@ export async function syncSkills(directory: string): Promise<{ changed: boolean
10071129
carriedPrevious: carried,
10081130
err: String(err),
10091131
})
1132+
const why = skipReason(err)
1133+
skippedSkills.push({ skill: displayId(summary.publicId), reason: carried ? `${why} (kept the previous copy)` : why })
10101134
}
10111135
}
10121136
if (remote.length > 0 && Object.keys(next.skills).length === 0) {
@@ -1068,6 +1192,13 @@ export async function syncSkills(directory: string): Promise<{ changed: boolean
10681192
failed = true
10691193
await fs.rm(staging, { recursive: true, force: true }).catch(() => {})
10701194
log.warn("workspace skill sync failed; kept the existing snapshot", { err: String(err) })
1195+
// Per-skill reasons are already in `skippedSkills`; this says the snapshot
1196+
// as a whole did not change. Only claim the old skills survived when they
1197+
// did: a rebind removed them above, and a first sync had none.
1198+
syncError ??=
1199+
foreign || !manifest
1200+
? "could not install the workspace skills"
1201+
: "could not update the workspace skills; kept the previous ones"
10711202
}
10721203
})()
10731204
// Published to `inFlight` so a joining caller awaits the SAME settled result
@@ -1079,6 +1210,7 @@ export async function syncSkills(directory: string): Promise<{ changed: boolean
10791210
} catch (err) {
10801211
ok = false
10811212
log.warn("workspace skill sync errored", { err: String(err) })
1213+
syncError ??= "workspace skill sync errored"
10821214
}
10831215
// Only a clean run earns the poll interval. `failed` is set by the inner
10841216
// catch, which swallows so that skills can never block a turn.
@@ -1098,7 +1230,11 @@ export async function syncSkills(directory: string): Promise<{ changed: boolean
10981230
if (!changed && validated)
10991231
await fs.writeFile(path.join(managedRoot(canon), SYNCED_MARKER), markerFor(validated, now)).catch(() => {})
11001232
}
1101-
return { changed }
1233+
// Cleared here, not by callers, so a clean Refresh or IDE refresh resets it
1234+
// too — otherwise a problem it fixed would stay latched, and its return
1235+
// would never be announced.
1236+
if (skippedSkills.length === 0 && !syncError) store.announced.delete(canon)
1237+
return { changed, skipped: skippedSkills, error: syncError }
11021238
})()
11031239
inFlight.set(canon, settled)
11041240
try {

‎packages/opencode/src/plugin/tui/altimate/workspace.tsx‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { existsSync } from "node:fs"
2828
import open from "open"
2929
// altimate_change start - the /workspace action menu
3030
import * as Manage from "@/altimate/workspace/manage"
31+
import { describeSyncProblems } from "@/altimate/workspace/skill-sync"
3132
import { inertWorkspaceName } from "@/altimate/workspace/workspace-name"
3233
// altimate_change end
3334
import { createSignal, onCleanup, onMount } from "solid-js"
@@ -1856,18 +1857,26 @@ async function runWorkspaceManage(api: TuiPluginApi, directory: string): Promise
18561857
if (option.value === "refresh") {
18571858
Manage.refresh(directory)
18581859
.then((result) => {
1860+
// Named, with reasons: a partial pull otherwise reads as success
1861+
// with fewer skills than the workspace has. Built by the same
1862+
// helper as the per-turn warning, so both cap the list alike;
1863+
// the IDE route still gets every entry in `skillsSkipped`.
1864+
const skipped = describeSyncProblems({ changed: result.skillsChanged, skipped: result.skillsSkipped })
1865+
const problems = skipped
1866+
? [...result.errors, `${skipped.title}: ${skipped.message.split("\n").join("; ")}`]
1867+
: result.errors
18591868
const said = [
18601869
result.skillsChanged ? "skills updated" : "skills already current",
18611870
result.memoryInvalidated ? "memory reloads on your next message" : null,
18621871
].filter(Boolean)
18631872
api.ui.toast({
1864-
variant: result.errors.length > 0 ? "warning" : "success",
1873+
variant: problems.length > 0 ? "warning" : "success",
18651874
// The problems line still names what DID land: the halves are
18661875
// independent, and a failed skill pull does not undo the memory
18671876
// invalidation that happened beside it.
18681877
message:
1869-
result.errors.length > 0
1870-
? `Refreshed with problems — ${result.errors.join("; ")}${
1878+
problems.length > 0
1879+
? `Refreshed with problems — ${problems.join("; ")}${
18711880
result.memoryInvalidated ? "; memory reloads on your next message" : ""
18721881
}`
18731882
: `Refreshed: ${said.join(", ")}.`,

0 commit comments

Comments
 (0)