feat(web): re-land the goals wire domain as mutation-only ref acknowledgements

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.
This commit is contained in:
imccyu
2026-07-28 21:05:43 +08:00
parent cbab62bdea
commit 1c2923c84f
13 changed files with 149 additions and 146 deletions

View File

@@ -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 }))),
}

View File

@@ -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 () => {