@anchrd/intel-api 0.6.3 → 0.6.5
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/adapters/cloudflare/cloudflare-flow-workflow.d.ts +21 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +76 -26
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +1 -0
- package/dist/adapters/db/db-flows.js +52 -3
- package/dist/flows/flows.js +99 -8
- package/dist/flows/flows.types.d.ts +12 -2
- package/dist/http/http.js +18 -3
- package/dist/mcp/mcp.js +15 -1
- package/migrations/0010_no_run_waits_a_year.sql +27 -0
- package/package.json +1 -1
|
@@ -15,6 +15,27 @@ interface FlowWorkflowParams {
|
|
|
15
15
|
interface IntelFlowWorkflowInstance {
|
|
16
16
|
run(event: Readonly<WorkflowEvent<FlowWorkflowParams>>, step: WorkflowStep): Promise<unknown>;
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* What the durable wait actually does, with storage handed in.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ Separated from the class because `WorkflowEntrypoint` is a runtime class that cannot be
|
|
22
|
+
* constructed outside workerd's workflow context — so as long as this logic lived inside it, none
|
|
23
|
+
* of it could be tested, including the rule that keeps a caller alive while its child runs. That
|
|
24
|
+
* rule is the one that decides whether this feature destroys work or not, and it had to be
|
|
25
|
+
* provable. The shell below is now only bindings and SQL, which is what `CLAUDE.md` asks of it.
|
|
26
|
+
*/
|
|
27
|
+
export declare function driveFlowRun(step: WorkflowStep, deps: {
|
|
28
|
+
readStatus(): Promise<{
|
|
29
|
+
status: string;
|
|
30
|
+
error: string | null;
|
|
31
|
+
}>;
|
|
32
|
+
hasRunningChild(): Promise<boolean>;
|
|
33
|
+
markFailed(error: string): Promise<void>;
|
|
34
|
+
stallTimeout: string;
|
|
35
|
+
}): Promise<{
|
|
36
|
+
status: string;
|
|
37
|
+
error: string | null;
|
|
38
|
+
}>;
|
|
18
39
|
export declare const IntelFlowWorkflow: {
|
|
19
40
|
new (context: unknown, env: CloudflareEnv): IntelFlowWorkflowInstance;
|
|
20
41
|
};
|
|
@@ -3,37 +3,87 @@
|
|
|
3
3
|
import { WorkflowEntrypoint as RuntimeWorkflowEntrypoint } from "cloudflare:workers";
|
|
4
4
|
class WorkflowEntrypoint extends RuntimeWorkflowEntrypoint {
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* How long a run may stand on one step before nobody is working on it any more (#83).
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ This is a product decision and not a constant somebody may tune while reading the code. Too
|
|
10
|
+
* short is worse than the problem it fixes: it ends runs that were only slow, which destroys work
|
|
11
|
+
* instead of merely displaying it wrongly. 30 minutes covers a large document, a sluggish API and
|
|
12
|
+
* a person who walks away from an approval; it does not cover a tab that was closed yesterday.
|
|
13
|
+
*
|
|
14
|
+
* The deployment may override it with `FLOW_RUN_STALL_TIMEOUT` (a Workflows duration, e.g. "2
|
|
15
|
+
* hours"), which is what makes the figure readable in operation rather than buried here.
|
|
16
|
+
*/
|
|
17
|
+
const DefaultStallTimeout = "30 minutes";
|
|
18
|
+
/**
|
|
19
|
+
* What the durable wait actually does, with storage handed in.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ Separated from the class because `WorkflowEntrypoint` is a runtime class that cannot be
|
|
22
|
+
* constructed outside workerd's workflow context — so as long as this logic lived inside it, none
|
|
23
|
+
* of it could be tested, including the rule that keeps a caller alive while its child runs. That
|
|
24
|
+
* rule is the one that decides whether this feature destroys work or not, and it had to be
|
|
25
|
+
* provable. The shell below is now only bindings and SQL, which is what `CLAUDE.md` asks of it.
|
|
26
|
+
*/
|
|
27
|
+
export async function driveFlowRun(step, deps) {
|
|
28
|
+
const fail = async (name, error) => await step.do(name, async () => {
|
|
29
|
+
await deps.markFailed(error);
|
|
30
|
+
return { status: "failed", error };
|
|
31
|
+
});
|
|
32
|
+
for (let iteration = 0; iteration < 500; iteration += 1) {
|
|
33
|
+
const state = await step.do(`inspect flow run ${iteration}`, async () => await deps.readStatus());
|
|
34
|
+
if (["completed", "failed", "cancelled"].includes(state.status))
|
|
35
|
+
return state;
|
|
36
|
+
try {
|
|
37
|
+
await step.waitForEvent(`wait for flow run ${iteration}`, {
|
|
38
|
+
type: "advance",
|
|
39
|
+
// ⚠️ The clock starts HERE, which is what makes it "since the last step" rather than "since
|
|
40
|
+
// the run began": every `advance` returns to the top of the loop and waits anew. A flow
|
|
41
|
+
// with one genuinely long step is measured against that step, not against its total length.
|
|
42
|
+
timeout: deps.stallTimeout,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// ⚠️ A caller standing on a sub-flow node looks exactly like a run nobody is working on — it
|
|
47
|
+
// is waiting, by design, for the called run to finish. Ending it here would tear down a
|
|
48
|
+
// caller because its child was slow, which is the one way this fix could destroy work rather
|
|
49
|
+
// than merely display it wrongly. So the timeout asks first, and waits again if a child is
|
|
50
|
+
// still going.
|
|
51
|
+
const waiting = await step.do(`check called run ${iteration}`, async () => await deps.hasRunningChild());
|
|
52
|
+
if (waiting)
|
|
53
|
+
continue;
|
|
54
|
+
return await fail(`expire flow run ${iteration}`, "Nothing has moved this run forward, so it was ended. Whatever was running it stopped without reporting a result — start it again if it is still needed.");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return await fail("fail flow step limit", "Flow exceeded the maximum durable step count");
|
|
58
|
+
}
|
|
6
59
|
class IntelFlowWorkflowImplementation extends WorkflowEntrypoint {
|
|
7
60
|
async run(event, step) {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
SET status = 'failed', error = ?, completed_at = ?, updated_at = ?
|
|
12
|
-
WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled')`)
|
|
13
|
-
.bind(error, occurredAt, occurredAt, event.payload.runId)
|
|
14
|
-
.run();
|
|
15
|
-
return { status: "failed", error };
|
|
16
|
-
});
|
|
17
|
-
for (let iteration = 0; iteration < 500; iteration += 1) {
|
|
18
|
-
const state = await step.do(`inspect flow run ${iteration}`, async () => {
|
|
61
|
+
const runId = event.payload.runId;
|
|
62
|
+
return await driveFlowRun(step, {
|
|
63
|
+
readStatus: async () => {
|
|
19
64
|
const row = await this.env.DB.prepare("SELECT status, error FROM flow_runs WHERE id = ?")
|
|
20
|
-
.bind(
|
|
65
|
+
.bind(runId)
|
|
21
66
|
.first();
|
|
22
67
|
return row ?? { status: "failed", error: "Flow run disappeared" };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
68
|
+
},
|
|
69
|
+
hasRunningChild: async () => {
|
|
70
|
+
const row = await this.env.DB.prepare(`SELECT id FROM flow_runs
|
|
71
|
+
WHERE parent_run_id = ? AND status IN ('queued', 'running')
|
|
72
|
+
LIMIT 1`)
|
|
73
|
+
.bind(runId)
|
|
74
|
+
.first();
|
|
75
|
+
return row !== null;
|
|
76
|
+
},
|
|
77
|
+
markFailed: async (error) => {
|
|
78
|
+
const occurredAt = new Date().toISOString();
|
|
79
|
+
await this.env.DB.prepare(`UPDATE flow_runs
|
|
80
|
+
SET status = 'failed', error = ?, completed_at = ?, updated_at = ?
|
|
81
|
+
WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled')`)
|
|
82
|
+
.bind(error, occurredAt, occurredAt, runId)
|
|
83
|
+
.run();
|
|
84
|
+
},
|
|
85
|
+
stallTimeout: this.env.FLOW_RUN_STALL_TIMEOUT || DefaultStallTimeout,
|
|
86
|
+
});
|
|
37
87
|
}
|
|
38
88
|
}
|
|
39
89
|
export const IntelFlowWorkflow = IntelFlowWorkflowImplementation;
|
|
@@ -22,6 +22,7 @@ export interface CloudflareEnv {
|
|
|
22
22
|
TOOL_SOURCE_ORIGINS: string;
|
|
23
23
|
MCP_PORTAL_URL?: string;
|
|
24
24
|
ALLOW_INSECURE_OAUTH?: string;
|
|
25
|
+
FLOW_RUN_STALL_TIMEOUT?: string;
|
|
25
26
|
}
|
|
26
27
|
export interface CloudflareExecutionContext {
|
|
27
28
|
waitUntil(promise: Promise<unknown>): void;
|
|
@@ -122,15 +122,15 @@ export function createFlowRepository(deps) {
|
|
|
122
122
|
* same reason `COUNT(*) OVER ()` is a count of those rows alone — the number the graph reports
|
|
123
123
|
* about its own size would otherwise disclose that something else is there.
|
|
124
124
|
*/
|
|
125
|
-
const visibleFlows = (scopeClause, bounded) => `${subtreeCte}
|
|
125
|
+
const visibleFlows = (scopeClause, bounded, includeArchived = false) => `${subtreeCte}
|
|
126
126
|
SELECT ${flowColumns}${bounded ? ", COUNT(*) OVER () AS total" : ""} FROM flows flow
|
|
127
|
-
WHERE ${flowInSubtree} AND flow.archived_at IS NULL ${scopeClause}
|
|
127
|
+
WHERE ${flowInSubtree} ${includeArchived ? "" : "AND flow.archived_at IS NULL"} ${scopeClause}
|
|
128
128
|
ORDER BY lower(flow.title), flow.id${bounded ? " LIMIT ?" : ""}`;
|
|
129
129
|
return {
|
|
130
130
|
async listVisible(actor, input = {}) {
|
|
131
131
|
const scope = scopeOf(input.parentId);
|
|
132
132
|
const result = await deps.db
|
|
133
|
-
.prepare(visibleFlows(scope.clause, false))
|
|
133
|
+
.prepare(visibleFlows(scope.clause, false, input.includeArchived === true))
|
|
134
134
|
.bind(...readableBindings(actor), ...flowInSubtreeBindings(actor), ...scope.bindings)
|
|
135
135
|
.all();
|
|
136
136
|
return (result.results ?? []).map(mapFlow);
|
|
@@ -388,6 +388,55 @@ export function createFlowRepository(deps) {
|
|
|
388
388
|
.first();
|
|
389
389
|
return row ? mapFlow(row) : "conflict";
|
|
390
390
|
},
|
|
391
|
+
// Archiving and restoring are one statement, told apart only by whether `archived_at` is a
|
|
392
|
+
// timestamp or null. Like `updateFlow` it writes the flow row alone: an archived flow keeps its
|
|
393
|
+
// versions and its published version, because coming back out of the archive has to return the
|
|
394
|
+
// flow that went in.
|
|
395
|
+
//
|
|
396
|
+
// ⚠️ The idempotency row is written only if the UPDATE actually matched — same guard as
|
|
397
|
+
// `knowledge.archive` — so a stale `baseUpdatedAt` leaves no key behind that would make the
|
|
398
|
+
// retry of a *lost* write look like a replay of a successful one.
|
|
399
|
+
async archiveFlow(input) {
|
|
400
|
+
try {
|
|
401
|
+
await deps.db.batch([
|
|
402
|
+
deps.db
|
|
403
|
+
.prepare(`UPDATE flows SET archived_at = ?, updated_at = ?
|
|
404
|
+
WHERE id = ? AND updated_at = ?`)
|
|
405
|
+
.bind(input.archivedAt, input.updatedAt, input.flowId, input.baseUpdatedAt),
|
|
406
|
+
deps.db
|
|
407
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
408
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
409
|
+
) SELECT ?, 'flows.archive', ?, ?, ?
|
|
410
|
+
WHERE EXISTS (
|
|
411
|
+
SELECT 1 FROM flows WHERE id = ? AND archived_at IS ? AND updated_at = ?
|
|
412
|
+
)`)
|
|
413
|
+
.bind(input.actorId, input.idempotencyKey, input.flowId, input.updatedAt, input.flowId, input.archivedAt, input.updatedAt),
|
|
414
|
+
deps.db
|
|
415
|
+
.prepare(`INSERT INTO audit_events (
|
|
416
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
417
|
+
) SELECT ?, ?, 'flows.archive', 'flow', ?, ?, ?
|
|
418
|
+
WHERE EXISTS (
|
|
419
|
+
SELECT 1 FROM idempotency_keys
|
|
420
|
+
WHERE actor_id = ? AND operation = 'flows.archive'
|
|
421
|
+
AND idempotency_key = ? AND resource_id = ?
|
|
422
|
+
)`)
|
|
423
|
+
.bind(input.auditId, input.actorId, input.flowId, JSON.stringify({ archived: input.archivedAt !== null }), input.updatedAt, input.actorId, input.idempotencyKey, input.flowId),
|
|
424
|
+
]);
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
if (!(await this.findIdempotent(input.actorId, "flows.archive", input.idempotencyKey))) {
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const replayed = await this.findIdempotent(input.actorId, "flows.archive", input.idempotencyKey);
|
|
432
|
+
if (!replayed)
|
|
433
|
+
return "conflict";
|
|
434
|
+
const row = await deps.db
|
|
435
|
+
.prepare(`SELECT ${flowColumns} FROM flows WHERE id = ?`)
|
|
436
|
+
.bind(replayed)
|
|
437
|
+
.first();
|
|
438
|
+
return row ? mapFlow(row) : "conflict";
|
|
439
|
+
},
|
|
391
440
|
// Versions are immutable, so a set of them is a single read by definition: the trail of a nested
|
|
392
441
|
// run needs one per level and the relation graph one per flow it draws, and both used to ask
|
|
393
442
|
// level by level (#30). Missing IDs are simply absent from the answer.
|
package/dist/flows/flows.js
CHANGED
|
@@ -320,9 +320,26 @@ export function createFlows(deps) {
|
|
|
320
320
|
.filter(([, callees]) => callees.some((callee) => reachable.has(callee)))
|
|
321
321
|
.map(([flowId]) => flowId);
|
|
322
322
|
}
|
|
323
|
-
|
|
323
|
+
/**
|
|
324
|
+
* The flow a run already belongs to, as this actor may see it.
|
|
325
|
+
*
|
|
326
|
+
* ⚠️ The same ACL as `requireRunnableFlow` and deliberately without its archived refusal. This is
|
|
327
|
+
* the answer to "what should archiving do to a run in flight" (#112): archiving decides what may
|
|
328
|
+
* be STARTED, never what happens to what is already going. The other reading — refusing here —
|
|
329
|
+
* is what produced the state that ticket was written about: the step was recorded and the caller
|
|
330
|
+
* was told 404, with no way to read the run afterwards to find out otherwise.
|
|
331
|
+
*/
|
|
332
|
+
async function requireStartedFlow(actor, flowId) {
|
|
324
333
|
const flow = await deps.repository.getCallable(actor, flowId);
|
|
325
|
-
if (!flow
|
|
334
|
+
if (!flow)
|
|
335
|
+
throw new IntelError(404, "flow_not_found", "Flow was not found");
|
|
336
|
+
return flow;
|
|
337
|
+
}
|
|
338
|
+
// The same flow for something that is about to START — a run, a validation. An archived flow is
|
|
339
|
+
// absent here, which is the whole of what archiving does.
|
|
340
|
+
async function requireRunnableFlow(actor, flowId) {
|
|
341
|
+
const flow = await requireStartedFlow(actor, flowId);
|
|
342
|
+
if (flow.archivedAt)
|
|
326
343
|
throw new IntelError(404, "flow_not_found", "Flow was not found");
|
|
327
344
|
return flow;
|
|
328
345
|
}
|
|
@@ -569,13 +586,33 @@ export function createFlows(deps) {
|
|
|
569
586
|
const found = await deps.repository.visibleCallRuns(actor, sites);
|
|
570
587
|
return new Map(found.map((call) => [callKey(call.runId, call.nodeId), call.calledRunId]));
|
|
571
588
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
589
|
+
/**
|
|
590
|
+
* What a run looks like right now: the run as this actor may read it, the node it stands on, and
|
|
591
|
+
* the trail it belongs to. Data only — it asks nothing.
|
|
592
|
+
*
|
|
593
|
+
* ⚠️ Split out of `step` because of the order this used to be in (#112). `completeStep` writes
|
|
594
|
+
* the step and then shapes its answer, and shaping used to authorize a second time — so a check
|
|
595
|
+
* that failed AFTER the write told the caller "404" about work that had happened, and the retry
|
|
596
|
+
* replayed into the same 404 forever. Whoever just completed a step may be told what became of
|
|
597
|
+
* it. Whether they may run the NEXT one is asked when they run it, by the call that runs it.
|
|
598
|
+
*
|
|
599
|
+
* `narrowed` stays: it decides how much of a failure's text this reader may see, which is
|
|
600
|
+
* disclosure and not permission.
|
|
601
|
+
*/
|
|
602
|
+
// ⚠️ The version is passed in rather than looked up. Both callers already hold it, and reading it
|
|
603
|
+
// again here cost one extra round trip per answer — which the query-count test caught (#30).
|
|
604
|
+
async function runState(actor, run, version) {
|
|
575
605
|
const node = nodeFor(version, run.currentNodeId);
|
|
576
|
-
await requireNodeAuthorized(actor, node, version.graph);
|
|
577
606
|
return { run: await narrowed(actor, run, version), node, trail: await trailFor(run, version) };
|
|
578
607
|
}
|
|
608
|
+
// The same picture for somebody ASKING for it rather than having just acted: the flow has to
|
|
609
|
+
// resolve and the node they would be handed has to be one they may execute.
|
|
610
|
+
async function step(actor, run) {
|
|
611
|
+
await requireStartedFlow(actor, run.flowId);
|
|
612
|
+
const version = await requireVersion(run.versionId, run.flowId);
|
|
613
|
+
await requireNodeAuthorized(actor, nodeFor(version, run.currentNodeId), version.graph);
|
|
614
|
+
return await runState(actor, run, version);
|
|
615
|
+
}
|
|
579
616
|
// A failed run carries the text of the step that failed, and for a call that text came out of
|
|
580
617
|
// another run. It is narrowed here rather than at the door it entered through: what storage keeps
|
|
581
618
|
// is what happened, and who may read it is a question about the person asking, not about the row.
|
|
@@ -1053,6 +1090,56 @@ export function createFlows(deps) {
|
|
|
1053
1090
|
}
|
|
1054
1091
|
return updated;
|
|
1055
1092
|
},
|
|
1093
|
+
/**
|
|
1094
|
+
* Archive a flow, or take it back out again.
|
|
1095
|
+
*
|
|
1096
|
+
* ⚠️ The one place that resolves the flow through `getVisible` instead of `requireFlow`. Every
|
|
1097
|
+
* other entry point treats an archived flow as absent — that is the whole point of archiving —
|
|
1098
|
+
* but the call that restores one has to be able to find it, and `requireFlow` answers 404 for
|
|
1099
|
+
* exactly the rows this call exists for. The visibility predicate is the same one; only the
|
|
1100
|
+
* `archivedAt` refusal is left out.
|
|
1101
|
+
*
|
|
1102
|
+
* `write` and nothing more, asked before the row is touched: archiving is organization, the same
|
|
1103
|
+
* grant that renames and moves (ADR-0004). It appends no version and withdraws no publication.
|
|
1104
|
+
*
|
|
1105
|
+
* ⚠️ It does NOT leave runs in flight alone, however much the word "archive" suggests it would.
|
|
1106
|
+
* `completeStep` writes before it shapes its answer: the step is recorded, and `step()` then
|
|
1107
|
+
* resolves the flow and throws 404 for the archived one. The caller is told the step failed
|
|
1108
|
+
* while storage has it done, and a retry replays into the same 404. Restoring the flow is what
|
|
1109
|
+
* makes the run readable again. That belongs to the run machine and has its own ticket
|
|
1110
|
+
* (issue 112) — archiving is only the easiest way to walk into it.
|
|
1111
|
+
*/
|
|
1112
|
+
async archive(actor, input) {
|
|
1113
|
+
const current = await deps.repository.getVisible(actor, input.flowId);
|
|
1114
|
+
if (!current)
|
|
1115
|
+
throw new IntelError(404, "flow_not_found", "Flow was not found");
|
|
1116
|
+
if (!(await deps.repository.can(actor, input.flowId, "write"))) {
|
|
1117
|
+
throw new IntelError(403, "flow_edit_forbidden", "Flow cannot be edited");
|
|
1118
|
+
}
|
|
1119
|
+
const replayed = await deps.repository.findIdempotent(actor.id, "flows.archive", input.idempotencyKey);
|
|
1120
|
+
// Read back the way this call reads anything, or replaying an archive would 404 on the row it
|
|
1121
|
+
// just archived.
|
|
1122
|
+
if (replayed) {
|
|
1123
|
+
const flow = await deps.repository.getVisible(actor, replayed);
|
|
1124
|
+
if (!flow)
|
|
1125
|
+
throw new IntelError(404, "flow_not_found", "Flow was not found");
|
|
1126
|
+
return flow;
|
|
1127
|
+
}
|
|
1128
|
+
const updatedAt = deps.now().toISOString();
|
|
1129
|
+
const updated = await deps.repository.archiveFlow({
|
|
1130
|
+
flowId: current.id,
|
|
1131
|
+
baseUpdatedAt: input.baseUpdatedAt,
|
|
1132
|
+
archivedAt: input.archived ? updatedAt : null,
|
|
1133
|
+
updatedAt,
|
|
1134
|
+
actorId: actor.id,
|
|
1135
|
+
idempotencyKey: input.idempotencyKey,
|
|
1136
|
+
auditId: deps.id(),
|
|
1137
|
+
});
|
|
1138
|
+
if (updated === "conflict") {
|
|
1139
|
+
throw new IntelError(409, "flow_update_conflict", "Flow was changed by another editor");
|
|
1140
|
+
}
|
|
1141
|
+
return updated;
|
|
1142
|
+
},
|
|
1056
1143
|
async save(actor, input) {
|
|
1057
1144
|
const replayed = await deps.repository.findIdempotent(actor.id, "flows.save", input.idempotencyKey);
|
|
1058
1145
|
if (replayed) {
|
|
@@ -1278,7 +1365,9 @@ export function createFlows(deps) {
|
|
|
1278
1365
|
* and no output, because a run reaches Knowledge and tools with the rights of whoever started it.
|
|
1279
1366
|
*/
|
|
1280
1367
|
async listRuns(actor, input) {
|
|
1281
|
-
|
|
1368
|
+
// The history of a flow that has since been archived stays readable — the runs happened, and
|
|
1369
|
+
// archiving is about what may start next (#112).
|
|
1370
|
+
const flow = await requireStartedFlow(actor, input.flowId);
|
|
1282
1371
|
// One row beyond the page: it answers "is there more" and is dropped rather than shown, so a
|
|
1283
1372
|
// count over the whole table is never needed to draw a "next" affordance.
|
|
1284
1373
|
const rows = await deps.repository.listRunsVisible(actor, {
|
|
@@ -1506,7 +1595,9 @@ export function createFlows(deps) {
|
|
|
1506
1595
|
throw new IntelError(409, "flow_step_conflict", "Flow step was already completed");
|
|
1507
1596
|
}
|
|
1508
1597
|
await deps.runtime.signal(next.id);
|
|
1509
|
-
|
|
1598
|
+
// ⚠️ `runState`, not `step`: the write above has happened. Asking again here is what turned a
|
|
1599
|
+
// completed step into a 404 for the caller (#112).
|
|
1600
|
+
return await runState(actor, next, version);
|
|
1510
1601
|
},
|
|
1511
1602
|
};
|
|
1512
1603
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CompleteFlowRunStepInput, CreateFlowInput, Flow, FlowDocument, FlowGraph, FlowPublishPreview, FlowRequirements, FlowRun, FlowRunHistory, FlowRunList, FlowRunStep, FlowValidation, FlowVersion, KnowledgeNode, ListFlowRunsInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraph, RelationGraphInput, ResourceVerb, SaveFlowVersionInput, StartFlowRunInput, UpdateFlowInput } from "@anchrd/intel-contract";
|
|
1
|
+
import type { ArchiveFlowInput, CompleteFlowRunStepInput, CreateFlowInput, Flow, FlowDocument, FlowGraph, FlowPublishPreview, FlowRequirements, FlowRun, FlowRunHistory, FlowRunList, FlowRunStep, FlowValidation, FlowVersion, KnowledgeNode, ListFlowRunsInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraph, RelationGraphInput, ResourceVerb, SaveFlowVersionInput, StartFlowRunInput, UpdateFlowInput } from "@anchrd/intel-contract";
|
|
2
2
|
export type FlowPrincipal = Pick<FlowActor, "id" | "email" | "isAdmin">;
|
|
3
3
|
export type FlowCallReach = "subtree" | "library" | "out-of-reach";
|
|
4
4
|
export interface FlowRunChainEntry {
|
|
@@ -40,7 +40,7 @@ export interface FlowActor {
|
|
|
40
40
|
isAdmin?: boolean;
|
|
41
41
|
}
|
|
42
42
|
export type FlowVerb = Extract<ResourceVerb, "read" | "write" | "execute">;
|
|
43
|
-
export type FlowOperation = "flows.create" | "flows.update" | "flows.save" | "flows.publish" | "flows.run" | "flows.complete";
|
|
43
|
+
export type FlowOperation = "flows.create" | "flows.update" | "flows.archive" | "flows.save" | "flows.publish" | "flows.run" | "flows.complete";
|
|
44
44
|
export interface BoundedLevel<T> {
|
|
45
45
|
items: T[];
|
|
46
46
|
total: number;
|
|
@@ -76,6 +76,15 @@ export interface FlowRepository {
|
|
|
76
76
|
idempotencyKey: string;
|
|
77
77
|
auditId: string;
|
|
78
78
|
}): Promise<"conflict" | Flow>;
|
|
79
|
+
archiveFlow(input: {
|
|
80
|
+
flowId: string;
|
|
81
|
+
baseUpdatedAt: string;
|
|
82
|
+
archivedAt: string | null;
|
|
83
|
+
updatedAt: string;
|
|
84
|
+
actorId: string;
|
|
85
|
+
idempotencyKey: string;
|
|
86
|
+
auditId: string;
|
|
87
|
+
}): Promise<"conflict" | Flow>;
|
|
79
88
|
getVersion(versionId: string): Promise<FlowVersion | null>;
|
|
80
89
|
getVersions(versionIds: string[]): Promise<FlowVersion[]>;
|
|
81
90
|
insertVersion(input: {
|
|
@@ -169,6 +178,7 @@ export interface FlowService {
|
|
|
169
178
|
listRequirements(actor: FlowActor, flowId: string): Promise<FlowRequirements>;
|
|
170
179
|
create(actor: FlowActor, input: CreateFlowInput): Promise<Flow>;
|
|
171
180
|
update(actor: FlowActor, input: UpdateFlowInput): Promise<Flow>;
|
|
181
|
+
archive(actor: FlowActor, input: ArchiveFlowInput): Promise<Flow>;
|
|
172
182
|
save(actor: FlowActor, input: SaveFlowVersionInput): Promise<FlowDocument>;
|
|
173
183
|
previewPublish(actor: FlowActor, input: PreviewFlowPublishInput): Promise<FlowPublishPreview>;
|
|
174
184
|
publish(actor: FlowActor, input: PublishFlowInput): Promise<Flow>;
|
package/dist/http/http.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AppendKnowledgeTableRowsInput, ArchiveKnowledgeNodeInput, CompleteFlowRunStepInput, CreateFlowInput, CreateKnowledgeNodeInput, DefineKnowledgeTableInput, ExecuteToolInput, GetFlowInput, GetFlowRunInput, KnowledgeGraphInput, ListFlowRunsInput, ListFlowsInput, ListKnowledgeNodesInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraphInput, ResolveKnowledgeLinksInput, RevokeKnowledgeGrantInput, SaveFlowVersionInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareKnowledgeInput, StartFlowRunInput, TestToolInput, UpdateFlowInput, UpdateKnowledgeNodeInput, } from "@anchrd/intel-contract";
|
|
1
|
+
import { AppendKnowledgeTableRowsInput, ArchiveFlowInput, ArchiveKnowledgeNodeInput, CompleteFlowRunStepInput, CreateFlowInput, CreateKnowledgeNodeInput, DefineKnowledgeTableInput, ExecuteToolInput, GetFlowInput, GetFlowRunInput, KnowledgeGraphInput, ListFlowRunsInput, ListFlowsInput, ListKnowledgeNodesInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraphInput, ResolveKnowledgeLinksInput, RevokeKnowledgeGrantInput, SaveFlowVersionInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareKnowledgeInput, StartFlowRunInput, TestToolInput, UpdateFlowInput, UpdateKnowledgeNodeInput, } from "@anchrd/intel-contract";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { authorizeBearer, bearer, permits, } from "../shared/gate-authorization/gate-authorization.js";
|
|
@@ -250,11 +250,16 @@ export function createHttp(deps) {
|
|
|
250
250
|
app.get("/flows", async (context) => {
|
|
251
251
|
const auth = requireCapability(context, "flows", "read");
|
|
252
252
|
const url = new URL(context.req.url);
|
|
253
|
-
requireKnownQuery(url, ["parentId"]);
|
|
253
|
+
requireKnownQuery(url, ["parentId", "includeArchived"]);
|
|
254
254
|
// Absent means "every flow I may see"; present but empty means the root of the shared tree.
|
|
255
255
|
// Without that distinction a tree level and a full list would be the same request.
|
|
256
256
|
const parentId = url.searchParams.get("parentId");
|
|
257
|
-
|
|
257
|
+
// Same rule for the archive: absent means "without it". Spelling the default out would put a
|
|
258
|
+
// field into every call that asks nothing about the archive.
|
|
259
|
+
const input = ListFlowsInput.parse({
|
|
260
|
+
...(url.searchParams.has("parentId") ? { parentId: parentId === "" ? null : parentId } : {}),
|
|
261
|
+
...(url.searchParams.get("includeArchived") === "true" ? { includeArchived: true } : {}),
|
|
262
|
+
});
|
|
258
263
|
return context.json(await deps.flows.list(asFlowActor(auth), input));
|
|
259
264
|
});
|
|
260
265
|
// ⚠️ Before `/flows/:flowId`, or the router would read "graph" as a flow ID. Same order and same
|
|
@@ -318,6 +323,16 @@ export function createHttp(deps) {
|
|
|
318
323
|
}
|
|
319
324
|
return context.json(await deps.flows.update(asFlowActor(auth), input));
|
|
320
325
|
});
|
|
326
|
+
// `flows/write`, the same capability renaming and moving need: archiving is organization, not a
|
|
327
|
+
// change to what the flow does.
|
|
328
|
+
app.post("/flows/:flowId/archive", async (context) => {
|
|
329
|
+
const auth = requireCapability(context, "flows", "write");
|
|
330
|
+
const input = ArchiveFlowInput.parse(await context.req.json().catch(() => null));
|
|
331
|
+
if (input.flowId !== context.req.param("flowId")) {
|
|
332
|
+
throw new IntelError(400, "flow_id_mismatch", "Path and body flow IDs differ");
|
|
333
|
+
}
|
|
334
|
+
return context.json(await deps.flows.archive(asFlowActor(auth), input));
|
|
335
|
+
});
|
|
321
336
|
app.post("/flows/:flowId/versions", async (context) => {
|
|
322
337
|
const auth = requireCapability(context, "flows", "write");
|
|
323
338
|
const input = SaveFlowVersionInput.parse(await context.req.json().catch(() => null));
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AppendKnowledgeTableRowsInput, ArchiveKnowledgeNodeInput, CompleteFlowRunStepInput, CreateFlowInput, CreateKnowledgeNodeInput, DefineKnowledgeTableInput, ExecuteToolInput, GetFlowInput, GetFlowRunInput, GetKnowledgeNodeInput, GetKnowledgeTableInput, KnowledgeGraphInput, ListFlowRunsInput, ListFlowsInput, ListKnowledgeGrantsInput, ListKnowledgeNodesInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraphInput, ResolveKnowledgeLinksInput, RevokeKnowledgeGrantInput, SaveFlowVersionInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareKnowledgeInput, StartFlowRunInput, TestToolInput, UpdateFlowInput, UpdateKnowledgeNodeInput, } from "@anchrd/intel-contract";
|
|
1
|
+
import { AppendKnowledgeTableRowsInput, ArchiveFlowInput, ArchiveKnowledgeNodeInput, CompleteFlowRunStepInput, CreateFlowInput, CreateKnowledgeNodeInput, DefineKnowledgeTableInput, ExecuteToolInput, GetFlowInput, GetFlowRunInput, GetKnowledgeNodeInput, GetKnowledgeTableInput, KnowledgeGraphInput, ListFlowRunsInput, ListFlowsInput, ListKnowledgeGrantsInput, ListKnowledgeNodesInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraphInput, ResolveKnowledgeLinksInput, RevokeKnowledgeGrantInput, SaveFlowVersionInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareKnowledgeInput, StartFlowRunInput, TestToolInput, UpdateFlowInput, UpdateKnowledgeNodeInput, } from "@anchrd/intel-contract";
|
|
2
2
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
4
4
|
import { z } from "zod";
|
|
@@ -473,6 +473,20 @@ export async function handleMcp(request, deps) {
|
|
|
473
473
|
openWorldHint: false,
|
|
474
474
|
},
|
|
475
475
|
}, async (input) => text(await deps.flows.update(flowActor, input)));
|
|
476
|
+
server.registerTool("flow_archive", {
|
|
477
|
+
title: "Archive flow",
|
|
478
|
+
description: "Archive or restore one flow using optimistic concurrency. An archived flow keeps its versions but can no longer be opened, started, or called by another flow.",
|
|
479
|
+
inputSchema: ArchiveFlowInput,
|
|
480
|
+
annotations: {
|
|
481
|
+
title: "Archive flow",
|
|
482
|
+
readOnlyHint: false,
|
|
483
|
+
// What it takes away is reach, not content: nothing is deleted and `archived: false` puts
|
|
484
|
+
// it back. Destructive all the same, because a flow another flow calls stops resolving.
|
|
485
|
+
destructiveHint: true,
|
|
486
|
+
idempotentHint: true,
|
|
487
|
+
openWorldHint: false,
|
|
488
|
+
},
|
|
489
|
+
}, async (input) => text(await deps.flows.archive(flowActor, input)));
|
|
476
490
|
server.registerTool("flow_save", {
|
|
477
491
|
title: "Save flow",
|
|
478
492
|
description: "Append a validated immutable flow graph version with optimistic concurrency.",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- Runs that nobody is carrying forward, ended (#83).
|
|
2
|
+
--
|
|
3
|
+
-- The workflow used to wait `365 days` for the next step to be reported, so a run whose agent
|
|
4
|
+
-- stopped — context full, tab closed, session over — stood as "running" for a year and was
|
|
5
|
+
-- indistinguishable from one that really was running. The wait is now the stall timeout; this
|
|
6
|
+
-- clears the ones that were left behind before it existed.
|
|
7
|
+
--
|
|
8
|
+
-- ⚠️ The cut-off is deliberately far past the new timeout rather than equal to it. This runs once,
|
|
9
|
+
-- against rows whose `updated_at` is whatever it happened to be, and a run that is genuinely mid-
|
|
10
|
+
-- step when the migration is applied must not be swept up. A day is long enough that anything
|
|
11
|
+
-- older stopped for good; anything younger is left to the timeout, which measures properly.
|
|
12
|
+
--
|
|
13
|
+
-- ⚠️ `parent_run_id IS NULL OR the child is finished`: a caller standing on a sub-flow node looks
|
|
14
|
+
-- exactly like a stalled run. Ending a caller whose child is still going is the one way this could
|
|
15
|
+
-- destroy work, and it is the same rule the workflow applies at its timeout.
|
|
16
|
+
UPDATE flow_runs
|
|
17
|
+
SET status = 'failed',
|
|
18
|
+
error = 'Nothing has moved this run forward, so it was ended. Whatever was running it stopped without reporting a result — start it again if it is still needed.',
|
|
19
|
+
completed_at = datetime('now'),
|
|
20
|
+
updated_at = datetime('now')
|
|
21
|
+
WHERE status IN ('queued', 'running')
|
|
22
|
+
AND updated_at < datetime('now', '-1 day')
|
|
23
|
+
AND NOT EXISTS (
|
|
24
|
+
SELECT 1 FROM flow_runs child
|
|
25
|
+
WHERE child.parent_run_id = flow_runs.id
|
|
26
|
+
AND child.status IN ('queued', 'running')
|
|
27
|
+
);
|