Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
fd13c9b
feat: read the engine's unfulfilled-keys report instead of diffing de…
Sep 12, 2026
b92dab6
test: end-to-end through the MCP service against a real engine
Sep 12, 2026
93c879a
chore: wrap the catalog _meta hunks in altimate_change markers
Sep 12, 2026
8253154
fix: accept numeric integration ids in the engine report
Sep 12, 2026
ceb212e
feat(workspace): post a sanitized session attach report when the outc…
Sep 12, 2026
e2e7e14
Merge remote-tracking branch 'origin/feat/attach-report-post' into fe…
Sep 15, 2026
39ba0e5
feat(workspace): one-line attach toast, and a /workspace Status view …
Sep 14, 2026
7f6110e
fix(workspace): key sub-rows the dialog will show, and a title that fits
Sep 14, 2026
a34daca
feat(workspace): say so in the boot box when the CLI runs in workspac…
Sep 14, 2026
cf55a2e
Merge remote-tracking branch 'origin/feat/workspace-sidebar-status' i…
Sep 15, 2026
45a8d02
fix(workspace): commit the tools and their report together, and say w…
Sep 15, 2026
2908861
Merge remote-tracking branch 'origin/feat/unfulfilled-keys-meta' into…
Sep 15, 2026
587d82d
Merge remote-tracking branch 'origin/feat/attach-report-post' into fe…
Sep 15, 2026
820147a
fix(workspace): a listing carries its own _meta, and the headline nev…
Sep 15, 2026
282e762
Merge remote-tracking branch 'origin/feat/unfulfilled-keys-meta' into…
Sep 15, 2026
5d50c75
Merge remote-tracking branch 'origin/feat/attach-report-post' into fe…
Sep 15, 2026
9452109
fix(workspace): the headline counts catalog entries, not declarations
Sep 15, 2026
d2298f1
Merge remote-tracking branch 'origin/feat/unfulfilled-keys-meta' into…
Sep 15, 2026
88ae2f7
Merge remote-tracking branch 'origin/feat/attach-report-post' into fe…
Sep 15, 2026
413fadc
fix(workspace): a catalog entry is counted once across the ordinary a…
Sep 15, 2026
be446a7
Merge remote-tracking branch 'origin/feat/unfulfilled-keys-meta' into…
Sep 15, 2026
591d0f0
Merge remote-tracking branch 'origin/feat/attach-report-post' into fe…
Sep 15, 2026
b0343bb
chore(workspace): wrap the two remaining hunks in altimate_change mar…
Sep 15, 2026
1a2b614
Merge remote-tracking branch 'origin/feat/unfulfilled-keys-meta' into…
Sep 15, 2026
6bf984b
Merge remote-tracking branch 'origin/feat/attach-report-post' into fe…
Sep 15, 2026
ef0b8ed
chore(workspace): markers around the listing hunks the guard still sa…
Sep 15, 2026
3684c27
Merge remote-tracking branch 'origin/feat/unfulfilled-keys-meta' into…
Sep 15, 2026
dc155e2
Merge remote-tracking branch 'origin/feat/attach-report-post' into fe…
Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/opencode/src/altimate/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,12 @@ export namespace AltimateApi {
await request(creds, "DELETE", `/datamates/${id}`)
}

/** Post this session's attach report for a workspace (session attach report store). */
export async function postAttachReport(datamateId: string, report: unknown): Promise<void> {
const creds = await getCredentials()
await request(creds, "POST", `/datamates/${datamateId}/attach-reports`, report)
}

