diff --git a/.changeset/28856-action-stale-summary-answer.md b/.changeset/28856-action-stale-summary-answer.md new file mode 100644 index 00000000000..d07051f5bf9 --- /dev/null +++ b/.changeset/28856-action-stale-summary-answer.md @@ -0,0 +1,18 @@ +--- +'@objectstack/runtime': patch +'@objectstack/mcp': patch +--- + +fix(runtime,mcp): a declarative update action whose write landed under a failing roll-up answers its own success at both action doors (#28856) + +Clause-②: no +Metadata-protocol: none +Skills-docs: none — no file under `skills/**` teaches what an action door answers when a parent roll-up cannot be recomputed after the action's write. + +When a declarative update action (`operation: 'update'`) writes its record but a parent's roll-up summary cannot be recomputed after it (`ERR_SUMMARY_RECOMPUTE`, "the triggering records WERE written"), the action now answers the result it answers when the recompute succeeds, instead of an error that invited the caller to run the action again. No new code, member, status or shape: + +- **REST** `POST /actions/:object/:action/:id`: `200` with the declarative result (`{ operation, object, id, record, undo? }`). +- **MCP `run_action`**: the usual `{ ok, action, objectName, recordId, result }`. +- The action's invocation audit row records `succeeded`, the outcome the door answers. + +The runtime composes the action's result once and attaches it to the refusal through the existing carrier; each door relays it. The stale summary column is logged at `error` once per field per process. A refusal before the write answers exactly as before. Other action kinds are unchanged: an `api` action's forward already lands on a data door that answers success, a `flow` action's record nodes turn the refusal into a node failure, and a script or body action is a separate follow-up. diff --git a/packages/mcp/src/mcp-http-tools.ts b/packages/mcp/src/mcp-http-tools.ts index 882ed74b405..757ccb12ea1 100644 --- a/packages/mcp/src/mcp-http-tools.ts +++ b/packages/mcp/src/mcp-http-tools.ts @@ -482,17 +482,23 @@ function errorResultWithCode(err: unknown) { } /** - * [#28782, #28844] A write tool's answer to a throw: a write the engine stored - * but whose parent roll-up it could not recompute (`ERR_SUMMARY_RECOMPUTE`, + * [#28782, #28844, #28856] A write tool's answer to a throw: a write the engine + * stored but whose parent roll-up it could not recompute (`ERR_SUMMARY_RECOMPUTE`, * "the triggering records WERE written") answers the write it is, read off * #28781's carrier the bridge attached, with the stale column logged once at * `error`. An agent that read an error would apply the write again. Any other * throw, including every refusal raised before the write, renders through - * {@link errorResultWithCode} exactly as before. + * `otherwise` exactly as before: {@link errorResultWithCode} for the data tools, + * {@link errorResultFromThrown} for `run_action`, whose answer the runtime + * composed and attached. */ -function landedWriteResultOrError(err: unknown, objectName: string) { +function landedWriteResultOrError( + err: unknown, + objectName: string | undefined, + otherwise: (err: unknown) => ReturnType = errorResultWithCode, +) { const earned = earnedAnswerOf(err); - if (earned === undefined) return errorResultWithCode(err); + if (earned === undefined) return otherwise(err); for (const line of staleSummaryAnswerLines(err as SummaryRecomputeRefusal, objectName)) console.error(`[MCP] ${line}`); return textResult(earned); } @@ -1590,7 +1596,7 @@ export function registerActionTools( }); return textResult(result); } catch (err) { - return errorResultFromThrown(err); + return landedWriteResultOrError(err, objectName, errorResultFromThrown); } }, ); diff --git a/packages/mcp/src/mcp-run-action-stale-summary.test.ts b/packages/mcp/src/mcp-run-action-stale-summary.test.ts new file mode 100644 index 00000000000..2ff4522b2e2 --- /dev/null +++ b/packages/mcp/src/mcp-run-action-stale-summary.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28856] The MCP `run_action` tool answers an action whose write the engine + * STORED but whose parent roll-up it could not recompute with the action's + * own answer — never the refusal, which told an agent the action failed and + * invited it to run the action again, repeating every side effect. + * + * ## The defect, measured on `origin/main` (`3b920f6d`) before this file + * + * The runtime bridge's `runAction` threw the engine's `ERR_SUMMARY_RECOMPUTE` + * refusal and the tool rendered it as an `isError` result. The runtime now + * composes the action's `run_action` answer and attaches it to the refusal + * through #28781's carrier (`invokeBusinessAction`, pinned by + * `packages/runtime/src/action-declarative-update-stale-summary.test.ts`); + * this file pins the tool's half: it relays that answer. + * + * ## What is pinned (over the HTTP `/mcp` transport) + * + * - the attached answer is the tool's result, not an error; + * - a refusal before the write keeps its existing rendering, `details` and all; + * - a stale refusal with NO answer attached (an action kind whose result the + * runtime cannot compose, #28861) still answers its error; + * - the stale column is logged once, at `error`. + */ + +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { AI_ACTION_CONFIRMATION_MEMBER } from '@objectstack/spec/contracts'; +import { withEarnedAnswer } from '@objectstack/types'; + +import { MCPServerRuntime } from './mcp-server-runtime.js'; +import type { McpActionBridge, McpDataBridge } from './mcp-http-tools.js'; + +type Bridge = McpDataBridge & McpActionBridge; + +const OBJECT = 'stl_case'; +const RECORD_ID = 'case_1'; +const ACTION = 'close_case'; + +/** objectql's `SummaryRecomputeError`, as a write's caller reads it: by `code`, carrying `written`. */ +function staleRefusal(): Error { + return Object.assign(new Error('Roll-up summary recompute failed after retries for 1 parent record(s); the triggering records WERE written.'), { + code: 'ERR_SUMMARY_RECOMPUTE', + written: { id: RECORD_ID }, + failures: [{ childObject: OBJECT, parentObject: 'stl_account', parentId: 'a1', field: 'open_cases', error: new Error('sum over text') }], + }); +} + +/** The runtime's answer for a declarative update action (`invokeBusinessAction`). */ +const ANSWER = { + ok: true, + action: ACTION, + objectName: OBJECT, + recordId: RECORD_ID, + result: { operation: 'update', object: OBJECT, id: RECORD_ID, record: { id: RECORD_ID, status: 'closed' } }, +}; + +function bridgeThrowing(thrown: () => Error): Bridge { + return { + async listObjects() { return [{ name: OBJECT, label: 'Case', fieldCount: 1 }]; }, + async describeObject() { return { name: OBJECT, fields: [{ name: 'status', type: 'text' }] }; }, + async query() { return { object: OBJECT, records: [], total: 0 }; }, + async get(_object, id) { return { id, status: 'open' }; }, + async create() { return {}; }, + async update() { return {}; }, + async remove() { return {}; }, + async listActions() { + return [{ name: ACTION, objectName: OBJECT, type: 'script', requiresRecord: true, requiresConfirmation: false }]; + }, + async runAction() { throw thrown(); }, + async resumeRun() { return {}; }, + }; +} + +let nextId = 1; +async function runAction(bridge: Bridge) { + const body = { + jsonrpc: '2.0', id: nextId++, method: 'tools/call', + params: { name: 'run_action', arguments: { actionName: ACTION, objectName: OBJECT, recordId: RECORD_ID } }, + }; + const runtime = new MCPServerRuntime({ name: 'pin', version: '0.0.0' }); + const res = await runtime.handleHttpRequest( + new Request('http://localhost/api/v1/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(body), + }), + { bridge, parsedBody: body }, + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { result?: any; error?: unknown }; + expect(json.error, JSON.stringify(json.error)).toBeUndefined(); + return json.result as { content: Array<{ text: string }>; isError?: boolean }; +} + +let errorLog: ReturnType; +beforeAll(() => { errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); }); +afterAll(() => { errorLog.mockRestore(); }); + +describe('[#28856] MCP run_action answers an action whose write landed under a failing roll-up as the action it is', () => { + it('relays the answer the runtime attached to the refusal', async () => { + const result = await runAction(bridgeThrowing(() => withEarnedAnswer(staleRefusal() as any, ANSWER))); + expect(result.isError, result.content[0]?.text).toBeFalsy(); + expect(JSON.parse(result.content[0]!.text)).toEqual(ANSWER); + }); + + it('a refusal before the write keeps its rendering, details included', async () => { + const result = await runAction(bridgeThrowing(() => Object.assign( + new Error(`Action '${ACTION}' on '${OBJECT}' declares ai.requiresConfirmation: true — nothing was run.`), + { code: 'ACTION_CONFIRMATION_REQUIRED', status: 428, details: { actionName: ACTION, confirmationMember: AI_ACTION_CONFIRMATION_MEMBER } }, + ))); + expect(result.isError).toBe(true); + const { error } = JSON.parse(result.content[0]!.text); + expect(error).toMatchObject({ code: 'ACTION_CONFIRMATION_REQUIRED', status: 428, details: { actionName: ACTION } }); + }); + + it('a stale refusal with no answer attached still answers its error', async () => { + const result = await runAction(bridgeThrowing(staleRefusal)); + expect(result.isError).toBe(true); + expect(JSON.parse(result.content[0]!.text).error.code).toBe('ERR_SUMMARY_RECOMPUTE'); + }); + + it('the stale column is logged once, at error', () => { + const lines = errorLog.mock.calls.map((c: unknown[]) => String(c[0])).filter((l: string) => l.includes('could not be recomputed')); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('"stl_account.open_cases"'); + }); +}); diff --git a/packages/runtime/src/action-declarative-update-stale-summary.test.ts b/packages/runtime/src/action-declarative-update-stale-summary.test.ts new file mode 100644 index 00000000000..f8d9aac5c0c --- /dev/null +++ b/packages/runtime/src/action-declarative-update-stale-summary.test.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28856] A declarative update action (`operation: 'update'`) whose write the + * engine STORED but whose parent roll-up it could not recompute answers the + * action it is, at both action doors — never the refusal, which told a caller + * the action failed and invited it to run the action again. + * + * ## The defect, measured on `origin/main` (`3b920f6d`) before this file + * + * The engine reports that case as `ERR_SUMMARY_RECOMPUTE` ("the triggering + * records WERE written", carrying `written`). The action's one write goes + * through `callData('update')`, whose producers (the protocol's `updateData`, + * and the ObjectQL fallback this rig runs) attach the WRITE's answer since + * #28781 / #28782. `executeDeclarativeUpdateAction` never composed the + * action's result from it, and neither door read the carrier: + * + * - REST `POST /actions/:object/:action` answered `500` with the refusal; + * - the MCP bridge's `runAction` threw it, and the audit row said `failed`. + * + * ## What is pinned + * + * - REST answers `200` with the declarative result it answers when the + * recompute succeeds, and the audit row says `succeeded`; + * - the MCP bridge's `runAction` throws the refusal carrying the action's + * `run_action` answer (`{ ok, action, objectName, recordId, result }`) for + * the tool to relay, and the audit row says `succeeded`; + * - the update is applied exactly once at each door; + * - a refusal before the write still answers exactly as before (ruling 5); + * - the stale column is logged once, at `error`, by the REST door. + * + * The engine is a double whose `update` applies the write and then throws the + * refusal as objectql's `SummaryRecomputeError` does; the real engine's throw + * is pinned by #28781's REST suite. + */ + +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { earnedAnswerOf } from '@objectstack/types'; + +import { HttpDispatcher } from './http-dispatcher.js'; + +const OBJECT = 'stl_case'; +const RECORD = 'case_1'; +const CLOSE = { + name: 'close_case', label: 'Close', objectName: OBJECT, operation: 'update', + patch: { status: 'closed' }, undoable: true, ai: { exposed: true, description: 'Close the case.' }, +}; + +/** objectql's `SummaryRecomputeError`, as a write's caller reads it: by `code`, carrying `written`. */ +function staleRefusal(written: unknown): Error { + return Object.assign(new Error('Roll-up summary recompute failed after retries for 1 parent record(s); the triggering records WERE written.'), { + code: 'ERR_SUMMARY_RECOMPUTE', + written, + failures: [{ childObject: OBJECT, parentObject: 'stl_account', parentId: 'a1', field: 'open_cases', error: new Error('sum over text') }], + }); +} + +/** + * The dispatcher over an engine whose `update` applies the write, then + * reports the stale roll-up — or refuses before writing anything when the + * caller is `u_reader`. + */ +function makeRig() { + const events: any[] = []; + const audit = { recordActionInvocation: vi.fn(async (e: any) => { events.push(e); }) }; + const row: Record = { id: RECORD, status: 'open', owner: 'u_member' }; + const writes: string[] = []; + + const objectDef = { + name: OBJECT, + fields: { status: { type: 'text' }, owner: { type: 'text' } }, + actions: [CLOSE], + }; + const schemaOf = (n: string) => (n === OBJECT ? objectDef : undefined); + const ql: any = { + executeAction: vi.fn(async (object: string, key: string) => { + throw new Error(`Action '${key}' on object '${object}' not found`); + }), + getSchema: schemaOf, + registry: { getObject: schemaOf, getItem: () => undefined }, + find: vi.fn(async (_object: string, q: any) => { + const rows = q?.where?.id === RECORD ? [{ ...row }] : []; + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }), + update: vi.fn(async (object: string, data: any, options?: any) => { + assertEngineUpdateDispatch(data, options); + if (options?.context?.userId === 'u_reader') { + throw Object.assign(new Error(`You do not have permission to update ${object}`), { code: 'PERMISSION_DENIED', status: 403 }); + } + Object.assign(row, data); + writes.push(`update:${options?.where?.id}`); + throw staleRefusal({ ...row }); + }), + insert: vi.fn(), + delete: vi.fn(), + }; + const metadata: any = { + load: vi.fn(async () => null), + loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })), + loadMany: vi.fn(async () => []), + listObjects: vi.fn(async () => [objectDef]), + getObject: vi.fn(async (n: string) => schemaOf(n)), + }; + const services: Record = { objectql: ql, data: ql, metadata, audit }; + const resolve = (n: string) => services[n] ?? null; + const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } }; + const dispatcher = new HttpDispatcher(kernel); + + const ctx = (userId: string): any => ({ + request: { headers: { host: 'app.example.test' } }, + environmentId: 'platform', + executionContext: { userId, tenantId: 'org_1', systemPermissions: [] }, + }); + + /** REST `POST /actions/:object/:action/:id` — the console's click, the SDK's call. */ + const viaRest = async (userId = 'u_member') => { + const res: any = await dispatcher.handleActions(`/${OBJECT}/${CLOSE.name}/${RECORD}`, 'POST', {}, ctx(userId)); + return res.response; + }; + /** The real `/mcp` bridge's `runAction` — what the `run_action` tool calls. */ + const viaMcp = async (userId = 'u_member') => { + const bridge = (dispatcher as any).buildMcpBridge(ctx(userId)); + try { + return { value: await bridge.runAction(CLOSE.name, { objectName: OBJECT, recordId: RECORD }) }; + } catch (err: any) { + return { err }; + } + }; + return { events, row, writes, viaRest, viaMcp }; +} + +const DECLARATIVE_RESULT = { + operation: 'update', + object: OBJECT, + id: RECORD, + record: { id: RECORD, status: 'closed', owner: 'u_member' }, + undo: { type: 'update', objectName: OBJECT, recordId: RECORD, undoData: { status: 'open' }, redoData: { status: 'closed' } }, +}; + +let errorLog: ReturnType; +beforeAll(() => { errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); }); +afterAll(() => { errorLog.mockRestore(); }); + +describe('[#28856] a declarative update action whose write landed under a failing roll-up answers the action it is', () => { + it('REST action door: 200 with the declarative result, the update applied once, audit row succeeded', async () => { + const rig = makeRig(); + const res = await rig.viaRest(); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toEqual(DECLARATIVE_RESULT); + expect(rig.writes).toEqual([`update:${RECORD}`]); + expect(rig.row.status).toBe('closed'); + expect(rig.events).toHaveLength(1); + expect(rig.events[0]).toMatchObject({ actionName: CLOSE.name, outcome: 'succeeded' }); + expect(rig.events[0]).not.toHaveProperty('status'); + expect(rig.events[0]).not.toHaveProperty('errorCode'); + }); + + it('MCP bridge runAction: the refusal carries the run_action answer, the update applied once, audit row succeeded', async () => { + const rig = makeRig(); + const out = await rig.viaMcp(); + expect(out.value).toBeUndefined(); + expect(earnedAnswerOf(out.err)).toEqual({ ok: true, action: CLOSE.name, objectName: OBJECT, recordId: RECORD, result: DECLARATIVE_RESULT }); + expect(rig.writes).toEqual([`update:${RECORD}`]); + expect(rig.events).toHaveLength(1); + expect(rig.events[0]).toMatchObject({ actionName: CLOSE.name, outcome: 'succeeded' }); + expect(rig.events[0]).not.toHaveProperty('errorCode'); + }); + + it('a refusal before the write answers exactly as before at both doors, nothing written, audit row failed', async () => { + const rig = makeRig(); + const res = await rig.viaRest('u_reader'); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('PERMISSION_DENIED'); + const out = await rig.viaMcp('u_reader'); + expect(out.err?.code).toBe('PERMISSION_DENIED'); + expect(earnedAnswerOf(out.err)).toBeUndefined(); + expect(rig.writes).toEqual([]); + expect(rig.row.status).toBe('open'); + expect(rig.events.map((e) => e.outcome)).toEqual(['failed', 'failed']); + }); + + it('the stale column is logged once, at error', () => { + const lines = errorLog.mock.calls.map((c: unknown[]) => String(c[0])).filter((l: string) => l.includes('could not be recomputed')); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('"stl_account.open_cases"'); + }); +}); diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index f1f67521ba8..ca5ef1c5104 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -89,7 +89,7 @@ import { resolveActionHandlerKeys, standaloneActionOwnerKey, } from '@objectstack/objectql'; -import { staleSummaryRefusalOrThrow, withEarnedAnswer, type SummaryRecomputeRefusal } from '@objectstack/types'; +import { earnedAnswerOf, staleSummaryRefusalOrThrow, withEarnedAnswer, type SummaryRecomputeRefusal } from '@objectstack/types'; // [ADR-0110] The addressing vocabulary and the D5 reconciliation moved to // @objectstack/objectql (the engine owns the map they describe, and its @@ -3724,7 +3724,20 @@ export async function executeDeclarativeUpdateAction( // ⛔ `ec`, never `buildActionExecutionContext(ec)`. See the docblock — this // single argument is the difference between a user edit and a privilege // escalation. - const written = await callData('update', { object: objectName, id: recordId, data }, driver, envId, ec); + // [#28856] An update the engine stored but whose parent roll-up it could + // not recompute (`ERR_SUMMARY_RECOMPUTE`) is still this action's write: + // the result below is composed from the answer `callData`'s producer + // attached to the refusal, exactly as from a normal answer, and attached + // in turn so each action door answers the action's success. Every other + // throw, a refusal before the write among them, propagates as is. + let stale: SummaryRecomputeRefusal | undefined; + let written: unknown; + try { + written = await callData('update', { object: objectName, id: recordId, data }, driver, envId, ec); + } catch (e) { + stale = staleSummaryRefusalOrThrow(e); + written = earnedAnswerOf(stale); + } const prior: Record = subject.record ?? {}; // The result carries the caller's merged patch (`record`'s fallback, @@ -3768,13 +3781,15 @@ export async function executeDeclarativeUpdateAction( }; } - return { + const result: DeclarativeUpdateResult = { operation: DECLARATIVE_UPDATE_OPERATION, object: objectName, id: recordId, record, ...(undo ? { undo } : {}), }; + if (stale) throw withEarnedAnswer(stale, { ...result }); + return result; } /** @@ -3956,9 +3971,11 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps, action, objectName, recordId, params, subject, ec, driver, envId, callData, }); } catch (err) { - await recordActionInvocation(deps, requestContext, envId, { - ...invocation, outcome: 'failed', ...actionInvocationFailure(err), - }); + // [#28856] A refusal carrying the action's answer is answered as the + // success it is by the `run_action` tool, so the row records that. + await recordActionInvocation(deps, requestContext, envId, earnedAnswerOf(err) + ? { ...invocation, outcome: 'succeeded' } + : { ...invocation, outcome: 'failed', ...actionInvocationFailure(err) }); throw err; } await recordActionInvocation(deps, requestContext, envId, { ...invocation, outcome: 'succeeded' }); @@ -3997,10 +4014,20 @@ async function dispatchAdmittedBusinessAction(deps: ActionExecutionDeps, // cannot disagree about the identity the write carries — the failure class // commit f19475c0a and #15168 each paid for once, on this exact seam. if (isDeclarativeUpdateAction(action)) { - const result = await executeDeclarativeUpdateAction(deps, action, { - objectName, actionName: name, subject, recordId, params, ec, driver, envId, callData, requestContext, - }); - return { ok: true, action: action.name, objectName, ...(recordId ? { recordId } : {}), result }; + const answered = (result: unknown) => ({ ok: true, action: action.name, objectName, ...(recordId ? { recordId } : {}), result }); + let result: DeclarativeUpdateResult; + try { + result = await executeDeclarativeUpdateAction(deps, action, { + objectName, actionName: name, subject, recordId, params, ec, driver, envId, callData, requestContext, + }); + } catch (e) { + // [#28856] The write landed under a failing roll-up: the action's + // own answer rides the refusal for the `run_action` tool to relay. + const earned = earnedAnswerOf(e); + if (!earned) throw e; + throw withEarnedAnswer(e as SummaryRecomputeRefusal, answered(earned)); + } + return answered(result); } // ── contract point 3: the caller-scope load's VERDICT, consumed ────────── diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index a762e11688a..292d9e8f32f 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -65,7 +65,7 @@ import { // generic replacement text — the same two `@objectstack/rest` composes into // `isScriptFaultMessage` / `UNCLASSIFIED_FAULT`, so this door's crash reading // cannot drift from the `/data` door's. ⛔ Never re-inline either. -import { isNativeErrorName, INTERNAL_ERROR_MESSAGE, sandboxBusinessMessage, driverNotNullRefusalAnswer } from '@objectstack/types'; +import { isNativeErrorName, INTERNAL_ERROR_MESSAGE, sandboxBusinessMessage, driverNotNullRefusalAnswer, earnedAnswerOf, staleSummaryAnswerLines, type SummaryRecomputeRefusal } from '@objectstack/types'; // [ADR-0029 D9.6] The ONE "does a code package ship this?" reading, which the // activation write below asks to name the ledger row's package. import { isCodeArtifactBody, isTenantAuthored } from '@objectstack/metadata-core'; @@ -1068,6 +1068,17 @@ async function dispatchAdmittedRestAction( // carry a status now, so the extra layer lost its job. return { handled: true, response: deps.success(result) }; } catch (err: any) { + // [#28856] An action whose write landed under a failing roll-up answers + // the result the runtime composed and attached (`executeDeclarativeUpdateAction`), + // as when the recompute succeeds; the stale column is logged once. + const earned = earnedAnswerOf(err); + if (earned) { + const log = deps.logger && typeof deps.logger.error === 'function' + ? (line: string) => deps.logger.error(`[HttpDispatcher] ${line}`) + : (line: string) => console.error(`[HttpDispatcher] ${line}`); + for (const line of staleSummaryAnswerLines(err as SummaryRecomputeRefusal, objectName)) log(line); + return { handled: true, response: deps.success(earned) }; + } const full = err?.message ?? String(err); // The sandbox wraps a user throw as ` '' threw: ` for // server logs; surface only the business `` to the client so an diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 68632249ff5..1e2dca872ff 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -4551,6 +4551,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/runtime/src/action-declarative-update-stale-summary.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/runtime/src/action-declarative-update.test.ts", "verb": "update",