@anchrd/intel-api 0.6.4 → 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.
@@ -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 fail = async (name, error) => await step.do(name, async () => {
9
- const occurredAt = new Date().toISOString();
10
- await this.env.DB.prepare(`UPDATE flow_runs
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(event.payload.runId)
65
+ .bind(runId)
21
66
  .first();
22
67
  return row ?? { status: "failed", error: "Flow run disappeared" };
23
- });
24
- if (["completed", "failed", "cancelled"].includes(state.status))
25
- return state;
26
- try {
27
- await step.waitForEvent(`wait for flow run ${iteration}`, {
28
- type: "advance",
29
- timeout: "365 days",
30
- });
31
- }
32
- catch {
33
- return await fail(`expire flow run ${iteration}`, "Flow run timed out waiting for progress");
34
- }
35
- }
36
- return await fail("fail flow step limit", "Flow exceeded the maximum durable step count");
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;
@@ -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
- async function requireRunnableFlow(actor, flowId) {
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 || flow.archivedAt)
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
- async function step(actor, run) {
573
- await requireRunnableFlow(actor, run.flowId);
574
- const version = await requireVersion(run.versionId, run.flowId);
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.
@@ -1328,7 +1365,9 @@ export function createFlows(deps) {
1328
1365
  * and no output, because a run reaches Knowledge and tools with the rights of whoever started it.
1329
1366
  */
1330
1367
  async listRuns(actor, input) {
1331
- const flow = await requireRunnableFlow(actor, input.flowId);
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);
1332
1371
  // One row beyond the page: it answers "is there more" and is dropped rather than shown, so a
1333
1372
  // count over the whole table is never needed to draw a "next" affordance.
1334
1373
  const rows = await deps.repository.listRunsVisible(actor, {
@@ -1556,7 +1595,9 @@ export function createFlows(deps) {
1556
1595
  throw new IntelError(409, "flow_step_conflict", "Flow step was already completed");
1557
1596
  }
1558
1597
  await deps.runtime.signal(next.id);
1559
- return await step(actor, next);
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);
1560
1601
  },
1561
1602
  };
1562
1603
  }
@@ -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
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {