@oqtopus-team/pi-qdash 0.1.11-beta.21 → 0.1.11-beta.22

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.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Stop waiting when the host cancels the call.
3
+ *
4
+ * The client's polling helpers (`waitForExecution` and friends) run until the
5
+ * execution finishes or their own timeout passes; they take no signal. A tool
6
+ * that awaits one directly keeps a cancelled turn alive until then: the host
7
+ * cannot end the turn, and the chat refuses the next message because the
8
+ * conversation is still busy. This races the wait against the signal, so the
9
+ * call returns as cancelled at once; the poll it leaves behind only reads.
10
+ */
11
+ export class WaitCancelled extends Error {
12
+ override readonly name = "AbortError";
13
+ constructor(what: string) {
14
+ super(`${what} was cancelled before it finished; the execution itself keeps running in QDash.`);
15
+ }
16
+ }
17
+
18
+ export function withAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined, what: string): Promise<T> {
19
+ if (!signal) return promise;
20
+ if (signal.aborted) return Promise.reject(new WaitCancelled(what));
21
+ return new Promise<T>((resolve, reject) => {
22
+ const onAbort = () => reject(new WaitCancelled(what));
23
+ signal.addEventListener("abort", onAbort, { once: true });
24
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
25
+ });
26
+ }
@@ -12,6 +12,8 @@ const WRITE_TOOL_NAMES = new Set([
12
12
  "qdash_create_forum_evidence_reply",
13
13
  "qdash_create_forum_image_reply",
14
14
  "qdash_run_pipeline",
15
+ "qdash_save_flow",
16
+ "qdash_execute_flow",
15
17
  ]);
16
18
 
17
19
  export function installQDashWriteGate(pi: ExtensionAPI): void {
@@ -25,8 +25,10 @@ import { toFigureToolResult, toTextToolResult, toToolResult } from "./lib/result
25
25
  import { ansi, boxLinesToWidth, boxed, formatNumber, textComponent } from "./lib/render.js";
26
26
  import { plotSeriesLines, timeseriesPlotComponent, timeseriesPoints } from "./lib/timeseries-plot.js";
27
27
  import { installQDashWriteGate } from "./lib/write-gate.js";
28
+ import { withAbort } from "./lib/abort.js";
28
29
  import { qcalContextText, recordedParameters, sweepsOf, type RecordedParameter, type Sweep } from "./lib/qcal-evidence.js";
29
30
  import { registerCalibrationPipelineTools } from "./tools/calibration-pipeline.js";
31
+ import { registerFlowAuthoringTools } from "./tools/flow-authoring.js";
30
32
  import { registerTaskKnowledgeTool } from "./tools/task-knowledge.js";
31
33
  import { registerTimeseriesComparisonTool } from "./tools/timeseries.js";
32
34
  import { analyzeWiringMarkdown, wiringInsightLines, type WiringInsights } from "./lib/wiring-analysis.js";
@@ -1484,6 +1486,11 @@ export default function qdashExtension(pi: ExtensionAPI) {
1484
1486
  makeClient,
1485
1487
  resolveChipId: (client, chipId) => defaultChipId(client as QDashClient, chipId),
1486
1488
  });
1489
+ registerFlowAuthoringTools(pi, {
1490
+ makeClient,
1491
+ resolveChipId: (client, chipId) => defaultChipId(client as QDashClient, chipId),
1492
+ flowPageUrl: (client, name) => qdashWebUrl(client as QDashClient, `/workflow/${encodeURIComponent(name)}`),
1493
+ });
1487
1494
 
1488
1495
  pi.registerTool({
1489
1496
  name: "qdash_config_info",
@@ -1827,9 +1834,13 @@ export default function qdashExtension(pi: ExtensionAPI) {
1827
1834
  timeoutSeconds: Type.Optional(Type.Number()),
1828
1835
  pollIntervalSeconds: Type.Optional(Type.Number()),
1829
1836
  }),
1830
- async execute(_toolCallId, params: { profile?: string; configPath?: string; useEnv?: boolean; executionId: string; timeoutSeconds?: number; pollIntervalSeconds?: number }) {
1837
+ async execute(_toolCallId, params: { profile?: string; configPath?: string; useEnv?: boolean; executionId: string; timeoutSeconds?: number; pollIntervalSeconds?: number }, signal) {
1831
1838
  const client = await makeClient(params);
1832
- const data = await client.waitForExecution(params.executionId, { timeoutSeconds: params.timeoutSeconds, pollIntervalSeconds: params.pollIntervalSeconds });
1839
+ const data = await withAbort(
1840
+ client.waitForExecution(params.executionId, { timeoutSeconds: params.timeoutSeconds, pollIntervalSeconds: params.pollIntervalSeconds }),
1841
+ signal,
1842
+ `Waiting for execution ${params.executionId}`,
1843
+ );
1833
1844
  return toToolResult(withQDashLinks(client, data), { tool: "qdash_wait_execution", executionId: params.executionId, webBaseUrl: qdashWebBaseUrl(client) });
1834
1845
  },
1835
1846
  });