export async function listIntegrations() {
const creds = await getCredentials()
const data = await request(creds, "GET", "/datamate_integrations/")
Expand Down
146 changes: 146 additions & 0 deletions packages/opencode/src/altimate/workspace/attach-report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// altimate_change - new file
//
// The session attach report: what this session actually received from its
// workspace, posted to the backend when the outcome settles so the workspace
// page can show it. Pure shaping here; the one I/O function at the bottom
// goes through the API client and never throws.
import { AltimateApi } from "@/altimate/api/client"
import { sanitize } from "@/mcp/catalog"
import { log, syncInternals } from "./engine-seams"
import type { Declared, Outcome, Unfulfilled } from "./engine-types"

/** What the backend accepts as an unserved key's detail: a code and, for
* spawn failures, the basename of the command. Never the engine's raw error
* text, which can name paths and hosts. */
export type AttachReportDetail = { code: string; command?: string }

export type AttachReportUnfulfilled = {
key: string
integration_id: string
reason: string
detail?: AttachReportDetail
}

export type AttachReportOutcome = "attached" | "engine-missing" | "engine-too-old" | "connect-failed"

export type AttachReport = {
binding_key: string
outcome: AttachReportOutcome
cli_version: string
engine_version: string | null
bridge_connected: boolean
declared_keys: string[]
delivered_keys: string[]
unfulfilled: AttachReportUnfulfilled[]
reported_at: string
}

/** The identity the server binding row already carries: the git remote when
* the project has one, else its absolute path. Nothing new about the machine
* leaves it. */
export function bindingKey(binding: { repoRemote: string | null; projectPath: string | null }): string | null {
return binding.repoRemote || binding.projectPath || null
}

const CODES: Array<[RegExp, string]> = [
[/\bENOENT\b/, "ENOENT"],
[/\bEACCES\b|\bEPERM\b/, "EACCES"],
[/\bETIMEDOUT\b|timed? ?out/i, "ETIMEDOUT"],
[/\bECONNREFUSED\b/, "ECONNREFUSED"],
[/invalid url/i, "invalid-url"],
]

/** A code for an error string, never the string. */
export function errorCode(text: string): string {
return CODES.find(([re]) => re.test(text))?.[1] ?? "other"
}

/** Reduce an engine detail to what may leave the machine. For a spawn
* failure the spawned command's basename is kept (`spawn /Users/x/bin/docker
* ENOENT` → `docker`); the directory, and everything else, is dropped. */
export function sanitizeDetail(detail: string | undefined, reason: string): AttachReportDetail | undefined {
if (!detail) return undefined
const code = errorCode(detail)
if (reason !== "spawn-failed") return { code }
const match = /\bspawn\s+(\S+)/.exec(detail)
const command = match ? match[1].split(/[\\/]/).pop() : undefined
return command ? { code, command } : { code }
}

function sanitizeUnfulfilled(entries: Unfulfilled[]): AttachReportUnfulfilled[] {
return entries.map((u) => {
const detail = sanitizeDetail(u.detail, u.reason)
return { key: u.key, integration_id: u.integrationId, reason: u.reason, ...(detail ? { detail } : {}) }
})
}

export type AttachReportInput = {
outcome: Outcome
bindingKey: string
cliVersion: string
/** The probed engine version, when the engine ran at all. */
engineVersion: string | null
declared: Declared | null
/** Keys the engine served under the workspace key (attached only). */
present?: Set<string>
bridgeConnected: boolean
reportedAt: string
}

/** The report for a settled outcome, or null for outcomes that are not about
* the engine at all (disabled, unbound). */
export function buildAttachReport(input: AttachReportInput): AttachReport | null {
const { outcome, declared } = input
const declaredKeys = declared ? [...declared.keys, ...declared.extensionKeys] : []
const base = {
binding_key: input.bindingKey,
cli_version: input.cliVersion,
bridge_connected: input.bridgeConnected,
declared_keys: declaredKeys,
delivered_keys: [] as string[],
unfulfilled: [] as AttachReportUnfulfilled[],
reported_at: input.reportedAt,
}
switch (outcome.kind) {
case "attached": {
const present = input.present ?? new Set<string>()
// Never a key the engine reports unfulfilled: two raw keys can sanitise to one catalog name.
const reported = new Set((outcome.unfulfilled ?? []).map((u) => u.key))
const delivered = declared ? declaredKeys.filter((k) => present.has(sanitize(k)) && !reported.has(k)) : [...present]
return {
...base,
outcome: "attached",
engine_version: input.engineVersion,
delivered_keys: delivered,
unfulfilled: sanitizeUnfulfilled(outcome.unfulfilled ?? []),
}
}
case "engine-missing":
return { ...base, outcome: "engine-missing", engine_version: null }
case "engine-too-old":
return { ...base, outcome: "engine-too-old", engine_version: outcome.found }
case "connect-failed":
return { ...base, outcome: "connect-failed", engine_version: input.engineVersion }
default:
return null
}
}

/** Everything that would make the backend row different — so an identical
* re-attach does not post again, and a changed reason or version does. */
export function attachReportSignature(report: AttachReport): string {
const { reported_at: _at, ...rest } = report
return JSON.stringify(rest)
}

/** Post a report; fire-and-forget by contract. A failure is logged once at
* debug and never reaches the user or the turn. */
export async function postAttachReport(datamateId: string, report: AttachReport): Promise<void> {
try {
if (syncInternals.reportAttach) return await syncInternals.reportAttach(datamateId, report)
if (!(await AltimateApi.isConfigured())) return
await AltimateApi.postAttachReport(datamateId, report)
} catch (err) {
log.debug("attach report not posted", { datamateId, err: String(err) })
}
}
83 changes: 83 additions & 0 deletions packages/opencode/src/altimate/workspace/attach-snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// altimate_change - new file
//
// What the last attach in a directory produced, on disk. The overlay settles
// an attach inside the server process; the TUI plugin (the `/workspace`
// menu, the sidebar tile) runs in another, so the memory the overlay keeps is
// invisible to it — the same reason the binding cache lives in a file. One
// small JSON under the state directory, keyed by project directory, latest
// attach per directory, bounded.
import path from "node:path"
import { chmodSync, existsSync, readFileSync } from "node:fs"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Log } from "@/altimate/util/log"
import type { Declared, Unfulfilled } from "./engine-types"

