@stigmer/server 3.37.0 → 3.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +3 -0
- package/dist/boot/compose.js.map +1 -1
- package/dist/domain/agentexecution/artifacts.d.ts.map +1 -1
- package/dist/domain/agentexecution/artifacts.js +15 -6
- package/dist/domain/agentexecution/artifacts.js.map +1 -1
- package/dist/domain/agentexecution/lifecycle.d.ts.map +1 -1
- package/dist/domain/agentexecution/lifecycle.js +5 -3
- package/dist/domain/agentexecution/lifecycle.js.map +1 -1
- package/dist/domain/agentexecution/platform-rows.d.ts +63 -0
- package/dist/domain/agentexecution/platform-rows.d.ts.map +1 -0
- package/dist/domain/agentexecution/platform-rows.js +106 -0
- package/dist/domain/agentexecution/platform-rows.js.map +1 -0
- package/dist/domain/agentexecution/subscribe.d.ts.map +1 -1
- package/dist/domain/agentexecution/subscribe.js +7 -4
- package/dist/domain/agentexecution/subscribe.js.map +1 -1
- package/dist/domain/agentexecution/update-status.d.ts.map +1 -1
- package/dist/domain/agentexecution/update-status.js +48 -17
- package/dist/domain/agentexecution/update-status.js.map +1 -1
- package/dist/domain/iampolicy/grant-path.d.ts +7 -3
- package/dist/domain/iampolicy/grant-path.d.ts.map +1 -1
- package/dist/domain/iampolicy/grant-path.js +10 -2
- package/dist/domain/iampolicy/grant-path.js.map +1 -1
- package/dist/domain/organization/controller.d.ts +4 -1
- package/dist/domain/organization/controller.d.ts.map +1 -1
- package/dist/domain/organization/controller.js +28 -4
- package/dist/domain/organization/controller.js.map +1 -1
- package/dist/domain/organization/steps.d.ts +22 -0
- package/dist/domain/organization/steps.d.ts.map +1 -1
- package/dist/domain/organization/steps.js +50 -2
- package/dist/domain/organization/steps.js.map +1 -1
- package/dist/domain/workflowexecution/create-steps.d.ts.map +1 -1
- package/dist/domain/workflowexecution/create-steps.js +6 -2
- package/dist/domain/workflowexecution/create-steps.js.map +1 -1
- package/dist/domain/workflowexecution/lifecycle.d.ts.map +1 -1
- package/dist/domain/workflowexecution/lifecycle.js +5 -2
- package/dist/domain/workflowexecution/lifecycle.js.map +1 -1
- package/dist/domain/workflowexecution/send-signal.d.ts.map +1 -1
- package/dist/domain/workflowexecution/send-signal.js +6 -3
- package/dist/domain/workflowexecution/send-signal.js.map +1 -1
- package/dist/domain/workflowexecution/submit-approval.d.ts.map +1 -1
- package/dist/domain/workflowexecution/submit-approval.js +6 -2
- package/dist/domain/workflowexecution/submit-approval.js.map +1 -1
- package/dist/domain/workflowexecution/submit-file-decision.d.ts.map +1 -1
- package/dist/domain/workflowexecution/submit-file-decision.js +6 -2
- package/dist/domain/workflowexecution/submit-file-decision.js.map +1 -1
- package/dist/domain/workflowexecution/submit-workflow-task-approval.d.ts.map +1 -1
- package/dist/domain/workflowexecution/submit-workflow-task-approval.js +6 -2
- package/dist/domain/workflowexecution/submit-workflow-task-approval.js.map +1 -1
- package/dist/domain/workflowexecution/subscribe-events.d.ts.map +1 -1
- package/dist/domain/workflowexecution/subscribe-events.js +6 -2
- package/dist/domain/workflowexecution/subscribe-events.js.map +1 -1
- package/dist/domain/workflowexecution/subscribe.d.ts.map +1 -1
- package/dist/domain/workflowexecution/subscribe.js +7 -4
- package/dist/domain/workflowexecution/subscribe.js.map +1 -1
- package/dist/extensions/gate-slots.d.ts +14 -1
- package/dist/extensions/gate-slots.d.ts.map +1 -1
- package/dist/extensions/gate-slots.js +1 -0
- package/dist/extensions/gate-slots.js.map +1 -1
- package/dist/pipeline/steps/authorization-tuples.d.ts +6 -2
- package/dist/pipeline/steps/authorization-tuples.d.ts.map +1 -1
- package/dist/pipeline/steps/authorization-tuples.js +6 -2
- package/dist/pipeline/steps/authorization-tuples.js.map +1 -1
- package/dist/temporal/agentexecution/runner-result.d.ts +12 -0
- package/dist/temporal/agentexecution/runner-result.d.ts.map +1 -1
- package/dist/temporal/agentexecution/runner-result.js +15 -0
- package/dist/temporal/agentexecution/runner-result.js.map +1 -1
- package/dist/temporal/agentexecution/workflows/invoke-agent-execution.d.ts.map +1 -1
- package/dist/temporal/agentexecution/workflows/invoke-agent-execution.js +44 -22
- package/dist/temporal/agentexecution/workflows/invoke-agent-execution.js.map +1 -1
- package/package.json +6 -6
- package/src/boot/compose.ts +3 -0
- package/src/domain/agentexecution/__tests__/store-faults.test.ts +315 -0
- package/src/domain/agentexecution/__tests__/update-status.test.ts +217 -0
- package/src/domain/agentexecution/artifacts.ts +21 -12
- package/src/domain/agentexecution/lifecycle.ts +5 -3
- package/src/domain/agentexecution/platform-rows.ts +133 -0
- package/src/domain/agentexecution/subscribe.ts +7 -3
- package/src/domain/agentexecution/update-status.ts +70 -19
- package/src/domain/iampolicy/__tests__/grant-path.test.ts +40 -7
- package/src/domain/iampolicy/grant-path.ts +19 -5
- package/src/domain/organization/__tests__/organization-delete.test.ts +266 -0
- package/src/domain/organization/controller.ts +40 -8
- package/src/domain/organization/steps.ts +66 -2
- package/src/domain/workflowexecution/__tests__/store-faults.test.ts +418 -0
- package/src/domain/workflowexecution/create-steps.ts +6 -2
- package/src/domain/workflowexecution/lifecycle.ts +5 -2
- package/src/domain/workflowexecution/send-signal.ts +11 -4
- package/src/domain/workflowexecution/submit-approval.ts +6 -2
- package/src/domain/workflowexecution/submit-file-decision.ts +6 -2
- package/src/domain/workflowexecution/submit-workflow-task-approval.ts +6 -2
- package/src/domain/workflowexecution/subscribe-events.ts +6 -2
- package/src/domain/workflowexecution/subscribe.ts +11 -4
- package/src/extensions/__tests__/registry.test.ts +3 -0
- package/src/extensions/gate-slots.ts +14 -0
- package/src/pipeline/__tests__/blind-not-found.test.ts +297 -0
- package/src/pipeline/steps/authorization-tuples.ts +6 -2
- package/src/temporal/agentexecution/__tests__/invoke-workflow.test.ts +51 -1
- package/src/temporal/agentexecution/__tests__/own-behalf-status-writes.test.ts +149 -9
- package/src/temporal/agentexecution/__tests__/replay-histories/runner-failed.json +757 -0
- package/src/temporal/agentexecution/__tests__/replay-histories/user-cancel.json +755 -0
- package/src/temporal/agentexecution/__tests__/replay.test.ts +4 -2
- package/src/temporal/agentexecution/runner-result.ts +16 -0
- package/src/temporal/agentexecution/workflows/invoke-agent-execution.ts +49 -22
- package/src/domain/agentexecution/__tests__/execution-usage-report.test.ts +0 -78
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the store-fault contract every agent-execution load shares: a typed
|
|
3
|
+
* ResourceNotFoundError answers NotFound with the surface's own pinned copy,
|
|
4
|
+
* and any other store failure is an infrastructure fault answered as a
|
|
5
|
+
* sanitized Internal (`failed to load agent execution`), never a NotFound
|
|
6
|
+
* that tells a client the execution does not exist (stigmer/stigmer#859,
|
|
7
|
+
* stigmer/stigmer#1337).
|
|
8
|
+
*
|
|
9
|
+
* The surfaces: the five lifecycle verbs (their shared LoadExecutionById
|
|
10
|
+
* step), subscribe's snapshot read, the two artifact reads' existence
|
|
11
|
+
* checks, and the usage report's LoadExecution. The composed suites reach
|
|
12
|
+
* only a real store, which cannot fail selectively, so each surface runs
|
|
13
|
+
* here against a store whose one read throws. Every other dependency is
|
|
14
|
+
* untouchable: a load that fails must stop the call before any of them.
|
|
15
|
+
*
|
|
16
|
+
* Out of scope: the NotFound copy itself (wire contract, pinned where each
|
|
17
|
+
* surface is otherwise tested) and loads outside this domain.
|
|
18
|
+
*/
|
|
19
|
+
import { create } from "@bufbuild/protobuf";
|
|
20
|
+
import type { HandlerContext } from "@connectrpc/connect";
|
|
21
|
+
import { Code, ConnectError, createContextValues } from "@connectrpc/connect";
|
|
22
|
+
import { describe, expect, it } from "vitest";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
AgentExecutionIdSchema,
|
|
26
|
+
CancelAgentExecutionInputSchema,
|
|
27
|
+
GetArtifactContentRequestSchema,
|
|
28
|
+
GetArtifactDownloadUrlRequestSchema,
|
|
29
|
+
PauseAgentExecutionInputSchema,
|
|
30
|
+
RecoverAgentExecutionInputSchema,
|
|
31
|
+
ResumeAgentExecutionInputSchema,
|
|
32
|
+
TerminateAgentExecutionInputSchema,
|
|
33
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/io_pb";
|
|
34
|
+
import { AgentExecutionQueryController } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/query_pb";
|
|
35
|
+
|
|
36
|
+
import type { ArtifactStorage } from "../../../artifactstorage/artifact-storage.js";
|
|
37
|
+
import { createLogger } from "../../../boot/logger.js";
|
|
38
|
+
import { callerIdentityKey } from "../../../pipeline/interceptors/auth.js";
|
|
39
|
+
import { testCallerIdentity } from "../../../pipeline/__tests__/support.js";
|
|
40
|
+
import { newPermissiveSingleTeamAuthorizer } from "../../../pipeline/steps/authorize.js";
|
|
41
|
+
import { ResourceNotFoundError } from "../../../store/interface.js";
|
|
42
|
+
import type { Store } from "../../../store/interface.js";
|
|
43
|
+
|
|
44
|
+
import { getArtifactContent, getArtifactDownloadUrl } from "../artifacts.js";
|
|
45
|
+
import type { LifecycleDeps } from "../lifecycle.js";
|
|
46
|
+
import {
|
|
47
|
+
cancelExecution,
|
|
48
|
+
pauseExecution,
|
|
49
|
+
recoverExecution,
|
|
50
|
+
resumeExecution,
|
|
51
|
+
terminateExecution,
|
|
52
|
+
} from "../lifecycle.js";
|
|
53
|
+
import { StreamBroker } from "../stream-broker.js";
|
|
54
|
+
import { subscribeExecution } from "../subscribe.js";
|
|
55
|
+
import { getExecutionUsageReport } from "../usage.js";
|
|
56
|
+
|
|
57
|
+
const silentLogger = createLogger({
|
|
58
|
+
level: "error",
|
|
59
|
+
pretty: false,
|
|
60
|
+
write: () => {},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const EXECUTION_ID = "aex_storefault";
|
|
64
|
+
|
|
65
|
+
const LOAD_FAULT_COPY = "failed to load agent execution";
|
|
66
|
+
|
|
67
|
+
/** A dependency the call must never reach once its load has failed. */
|
|
68
|
+
function untouchable<T extends object>(name: string): T {
|
|
69
|
+
return new Proxy({} as T, {
|
|
70
|
+
get(_target, prop) {
|
|
71
|
+
throw new Error(`${name}.${String(prop)} reached after a failed load`);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A store whose every read fails with the given error. */
|
|
77
|
+
function failingStore(error: Error): Store {
|
|
78
|
+
return {
|
|
79
|
+
getResource: () => Promise.reject(error),
|
|
80
|
+
} as unknown as Store;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const MISSING = (): Error =>
|
|
84
|
+
new ResourceNotFoundError(`agent_execution/${EXECUTION_ID}`);
|
|
85
|
+
const LOCKED = (): Error => new Error("SQLITE_BUSY: database is locked");
|
|
86
|
+
|
|
87
|
+
async function errorOf(call: () => Promise<unknown>): Promise<ConnectError> {
|
|
88
|
+
const error = await call().then(
|
|
89
|
+
() => undefined,
|
|
90
|
+
(e: unknown) => e,
|
|
91
|
+
);
|
|
92
|
+
expect(error, "the call must fail").toBeInstanceOf(ConnectError);
|
|
93
|
+
return error as ConnectError;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function lifecycleDeps(store: Store): LifecycleDeps {
|
|
97
|
+
return {
|
|
98
|
+
store,
|
|
99
|
+
logger: silentLogger,
|
|
100
|
+
authorizer: newPermissiveSingleTeamAuthorizer(),
|
|
101
|
+
broker: untouchable("broker"),
|
|
102
|
+
engineState: untouchable("engineState"),
|
|
103
|
+
executionContextBuilder: untouchable("executionContextBuilder"),
|
|
104
|
+
gateSteps: new Map(),
|
|
105
|
+
statusObservers: [],
|
|
106
|
+
sandboxLane: untouchable("sandboxLane"),
|
|
107
|
+
temporalConfig: untouchable("temporalConfig"),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const LIFECYCLE_VERBS: ReadonlyArray<
|
|
112
|
+
readonly [string, (deps: LifecycleDeps) => Promise<unknown>]
|
|
113
|
+
> = [
|
|
114
|
+
[
|
|
115
|
+
"cancel",
|
|
116
|
+
(deps) =>
|
|
117
|
+
cancelExecution(
|
|
118
|
+
deps,
|
|
119
|
+
create(CancelAgentExecutionInputSchema, { id: EXECUTION_ID }),
|
|
120
|
+
testCallerIdentity(),
|
|
121
|
+
),
|
|
122
|
+
],
|
|
123
|
+
[
|
|
124
|
+
"terminate",
|
|
125
|
+
(deps) =>
|
|
126
|
+
terminateExecution(
|
|
127
|
+
deps,
|
|
128
|
+
create(TerminateAgentExecutionInputSchema, { id: EXECUTION_ID }),
|
|
129
|
+
testCallerIdentity(),
|
|
130
|
+
),
|
|
131
|
+
],
|
|
132
|
+
[
|
|
133
|
+
"pause",
|
|
134
|
+
(deps) =>
|
|
135
|
+
pauseExecution(
|
|
136
|
+
deps,
|
|
137
|
+
create(PauseAgentExecutionInputSchema, { id: EXECUTION_ID }),
|
|
138
|
+
testCallerIdentity(),
|
|
139
|
+
),
|
|
140
|
+
],
|
|
141
|
+
[
|
|
142
|
+
"resume",
|
|
143
|
+
(deps) =>
|
|
144
|
+
resumeExecution(
|
|
145
|
+
deps,
|
|
146
|
+
create(ResumeAgentExecutionInputSchema, { id: EXECUTION_ID }),
|
|
147
|
+
testCallerIdentity(),
|
|
148
|
+
),
|
|
149
|
+
],
|
|
150
|
+
[
|
|
151
|
+
"recover",
|
|
152
|
+
(deps) =>
|
|
153
|
+
recoverExecution(
|
|
154
|
+
deps,
|
|
155
|
+
create(RecoverAgentExecutionInputSchema, { id: EXECUTION_ID }),
|
|
156
|
+
testCallerIdentity(),
|
|
157
|
+
),
|
|
158
|
+
],
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
describe.each(LIFECYCLE_VERBS)("%s — LoadExecutionById", (_verb, run) => {
|
|
162
|
+
it("a missing execution answers NotFound with the lifecycle copy", async () => {
|
|
163
|
+
const error = await errorOf(() =>
|
|
164
|
+
run(lifecycleDeps(failingStore(MISSING()))),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(error.code).toBe(Code.NotFound);
|
|
168
|
+
expect(error.rawMessage).toBe(`agent_execution not found: ${EXECUTION_ID}`);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("any other store failure answers a sanitized Internal", async () => {
|
|
172
|
+
const error = await errorOf(() =>
|
|
173
|
+
run(lifecycleDeps(failingStore(LOCKED()))),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
expect(error.code).toBe(Code.Internal);
|
|
177
|
+
expect(error.rawMessage).toBe(LOAD_FAULT_COPY);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("subscribe — the snapshot read", () => {
|
|
182
|
+
function handlerContext(): HandlerContext {
|
|
183
|
+
const values = createContextValues();
|
|
184
|
+
values.set(callerIdentityKey, testCallerIdentity());
|
|
185
|
+
return { signal: new AbortController().signal, values } as HandlerContext;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function subscribeError(
|
|
189
|
+
store: Store,
|
|
190
|
+
broker: StreamBroker,
|
|
191
|
+
): Promise<ConnectError> {
|
|
192
|
+
return errorOf(() =>
|
|
193
|
+
subscribeExecution(
|
|
194
|
+
{
|
|
195
|
+
store,
|
|
196
|
+
logger: silentLogger,
|
|
197
|
+
broker,
|
|
198
|
+
authorizer: newPermissiveSingleTeamAuthorizer(),
|
|
199
|
+
},
|
|
200
|
+
create(AgentExecutionIdSchema, { value: EXECUTION_ID }),
|
|
201
|
+
handlerContext(),
|
|
202
|
+
).next(),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
it("a missing execution answers NotFound with the subscribe copy", async () => {
|
|
207
|
+
const broker = new StreamBroker(silentLogger);
|
|
208
|
+
const error = await subscribeError(failingStore(MISSING()), broker);
|
|
209
|
+
|
|
210
|
+
expect(error.code).toBe(Code.NotFound);
|
|
211
|
+
expect(error.rawMessage).toBe(`AgentExecution not found: ${EXECUTION_ID}`);
|
|
212
|
+
expect(broker.getSubscriberCount(EXECUTION_ID)).toBe(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("any other store failure answers a sanitized Internal and releases the subscription", async () => {
|
|
216
|
+
const broker = new StreamBroker(silentLogger);
|
|
217
|
+
const error = await subscribeError(failingStore(LOCKED()), broker);
|
|
218
|
+
|
|
219
|
+
expect(error.code).toBe(Code.Internal);
|
|
220
|
+
expect(error.rawMessage).toBe(LOAD_FAULT_COPY);
|
|
221
|
+
expect(broker.getSubscriberCount(EXECUTION_ID)).toBe(0);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("artifact reads — the existence check", () => {
|
|
226
|
+
function artifactDeps(store: Store) {
|
|
227
|
+
return {
|
|
228
|
+
store,
|
|
229
|
+
logger: silentLogger,
|
|
230
|
+
artifactStorage: untouchable<ArtifactStorage>("artifactStorage"),
|
|
231
|
+
authorizer: newPermissiveSingleTeamAuthorizer(),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// getArtifactContent checks the key's prefix before its load, so the key
|
|
236
|
+
// sits under the execution; getArtifactDownloadUrl loads first.
|
|
237
|
+
const ARTIFACT_READS: ReadonlyArray<
|
|
238
|
+
readonly [string, (store: Store) => Promise<unknown>]
|
|
239
|
+
> = [
|
|
240
|
+
[
|
|
241
|
+
"getArtifactContent",
|
|
242
|
+
(store) =>
|
|
243
|
+
getArtifactContent(
|
|
244
|
+
artifactDeps(store),
|
|
245
|
+
create(GetArtifactContentRequestSchema, {
|
|
246
|
+
executionId: EXECUTION_ID,
|
|
247
|
+
storageKey: `artifacts/${EXECUTION_ID}/out.txt`,
|
|
248
|
+
}),
|
|
249
|
+
testCallerIdentity(),
|
|
250
|
+
),
|
|
251
|
+
],
|
|
252
|
+
[
|
|
253
|
+
"getArtifactDownloadUrl",
|
|
254
|
+
(store) =>
|
|
255
|
+
getArtifactDownloadUrl(
|
|
256
|
+
artifactDeps(store),
|
|
257
|
+
create(GetArtifactDownloadUrlRequestSchema, {
|
|
258
|
+
executionId: EXECUTION_ID,
|
|
259
|
+
storageKey: `artifacts/${EXECUTION_ID}/out.txt`,
|
|
260
|
+
}),
|
|
261
|
+
testCallerIdentity(),
|
|
262
|
+
),
|
|
263
|
+
],
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
describe.each(ARTIFACT_READS)("%s", (_read, run) => {
|
|
267
|
+
it("a missing execution answers NotFound with the artifact copy", async () => {
|
|
268
|
+
const error = await errorOf(() => run(failingStore(MISSING())));
|
|
269
|
+
|
|
270
|
+
expect(error.code).toBe(Code.NotFound);
|
|
271
|
+
expect(error.rawMessage).toBe(`execution not found: ${EXECUTION_ID}`);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("any other store failure answers a sanitized Internal", async () => {
|
|
275
|
+
const error = await errorOf(() => run(failingStore(LOCKED())));
|
|
276
|
+
|
|
277
|
+
expect(error.code).toBe(Code.Internal);
|
|
278
|
+
expect(error.rawMessage).toBe(LOAD_FAULT_COPY);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("getExecutionUsageReport — LoadExecution", () => {
|
|
284
|
+
function reportError(store: Store): Promise<ConnectError> {
|
|
285
|
+
return errorOf(() =>
|
|
286
|
+
getExecutionUsageReport(
|
|
287
|
+
{
|
|
288
|
+
store,
|
|
289
|
+
logger: silentLogger,
|
|
290
|
+
authorizer: newPermissiveSingleTeamAuthorizer(),
|
|
291
|
+
listReadScope: undefined,
|
|
292
|
+
},
|
|
293
|
+
create(
|
|
294
|
+
AgentExecutionQueryController.method.getExecutionUsageReport.input,
|
|
295
|
+
{ executionId: EXECUTION_ID },
|
|
296
|
+
),
|
|
297
|
+
testCallerIdentity(),
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
it("a missing execution answers NotFound with the domain's copy", async () => {
|
|
303
|
+
const error = await reportError(failingStore(MISSING()));
|
|
304
|
+
|
|
305
|
+
expect(error.code).toBe(Code.NotFound);
|
|
306
|
+
expect(error.rawMessage).toBe(`agent_execution not found: ${EXECUTION_ID}`);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("any other store failure answers a sanitized Internal", async () => {
|
|
310
|
+
const error = await reportError(failingStore(LOCKED()));
|
|
311
|
+
|
|
312
|
+
expect(error.code).toBe(Code.Internal);
|
|
313
|
+
expect(error.rawMessage).toBe(LOAD_FAULT_COPY);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* transcript regression guard (shrink + front-truncation-with-append +
|
|
6
6
|
* the terminal exemption), the equal-length approval finalize, and the
|
|
7
7
|
* presence-guarded runner-owned field pattern (recalled_memories_report).
|
|
8
|
+
* Also pins the platform-row rules (platform-rows.ts, stigmer#980): a
|
|
9
|
+
* platform marker write is appended to the transcript and never replaces
|
|
10
|
+
* it, idempotently, and a PAUSED or CANCELLED execution's stop row stays
|
|
11
|
+
* last across the runner's late writes, whatever order they land in.
|
|
8
12
|
* These exercise applyUpdateStatusMerge directly on a clone — mirroring
|
|
9
13
|
* the freshly-loaded resource the merge mutates in place inside the
|
|
10
14
|
* updateResource write lock.
|
|
@@ -31,6 +35,7 @@ import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexec
|
|
|
31
35
|
|
|
32
36
|
import { createLogger } from "../../../boot/logger.js";
|
|
33
37
|
import { findToolCallInExecution } from "../submit-approval.js";
|
|
38
|
+
import { CANCEL_STOP_ROW, PAUSE_STOP_ROW } from "../platform-rows.js";
|
|
34
39
|
import { applyUpdateStatusMerge } from "../update-status.js";
|
|
35
40
|
|
|
36
41
|
const silentLogger = createLogger({
|
|
@@ -367,3 +372,215 @@ describe("the presence-guarded runner-owned field pattern (recalled_memories_rep
|
|
|
367
372
|
expect(merged.status?.recalledMemoriesReport).toBeUndefined();
|
|
368
373
|
});
|
|
369
374
|
});
|
|
375
|
+
|
|
376
|
+
describe("platform rows (stigmer#980)", () => {
|
|
377
|
+
// The runner's transcript: AI and tool turns, then (for a failure it
|
|
378
|
+
// reports itself) its own system rows.
|
|
379
|
+
function runnerTranscript(...contents: string[]) {
|
|
380
|
+
return contents.map((content) => ({ type: MessageType.MESSAGE_AI, content }));
|
|
381
|
+
}
|
|
382
|
+
function system(...contents: string[]) {
|
|
383
|
+
return contents.map((content) => ({ type: MessageType.MESSAGE_SYSTEM, content }));
|
|
384
|
+
}
|
|
385
|
+
function executionAt(
|
|
386
|
+
phase: ExecutionPhase,
|
|
387
|
+
msgs: MessageInitShape<typeof AgentMessageSchema>[],
|
|
388
|
+
): AgentExecution {
|
|
389
|
+
return create(AgentExecutionSchema, {
|
|
390
|
+
metadata: { id: "exec-rows", name: "exec-rows" },
|
|
391
|
+
spec: {},
|
|
392
|
+
status: { phase, messages: msgs },
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
function contents(execution: AgentExecution): string[] {
|
|
396
|
+
return (execution.status?.messages ?? []).map((m) => m.content);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
describe("a platform marker write is appended, never substituted", () => {
|
|
400
|
+
it("appends the cancel row to a transcript already persisted CANCELLED", () => {
|
|
401
|
+
// The Cancel RPC persisted CANCELLED first, then the workflow's
|
|
402
|
+
// cancellation cleanup lands its row: today it replaced the whole
|
|
403
|
+
// transcript with that one line.
|
|
404
|
+
const merged = runBuildStep(
|
|
405
|
+
executionAt(ExecutionPhase.EXECUTION_CANCELLED, runnerTranscript("a1", "a2")),
|
|
406
|
+
{ phase: ExecutionPhase.EXECUTION_CANCELLED, messages: system(CANCEL_STOP_ROW) },
|
|
407
|
+
);
|
|
408
|
+
expect(contents(merged)).toEqual(["a1", "a2", CANCEL_STOP_ROW]);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("appends the broken-flow failure lines to a FAILED transcript", () => {
|
|
412
|
+
const merged = runBuildStep(
|
|
413
|
+
executionAt(ExecutionPhase.EXECUTION_FAILED, runnerTranscript("a1", "a2", "a3")),
|
|
414
|
+
{
|
|
415
|
+
phase: ExecutionPhase.EXECUTION_FAILED,
|
|
416
|
+
error: "boom",
|
|
417
|
+
messages: system("Internal failure.", "Error details: boom"),
|
|
418
|
+
},
|
|
419
|
+
);
|
|
420
|
+
expect(contents(merged)).toEqual([
|
|
421
|
+
"a1",
|
|
422
|
+
"a2",
|
|
423
|
+
"a3",
|
|
424
|
+
"Internal failure.",
|
|
425
|
+
"Error details: boom",
|
|
426
|
+
]);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("appends a marker landing before the phase is terminal instead of dropping it to the shrink guard", () => {
|
|
430
|
+
// The runner died without persisting a terminal: the stored phase is
|
|
431
|
+
// still IN_PROGRESS, and the failure lines are shorter than the
|
|
432
|
+
// transcript. Today the shrink guard dropped them.
|
|
433
|
+
const merged = runBuildStep(
|
|
434
|
+
executionAt(ExecutionPhase.EXECUTION_IN_PROGRESS, runnerTranscript("a1", "a2", "a3")),
|
|
435
|
+
{
|
|
436
|
+
phase: ExecutionPhase.EXECUTION_FAILED,
|
|
437
|
+
messages: system("Internal failure.", "Error details: boom"),
|
|
438
|
+
},
|
|
439
|
+
);
|
|
440
|
+
expect(contents(merged)).toEqual([
|
|
441
|
+
"a1",
|
|
442
|
+
"a2",
|
|
443
|
+
"a3",
|
|
444
|
+
"Internal failure.",
|
|
445
|
+
"Error details: boom",
|
|
446
|
+
]);
|
|
447
|
+
expect(merged.status?.phase).toBe(ExecutionPhase.EXECUTION_FAILED);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("is idempotent: an activity retry of the same marker appends nothing", () => {
|
|
451
|
+
const merged = runBuildStep(
|
|
452
|
+
executionAt(ExecutionPhase.EXECUTION_FAILED, [
|
|
453
|
+
...runnerTranscript("a1"),
|
|
454
|
+
...system("Internal failure.", "Error details: boom"),
|
|
455
|
+
]),
|
|
456
|
+
{
|
|
457
|
+
phase: ExecutionPhase.EXECUTION_FAILED,
|
|
458
|
+
messages: system("Internal failure.", "Error details: boom"),
|
|
459
|
+
},
|
|
460
|
+
);
|
|
461
|
+
expect(contents(merged)).toEqual(["a1", "Internal failure.", "Error details: boom"]);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("keeps an all-system write that extends the stored transcript a replacement (the runner's own growth)", () => {
|
|
465
|
+
const merged = runBuildStep(
|
|
466
|
+
executionAt(ExecutionPhase.EXECUTION_IN_PROGRESS, system("s1")),
|
|
467
|
+
{ phase: ExecutionPhase.EXECUTION_IN_PROGRESS, messages: system("s1", "s2") },
|
|
468
|
+
);
|
|
469
|
+
expect(contents(merged)).toEqual(["s1", "s2"]);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("keeps a system write onto an empty transcript a replacement", () => {
|
|
473
|
+
const merged = runBuildStep(
|
|
474
|
+
executionAt(ExecutionPhase.EXECUTION_IN_PROGRESS, []),
|
|
475
|
+
{ phase: ExecutionPhase.EXECUTION_FAILED, messages: system("Internal failure.") },
|
|
476
|
+
);
|
|
477
|
+
expect(contents(merged)).toEqual(["Internal failure."]);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("keeps the runner's full terminal write a replacement (the terminal exemption is untouched)", () => {
|
|
481
|
+
const merged = runBuildStep(
|
|
482
|
+
executionAt(ExecutionPhase.EXECUTION_FAILED, runnerTranscript("a1", "a2")),
|
|
483
|
+
{
|
|
484
|
+
phase: ExecutionPhase.EXECUTION_FAILED,
|
|
485
|
+
messages: [...runnerTranscript("a1-final"), ...system("Execution failed: x")],
|
|
486
|
+
},
|
|
487
|
+
);
|
|
488
|
+
expect(contents(merged)).toEqual(["a1-final", "Execution failed: x"]);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
describe("a stop row stays last across the runner's late writes", () => {
|
|
493
|
+
it("re-appends the cancel row after a late runner write that extends the transcript", () => {
|
|
494
|
+
// Cancel's three writers race: the RPC's CANCELLED persist and the
|
|
495
|
+
// workflow's row landed; the runner's settle write (it learns of the
|
|
496
|
+
// cancel on its next heartbeat) lands last, carrying no row.
|
|
497
|
+
const merged = runBuildStep(
|
|
498
|
+
executionAt(ExecutionPhase.EXECUTION_CANCELLED, [
|
|
499
|
+
...runnerTranscript("a1"),
|
|
500
|
+
...system(CANCEL_STOP_ROW),
|
|
501
|
+
]),
|
|
502
|
+
{ messages: runnerTranscript("a1", "a2") },
|
|
503
|
+
);
|
|
504
|
+
expect(contents(merged)).toEqual(["a1", "a2", CANCEL_STOP_ROW]);
|
|
505
|
+
expect(merged.status?.phase).toBe(ExecutionPhase.EXECUTION_CANCELLED);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("keeps the pause row last when the runner's settle write lands after it", () => {
|
|
509
|
+
const merged = runBuildStep(
|
|
510
|
+
executionAt(ExecutionPhase.EXECUTION_PAUSED, [
|
|
511
|
+
...runnerTranscript("a1"),
|
|
512
|
+
...system(PAUSE_STOP_ROW),
|
|
513
|
+
]),
|
|
514
|
+
{ messages: runnerTranscript("a1") },
|
|
515
|
+
);
|
|
516
|
+
expect(
|
|
517
|
+
contents(merged),
|
|
518
|
+
"the settle write does not trip the shrink guard on the row, and does not drop it",
|
|
519
|
+
).toEqual(["a1", PAUSE_STOP_ROW]);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("merges a longer settle write under the pause row", () => {
|
|
523
|
+
const merged = runBuildStep(
|
|
524
|
+
executionAt(ExecutionPhase.EXECUTION_PAUSED, [
|
|
525
|
+
...runnerTranscript("a1"),
|
|
526
|
+
...system(PAUSE_STOP_ROW),
|
|
527
|
+
]),
|
|
528
|
+
{ messages: runnerTranscript("a1", "a2", "a3") },
|
|
529
|
+
);
|
|
530
|
+
expect(contents(merged)).toEqual(["a1", "a2", "a3", PAUSE_STOP_ROW]);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("still guards a paused transcript against a real shrink", () => {
|
|
534
|
+
const merged = runBuildStep(
|
|
535
|
+
executionAt(ExecutionPhase.EXECUTION_PAUSED, [
|
|
536
|
+
...runnerTranscript("a1", "a2"),
|
|
537
|
+
...system(PAUSE_STOP_ROW),
|
|
538
|
+
]),
|
|
539
|
+
{ messages: runnerTranscript("a1") },
|
|
540
|
+
);
|
|
541
|
+
expect(contents(merged)).toEqual(["a1", "a2", PAUSE_STOP_ROW]);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("does not duplicate the row when a retry of the stop marker lands on it", () => {
|
|
545
|
+
const merged = runBuildStep(
|
|
546
|
+
executionAt(ExecutionPhase.EXECUTION_CANCELLED, [
|
|
547
|
+
...runnerTranscript("a1"),
|
|
548
|
+
...system(CANCEL_STOP_ROW),
|
|
549
|
+
]),
|
|
550
|
+
{ phase: ExecutionPhase.EXECUTION_CANCELLED, messages: system(CANCEL_STOP_ROW) },
|
|
551
|
+
);
|
|
552
|
+
expect(contents(merged)).toEqual(["a1", CANCEL_STOP_ROW]);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("treats the pause row as ordinary history once a resume moved the phase on", () => {
|
|
556
|
+
// The resumed runner seeds the persisted transcript, row included,
|
|
557
|
+
// and grows it; nothing is held back or re-appended.
|
|
558
|
+
const merged = runBuildStep(
|
|
559
|
+
executionAt(ExecutionPhase.EXECUTION_IN_PROGRESS, [
|
|
560
|
+
...runnerTranscript("a1"),
|
|
561
|
+
...system(PAUSE_STOP_ROW),
|
|
562
|
+
]),
|
|
563
|
+
{
|
|
564
|
+
phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
|
|
565
|
+
messages: [
|
|
566
|
+
...runnerTranscript("a1"),
|
|
567
|
+
...system(PAUSE_STOP_ROW),
|
|
568
|
+
...runnerTranscript("a2"),
|
|
569
|
+
],
|
|
570
|
+
},
|
|
571
|
+
);
|
|
572
|
+
expect(contents(merged)).toEqual(["a1", PAUSE_STOP_ROW, "a2"]);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("never holds a row the phase does not own (a pause row on a CANCELLED execution is history)", () => {
|
|
576
|
+
const merged = runBuildStep(
|
|
577
|
+
executionAt(ExecutionPhase.EXECUTION_CANCELLED, [
|
|
578
|
+
...runnerTranscript("a1"),
|
|
579
|
+
...system(PAUSE_STOP_ROW),
|
|
580
|
+
]),
|
|
581
|
+
{ messages: [...runnerTranscript("a1"), ...system(PAUSE_STOP_ROW), ...runnerTranscript("a2")] },
|
|
582
|
+
);
|
|
583
|
+
expect(contents(merged)).toEqual(["a1", PAUSE_STOP_ROW, "a2"]);
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
});
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
invalidArgumentError,
|
|
58
58
|
} from "../../pipeline/errors.js";
|
|
59
59
|
import { authorizeDirect } from "../../pipeline/steps/authorize.js";
|
|
60
|
+
import { ResourceNotFoundError } from "../../store/interface.js";
|
|
60
61
|
import type { Store } from "../../store/interface.js";
|
|
61
62
|
import { casBlobContentMismatch } from "./filereview/cas-blob.js";
|
|
62
63
|
|
|
@@ -254,18 +255,22 @@ export async function getArtifactContent(
|
|
|
254
255
|
throw invalidArgumentError("storage_key does not belong to this execution");
|
|
255
256
|
}
|
|
256
257
|
|
|
257
|
-
// Existence check
|
|
258
|
+
// Existence check: only a missing execution is NotFound; any other store
|
|
259
|
+
// failure is an infrastructure fault.
|
|
258
260
|
try {
|
|
259
261
|
await deps.store.getResource(
|
|
260
262
|
ApiResourceKind.agent_execution,
|
|
261
263
|
req.executionId,
|
|
262
264
|
AgentExecutionSchema,
|
|
263
265
|
);
|
|
264
|
-
} catch {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (error instanceof ResourceNotFoundError) {
|
|
268
|
+
throw new ConnectError(
|
|
269
|
+
`execution not found: ${req.executionId}`,
|
|
270
|
+
Code.NotFound,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
throw internalError(error, "failed to load agent execution");
|
|
269
274
|
}
|
|
270
275
|
|
|
271
276
|
let maxBytes = Number(req.maxBytes);
|
|
@@ -424,7 +429,8 @@ export async function getArtifactDownloadUrl(
|
|
|
424
429
|
);
|
|
425
430
|
|
|
426
431
|
// Load BEFORE the key check: the attachment arm needs spec.attachments,
|
|
427
|
-
// and this doubles as the existence check
|
|
432
|
+
// and this doubles as the existence check (a missing execution is
|
|
433
|
+
// NotFound; any other store failure is an infrastructure fault).
|
|
428
434
|
let execution: AgentExecution;
|
|
429
435
|
try {
|
|
430
436
|
execution = await deps.store.getResource(
|
|
@@ -432,11 +438,14 @@ export async function getArtifactDownloadUrl(
|
|
|
432
438
|
req.executionId,
|
|
433
439
|
AgentExecutionSchema,
|
|
434
440
|
);
|
|
435
|
-
} catch {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (error instanceof ResourceNotFoundError) {
|
|
443
|
+
throw new ConnectError(
|
|
444
|
+
`execution not found: ${req.executionId}`,
|
|
445
|
+
Code.NotFound,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
throw internalError(error, "failed to load agent execution");
|
|
440
449
|
}
|
|
441
450
|
|
|
442
451
|
// Same normalize-before-check posture as getArtifactContent
|
|
@@ -135,9 +135,11 @@ function newLoadExecutionByIdStep<Desc extends DescMessage>(
|
|
|
135
135
|
executionId,
|
|
136
136
|
AgentExecutionSchema,
|
|
137
137
|
);
|
|
138
|
-
} catch {
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error instanceof ResourceNotFoundError) {
|
|
140
|
+
throw notFoundError("agent_execution", executionId);
|
|
141
|
+
}
|
|
142
|
+
throw internalError(error, "failed to load agent execution");
|
|
141
143
|
}
|
|
142
144
|
ctx.set(LOADED_EXECUTION_KEY, execution);
|
|
143
145
|
},
|