@united-workforce/cli 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -11
- package/dist/.build-fingerprint +1 -0
- package/dist/__tests__/adapter-json-roundtrip.test.js +17 -7
- package/dist/__tests__/adapter-json-roundtrip.test.js.map +1 -1
- package/dist/__tests__/agent-resolution-llm-free.test.d.ts +2 -0
- package/dist/__tests__/agent-resolution-llm-free.test.d.ts.map +1 -0
- package/dist/__tests__/agent-resolution-llm-free.test.js +30 -0
- package/dist/__tests__/agent-resolution-llm-free.test.js.map +1 -0
- package/dist/__tests__/build-step-entry.test.d.ts +2 -0
- package/dist/__tests__/build-step-entry.test.d.ts.map +1 -0
- package/dist/__tests__/build-step-entry.test.js +173 -0
- package/dist/__tests__/build-step-entry.test.js.map +1 -0
- package/dist/__tests__/clear-thread-failed-attempts.test.d.ts +2 -0
- package/dist/__tests__/clear-thread-failed-attempts.test.d.ts.map +1 -0
- package/dist/__tests__/clear-thread-failed-attempts.test.js +93 -0
- package/dist/__tests__/clear-thread-failed-attempts.test.js.map +1 -0
- package/dist/__tests__/concurrency.test.d.ts +2 -0
- package/dist/__tests__/concurrency.test.d.ts.map +1 -0
- package/dist/__tests__/concurrency.test.js +196 -0
- package/dist/__tests__/concurrency.test.js.map +1 -0
- package/dist/__tests__/config.test.js +26 -302
- package/dist/__tests__/config.test.js.map +1 -1
- package/dist/__tests__/current-role.test.js +7 -6
- package/dist/__tests__/current-role.test.js.map +1 -1
- package/dist/__tests__/e2e-mock-agent.test.js +43 -30
- package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
- package/dist/__tests__/format-text-default.test.d.ts +2 -0
- package/dist/__tests__/format-text-default.test.d.ts.map +1 -0
- package/dist/__tests__/format-text-default.test.js +43 -0
- package/dist/__tests__/format-text-default.test.js.map +1 -0
- package/dist/__tests__/format-text-registry.test.d.ts +2 -0
- package/dist/__tests__/format-text-registry.test.d.ts.map +1 -0
- package/dist/__tests__/format-text-registry.test.js +158 -0
- package/dist/__tests__/format-text-registry.test.js.map +1 -0
- package/dist/__tests__/issue-180-workflow-ref-removed.test.d.ts +2 -0
- package/dist/__tests__/issue-180-workflow-ref-removed.test.d.ts.map +1 -0
- package/dist/__tests__/issue-180-workflow-ref-removed.test.js +40 -0
- package/dist/__tests__/issue-180-workflow-ref-removed.test.js.map +1 -0
- package/dist/__tests__/log-text-renderer.test.d.ts +2 -0
- package/dist/__tests__/log-text-renderer.test.d.ts.map +1 -0
- package/dist/__tests__/log-text-renderer.test.js +265 -0
- package/dist/__tests__/log-text-renderer.test.js.map +1 -0
- package/dist/__tests__/moderator-evaluate.test.js +9 -50
- package/dist/__tests__/moderator-evaluate.test.js.map +1 -1
- package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts +2 -0
- package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts.map +1 -0
- package/dist/__tests__/output-mapper-thread-list-startedat.test.js +102 -0
- package/dist/__tests__/output-mapper-thread-list-startedat.test.js.map +1 -0
- package/dist/__tests__/output-mapper-workflow-add.test.d.ts +2 -0
- package/dist/__tests__/output-mapper-workflow-add.test.d.ts.map +1 -0
- package/dist/__tests__/output-mapper-workflow-add.test.js +22 -0
- package/dist/__tests__/output-mapper-workflow-add.test.js.map +1 -0
- package/dist/__tests__/pid-recycling.test.d.ts +2 -0
- package/dist/__tests__/pid-recycling.test.d.ts.map +1 -0
- package/dist/__tests__/pid-recycling.test.js +273 -0
- package/dist/__tests__/pid-recycling.test.js.map +1 -0
- package/dist/__tests__/prompt.test.js +365 -2
- package/dist/__tests__/prompt.test.js.map +1 -1
- package/dist/__tests__/resolve-head-hash.test.js +12 -4
- package/dist/__tests__/resolve-head-hash.test.js.map +1 -1
- package/dist/__tests__/setup-agent-discovery.test.js +21 -30
- package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
- package/dist/__tests__/setup-complexity.test.js +2 -168
- package/dist/__tests__/setup-complexity.test.js.map +1 -1
- package/dist/__tests__/setup-no-llm.test.d.ts +2 -0
- package/dist/__tests__/setup-no-llm.test.d.ts.map +1 -0
- package/dist/__tests__/setup-no-llm.test.js +52 -0
- package/dist/__tests__/setup-no-llm.test.js.map +1 -0
- package/dist/__tests__/solve-issue-tea-worktree.test.js +27 -28
- package/dist/__tests__/solve-issue-tea-worktree.test.js.map +1 -1
- package/dist/__tests__/step-ask.test.d.ts +2 -0
- package/dist/__tests__/step-ask.test.d.ts.map +1 -0
- package/dist/__tests__/step-ask.test.js +507 -0
- package/dist/__tests__/step-ask.test.js.map +1 -0
- package/dist/__tests__/step-show-json.test.js +1 -0
- package/dist/__tests__/step-show-json.test.js.map +1 -1
- package/dist/__tests__/step-timing.test.js +2 -0
- package/dist/__tests__/step-timing.test.js.map +1 -1
- package/dist/__tests__/store-global-cas.test.js +2 -2
- package/dist/__tests__/store-global-cas.test.js.map +1 -1
- package/dist/__tests__/store-unified-threads.test.js +28 -26
- package/dist/__tests__/store-unified-threads.test.js.map +1 -1
- package/dist/__tests__/thread-cancel-status.test.js +25 -19
- package/dist/__tests__/thread-cancel-status.test.js.map +1 -1
- package/dist/__tests__/thread-cancel-text-renderer.test.d.ts +2 -0
- package/dist/__tests__/thread-cancel-text-renderer.test.d.ts.map +1 -0
- package/dist/__tests__/thread-cancel-text-renderer.test.js +110 -0
- package/dist/__tests__/thread-cancel-text-renderer.test.js.map +1 -0
- package/dist/__tests__/thread-list-filters.test.js +354 -17
- package/dist/__tests__/thread-list-filters.test.js.map +1 -1
- package/dist/__tests__/thread-list-template-ms-date.test.d.ts +2 -0
- package/dist/__tests__/thread-list-template-ms-date.test.d.ts.map +1 -0
- package/dist/__tests__/thread-list-template-ms-date.test.js +102 -0
- package/dist/__tests__/thread-list-template-ms-date.test.js.map +1 -0
- package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts +2 -0
- package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts.map +1 -0
- package/dist/__tests__/thread-list-workflow-corrupt.test.js +157 -0
- package/dist/__tests__/thread-list-workflow-corrupt.test.js.map +1 -0
- package/dist/__tests__/thread-poke.test.d.ts +2 -0
- package/dist/__tests__/thread-poke.test.d.ts.map +1 -0
- package/dist/__tests__/thread-poke.test.js +422 -0
- package/dist/__tests__/thread-poke.test.js.map +1 -0
- package/dist/__tests__/thread-read-xml-tags.test.js +10 -9
- package/dist/__tests__/thread-read-xml-tags.test.js.map +1 -1
- package/dist/__tests__/thread-resume.test.js +21 -15
- package/dist/__tests__/thread-resume.test.js.map +1 -1
- package/dist/__tests__/thread-show-status.test.js +17 -28
- package/dist/__tests__/thread-show-status.test.js.map +1 -1
- package/dist/__tests__/thread-start-cwd-cli.test.js +15 -3
- package/dist/__tests__/thread-start-cwd-cli.test.js.map +1 -1
- package/dist/__tests__/thread-stop-text-renderer.test.d.ts +2 -0
- package/dist/__tests__/thread-stop-text-renderer.test.d.ts.map +1 -0
- package/dist/__tests__/thread-stop-text-renderer.test.js +148 -0
- package/dist/__tests__/thread-stop-text-renderer.test.js.map +1 -0
- package/dist/__tests__/thread-suspend-step.test.js +13 -16
- package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
- package/dist/__tests__/thread-suspended-display.test.js +10 -22
- package/dist/__tests__/thread-suspended-display.test.js.map +1 -1
- package/dist/__tests__/thread-test-helpers.d.ts +7 -0
- package/dist/__tests__/thread-test-helpers.d.ts.map +1 -1
- package/dist/__tests__/thread-test-helpers.js +13 -0
- package/dist/__tests__/thread-test-helpers.js.map +1 -1
- package/dist/__tests__/thread.test.js +15 -13
- package/dist/__tests__/thread.test.js.map +1 -1
- package/dist/__tests__/validate-semantic.test.js +105 -23
- package/dist/__tests__/validate-semantic.test.js.map +1 -1
- package/dist/__tests__/workflow-list-recursive.test.d.ts +2 -0
- package/dist/__tests__/workflow-list-recursive.test.d.ts.map +1 -0
- package/dist/__tests__/workflow-list-recursive.test.js +286 -0
- package/dist/__tests__/workflow-list-recursive.test.js.map +1 -0
- package/dist/__tests__/workflow-resolution.test.js +46 -28
- package/dist/__tests__/workflow-resolution.test.js.map +1 -1
- package/dist/__tests__/workflow-show-resolution.test.d.ts +2 -0
- package/dist/__tests__/workflow-show-resolution.test.d.ts.map +1 -0
- package/dist/__tests__/workflow-show-resolution.test.js +213 -0
- package/dist/__tests__/workflow-show-resolution.test.js.map +1 -0
- package/dist/__tests__/workflow-validate.test.d.ts +2 -0
- package/dist/__tests__/workflow-validate.test.d.ts.map +1 -0
- package/dist/__tests__/workflow-validate.test.js +707 -0
- package/dist/__tests__/workflow-validate.test.js.map +1 -0
- package/dist/__tests__/write-envelope.test.d.ts +2 -0
- package/dist/__tests__/write-envelope.test.d.ts.map +1 -0
- package/dist/__tests__/write-envelope.test.js +201 -0
- package/dist/__tests__/write-envelope.test.js.map +1 -0
- package/dist/background/background.d.ts +22 -1
- package/dist/background/background.d.ts.map +1 -1
- package/dist/background/background.js +83 -6
- package/dist/background/background.js.map +1 -1
- package/dist/background/index.d.ts +1 -1
- package/dist/background/index.d.ts.map +1 -1
- package/dist/background/index.js +1 -1
- package/dist/background/index.js.map +1 -1
- package/dist/background/types.d.ts +1 -0
- package/dist/background/types.d.ts.map +1 -1
- package/dist/cli.js +120 -62
- package/dist/cli.js.map +1 -1
- package/dist/commands/config.d.ts +3 -1
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +17 -31
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/prompt.d.ts.map +1 -1
- package/dist/commands/prompt.js +57 -31
- package/dist/commands/prompt.js.map +1 -1
- package/dist/commands/setup.d.ts +12 -39
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +72 -303
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/step.d.ts +44 -1
- package/dist/commands/step.d.ts.map +1 -1
- package/dist/commands/step.js +255 -11
- package/dist/commands/step.js.map +1 -1
- package/dist/commands/thread.d.ts +16 -3
- package/dist/commands/thread.d.ts.map +1 -1
- package/dist/commands/thread.js +423 -142
- package/dist/commands/thread.js.map +1 -1
- package/dist/commands/workflow.d.ts +9 -1
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +126 -6
- package/dist/commands/workflow.js.map +1 -1
- package/dist/concurrency/concurrency.d.ts +34 -0
- package/dist/concurrency/concurrency.d.ts.map +1 -0
- package/dist/concurrency/concurrency.js +216 -0
- package/dist/concurrency/concurrency.js.map +1 -0
- package/dist/concurrency/index.d.ts +3 -0
- package/dist/concurrency/index.d.ts.map +1 -0
- package/dist/concurrency/index.js +2 -0
- package/dist/concurrency/index.js.map +1 -0
- package/dist/concurrency/types.d.ts +19 -0
- package/dist/concurrency/types.d.ts.map +1 -0
- package/dist/concurrency/types.js +2 -0
- package/dist/concurrency/types.js.map +1 -0
- package/dist/format.d.ts +69 -2
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +198 -1
- package/dist/format.js.map +1 -1
- package/dist/moderator/__tests__/evaluate.test.js +31 -17
- package/dist/moderator/__tests__/evaluate.test.js.map +1 -1
- package/dist/moderator/evaluate.d.ts.map +1 -1
- package/dist/moderator/evaluate.js +4 -16
- package/dist/moderator/evaluate.js.map +1 -1
- package/dist/moderator/index.d.ts +1 -2
- package/dist/moderator/index.d.ts.map +1 -1
- package/dist/moderator/index.js +0 -1
- package/dist/moderator/index.js.map +1 -1
- package/dist/moderator/types.d.ts +6 -10
- package/dist/moderator/types.d.ts.map +1 -1
- package/dist/moderator/types.js +1 -3
- package/dist/moderator/types.js.map +1 -1
- package/dist/output-mappers.d.ts +122 -0
- package/dist/output-mappers.d.ts.map +1 -0
- package/dist/output-mappers.js +134 -0
- package/dist/output-mappers.js.map +1 -0
- package/dist/schemas.d.ts +6 -1
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +34 -5
- package/dist/schemas.js.map +1 -1
- package/dist/store.d.ts +28 -9
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +75 -16
- package/dist/store.js.map +1 -1
- package/dist/text-renderers.d.ts +30 -0
- package/dist/text-renderers.d.ts.map +1 -0
- package/dist/text-renderers.js +251 -0
- package/dist/text-renderers.js.map +1 -0
- package/dist/validate-semantic.d.ts.map +1 -1
- package/dist/validate-semantic.js +95 -61
- package/dist/validate-semantic.js.map +1 -1
- package/dist/validate.d.ts +6 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +24 -0
- package/dist/validate.js.map +1 -1
- package/examples/brainstorm.yaml +130 -0
- package/examples/debate.yaml +169 -0
- package/examples/socratic-questioning.yaml +112 -0
- package/package.json +9 -10
- package/src/__tests__/adapter-json-roundtrip.test.ts +16 -7
- package/src/__tests__/agent-resolution-llm-free.test.ts +39 -0
- package/src/__tests__/build-step-entry.test.ts +203 -0
- package/src/__tests__/clear-thread-failed-attempts.test.ts +122 -0
- package/src/__tests__/concurrency.test.ts +266 -0
- package/src/__tests__/config.test.ts +33 -321
- package/src/__tests__/current-role.test.ts +7 -6
- package/src/__tests__/e2e-mock-agent.test.ts +65 -30
- package/src/__tests__/fixtures/e2e-count.workflow.yaml +1 -0
- package/src/__tests__/fixtures/e2e-linear.workflow.yaml +1 -0
- package/src/__tests__/fixtures/{e2e-mustache.workflow.yaml → e2e-liquid.workflow.yaml} +3 -2
- package/src/__tests__/fixtures/e2e-loop.workflow.yaml +1 -0
- package/src/__tests__/fixtures/e2e-suspend.mock.yaml +2 -2
- package/src/__tests__/fixtures/e2e-suspend.workflow.yaml +6 -10
- package/src/__tests__/format-text-default.test.ts +49 -0
- package/src/__tests__/format-text-registry.test.ts +173 -0
- package/src/__tests__/issue-180-workflow-ref-removed.test.ts +43 -0
- package/src/__tests__/log-text-renderer.test.ts +294 -0
- package/src/__tests__/moderator-evaluate.test.ts +9 -52
- package/src/__tests__/output-mapper-thread-list-startedat.test.ts +124 -0
- package/src/__tests__/output-mapper-workflow-add.test.ts +24 -0
- package/src/__tests__/pid-recycling.test.ts +329 -0
- package/src/__tests__/prompt.test.ts +443 -2
- package/src/__tests__/resolve-head-hash.test.ts +11 -4
- package/src/__tests__/setup-agent-discovery.test.ts +26 -51
- package/src/__tests__/setup-complexity.test.ts +1 -203
- package/src/__tests__/setup-no-llm.test.ts +68 -0
- package/src/__tests__/solve-issue-tea-worktree.test.ts +27 -31
- package/src/__tests__/step-ask.test.ts +677 -0
- package/src/__tests__/step-show-json.test.ts +1 -0
- package/src/__tests__/step-timing.test.ts +2 -0
- package/src/__tests__/store-global-cas.test.ts +2 -2
- package/src/__tests__/store-unified-threads.test.ts +30 -27
- package/src/__tests__/thread-cancel-status.test.ts +27 -20
- package/src/__tests__/thread-cancel-text-renderer.test.ts +125 -0
- package/src/__tests__/thread-list-filters.test.ts +443 -17
- package/src/__tests__/thread-list-template-ms-date.test.ts +110 -0
- package/src/__tests__/thread-list-workflow-corrupt.test.ts +198 -0
- package/src/__tests__/thread-poke.test.ts +554 -0
- package/src/__tests__/thread-read-xml-tags.test.ts +9 -11
- package/src/__tests__/thread-resume.test.ts +20 -15
- package/src/__tests__/thread-show-status.test.ts +17 -29
- package/src/__tests__/thread-start-cwd-cli.test.ts +15 -3
- package/src/__tests__/thread-stop-text-renderer.test.ts +168 -0
- package/src/__tests__/thread-suspend-step.test.ts +13 -16
- package/src/__tests__/thread-suspended-display.test.ts +10 -22
- package/src/__tests__/thread-test-helpers.ts +15 -1
- package/src/__tests__/thread.test.ts +14 -14
- package/src/__tests__/validate-semantic.test.ts +118 -33
- package/src/__tests__/workflow-list-recursive.test.ts +370 -0
- package/src/__tests__/workflow-resolution.test.ts +48 -29
- package/src/__tests__/workflow-show-resolution.test.ts +286 -0
- package/src/__tests__/workflow-validate.test.ts +828 -0
- package/src/__tests__/write-envelope.test.ts +257 -0
- package/src/background/background.ts +88 -6
- package/src/background/index.ts +2 -0
- package/src/background/types.ts +1 -0
- package/src/cli.ts +184 -77
- package/src/commands/config.ts +16 -33
- package/src/commands/prompt.ts +57 -31
- package/src/commands/setup.ts +80 -358
- package/src/commands/step.ts +339 -12
- package/src/commands/thread.ts +511 -171
- package/src/commands/workflow.ts +155 -4
- package/src/concurrency/concurrency.ts +245 -0
- package/src/concurrency/index.ts +10 -0
- package/src/concurrency/types.ts +19 -0
- package/src/format.ts +282 -2
- package/src/moderator/__tests__/evaluate.test.ts +34 -17
- package/src/moderator/evaluate.ts +5 -17
- package/src/moderator/index.ts +1 -6
- package/src/moderator/types.ts +6 -14
- package/src/output-mappers.ts +254 -0
- package/src/schemas.ts +51 -5
- package/src/store.ts +86 -20
- package/src/text-renderers.ts +355 -0
- package/src/validate-semantic.ts +125 -73
- package/src/validate.ts +27 -0
- package/dist/__tests__/setup-validate.test.d.ts +0 -2
- package/dist/__tests__/setup-validate.test.d.ts.map +0 -1
- package/dist/__tests__/setup-validate.test.js +0 -108
- package/dist/__tests__/setup-validate.test.js.map +0 -1
- package/src/__tests__/setup-validate.test.ts +0 -148
- /package/src/__tests__/fixtures/{e2e-mustache.mock.yaml → e2e-liquid.mock.yaml} +0 -0
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
7
|
+
import { stringify } from "yaml";
|
|
8
|
+
const TEST_DIR = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const CLI_PATH = join(TEST_DIR, "..", "..", "dist", "cli.js");
|
|
10
|
+
function runValidate(filePath, extraArgs = [], envOverride) {
|
|
11
|
+
const args = [CLI_PATH, "workflow", "validate", filePath, ...extraArgs];
|
|
12
|
+
try {
|
|
13
|
+
const stdout = execFileSync(process.execPath, args, {
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
16
|
+
env: envOverride ?? process.env,
|
|
17
|
+
timeout: 15_000,
|
|
18
|
+
});
|
|
19
|
+
return { stdout, stderr: "", exitCode: 0 };
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
const err = e;
|
|
23
|
+
return {
|
|
24
|
+
stdout: typeof err.stdout === "string" ? err.stdout : (err.stdout?.toString() ?? ""),
|
|
25
|
+
stderr: typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString() ?? ""),
|
|
26
|
+
exitCode: err.status ?? 1,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function runCli(args) {
|
|
31
|
+
try {
|
|
32
|
+
const stdout = execFileSync(process.execPath, [CLI_PATH, ...args], {
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
35
|
+
env: process.env,
|
|
36
|
+
timeout: 15_000,
|
|
37
|
+
});
|
|
38
|
+
return { stdout, stderr: "", exitCode: 0 };
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
const err = e;
|
|
42
|
+
return {
|
|
43
|
+
stdout: typeof err.stdout === "string" ? err.stdout : (err.stdout?.toString() ?? ""),
|
|
44
|
+
stderr: typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString() ?? ""),
|
|
45
|
+
exitCode: err.status ?? 1,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Build a valid single-role workflow payload (writer→$END, status `done`). */
|
|
50
|
+
function makeMinimalPayload(name) {
|
|
51
|
+
return {
|
|
52
|
+
name,
|
|
53
|
+
description: `${name} workflow`,
|
|
54
|
+
roles: {
|
|
55
|
+
writer: {
|
|
56
|
+
description: "Writes content",
|
|
57
|
+
goal: "Write content",
|
|
58
|
+
capabilities: ["writing"],
|
|
59
|
+
procedure: "Write it",
|
|
60
|
+
output: "The content",
|
|
61
|
+
frontmatter: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
$status: { const: "done" },
|
|
65
|
+
},
|
|
66
|
+
required: ["$status"],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
graph: {
|
|
71
|
+
$START: {
|
|
72
|
+
new: { role: "writer", prompt: "Begin", location: null },
|
|
73
|
+
resume: { role: "writer", prompt: "Resume", location: null },
|
|
74
|
+
},
|
|
75
|
+
writer: { done: { role: "$END", prompt: "Done", location: null } },
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** Build a valid writer→reviewer workflow with Liquid var. */
|
|
80
|
+
function makeMultiRolePayload(name) {
|
|
81
|
+
return {
|
|
82
|
+
name,
|
|
83
|
+
description: `${name} workflow`,
|
|
84
|
+
roles: {
|
|
85
|
+
writer: {
|
|
86
|
+
description: "Writes content",
|
|
87
|
+
goal: "Write content",
|
|
88
|
+
capabilities: ["writing"],
|
|
89
|
+
procedure: "Write it",
|
|
90
|
+
output: "The content",
|
|
91
|
+
frontmatter: {
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: {
|
|
94
|
+
$status: { const: "done" },
|
|
95
|
+
plan: { type: "string" },
|
|
96
|
+
},
|
|
97
|
+
required: ["$status", "plan"],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
reviewer: {
|
|
101
|
+
description: "Reviews content",
|
|
102
|
+
goal: "Review content",
|
|
103
|
+
capabilities: ["reviewing"],
|
|
104
|
+
procedure: "Review it",
|
|
105
|
+
output: "The review",
|
|
106
|
+
frontmatter: {
|
|
107
|
+
type: "object",
|
|
108
|
+
properties: {
|
|
109
|
+
$status: { const: "approved" },
|
|
110
|
+
summary: { type: "string" },
|
|
111
|
+
},
|
|
112
|
+
required: ["$status", "summary"],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
graph: {
|
|
117
|
+
$START: {
|
|
118
|
+
new: { role: "writer", prompt: "Begin writing", location: null },
|
|
119
|
+
resume: { role: "writer", prompt: "Continue", location: null },
|
|
120
|
+
},
|
|
121
|
+
writer: {
|
|
122
|
+
done: { role: "reviewer", prompt: "Review this: {{ plan }}", location: null },
|
|
123
|
+
},
|
|
124
|
+
reviewer: {
|
|
125
|
+
approved: { role: "$END", prompt: "Done: {{ summary }}", location: null },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** Build a valid reviewer with oneOf multi-exit. */
|
|
131
|
+
function makeOneOfPayload(name) {
|
|
132
|
+
return {
|
|
133
|
+
name,
|
|
134
|
+
description: `${name} workflow`,
|
|
135
|
+
roles: {
|
|
136
|
+
writer: {
|
|
137
|
+
description: "Writes content",
|
|
138
|
+
goal: "Write",
|
|
139
|
+
capabilities: ["writing"],
|
|
140
|
+
procedure: "Write",
|
|
141
|
+
output: "Content",
|
|
142
|
+
frontmatter: {
|
|
143
|
+
type: "object",
|
|
144
|
+
properties: {
|
|
145
|
+
$status: { const: "done" },
|
|
146
|
+
plan: { type: "string" },
|
|
147
|
+
},
|
|
148
|
+
required: ["$status", "plan"],
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
reviewer: {
|
|
152
|
+
description: "Reviews",
|
|
153
|
+
goal: "Review",
|
|
154
|
+
capabilities: ["reviewing"],
|
|
155
|
+
procedure: "Review",
|
|
156
|
+
output: "Review",
|
|
157
|
+
frontmatter: {
|
|
158
|
+
type: "object",
|
|
159
|
+
oneOf: [
|
|
160
|
+
{
|
|
161
|
+
properties: {
|
|
162
|
+
$status: { const: "approved" },
|
|
163
|
+
summary: { type: "string" },
|
|
164
|
+
},
|
|
165
|
+
required: ["$status", "summary"],
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
properties: {
|
|
169
|
+
$status: { const: "rejected" },
|
|
170
|
+
reason: { type: "string" },
|
|
171
|
+
},
|
|
172
|
+
required: ["$status", "reason"],
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
graph: {
|
|
179
|
+
$START: {
|
|
180
|
+
new: { role: "writer", prompt: "Begin", location: null },
|
|
181
|
+
resume: { role: "writer", prompt: "Resume", location: null },
|
|
182
|
+
},
|
|
183
|
+
writer: {
|
|
184
|
+
done: { role: "reviewer", prompt: "Review: {{ plan }}", location: null },
|
|
185
|
+
},
|
|
186
|
+
reviewer: {
|
|
187
|
+
approved: { role: "$END", prompt: "Done: {{ summary }}", location: null },
|
|
188
|
+
rejected: { role: "writer", prompt: "Fix: {{ reason }}", location: null },
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
let tmpDir;
|
|
194
|
+
beforeAll(() => {
|
|
195
|
+
// Confirm CLI is built before running tests
|
|
196
|
+
// (proman build should have produced dist/cli.js)
|
|
197
|
+
});
|
|
198
|
+
beforeEach(async () => {
|
|
199
|
+
tmpDir = await mkdtemp(join(tmpdir(), "wf-validate-test-"));
|
|
200
|
+
});
|
|
201
|
+
afterEach(async () => {
|
|
202
|
+
// chmod back in case a test modified a directory
|
|
203
|
+
try {
|
|
204
|
+
await chmod(tmpDir, 0o755);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// ignore
|
|
208
|
+
}
|
|
209
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
210
|
+
});
|
|
211
|
+
// ── Suite A: Success Path ────────────────────────────────────────────────────
|
|
212
|
+
//
|
|
213
|
+
// Note: Issue #308 makes validate an envelope-emitting command. Default
|
|
214
|
+
// `--format text` renders `✓ valid` (or `✗ invalid (N errors)`) to stdout.
|
|
215
|
+
// Tests below assert the new envelope contract.
|
|
216
|
+
describe("workflow validate — Suite A: Success Path", () => {
|
|
217
|
+
test("A.1 valid single-role workflow exits 0 with text envelope", async () => {
|
|
218
|
+
const file = join(tmpDir, "test-wf.yaml");
|
|
219
|
+
await writeFile(file, stringify(makeMinimalPayload("test-wf")));
|
|
220
|
+
const result = runValidate(file);
|
|
221
|
+
expect(result.exitCode).toBe(0);
|
|
222
|
+
expect(result.stdout.trim()).toBe("✓ valid");
|
|
223
|
+
expect(result.stderr).toBe("");
|
|
224
|
+
});
|
|
225
|
+
test("A.2 valid multi-role workflow with Liquid vars exits 0 with text envelope", async () => {
|
|
226
|
+
const file = join(tmpDir, "writer-flow.yaml");
|
|
227
|
+
await writeFile(file, stringify(makeMultiRolePayload("writer-flow")));
|
|
228
|
+
const result = runValidate(file);
|
|
229
|
+
expect(result.exitCode).toBe(0);
|
|
230
|
+
expect(result.stdout.trim()).toBe("✓ valid");
|
|
231
|
+
expect(result.stderr).toBe("");
|
|
232
|
+
});
|
|
233
|
+
test("A.3 valid oneOf multi-exit workflow exits 0 with text envelope", async () => {
|
|
234
|
+
const file = join(tmpDir, "review-flow.yaml");
|
|
235
|
+
await writeFile(file, stringify(makeOneOfPayload("review-flow")));
|
|
236
|
+
const result = runValidate(file);
|
|
237
|
+
expect(result.exitCode).toBe(0);
|
|
238
|
+
expect(result.stdout.trim()).toBe("✓ valid");
|
|
239
|
+
expect(result.stderr).toBe("");
|
|
240
|
+
});
|
|
241
|
+
test("A.4 !include tag resolution against YAML directory", async () => {
|
|
242
|
+
const subDir = join(tmpDir, "sub");
|
|
243
|
+
await mkdir(subDir, { recursive: true });
|
|
244
|
+
// The include payload is the workflow's roles section
|
|
245
|
+
const rolesYaml = stringify({
|
|
246
|
+
writer: {
|
|
247
|
+
description: "Writes",
|
|
248
|
+
goal: "Write",
|
|
249
|
+
capabilities: ["writing"],
|
|
250
|
+
procedure: "Write",
|
|
251
|
+
output: "Content",
|
|
252
|
+
frontmatter: {
|
|
253
|
+
type: "object",
|
|
254
|
+
properties: {
|
|
255
|
+
$status: { const: "done" },
|
|
256
|
+
},
|
|
257
|
+
required: ["$status"],
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
await writeFile(join(subDir, "roles.yaml"), rolesYaml);
|
|
262
|
+
const mainYaml = `name: main-wf
|
|
263
|
+
description: Main workflow
|
|
264
|
+
roles: !include sub/roles.yaml
|
|
265
|
+
graph:
|
|
266
|
+
$START:
|
|
267
|
+
new: { role: writer, prompt: Begin, location: null }
|
|
268
|
+
resume: { role: writer, prompt: Resume, location: null }
|
|
269
|
+
writer:
|
|
270
|
+
done: { role: $END, prompt: Done, location: null }
|
|
271
|
+
`;
|
|
272
|
+
const file = join(tmpDir, "main-wf.yaml");
|
|
273
|
+
await writeFile(file, mainYaml);
|
|
274
|
+
const result = runValidate(file);
|
|
275
|
+
expect(result.exitCode).toBe(0);
|
|
276
|
+
expect(result.stdout.trim()).toBe("✓ valid");
|
|
277
|
+
expect(result.stderr).toBe("");
|
|
278
|
+
});
|
|
279
|
+
test("A.5 --format raw-json emits bare valid envelope value", async () => {
|
|
280
|
+
const file = join(tmpDir, "test-wf.yaml");
|
|
281
|
+
await writeFile(file, stringify(makeMinimalPayload("test-wf")));
|
|
282
|
+
// --format is a global option on `program`, must come before the subcommand
|
|
283
|
+
const args = [CLI_PATH, "--format", "raw-json", "workflow", "validate", file];
|
|
284
|
+
let result;
|
|
285
|
+
try {
|
|
286
|
+
const stdout = execFileSync(process.execPath, args, {
|
|
287
|
+
encoding: "utf8",
|
|
288
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
289
|
+
env: process.env,
|
|
290
|
+
timeout: 15_000,
|
|
291
|
+
});
|
|
292
|
+
result = { stdout, stderr: "", exitCode: 0 };
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
const err = e;
|
|
296
|
+
result = {
|
|
297
|
+
stdout: typeof err.stdout === "string" ? err.stdout : (err.stdout?.toString() ?? ""),
|
|
298
|
+
stderr: typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString() ?? ""),
|
|
299
|
+
exitCode: err.status ?? 1,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
expect(result.exitCode).toBe(0);
|
|
303
|
+
expect(JSON.parse(result.stdout)).toEqual({ valid: true, errors: [] });
|
|
304
|
+
expect(result.stderr).toBe("");
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
// ── Suite B: File / IO Errors ────────────────────────────────────────────────
|
|
308
|
+
describe("workflow validate — Suite B: File / IO Errors", () => {
|
|
309
|
+
test("B.1 missing file exits 1 with file-not-found error", () => {
|
|
310
|
+
const file = join(tmpDir, "does-not-exist.yaml");
|
|
311
|
+
const result = runValidate(file);
|
|
312
|
+
expect(result.exitCode).toBe(1);
|
|
313
|
+
expect(result.stdout).toBe("");
|
|
314
|
+
expect(result.stderr).toContain("file not found:");
|
|
315
|
+
expect(result.stderr).toContain(file);
|
|
316
|
+
});
|
|
317
|
+
test("B.2 directory passed as file exits 1 with non-empty stderr", () => {
|
|
318
|
+
const result = runValidate(tmpDir);
|
|
319
|
+
expect(result.exitCode).toBe(1);
|
|
320
|
+
expect(result.stderr.length).toBeGreaterThan(0);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
// ── Suite C: YAML / Shape Errors ─────────────────────────────────────────────
|
|
324
|
+
describe("workflow validate — Suite C: YAML / Shape Errors", () => {
|
|
325
|
+
test("C.1 malformed YAML exits 1 with 'invalid YAML' prefix", async () => {
|
|
326
|
+
const file = join(tmpDir, "broken.yaml");
|
|
327
|
+
await writeFile(file, ":::: not yaml ::::");
|
|
328
|
+
const result = runValidate(file);
|
|
329
|
+
expect(result.exitCode).toBe(1);
|
|
330
|
+
expect(result.stdout).toBe("");
|
|
331
|
+
expect(result.stderr).toContain("invalid YAML:");
|
|
332
|
+
});
|
|
333
|
+
test("C.2 valid YAML but wrong shape exits 1 with WorkflowPayload error", async () => {
|
|
334
|
+
const file = join(tmpDir, "wrong-shape.yaml");
|
|
335
|
+
await writeFile(file, stringify({ hello: "world" }));
|
|
336
|
+
const result = runValidate(file);
|
|
337
|
+
expect(result.exitCode).toBe(1);
|
|
338
|
+
expect(result.stderr).toContain("invalid workflow YAML: expected WorkflowPayload shape");
|
|
339
|
+
});
|
|
340
|
+
test("C.3 empty file exits 1", async () => {
|
|
341
|
+
const file = join(tmpDir, "empty.yaml");
|
|
342
|
+
await writeFile(file, "");
|
|
343
|
+
const result = runValidate(file);
|
|
344
|
+
expect(result.exitCode).toBe(1);
|
|
345
|
+
// either "invalid YAML:" or the WorkflowPayload-shape error is acceptable
|
|
346
|
+
const okMessage = result.stderr.includes("invalid YAML:") || result.stderr.includes("invalid workflow YAML:");
|
|
347
|
+
expect(okMessage).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
// ── Suite D: Filename Consistency ────────────────────────────────────────────
|
|
351
|
+
describe("workflow validate — Suite D: Filename Consistency", () => {
|
|
352
|
+
test("D.1 name mismatch with filename exits 1", async () => {
|
|
353
|
+
const file = join(tmpDir, "foo-bar.yaml");
|
|
354
|
+
// write a payload whose name is baz-qux, file is foo-bar
|
|
355
|
+
await writeFile(file, stringify(makeMinimalPayload("baz-qux")));
|
|
356
|
+
const result = runValidate(file);
|
|
357
|
+
expect(result.exitCode).toBe(1);
|
|
358
|
+
// text envelope contains the error in stdout
|
|
359
|
+
expect(result.stdout).toContain("workflow name mismatch:");
|
|
360
|
+
expect(result.stdout).toContain("foo-bar");
|
|
361
|
+
expect(result.stdout).toContain("baz-qux");
|
|
362
|
+
});
|
|
363
|
+
test("D.2 index.yaml accepts directory name as workflow name", async () => {
|
|
364
|
+
const dir = join(tmpDir, "my-flow");
|
|
365
|
+
await mkdir(dir, { recursive: true });
|
|
366
|
+
const file = join(dir, "index.yaml");
|
|
367
|
+
await writeFile(file, stringify(makeMinimalPayload("my-flow")));
|
|
368
|
+
const result = runValidate(file);
|
|
369
|
+
expect(result.exitCode).toBe(0);
|
|
370
|
+
expect(result.stdout.trim()).toBe("✓ valid");
|
|
371
|
+
expect(result.stderr).toBe("");
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
// ── Suite E: Semantic Errors ─────────────────────────────────────────────────
|
|
375
|
+
//
|
|
376
|
+
// Issue #308: errors are now rendered to stdout via the validate-result
|
|
377
|
+
// envelope template (`✗ invalid (N errors)\n - <error>...`). Exit code is 1.
|
|
378
|
+
describe("workflow validate — Suite E: Semantic Errors", () => {
|
|
379
|
+
test("E.1 graph prompt references variable absent from frontmatter", async () => {
|
|
380
|
+
const payload = {
|
|
381
|
+
name: "comment-pr",
|
|
382
|
+
description: "Comment on PR",
|
|
383
|
+
roles: {
|
|
384
|
+
commenter: {
|
|
385
|
+
description: "Commenter",
|
|
386
|
+
goal: "Comment",
|
|
387
|
+
capabilities: ["commenting"],
|
|
388
|
+
procedure: "Comment",
|
|
389
|
+
output: "Comment",
|
|
390
|
+
// NB: no `prNumber` property declared
|
|
391
|
+
frontmatter: {
|
|
392
|
+
type: "object",
|
|
393
|
+
properties: {
|
|
394
|
+
$status: { const: "approved" },
|
|
395
|
+
},
|
|
396
|
+
required: ["$status"],
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
graph: {
|
|
401
|
+
$START: {
|
|
402
|
+
new: { role: "commenter", prompt: "Begin", location: null },
|
|
403
|
+
resume: { role: "commenter", prompt: "Resume", location: null },
|
|
404
|
+
},
|
|
405
|
+
commenter: {
|
|
406
|
+
approved: {
|
|
407
|
+
role: "$END",
|
|
408
|
+
prompt: "Comment on PR #{{ prNumber }}",
|
|
409
|
+
location: null,
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
const file = join(tmpDir, "comment-pr.yaml");
|
|
415
|
+
await writeFile(file, stringify(payload));
|
|
416
|
+
const result = runValidate(file);
|
|
417
|
+
expect(result.exitCode).toBe(1);
|
|
418
|
+
expect(result.stdout).toContain("✗ invalid");
|
|
419
|
+
expect(result.stdout).toContain('template variable "prNumber"');
|
|
420
|
+
expect(result.stdout).toContain("commenter");
|
|
421
|
+
});
|
|
422
|
+
test("E.2 multi-exit oneOf variant prompt references variable not in that variant", async () => {
|
|
423
|
+
const payload = {
|
|
424
|
+
name: "review-bad",
|
|
425
|
+
description: "Bad review",
|
|
426
|
+
roles: {
|
|
427
|
+
writer: {
|
|
428
|
+
description: "Writes",
|
|
429
|
+
goal: "Write",
|
|
430
|
+
capabilities: ["writing"],
|
|
431
|
+
procedure: "Write",
|
|
432
|
+
output: "Content",
|
|
433
|
+
frontmatter: {
|
|
434
|
+
type: "object",
|
|
435
|
+
properties: {
|
|
436
|
+
$status: { const: "done" },
|
|
437
|
+
plan: { type: "string" },
|
|
438
|
+
},
|
|
439
|
+
required: ["$status", "plan"],
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
reviewer: {
|
|
443
|
+
description: "Reviews",
|
|
444
|
+
goal: "Review",
|
|
445
|
+
capabilities: ["reviewing"],
|
|
446
|
+
procedure: "Review",
|
|
447
|
+
output: "Review",
|
|
448
|
+
frontmatter: {
|
|
449
|
+
type: "object",
|
|
450
|
+
oneOf: [
|
|
451
|
+
{
|
|
452
|
+
properties: {
|
|
453
|
+
$status: { const: "approved" },
|
|
454
|
+
summary: { type: "string" },
|
|
455
|
+
},
|
|
456
|
+
required: ["$status", "summary"],
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
properties: {
|
|
460
|
+
$status: { const: "rejected" },
|
|
461
|
+
reason: { type: "string" },
|
|
462
|
+
},
|
|
463
|
+
required: ["$status", "reason"],
|
|
464
|
+
},
|
|
465
|
+
],
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
graph: {
|
|
470
|
+
$START: {
|
|
471
|
+
new: { role: "writer", prompt: "Begin", location: null },
|
|
472
|
+
resume: { role: "writer", prompt: "Resume", location: null },
|
|
473
|
+
},
|
|
474
|
+
writer: { done: { role: "reviewer", prompt: "Review: {{ plan }}", location: null } },
|
|
475
|
+
reviewer: {
|
|
476
|
+
// approved branch references {{ reason }} which only exists in rejected variant
|
|
477
|
+
approved: { role: "$END", prompt: "Approved because: {{ reason }}", location: null },
|
|
478
|
+
rejected: { role: "writer", prompt: "Fix: {{ reason }}", location: null },
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
const file = join(tmpDir, "review-bad.yaml");
|
|
483
|
+
await writeFile(file, stringify(payload));
|
|
484
|
+
const result = runValidate(file);
|
|
485
|
+
expect(result.exitCode).toBe(1);
|
|
486
|
+
expect(result.stdout).toContain('template variable "reason"');
|
|
487
|
+
expect(result.stdout).toContain('variant "approved"');
|
|
488
|
+
});
|
|
489
|
+
test("E.3 graph references unknown role", async () => {
|
|
490
|
+
const payload = makeMinimalPayload("orphan-graph");
|
|
491
|
+
const graph = payload.graph;
|
|
492
|
+
graph.bogus = { done: { role: "$END", prompt: "x", location: null } };
|
|
493
|
+
const file = join(tmpDir, "orphan-graph.yaml");
|
|
494
|
+
await writeFile(file, stringify(payload));
|
|
495
|
+
const result = runValidate(file);
|
|
496
|
+
expect(result.exitCode).toBe(1);
|
|
497
|
+
expect(result.stdout).toContain('unknown role "bogus"');
|
|
498
|
+
});
|
|
499
|
+
test("E.4 $START missing resume edge", async () => {
|
|
500
|
+
const payload = makeMinimalPayload("no-resume");
|
|
501
|
+
const graph = payload.graph;
|
|
502
|
+
graph.$START = {
|
|
503
|
+
new: { role: "writer", prompt: "Begin", location: null },
|
|
504
|
+
// no resume edge
|
|
505
|
+
};
|
|
506
|
+
const file = join(tmpDir, "no-resume.yaml");
|
|
507
|
+
await writeFile(file, stringify(payload));
|
|
508
|
+
const result = runValidate(file);
|
|
509
|
+
expect(result.exitCode).toBe(1);
|
|
510
|
+
expect(result.stdout).toContain('$START must have edges with statuses "new" and "resume"');
|
|
511
|
+
});
|
|
512
|
+
test("E.5 unreachable role exits 1", async () => {
|
|
513
|
+
const payload = makeMultiRolePayload("unreachable");
|
|
514
|
+
// add an extra role that is in roles + graph but no edge points to it
|
|
515
|
+
const roles = payload.roles;
|
|
516
|
+
roles.orphan = {
|
|
517
|
+
description: "Orphan",
|
|
518
|
+
goal: "nothing",
|
|
519
|
+
capabilities: [],
|
|
520
|
+
procedure: "none",
|
|
521
|
+
output: "none",
|
|
522
|
+
frontmatter: {
|
|
523
|
+
type: "object",
|
|
524
|
+
properties: { $status: { const: "done" } },
|
|
525
|
+
required: ["$status"],
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
const graph = payload.graph;
|
|
529
|
+
graph.orphan = { done: { role: "$END", prompt: "ok", location: null } };
|
|
530
|
+
const file = join(tmpDir, "unreachable.yaml");
|
|
531
|
+
await writeFile(file, stringify(payload));
|
|
532
|
+
const result = runValidate(file);
|
|
533
|
+
expect(result.exitCode).toBe(1);
|
|
534
|
+
expect(result.stdout).toContain("is not reachable from $START");
|
|
535
|
+
});
|
|
536
|
+
test("E.6 $SUSPEND used as edge target exits 1", async () => {
|
|
537
|
+
const payload = makeMinimalPayload("bad-suspend");
|
|
538
|
+
const graph = payload.graph;
|
|
539
|
+
graph.writer = { done: { role: "$SUSPEND", prompt: "x", location: null } };
|
|
540
|
+
const file = join(tmpDir, "bad-suspend.yaml");
|
|
541
|
+
await writeFile(file, stringify(payload));
|
|
542
|
+
const result = runValidate(file);
|
|
543
|
+
expect(result.exitCode).toBe(1);
|
|
544
|
+
expect(result.stdout).toContain("$SUSPEND");
|
|
545
|
+
});
|
|
546
|
+
test("E.7 multiple semantic errors are all reported", async () => {
|
|
547
|
+
const payload = makeMinimalPayload("multi-error");
|
|
548
|
+
// 1) unknown role: graph node referencing undefined role
|
|
549
|
+
const graph = payload.graph;
|
|
550
|
+
graph.bogus = { done: { role: "$END", prompt: "x", location: null } };
|
|
551
|
+
// 2) $START missing resume
|
|
552
|
+
graph.$START = {
|
|
553
|
+
new: { role: "writer", prompt: "Begin", location: null },
|
|
554
|
+
};
|
|
555
|
+
// 3) bad Liquid variable
|
|
556
|
+
graph.writer = {
|
|
557
|
+
done: { role: "$END", prompt: "Use {{ missing }}", location: null },
|
|
558
|
+
};
|
|
559
|
+
const file = join(tmpDir, "multi-error.yaml");
|
|
560
|
+
await writeFile(file, stringify(payload));
|
|
561
|
+
const result = runValidate(file);
|
|
562
|
+
expect(result.exitCode).toBe(1);
|
|
563
|
+
expect(result.stdout).toContain("✗ invalid");
|
|
564
|
+
expect(result.stdout).toContain('unknown role "bogus"');
|
|
565
|
+
expect(result.stdout).toContain("$START must have edges");
|
|
566
|
+
expect(result.stdout).toContain("missing");
|
|
567
|
+
// each error is bullet-prefixed with ` - `
|
|
568
|
+
expect(result.stdout).toContain(" - ");
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
// ── Suite F: Isolation From CAS / Store ──────────────────────────────────────
|
|
572
|
+
//
|
|
573
|
+
// Issue #308: validate now uses the unified envelope writer, which requires
|
|
574
|
+
// the CAS store (for `@uwf/output/validate-result` schema lookup and the
|
|
575
|
+
// `@ocas/template/text/<hash>` template). The store is initialized
|
|
576
|
+
// idempotently on startup. These tests assert that:
|
|
577
|
+
// - validate works without explicit OCAS_HOME
|
|
578
|
+
// - validate runs idempotently (second run modifies nothing on success)
|
|
579
|
+
// - validate does not modify the workflow registry on success
|
|
580
|
+
describe("workflow validate — Suite F: Isolation From CAS / Store", () => {
|
|
581
|
+
test("F.1 runs without OCAS_HOME set", async () => {
|
|
582
|
+
const file = join(tmpDir, "iso-wf.yaml");
|
|
583
|
+
await writeFile(file, stringify(makeMinimalPayload("iso-wf")));
|
|
584
|
+
// Strip OCAS_HOME / UWF_HOME from env, point HOME at empty tmp.
|
|
585
|
+
const isolatedHome = join(tmpDir, "isolated-home");
|
|
586
|
+
await mkdir(isolatedHome, { recursive: true });
|
|
587
|
+
const env = { ...process.env };
|
|
588
|
+
delete env.OCAS_HOME;
|
|
589
|
+
delete env.UWF_HOME;
|
|
590
|
+
env.HOME = isolatedHome;
|
|
591
|
+
const result = runValidate(file, [], env);
|
|
592
|
+
expect(result.exitCode).toBe(0);
|
|
593
|
+
expect(result.stdout.trim()).toBe("✓ valid");
|
|
594
|
+
expect(result.stderr).toBe("");
|
|
595
|
+
});
|
|
596
|
+
test("F.2 runs even when ocas store is empty/uninitialized (skip on win32)", {
|
|
597
|
+
skip: process.platform === "win32",
|
|
598
|
+
}, async () => {
|
|
599
|
+
const file = join(tmpDir, "ro-home-wf.yaml");
|
|
600
|
+
await writeFile(file, stringify(makeMinimalPayload("ro-home-wf")));
|
|
601
|
+
// Use a writable but empty OCAS_HOME — schema registration writes
|
|
602
|
+
// happen at startup but the validate command should still succeed.
|
|
603
|
+
const ocasHome = join(tmpDir, "fresh-ocas");
|
|
604
|
+
await mkdir(ocasHome, { recursive: true });
|
|
605
|
+
const env = { ...process.env };
|
|
606
|
+
delete env.UWF_HOME;
|
|
607
|
+
env.OCAS_HOME = ocasHome;
|
|
608
|
+
const result = runValidate(file, [], env);
|
|
609
|
+
expect(result.exitCode).toBe(0);
|
|
610
|
+
expect(result.stdout.trim()).toBe("✓ valid");
|
|
611
|
+
expect(result.stderr).toBe("");
|
|
612
|
+
});
|
|
613
|
+
test("F.3 does not modify workflow registry on success", async () => {
|
|
614
|
+
const file = join(tmpDir, "reg-wf.yaml");
|
|
615
|
+
await writeFile(file, stringify(makeMinimalPayload("reg-wf")));
|
|
616
|
+
const ocasHome = join(tmpDir, "ocas-home");
|
|
617
|
+
await mkdir(ocasHome, { recursive: true });
|
|
618
|
+
const env = { ...process.env, OCAS_HOME: ocasHome, UWF_HOME: ocasHome };
|
|
619
|
+
// First run primes the schema/template registrations
|
|
620
|
+
const first = runValidate(file, [], env);
|
|
621
|
+
expect(first.exitCode).toBe(0);
|
|
622
|
+
expect(first.stdout.trim()).toBe("✓ valid");
|
|
623
|
+
// Capture state after registrations are present
|
|
624
|
+
const beforeListing = await listingSnapshot(ocasHome);
|
|
625
|
+
// Second run must not modify the registry (no @uwf/registry/<name> binding)
|
|
626
|
+
const second = runValidate(file, [], env);
|
|
627
|
+
expect(second.exitCode).toBe(0);
|
|
628
|
+
expect(second.stdout.trim()).toBe("✓ valid");
|
|
629
|
+
expect(second.stderr).toBe("");
|
|
630
|
+
const afterListing = await listingSnapshot(ocasHome);
|
|
631
|
+
expect(afterListing).toEqual(beforeListing);
|
|
632
|
+
});
|
|
633
|
+
test("F.4 second run on the same workflow is idempotent (no further CAS writes)", async () => {
|
|
634
|
+
const file = join(tmpDir, "cas-iso-wf.yaml");
|
|
635
|
+
await writeFile(file, stringify(makeMinimalPayload("cas-iso-wf")));
|
|
636
|
+
const ocasHome = join(tmpDir, "ocas2");
|
|
637
|
+
await mkdir(ocasHome, { recursive: true });
|
|
638
|
+
const env = { ...process.env, OCAS_HOME: ocasHome, UWF_HOME: ocasHome };
|
|
639
|
+
// First run: schema/template registration is allowed to write to CAS
|
|
640
|
+
const first = runValidate(file, [], env);
|
|
641
|
+
expect(first.exitCode).toBe(0);
|
|
642
|
+
const beforeListing = await listingSnapshot(ocasHome);
|
|
643
|
+
// Second run: registrations are idempotent → no new writes
|
|
644
|
+
const second = runValidate(file, [], env);
|
|
645
|
+
expect(second.exitCode).toBe(0);
|
|
646
|
+
const afterListing = await listingSnapshot(ocasHome);
|
|
647
|
+
expect(afterListing).toEqual(beforeListing);
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
// ── Suite G: Argument Surface ────────────────────────────────────────────────
|
|
651
|
+
describe("workflow validate — Suite G: Argument Surface", () => {
|
|
652
|
+
test("G.1 missing <file> argument fails with non-zero exit", () => {
|
|
653
|
+
const result = runCli(["workflow", "validate"]);
|
|
654
|
+
expect(result.exitCode).not.toBe(0);
|
|
655
|
+
// commander phrasing varies; check for the broad `missing required argument` string.
|
|
656
|
+
expect(result.stderr.toLowerCase()).toContain("missing required argument");
|
|
657
|
+
});
|
|
658
|
+
test("G.2 'workflow --help' lists 'validate'", () => {
|
|
659
|
+
const result = runCli(["workflow", "--help"]);
|
|
660
|
+
expect(result.exitCode).toBe(0);
|
|
661
|
+
expect(result.stdout).toContain("validate");
|
|
662
|
+
});
|
|
663
|
+
test("G.3 'workflow validate --help' describes the command", () => {
|
|
664
|
+
const result = runCli(["workflow", "validate", "--help"]);
|
|
665
|
+
expect(result.exitCode).toBe(0);
|
|
666
|
+
expect(result.stdout).toContain("file");
|
|
667
|
+
expect(result.stdout.length).toBeGreaterThan(0);
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
// ── helpers for snapshot ─────────────────────────────────────────────────────
|
|
671
|
+
/**
|
|
672
|
+
* Recursively snapshot a directory's listing (paths + sizes).
|
|
673
|
+
* Used to assert no files are written during validate.
|
|
674
|
+
*/
|
|
675
|
+
async function listingSnapshot(root) {
|
|
676
|
+
const out = {};
|
|
677
|
+
async function walk(dir) {
|
|
678
|
+
let entries = [];
|
|
679
|
+
try {
|
|
680
|
+
entries = await readdir(dir);
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
for (const entry of entries) {
|
|
686
|
+
const full = join(dir, entry);
|
|
687
|
+
let st;
|
|
688
|
+
try {
|
|
689
|
+
st = await stat(full);
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (st.isDirectory()) {
|
|
695
|
+
await walk(full);
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
out[full.slice(root.length)] = st.size;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
await walk(root);
|
|
703
|
+
return out;
|
|
704
|
+
}
|
|
705
|
+
// Suppress unused warnings in tests that don't currently use these helpers
|
|
706
|
+
void readFile;
|
|
707
|
+
//# sourceMappingURL=workflow-validate.test.js.map
|