Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 @@ -35,7 +35,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 @@ -136,6 +147,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 @@ -247,7 +260,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 @@ -261,6 +274,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 @@ -303,7 +317,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 @@ -317,6 +338,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 @@ -329,6 +351,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 @@ -369,7 +419,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 @@ -505,7 +557,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 @@ -517,7 +571,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 @@ -540,6 +595,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 @@ -575,6 +631,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 @@ -598,7 +655,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 @@ -640,6 +703,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 @@ -672,6 +736,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