Skip to content
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
143 changes: 143 additions & 0 deletions packages/opencode/src/altimate/workspace/attach-report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// 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 { 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>()
const delivered = declared ? declaredKeys.filter((k) => present.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) })
}
}
84 changes: 77 additions & 7 deletions packages/opencode/src/altimate/workspace/engine-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,18 @@ import {
syncInternals,
type ScopedBinding,
} from "./engine-seams"
import { declaredBounded, fingerprint, notify, printLine, resolveBinding, versionOf, which } from "./engine-probes"
import {
declaredBounded,
fingerprint,
liveBridge,
notify,
printLine,
resolveBinding,
versionOf,
which,
} from "./engine-probes"
import { attachReportSignature, bindingKey, buildAttachReport, postAttachReport } from "./attach-report"
import { Installation } from "@/installation"
import { OFFER_RECHECK_MS, OFFER_SKIP_TTL_MS, installCommand, offerOrNotify, type EngineOffer } from "./engine-offer"
import {
ENGINE_BINARY,
Expand Down Expand Up @@ -137,6 +148,8 @@ type Overlay = {
/** The derived entry, or null when the engine is unusable. */
entry: LocalMcpConfig | null
refusal: Extract<Outcome, { kind: "engine-missing" | "engine-too-old" }> | null
/** The probed engine version when the engine ran; null when it is missing. */
version: string | null
}

/** Per-directory state. Config and MCP state are per project instance, and one
Expand Down Expand Up @@ -248,7 +261,7 @@ export async function overlay(
const entry = engineEntry(workspace.id)
config.mcp ??= {}
config.mcp[DATAMATE_KEY] = entry
state.current = { directory, workspace, entry, refusal: null }
state.current = { directory, workspace, entry, refusal: null, version: probe.version }
log.info("workspace engine overlay applied", { workspaceId: workspace.id, version: probe.version })
return
}
Expand All @@ -262,6 +275,7 @@ export async function overlay(
workspace,
entry: null,
refusal: probe.kind === "missing" ? { kind: "engine-missing" } : { kind: "engine-too-old", found: probe.found },
version: probe.kind === "missing" ? null : probe.found,
}
log.info("workspace engine overlay refused", { workspaceId: workspace.id, reason: probe.kind })
} catch (err) {
Expand Down Expand Up @@ -304,7 +318,14 @@ export async function managedWorkspaceLoaded(

/** `retried`: this session already spent its one re-add on a failed handshake.
* Per session, so "start a new session to try again" is true. */
type SessionRecord = { outcome: Outcome; announced?: string; announcedAt?: number; retried?: boolean }
type SessionRecord = {
outcome: Outcome
announced?: string
announcedAt?: number
retried?: boolean
/** Signature of the last attach report posted for this session. */
reported?: string
}
const sessions = new Map<string, SessionRecord>()
const declaredCache = new Map<string, { value: Declared | null; at: number }>()
/** Verdict signatures a headless process has already printed to stderr. */
Expand All @@ -318,6 +339,7 @@ function record(sessionID: string, outcome: Outcome): SessionRecord {
announced: previous?.announced,
announcedAt: previous?.announcedAt,
retried: previous?.retried,
reported: previous?.reported,
}
sessions.set(sessionID, next)
while (sessions.size > MAX_TRACKED_SESSIONS) {
Expand All @@ -330,6 +352,34 @@ function record(sessionID: string, outcome: Outcome): SessionRecord {

/** The outcome a session settled at its last turn boundary. A pure read;
* `undefined` before the first `beforeTurn` for that session. */
/** Post the settled outcome as this session's attach report, once per
* distinct report. Never awaited by the turn: the post is fire-and-forget and
* swallows its own failures. */
function reportOutcome(
sessionID: string,
binding: ScopedBinding,
extras: { engineVersion: string | null; declared: Declared | null; present?: Set<string>; bridgeConnected: boolean },
): void {
const rec = sessions.get(sessionID)
const key = bindingKey(binding)
if (!rec || !key) return
const report = buildAttachReport({
outcome: rec.outcome,
bindingKey: key,
cliVersion: Installation.VERSION,
engineVersion: extras.engineVersion,
declared: extras.declared,
present: extras.present,
bridgeConnected: extras.bridgeConnected,
reportedAt: new Date(now()).toISOString(),
})
if (!report) return
const signature = attachReportSignature(report)
if (rec.reported === signature) return
rec.reported = signature
void postAttachReport(String(binding.datamateId), report)
}

export function settledOutcome(sessionID: string): Outcome | undefined {
return sessions.get(sessionID)?.outcome
}
Expand Down Expand Up @@ -372,7 +422,9 @@ async function refuseUnreadableLink(sessionID: string, state: DirectoryState, er
record(sessionID, outcome)
const kept = state.applied?.entry ? "the running engine is kept and " : ""
await announceRefusal(sessionID, outcome, {
title: state.applied ? `Workspace "${state.applied.workspace.name}": link could not be read` : "Workspace link could not be read",
title: state.applied
? `Workspace "${state.applied.workspace.name}": link could not be read`
: "Workspace link could not be read",
message: `${outcome.error} (${error}); ${kept}it is read again next turn.`,
variant: "warning",
})
Expand Down Expand Up @@ -508,7 +560,9 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS
// probe memo bounds how often that is asked).
let reload = state.current
? state.current.workspace.key !== boundKey
: state.linkUnreadable !== undefined || state.failedAt === undefined || now() - state.failedAt >= FAILED_PROBE_TTL_MS
: state.linkUnreadable !== undefined ||
state.failedAt === undefined ||
now() - state.failedAt >= FAILED_PROBE_TTL_MS
if (!reload && state.current && !state.current.entry) {
const probe = await probeEngine()
reload = probe.kind === "ok"
Expand All @@ -520,7 +574,8 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS
}
// The boundary read the binding but the reload could not: the link is
// flapping, and the reload's verdict is the one the config now reflects.
if (!state.current && state.linkUnreadable !== undefined) return refuseUnreadableLink(sessionID, state, state.linkUnreadable)
if (!state.current && state.linkUnreadable !== undefined)
return refuseUnreadableLink(sessionID, state, state.linkUnreadable)

// A transient overlay failure (its retry is throttled above) keeps what was
// last applied for this same workspace: a running engine is not released
Expand All @@ -543,6 +598,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS
// Say so, once, rather than settling a bound directory as unbound in silence.
const outcome: Outcome = { kind: "connect-failed", error: "the workspace engine could not be checked" }
record(sessionID, outcome)
reportOutcome(sessionID, binding, { engineVersion: null, declared: null, bridgeConnected: false })
await announceRefusal(sessionID, outcome, {
title: `Workspace "${binding.datamateName}": engine unavailable`,
message: `${outcome.error}; it is checked again shortly.`,
Expand Down Expand Up @@ -578,6 +634,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS
const outcome: Outcome =
count === undefined ? { kind: "engine-missing" } : { kind: "engine-missing", declared: count }
record(sessionID, outcome)
reportOutcome(sessionID, binding, { engineVersion: null, declared, bridgeConnected: false })
const what =
count === undefined
? `Workspace "${workspace.name}" has integration tools that run on the local engine, which is not installed.`
Expand All @@ -601,7 +658,13 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS
return
}
record(sessionID, refusal)
const declared = (await declaredFor(workspace))?.keys.length
const declaredAll = await declaredFor(workspace)
const declared = declaredAll?.keys.length
reportOutcome(sessionID, binding, {
engineVersion: overlayNow.version,
declared: declaredAll,
bridgeConnected: false,
})
await announceRefusal(
sessionID,
refusal,
Expand Down Expand Up @@ -643,6 +706,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS
error: status?.error ?? `engine status: ${status?.status ?? "unknown"}`,
}
record(sessionID, outcome)
reportOutcome(sessionID, binding, { engineVersion: overlayNow.version, declared, bridgeConnected: false })
await announceRefusal(sessionID, outcome, {
title: `Workspace "${workspace.name}": engine failed to start`,
message: `${outcome.error}. Start a new session to try again.`,
Expand Down Expand Up @@ -701,6 +765,12 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS
...(unfulfilled === undefined ? {} : { unfulfilled }),
}
const rec = record(sessionID, outcome)
reportOutcome(sessionID, binding, {
engineVersion: overlayNow.version,
declared,
present,
bridgeConnected: extServed > 0 || liveBridge(directory),
})
// Keyed on the workspace too: a re-link with an identical inventory is still
// a new verdict the user should hear.
// extServed is part of what the user hears, so it is part of the signature:
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/src/altimate/workspace/engine-seams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Instance } from "@/project/instance"
import { Log } from "@/altimate/util/log"
import type { CachedBinding } from "./state"
import type { Declared, LocalMcpConfig, McpEntry, McpStatus, Toast } from "./engine-types"
import type { AttachReport } from "./attach-report"
import type { EngineOffer, InstallResult } from "./engine-offer"

export const log = Log.create({ service: "workspace-engine" })
Expand All @@ -19,7 +20,10 @@ export type ScopedBinding = CachedBinding & { scope?: string }
/** What a binding read established. `failed` is not `unbound`: the link may
* well exist, it could not be read, and nothing may be handed the key on the
* strength of that. */
export type BindingRead = { kind: "bound"; binding: ScopedBinding } | { kind: "unbound" } | { kind: "failed"; error: string }
export type BindingRead =
| { kind: "bound"; binding: ScopedBinding }
| { kind: "unbound" }
| { kind: "failed"; error: string }

export const syncInternals: {
resolveBinding?: (directory: string) => Promise<ScopedBinding | null>
Expand All @@ -29,6 +33,8 @@ export const syncInternals: {
declared?: (workspaceId: string) => Promise<Declared | null>
liveBridge?: (cwd: string) => boolean
notify?: (toast: Toast) => Promise<void>
/** Attach-report sink (see attach-report.ts); production posts through the API client. */
reportAttach?: (datamateId: string, report: AttachReport) => Promise<void>
printLine?: (line: string) => void
/** Install-offer seams (see engine-offer.ts). */
offer?: (offer: EngineOffer) => boolean
Expand Down
Loading
Loading