@fred-drake/anvil 0.0.1
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 +72 -0
- package/assets/anvil-logo.png +0 -0
- package/assets/anvil-logo.txt +21 -0
- package/extensions/anvil/index.ts +1 -0
- package/package.json +58 -0
- package/skills/anvil-workflow-builder/SKILL.md +109 -0
- package/src/discovery.ts +149 -0
- package/src/engine.ts +591 -0
- package/src/errors.ts +18 -0
- package/src/gates.ts +198 -0
- package/src/index.ts +700 -0
- package/src/paths.ts +35 -0
- package/src/prompts.ts +253 -0
- package/src/shell.ts +3 -0
- package/src/subagent/child.ts +61 -0
- package/src/subagent/cmux.ts +227 -0
- package/src/subagent/exit.ts +113 -0
- package/src/subagent/herdr.ts +142 -0
- package/src/subagent/runner.ts +178 -0
- package/src/types.ts +122 -0
- package/src/ui.ts +94 -0
- package/src/validate.ts +340 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/anvil-logo.png" alt="Anvil logo" width="320">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Anvil
|
|
6
|
+
|
|
7
|
+
A Pi extension that runs declarative TypeScript workflows with deterministic and agent-judged gates.
|
|
8
|
+
|
|
9
|
+
Anvil is for those Pi tasks where you keep thinking, “I want the agent to do this the same way every time, but I also want it to use judgment when a script cannot tell the whole story.” You can chain steps together, gate each step with hard pass/fail checks or agent-reviewed thumbs-up/thumbs-down checks, and keep the process moving without babysitting every turn.
|
|
10
|
+
|
|
11
|
+
## Features at a glance
|
|
12
|
+
|
|
13
|
+
- Build the workflow in your own words and Anvil will worry about how to properly build it.
|
|
14
|
+
- For each workflow step, set any number of gating checks that must pass. Checks can be deterministic, like a script or executable that returns exit code `0`, or non-deterministic, where a subagent evaluates the result and gives a 👍 or 👎 based on what it thinks should happen.
|
|
15
|
+
- Define subagent behavior based on how you have configured Pi. cmux and herdr compatibility come out of the box, you can choose a custom skill that you wrote for handling subagent processing, or no subagent at all if you wish.
|
|
16
|
+
- Optionally define the number of times a step has to be retried before bailing.
|
|
17
|
+
- Optionally define a different model and thinking level for each step, including retry-based upgrades.
|
|
18
|
+
|
|
19
|
+
## Build workflows by talking to Pi
|
|
20
|
+
|
|
21
|
+
The intended way to create a workflow is to describe what you want in plain language and let the agent shape it into something Anvil can run. This extension includes an `anvil-workflow-builder` skill that guides that conversation, asks for missing details when it needs them, and handles the workflow structure for you.
|
|
22
|
+
|
|
23
|
+
Although it follows a typescript schema under the hood, the intention is to say what the workflow should do in your own words. If Anvil needs exact commands, gating behavior, model choices, or delegation preferences, it will ask.
|
|
24
|
+
|
|
25
|
+
Workflows live in:
|
|
26
|
+
|
|
27
|
+
- User: `~/.pi/agent/anvil/workflows/*.ts` (also `.js`/`.mjs`)
|
|
28
|
+
- Project: `.pi/anvil/workflows/*.ts` (project workflows win on name collisions)
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
/anvil list
|
|
34
|
+
/anvil validate <name>
|
|
35
|
+
/anvil run <name> <free-form task input>
|
|
36
|
+
/anvil resume <step> [retry-number]
|
|
37
|
+
/anvil abort
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Use `/anvil list` to see available workflows, `/anvil validate` to check that one is ready, and `/anvil run` to start a workflow with whatever task input you want to give it.
|
|
41
|
+
|
|
42
|
+
Use `/anvil resume` after a failed or aborted run to see a numbered step map, for example `1. Plan`, `2. Implement`, `3. Verify`. Then run `/anvil resume <step> [retry-number]` with the one-based step number to restart from that workflow step using the original task input. Omit `retry-number` for no retries, or pass a retry count to seed `{loop}` on the resumed step.
|
|
43
|
+
|
|
44
|
+
## Declarative subagents
|
|
45
|
+
|
|
46
|
+
Each workflow step can decide how much help it wants from another agent. By default, `delegation: "auto"` auto-detects your mux environment, using `HERDR_ENV=1` for herdr first, then `CMUX_SHELL_INTEGRATION=1` for cmux. Current supported environments are cmux and herdr. You can also force one of these two environments with `delegation: { subagent: "cmux" }` or `delegation: { subagent: "herdr" }`. Or if you prefer to use a specific skill that you've crafted that handles subagents, you can tell it to use that instead. Lastly, you can explicitly tell it to do not use subagents.
|
|
47
|
+
|
|
48
|
+
⚠️ It is advised to use subagents on any step that is non-trivial, because you run the risk of context pollution.
|
|
49
|
+
|
|
50
|
+
⚠️ While skills are supported, be aware that unlike the other options you are at the mercy of the model to get it right. Non-deterministic skills run the risk of it doing the right thing 95% of the time, then misbehaving in that one time out of twenty.
|
|
51
|
+
|
|
52
|
+
Warnings aside, this is ultimately _your workflow, your rules_. Checks still guard the workflow either way: deterministic checks run commands, while agent-judged checks ask for a clear pass/fail verdict before the workflow moves on.
|
|
53
|
+
|
|
54
|
+
### Retry-based model selection
|
|
55
|
+
|
|
56
|
+
When you ask Pi to build a workflow, you can tell it to use different models or thinking levels after a step has to retry. For example, `retryModelSelections: [{ retry: 0, model: "cheap/model:minimal" }, { retry: 1, model: "strong/model", thinkingLevel: "high" }]` starts cheaper on the first attempt (`retry: 0`), then upgrades after the first retry. The highest selection with `retry` less than or equal to the current retry count wins, and model selection also applies to declarative subagent launches.
|
|
57
|
+
|
|
58
|
+
This is useful when most runs should stay fast and inexpensive, but difficult cases deserve more reasoning instead of repeating the same attempt with the same settings. By default, Anvil keeps the same model and thinking level for every attempt, so nothing changes unless you ask for retry-based escalation.
|
|
59
|
+
|
|
60
|
+
You do not need to know the workflow syntax for this feature. Describe the escalation you want in plain language, and the `anvil-workflow-builder` skill will capture it while building the workflow.
|
|
61
|
+
|
|
62
|
+
## Develop
|
|
63
|
+
|
|
64
|
+
If you use nix, a nix dev environment is included with everything you need for development.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
cd anvil
|
|
68
|
+
npm install
|
|
69
|
+
npm run typecheck
|
|
70
|
+
npm test
|
|
71
|
+
npm run dev # pi -e ./src/index.ts
|
|
72
|
+
```
|
|
Binary file
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
kbbbkbbdbbJu}bbdpddpk
|
|
2
|
+
ZdppddbdbbbbbbbbbbkbkbbbbbbamtJhd0UYYUUYXYJJ$
|
|
3
|
+
J(bmCt)~jZh*hvcdqzrnrjnnnnnnvnnuzXXXcvzzJYzYYn
|
|
4
|
+
mdhhha*ooaooaahahi;//~"1|f//jtjrjrjxjjxunuuxnuvczuuzzcx
|
|
5
|
+
)?>i>~_+_[}}{)|fzxn;/|~"}|\/\/tf/frfffrrrnxujunxxnnzv
|
|
6
|
+
Jt|111-+~~--{{nCJ\|(+"[(\(\tf\jjjxnrfU&xxrjjxnxxn
|
|
7
|
+
qc/())1(1()\\/t/\_,[\\\\\/)Ipdbkhaaoxrrfrjrx
|
|
8
|
+
Lzt1?~<+][}{[:]|(/|\/);ppffIbhajjrfjxc
|
|
9
|
+
$qL<ru]\\t|\\(Iddtu:b|;ahfjxO
|
|
10
|
+
UJJv{<1/\/)IbbbbbftIhhrrx
|
|
11
|
+
unvvx(?]\\(lkkttff/Iahjju
|
|
12
|
+
)/fjrjj/\((lkk///tfffffjf
|
|
13
|
+
{{}111)|\(\/tt/tftjxYQQOZqp
|
|
14
|
+
?-_-[{{1(1lnzXJQO/tf/|(1JmL0Xct
|
|
15
|
+
tuuj\|1}]-~~-])\-mZmZn)|\||(1[[[]<\{1))fw
|
|
16
|
+
zcr+cur/(}_+<<ii(()}+]}{())1{[[?-_?]]?u}mdjz
|
|
17
|
+
trnxnn1ruj/}?<iUzzv_]}{{1{}}]??-+++--_-ffxrY
|
|
18
|
+
{(\/t/(-j|{---]?1)11}[[??jw$ qu<;_|p
|
|
19
|
+
{[}{)()+\()\jf)q
|
|
20
|
+
f?[1{)1))|
|
|
21
|
+
r}w
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "../../src/index.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fred-drake/anvil",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Pi extension that runs declarative TypeScript workflows with deterministic and agent-judged gates.",
|
|
5
|
+
"homepage": "https://github.com/fred-drake/anvil#readme",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/fred-drake/anvil.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/fred-drake/anvil/issues"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi-package",
|
|
15
|
+
"pi",
|
|
16
|
+
"pi-extension",
|
|
17
|
+
"workflow",
|
|
18
|
+
"agent"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
"assets",
|
|
22
|
+
"extensions",
|
|
23
|
+
"skills",
|
|
24
|
+
"src",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"type": "module",
|
|
31
|
+
"scripts": {
|
|
32
|
+
"dev": "pi -e ./src/index.ts",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"check": "npm run typecheck && npm run test"
|
|
36
|
+
},
|
|
37
|
+
"pi": {
|
|
38
|
+
"extensions": [
|
|
39
|
+
"./extensions/anvil/index.ts"
|
|
40
|
+
],
|
|
41
|
+
"skills": [
|
|
42
|
+
"./skills"
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@earendil-works/pi-ai": "^0.80.3",
|
|
47
|
+
"@earendil-works/pi-coding-agent": "^0.80.3",
|
|
48
|
+
"jiti": "^2.7.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
52
|
+
"typescript": "^5.9.3",
|
|
53
|
+
"vitest": "^4.1.10"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=22"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: anvil-workflow-builder
|
|
3
|
+
description: Use when the user wants to create or edit an automated multi-step workflow with gated checks.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Anvil Workflow Builder
|
|
7
|
+
|
|
8
|
+
Help the user author a declarative anvil workflow. Interview them first, then write a workflow file and validate it.
|
|
9
|
+
|
|
10
|
+
## Storage policy
|
|
11
|
+
|
|
12
|
+
Anvil workflows must be stored only in Anvil's workflow directories:
|
|
13
|
+
|
|
14
|
+
- User scope: `~/.pi/agent/anvil/workflows/<name>.ts`
|
|
15
|
+
- Project scope: `.pi/anvil/workflows/<name>.ts`
|
|
16
|
+
|
|
17
|
+
Do not write Anvil workflows to Pi's generic workflow locations such as `.pi/workflows/saved/` or `~/.pi/workflows/saved/`; those belong to other workflow implementations and may conflict.
|
|
18
|
+
|
|
19
|
+
## Interview flow
|
|
20
|
+
|
|
21
|
+
Guide the user with pickers when Anvil-specific choices are missing. The user may know the desired workflow but not Anvil's required structure, so do not silently guess for ambiguous scope or gate choices.
|
|
22
|
+
|
|
23
|
+
1. Choose a workflow name (`[a-z0-9-]+`) and a one-sentence goal.
|
|
24
|
+
2. Determine workflow scope.
|
|
25
|
+
- If the user explicitly says user/global/personal scope, use user scope (`~/.pi/agent/anvil/workflows/<name>.ts`).
|
|
26
|
+
- If the user explicitly says project/repo/local scope, use project scope (`.pi/anvil/workflows/<name>.ts`).
|
|
27
|
+
- If they do not specifically name the user or project scope, ask with a picker containing exactly these two choices: user scope or project scope.
|
|
28
|
+
3. Decide workflow delegation defaults:
|
|
29
|
+
- Default to `delegation: "auto"` unless the user specifically chooses otherwise; auto detects `HERDR_ENV=1` as herdr first, then `CMUX_SHELL_INTEGRATION=1` as cmux, and otherwise lets the main agent proceed.
|
|
30
|
+
- `delegation: { subagent: "cmux" }` — Anvil itself spawns each step in a dedicated pi subagent session inside a cmux surface (declarative; requires running pi inside cmux). Per-step `model`/`thinkingLevel` are passed to the subagent.
|
|
31
|
+
- `delegation: { subagent: "herdr" }` — Anvil itself spawns each step in a dedicated pi subagent session inside a herdr pane/tab (declarative; requires running pi inside herdr). Per-step `model`/`thinkingLevel` are passed to the subagent.
|
|
32
|
+
- `delegation: { skill: "<skill-name>" }` — prompt hint to prefer a specific skill (the main agent decides).
|
|
33
|
+
- `delegation: "auto"` — auto-detect herdr/cmux subagent support from the environment.
|
|
34
|
+
- `delegation: "none"` — avoid subagents.
|
|
35
|
+
4. For each step, capture:
|
|
36
|
+
- `id` (stable kebab-case identifier)
|
|
37
|
+
- purpose / prompt
|
|
38
|
+
- optional per-step `model` and/or `thinkingLevel`. Pi's thinking shorthand is colon-based (`provider/model:high`, not `provider/model/high`). Supported `thinkingLevel` values are `off`, `minimal`, `low`, `medium`, `high`, and `xhigh`. Omitted model/thinking values use the workflow-start defaults.
|
|
39
|
+
- optional `retryModelSelections` when the user wants retry-aware model or thinking changes. `retry: 0` is the first attempt; `retry: 1` is the first retry. The highest retry less than or equal to the current retry count wins, and omitted fields fall back to the step's regular model/thinking. These selections affect main-session steps and declarative subagent launches.
|
|
40
|
+
- optional `subagentTimeoutMs` on workflow defaults or individual steps that use declarative subagent delegation (defaults to 1,800,000ms).
|
|
41
|
+
- optional per-step `delegation` override or `runInMain: true`
|
|
42
|
+
5. For each step, determine whether it has gating checks.
|
|
43
|
+
- If the user explicitly provides one or more checks for the step, capture them.
|
|
44
|
+
- If the user explicitly says the step has no check / no gate, record no `checks` for that step without asking again.
|
|
45
|
+
- If they are not explicit about whether there is no gating check for the step, ask with a picker: add a gating check, or no gating check.
|
|
46
|
+
6. For each gating check, determine its type and details.
|
|
47
|
+
- Explain that checks may be deterministic or non-deterministic.
|
|
48
|
+
- Deterministic checks: a repeatable command or script, optional timeout/cwd. String commands render `{input}` and `{loop}` as quoted shell-variable expansions before running; function commands must quote any context values they interpolate. If the user says that a bash command must run successfully, treat it as implicitly deterministic.
|
|
49
|
+
- Non-deterministic checks: natural-language criteria judged by an agent, optional evaluation subagent.
|
|
50
|
+
- If the user is not explicit about whether a check is deterministic and it is not completely obvious, ask with a picker: deterministic command/script check, or non-deterministic agent-judged check.
|
|
51
|
+
- If the deterministic check does not name a script or command that already exists somewhere, offer to write the script/command for them as part of creating the workflow.
|
|
52
|
+
7. For failures, choose `stop`, `continue`, or `{ goto, maxLoops, onExhausted, feedback }`.
|
|
53
|
+
8. Confirm a concise summary before writing the file.
|
|
54
|
+
|
|
55
|
+
## Template
|
|
56
|
+
|
|
57
|
+
Prefer the import form when possible:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { defineWorkflow } from "anvil";
|
|
61
|
+
|
|
62
|
+
export default defineWorkflow({
|
|
63
|
+
name: "example-workflow",
|
|
64
|
+
description: "Short description.",
|
|
65
|
+
defaults: {
|
|
66
|
+
delegation: "auto",
|
|
67
|
+
onFail: "stop",
|
|
68
|
+
maxLoops: 3,
|
|
69
|
+
},
|
|
70
|
+
steps: [
|
|
71
|
+
{
|
|
72
|
+
id: "implement",
|
|
73
|
+
title: "Implement the change",
|
|
74
|
+
model: "openai-codex/gpt-5.5:high",
|
|
75
|
+
retryModelSelections: [
|
|
76
|
+
{ retry: 0, model: "openai-codex/gpt-5.5:minimal" }, // first attempt
|
|
77
|
+
{ retry: 1, model: "openai-codex/gpt-5.5", thinkingLevel: "high" },
|
|
78
|
+
],
|
|
79
|
+
prompt: "Implement this request: {input}",
|
|
80
|
+
checks: [
|
|
81
|
+
{
|
|
82
|
+
type: "deterministic",
|
|
83
|
+
id: "tests",
|
|
84
|
+
name: "Test suite",
|
|
85
|
+
command: "npm test",
|
|
86
|
+
onFail: { goto: "implement", maxLoops: 2, feedback: true },
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
If TypeScript module resolution is awkward, a plain object default export is also valid:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
export default {
|
|
98
|
+
name: "example-workflow",
|
|
99
|
+
steps: [{ id: "step-one", runInMain: true, prompt: "Do this: {input}" }],
|
|
100
|
+
};
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Finish
|
|
104
|
+
|
|
105
|
+
Write the file to the chosen Anvil workflow path (`~/.pi/agent/anvil/workflows/<name>.ts` or `.pi/anvil/workflows/<name>.ts`), run `/anvil validate <name>`, fix any errors, and tell the user the invocation:
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
/anvil run <name> <task input>
|
|
109
|
+
```
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, extname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { createJiti } from "jiti";
|
|
5
|
+
import type { AnvilPathOptions } from "./paths.ts";
|
|
6
|
+
import { getWorkflowDirs } from "./paths.ts";
|
|
7
|
+
import type { WorkflowDefinition } from "./types.ts";
|
|
8
|
+
import { getWorkflowNameCandidate, validateWorkflow } from "./validate.ts";
|
|
9
|
+
|
|
10
|
+
export type WorkflowSource = "user" | "project";
|
|
11
|
+
|
|
12
|
+
export interface DiscoveredWorkflow {
|
|
13
|
+
name: string;
|
|
14
|
+
file: string;
|
|
15
|
+
source: WorkflowSource;
|
|
16
|
+
workflow?: WorkflowDefinition;
|
|
17
|
+
errors?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const WORKFLOW_EXTENSIONS = new Set([".ts", ".js", ".mjs"]);
|
|
21
|
+
const TYPES_ALIAS_PATH = fileURLToPath(new URL("./types.ts", import.meta.url));
|
|
22
|
+
const discoveryCache = new Map<string, { signature: string; workflows: DiscoveredWorkflow[] }>();
|
|
23
|
+
|
|
24
|
+
export interface WorkflowDiscoveryOptions extends AnvilPathOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Reuse mtime-based discovery results when available. Keep this enabled for
|
|
27
|
+
* keystroke-driven autocomplete, but disable it for commands that must reflect
|
|
28
|
+
* the workflow on disk even when mtimes or imported helper files make the
|
|
29
|
+
* directory signature look unchanged.
|
|
30
|
+
*/
|
|
31
|
+
useCache?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function discoverWorkflows(options: WorkflowDiscoveryOptions = {}): Promise<DiscoveredWorkflow[]> {
|
|
35
|
+
const dirs = getWorkflowDirs(options);
|
|
36
|
+
const useCache = options.useCache !== false;
|
|
37
|
+
const cacheKey = `${dirs.user}\0${dirs.project}`;
|
|
38
|
+
const signature = await workflowDirsSignature(dirs);
|
|
39
|
+
const cached = discoveryCache.get(cacheKey);
|
|
40
|
+
if (useCache && cached?.signature === signature) return cached.workflows;
|
|
41
|
+
|
|
42
|
+
const user = await loadWorkflowDir(dirs.user, "user");
|
|
43
|
+
const project = await loadWorkflowDir(dirs.project, "project");
|
|
44
|
+
|
|
45
|
+
const byName = new Map<string, DiscoveredWorkflow>();
|
|
46
|
+
for (const result of [...user, ...project]) {
|
|
47
|
+
byName.set(result.name, result);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const workflows = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
51
|
+
discoveryCache.set(cacheKey, { signature, workflows });
|
|
52
|
+
return workflows;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function loadWorkflowFile(file: string, source: WorkflowSource): Promise<DiscoveredWorkflow> {
|
|
56
|
+
let loaded: unknown;
|
|
57
|
+
try {
|
|
58
|
+
const jiti = createJiti(import.meta.url, {
|
|
59
|
+
moduleCache: false,
|
|
60
|
+
fsCache: false,
|
|
61
|
+
interopDefault: true,
|
|
62
|
+
alias: { "anvil": TYPES_ALIAS_PATH },
|
|
63
|
+
});
|
|
64
|
+
loaded = await jiti.import<unknown>(file, { default: true });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return {
|
|
67
|
+
name: workflowNameFromFile(file),
|
|
68
|
+
file,
|
|
69
|
+
source,
|
|
70
|
+
errors: [`failed to load: ${formatError(error)}`],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const validation = validateWorkflow(loaded);
|
|
75
|
+
if (validation.ok) {
|
|
76
|
+
return { name: validation.workflow.name, file, source, workflow: validation.workflow };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
name: getWorkflowNameCandidate(loaded, workflowNameFromFile(file)),
|
|
81
|
+
file,
|
|
82
|
+
source,
|
|
83
|
+
errors: validation.errors,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function workflowDirsSignature(dirs: { user: string; project: string }): Promise<string> {
|
|
88
|
+
const [user, project] = await Promise.all([workflowDirSignature(dirs.user), workflowDirSignature(dirs.project)]);
|
|
89
|
+
return `${dirs.user}:${user}\0${dirs.project}:${project}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function workflowDirSignature(dir: string): Promise<string> {
|
|
93
|
+
try {
|
|
94
|
+
const entries = await readdir(dir);
|
|
95
|
+
const parts = await Promise.all(
|
|
96
|
+
entries
|
|
97
|
+
.filter((entry) => WORKFLOW_EXTENSIONS.has(extname(entry)))
|
|
98
|
+
.sort((a, b) => a.localeCompare(b))
|
|
99
|
+
.map(async (entry) => {
|
|
100
|
+
const file = join(dir, entry);
|
|
101
|
+
try {
|
|
102
|
+
const info = await stat(file);
|
|
103
|
+
return `${entry}:${info.mtimeMs}:${info.size}`;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return `${entry}:error:${formatError(error)}`;
|
|
106
|
+
}
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
return parts.join("|");
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return isNodeError(error) && error.code === "ENOENT" ? "missing" : `error:${formatError(error)}`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function loadWorkflowDir(dir: string, source: WorkflowSource): Promise<DiscoveredWorkflow[]> {
|
|
116
|
+
let entries: string[];
|
|
117
|
+
try {
|
|
118
|
+
entries = await readdir(dir);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (isNodeError(error) && error.code === "ENOENT") return [];
|
|
121
|
+
return [
|
|
122
|
+
{
|
|
123
|
+
name: source,
|
|
124
|
+
file: dir,
|
|
125
|
+
source,
|
|
126
|
+
errors: [`failed to read workflow directory: ${formatError(error)}`],
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const files = entries
|
|
132
|
+
.filter((entry) => WORKFLOW_EXTENSIONS.has(extname(entry)))
|
|
133
|
+
.sort((a, b) => a.localeCompare(b))
|
|
134
|
+
.map((entry) => join(dir, entry));
|
|
135
|
+
|
|
136
|
+
return Promise.all(files.map((file) => loadWorkflowFile(file, source)));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function workflowNameFromFile(file: string): string {
|
|
140
|
+
return basename(file, extname(file));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatError(error: unknown): string {
|
|
144
|
+
return error instanceof Error ? error.message : String(error);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
148
|
+
return error instanceof Error && "code" in error;
|
|
149
|
+
}
|