fix(api-gateway): harden remote lifecycle and recovery

This commit is contained in:
imccyu
2026-08-07 16:53:04 +08:00
parent e89d078819
commit 686ee5b3f6
21 changed files with 218 additions and 34 deletions

View File

@@ -38,10 +38,10 @@ const NS = 'goal'
/** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */
export const inject = ['slots', 'sessions', 'api', 'locale']
/** Map one generated Remote call onto the strip's inline-render shape. */
async function settle(result: Promise<unknown>): Promise<GoalActionResult> {
/** Map one generated Remote call, including synchronous namespace lookup failures, onto the strip's inline-render shape. */
async function settle(invoke: () => Promise<unknown>): Promise<GoalActionResult> {
try {
await result
await invoke()
return { ok: true }
} catch (error) {
const cause = error instanceof Error ? error.cause : undefined
@@ -94,22 +94,22 @@ export function apply(ctx: ClientContext): void {
onEdit: async (objective) => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle(ctx.api.goals.edit(sessionId, ref, { objective }))
return settle(() => ctx.api.goals.edit(sessionId, ref, { objective }))
},
onPause: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle(ctx.api.goals.pause(sessionId, ref))
return settle(() => ctx.api.goals.pause(sessionId, ref))
},
onResume: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle(ctx.api.goals.resume(sessionId, ref))
return settle(() => ctx.api.goals.resume(sessionId, ref))
},
onClear: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle(ctx.api.goals.clear(sessionId, ref))
return settle(() => ctx.api.goals.clear(sessionId, ref))
},
}),
}, GoalDock))

View File

@@ -70,7 +70,7 @@ async function bench(options: {
resume: answer(`${prefix}/resume`, { ref }),
clear: answer(`${prefix}/clear`, ref),
})
let activeGoals = goals('goals')
let activeGoals: ReturnType<typeof goals> | undefined = goals('goals')
ctx.provide('api', {
get goals() { return activeGoals },
})
@@ -95,6 +95,7 @@ async function bench(options: {
fiber,
calls,
remountGoals: () => { activeGoals = goals('remounted-goals') },
unmountGoals: () => { activeGoals = undefined },
entry: () => {
const entry = ctx.slots.entries('conversation.input.dock')[0]
if (entry === undefined) return undefined
@@ -141,6 +142,18 @@ describe('ui-goal browser plugin', () => {
expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }])
})
it('settles every verb when the Remote namespace is temporarily absent', async () => {
const b = await bench({ projection: makeProjection() })
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
b.unmountGoals()
for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) {
expect(result).toMatchObject({ ok: false, error: { code: 'internal' } })
}
expect(b.calls).toHaveLength(0)
})
it('a null or absent projection short-circuits every verb without touching the wire', async () => {
for (const projection of [null, undefined]) {
const b = await bench({ projection })