@@ -2036,12 +2047,16 @@ export default function qdashExtension(pi: ExtensionAPI) {
2036
2047
  timeoutSeconds: Type.Optional(Type.Number()),
2037
2048
  pollIntervalSeconds: Type.Optional(Type.Number()),
2038
2049
  }),
2039
- async execute(_toolCallId, params: { profile?: string; configPath?: string; useEnv?: boolean; sessionId: string; actionId: string; waitForExecution?: boolean; timeoutSeconds?: number; pollIntervalSeconds?: number }) {
2050
+ async execute(_toolCallId, params: { profile?: string; configPath?: string; useEnv?: boolean; sessionId: string; actionId: string; waitForExecution?: boolean; timeoutSeconds?: number; pollIntervalSeconds?: number }, signal) {
2040
2051
  const client = await makeClient(params);
2041
2052
  const options = { timeoutSeconds: params.timeoutSeconds, pollIntervalSeconds: params.pollIntervalSeconds };
2042
- const data = params.waitForExecution
2043
- ? await client.waitForAgentActionExecution(params.sessionId, params.actionId, options)
2044
- : await client.waitForAgentAction(params.sessionId, params.actionId, options);
2053
+ const data = await withAbort(
2054
+ params.waitForExecution
2055
+ ? client.waitForAgentActionExecution(params.sessionId, params.actionId, options)
2056
+ : client.waitForAgentAction(params.sessionId, params.actionId, options),
2057
+ signal,
2058
+ `Waiting for agent action ${params.actionId}`,
2059
+ );
2045
2060
  return toToolResult(data, { tool: "qdash_wait_agent_action" });
2046
2061
  },
2047
2062
  });
@@ -2440,9 +2455,16 @@ export default function qdashExtension(pi: ExtensionAPI) {
2440
2455
  promptSnippet: "Poll QDash until a candidate apply operation finishes",
2441
2456
  promptGuidelines: ["Use qdash_wait_agent_candidate_apply after applying a candidate commit when the user wants polling."],
2442
2457
  parameters: Type.Object({ ...connectionParams, sessionId: Type.String(), commitId: Type.String(), timeoutSeconds: Type.Optional(Type.Number()), pollIntervalSeconds: Type.Optional(Type.Number()) }),
2443
- async execute(_toolCallId, params: { profile?: string; configPath?: string; useEnv?: boolean; sessionId: string; commitId: string; timeoutSeconds?: number; pollIntervalSeconds?: number }) {
2458
+ async execute(_toolCallId, params: { profile?: string; configPath?: string; useEnv?: boolean; sessionId: string; commitId: string; timeoutSeconds?: number; pollIntervalSeconds?: number }, signal) {
2444
2459
  const client = await makeClient(params);
2445
- return toToolResult(await client.waitForAgentCandidateApply(params.sessionId, params.commitId, { timeoutSeconds: params.timeoutSeconds, pollIntervalSeconds: params.pollIntervalSeconds }), { tool: "qdash_wait_agent_candidate_apply" });
2460
+ return toToolResult(
2461
+ await withAbort(
2462
+ client.waitForAgentCandidateApply(params.sessionId, params.commitId, { timeoutSeconds: params.timeoutSeconds, pollIntervalSeconds: params.pollIntervalSeconds }),
2463
+ signal,
2464
+ `Waiting for candidate commit ${params.commitId}`,
2465
+ ),
2466
+ { tool: "qdash_wait_agent_candidate_apply" },
2467
+ );
2446
2468
  },
2447
2469
  });
2448
2470
 
