From 1c2923c84f4d448d8a08f9faf45f1e984b60a9a7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:05:43 +0800 Subject: [PATCH] feat(web): re-land the goals wire domain as mutation-only ref acknowledgements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six mutation RPCs (create/edit/pause/resume/complete/clear) move into dsh-host-apiproxy (the PR's host/runtime carrier is gone): goalService() resolves ctx.get('goals') with a loud absence error, mutateGoal() resolves the session's agent (agentFor, the command.* implicit-resume precedent) and acknowledges with the new CAS ref only. GoalError codes ride err.details. goal.get and the wire GoalView/goalViewSchema are gone: the read side is the 'goal' session projection (whole values on the history tail page and session/projection frames), so responses never feed client state — the rule whose absence forced the original PR's write-revision fences. --- packages/client/connection/tests/fake-api.ts | 10 +-- .../client/connection/tests/fixture.spec.ts | 14 +-- packages/client/runtime/tests/fake-api.ts | 10 +-- packages/host/apiproxy/package.json | 1 + packages/host/apiproxy/src/api-proxy.ts | 85 ++++++++++++++++++- .../host/apiproxy/src/api/goals.schema.ts | 47 +++------- packages/host/apiproxy/src/api/goals.ts | 60 +++---------- packages/host/apiproxy/src/api/index.ts | 2 +- packages/host/apiproxy/src/fetch/client.ts | 2 - packages/host/apiproxy/src/index.ts | 2 + .../apiproxy/tests/client-handler.spec.ts | 56 ++++-------- .../host/apiproxy/tests/fetch-carrier.spec.ts | 3 - packages/host/apiproxy/tsconfig.json | 3 + 13 files changed, 149 insertions(+), 146 deletions(-) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 6fe07e5f48..d974c4bf3b 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -128,11 +128,11 @@ export class FakeApiClient implements IApiClient { } readonly goals: IApiClient['goals'] = { - create: payload => this.record('goal.create', payload, Promise.resolve(ok({ goal: null as unknown as never }))), - edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ goal: null as unknown as never }))), - pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ goal: null as unknown as never }))), - resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ goal: null as unknown as never }))), - complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ goal: null as unknown as never }))), + create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))), } diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 18eda62723..fd51123a77 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -698,6 +698,13 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId }) if (!moved.result.ok) throw new Error('workspace move failed') expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId]) + const ref = { id: 'fx-goal-1' as never, revision: 1 } + expect((await client.goals.create({ sessionId: id, objective: 'x' })).result.ok).toBe(false) + expect((await client.goals.edit({ sessionId: id, ref, objective: 'x' })).result.ok).toBe(false) + expect((await client.goals.pause({ sessionId: id, ref })).result.ok).toBe(false) + expect((await client.goals.resume({ sessionId: id, ref })).result.ok).toBe(false) + expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false) + expect((await client.goals.clear({ sessionId: id, ref })).result.ok).toBe(false) }) it('maps empty, prompt-reject, and workspace-first query scenarios', async () => { @@ -745,13 +752,6 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId: sid('fx-query-dropped'), })).rejects.toThrow(/dropped session\.create response/) - const ref = { id: 'fx-goal-1' as never, revision: 1 } - expect((await client.goals.create({ sessionId: id, objective: 'x' })).result.ok).toBe(false) - expect((await client.goals.edit({ sessionId: id, ref, objective: 'x' })).result.ok).toBe(false) - expect((await client.goals.pause({ sessionId: id, ref })).result.ok).toBe(false) - expect((await client.goals.resume({ sessionId: id, ref })).result.ok).toBe(false) - expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false) - expect((await client.goals.clear({ sessionId: id, ref })).result.ok).toBe(false) }) it('fires onOpen at stream-iteration start and taps server-request full forms', async () => { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index e6dfec1519..bf1a4a04b3 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -154,11 +154,11 @@ export class FakeApiClient implements IApiClient { } readonly goals: IApiClient['goals'] = { - create: payload => this.record('goal.create', payload, Promise.resolve(ok({ goal: null as unknown as never }))), - edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ goal: null as unknown as never }))), - pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ goal: null as unknown as never }))), - resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ goal: null as unknown as never }))), - complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ goal: null as unknown as never }))), + create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))), } diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index ab632199bd..1159ffea75 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 56f488857f..186c5f7231 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -24,12 +24,15 @@ import { // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' import type { - ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning, + ApiProxy, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSummary, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' // Type-only: resolves `ctx.get('sessionProjections')` to the projection registry. import type {} from '@deepseek-ai/dsh-session-projection' +// GoalError narrows domain rejections to their stable codes at the wire boundary. +import { GoalError } from '@deepseek-ai/dsh-goal' +import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal' // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-skill' @@ -645,6 +648,38 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return operation } + /** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */ + function goalService(): NonNullable>> | { error: RpcError } { + const goals = ctx.get('goals') + if (goals === undefined) { + return { error: { code: 'internal', message: 'goal service is absent: this deployment does not mount @deepseek-ai/dsh-goal in its composition (cordis.yml or explicit assembly)', details: {} } } + } + return goals + } + + /** Map one goal-domain rejection to the wire error (stable GoalError codes ride in details). */ + function goalError(request: RpcRequest, error: unknown): RpcResponse { + const details = error instanceof GoalError ? { goalCode: error.code } : {} + return err(request, { code: 'internal', message: String(error), details }) + } + + /** Resolve a session's agent, apply one goal mutation, and acknowledge with the new CAS ref. */ + async function mutateGoal( + request: RpcRequest<{ sessionId: SessionId }>, + mutation: (goals: NonNullable>>, agent: Agent) => CoreGoalRef, + ): Promise> { + const goals = goalService() + if ('error' in goals) return err(request, goals.error) + const found = await agentFor(request.payload.sessionId) + if ('error' in found) return err(request, found.error) + try { + const ref = mutation(goals, found.agent) + return ok(request, { ref: { id: ref.id, revision: ref.revision } }) + } catch (error: unknown) { + return goalError(request, error) + } + } + return { sessions: { // Attached sessions summarize from memory; persisted-but-unattached (cold) @@ -1090,6 +1125,54 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, + goals: { + // Mutations only — the read side is the 'goal' session projection. + // Every verb resolves the session's agent (agentFor: implicit cold + // resume, the command.* precedent) and acknowledges with the new CAS + // ref; the committed goal/change event carries the whole value to every + // client through the projection frames. + async create(request) { + const { objective, maxGoalRounds } = request.payload + return mutateGoal(request, (goals, agent) => goals.create(agent, { + objective, + ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}), + })) + }, + + async edit(request) { + const { ref, objective, maxGoalRounds } = request.payload + return mutateGoal(request, (goals, agent) => goals.edit(agent, ref, { + ...(objective !== undefined ? { objective } : {}), + ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}), + })) + }, + + async pause(request) { + return mutateGoal(request, (goals, agent) => goals.pause(agent, request.payload.ref)) + }, + + async resume(request) { + return mutateGoal(request, (goals, agent) => goals.resume(agent, request.payload.ref)) + }, + + async complete(request) { + return mutateGoal(request, (goals, agent) => goals.complete(agent, request.payload.ref)) + }, + + async clear(request) { + const goals = goalService() + if ('error' in goals) return err(request, goals.error) + const found = await agentFor(request.payload.sessionId) + if ('error' in found) return err(request, found.error) + try { + goals.clear(found.agent, request.payload.ref) + return ok(request, { cleared: true as const }) + } catch (error: unknown) { + return goalError(request, error) + } + }, + }, + skills: { // Skill lookup never touches the Agent registry: the session address // resolves to a canonical cwd from the host-resident session header, so diff --git a/packages/host/apiproxy/src/api/goals.schema.ts b/packages/host/apiproxy/src/api/goals.schema.ts index 043c8b1c80..24615502c3 100644 --- a/packages/host/apiproxy/src/api/goals.schema.ts +++ b/packages/host/apiproxy/src/api/goals.schema.ts @@ -1,10 +1,12 @@ /** - * goals domain zod schemas. + * goals domain zod schemas. Mutation-only shapes: every value schema is a + * `{ ref }` acknowledgement (clear: `{ cleared }`) — the current goal state + * travels exclusively on the 'goal' session projection. */ import { z } from 'zod' import type { Wire } from './rpc.schema.ts' -import type { GoalRef, GoalView, RequestPayload, ResponseValue } from './index.ts' +import type { GoalRef, RequestPayload, ResponseValue } from './index.ts' /** GoalRef schema. */ export const goalRefSchema = z.object({ @@ -12,25 +14,8 @@ export const goalRefSchema = z.object({ revision: z.number().int().positive(), }) as unknown as z.ZodType> -/** Goal block reason schema. */ -export const goalBlockReasonSchema = z.object({ - code: z.string(), - message: z.string(), -}) - -/** GoalView schema. */ -export const goalViewSchema = z.object({ - id: z.string(), - revision: z.number().int().positive(), - objective: z.string(), - phase: z.union([z.literal('active'), z.literal('paused'), z.literal('blocked'), z.literal('complete')]), - blockedReason: goalBlockReasonSchema.optional(), - maxGoalRounds: z.number().int().positive(), - roundsStarted: z.number().int().nonnegative(), - createdAt: z.number(), - updatedAt: z.number(), - activation: z.union([z.literal('armed'), z.literal('disarmed')]), -}) as unknown as z.ZodType> +/** Shared `{ ref }` acknowledgement value of every non-clear mutation. */ +const goalRefValueSchema = z.object({ ref: goalRefSchema }) /** goal.create request payload. */ export const goalCreateRequestSchema = z.object({ @@ -40,9 +25,7 @@ export const goalCreateRequestSchema = z.object({ }) as unknown as z.ZodType>> /** goal.create response value. */ -export const goalCreateValueSchema = z.object({ - goal: goalViewSchema, -}) as unknown as z.ZodType>> +export const goalCreateValueSchema = goalRefValueSchema as unknown as z.ZodType>> /** goal.edit request payload. */ export const goalEditRequestSchema = z.object({ @@ -55,9 +38,7 @@ export const goalEditRequestSchema = z.object({ }) as unknown as z.ZodType>> /** goal.edit response value. */ -export const goalEditValueSchema = z.object({ - goal: goalViewSchema, -}) as unknown as z.ZodType>> +export const goalEditValueSchema = goalRefValueSchema as unknown as z.ZodType>> /** goal.pause request payload. */ export const goalPauseRequestSchema = z.object({ @@ -66,9 +47,7 @@ export const goalPauseRequestSchema = z.object({ }) as unknown as z.ZodType>> /** goal.pause response value. */ -export const goalPauseValueSchema = z.object({ - goal: goalViewSchema, -}) as unknown as z.ZodType>> +export const goalPauseValueSchema = goalRefValueSchema as unknown as z.ZodType>> /** goal.resume request payload. */ export const goalResumeRequestSchema = z.object({ @@ -77,9 +56,7 @@ export const goalResumeRequestSchema = z.object({ }) as unknown as z.ZodType>> /** goal.resume response value. */ -export const goalResumeValueSchema = z.object({ - goal: goalViewSchema, -}) as unknown as z.ZodType>> +export const goalResumeValueSchema = goalRefValueSchema as unknown as z.ZodType>> /** goal.complete request payload. */ export const goalCompleteRequestSchema = z.object({ @@ -88,9 +65,7 @@ export const goalCompleteRequestSchema = z.object({ }) as unknown as z.ZodType>> /** goal.complete response value. */ -export const goalCompleteValueSchema = z.object({ - goal: goalViewSchema, -}) as unknown as z.ZodType>> +export const goalCompleteValueSchema = goalRefValueSchema as unknown as z.ZodType>> /** goal.clear request payload. */ export const goalClearRequestSchema = z.object({ diff --git a/packages/host/apiproxy/src/api/goals.ts b/packages/host/apiproxy/src/api/goals.ts index f64208f4e1..c585eacdf5 100644 --- a/packages/host/apiproxy/src/api/goals.ts +++ b/packages/host/apiproxy/src/api/goals.ts @@ -1,6 +1,12 @@ /** * goals domain contract. Method signatures are the source of truth: * unary methods take the RpcRequest

narrow form and the impl echoes rpcId. + * + * Mutations only: the read side is the 'goal' session projection (history + * tail-page projections block + session/projection frames), so there is no + * goal.get and no wire goal view — responses acknowledge with the new CAS + * ref and never feed client state (the committed goal/change event reaches + * every client through the mux stream carrying the same whole value). */ import type { Branded } from '@deepseek-ai/dsh-brand' @@ -16,69 +22,27 @@ export interface GoalRef { readonly revision: number } -/** Durable continuation phase. */ -export type GoalPhase = - | 'active' - | 'paused' - | 'blocked' - | 'complete' - -/** Machine-routable and human-readable explanation for a blocked goal. */ -export interface GoalBlockReason { - readonly code: string - readonly message: string -} - -/** Whether this live process may automatically continue an active goal. */ -export type GoalActivation = 'armed' | 'disarmed' - -/** Current goal projection, including values derived from the session log. */ -export interface GoalView { - readonly id: GoalId - readonly revision: number - readonly objective: string - readonly phase: GoalPhase - readonly blockedReason?: GoalBlockReason - readonly maxGoalRounds: number - readonly roundsStarted: number - readonly createdAt: number - readonly updatedAt: number - readonly activation: GoalActivation -} - -/** Input whose omitted round cap is resolved by the service configuration. */ -export interface CreateGoalRequest { - readonly objective: string - readonly maxGoalRounds?: number -} - -/** Fields changed by an edit; at least one must be present. */ -export interface EditGoalRequest { - readonly objective?: string - readonly maxGoalRounds?: number -} - -/** Goal-domain unary methods (mutations only: the read side is the 'goal' session projection). */ +/** Goal-domain unary methods (every mutation resolves the session's agent and applies one CAS-guarded verb). */ export interface GoalsApi { /** Create and arm a goal. */ create(request: RpcRequest<{ sessionId: SessionId; objective: string; maxGoalRounds?: number }>): - Promise> + Promise> /** Edit objective and/or round cap without changing phase. */ edit(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef; objective?: string; maxGoalRounds?: number }>): - Promise> + Promise> /** Pause an active goal and disarm automatic continuation. */ pause(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>): - Promise> + Promise> /** Resume and arm a stopped goal. */ resume(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>): - Promise> + Promise> /** Mark a current non-complete goal complete and disarm it. */ complete(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>): - Promise> + Promise> /** Clear the current goal while retaining a durable tombstone and history. */ clear(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>): diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index f476febff8..451655d114 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -36,7 +36,7 @@ export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts' -export type { GoalsApi, GoalView, GoalRef, GoalPhase, GoalBlockReason, CreateGoalRequest, EditGoalRequest } from './goals.ts' +export type { GoalsApi, GoalId, GoalRef } from './goals.ts' export type { ApprovalResponsePayload } from './approvals.ts' export type { QuestionResponsePayload } from './questions.ts' diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 330284be4c..fab8166c3f 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -92,7 +92,6 @@ export interface IApiClient { host(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> } goals: { - get(payload: RequestPayload<'goal.get'>, signal?: AbortSignal): Promise>> create(payload: RequestPayload<'goal.create'>, signal?: AbortSignal): Promise>> edit(payload: RequestPayload<'goal.edit'>, signal?: AbortSignal): Promise>> pause(payload: RequestPayload<'goal.pause'>, signal?: AbortSignal): Promise>> @@ -353,7 +352,6 @@ export abstract class AbstractApiClient implements IApiClient { } readonly goals: IApiClient['goals'] = { - get: (payload, signal) => this.callUnary('goal.get', payload, signal), create: (payload, signal) => this.callUnary('goal.create', payload, signal), edit: (payload, signal) => this.callUnary('goal.edit', payload, signal), pause: (payload, signal) => this.callUnary('goal.pause', payload, signal), diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index e7ef6c7332..c9a4e3afb9 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -57,6 +57,7 @@ export class ApiProxyService extends Service implements ApiProxy { readonly workspace: ApiProxy['workspace'] readonly host: ApiProxy['host'] readonly commands: ApiProxy['commands'] + readonly goals: ApiProxy['goals'] readonly skills: ApiProxy['skills'] readonly events: ApiProxy['events'] readonly respond: ApiProxy['respond'] @@ -74,6 +75,7 @@ export class ApiProxyService extends Service implements ApiProxy { this.workspace = api.workspace this.host = api.host this.commands = api.commands + this.goals = api.goals this.skills = api.skills this.events = api.events // createApiProxy returns closures (no `this` capture); bind only satisfies diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index b096522c41..3f2ef875f9 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-session' -import type { ApiProxy, GoalRef, GoalView, HostFrame, MuxFrame, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy' +import type { ApiProxy, GoalRef, HostFrame, MuxFrame, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy' import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' const sid = (id: string): SessionId => id as SessionId @@ -418,19 +418,9 @@ describe('SSE stream path', () => { }) describe('goals unary surface', () => { - /** A wire-valid GoalView the scripted impl hands back. */ - const view: GoalView = { - id: 'goal-1' as GoalView['id'], - revision: 2, - objective: 'ship it', - phase: 'active' as const, - maxGoalRounds: 4, - roundsStarted: 1, - createdAt: 10, - updatedAt: 20, - activation: 'armed' as const, - } const ref: GoalRef = { id: 'goal-1' as GoalRef['id'], revision: 1 } + /** The `{ ref }` acknowledgement every non-clear mutation answers (state travels on the projection). */ + const ack = { ref: { id: 'goal-1' as GoalRef['id'], revision: 2 } } it('round-trips every goal method with its own payload and value shape', async () => { const seen: { method: string; payload: unknown }[] = [] @@ -441,43 +431,33 @@ describe('goals unary surface', () => { } const api = scriptedApi({ goals: { - get: record('goal.get', r => ok(r, { goal: view })), - create: record('goal.create', r => ok(r, { goal: view })), - edit: record('goal.edit', r => ok(r, { goal: { ...view, revision: 3 } })), - pause: record('goal.pause', r => ok(r, { goal: { ...view, phase: 'paused' as const, activation: 'disarmed' as const } })), - resume: record('goal.resume', r => ok(r, { goal: view })), - complete: record('goal.complete', r => ok(r, { goal: { ...view, phase: 'complete' as const, activation: 'disarmed' as const } })), + create: record('goal.create', r => ok(r, ack)), + edit: record('goal.edit', r => ok(r, { ref: { ...ack.ref, revision: 3 } })), + pause: record('goal.pause', r => ok(r, ack)), + resume: record('goal.resume', r => ok(r, ack)), + complete: record('goal.complete', r => ok(r, ack)), clear: record('goal.clear', r => ok(r, { cleared: true as const })), }, }) const c = client(api) - const got = await c.goals.get({ sessionId: sid('s1') }) - expect(got.result).toEqual({ ok: true, value: { goal: view } }) const created = await c.goals.create({ sessionId: sid('s1'), objective: 'ship it', maxGoalRounds: 4 }) - expect(created.result).toEqual({ ok: true, value: { goal: view } }) + expect(created.result).toEqual({ ok: true, value: ack }) const edited = await c.goals.edit({ sessionId: sid('s1'), ref, objective: 'ship v2' }) - expect(edited.result).toEqual({ ok: true, value: { goal: { ...view, revision: 3 } } }) - const paused = await c.goals.pause({ sessionId: sid('s1'), ref }) - expect(paused.result.ok && paused.result.value.goal.phase).toBe('paused') - const resumed = await c.goals.resume({ sessionId: sid('s1'), ref }) - expect(resumed.result.ok && resumed.result.value.goal.phase).toBe('active') - const completed = await c.goals.complete({ sessionId: sid('s1'), ref }) - expect(completed.result.ok && completed.result.value.goal.phase).toBe('complete') + expect(edited.result).toEqual({ ok: true, value: { ref: { ...ack.ref, revision: 3 } } }) + expect((await c.goals.pause({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack }) + expect((await c.goals.resume({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack }) + expect((await c.goals.complete({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack }) const cleared = await c.goals.clear({ sessionId: sid('s1'), ref }) expect(cleared.result).toEqual({ ok: true, value: { cleared: true } }) // The handler dispatched each call through its own route row: payload parsed per method. - expect(seen.map(s => s.method)).toEqual(['goal.get', 'goal.create', 'goal.edit', 'goal.pause', 'goal.resume', 'goal.complete', 'goal.clear']) - expect(seen[1]?.payload).toEqual({ sessionId: 's1', objective: 'ship it', maxGoalRounds: 4 }) - expect(seen[2]?.payload).toEqual({ sessionId: 's1', ref, objective: 'ship v2' }) + expect(seen.map(s => s.method)).toEqual(['goal.create', 'goal.edit', 'goal.pause', 'goal.resume', 'goal.complete', 'goal.clear']) + expect(seen[0]?.payload).toEqual({ sessionId: 's1', objective: 'ship it', maxGoalRounds: 4 }) + expect(seen[1]?.payload).toEqual({ sessionId: 's1', ref, objective: 'ship v2' }) }) - it('passes a null goal and business errors through the goal.get route', async () => { - const api = scriptedApi({ goals: { get: r => ok(r, { goal: null }) } }) - const response = await client(api).goals.get({ sessionId: sid('s-empty') }) - expect(response.result).toEqual({ ok: true, value: { goal: null } }) - + it('passes business errors through as results, not throws', async () => { // Default scripted goals impl answers an err result: it must arrive as a result, not a throw. const failed = await client(scriptedApi()).goals.pause({ sessionId: sid('s1'), ref }) expect(failed.result.ok).toBe(false) @@ -490,7 +470,7 @@ describe('goals unary surface', () => { if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') let editCalls = 0 - const api = scriptedApi({ goals: { edit: (r) => { editCalls++; return ok(r, { goal: view }) } } }) + const api = scriptedApi({ goals: { edit: (r) => { editCalls++; return ok(r, ack) } } }) const emptyEdit = await client(api).goals.edit({ sessionId: sid('s1'), ref }) expect(emptyEdit.result.ok).toBe(false) if (!emptyEdit.result.ok) expect(emptyEdit.result.error.code).toBe('bad-request') diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index a486e82788..3d5fac2a5a 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -136,9 +136,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, }, goals: { - async get(request) { - return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } } - }, async create(request) { return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } } }, diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index bf65db029d..20662d48e7 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../goal/goal" + }, { "path": "../../../vendor/cordis" },