@sweny-ai/core 0.1.159 → 0.1.161
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/cli/final-output.d.ts +41 -0
- package/dist/cli/final-output.js +155 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.js +45 -2
- package/dist/cli/run-history.js +4 -1
- package/dist/cli/run-output.d.ts +25 -0
- package/dist/cli/run-output.js +47 -0
- package/dist/cli/templates.js +27 -1
- package/dist/cli/workflow-upgrade.d.ts +35 -0
- package/dist/cli/workflow-upgrade.js +139 -0
- package/dist/harness/claude-code.d.ts +2 -0
- package/dist/harness/claude-code.js +13 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/loader.d.ts +8 -1
- package/dist/loader.js +12 -2
- package/dist/migrations.d.ts +63 -0
- package/dist/migrations.js +93 -0
- package/dist/schema.d.ts +9 -0
- package/dist/schema.js +9 -0
- package/dist/types.d.ts +16 -0
- package/package.json +1 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The answer at the end of `sweny workflow run`: the terminal node's result
|
|
3
|
+
* (or the failed node's error), rendered for a human.
|
|
4
|
+
*
|
|
5
|
+
* Unlike run-history and the receipt, this DOES read node output. It is
|
|
6
|
+
* local-only: printed to the terminal and saved to
|
|
7
|
+
* `.sweny/runs/<run-id>/output.md`. Nothing here is sent to the cloud.
|
|
8
|
+
*/
|
|
9
|
+
import type { JSONSchema, NodeResult, Workflow } from "../types.js";
|
|
10
|
+
/** Lines of the answer printed to the terminal before the rest is left in output.md. */
|
|
11
|
+
export declare const FINAL_OUTPUT_MAX_LINES = 60;
|
|
12
|
+
export interface FinalOutput {
|
|
13
|
+
kind: "result" | "error";
|
|
14
|
+
/** Node id the text came from. */
|
|
15
|
+
node: string;
|
|
16
|
+
/** Rendered lines, uncapped. */
|
|
17
|
+
lines: string[];
|
|
18
|
+
}
|
|
19
|
+
/** Render a node's declared output fields as a compact `key: value` block. Empty when nothing declared is present. */
|
|
20
|
+
export declare function renderSchemaOutput(data: Record<string, unknown>, schema: JSONSchema): string[];
|
|
21
|
+
/**
|
|
22
|
+
* Pick what the user should see: the last-run terminal node's result on
|
|
23
|
+
* success, the first failed node's error on failure. Null when there is
|
|
24
|
+
* nothing to show (no schema fields and no summary text).
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveFinalOutput(workflow: Workflow, results: Map<string, NodeResult>): FinalOutput | null;
|
|
27
|
+
/**
|
|
28
|
+
* Terminal text: the first `maxLines` lines, then
|
|
29
|
+
* `... (N more lines, see .sweny/runs/<id>/output.md)`. When no file was
|
|
30
|
+
* saved the pointer is dropped, never invented.
|
|
31
|
+
*/
|
|
32
|
+
export declare function formatFinalOutput(out: FinalOutput, opts?: {
|
|
33
|
+
maxLines?: number;
|
|
34
|
+
outputPath?: string | null;
|
|
35
|
+
}): string;
|
|
36
|
+
/** Markdown saved to output.md: the full, uncapped answer. */
|
|
37
|
+
export declare function formatFinalMarkdown(workflow: Workflow, out: FinalOutput): string;
|
|
38
|
+
/** Relative path shown to the user: `.sweny/runs/<id>/output.md`. */
|
|
39
|
+
export declare function outputRelPath(runId: string): string;
|
|
40
|
+
/** Write output.md. Local only. Returns the relative path, or null on failure. Never throws. */
|
|
41
|
+
export declare function writeFinalOutput(runId: string, markdown: string, cwd?: string): string | null;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The answer at the end of `sweny workflow run`: the terminal node's result
|
|
3
|
+
* (or the failed node's error), rendered for a human.
|
|
4
|
+
*
|
|
5
|
+
* Unlike run-history and the receipt, this DOES read node output. It is
|
|
6
|
+
* local-only: printed to the terminal and saved to
|
|
7
|
+
* `.sweny/runs/<run-id>/output.md`. Nothing here is sent to the cloud.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { RUN_HISTORY_DIR } from "./run-history.js";
|
|
12
|
+
/** Lines of the answer printed to the terminal before the rest is left in output.md. */
|
|
13
|
+
export const FINAL_OUTPUT_MAX_LINES = 60;
|
|
14
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
15
|
+
function isEmpty(v) {
|
|
16
|
+
return (v === undefined ||
|
|
17
|
+
v === null ||
|
|
18
|
+
(typeof v === "string" && v.trim() === "") ||
|
|
19
|
+
(Array.isArray(v) && v.length === 0) ||
|
|
20
|
+
(isObj(v) && Object.keys(v).length === 0));
|
|
21
|
+
}
|
|
22
|
+
function scalar(v) {
|
|
23
|
+
return typeof v === "string" ? v : JSON.stringify(v);
|
|
24
|
+
}
|
|
25
|
+
function indentLines(lines, pad) {
|
|
26
|
+
return lines.map((l) => (l === "" ? l : pad + l));
|
|
27
|
+
}
|
|
28
|
+
/** One value as lines under a label: `key: value`, or `key:` plus an indented block. */
|
|
29
|
+
function renderField(label, value, schema) {
|
|
30
|
+
if (Array.isArray(value)) {
|
|
31
|
+
const itemSchema = isObj(schema?.items) ? schema.items : undefined;
|
|
32
|
+
const out = [`${label}:`];
|
|
33
|
+
for (const item of value)
|
|
34
|
+
out.push(...renderListItem(item, itemSchema));
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
if (isObj(value)) {
|
|
38
|
+
return [`${label}:`, ...indentLines(renderObject(value, schema), " ")];
|
|
39
|
+
}
|
|
40
|
+
const text = scalar(value);
|
|
41
|
+
if (!text.includes("\n"))
|
|
42
|
+
return [`${label}: ${text}`];
|
|
43
|
+
return [`${label}:`, ...indentLines(text.split("\n"), " ")];
|
|
44
|
+
}
|
|
45
|
+
function renderListItem(item, schema) {
|
|
46
|
+
if (isObj(item)) {
|
|
47
|
+
const inner = renderObject(item, schema);
|
|
48
|
+
if (inner.length === 0)
|
|
49
|
+
return [];
|
|
50
|
+
return [`- ${inner[0]}`, ...indentLines(inner.slice(1), " ")];
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(item)) {
|
|
53
|
+
return [`- ${item.map(scalar).join(", ")}`];
|
|
54
|
+
}
|
|
55
|
+
const text = scalar(item);
|
|
56
|
+
const [first, ...rest] = text.split("\n");
|
|
57
|
+
return [`- ${first}`, ...indentLines(rest, " ")];
|
|
58
|
+
}
|
|
59
|
+
/** Declared fields first (schema order), then none of the extras: the schema is the contract. */
|
|
60
|
+
function renderObject(data, schema) {
|
|
61
|
+
const props = isObj(schema?.properties) ? schema.properties : undefined;
|
|
62
|
+
const keys = props ? Object.keys(props) : Object.keys(data);
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const key of keys) {
|
|
65
|
+
const value = data[key];
|
|
66
|
+
if (isEmpty(value))
|
|
67
|
+
continue;
|
|
68
|
+
const propSchema = props && isObj(props[key]) ? props[key] : undefined;
|
|
69
|
+
const title = typeof propSchema?.title === "string" && propSchema.title.trim() ? propSchema.title.trim() : key;
|
|
70
|
+
out.push(...renderField(title, value, propSchema));
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/** Render a node's declared output fields as a compact `key: value` block. Empty when nothing declared is present. */
|
|
75
|
+
export function renderSchemaOutput(data, schema) {
|
|
76
|
+
const props = schema.properties;
|
|
77
|
+
if (!isObj(props) || Object.keys(props).length === 0)
|
|
78
|
+
return [];
|
|
79
|
+
return renderObject(data, schema);
|
|
80
|
+
}
|
|
81
|
+
function trimBlock(text) {
|
|
82
|
+
const lines = text.replace(/\r\n/g, "\n").trim().split("\n");
|
|
83
|
+
return lines.map((l) => l.replace(/\s+$/, ""));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Pick what the user should see: the last-run terminal node's result on
|
|
87
|
+
* success, the first failed node's error on failure. Null when there is
|
|
88
|
+
* nothing to show (no schema fields and no summary text).
|
|
89
|
+
*/
|
|
90
|
+
export function resolveFinalOutput(workflow, results) {
|
|
91
|
+
for (const [id, r] of results) {
|
|
92
|
+
if (r.status !== "failed")
|
|
93
|
+
continue;
|
|
94
|
+
const err = r.data?.error;
|
|
95
|
+
const text = typeof err === "string" && err.trim() !== "" ? err : "node failed";
|
|
96
|
+
return { kind: "error", node: id, lines: trimBlock(text) };
|
|
97
|
+
}
|
|
98
|
+
const hasOutgoing = new Set(workflow.edges.map((e) => e.from));
|
|
99
|
+
const succeeded = [...results].filter(([, r]) => r.status === "success");
|
|
100
|
+
const terminal = succeeded.filter(([id]) => !hasOutgoing.has(id));
|
|
101
|
+
// Walk back from the end: the last node that actually yields something wins.
|
|
102
|
+
const candidates = [...(terminal.length > 0 ? terminal : succeeded)].reverse();
|
|
103
|
+
for (const [id, r] of candidates) {
|
|
104
|
+
const schema = workflow.nodes[id]?.output;
|
|
105
|
+
let lines = [];
|
|
106
|
+
if (schema)
|
|
107
|
+
lines = renderSchemaOutput(r.data ?? {}, schema);
|
|
108
|
+
if (lines.length === 0) {
|
|
109
|
+
const summary = r.data?.summary;
|
|
110
|
+
if (typeof summary === "string" && summary.trim() !== "")
|
|
111
|
+
lines = trimBlock(summary);
|
|
112
|
+
}
|
|
113
|
+
if (lines.length > 0)
|
|
114
|
+
return { kind: "result", node: id, lines };
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Terminal text: the first `maxLines` lines, then
|
|
120
|
+
* `... (N more lines, see .sweny/runs/<id>/output.md)`. When no file was
|
|
121
|
+
* saved the pointer is dropped, never invented.
|
|
122
|
+
*/
|
|
123
|
+
export function formatFinalOutput(out, opts = {}) {
|
|
124
|
+
const max = opts.maxLines ?? FINAL_OUTPUT_MAX_LINES;
|
|
125
|
+
const shown = out.lines.slice(0, max);
|
|
126
|
+
const hidden = out.lines.length - shown.length;
|
|
127
|
+
if (hidden > 0) {
|
|
128
|
+
const noun = hidden === 1 ? "line" : "lines";
|
|
129
|
+
shown.push(opts.outputPath ? `... (${hidden} more ${noun}, see ${opts.outputPath})` : `... (${hidden} more ${noun})`);
|
|
130
|
+
}
|
|
131
|
+
return shown.map((l) => (l === "" ? "" : ` ${l}`)).join("\n");
|
|
132
|
+
}
|
|
133
|
+
/** Markdown saved to output.md: the full, uncapped answer. */
|
|
134
|
+
export function formatFinalMarkdown(workflow, out) {
|
|
135
|
+
const nodeName = workflow.nodes[out.node]?.name ?? out.node;
|
|
136
|
+
const head = out.kind === "error" ? `# ${workflow.name}: ${nodeName} failed` : `# ${workflow.name}`;
|
|
137
|
+
return `${head}\n\n${out.lines.join("\n")}\n`;
|
|
138
|
+
}
|
|
139
|
+
/** Relative path shown to the user: `.sweny/runs/<id>/output.md`. */
|
|
140
|
+
export function outputRelPath(runId) {
|
|
141
|
+
return path.posix.join(...RUN_HISTORY_DIR.split(path.sep), runId, "output.md");
|
|
142
|
+
}
|
|
143
|
+
/** Write output.md. Local only. Returns the relative path, or null on failure. Never throws. */
|
|
144
|
+
export function writeFinalOutput(runId, markdown, cwd = process.cwd()) {
|
|
145
|
+
try {
|
|
146
|
+
const rel = outputRelPath(runId);
|
|
147
|
+
const file = path.join(cwd, rel);
|
|
148
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
149
|
+
fs.writeFileSync(file, markdown);
|
|
150
|
+
return rel;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
package/dist/cli/main.d.ts
CHANGED
|
@@ -17,3 +17,6 @@ export declare function workflowExportAction(name: string): void;
|
|
|
17
17
|
export declare function workflowValidateAction(file: string, options: {
|
|
18
18
|
json?: boolean;
|
|
19
19
|
}): void;
|
|
20
|
+
export declare function workflowUpgradeAction(file: string, options: {
|
|
21
|
+
dryRun?: boolean;
|
|
22
|
+
}): void;
|
package/dist/cli/main.js
CHANGED
|
@@ -19,6 +19,7 @@ import { configuredSkills, configuredSkillsWithDiagnostics } from "../skills/cus
|
|
|
19
19
|
import { buildAutoMcpServers, buildSkillMcpServers, buildProviderContext } from "../mcp.js";
|
|
20
20
|
import { loadAdditionalContext } from "../templates.js";
|
|
21
21
|
import { loadAndValidateWorkflow } from "../loader.js";
|
|
22
|
+
import { upgradeWorkflowFile } from "./workflow-upgrade.js";
|
|
22
23
|
import { validateRuntimeInput } from "../inputs.js";
|
|
23
24
|
import { mergeDryRunIntoInput, parseInputFlag, parseRunBudgetFlags, parseSpendFlags } from "./workflow-input.js";
|
|
24
25
|
import { stringify as stringifyYaml } from "yaml";
|
|
@@ -30,6 +31,7 @@ import * as readline from "node:readline";
|
|
|
30
31
|
import { loadDotenv, loadConfigFile, applyAgentFileConfig } from "./config-file.js";
|
|
31
32
|
import { buildCredentialMap } from "./credentials.js";
|
|
32
33
|
import { nonInteractiveUsage, runNew } from "./new.js";
|
|
34
|
+
import { formatFinalMarkdown, formatFinalOutput, resolveFinalOutput, writeFinalOutput } from "./final-output.js";
|
|
33
35
|
import { buildRunRecord, createNodeTimer, historyDisabled, newRunId, recordRun } from "./run-history.js";
|
|
34
36
|
import { registerRunsCommand } from "./runs.js";
|
|
35
37
|
import { runE2eRun, runWithWallClockBudget, DEFAULT_WORKFLOW_TIMEOUT_MS } from "./e2e.js";
|
|
@@ -724,6 +726,8 @@ export function loadWorkflowFile(filePath, knownSkills) {
|
|
|
724
726
|
if (!result.ok) {
|
|
725
727
|
throw new Error(`Invalid workflow file:\n${result.errors.map((e) => ` ${e.message}`).join("\n")}`);
|
|
726
728
|
}
|
|
729
|
+
for (const w of result.warnings ?? [])
|
|
730
|
+
console.error(chalk.yellow(` \u26A0 ${w}`));
|
|
727
731
|
return result.workflow;
|
|
728
732
|
}
|
|
729
733
|
export async function workflowRunAction(file, options) {
|
|
@@ -1049,6 +1053,19 @@ export async function workflowRunAction(file, options) {
|
|
|
1049
1053
|
runLogger.flush();
|
|
1050
1054
|
const receipt = summarizeRun(results, wfDurationMs);
|
|
1051
1055
|
writeStepSummary(workflow, results, receipt, trace);
|
|
1056
|
+
// The answer, above the receipt: the terminal node's result, or the failed node's error.
|
|
1057
|
+
// Also saved to .sweny/runs/<id>/output.md (local only; --no-history skips the file).
|
|
1058
|
+
const finalOutput = resolveFinalOutput(workflow, results);
|
|
1059
|
+
if (finalOutput) {
|
|
1060
|
+
const savedTo = historyDisabled(options.history, fileConfig["history"])
|
|
1061
|
+
? null
|
|
1062
|
+
: writeFinalOutput(runId, formatFinalMarkdown(workflow, finalOutput));
|
|
1063
|
+
const block = formatFinalOutput(finalOutput, { outputPath: savedTo });
|
|
1064
|
+
if (wfHasFailed)
|
|
1065
|
+
console.error(`${block}\n`);
|
|
1066
|
+
else
|
|
1067
|
+
console.log(`${block}\n`);
|
|
1068
|
+
}
|
|
1052
1069
|
if (wfHasFailed) {
|
|
1053
1070
|
console.error(` ${renderReceiptLine(receipt, isTTY)}\n`);
|
|
1054
1071
|
process.exit(1);
|
|
@@ -1108,7 +1125,10 @@ export function workflowValidateAction(file, options) {
|
|
|
1108
1125
|
const result = loadAndValidateWorkflow(file);
|
|
1109
1126
|
// Missing skill env is a warning here, not a failure: `run` is where it blocks.
|
|
1110
1127
|
const warnings = result.ok
|
|
1111
|
-
?
|
|
1128
|
+
? [
|
|
1129
|
+
...(result.warnings ?? []),
|
|
1130
|
+
...skillEnvWarnings(result.workflow, process.env, builtinSkills.concat(configuredSkills(process.env, process.cwd()))),
|
|
1131
|
+
]
|
|
1112
1132
|
: [];
|
|
1113
1133
|
if (options.json) {
|
|
1114
1134
|
const errs = result.ok ? [] : result.errors;
|
|
@@ -1135,6 +1155,29 @@ workflowCmd
|
|
|
1135
1155
|
.description("Validate a workflow YAML or JSON file")
|
|
1136
1156
|
.option("--json", "Output result as JSON")
|
|
1137
1157
|
.action(workflowValidateAction);
|
|
1158
|
+
export function workflowUpgradeAction(file, options) {
|
|
1159
|
+
const result = upgradeWorkflowFile(file, { dryRun: options.dryRun });
|
|
1160
|
+
if (result.status === "error") {
|
|
1161
|
+
console.error(chalk.red(` \u2717 ${result.message}`));
|
|
1162
|
+
process.exit(1);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
if (result.status === "current") {
|
|
1166
|
+
console.log(chalk.green(` \u2713 ${file} is already at spec_version "${result.version}"`));
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
const verb = result.status === "dry-run" ? "Would upgrade" : "Upgraded";
|
|
1170
|
+
console.log(chalk.green(` \u2713 ${verb} ${file}: spec_version "${result.from}" -> "${result.to}"`));
|
|
1171
|
+
for (const step of result.steps)
|
|
1172
|
+
console.log(chalk.dim(` - ${step}`));
|
|
1173
|
+
if (result.status === "dry-run")
|
|
1174
|
+
console.log(chalk.dim(" (dry run, file not changed)"));
|
|
1175
|
+
}
|
|
1176
|
+
workflowCmd
|
|
1177
|
+
.command("upgrade <file>")
|
|
1178
|
+
.description("Rewrite a workflow file at the current spec version (comments are kept where possible)")
|
|
1179
|
+
.option("--dry-run", "Show what would change without writing the file")
|
|
1180
|
+
.action(workflowUpgradeAction);
|
|
1138
1181
|
const workflowRunCmd = workflowCmd
|
|
1139
1182
|
.command("run [file]")
|
|
1140
1183
|
.description(WORKFLOW_RUN_DESCRIPTION)
|
|
@@ -1142,7 +1185,7 @@ const workflowRunCmd = workflowCmd
|
|
|
1142
1185
|
for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
|
|
1143
1186
|
workflowRunCmd.option(flags, description);
|
|
1144
1187
|
}
|
|
1145
|
-
workflowRunCmd.option("--no-history", "Do not record this run in .sweny/runs/ (or set `history: off` in .sweny.yml)");
|
|
1188
|
+
workflowRunCmd.option("--no-history", "Do not record this run or save its output.md in .sweny/runs/ (or set `history: off` in .sweny.yml)");
|
|
1146
1189
|
registerRunsCommand(program);
|
|
1147
1190
|
workflowCmd
|
|
1148
1191
|
.command("diagram <file>")
|
package/dist/cli/run-history.js
CHANGED
|
@@ -153,8 +153,11 @@ export function pruneRuns(cwd = process.cwd(), keep = RUN_HISTORY_KEEP) {
|
|
|
153
153
|
.filter((f) => f.endsWith(".json") && RUN_ID_RE.test(f.slice(0, -5)))
|
|
154
154
|
.sort();
|
|
155
155
|
const extra = files.slice(0, Math.max(0, files.length - keep));
|
|
156
|
-
for (const f of extra)
|
|
156
|
+
for (const f of extra) {
|
|
157
157
|
fs.rmSync(path.join(dir, f), { force: true });
|
|
158
|
+
// output.md lives beside the record in <run-id>/ (final-output.ts)
|
|
159
|
+
fs.rmSync(path.join(dir, f.slice(0, -5)), { recursive: true, force: true });
|
|
160
|
+
}
|
|
158
161
|
return extra.length;
|
|
159
162
|
}
|
|
160
163
|
catch {
|
package/dist/cli/run-output.d.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface RunSummary {
|
|
|
25
25
|
* (`max_turns`, `egress allowlist`, `deny [write]`). Absent when none.
|
|
26
26
|
*/
|
|
27
27
|
degraded?: string[];
|
|
28
|
+
/**
|
|
29
|
+
* What the run's opinions did, from facts the nodes reported: env scope and
|
|
30
|
+
* sandbox (Claude Code only) and safe-output counts. Absent when the run
|
|
31
|
+
* knows none of it.
|
|
32
|
+
*/
|
|
33
|
+
policy?: RunPolicy;
|
|
28
34
|
/** The spend budget a node crossed (#449). Absent when none was. */
|
|
29
35
|
budget?: {
|
|
30
36
|
node: string;
|
|
@@ -34,10 +40,29 @@ export interface RunSummary {
|
|
|
34
40
|
spent: number;
|
|
35
41
|
};
|
|
36
42
|
}
|
|
43
|
+
export interface RunPolicy {
|
|
44
|
+
/** Agent env narrowed to the allowlist. Absent when no node reported it. */
|
|
45
|
+
envScope?: boolean;
|
|
46
|
+
/** Sandbox facts. `started` is false when the mode asked for one but the host could not. */
|
|
47
|
+
sandbox?: {
|
|
48
|
+
mode: "off" | "auto" | "strict";
|
|
49
|
+
started: boolean;
|
|
50
|
+
};
|
|
51
|
+
/** Safe outputs staged (previewed) and applied (written), summed over nodes. */
|
|
52
|
+
outputs?: {
|
|
53
|
+
staged: number;
|
|
54
|
+
applied: number;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
37
57
|
/** The short key of a `degraded` entry: the text before its first colon. */
|
|
38
58
|
export declare function degradedKey(entry: string): string;
|
|
39
59
|
/** Sum the per-node results into one run summary. */
|
|
40
60
|
export declare function summarizeRun(results: Map<string, NodeResult>, durationMs: number, crashed?: boolean): RunSummary;
|
|
61
|
+
/**
|
|
62
|
+
* `policy: env scoped, sandbox on, 1 output staged`. Only what the run knew:
|
|
63
|
+
* no segment for a fact no node reported. Undefined when there is nothing to say.
|
|
64
|
+
*/
|
|
65
|
+
export declare function formatPolicySegment(p: RunPolicy | undefined): string | undefined;
|
|
41
66
|
export declare function formatReceiptDuration(ms: number): string;
|
|
42
67
|
export declare function formatTokenCount(n: number): string;
|
|
43
68
|
export declare function formatCost(usd: number): string;
|
package/dist/cli/run-output.js
CHANGED
|
@@ -25,6 +25,9 @@ export function summarizeRun(results, durationMs, crashed = false) {
|
|
|
25
25
|
let harness;
|
|
26
26
|
const degraded = new Set();
|
|
27
27
|
let budget;
|
|
28
|
+
const policy = {};
|
|
29
|
+
let staged = 0;
|
|
30
|
+
let applied = 0;
|
|
28
31
|
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
29
32
|
for (const [nodeId, r] of results) {
|
|
30
33
|
if (r.budget && !budget)
|
|
@@ -32,6 +35,20 @@ export function summarizeRun(results, durationMs, crashed = false) {
|
|
|
32
35
|
harness ??= r.harness?.id;
|
|
33
36
|
for (const d of r.degraded ?? [])
|
|
34
37
|
degraded.add(degradedKey(d));
|
|
38
|
+
if (r.policy) {
|
|
39
|
+
// Any node that ran unscoped or unsandboxed counts: the receipt must not overstate.
|
|
40
|
+
policy.envScope = policy.envScope === undefined ? r.policy.envScope : policy.envScope && r.policy.envScope;
|
|
41
|
+
const sb = { mode: r.policy.sandbox, started: r.policy.sandboxStarted };
|
|
42
|
+
// Keep the weakest node: the first one that did not run sandboxed.
|
|
43
|
+
if (!policy.sandbox || (policy.sandbox.started && !sb.started))
|
|
44
|
+
policy.sandbox = sb;
|
|
45
|
+
}
|
|
46
|
+
for (const o of r.outputs ?? []) {
|
|
47
|
+
if (o.status === "staged")
|
|
48
|
+
staged++;
|
|
49
|
+
else if (o.status === "applied")
|
|
50
|
+
applied++;
|
|
51
|
+
}
|
|
35
52
|
if (r.status === "success")
|
|
36
53
|
nodesOk++;
|
|
37
54
|
else if (r.status === "skipped")
|
|
@@ -49,6 +66,8 @@ export function summarizeRun(results, durationMs, crashed = false) {
|
|
|
49
66
|
costUsd = (costUsd ?? 0) + u.costUsd;
|
|
50
67
|
}
|
|
51
68
|
}
|
|
69
|
+
if (staged > 0 || applied > 0)
|
|
70
|
+
policy.outputs = { staged, applied };
|
|
52
71
|
return {
|
|
53
72
|
ok: !failed,
|
|
54
73
|
nodesOk,
|
|
@@ -60,9 +79,36 @@ export function summarizeRun(results, durationMs, crashed = false) {
|
|
|
60
79
|
...(costUsd !== undefined ? { costUsd } : {}),
|
|
61
80
|
...(harness !== undefined ? { harness } : {}),
|
|
62
81
|
...(degraded.size > 0 ? { degraded: [...degraded] } : {}),
|
|
82
|
+
...(Object.keys(policy).length > 0 ? { policy } : {}),
|
|
63
83
|
...(budget ? { budget } : {}),
|
|
64
84
|
};
|
|
65
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* `policy: env scoped, sandbox on, 1 output staged`. Only what the run knew:
|
|
88
|
+
* no segment for a fact no node reported. Undefined when there is nothing to say.
|
|
89
|
+
*/
|
|
90
|
+
export function formatPolicySegment(p) {
|
|
91
|
+
if (!p)
|
|
92
|
+
return undefined;
|
|
93
|
+
const parts = [];
|
|
94
|
+
if (p.envScope !== undefined)
|
|
95
|
+
parts.push(p.envScope ? "env scoped" : "env unscoped");
|
|
96
|
+
if (p.sandbox) {
|
|
97
|
+
parts.push(p.sandbox.mode === "off"
|
|
98
|
+
? "sandbox off"
|
|
99
|
+
: p.sandbox.started
|
|
100
|
+
? "sandbox on"
|
|
101
|
+
: "sandbox unavailable (ran unsandboxed)");
|
|
102
|
+
}
|
|
103
|
+
if (p.outputs) {
|
|
104
|
+
const n = (count) => `${count} ${count === 1 ? "output" : "outputs"}`;
|
|
105
|
+
if (p.outputs.staged > 0)
|
|
106
|
+
parts.push(`${n(p.outputs.staged)} staged`);
|
|
107
|
+
if (p.outputs.applied > 0)
|
|
108
|
+
parts.push(`${n(p.outputs.applied)} applied`);
|
|
109
|
+
}
|
|
110
|
+
return parts.length > 0 ? `policy: ${parts.join(", ")}` : undefined;
|
|
111
|
+
}
|
|
66
112
|
export function formatReceiptDuration(ms) {
|
|
67
113
|
if (ms < 1000)
|
|
68
114
|
return `${Math.max(0, Math.round(ms))}ms`;
|
|
@@ -108,6 +154,7 @@ export function formatReceipt(s) {
|
|
|
108
154
|
...(s.costUsd !== undefined ? [formatCost(s.costUsd)] : []),
|
|
109
155
|
...(s.harness !== undefined && s.harness !== "claude-code" ? [s.harness] : []),
|
|
110
156
|
...(s.degraded && s.degraded.length > 0 ? [`degraded: ${s.degraded.join(", ")}`] : []),
|
|
157
|
+
...(formatPolicySegment(s.policy) ? [formatPolicySegment(s.policy)] : []),
|
|
111
158
|
...(s.budget ? [formatBudgetOverrun(s.budget)] : []),
|
|
112
159
|
];
|
|
113
160
|
return parts.join(" · ");
|
package/dist/cli/templates.js
CHANGED
|
@@ -31,7 +31,33 @@ nodes:
|
|
|
31
31
|
From the survey, write a short plain-English explanation:
|
|
32
32
|
what this project does, how it is organized, how to build and
|
|
33
33
|
run it, and where a new contributor should start.
|
|
34
|
-
Keep
|
|
34
|
+
Keep every field short and concrete: a sentence or a few
|
|
35
|
+
bullets, in plain English, naming real files and commands.
|
|
36
|
+
output:
|
|
37
|
+
type: object
|
|
38
|
+
properties:
|
|
39
|
+
purpose:
|
|
40
|
+
type: string
|
|
41
|
+
title: What it is
|
|
42
|
+
description: One or two sentences on what this project does and who it is for.
|
|
43
|
+
how_to_run:
|
|
44
|
+
type: array
|
|
45
|
+
title: How to run it
|
|
46
|
+
description: The commands, in order, to install, build, test, and start it.
|
|
47
|
+
items:
|
|
48
|
+
type: string
|
|
49
|
+
key_files:
|
|
50
|
+
type: array
|
|
51
|
+
title: Key files
|
|
52
|
+
description: The files and directories a new contributor should open first, each with a few words on why.
|
|
53
|
+
items:
|
|
54
|
+
type: string
|
|
55
|
+
risks:
|
|
56
|
+
type: array
|
|
57
|
+
title: Watch out for
|
|
58
|
+
description: Sharp edges, missing docs, or things that look fragile. Empty if none.
|
|
59
|
+
items:
|
|
60
|
+
type: string
|
|
35
61
|
|
|
36
62
|
edges:
|
|
37
63
|
- from: survey
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sweny workflow upgrade <file> [--dry-run]`
|
|
3
|
+
*
|
|
4
|
+
* Rewrites a workflow file at the current spec version by running the same
|
|
5
|
+
* migration chain the loader applies in memory.
|
|
6
|
+
*
|
|
7
|
+
* Comment preservation (YAML): the file is edited as a YAML document, not
|
|
8
|
+
* re-serialized from scratch. Comments, blank lines, key order and quoting on
|
|
9
|
+
* everything a migration did not touch are kept. NOT preserved: a comment
|
|
10
|
+
* attached to a value a migration replaced or removed, the original position of
|
|
11
|
+
* keys a migration renamed (they are added at the end of their map), and
|
|
12
|
+
* formatting of arrays whose length a migration changed (rewritten whole).
|
|
13
|
+
* JSON files carry no comments and are rewritten with 2-space indentation.
|
|
14
|
+
*/
|
|
15
|
+
import { type MigrationConfig } from "../migrations.js";
|
|
16
|
+
export type UpgradeResult = {
|
|
17
|
+
status: "error";
|
|
18
|
+
message: string;
|
|
19
|
+
} | {
|
|
20
|
+
status: "current";
|
|
21
|
+
version: number;
|
|
22
|
+
} | {
|
|
23
|
+
status: "upgraded" | "dry-run";
|
|
24
|
+
from: number;
|
|
25
|
+
to: number;
|
|
26
|
+
/** Migration descriptions applied, in order. Empty when only `spec_version` was stamped. */
|
|
27
|
+
steps: string[];
|
|
28
|
+
/** The full text that was (or, for dry-run, would be) written. */
|
|
29
|
+
content: string;
|
|
30
|
+
};
|
|
31
|
+
export interface UpgradeOptions {
|
|
32
|
+
dryRun?: boolean;
|
|
33
|
+
migration?: MigrationConfig;
|
|
34
|
+
}
|
|
35
|
+
export declare function upgradeWorkflowFile(filePath: string, options?: UpgradeOptions): UpgradeResult;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sweny workflow upgrade <file> [--dry-run]`
|
|
3
|
+
*
|
|
4
|
+
* Rewrites a workflow file at the current spec version by running the same
|
|
5
|
+
* migration chain the loader applies in memory.
|
|
6
|
+
*
|
|
7
|
+
* Comment preservation (YAML): the file is edited as a YAML document, not
|
|
8
|
+
* re-serialized from scratch. Comments, blank lines, key order and quoting on
|
|
9
|
+
* everything a migration did not touch are kept. NOT preserved: a comment
|
|
10
|
+
* attached to a value a migration replaced or removed, the original position of
|
|
11
|
+
* keys a migration renamed (they are added at the end of their map), and
|
|
12
|
+
* formatting of arrays whose length a migration changed (rewritten whole).
|
|
13
|
+
* JSON files carry no comments and are rewritten with 2-space indentation.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { isMap, parseDocument } from "yaml";
|
|
18
|
+
import { DEFAULT_MIGRATION_CONFIG, migrateWorkflow } from "../migrations.js";
|
|
19
|
+
import { validateParsed } from "../loader.js";
|
|
20
|
+
function isPlainObject(v) {
|
|
21
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
22
|
+
}
|
|
23
|
+
function withoutSpecVersion(obj) {
|
|
24
|
+
const copy = { ...obj };
|
|
25
|
+
delete copy.spec_version;
|
|
26
|
+
return copy;
|
|
27
|
+
}
|
|
28
|
+
function sameJson(a, b) {
|
|
29
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
30
|
+
}
|
|
31
|
+
/** Apply the difference old -> next onto the YAML document, touching only what changed. */
|
|
32
|
+
function applyDiff(doc, at, old, next) {
|
|
33
|
+
if (sameJson(old, next))
|
|
34
|
+
return;
|
|
35
|
+
if (isPlainObject(old) && isPlainObject(next)) {
|
|
36
|
+
for (const key of Object.keys(old)) {
|
|
37
|
+
if (!(key in next))
|
|
38
|
+
doc.deleteIn([...at, key]);
|
|
39
|
+
}
|
|
40
|
+
for (const key of Object.keys(next)) {
|
|
41
|
+
if (key in old)
|
|
42
|
+
applyDiff(doc, [...at, key], old[key], next[key]);
|
|
43
|
+
else
|
|
44
|
+
doc.setIn([...at, key], next[key]);
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (Array.isArray(old) && Array.isArray(next) && old.length === next.length) {
|
|
49
|
+
next.forEach((item, i) => applyDiff(doc, [...at, i], old[i], item));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (at.length === 0)
|
|
53
|
+
doc.contents = doc.createNode(next);
|
|
54
|
+
else
|
|
55
|
+
doc.setIn(at, next);
|
|
56
|
+
}
|
|
57
|
+
function setSpecVersion(doc, version) {
|
|
58
|
+
const root = doc.contents;
|
|
59
|
+
if (!isMap(root))
|
|
60
|
+
return;
|
|
61
|
+
if (root.has("spec_version")) {
|
|
62
|
+
root.set("spec_version", version);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Insert as the first key. A comment sitting directly above the old first key
|
|
66
|
+
// is the file's header comment; carry it onto the new pair so it stays on top.
|
|
67
|
+
const first = root.items[0];
|
|
68
|
+
const pair = doc.createPair("spec_version", version);
|
|
69
|
+
const firstKey = first?.key;
|
|
70
|
+
if (firstKey && firstKey.commentBefore) {
|
|
71
|
+
pair.key.commentBefore = firstKey.commentBefore;
|
|
72
|
+
firstKey.commentBefore = undefined;
|
|
73
|
+
}
|
|
74
|
+
root.items.unshift(pair);
|
|
75
|
+
}
|
|
76
|
+
export function upgradeWorkflowFile(filePath, options = {}) {
|
|
77
|
+
const config = options.migration ?? DEFAULT_MIGRATION_CONFIG;
|
|
78
|
+
let content;
|
|
79
|
+
try {
|
|
80
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
return { status: "error", message: `Could not read "${filePath}": ${err instanceof Error ? err.message : err}` };
|
|
84
|
+
}
|
|
85
|
+
const isYaml = [".yaml", ".yml"].includes(path.extname(filePath).toLowerCase());
|
|
86
|
+
let doc;
|
|
87
|
+
let raw;
|
|
88
|
+
try {
|
|
89
|
+
if (isYaml) {
|
|
90
|
+
doc = parseDocument(content);
|
|
91
|
+
if (doc.errors.length > 0)
|
|
92
|
+
throw doc.errors[0];
|
|
93
|
+
raw = doc.toJS();
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
raw = JSON.parse(content);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
return { status: "error", message: `Could not parse "${filePath}": ${err instanceof Error ? err.message : err}` };
|
|
101
|
+
}
|
|
102
|
+
if (!isPlainObject(raw))
|
|
103
|
+
return { status: "error", message: `Expected a workflow object in "${filePath}"` };
|
|
104
|
+
const migrated = migrateWorkflow(raw, config);
|
|
105
|
+
if (!migrated.ok)
|
|
106
|
+
return { status: "error", message: migrated.message };
|
|
107
|
+
const versionText = String(migrated.to);
|
|
108
|
+
if (migrated.applied.length === 0 && raw.spec_version === versionText) {
|
|
109
|
+
return { status: "current", version: migrated.to };
|
|
110
|
+
}
|
|
111
|
+
// Never write a file that would not load.
|
|
112
|
+
const check = validateParsed(migrated.workflow, { migration: config });
|
|
113
|
+
if (!check.ok) {
|
|
114
|
+
return {
|
|
115
|
+
status: "error",
|
|
116
|
+
message: `The upgraded workflow is not valid, so "${filePath}" was left unchanged:\n${check.errors
|
|
117
|
+
.map((e) => ` ${e.message}`)
|
|
118
|
+
.join("\n")}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
let out;
|
|
122
|
+
if (doc) {
|
|
123
|
+
applyDiff(doc, [], withoutSpecVersion(raw), withoutSpecVersion(migrated.workflow));
|
|
124
|
+
setSpecVersion(doc, versionText);
|
|
125
|
+
out = doc.toString();
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
out = JSON.stringify({ spec_version: versionText, ...withoutSpecVersion(migrated.workflow) }, null, 2) + "\n";
|
|
129
|
+
}
|
|
130
|
+
if (!options.dryRun)
|
|
131
|
+
fs.writeFileSync(filePath, out, "utf-8");
|
|
132
|
+
return {
|
|
133
|
+
status: options.dryRun ? "dry-run" : "upgraded",
|
|
134
|
+
from: migrated.from,
|
|
135
|
+
to: migrated.to,
|
|
136
|
+
steps: migrated.applied.map((m) => m.description),
|
|
137
|
+
content: out,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -150,6 +150,8 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
|
|
|
150
150
|
private sandboxProbe;
|
|
151
151
|
private sandboxWarned;
|
|
152
152
|
private envScope;
|
|
153
|
+
/** Floor facts from the latest runQuery, read by run() for the receipt. */
|
|
154
|
+
private lastPolicyFacts;
|
|
153
155
|
private toolBridge;
|
|
154
156
|
private toolBridgeShim;
|
|
155
157
|
private authProbe;
|
|
@@ -217,6 +217,8 @@ export class ClaudeCodeHarness {
|
|
|
217
217
|
sandboxProbe;
|
|
218
218
|
sandboxWarned = false;
|
|
219
219
|
envScope;
|
|
220
|
+
/** Floor facts from the latest runQuery, read by run() for the receipt. */
|
|
221
|
+
lastPolicyFacts;
|
|
220
222
|
toolBridge;
|
|
221
223
|
toolBridgeShim;
|
|
222
224
|
authProbe;
|
|
@@ -312,7 +314,12 @@ export class ClaudeCodeHarness {
|
|
|
312
314
|
sandboxMode: policy.sandbox,
|
|
313
315
|
agentAccess: { envVars: req.agentAccess?.envVars ?? [], domains: policy.egress, noPush: req.agentAccess?.noPush },
|
|
314
316
|
});
|
|
315
|
-
return {
|
|
317
|
+
return {
|
|
318
|
+
...result,
|
|
319
|
+
harness: this.info(),
|
|
320
|
+
degraded: gate.degraded,
|
|
321
|
+
...(this.lastPolicyFacts ? { policy: this.lastPolicyFacts } : {}),
|
|
322
|
+
};
|
|
316
323
|
}
|
|
317
324
|
async runQuery(opts) {
|
|
318
325
|
const { instruction, context, tools, outputSchema, onProgress, maxTurns, model, timeoutMs, signal, readOnly, agentAccess, onUsage, } = opts;
|
|
@@ -334,6 +341,11 @@ export class ClaudeCodeHarness {
|
|
|
334
341
|
probe: this.sandboxProbe,
|
|
335
342
|
logger: this.logger,
|
|
336
343
|
});
|
|
344
|
+
this.lastPolicyFacts = {
|
|
345
|
+
envScope: resolveEnvScope(process.env, this.envScope),
|
|
346
|
+
sandbox: sandbox.mode,
|
|
347
|
+
sandboxStarted: sandbox.settings !== undefined,
|
|
348
|
+
};
|
|
337
349
|
if (sandbox.error) {
|
|
338
350
|
this.logger.error(sandbox.error);
|
|
339
351
|
return { status: "failed", data: { error: sandbox.error, refused: true }, toolCalls: [] };
|
package/dist/index.d.ts
CHANGED
|
@@ -45,6 +45,8 @@ export { workflowZ, nodeZ, edgeZ, skillZ, mcpServerConfigZ, skillDefinitionZ, so
|
|
|
45
45
|
export type { WorkflowError } from "./schema.js";
|
|
46
46
|
export { loadAndValidateWorkflow, validateParsed } from "./loader.js";
|
|
47
47
|
export type { LoaderResult, LoaderError, LoaderOptions } from "./loader.js";
|
|
48
|
+
export { CURRENT_SPEC_VERSION, MIGRATIONS, migrateWorkflow } from "./migrations.js";
|
|
49
|
+
export type { Migration, MigrationConfig, MigrateResult } from "./migrations.js";
|
|
48
50
|
export { validateRuntimeInput, summarizeInputShape } from "./inputs.js";
|
|
49
51
|
export { buildAutoMcpServers, buildSkillMcpServers, buildProviderContext } from "./mcp.js";
|
|
50
52
|
export type { ProviderContextOptions, SkillMcpOptions } from "./mcp.js";
|
package/dist/index.js
CHANGED
|
@@ -47,6 +47,7 @@ export { workflowZ, nodeZ, edgeZ, skillZ, mcpServerConfigZ, skillDefinitionZ, so
|
|
|
47
47
|
// the CLI uses for `workflow run`, `workflow validate`, and `publish`).
|
|
48
48
|
// Node-only: loader.ts imports `node:fs`, so this is NOT mirrored in browser.ts.
|
|
49
49
|
export { loadAndValidateWorkflow, validateParsed } from "./loader.js";
|
|
50
|
+
export { CURRENT_SPEC_VERSION, MIGRATIONS, migrateWorkflow } from "./migrations.js";
|
|
50
51
|
// Workflow input validation
|
|
51
52
|
export { validateRuntimeInput, summarizeInputShape } from "./inputs.js";
|
|
52
53
|
// MCP auto-injection
|
package/dist/loader.d.ts
CHANGED
|
@@ -11,15 +11,18 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { Workflow } from "./types.js";
|
|
13
13
|
import { type WorkflowError } from "./schema.js";
|
|
14
|
+
import { type MigrationConfig } from "./migrations.js";
|
|
14
15
|
/** Structural/schema error. `code` is present only for structural errors. */
|
|
15
16
|
export interface LoaderError {
|
|
16
17
|
message: string;
|
|
17
|
-
code?: WorkflowError["code"] | "IO" | "PARSE" | "SCHEMA";
|
|
18
|
+
code?: WorkflowError["code"] | "IO" | "PARSE" | "SCHEMA" | "SPEC_VERSION";
|
|
18
19
|
nodeId?: string;
|
|
19
20
|
}
|
|
20
21
|
export type LoaderResult = {
|
|
21
22
|
ok: true;
|
|
22
23
|
workflow: Workflow;
|
|
24
|
+
/** Non-fatal notes, e.g. the one-line "migrated in memory" warning. */
|
|
25
|
+
warnings?: string[];
|
|
23
26
|
} | {
|
|
24
27
|
ok: false;
|
|
25
28
|
errors: LoaderError[];
|
|
@@ -27,6 +30,10 @@ export type LoaderResult = {
|
|
|
27
30
|
export interface LoaderOptions {
|
|
28
31
|
/** Optional set of known skill IDs for UNKNOWN_SKILL validation. */
|
|
29
32
|
knownSkills?: Set<string>;
|
|
33
|
+
/** Spec version + migration registry. Defaults to the built-in one; tests inject their own. */
|
|
34
|
+
migration?: MigrationConfig;
|
|
35
|
+
/** Path shown in the migration warning. Set by loadAndValidateWorkflow. */
|
|
36
|
+
filePath?: string;
|
|
30
37
|
}
|
|
31
38
|
/**
|
|
32
39
|
* Load a workflow YAML or JSON file, parse with the canonical Zod schema,
|
package/dist/loader.js
CHANGED
|
@@ -14,6 +14,7 @@ import * as path from "node:path";
|
|
|
14
14
|
import { parse as parseYaml } from "yaml";
|
|
15
15
|
import { ZodError } from "zod";
|
|
16
16
|
import { workflowZ, validateWorkflow, preflightLegacyVerify } from "./schema.js";
|
|
17
|
+
import { DEFAULT_MIGRATION_CONFIG, migrateWorkflow, migrationWarning, } from "./migrations.js";
|
|
17
18
|
/**
|
|
18
19
|
* Load a workflow YAML or JSON file, parse with the canonical Zod schema,
|
|
19
20
|
* and run structural validation. All three phases contribute to `errors`.
|
|
@@ -36,7 +37,7 @@ export function loadAndValidateWorkflow(filePath, options = {}) {
|
|
|
36
37
|
const msg = err instanceof Error ? err.message : String(err);
|
|
37
38
|
return { ok: false, errors: [{ message: `Could not parse "${filePath}": ${msg}`, code: "PARSE" }] };
|
|
38
39
|
}
|
|
39
|
-
return validateParsed(raw, options);
|
|
40
|
+
return validateParsed(raw, { ...options, filePath: options.filePath ?? filePath });
|
|
40
41
|
}
|
|
41
42
|
/**
|
|
42
43
|
* Validate an already-parsed workflow object (e.g. from a marketplace fetch
|
|
@@ -47,6 +48,15 @@ export function validateParsed(raw, options = {}) {
|
|
|
47
48
|
const kind = raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw;
|
|
48
49
|
return { ok: false, errors: [{ message: `Expected a workflow object, got ${kind}`, code: "SCHEMA" }] };
|
|
49
50
|
}
|
|
51
|
+
// Apply spec_version migrations in memory (older versions), or refuse a
|
|
52
|
+
// version newer than this build supports. Runs first so a migration can
|
|
53
|
+
// reshape a file before the checks below see it.
|
|
54
|
+
const migrated = migrateWorkflow(raw, options.migration ?? DEFAULT_MIGRATION_CONFIG);
|
|
55
|
+
if (!migrated.ok) {
|
|
56
|
+
return { ok: false, errors: [{ message: migrated.message, code: "SPEC_VERSION" }] };
|
|
57
|
+
}
|
|
58
|
+
raw = migrated.workflow;
|
|
59
|
+
const warnings = migrated.applied.length > 0 ? [migrationWarning(migrated.from, migrated.to, options.filePath)] : [];
|
|
50
60
|
// Detect legacy `verify:` blocks (renamed to `eval:` in v0.2.0) before the
|
|
51
61
|
// Zod parse. workflowZ is intentionally non-strict at the top level, so a
|
|
52
62
|
// top-level `verify:` would be silently STRIPPED instead of surfacing the
|
|
@@ -83,5 +93,5 @@ export function validateParsed(raw, options = {}) {
|
|
|
83
93
|
errors: structural.map((e) => ({ message: e.message, code: e.code, nodeId: e.nodeId })),
|
|
84
94
|
};
|
|
85
95
|
}
|
|
86
|
-
return { ok: true, workflow: parsed };
|
|
96
|
+
return warnings.length > 0 ? { ok: true, workflow: parsed, warnings } : { ok: true, workflow: parsed };
|
|
87
97
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow spec versioning and migrations.
|
|
3
|
+
*
|
|
4
|
+
* Every workflow file carries an optional top-level `spec_version` (a string
|
|
5
|
+
* holding a positive integer, e.g. "1"). A file without the field is version 1:
|
|
6
|
+
* that is what every file written before the field existed means.
|
|
7
|
+
*
|
|
8
|
+
* Breaking spec changes bump CURRENT_SPEC_VERSION and add one entry to
|
|
9
|
+
* MIGRATIONS: a pure function from the raw (unvalidated) vN object to the raw
|
|
10
|
+
* vN+1 object. The loader applies the chain in memory so old files keep
|
|
11
|
+
* running; `sweny workflow upgrade <file>` writes the result back to disk.
|
|
12
|
+
*
|
|
13
|
+
* Adding a migration: see `__tests__/migrations.test.ts` for the harness
|
|
14
|
+
* (registry validity check plus a chain test to copy).
|
|
15
|
+
*/
|
|
16
|
+
/** A raw, not-yet-validated workflow object as parsed from YAML or JSON. */
|
|
17
|
+
export type RawWorkflow = Record<string, unknown>;
|
|
18
|
+
export interface Migration {
|
|
19
|
+
/** Version this migration reads. */
|
|
20
|
+
from: number;
|
|
21
|
+
/** Version it produces. Always `from + 1`. */
|
|
22
|
+
to: number;
|
|
23
|
+
/** One line shown by `sweny workflow upgrade`. */
|
|
24
|
+
description: string;
|
|
25
|
+
/** Pure: must not mutate its input. Returns the next-version object. */
|
|
26
|
+
migrate: (raw: RawWorkflow) => RawWorkflow;
|
|
27
|
+
}
|
|
28
|
+
/** The spec version this build of sweny reads natively. */
|
|
29
|
+
export declare const CURRENT_SPEC_VERSION = 1;
|
|
30
|
+
/**
|
|
31
|
+
* Ordered registry, one entry per version step. Empty while v1 is current.
|
|
32
|
+
* Append only; never edit a shipped migration.
|
|
33
|
+
*/
|
|
34
|
+
export declare const MIGRATIONS: readonly Migration[];
|
|
35
|
+
export interface MigrationConfig {
|
|
36
|
+
current: number;
|
|
37
|
+
migrations: readonly Migration[];
|
|
38
|
+
}
|
|
39
|
+
export declare const DEFAULT_MIGRATION_CONFIG: MigrationConfig;
|
|
40
|
+
export type MigrateError = {
|
|
41
|
+
code: "INVALID_VERSION" | "TOO_NEW" | "MIGRATION_FAILED";
|
|
42
|
+
message: string;
|
|
43
|
+
};
|
|
44
|
+
export type MigrateResult = {
|
|
45
|
+
ok: true;
|
|
46
|
+
/** The (possibly migrated) object. `spec_version` is a string when it was declared or migrated. */
|
|
47
|
+
workflow: RawWorkflow;
|
|
48
|
+
/** Version the file declared (1 when absent). */
|
|
49
|
+
from: number;
|
|
50
|
+
/** Version of `workflow` after migration. */
|
|
51
|
+
to: number;
|
|
52
|
+
/** Whether the input declared `spec_version`. */
|
|
53
|
+
declared: boolean;
|
|
54
|
+
/** Migrations applied, in order. Empty when the file is already current. */
|
|
55
|
+
applied: Migration[];
|
|
56
|
+
} | ({
|
|
57
|
+
ok: false;
|
|
58
|
+
} & MigrateError);
|
|
59
|
+
/** Throws if a registry is not an unbroken chain of single steps ending at `current`. */
|
|
60
|
+
export declare function assertRegistryValid(config: MigrationConfig): void;
|
|
61
|
+
export declare function migrateWorkflow(raw: RawWorkflow, config?: MigrationConfig): MigrateResult;
|
|
62
|
+
/** The single warning the loader emits when it migrated in memory. */
|
|
63
|
+
export declare function migrationWarning(from: number, to: number, filePath?: string): string;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow spec versioning and migrations.
|
|
3
|
+
*
|
|
4
|
+
* Every workflow file carries an optional top-level `spec_version` (a string
|
|
5
|
+
* holding a positive integer, e.g. "1"). A file without the field is version 1:
|
|
6
|
+
* that is what every file written before the field existed means.
|
|
7
|
+
*
|
|
8
|
+
* Breaking spec changes bump CURRENT_SPEC_VERSION and add one entry to
|
|
9
|
+
* MIGRATIONS: a pure function from the raw (unvalidated) vN object to the raw
|
|
10
|
+
* vN+1 object. The loader applies the chain in memory so old files keep
|
|
11
|
+
* running; `sweny workflow upgrade <file>` writes the result back to disk.
|
|
12
|
+
*
|
|
13
|
+
* Adding a migration: see `__tests__/migrations.test.ts` for the harness
|
|
14
|
+
* (registry validity check plus a chain test to copy).
|
|
15
|
+
*/
|
|
16
|
+
/** The spec version this build of sweny reads natively. */
|
|
17
|
+
export const CURRENT_SPEC_VERSION = 1;
|
|
18
|
+
/**
|
|
19
|
+
* Ordered registry, one entry per version step. Empty while v1 is current.
|
|
20
|
+
* Append only; never edit a shipped migration.
|
|
21
|
+
*/
|
|
22
|
+
export const MIGRATIONS = [];
|
|
23
|
+
export const DEFAULT_MIGRATION_CONFIG = {
|
|
24
|
+
current: CURRENT_SPEC_VERSION,
|
|
25
|
+
migrations: MIGRATIONS,
|
|
26
|
+
};
|
|
27
|
+
/** Throws if a registry is not an unbroken chain of single steps ending at `current`. */
|
|
28
|
+
export function assertRegistryValid(config) {
|
|
29
|
+
const { current, migrations } = config;
|
|
30
|
+
if (!Number.isInteger(current) || current < 1)
|
|
31
|
+
throw new Error("current spec version must be a positive integer");
|
|
32
|
+
if (migrations.length !== current - 1) {
|
|
33
|
+
throw new Error(`expected ${current - 1} migration(s) to reach v${current}, found ${migrations.length}`);
|
|
34
|
+
}
|
|
35
|
+
migrations.forEach((m, i) => {
|
|
36
|
+
if (m.from !== i + 1 || m.to !== i + 2) {
|
|
37
|
+
throw new Error(`migration #${i} must be v${i + 1} -> v${i + 2}, got v${m.from} -> v${m.to}`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function parseVersion(value) {
|
|
42
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 1)
|
|
43
|
+
return value;
|
|
44
|
+
if (typeof value === "string" && /^[1-9]\d*$/.test(value))
|
|
45
|
+
return Number(value);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
export function migrateWorkflow(raw, config = DEFAULT_MIGRATION_CONFIG) {
|
|
49
|
+
const declared = "spec_version" in raw && raw.spec_version !== undefined;
|
|
50
|
+
const from = declared ? parseVersion(raw.spec_version) : 1;
|
|
51
|
+
if (from === null) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
code: "INVALID_VERSION",
|
|
55
|
+
message: `spec_version must be a positive integer string like "1", got ${JSON.stringify(raw.spec_version)}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (from > config.current) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
code: "TOO_NEW",
|
|
62
|
+
message: `This workflow declares spec_version "${from}", but this version of sweny supports up to "${config.current}". ` +
|
|
63
|
+
`Upgrade sweny (run "sweny upgrade", or npm install -g @sweny-ai/core@latest) and try again.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
let current = declared ? { ...raw, spec_version: String(from) } : raw;
|
|
67
|
+
const applied = [];
|
|
68
|
+
for (let v = from; v < config.current; v++) {
|
|
69
|
+
const step = config.migrations.find((m) => m.from === v);
|
|
70
|
+
if (!step) {
|
|
71
|
+
return { ok: false, code: "MIGRATION_FAILED", message: `No migration registered from spec_version "${v}".` };
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
current = { ...step.migrate(current), spec_version: String(step.to) };
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
code: "MIGRATION_FAILED",
|
|
81
|
+
message: `Migration from spec_version "${step.from}" to "${step.to}" failed: ${msg}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
applied.push(step);
|
|
85
|
+
}
|
|
86
|
+
return { ok: true, workflow: current, from, to: config.current, declared, applied };
|
|
87
|
+
}
|
|
88
|
+
/** The single warning the loader emits when it migrated in memory. */
|
|
89
|
+
export function migrationWarning(from, to, filePath) {
|
|
90
|
+
const target = filePath ? `"${filePath}"` : "<file>";
|
|
91
|
+
return (`Workflow uses spec_version "${from}" (current is "${to}"); migrated in memory for this run. ` +
|
|
92
|
+
`Run \`sweny workflow upgrade ${target}\` to update the file.`);
|
|
93
|
+
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -1714,6 +1714,7 @@ export declare const workflowZ: z.ZodObject<{
|
|
|
1714
1714
|
id: z.ZodString;
|
|
1715
1715
|
name: z.ZodString;
|
|
1716
1716
|
description: z.ZodDefault<z.ZodString>;
|
|
1717
|
+
spec_version: z.ZodOptional<z.ZodString>;
|
|
1717
1718
|
workflow_type: z.ZodOptional<z.ZodEnum<["pr_review", "e2e_test", "content_generation", "monitor", "data_sync", "generic"]>>;
|
|
1718
1719
|
nodes: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
1719
1720
|
name: z.ZodString;
|
|
@@ -2876,6 +2877,7 @@ export declare const workflowZ: z.ZodObject<{
|
|
|
2876
2877
|
type?: string | undefined;
|
|
2877
2878
|
})[] | undefined;
|
|
2878
2879
|
judge_model?: string | undefined;
|
|
2880
|
+
spec_version?: string | undefined;
|
|
2879
2881
|
workflow_type?: "pr_review" | "e2e_test" | "content_generation" | "monitor" | "data_sync" | "generic" | undefined;
|
|
2880
2882
|
judge_budget?: number | undefined;
|
|
2881
2883
|
context_mode?: "full" | "bounded" | undefined;
|
|
@@ -3064,6 +3066,7 @@ export declare const workflowZ: z.ZodObject<{
|
|
|
3064
3066
|
type?: string | undefined;
|
|
3065
3067
|
})[] | undefined;
|
|
3066
3068
|
judge_model?: string | undefined;
|
|
3069
|
+
spec_version?: string | undefined;
|
|
3067
3070
|
workflow_type?: "pr_review" | "e2e_test" | "content_generation" | "monitor" | "data_sync" | "generic" | undefined;
|
|
3068
3071
|
judge_budget?: number | undefined;
|
|
3069
3072
|
context_mode?: "full" | "bounded" | undefined;
|
|
@@ -3266,6 +3269,7 @@ export declare function parseWorkflow(raw: unknown): {
|
|
|
3266
3269
|
type?: string | undefined;
|
|
3267
3270
|
})[] | undefined;
|
|
3268
3271
|
judge_model?: string | undefined;
|
|
3272
|
+
spec_version?: string | undefined;
|
|
3269
3273
|
workflow_type?: "pr_review" | "e2e_test" | "content_generation" | "monitor" | "data_sync" | "generic" | undefined;
|
|
3270
3274
|
judge_budget?: number | undefined;
|
|
3271
3275
|
context_mode?: "full" | "bounded" | undefined;
|
|
@@ -3649,6 +3653,11 @@ export declare const workflowJsonSchema: {
|
|
|
3649
3653
|
readonly description: {
|
|
3650
3654
|
readonly type: "string";
|
|
3651
3655
|
};
|
|
3656
|
+
readonly spec_version: {
|
|
3657
|
+
readonly type: "string";
|
|
3658
|
+
readonly pattern: "^[1-9][0-9]*$";
|
|
3659
|
+
readonly description: "Workflow spec version as a positive integer string. Absent means 1. Older versions are migrated in memory on load; newer than the CLI supports is refused. Run `sweny workflow upgrade <file>` to rewrite a file at the current version.";
|
|
3660
|
+
};
|
|
3652
3661
|
readonly entry: {
|
|
3653
3662
|
readonly type: "string";
|
|
3654
3663
|
readonly minLength: 1;
|
package/dist/schema.js
CHANGED
|
@@ -357,6 +357,10 @@ export const workflowZ = z.object({
|
|
|
357
357
|
id: z.string().min(1),
|
|
358
358
|
name: z.string().min(1),
|
|
359
359
|
description: z.string().default(""),
|
|
360
|
+
spec_version: z
|
|
361
|
+
.string()
|
|
362
|
+
.regex(/^[1-9]\d*$/, "spec_version must be a positive integer string")
|
|
363
|
+
.optional(),
|
|
360
364
|
workflow_type: workflowTypeZ.optional(),
|
|
361
365
|
nodes: z.record(nodeZ),
|
|
362
366
|
edges: z.array(edgeZ),
|
|
@@ -954,6 +958,11 @@ export const workflowJsonSchema = {
|
|
|
954
958
|
id: { type: "string", minLength: 1 },
|
|
955
959
|
name: { type: "string", minLength: 1 },
|
|
956
960
|
description: { type: "string" },
|
|
961
|
+
spec_version: {
|
|
962
|
+
type: "string",
|
|
963
|
+
pattern: "^[1-9][0-9]*$",
|
|
964
|
+
description: "Workflow spec version as a positive integer string. Absent means 1. Older versions are migrated in memory on load; newer than the CLI supports is refused. Run `sweny workflow upgrade <file>` to rewrite a file at the current version.",
|
|
965
|
+
},
|
|
957
966
|
entry: { type: "string", minLength: 1, description: "ID of the entry node" },
|
|
958
967
|
rules: {
|
|
959
968
|
type: "array",
|
package/dist/types.d.ts
CHANGED
|
@@ -514,6 +514,8 @@ export interface Workflow {
|
|
|
514
514
|
id: string;
|
|
515
515
|
name: string;
|
|
516
516
|
description: string;
|
|
517
|
+
/** Spec version, a positive integer string (e.g. "1"). Absent means "1". See `migrations.ts`. */
|
|
518
|
+
spec_version?: string;
|
|
517
519
|
/** Optional. Defaults to "generic" when absent. Required on marketplace templates. */
|
|
518
520
|
workflow_type?: WorkflowType;
|
|
519
521
|
nodes: Record<string, Node>;
|
|
@@ -622,6 +624,11 @@ export interface NodeResult {
|
|
|
622
624
|
};
|
|
623
625
|
/** Opinions this run could not honor natively. Always empty for Claude Code. */
|
|
624
626
|
degraded?: string[];
|
|
627
|
+
/**
|
|
628
|
+
* Agent floor this node actually ran under, for the run receipt. Set by the
|
|
629
|
+
* Claude Code harness only; absent for harnesses that do not report it.
|
|
630
|
+
*/
|
|
631
|
+
policy?: NodePolicyFacts;
|
|
625
632
|
/** Safe outputs (#365): what the write stage did with each intent. Absent when the node declares none. */
|
|
626
633
|
outputs?: SafeOutputReceipt[];
|
|
627
634
|
/**
|
|
@@ -631,6 +638,15 @@ export interface NodeResult {
|
|
|
631
638
|
*/
|
|
632
639
|
budget?: BudgetOverrun;
|
|
633
640
|
}
|
|
641
|
+
/** Facts about the agent floor (scoped env, process sandbox) a node ran under. Booleans and a mode only. */
|
|
642
|
+
export interface NodePolicyFacts {
|
|
643
|
+
/** The agent subprocess env was narrowed to the allowlist. */
|
|
644
|
+
envScope: boolean;
|
|
645
|
+
/** Requested sandbox mode. */
|
|
646
|
+
sandbox: "off" | "auto" | "strict";
|
|
647
|
+
/** The sandbox was enabled for this node (false under `off`, or `auto` on a host that cannot sandbox). */
|
|
648
|
+
sandboxStarted: boolean;
|
|
649
|
+
}
|
|
634
650
|
export interface ToolCall {
|
|
635
651
|
tool: string;
|
|
636
652
|
input: unknown;
|