diff --git a/.changeset/assistant-server-lifecycle.md b/.changeset/assistant-server-lifecycle.md new file mode 100644 index 000000000..01c8f4431 --- /dev/null +++ b/.changeset/assistant-server-lifecycle.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Connect Assistant history, Resume and Continue to the shared server context owner and hide unfinished continuation children. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index a81748517..39befffd1 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -148,3 +148,9 @@ Continue explicitly uses the record currently displayed in history review. Its opaque request tuple survives reloads and uncertain responses; tabs serialize creation before POST. A verified child opens Assistant paused with recorded provenance. Account, navigation and newer bus state fence result adoption. + +The assembled server shares one context delivery instance across Send, recovery, +Resume and Continue. Attach includes verified public continuation provenance and +rechecks its lease after receipt reads. Pending children stay out of history and +cannot attach, including after restart; only prepared children publish a session +event. Runtime activation remains an explicit capability from its existing owner. diff --git a/packages/harness/src/core/assistant-history.ts b/packages/harness/src/core/assistant-history.ts index 0882ccc2a..90b5909d7 100644 --- a/packages/harness/src/core/assistant-history.ts +++ b/packages/harness/src/core/assistant-history.ts @@ -16,6 +16,8 @@ interface Options { authorize: (id: string) => Promise; records: Pick; lifecycle: Pick; + /** Pending continuation children are not discoverable until preparation commits. */ + isVisible?: (id: string) => Promise; } /** Metadata-only discovery. Native availability is checked separately for a selected entry. */ @@ -32,6 +34,8 @@ export class AssistantHistory { } | null> { const session = this.options.sessions.get(id); if (!session) return null; + if (this.options.isVisible && !(await this.options.isVisible(id))) + return null; const project = JSON.stringify([session.harness, session.agentMapIdentity]); const binding = await this.options.authorize(id); if (!binding) return null; @@ -65,6 +69,8 @@ export class AssistantHistory { ) throw new OpenCodeAccessError("Assistant binding changed"); const current = this.options.sessions.get(id); + if (this.options.isVisible && !(await this.options.isVisible(id))) + return null; if ( !current || current.cwd !== session.cwd || @@ -139,6 +145,11 @@ export class AssistantHistory { // Revalidate the complete result after IO for the other entries. No old // authority's titles or existence may escape on a mid-list account change. for (const { entry, binding: previous } of entries) { + if ( + this.options.isVisible && + !(await this.options.isVisible(entry.harnessSessionId)) + ) + throw new OpenCodeAccessError("Assistant preparation changed"); const binding = await this.options.authorize(entry.harnessSessionId); if (!binding || JSON.stringify(binding) !== JSON.stringify(previous)) throw new OpenCodeAccessError("Assistant access changed"); diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 33b7243f3..b639eff5d 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -201,7 +201,7 @@ import { AssistantLifecycleCoordinator } from "../core/assistant-lifecycle.js"; import { AssistantEndCoordinator } from "../core/assistant-end.js"; import { createAssistantStateProjection } from "../core/assistant-state-projection.js"; import { OpenCodeAssociations } from "../core/opencode-association.js"; -import { OpenCodeHost, type HostedOpenCode } from "../core/opencode-host.js"; +import { OpenCodeHost, OpenCodeAccessError, type HostedOpenCode } from "../core/opencode-host.js"; import { AssistantRecordStore } from "../core/assistant-record-store.js"; import { AssistantRecordCapture } from "../core/assistant-record-capture.js"; import { assistantHistoryAccess } from "../core/assistant-history-access.js"; @@ -209,7 +209,13 @@ import { AssistantHistory } from "../core/assistant-history.js"; import { OpenCodeFinalResponse } from "../core/opencode-final-response.js"; import { createAssistantRecordsRouter } from "./assistant-records.js"; import { OpenCodeObserver } from "../core/opencode-observer.js"; -import { createAssistantContextResolver } from "./studio-assistant.js"; +import { createAssistantContextRuntime, type ActivatedAssistantContextRuntime } from "./assistant-context-runtime.js"; +import { AssistantContinuationStore } from "../core/assistant-continuation-store.js"; +import { AssistantContinuationNative } from "../core/assistant-continuation-native.js"; +import { AssistantContinuation } from "../core/assistant-continuation.js"; +import { AssistantNativeHistory } from "../core/assistant-native-history.js"; +import { createAssistantLifecycleRouter, projectAssistantContinuation } from "./assistant-lifecycle.js"; +import type { AssistantContinuationView } from "../shared/assistant-continuation.js"; import { OpenCodeBridge } from "./opencode-bridge.js"; import { createStaticRouter } from "./static.js"; import { createTerminalWebSocketHandler } from "./terminal-ws.js"; @@ -302,6 +308,8 @@ export interface HarnessServerOptions { * bundled DEFAULT_SYSTEM_PROMPT on any failure. Tests and offline hosts inject a * plain function instead of reaching the network. */ loadSystemPrompt?: () => Promise; + /** Explicit verified capability supplied by the existing context runtime owner. */ + assistantContextRuntime?: ActivatedAssistantContextRuntime; /** Host-supplied launcher for the local sapiom-dev MCP server, replacing the * default `npx @sapiom/mcp@latest`. The Electron host passes its own binary * (GUI-subsystem — allocates no console window on Windows, where the npx @@ -3546,6 +3554,7 @@ export const startServer = async ( const assistantSessions = new AssistantSessionStore(statePaths.root); const assistantRecords = new AssistantRecordStore(statePaths.root); + const assistantContinuations = new AssistantContinuationStore(statePaths.root); const recordCaptures = new WeakMap(); const authorizeAssistantWorkspace = async (id: string) => { const session = sessionManager.get(id); @@ -3556,10 +3565,24 @@ export const startServer = async ( }))) return null; return { harnessSessionId: id, cwd: session.cwd }; }; + const authorizeAssistantHistory = assistantHistoryAccess({ access: assistantAccess, authorize: authorizeAssistantWorkspace, store: assistantSessions }); + const continuationFor = async (id: string, conversationId: string): Promise => { + const receipt = await assistantContinuations.readChild(id); + if (!receipt) return null; + const binding = await authorizeAssistantHistory(id); + if (!binding || !receipt.childBinding || receipt.childStudioId !== id || + binding.conversationId !== conversationId || + binding.conversationId !== receipt.childBinding.conversationId || + binding.contextAuthorityScope !== receipt.childBinding.contextAuthorityScope || + binding.cwd !== receipt.childBinding.cwd) + throw new OpenCodeAccessError("Assistant continuation binding changed"); + return projectAssistantContinuation(receipt); + }; const openCodeHost = new OpenCodeHost({ + contextRuntime: options.assistantContextRuntime, access: assistantAccess, createObserver: (hosted, id, update) => { - const capture = new AssistantRecordCapture(hosted, id, assistantRecords); + const capture = new AssistantRecordCapture(hosted, id, assistantRecords, () => continuationFor(hosted.harnessSessionId, id)); recordCaptures.set(hosted, capture); const checkpoint = () => capture.checkpoint(); const observer = new OpenCodeObserver(hosted, id, update, capture.invalidate, () => { void checkpoint(); }); @@ -3571,7 +3594,7 @@ export const startServer = async ( authorize: authorizeAssistantWorkspace, }); const openCodeAssociations = new OpenCodeAssociations(assistantSessions); - const assistantLifecycle = new AssistantLifecycleCoordinator({ store: assistantSessions, host: openCodeHost, associations: openCodeAssociations }); + const assistantLifecycle = new AssistantLifecycleCoordinator({ store: assistantSessions, host: openCodeHost, associations: openCodeAssociations, canAttach: (id) => assistantContinuations.canAttach(id) }); const assistantEnd = new AssistantEndCoordinator({ store: assistantSessions, lifecycle: assistantLifecycle, sessionManager }); // Hydrate lifecycle headers without launching runtimes. A corrupt entry fails // closed when selected rather than preventing unrelated sessions from loading. @@ -3580,6 +3603,35 @@ export const startServer = async ( bus.publish({ type: "assistant.state", snapshot }), ); const getAssistantState = assistantProjection.get; + const assistantContext = createAssistantContextRuntime({ + activated: options.assistantContextRuntime, + continuations: assistantContinuations, + assertCurrent: (hosted) => openCodeHost.assertCurrent(hosted), + contextOptions: { + getSession: (id) => sessionManager.get(id), + resolveProject: (id) => studioProjectCatalog.resolveIdentity(id), + getWorkflows: readPublicWorkflows, + getEnvironment: () => assistantAccess.get()?.environment ?? null, + loadSystemPrompt: options.loadSystemPrompt, + }, + }); + const assistantNativeHistory = new AssistantNativeHistory({ + authorize: authorizeAssistantHistory, + lifecycle: assistantLifecycle, + preflight: assistantContext.delivery.recover, + }); + const assistantContinue = new AssistantContinuation({ + store: assistantContinuations, + records: assistantRecords, + sessions: sessionManager, + associations: assistantSessions, + lifecycle: assistantLifecycle, + native: new AssistantContinuationNative((hosted) => openCodeHost.assertCurrent(hosted)), + delivery: assistantContext.delivery, + authorize: authorizeAssistantHistory, + resolveCandidate: (hosted, _receipt, signal) => assistantContext.resolveCandidate(hosted, null, signal), + assertAvailable: assistantContext.assertAvailable, + }); const app: Express = express(); app.disable("x-powered-by"); app.use("/opencode-runtime", openCodeBridge.router); @@ -3588,19 +3640,15 @@ export const startServer = async ( createOpenCodeRouter( openCodeHost, options.bootToken, - createAssistantContextResolver({ - getSession: (id) => sessionManager.get(id), - resolveProject: (id) => studioProjectCatalog.resolveIdentity(id), - getWorkflows: readPublicWorkflows, - getEnvironment: () => assistantAccess.get()?.environment ?? null, - loadSystemPrompt: options.loadSystemPrompt, - }), + assistantContext.context, openCodeAssociations, new OpenCodeFinalResponse({ + recoverPrompt: assistantContext.delivery.recover, assertCurrent: (hosted) => assistantLifecycle.assertRuntime(hosted, true), onPersisted: async (hosted) => { await recordCaptures.get(hosted)?.checkpoint(); }, }), assistantLifecycle, + continuationFor, ), ); @@ -3625,8 +3673,20 @@ export const startServer = async ( res.setHeader("Cache-Control", "no-store"); res.json(assistantAccess.getBrowserState()); }); - const authorizeAssistantHistory = assistantHistoryAccess({ access: assistantAccess, authorize: authorizeAssistantWorkspace, store: assistantSessions }); - const assistantHistory = new AssistantHistory({ sessions: sessionManager, authorize: authorizeAssistantHistory, records: assistantRecords, lifecycle: assistantLifecycle }); + const assistantHistory = new AssistantHistory({ sessions: sessionManager, authorize: authorizeAssistantHistory, records: assistantRecords, lifecycle: assistantLifecycle, isVisible: (id) => assistantContinuations.canAttach(id) }); + app.use("/api", createAssistantLifecycleRouter({ + bootToken: options.bootToken, + history: assistantHistory, + native: assistantNativeHistory, + getSession: (id) => sessionManager.get(id), + resume: (id, revision, operationId) => assistantNativeHistory.resume(id, revision, operationId), + continue: async (id, request) => { + const prepared = await assistantContinue.continue(id, request); + // Publish only the verified child. Pending receipt allocation is private. + bus.publish({ type: "session.status", session: prepared.session }); + return prepared; + }, + })); app.use("/api", createAssistantRecordsRouter({ bootToken: options.bootToken, store: assistantRecords, diff --git a/packages/harness/src/server/opencode.test.ts b/packages/harness/src/server/opencode.test.ts index edb1547de..01e560d9b 100644 --- a/packages/harness/src/server/opencode.test.ts +++ b/packages/harness/src/server/opencode.test.ts @@ -743,6 +743,7 @@ it("binds an authorized association even when its browser disconnects during att function installLifecycle( context: Parameters[2] = resolveContext, + continuationFor?: Parameters[6], ) { const host = { ensure, @@ -779,6 +780,7 @@ function installLifecycle( associations, undefined, lifecycle, + continuationFor, ); return lifecycle; } @@ -840,6 +842,41 @@ it("requires a revision for attach and an exact lease for every subsequent nativ expect(other.status).toBe(409); }); +it("enriches exact attachments with authorized continuation provenance without prompting", async () => { + const continuation = { sourceSessionId: "source" } as Awaited< + ReturnType[6]>> + >; + const project = vi.fn(async () => continuation); + installLifecycle(resolveContext, project); + const attached = await attachLease(); + expect(attached).toHaveProperty("continuation", continuation); + expect(project).toHaveBeenCalledExactlyOnceWith( + "studio-a", + attached.conversationId, + ); + expect( + requests.filter((request) => request.path.endsWith("/prompt_async")), + ).toHaveLength(0); +}); + +it("does not return provenance with a lease superseded during receipt IO", async () => { + const lifecycle = installLifecycle(resolveContext, async (id) => { + const ending = lifecycle.beginEnd(id); + await ending.persistence; + await ending.native; + await lifecycle.finishEnd(ending.fence); + return null; + }); + const response = await request("studio-a/attach", { + method: "POST", + body: JSON.stringify({ expectedRevision: 0 }), + }); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + error: { code: "lifecycle_changed" }, + }); +}); + it("rejects paused recovery before native reads and enables only explicit prepared Send", async () => { const lifecycle = installLifecycle(); const attached = await attachLease(); diff --git a/packages/harness/src/server/opencode.ts b/packages/harness/src/server/opencode.ts index 120dfc604..5a60c0897 100644 --- a/packages/harness/src/server/opencode.ts +++ b/packages/harness/src/server/opencode.ts @@ -21,6 +21,7 @@ import { } from "../core/studio-assistant-context.js"; import type { AssistantContextDelivery } from "../core/studio-assistant-delivery.js"; import type { AssistantLifecycleCoordinator } from "../core/assistant-lifecycle.js"; +import type { AssistantContinuationView } from "../shared/assistant-continuation.js"; export function createOpenCodeRouter( host: Pick & @@ -33,6 +34,10 @@ export function createOpenCodeRouter( AssistantLifecycleCoordinator, "describe" | "attach" | "use" | "enable" | "assertRuntime" >, + continuationFor?: ( + id: string, + conversationId: string, + ) => Promise, ): Router { const router = express.Router(); const dispatch = @@ -129,7 +134,14 @@ export function createOpenCodeRouter( } if (attach && lifecycle) { const attached = await lifecycle.attach(id, req.body.expectedRevision); - if (!res.destroyed) res.json(attached); + const continuation = await continuationFor?.( + id, + attached.conversationId, + ); + // Receipt IO cannot publish an old lease after End or an authority change. + if (continuationFor) await lifecycle.use(id, attached.lease); + if (!res.destroyed) + res.json({ ...attached, ...(continuation ? { continuation } : {}) }); return; } const bound = lifecycle