@@ -0,0 +1,226 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+
4
+ import { toTextToolResult } from "../lib/results.js";
5
+
6
+ /**
7
+ * Flow authoring: saving a Prefect flow the agent wrote.
8
+ *
9
+ * A flow is Python that runs on the QDash workflow worker with hardware and
10
+ * database access, so saving one is a confirmation-gated write, and running one
11
+ * (`qdash_execute_flow`) is another. QDash owns the
12
+ * real validation (`POST /flows`: the `@flow` function must exist, the file is
13
+ * formatted with ruff, and a Prefect deployment is registered); these checks
14
+ * only catch what would certainly be rejected, so the user is not asked to
15
+ * approve a save that cannot succeed. Templates and helpers are read through
16
+ * `qdash_query` (`flow_templates`, `flow_template`, `flow_helper_files`,
17
+ * `flow_helper_file`), existing flows through `qdash_get_flow`.
18
+ */
19
+
20
+ type ConnectionParams = { profile?: string; configPath?: string; useEnv?: boolean };
21
+
22
+ export type SaveFlowRequest = {
23
+ name: string;
24
+ description: string;
25
+ code: string;
26
+ flow_function_name: string;
27
+ chip_id: string;
28
+ default_parameters: Record<string, unknown>;
29
+ default_run_parameters: Record<string, unknown>;
30
+ tags: string[];
31
+ };
32
+
33
+ export type SaveFlowResult = { name: string; file_path: string; message: string };
34
+
35
+ export type FlowExecution = {
36
+ execution_id: string;
37
+ flow_run_id: string;
38
+ flow_run_url: string;
39
+ qdash_ui_url: string;
40
+ message: string;
41
+ };
42
+
43
+ /** The slice of QDashClient these tools use; tests pass a stub. */
44
+ export type FlowClient = {
45
+ saveFlow(request: SaveFlowRequest): Promise<unknown>;
46
+ executeFlow(name: string, parameters?: Record<string, unknown>): Promise<unknown>;
47
+ };
48
+
49
+ export type FlowAuthoringDependencies = {
50
+ makeClient(params: ConnectionParams): Promise<FlowClient>;
51
+ /** The chip the flow is for: the explicit id, else the session/default chip. */
52
+ resolveChipId(client: FlowClient, chipId?: string): Promise<string>;
53
+ /** The QDash web page of a flow, when the web URL is known. */
54
+ flowPageUrl?(client: FlowClient, name: string): string | undefined;
55
+ };
56
+
57
+ const FLOW_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
58
+
59
+ /** Why a save would certainly be rejected, before anyone is asked to approve it. */
60
+ export function flowCodeProblems(name: string, code: string, functionName: string): string[] {
61
+ const problems: string[] = [];
62
+ if (!FLOW_NAME.test(name)) problems.push(`name '${name}' must be letters, digits and underscores, not starting with a digit`);
63
+ if (!FLOW_NAME.test(functionName)) problems.push(`flowFunctionName '${functionName}' is not a Python identifier`);
64
+ if (!/^\s*@flow\b/m.test(code)) problems.push("the code has no `@flow` decorator (import it with `from prefect import flow`)");
65
+ if (!new RegExp(`^\\s*(?:async\\s+)?def\\s+${functionName}\\s*\\(`, "m").test(code)) {
66
+ problems.push(`the code defines no function named '${functionName}'`);
67
+ }
68
+ if (!/^\s*(?:from|import)\s+prefect\b/m.test(code)) problems.push("the code does not import prefect");
69
+ return problems;
70
+ }
71
+
72
+ function detailOf(error: unknown): string | undefined {
73
+ if (!error || typeof error !== "object") return undefined;
74
+ const payload = (error as { payload?: unknown }).payload;
75
+ const detail = payload && typeof payload === "object" ? (payload as { detail?: unknown }).detail : undefined;
76
+ if (typeof detail === "string") return detail;
77
+ const message = (error as { message?: unknown }).message;
78
+ return typeof message === "string" ? message : undefined;
79
+ }
80
+
81
+ export function formatSavedFlow(result: SaveFlowResult, chipId: string, url: string | undefined): string {
82
+ return [
83
+ `${result.message} (chip ${chipId}).`,
84
+ `File: ${result.file_path}`,
85
+ ...(url ? [`Page: ${url}`] : []),
86
+ "The flow is registered as a Prefect deployment. Saving did not run it; qdash_execute_flow runs it when the user asks.",
87
+ ].join("\n");
88
+ }
89
+
90
+ export function formatFlowExecution(result: FlowExecution, name: string): string {
91
+ return [
92
+ `${result.message || `Flow '${name}' started.`}`,
93
+ `Execution: ${result.execution_id}`,
94
+ `QDash: ${result.qdash_ui_url}`,
95
+ `Prefect: ${result.flow_run_url}`,
96
+ "Follow it with qdash_wait_execution; completed tasks write their calibrated parameters to QDash directly.",
97
+ ].join("\n");
98
+ }
99
+
100
+ const connectionParams = {
101
+ profile: Type.Optional(Type.String({ description: "QDash profile name. Defaults to env when QDASH_BASE_URL is set, otherwise 'default'." })),
102
+ configPath: Type.Optional(Type.String({ description: "Optional path to qdash config.ini." })),
103
+ useEnv: Type.Optional(Type.Boolean({ description: "Force QDASH_* environment variables instead of a profile." })),
104
+ };
105
+
106
+ export function registerFlowAuthoringTools(pi: Pick<ExtensionAPI, "registerTool">, deps: FlowAuthoringDependencies): void {
107
+ pi.registerTool({
108
+ name: "qdash_save_flow",
109
+ label: "QDash Save Flow",
110
+ description:
111
+ "Save a Prefect flow (Python) as a QDash workflow: writes the file, formats it, registers its Prefect deployment, and records it in the project. Saving an existing name replaces that flow. Confirmation-gated write operation: the flow runs later on the workflow worker with hardware access. Start from a template (qdash_query flow_templates / flow_template) or an existing flow (qdash_get_flow).",
112
+ promptSnippet: "Save a Prefect flow the user asked you to write as a QDash workflow",
113
+ promptGuidelines: [
114
+ "Before qdash_save_flow, read a template or the existing flow and keep its structure: the @flow function, the helper imports, and the terminal handling.",
115
+ "Show the user the whole code and say which chip it is for; the save is confirmation-gated and replaces a flow of the same name.",
116
+ "Saving does not run the flow. Do not run it unless the user asks.",
117
+ ],
118
+ parameters: Type.Object({
119
+ ...connectionParams,
120
+ name: Type.String({ description: "Flow name: letters, digits and underscores. Also the file name and, by default, the @flow function name." }),
121
+ code: Type.String({ description: "The complete Python source of the flow module." }),
122
+ description: Type.Optional(Type.String({ description: "One line shown on the Workflow page." })),
123
+ chipId: Type.Optional(Type.String({ description: "Chip the flow is for. Defaults to the session/default chip." })),
124
+ flowFunctionName: Type.Optional(Type.String({ description: "The @flow function to deploy when it is not named like the flow." })),
125
+ defaultParameters: Type.Optional(Type.Object({}, { additionalProperties: true, description: "Default values of the flow function's parameters." })),
126
+ defaultRunParameters: Type.Optional(Type.Object({}, { additionalProperties: true, description: "Run parameters applied to every task, such as shots or interval." })),
127
+ tags: Type.Optional(Type.Array(Type.String())),
128
+ confirmWrite: Type.Optional(Type.Boolean({ description: "Required for non-interactive execution. Set true only after explicit user confirmation." })),
129
+ }),
130
+ async execute(
131
+ _toolCallId,
132
+ params: ConnectionParams & {
133
+ name: string;
134
+ code: string;
135
+ description?: string;
136
+ chipId?: string;
137
+ flowFunctionName?: string;
138
+ defaultParameters?: Record<string, unknown>;
139
+ defaultRunParameters?: Record<string, unknown>;
140
+ tags?: string[];
141
+ confirmWrite?: boolean;
142
+ },
143
+ _signal,
144
+ _onUpdate,
145
+ ctx,
146
+ ) {
147
+ const functionName = params.flowFunctionName ?? params.name;
148
+ const problems = flowCodeProblems(params.name, params.code, functionName);
149
+ if (problems.length > 0) {
150
+ const text = [
151
+ `The flow cannot be saved as it is (${problems.length} problem${problems.length === 1 ? "" : "s"}). Nothing was written.`,
152
+ ...problems.map((problem) => `- ${problem}`),
153
+ ].join("\n");
154
+ return toTextToolResult(text, { problems }, { tool: "qdash_save_flow", saved: false, problemCount: problems.length });
155
+ }
156
+ if (!params.confirmWrite) {
157
+ if (!ctx.hasUI || !(await ctx.ui.confirm("Save QDash flow?", `This writes flow '${params.name}' to the project and registers its deployment.`))) {
158
+ throw new Error("qdash_save_flow requires explicit confirmation");
159
+ }
160
+ }
161
+ const client = await deps.makeClient(params);
162
+ const chipId = await deps.resolveChipId(client, params.chipId);
163
+ try {
164
+ const result = (await client.saveFlow({
165
+ name: params.name,
166
+ description: params.description ?? "",
167
+ code: params.code,
168
+ flow_function_name: functionName,
169
+ chip_id: chipId,
170
+ default_parameters: params.defaultParameters ?? {},
171
+ default_run_parameters: params.defaultRunParameters ?? {},
172
+ tags: params.tags ?? [],
173
+ })) as SaveFlowResult;
174
+ const url = deps.flowPageUrl?.(client, result.name);
175
+ return toTextToolResult(formatSavedFlow(result, chipId, url), { ...result, chip_id: chipId, url }, {
176
+ tool: "qdash_save_flow",
177
+ saved: true,
178
+ flowName: result.name,
179
+ chipId,
180
+ });
181
+ } catch (error) {
182
+ const detail = detailOf(error);
183
+ if (!detail) throw error;
184
+ return toTextToolResult(
185
+ `QDash rejected the flow: ${detail}\nNothing was saved. Fix the code and save again.`,
186
+ { detail },
187
+ { tool: "qdash_save_flow", saved: false },
188
+ );
189
+ }
190
+ },
191
+ });
192
+
193
+ pi.registerTool({
194
+ name: "qdash_execute_flow",
195
+ label: "QDash Execute Flow",
196
+ description:
197
+ "Run a saved QDash flow as one execution, with parameters that override its defaults. Confirmation-gated write operation: the flow runs on the workflow worker with hardware access, takes the project execution lock, and completed tasks write their calibrated parameters to QDash directly.",
198
+ promptSnippet: "Run a saved QDash flow after explicit user confirmation",
199
+ promptGuidelines: [
200
+ "Use qdash_execute_flow only when the user asks to run a flow; saving one with qdash_save_flow is not a request to run it.",
201
+ "Check qdash_list_executions for a running execution first; a flow takes the project execution lock.",
202
+ "After it starts, follow the execution with qdash_wait_execution and summarize failed targets.",
203
+ ],
204
+ parameters: Type.Object({
205
+ ...connectionParams,
206
+ name: Type.String({ description: "The saved flow's name." }),
207
+ parameters: Type.Optional(Type.Object({}, { additionalProperties: true, description: "Flow function parameters; override the flow's default_parameters." })),
208
+ confirmWrite: Type.Optional(Type.Boolean({ description: "Required for non-interactive execution. Set true only after explicit user confirmation." })),
209
+ }),
210
+ async execute(_toolCallId, params: ConnectionParams & { name: string; parameters?: Record<string, unknown>; confirmWrite?: boolean }, _signal, _onUpdate, ctx) {
211
+ if (!params.confirmWrite) {
212
+ if (!ctx.hasUI || !(await ctx.ui.confirm("Run QDash flow?", `This starts flow '${params.name}' on the workflow worker. Completed tasks update calibrated parameters in QDash directly.`))) {
213
+ throw new Error("qdash_execute_flow requires explicit confirmation");
214
+ }
215
+ }
216
+ const client = await deps.makeClient(params);
217
+ const result = (await client.executeFlow(params.name, params.parameters ?? {})) as FlowExecution;
218
+ return toTextToolResult(formatFlowExecution(result, params.name), result, {
219
+ tool: "qdash_execute_flow",
220
+ flowName: params.name,
221
+ executionId: result.execution_id,
222
+ started: true,
223
+ });
224
+ },
225
+ });
226
+ }
@@ -99,6 +99,19 @@ function notFound(error: unknown): boolean {
99
99
  return value.status === 404 || value.statusCode === 404 || value.response?.status === 404 || /\b404\b|not found/i.test(String(value.message ?? ""));
100
100
  }
