stageflow 0.19.0 → 0.25.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/README.md +16 -3
- package/dist/a2a/configDiscovery.d.ts +9 -0
- package/dist/a2a/configDiscovery.js +18 -0
- package/dist/a2a/contracts.d.ts +93 -0
- package/dist/a2a/contracts.js +163 -0
- package/dist/a2a/limits.d.ts +14 -0
- package/dist/a2a/limits.js +29 -0
- package/dist/a2a/registry.d.ts +44 -0
- package/dist/a2a/registry.js +159 -0
- package/dist/a2a/server.d.ts +29 -0
- package/dist/a2a/server.js +341 -0
- package/dist/a2a/service.d.ts +97 -0
- package/dist/a2a/service.js +496 -0
- package/dist/a2a/store.d.ts +106 -0
- package/dist/a2a/store.js +245 -0
- package/dist/agent/activity.d.ts +5 -0
- package/dist/agent/activityObserver.d.ts +3 -0
- package/dist/agent/activityObserver.js +6 -1
- package/dist/agent/piAdapter.js +7 -3
- package/dist/agent/port.d.ts +2 -0
- package/dist/agent/providerInspect.d.ts +19 -0
- package/dist/agent/providerInspect.js +35 -0
- package/dist/agent/streamLogRedact.d.ts +2 -0
- package/dist/agent/streamLogRedact.js +13 -0
- package/dist/cli/a2aCommand.d.ts +8 -0
- package/dist/cli/a2aCommand.js +131 -0
- package/dist/cli/artifactCommand.js +2 -2
- package/dist/cli/envelopeCommand.js +2 -2
- package/dist/cli/exportRunCommand.js +2 -2
- package/dist/cli/hostClient.d.ts +43 -0
- package/dist/cli/hostClient.js +182 -0
- package/dist/cli/providersCommand.js +5 -3
- package/dist/cli/runCommand.d.ts +6 -5
- package/dist/cli/runCommand.js +23 -31
- package/dist/cli/runOutput.d.ts +1 -1
- package/dist/cli/runStageCommand.d.ts +33 -0
- package/dist/cli/runStageCommand.js +407 -0
- package/dist/cli/runsCommand.d.ts +3 -7
- package/dist/cli/runsCommand.js +33 -94
- package/dist/cli.js +20 -5
- package/dist/config/describePipeline.d.ts +26 -0
- package/dist/config/describePipeline.js +37 -0
- package/dist/config/loadPipeline.d.ts +13 -1
- package/dist/config/loadPipeline.js +34 -4
- package/dist/config/mergePipelineIncludes.d.ts +11 -0
- package/dist/config/mergePipelineIncludes.js +75 -0
- package/dist/config/validateCatalog.d.ts +2 -2
- package/dist/config/validateCatalog.js +24 -17
- package/dist/mcp/catalogTools.js +285 -52
- package/dist/mcp/controlTools.js +77 -19
- package/dist/mcp/deps.d.ts +3 -0
- package/dist/mcp/projectMcpTools.d.ts +3 -0
- package/dist/mcp/projectMcpTools.js +25 -0
- package/dist/mcp/providerTools.d.ts +3 -0
- package/dist/mcp/providerTools.js +17 -0
- package/dist/mcp/readArtifact.d.ts +9 -0
- package/dist/mcp/readArtifact.js +11 -0
- package/dist/mcp/server.js +9 -1
- package/dist/mcp/tailStreamLog.d.ts +8 -0
- package/dist/mcp/tailStreamLog.js +28 -0
- package/dist/mcp/toolResults.d.ts +17 -0
- package/dist/mcp/toolResults.js +15 -0
- package/dist/mcp/tools.js +4 -0
- package/dist/mcp/waitRun.js +4 -4
- package/dist/mcp/waitingGates.d.ts +1 -0
- package/dist/mcp/waitingGates.js +3 -2
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/projection/projectRun.d.ts +3 -0
- package/dist/projection/projectRun.js +7 -0
- package/dist/runstore/createStore.d.ts +10 -0
- package/dist/runstore/createStore.js +13 -4
- package/dist/runstore/port.d.ts +17 -2
- package/dist/runstore/sqlite/SqliteRunStore.d.ts +11 -1
- package/dist/runstore/sqlite/SqliteRunStore.js +81 -26
- package/dist/runstore/sqlite/schema.d.ts +1 -1
- package/dist/runstore/sqlite/schema.js +6 -0
- package/dist/runstore/submission.d.ts +11 -0
- package/dist/runstore/submission.js +7 -0
- package/dist/runstore/workspaceLayout.d.ts +1 -0
- package/dist/runstore/workspaceLayout.js +3 -0
- package/dist/runtime/feedbackLoopCoordinator.d.ts +9 -1
- package/dist/runtime/feedbackLoopCoordinator.js +50 -20
- package/dist/runtime/feedbackLoopDecision.js +33 -1
- package/dist/runtime/pipelineRunner.d.ts +5 -3
- package/dist/runtime/pipelineRunner.js +6 -1
- package/dist/runtime/pipelineScheduler.js +32 -15
- package/dist/runtime/reloadRunCatalog.js +7 -1
- package/dist/runtime/resolveEnvelopeRef.d.ts +30 -0
- package/dist/runtime/resolveEnvelopeRef.js +79 -0
- package/dist/runtime/resumeReconstruct.js +2 -2
- package/dist/runtime/runManager.d.ts +24 -2
- package/dist/runtime/runManager.js +83 -19
- package/dist/runtime/settingsFile.d.ts +2 -0
- package/dist/runtime/settingsFile.js +19 -0
- package/dist/runtime/stageAttemptBootstrap.d.ts +3 -0
- package/dist/runtime/stageAttemptBootstrap.js +8 -2
- package/dist/runtime/stageStreamLog.d.ts +17 -0
- package/dist/runtime/stageStreamLog.js +89 -0
- package/dist/runtime/stageWorker.js +2 -1
- package/dist/server/bootstrap.d.ts +2 -0
- package/dist/server/bootstrap.js +22 -4
- package/dist/server/createHttpHost.js +6 -0
- package/dist/server/ensureGlobalService.d.ts +35 -0
- package/dist/server/ensureGlobalService.js +138 -0
- package/dist/server/http.d.ts +20 -1
- package/dist/server/http.js +450 -402
- package/dist/server/mcpHost.js +7 -8
- package/dist/server/operatorResults.d.ts +10 -0
- package/dist/server/operatorResults.js +24 -0
- package/dist/server/providerRoutes.d.ts +0 -6
- package/dist/server/providerRoutes.js +10 -18
- package/dist/types/pipeline.d.ts +8 -0
- package/dist/ui/assets/{index-DMVdl1qB.js → index-Da5EHU6M.js} +5 -5
- package/dist/ui/index.html +1 -1
- package/package.json +2 -1
- package/skills/stageflow/references/control-surface.md +2 -2
- package/skills/stageflow-run/SKILL.md +27 -9
- package/skills/stageflow-run/references/mcp-call.md +1 -1
- package/skills/stageflow-run/scripts/mcp-call.mjs +9 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readRunArtifactBytes, artifactMediaType } from "../mcp/readArtifact.js";
|
|
3
|
+
import { waitRun } from "../mcp/waitRun.js";
|
|
4
|
+
import { readYamlObject } from "../config/readYamlObject.js";
|
|
5
|
+
import { payloadInstanceMismatch } from "../envelope/payloadSchema.js";
|
|
6
|
+
import { parseAskOperatorAnswer } from "../tools/askOperator.js";
|
|
7
|
+
import { PipelineValidationError } from "../runtime/pipelineValidationError.js";
|
|
8
|
+
import { EnvelopeRefError, resolveEnvelopeRefsToTask, } from "../runtime/resolveEnvelopeRef.js";
|
|
9
|
+
import { A2aApplicationError, contentHash, decodePromptHandle, encodePromptHandle, newTaskId, parseIncomingMessage, submissionKeyFor, } from "./contracts.js";
|
|
10
|
+
import { A2aStore } from "./store.js";
|
|
11
|
+
import { MAX_NONTERMINAL_TASKS_PER_CALLER, RateLimiter } from "./limits.js";
|
|
12
|
+
export class A2aInvocations {
|
|
13
|
+
registry;
|
|
14
|
+
manager;
|
|
15
|
+
runStore;
|
|
16
|
+
rootDir;
|
|
17
|
+
store;
|
|
18
|
+
rateLimiter;
|
|
19
|
+
constructor(registry, manager, runStore,
|
|
20
|
+
/** Base directory a `stage`/`pipeline` filesystem reference in a run_stage call resolves against. */
|
|
21
|
+
rootDir, store, rateLimiter = new RateLimiter()) {
|
|
22
|
+
this.registry = registry;
|
|
23
|
+
this.manager = manager;
|
|
24
|
+
this.runStore = runStore;
|
|
25
|
+
this.rootDir = rootDir;
|
|
26
|
+
this.store = store;
|
|
27
|
+
this.rateLimiter = rateLimiter;
|
|
28
|
+
}
|
|
29
|
+
close() {
|
|
30
|
+
this.store.close();
|
|
31
|
+
}
|
|
32
|
+
/** Deletes terminal tasks and message tombstones past their retention window. Call periodically; the core logic takes an explicit clock so it stays deterministic to test. */
|
|
33
|
+
async pruneExpired(now = new Date()) {
|
|
34
|
+
return this.store.pruneExpired(now);
|
|
35
|
+
}
|
|
36
|
+
checkRate(caller) {
|
|
37
|
+
if (!this.rateLimiter.tryConsume(caller.id)) {
|
|
38
|
+
throw new A2aApplicationError("busy", "Rate limit exceeded; retry after a short delay");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async send(caller, raw) {
|
|
42
|
+
this.checkRate(caller);
|
|
43
|
+
const message = parseIncomingMessage(raw);
|
|
44
|
+
if (message.kind === "invoke") {
|
|
45
|
+
return this.invoke(caller, message.messageId, message.contextId, message.capability, message.input);
|
|
46
|
+
}
|
|
47
|
+
if (message.kind === "run_stage") {
|
|
48
|
+
return this.runStandalone(caller, message);
|
|
49
|
+
}
|
|
50
|
+
return this.answer(caller, message.messageId, message.taskId, message.handle, message.answer);
|
|
51
|
+
}
|
|
52
|
+
async get(caller, taskId) {
|
|
53
|
+
this.checkRate(caller);
|
|
54
|
+
return this.projectTask(caller, taskId);
|
|
55
|
+
}
|
|
56
|
+
async list(caller, contextId) {
|
|
57
|
+
this.checkRate(caller);
|
|
58
|
+
const rows = this.store.listTasksForCaller(caller.id, contextId);
|
|
59
|
+
const tasks = [];
|
|
60
|
+
for (const row of rows) {
|
|
61
|
+
tasks.push(await this.projectTask(caller, row.task_id));
|
|
62
|
+
}
|
|
63
|
+
return tasks;
|
|
64
|
+
}
|
|
65
|
+
async readArtifact(caller, taskId, artifactId) {
|
|
66
|
+
this.checkRate(caller);
|
|
67
|
+
const row = this.requireOwnedTask(caller, taskId);
|
|
68
|
+
if (row.state !== "completed")
|
|
69
|
+
throw new A2aApplicationError("not-found", "Artifact not found");
|
|
70
|
+
const artifact = this.store.getArtifact(taskId, artifactId);
|
|
71
|
+
if (!artifact)
|
|
72
|
+
throw new A2aApplicationError("not-found", "Artifact not found");
|
|
73
|
+
const { readFile } = await import("node:fs/promises");
|
|
74
|
+
const bytes = await readFile(artifact.content_path);
|
|
75
|
+
return { bytes, name: artifact.name, mediaType: artifact.media_type ?? undefined };
|
|
76
|
+
}
|
|
77
|
+
requireOwnedTask(caller, taskId) {
|
|
78
|
+
const row = this.store.getTask(taskId);
|
|
79
|
+
if (!row || row.caller_id !== caller.id)
|
|
80
|
+
throw new A2aApplicationError("not-found", "Task not found");
|
|
81
|
+
return row;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* (caller, messageId) dedup shared by invoke and answer: a resend of a message already recorded with
|
|
85
|
+
* the same content hash replays its prior outcome; a resend with different content is a conflict.
|
|
86
|
+
* Returns undefined only for a message this caller has not sent before.
|
|
87
|
+
*/
|
|
88
|
+
async replayOrThrow(caller, messageId, hash, conflictMessage) {
|
|
89
|
+
const existing = this.store.getMessage(caller.id, messageId);
|
|
90
|
+
if (!existing)
|
|
91
|
+
return undefined;
|
|
92
|
+
if (existing.requestHash !== hash) {
|
|
93
|
+
throw new A2aApplicationError("message-conflict", conflictMessage);
|
|
94
|
+
}
|
|
95
|
+
return this.projectTask(caller, existing.taskId);
|
|
96
|
+
}
|
|
97
|
+
async invoke(caller, messageId, contextId, capability, input) {
|
|
98
|
+
const publication = this.registry.get(caller.id, capability);
|
|
99
|
+
if (!publication) {
|
|
100
|
+
throw new A2aApplicationError("unknown-capability", "Unknown capability: " + capability);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
await this.registry.assertUnchanged(caller.id, capability);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
throw new A2aApplicationError("unknown-capability", err instanceof Error ? err.message : "Publication configuration changed");
|
|
107
|
+
}
|
|
108
|
+
const mismatch = payloadInstanceMismatch(input ?? {}, publication.inputSchema);
|
|
109
|
+
if (mismatch) {
|
|
110
|
+
throw new A2aApplicationError("invalid-input", mismatch);
|
|
111
|
+
}
|
|
112
|
+
const hash = contentHash({
|
|
113
|
+
operation: "invoke",
|
|
114
|
+
capability,
|
|
115
|
+
revision: publication.revision,
|
|
116
|
+
contextId: contextId ?? null,
|
|
117
|
+
input,
|
|
118
|
+
});
|
|
119
|
+
const replay = await this.replayOrThrow(caller, messageId, hash, "Message ID was already used for different input");
|
|
120
|
+
if (replay)
|
|
121
|
+
return replay;
|
|
122
|
+
const submissionKey = submissionKeyFor(caller.id, capability, hash);
|
|
123
|
+
const existingTask = this.store.getTaskBySubmissionKey(submissionKey);
|
|
124
|
+
if (!existingTask && this.store.countNonterminal(caller.id) >= MAX_NONTERMINAL_TASKS_PER_CALLER) {
|
|
125
|
+
throw new A2aApplicationError("busy", "Too many active tasks for this caller; wait for one to finish");
|
|
126
|
+
}
|
|
127
|
+
const taskId = existingTask?.task_id ?? newTaskId();
|
|
128
|
+
const contextIdResolved = existingTask?.context_id ?? this.store.ensureContext(caller.id, contextId);
|
|
129
|
+
const result = await this.manager.startRunOnce({ pipeline: publication.pipeline, task: { id: taskId, goal: publication.goal, input: input } }, { key: submissionKey, requestHash: hash });
|
|
130
|
+
if (!result.ok) {
|
|
131
|
+
throw mapStartFailure(result);
|
|
132
|
+
}
|
|
133
|
+
if (!existingTask) {
|
|
134
|
+
this.store.createTask({
|
|
135
|
+
taskId,
|
|
136
|
+
contextId: contextIdResolved,
|
|
137
|
+
callerId: caller.id,
|
|
138
|
+
publicationId: publication.id,
|
|
139
|
+
publicationRevision: publication.revision,
|
|
140
|
+
submissionKey,
|
|
141
|
+
runId: result.runId,
|
|
142
|
+
});
|
|
143
|
+
if (result.done) {
|
|
144
|
+
void result.done
|
|
145
|
+
.catch(() => undefined)
|
|
146
|
+
.then(() => this.maybeFinalize(this.store.getTask(taskId)));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
this.store.recordMessage({ callerId: caller.id, messageId, taskId, operation: "invoke", requestHash: hash, outcome: { ok: true } });
|
|
150
|
+
return this.projectTask(caller, taskId);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* ADR-0001's wildcard-access operation: run any catalog or inline
|
|
154
|
+
* stage/pipeline directly. Deliberately does NOT call `this.registry.get`/
|
|
155
|
+
* `.list` — that allowlist lookup is the entire mechanism `invoke` uses to
|
|
156
|
+
* restrict a caller to its published capabilities, and this operation's
|
|
157
|
+
* whole point is to bypass it. The caller is still authenticated (by the
|
|
158
|
+
* time `send` reaches here) and still rate-limited/admission-capped like
|
|
159
|
+
* any other task.
|
|
160
|
+
*/
|
|
161
|
+
async runStandalone(caller, cmd) {
|
|
162
|
+
let taskInput;
|
|
163
|
+
if (cmd.envelope_ref) {
|
|
164
|
+
try {
|
|
165
|
+
taskInput = await resolveEnvelopeRefsToTask(this.runStore, cmd.envelope_ref, cmd.checkout);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
if (err instanceof EnvelopeRefError) {
|
|
169
|
+
throw new A2aApplicationError(err.kind === "not_found" ? "not-found" : "invalid-input", err.message);
|
|
170
|
+
}
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
taskInput = (cmd.task_path ?? cmd.task);
|
|
176
|
+
}
|
|
177
|
+
let pipelineArg;
|
|
178
|
+
let resultStageId;
|
|
179
|
+
if (cmd.stage !== undefined) {
|
|
180
|
+
let stageBody;
|
|
181
|
+
if (typeof cmd.stage === "string") {
|
|
182
|
+
if (!cmd.stage.trim())
|
|
183
|
+
throw new A2aApplicationError("invalid-input", "stage is required");
|
|
184
|
+
const absPath = path.resolve(this.rootDir, cmd.stage);
|
|
185
|
+
try {
|
|
186
|
+
stageBody = await readYamlObject(absPath);
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
190
|
+
throw new A2aApplicationError("invalid-input", `Failed to read stage file: ${message}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
stageBody = cmd.stage;
|
|
195
|
+
}
|
|
196
|
+
if (typeof stageBody.id !== "string" || !stageBody.id.trim()) {
|
|
197
|
+
throw new A2aApplicationError("invalid-input", "stage.id is required");
|
|
198
|
+
}
|
|
199
|
+
resultStageId = stageBody.id;
|
|
200
|
+
const effectiveStageBody = cmd.model !== undefined ? { ...stageBody, model: cmd.model } : stageBody;
|
|
201
|
+
pipelineArg = { id: `standalone-${resultStageId}`, stages: [effectiveStageBody] };
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
const pipeline = cmd.pipeline;
|
|
205
|
+
if (typeof pipeline === "string") {
|
|
206
|
+
if (!pipeline.trim())
|
|
207
|
+
throw new A2aApplicationError("invalid-input", "pipeline is required");
|
|
208
|
+
pipelineArg = pipeline;
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
if (typeof pipeline.id !== "string" || !pipeline.id.trim() || !Array.isArray(pipeline.stages)) {
|
|
212
|
+
throw new A2aApplicationError("invalid-input", "Inline pipeline requires id and stages");
|
|
213
|
+
}
|
|
214
|
+
pipelineArg = pipeline;
|
|
215
|
+
}
|
|
216
|
+
// No single result stage for a multi-stage pipeline call: finalize
|
|
217
|
+
// surfaces every stage's outcome instead of one envelope (see maybeFinalize).
|
|
218
|
+
}
|
|
219
|
+
const hash = contentHash({
|
|
220
|
+
operation: "run_stage",
|
|
221
|
+
stage: cmd.stage ?? null,
|
|
222
|
+
pipeline: cmd.pipeline ?? null,
|
|
223
|
+
task_path: cmd.task_path ?? null,
|
|
224
|
+
task: cmd.task ?? null,
|
|
225
|
+
envelope_ref: cmd.envelope_ref ?? null,
|
|
226
|
+
checkout: cmd.checkout ?? null,
|
|
227
|
+
model: cmd.model ?? null,
|
|
228
|
+
});
|
|
229
|
+
const replay = await this.replayOrThrow(caller, cmd.messageId, hash, "Message ID was already used for different input");
|
|
230
|
+
if (replay)
|
|
231
|
+
return replay;
|
|
232
|
+
// Same at-most-once-per-content shape as invoke: a resend under a FRESH
|
|
233
|
+
// messageId (e.g. the caller lost the first response) must still land on
|
|
234
|
+
// the same task/run rather than starting a duplicate, so this is keyed
|
|
235
|
+
// on content hash via submissionKey/getTaskBySubmissionKey, not messageId.
|
|
236
|
+
const submissionKey = submissionKeyFor(caller.id, "__run_stage__", hash);
|
|
237
|
+
const existingTask = this.store.getTaskBySubmissionKey(submissionKey);
|
|
238
|
+
if (!existingTask && this.store.countNonterminal(caller.id) >= MAX_NONTERMINAL_TASKS_PER_CALLER) {
|
|
239
|
+
throw new A2aApplicationError("busy", "Too many active tasks for this caller; wait for one to finish");
|
|
240
|
+
}
|
|
241
|
+
const taskId = existingTask?.task_id ?? newTaskId();
|
|
242
|
+
const contextIdResolved = existingTask?.context_id ?? this.store.ensureContext(caller.id, cmd.contextId);
|
|
243
|
+
let result;
|
|
244
|
+
try {
|
|
245
|
+
result = await this.manager.startRunOnce({ pipeline: pipelineArg, task: taskInput }, { key: submissionKey, requestHash: hash });
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
if (err instanceof PipelineValidationError) {
|
|
249
|
+
throw new A2aApplicationError("invalid-input", "Stage/pipeline validation failed: " + JSON.stringify(err.result.findings));
|
|
250
|
+
}
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
if (!result.ok) {
|
|
254
|
+
throw mapStartFailure(result);
|
|
255
|
+
}
|
|
256
|
+
if (!existingTask) {
|
|
257
|
+
this.store.createTask({
|
|
258
|
+
taskId,
|
|
259
|
+
contextId: contextIdResolved,
|
|
260
|
+
callerId: caller.id,
|
|
261
|
+
publicationId: "",
|
|
262
|
+
publicationRevision: "",
|
|
263
|
+
submissionKey,
|
|
264
|
+
runId: result.runId,
|
|
265
|
+
kind: "standalone",
|
|
266
|
+
resultStageId: resultStageId ?? null,
|
|
267
|
+
});
|
|
268
|
+
if (result.done) {
|
|
269
|
+
void result.done
|
|
270
|
+
.catch(() => undefined)
|
|
271
|
+
.then(() => this.maybeFinalize(this.store.getTask(taskId)));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
this.store.recordMessage({ callerId: caller.id, messageId: cmd.messageId, taskId, operation: "run_stage", requestHash: hash, outcome: { ok: true } });
|
|
275
|
+
if (cmd.blocking) {
|
|
276
|
+
await waitRun({ store: this.runStore, runId: result.runId, timeoutMs: cmd.timeout_ms, until: "any" }).catch(() => undefined);
|
|
277
|
+
}
|
|
278
|
+
return this.projectTask(caller, taskId);
|
|
279
|
+
}
|
|
280
|
+
async answer(caller, messageId, taskId, handle, answerPayload) {
|
|
281
|
+
const row = this.requireOwnedTask(caller, taskId);
|
|
282
|
+
const hash = contentHash({ operation: "answer", taskId, handle, answer: answerPayload });
|
|
283
|
+
const replay = await this.replayOrThrow(caller, messageId, hash, "Message ID was already used for a different answer");
|
|
284
|
+
if (replay)
|
|
285
|
+
return replay;
|
|
286
|
+
const decoded = decodePromptHandle(handle);
|
|
287
|
+
if (!decoded || decoded.taskId !== taskId) {
|
|
288
|
+
throw new A2aApplicationError("stale-question", "Unknown or expired prompt handle");
|
|
289
|
+
}
|
|
290
|
+
if (row.kind !== "standalone") {
|
|
291
|
+
// Published-capability tasks stay gated by the publication's own
|
|
292
|
+
// explicit caller_answerable_stages allowlist (config-time restricted
|
|
293
|
+
// to single-free_text-gate stages in registry.ts's validatePublication).
|
|
294
|
+
const publication = this.registry.get(caller.id, row.publication_id);
|
|
295
|
+
if (!publication || !publication.caller_answerable_stages.includes(decoded.stageId)) {
|
|
296
|
+
throw new A2aApplicationError("stale-question", "This prompt cannot be answered by the caller");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// A "standalone" (ADR-0001 run_stage) task has no publication/allowlist to
|
|
300
|
+
// consult — the caller already owns this ad hoc run outright (requireOwnedTask
|
|
301
|
+
// above). Any of its own free_text prompts are answerable; the pending_prompt.kind
|
|
302
|
+
// check below (shared with the publication path) is what still keeps an
|
|
303
|
+
// operator-only gate (confirm, etc.) out of reach here, same as for `invoke`.
|
|
304
|
+
if (!row.run_id) {
|
|
305
|
+
throw new A2aApplicationError("stale-question", "Task has no active run to answer");
|
|
306
|
+
}
|
|
307
|
+
const detail = await this.runStore.readRun(row.run_id);
|
|
308
|
+
const stage = detail.stages.find((candidate) => candidate.stage_id === decoded.stageId);
|
|
309
|
+
if (!stage ||
|
|
310
|
+
stage.status !== "waiting_for_input" ||
|
|
311
|
+
stage.pending_prompt?.id !== decoded.promptId ||
|
|
312
|
+
stage.pending_prompt?.kind !== "free_text") {
|
|
313
|
+
throw new A2aApplicationError("stale-question", "This question is no longer pending");
|
|
314
|
+
}
|
|
315
|
+
let parsed;
|
|
316
|
+
try {
|
|
317
|
+
parsed = parseAskOperatorAnswer({ promptId: decoded.promptId, kind: "free_text", text: answerPayload.text });
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
throw new A2aApplicationError("invalid-input", err instanceof Error ? err.message : "Invalid answer");
|
|
321
|
+
}
|
|
322
|
+
const delivered = await this.manager.deliverAnswer(row.run_id, decoded.stageId, parsed);
|
|
323
|
+
if (!delivered.ok) {
|
|
324
|
+
const category = delivered.status === 404 ? "not-found" : "stale-question";
|
|
325
|
+
throw new A2aApplicationError(category, delivered.reason);
|
|
326
|
+
}
|
|
327
|
+
this.store.recordMessage({ callerId: caller.id, messageId, taskId, operation: "answer", requestHash: hash, outcome: { ok: true } });
|
|
328
|
+
return this.projectTask(caller, taskId);
|
|
329
|
+
}
|
|
330
|
+
async maybeFinalize(row) {
|
|
331
|
+
if (row.state === "completed" || row.state === "failed")
|
|
332
|
+
return row;
|
|
333
|
+
if (!row.run_id)
|
|
334
|
+
return row;
|
|
335
|
+
let detail;
|
|
336
|
+
try {
|
|
337
|
+
detail = await this.runStore.readRun(row.run_id);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return row;
|
|
341
|
+
}
|
|
342
|
+
if (detail.status !== "succeeded" && detail.status !== "failed")
|
|
343
|
+
return row;
|
|
344
|
+
if (row.kind === "standalone") {
|
|
345
|
+
if (detail.status === "failed") {
|
|
346
|
+
await this.store.freezeFailed(row.task_id);
|
|
347
|
+
return this.store.getTask(row.task_id);
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
if (row.result_stage_id) {
|
|
351
|
+
// A `stage`-shaped call: exactly one synthesized result stage, same
|
|
352
|
+
// as run_stage's MCP `blocking` result. Unlike the publication path,
|
|
353
|
+
// every artifact the stage produced is exposed (no declared-artifact
|
|
354
|
+
// allowlist to filter by — see ADR-0001).
|
|
355
|
+
const stage = detail.stages.find((candidate) => candidate.stage_id === row.result_stage_id);
|
|
356
|
+
if (!stage || !stage.envelope)
|
|
357
|
+
throw new Error("Result stage did not produce an envelope");
|
|
358
|
+
const artifacts = [];
|
|
359
|
+
for (const match of stage.artifacts) {
|
|
360
|
+
const name = match.split("/").pop();
|
|
361
|
+
const bytes = await readRunArtifactBytes(this.runStore, row.run_id, match);
|
|
362
|
+
artifacts.push({ name, mediaType: artifactMediaType(name), bytes });
|
|
363
|
+
}
|
|
364
|
+
await this.store.freezeCompleted(row.task_id, {
|
|
365
|
+
summary: stage.envelope.summary,
|
|
366
|
+
payload: stage.envelope.payload,
|
|
367
|
+
artifacts,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
// A `pipeline`-shaped call: no single designated result stage, so
|
|
372
|
+
// surface every stage's outcome instead of one envelope. Files are
|
|
373
|
+
// not copied here; per ADR-0002 a caller chains into the next
|
|
374
|
+
// standalone call via envelope_ref rather than downloading artifacts
|
|
375
|
+
// from this summary.
|
|
376
|
+
await this.store.freezeCompleted(row.task_id, {
|
|
377
|
+
summary: `Run ${detail.pipeline_id} completed`,
|
|
378
|
+
payload: {
|
|
379
|
+
stages: detail.stages.map((s) => ({
|
|
380
|
+
stageId: s.stage_id,
|
|
381
|
+
status: s.status,
|
|
382
|
+
summary: s.envelope?.summary,
|
|
383
|
+
payload: s.envelope?.payload,
|
|
384
|
+
})),
|
|
385
|
+
},
|
|
386
|
+
artifacts: [],
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
await this.store.freezeFailed(row.task_id);
|
|
392
|
+
}
|
|
393
|
+
return this.store.getTask(row.task_id);
|
|
394
|
+
}
|
|
395
|
+
const publication = this.registry.get(row.caller_id, row.publication_id);
|
|
396
|
+
if (detail.status === "failed" || !publication) {
|
|
397
|
+
await this.store.freezeFailed(row.task_id);
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
try {
|
|
401
|
+
const stage = detail.stages.find((candidate) => candidate.stage_id === publication.results.stage);
|
|
402
|
+
if (!stage || !stage.envelope)
|
|
403
|
+
throw new Error("Result stage did not produce an envelope");
|
|
404
|
+
const artifacts = [];
|
|
405
|
+
for (const name of publication.results.artifacts) {
|
|
406
|
+
const match = stage.artifacts.find((entry) => entry.split("/").pop() === name);
|
|
407
|
+
if (!match)
|
|
408
|
+
throw new Error("Declared artifact " + name + " was not produced");
|
|
409
|
+
const bytes = await readRunArtifactBytes(this.runStore, row.run_id, match);
|
|
410
|
+
artifacts.push({ name, mediaType: artifactMediaType(name), bytes });
|
|
411
|
+
}
|
|
412
|
+
await this.store.freezeCompleted(row.task_id, {
|
|
413
|
+
summary: stage.envelope.summary,
|
|
414
|
+
payload: publication.results.include_payload ? stage.envelope.payload : undefined,
|
|
415
|
+
artifacts,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
await this.store.freezeFailed(row.task_id);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return this.store.getTask(row.task_id);
|
|
423
|
+
}
|
|
424
|
+
/** The five PublicTask fields every projection shares; call sites layer in only what differs per state. */
|
|
425
|
+
buildTask(row, extra = {}) {
|
|
426
|
+
return {
|
|
427
|
+
id: row.task_id,
|
|
428
|
+
contextId: row.context_id,
|
|
429
|
+
publication: row.publication_id,
|
|
430
|
+
createdAt: row.created_at,
|
|
431
|
+
updatedAt: row.updated_at,
|
|
432
|
+
state: row.state,
|
|
433
|
+
...extra,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
async projectTask(caller, taskId) {
|
|
437
|
+
let row = this.requireOwnedTask(caller, taskId);
|
|
438
|
+
row = await this.maybeFinalize(row);
|
|
439
|
+
const runIdExtra = row.kind === "standalone" && row.run_id ? { runId: row.run_id } : {};
|
|
440
|
+
if (row.state === "completed" || row.state === "failed") {
|
|
441
|
+
const resultData = row.result_json
|
|
442
|
+
? JSON.parse(row.result_json)
|
|
443
|
+
: undefined;
|
|
444
|
+
const artifacts = this.store
|
|
445
|
+
.listArtifacts(taskId)
|
|
446
|
+
.map((artifact) => ({ id: artifact.artifact_id, name: artifact.name, mediaType: artifact.media_type ?? undefined, size: artifact.size }));
|
|
447
|
+
return this.buildTask(row, {
|
|
448
|
+
...runIdExtra,
|
|
449
|
+
result: resultData ? { summary: resultData.summary, payload: resultData.payload, artifacts } : undefined,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
if (!row.run_id) {
|
|
453
|
+
return this.buildTask(row, { state: "submitted" });
|
|
454
|
+
}
|
|
455
|
+
const detail = await this.runStore.readRun(row.run_id);
|
|
456
|
+
const waitingIds = detail.waiting_stage_ids ?? (detail.waiting_stage_id ? [detail.waiting_stage_id] : []);
|
|
457
|
+
let answerableIds;
|
|
458
|
+
if (row.kind === "standalone") {
|
|
459
|
+
// No publication/allowlist to consult (ADR-0001): the caller owns this
|
|
460
|
+
// whole ad hoc run, so any of its own free_text prompts are answerable —
|
|
461
|
+
// same free_text-only boundary invoke's publication config enforces,
|
|
462
|
+
// just read off the live prompt instead of a config list.
|
|
463
|
+
answerableIds = waitingIds.filter((id) => {
|
|
464
|
+
const stage = detail.stages.find((candidate) => candidate.stage_id === id);
|
|
465
|
+
return stage?.pending_prompt?.kind === "free_text";
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
const publication = this.registry.get(caller.id, row.publication_id);
|
|
470
|
+
answerableIds = waitingIds.filter((id) => publication?.caller_answerable_stages.includes(id));
|
|
471
|
+
}
|
|
472
|
+
if (answerableIds.length > 0) {
|
|
473
|
+
const questions = answerableIds.map((stageId) => {
|
|
474
|
+
const stage = detail.stages.find((candidate) => candidate.stage_id === stageId);
|
|
475
|
+
const prompt = stage.pending_prompt;
|
|
476
|
+
const message = prompt.kind === "free_text" ? prompt.message : "Additional input required";
|
|
477
|
+
return { handle: encodePromptHandle(row.task_id, stageId, prompt.id), message };
|
|
478
|
+
});
|
|
479
|
+
return this.buildTask(row, { ...runIdExtra, state: "input-required", questions });
|
|
480
|
+
}
|
|
481
|
+
const state = waitingIds.length > 0 || detail.status === "running" ? "working" : "submitted";
|
|
482
|
+
return this.buildTask(row, { ...runIdExtra, state, waitingForOperator: waitingIds.length > 0 });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function mapStartFailure(result) {
|
|
486
|
+
if (result.code === "busy_capacity" || result.code === "busy_checkout") {
|
|
487
|
+
return new A2aApplicationError("busy", result.reason);
|
|
488
|
+
}
|
|
489
|
+
if (result.status === 409) {
|
|
490
|
+
return new A2aApplicationError("message-conflict", result.reason);
|
|
491
|
+
}
|
|
492
|
+
return new A2aApplicationError("invalid-input", result.reason);
|
|
493
|
+
}
|
|
494
|
+
export function createA2aInvocations(registry, manager, runStore, rootDir, connection, rateLimiter) {
|
|
495
|
+
return new A2aInvocations(registry, manager, runStore, rootDir, new A2aStore(rootDir, connection), rateLimiter);
|
|
496
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
export type TaskState = "submitted" | "working" | "input-required" | "completed" | "failed";
|
|
3
|
+
export type TaskKind = "invoke" | "standalone";
|
|
4
|
+
export type A2aTaskRow = {
|
|
5
|
+
task_id: string;
|
|
6
|
+
context_id: string;
|
|
7
|
+
caller_id: string;
|
|
8
|
+
publication_id: string;
|
|
9
|
+
publication_revision: string;
|
|
10
|
+
submission_key: string;
|
|
11
|
+
run_id: string | null;
|
|
12
|
+
state: TaskState;
|
|
13
|
+
result_json: string | null;
|
|
14
|
+
/** "invoke" (published-capability task) or "standalone" (ADR-0001 run_stage wildcard task). */
|
|
15
|
+
kind: TaskKind;
|
|
16
|
+
/**
|
|
17
|
+
* For a "standalone" task started from a single `stage`, the stage whose
|
|
18
|
+
* envelope is the task result. Null for a "standalone" task started from a
|
|
19
|
+
* `pipeline` (no single designated result stage) and always null for "invoke".
|
|
20
|
+
*/
|
|
21
|
+
result_stage_id: string | null;
|
|
22
|
+
created_at: string;
|
|
23
|
+
updated_at: string;
|
|
24
|
+
};
|
|
25
|
+
export type A2aArtifactRow = {
|
|
26
|
+
artifact_id: string;
|
|
27
|
+
task_id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
media_type: string | null;
|
|
30
|
+
size: number;
|
|
31
|
+
content_path: string;
|
|
32
|
+
hash: string;
|
|
33
|
+
created_at: string;
|
|
34
|
+
};
|
|
35
|
+
export type FrozenResult = {
|
|
36
|
+
summary: string;
|
|
37
|
+
payload?: Record<string, unknown>;
|
|
38
|
+
artifacts: Array<{
|
|
39
|
+
name: string;
|
|
40
|
+
mediaType?: string;
|
|
41
|
+
bytes: Buffer;
|
|
42
|
+
}>;
|
|
43
|
+
};
|
|
44
|
+
export type MessageRecord = {
|
|
45
|
+
taskId: string;
|
|
46
|
+
requestHash: string;
|
|
47
|
+
outcome: unknown;
|
|
48
|
+
};
|
|
49
|
+
export declare class A2aStore {
|
|
50
|
+
private readonly db;
|
|
51
|
+
private readonly artifactsRoot;
|
|
52
|
+
private readonly ownsConnection;
|
|
53
|
+
/**
|
|
54
|
+
* `connection`, when passed, is the SqliteRunStore's own connection to the same `state.db` file
|
|
55
|
+
* (see `createRunStoreWithConnection`) so the two stores share one transaction domain. Falls
|
|
56
|
+
* back to opening its own connection when no shared one is available (e.g. a caller-supplied
|
|
57
|
+
* non-SQLite `RunStore` in bootstrap).
|
|
58
|
+
*/
|
|
59
|
+
constructor(rootDir: string, connection?: Database.Database);
|
|
60
|
+
/**
|
|
61
|
+
* Additive migration for a `state.db` created before the run_stage A2A
|
|
62
|
+
* operation existed: `CREATE TABLE IF NOT EXISTS` above never alters an
|
|
63
|
+
* already-existing a2a_tasks table, so a pre-existing one is missing these
|
|
64
|
+
* two columns. Same PRAGMA table_info-guarded ADD COLUMN pattern as
|
|
65
|
+
* src/runstore/sqlite/SqliteRunStore.ts.
|
|
66
|
+
*/
|
|
67
|
+
private migrateAddStandaloneColumns;
|
|
68
|
+
close(): void;
|
|
69
|
+
ensureContext(callerId: string, contextId: string | undefined): string;
|
|
70
|
+
getMessage(callerId: string, messageId: string): MessageRecord | undefined;
|
|
71
|
+
recordMessage(params: {
|
|
72
|
+
callerId: string;
|
|
73
|
+
messageId: string;
|
|
74
|
+
taskId: string;
|
|
75
|
+
operation: string;
|
|
76
|
+
requestHash: string;
|
|
77
|
+
outcome: unknown;
|
|
78
|
+
}): void;
|
|
79
|
+
getTaskBySubmissionKey(key: string): A2aTaskRow | undefined;
|
|
80
|
+
createTask(params: {
|
|
81
|
+
taskId: string;
|
|
82
|
+
contextId: string;
|
|
83
|
+
callerId: string;
|
|
84
|
+
publicationId: string;
|
|
85
|
+
publicationRevision: string;
|
|
86
|
+
submissionKey: string;
|
|
87
|
+
runId: string;
|
|
88
|
+
kind?: TaskKind;
|
|
89
|
+
resultStageId?: string | null;
|
|
90
|
+
}): A2aTaskRow;
|
|
91
|
+
getTask(taskId: string): A2aTaskRow | undefined;
|
|
92
|
+
listTasksForCaller(callerId: string, contextId: string | undefined): A2aTaskRow[];
|
|
93
|
+
getArtifact(taskId: string, artifactId: string): A2aArtifactRow | undefined;
|
|
94
|
+
listArtifacts(taskId: string): A2aArtifactRow[];
|
|
95
|
+
/** Freezes a successful outcome exactly once; a second call for an already-terminal task is a no-op. */
|
|
96
|
+
freezeCompleted(taskId: string, result: FrozenResult): Promise<void>;
|
|
97
|
+
/** Freezes a failure exactly once; a second call for an already-terminal task is a no-op. */
|
|
98
|
+
freezeFailed(taskId: string): Promise<void>;
|
|
99
|
+
private freeze;
|
|
100
|
+
countNonterminal(callerId: string): number;
|
|
101
|
+
/** Deletes terminal tasks (and their frozen artifact files) and message tombstones older than the retention window. */
|
|
102
|
+
pruneExpired(now: Date, terminalRetentionMs?: number, messageRetentionMs?: number): Promise<{
|
|
103
|
+
removedTasks: number;
|
|
104
|
+
removedMessages: number;
|
|
105
|
+
}>;
|
|
106
|
+
}
|