const log = Log.create({ service: "altimate-workspace-attach-snapshot" })

export interface AttachSnapshot {
workspace: { id: string; name: string }
engineVersion: string | null
/** The allowlist the workspace declared, split like `Declared`; null when
* the lookup failed and the engine was taken at its word. */
declared: Declared | null
/** Every key the engine served under the workspace key, allowlisted or not. */
present: string[]
/** The engine's full report; undefined when it sent none. */
unfulfilled: Unfulfilled[] | undefined
/** Extension-declared keys a live IDE bridge served. */
extServed: number
at: number
}

interface SnapshotFile {
version: 1
snapshots: Record<string, AttachSnapshot>
}

/** Enough for a machine's worth of projects; the oldest go first. */
const MAX_SNAPSHOTS = 64

export function snapshotPath(): string {
return path.join(Global.Path.state, "altimate-attach-snapshots.json")
}

function readFile(): SnapshotFile | null {
const p = snapshotPath()
if (!existsSync(p)) return null
try {
const raw = JSON.parse(readFileSync(p, "utf8")) as Partial<SnapshotFile> | null
if (!raw || raw.version !== 1 || typeof raw.snapshots !== "object" || raw.snapshots === null) return null
return raw as SnapshotFile
} catch (err) {
log.warn("attach snapshot file is corrupt, discarding", { code: (err as NodeJS.ErrnoException)?.code })
return null
}
}

/** Best-effort, like every write to the state directory: a read-only home
* must not turn a successful attach into a failure. */
export function writeAttachSnapshot(directory: string, snapshot: AttachSnapshot): void {
try {
const file = readFile() ?? { version: 1, snapshots: {} }
file.snapshots[path.resolve(directory)] = snapshot
const entries = Object.entries(file.snapshots)
if (entries.length > MAX_SNAPSHOTS) {
entries.sort((a, b) => a[1].at - b[1].at)
file.snapshots = Object.fromEntries(entries.slice(entries.length - MAX_SNAPSHOTS))
}
const p = snapshotPath()
Filesystem.writeJsonAtomic(p, file)
try {
chmodSync(p, 0o600)
} catch {
// Umask permissions until the next write; the file holds tool keys, not credentials.
}
} catch (err) {
log.warn("could not write the attach snapshot", { err: String(err) })
}
}

export function readAttachSnapshot(directory: string): AttachSnapshot | undefined {
return readFile()?.snapshots[path.resolve(directory)]
}
Loading
Loading