101
101
 
102
+ const INLINE_IMAGE = /\(data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+\)/g;
103
+
104
+ /**
105
+ * The knowledge markdown as the model should read it: QDash inlines the
106
+ * expected-plot figures as base64 data URIs for self-contained rendering, which
107
+ * costs tens of thousands of tokens and tells a text model nothing. The alt
108
+ * text stays; the figure itself is shown on QDash's Knowledge page and by
109
+ * qdash_get_figure for results.
110
+ */
111
+ export function withoutInlineImages(markdown: string): string {
112
+ return markdown.replace(INLINE_IMAGE, "(figure omitted: shown on the task's Knowledge page)");
113
+ }
114
+
102
115
  export function registerTaskKnowledgeTool(pi: Pick<ExtensionAPI, "registerTool">, deps: TaskKnowledgeDependencies): void {
103
116
  pi.registerTool({
104
117
  name: "qdash_get_task_knowledge",
@@ -125,9 +138,11 @@ export function registerTaskKnowledgeTool(pi: Pick<ExtensionAPI, "registerTool">
125
138
  : await client.getTaskKnowledgeMarkdown(params.taskName);
126
139
  const markdown = format === "summary"
127
140
  ? taskKnowledgeSummary(data as TaskKnowledge)
128
- : typeof data === "string"
129
- ? data
130
- : text((data as { markdown?: unknown })?.markdown) ?? JSON.stringify(data, null, 2);
141
+ : withoutInlineImages(
142
+ typeof data === "string"
143
+ ? data
144
+ : text((data as { markdown?: unknown })?.markdown) ?? JSON.stringify(data, null, 2),
145
+ );
131
146
  return toTextToolResult(markdown, data, { taskName: params.taskName, format, found: true });
132
147
  } catch (error) {
133
148
  if (!notFound(error)) throw error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oqtopus-team/pi-qdash",
3
- "version": "0.1.11-beta.21",
3
+ "version": "0.1.11-beta.22",
4
4
  "description": "Pi extension for querying QDash via @oqtopus-team/qdash-client",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -41,6 +41,7 @@ Prefer the dedicated tool that matches the question. Use `qdash_query` only as a
41
41
  - `qdash_create_forum_post`, `qdash_update_forum_post` — create or update Forum content
42
42
  - `qdash_create_forum_evidence_reply`, `qdash_create_forum_image_reply` — publish previously reviewed evidence replies
43
43
  - `qdash_run_pipeline` — run a validated pipeline spec as one execution
44
+ - `qdash_save_flow`, `qdash_execute_flow` — save a Prefect flow you wrote as a QDash workflow, and run a saved flow (see Authoring a flow)
44
45
  4. Agent workflow state tools:
45
46
  - `qdash_get_agent_session`, `qdash_get_agent_action`, `qdash_list_agent_actions`, `qdash_wait_agent_action` — inspect agent workflow state
46
47
  - `qdash_list_agent_action_candidates`, `qdash_get_agent_candidate_commit`, `qdash_wait_agent_candidate_apply` — inspect candidates and apply status
@@ -67,6 +68,17 @@ Rules:
67
68
  - A pipeline takes the project execution lock. Check `qdash_list_executions` for a running execution before starting one.
68
69
  - Prefer short pipelines (one or two steps) when the next step depends on the result: run, inspect results and figures, then compose the next spec. Calibrated parameters persist in QDash between executions, so a later spec continues from the earlier one. Retries and branches are your decision between executions, not something the spec expresses.
69
70
 
71
+ ## Authoring a flow
72
+
73
+ A flow is a Python module with one `@flow` function that QDash runs on its workflow worker, with hardware and database access. Pipelines (above) cover ordered task lists without code; write a flow only when the user needs logic a pipeline cannot express: loops until convergence, branching on results, custom parameter sweeps.
74
+
75
+ Workflow:
76
+
77
+ 1. Start from what exists. `qdash_query` with `flow_templates` lists the templates and `flow_template` returns one with its code; `qdash_get_flow` returns a saved flow; `flow_helper_files` / `flow_helper_file` show the helpers templates import. Keep the template's structure: its imports, the `@flow` signature, and the terminal handling at the end of the run.
78
+ 2. Write the module. Name the `@flow` function like the flow unless the user wants otherwise. Task and parameter names must come from `qdash_get_pipeline_catalog` and the template; do not invent them. When a shell workspace is available, draft the file there and run `python -m py_compile` on it before showing it.
79
+ 3. Show the user the complete code and the chip it is for, then `qdash_save_flow`. QDash checks the `@flow` function, formats the file, registers a Prefect deployment, and replaces a flow of the same name. A rejection comes back as text with QDash's reason; fix the code and save again.
80
+ 4. Saving does not run anything. Run it only when the user asks: `qdash_execute_flow` with the flow's parameters (confirmation-gated, takes the project execution lock), then `qdash_wait_execution`.
81
+
70
82
  ## Session context
71
83
 
72
84
  When running in pi CLI, use `qdash_config_info` if the profile or non-secret connection settings are unclear.