smol-symphony 0.1.0 → 0.2.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/AGENTS.md +105 -38
- package/PRODUCT.md +2 -1
- package/README.md +195 -98
- package/SPEC.md +543 -1915
- package/WORKFLOW.md +654 -179
- package/WORKFLOW.template.md +761 -121
- package/dist/acp-bridge.js +324 -0
- package/dist/acp-bridge.js.map +1 -0
- package/dist/actions/cache.js +191 -0
- package/dist/actions/cache.js.map +1 -0
- package/dist/actions/effects.js +41 -0
- package/dist/actions/effects.js.map +1 -0
- package/dist/actions/executor.js +570 -0
- package/dist/actions/executor.js.map +1 -0
- package/dist/actions/index.js +13 -0
- package/dist/actions/index.js.map +1 -0
- package/dist/actions/parsing.js +273 -0
- package/dist/actions/parsing.js.map +1 -0
- package/dist/actions/predicate-env.js +27 -0
- package/dist/actions/predicate-env.js.map +1 -0
- package/dist/actions/predicates.js +49 -0
- package/dist/actions/predicates.js.map +1 -0
- package/dist/actions/templating.js +66 -0
- package/dist/actions/templating.js.map +1 -0
- package/dist/actions/types.js +15 -0
- package/dist/actions/types.js.map +1 -0
- package/dist/agent/acp.js +232 -63
- package/dist/agent/acp.js.map +1 -1
- package/dist/agent/adapter-names.js +159 -0
- package/dist/agent/adapter-names.js.map +1 -0
- package/dist/agent/adapters.js +338 -102
- package/dist/agent/adapters.js.map +1 -1
- package/dist/agent/credential-extractors.js +342 -0
- package/dist/agent/credential-extractors.js.map +1 -0
- package/dist/agent/credential-secrets.js +628 -0
- package/dist/agent/credential-secrets.js.map +1 -0
- package/dist/agent/credential-ticker.js +57 -0
- package/dist/agent/credential-ticker.js.map +1 -0
- package/dist/agent/gondolin-creds-staging.js +356 -0
- package/dist/agent/gondolin-creds-staging.js.map +1 -0
- package/dist/agent/gondolin-dispatch.js +375 -0
- package/dist/agent/gondolin-dispatch.js.map +1 -0
- package/dist/agent/gondolin.js +124 -0
- package/dist/agent/gondolin.js.map +1 -0
- package/dist/agent/runner-decisions.js +134 -0
- package/dist/agent/runner-decisions.js.map +1 -0
- package/dist/agent/runner.js +1352 -290
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/tool-call-summary.js +102 -0
- package/dist/agent/tool-call-summary.js.map +1 -0
- package/dist/agent/vm-acp-mapping.js +73 -0
- package/dist/agent/vm-acp-mapping.js.map +1 -0
- package/dist/agent/vm-guards.js +262 -0
- package/dist/agent/vm-guards.js.map +1 -0
- package/dist/agent/vm-port.js +22 -0
- package/dist/agent/vm-port.js.map +1 -0
- package/dist/agent/vm-process-registry.js +79 -0
- package/dist/agent/vm-process-registry.js.map +1 -0
- package/dist/bin/cli-args.js +105 -0
- package/dist/bin/cli-args.js.map +1 -0
- package/dist/bin/symphony.js +719 -130
- package/dist/bin/symphony.js.map +1 -1
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -0
- package/dist/http-disk.js +135 -0
- package/dist/http-disk.js.map +1 -0
- package/dist/http-handlers.js +180 -0
- package/dist/http-handlers.js.map +1 -0
- package/dist/http.js +1476 -764
- package/dist/http.js.map +1 -1
- package/dist/issues.js +178 -0
- package/dist/issues.js.map +1 -0
- package/dist/logging.js +163 -5
- package/dist/logging.js.map +1 -1
- package/dist/mcp.js +391 -163
- package/dist/mcp.js.map +1 -1
- package/dist/memory.js +85 -0
- package/dist/memory.js.map +1 -0
- package/dist/orchestrator-decisions.js +331 -0
- package/dist/orchestrator-decisions.js.map +1 -0
- package/dist/orchestrator.js +1189 -303
- package/dist/orchestrator.js.map +1 -1
- package/dist/prompt.js +5 -5
- package/dist/prompt.js.map +1 -1
- package/dist/reconciler/cache.js +65 -0
- package/dist/reconciler/cache.js.map +1 -0
- package/dist/reconciler/index.js +448 -0
- package/dist/reconciler/index.js.map +1 -0
- package/dist/reconciler/ledger.js +131 -0
- package/dist/reconciler/ledger.js.map +1 -0
- package/dist/reconciler/pr-adapters.js +174 -0
- package/dist/reconciler/pr-adapters.js.map +1 -0
- package/dist/reconciler/pr-decide.js +167 -0
- package/dist/reconciler/pr-decide.js.map +1 -0
- package/dist/reconciler/pr.js +422 -0
- package/dist/reconciler/pr.js.map +1 -0
- package/dist/reconciler/types.js +12 -0
- package/dist/reconciler/types.js.map +1 -0
- package/dist/reconciler/vm.js +243 -0
- package/dist/reconciler/vm.js.map +1 -0
- package/dist/reconciler/workspace-defaults.js +83 -0
- package/dist/reconciler/workspace-defaults.js.map +1 -0
- package/dist/reconciler/workspace.js +272 -0
- package/dist/reconciler/workspace.js.map +1 -0
- package/dist/runlog.js +403 -0
- package/dist/runlog.js.map +1 -0
- package/dist/scaffold.js +165 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/trackers/local.js +234 -133
- package/dist/trackers/local.js.map +1 -1
- package/dist/trackers/types.js +1 -1
- package/dist/trackers/types.js.map +1 -1
- package/dist/types.js +1 -1
- package/dist/util/clock.js +12 -0
- package/dist/util/clock.js.map +1 -0
- package/dist/util/crypto.js +25 -0
- package/dist/util/crypto.js.map +1 -0
- package/dist/util/frontmatter.js +70 -0
- package/dist/util/frontmatter.js.map +1 -0
- package/dist/util/fs-issues.js +22 -0
- package/dist/util/fs-issues.js.map +1 -0
- package/dist/util/process.js +152 -0
- package/dist/util/process.js.map +1 -0
- package/dist/util/workspace-key.js +10 -0
- package/dist/util/workspace-key.js.map +1 -0
- package/dist/workflow-loader.js +147 -0
- package/dist/workflow-loader.js.map +1 -0
- package/dist/workflow.js +656 -219
- package/dist/workflow.js.map +1 -1
- package/dist/workspace-types.js +8 -0
- package/dist/workspace-types.js.map +1 -0
- package/dist/workspace.js +367 -120
- package/dist/workspace.js.map +1 -1
- package/package.json +14 -6
- package/scripts/vm-agent.mjs +211 -0
- package/dist/agent/codex.js +0 -439
- package/dist/agent/codex.js.map +0 -1
- package/dist/agent/smolvm.js +0 -174
- package/dist/agent/smolvm.js.map +0 -1
- package/scripts/build-vm.sh +0 -67
package/dist/workflow.js
CHANGED
|
@@ -1,72 +1,45 @@
|
|
|
1
|
-
// WORKFLOW.md
|
|
2
|
-
|
|
3
|
-
import { existsSync, statSync } from 'node:fs';
|
|
1
|
+
// WORKFLOW.md parser and typed config view (SPEC §4). Pure: no fs, no process.
|
|
2
|
+
// The on-disk read and watcher live in `./workflow-loader.ts` (shell).
|
|
4
3
|
import path from 'node:path';
|
|
5
4
|
import os from 'node:os';
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
5
|
+
import { parseFrontMatter, FrontMatterError } from './util/frontmatter.js';
|
|
6
|
+
import { isKnownAdapter } from './agent/adapter-names.js';
|
|
7
|
+
import { parseActionsBlock } from './actions/parsing.js';
|
|
8
|
+
import { WorkflowError } from './errors.js';
|
|
8
9
|
import { log } from './logging.js';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
super(message);
|
|
14
|
-
this.code = code;
|
|
15
|
-
this.name = 'WorkflowError';
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
// §5.2: split YAML front matter from prompt body.
|
|
10
|
+
export { WorkflowError };
|
|
11
|
+
// §4.2: split YAML front matter from prompt body. Thin wrapper over the shared
|
|
12
|
+
// parser that translates FrontMatterError → WorkflowError so callers keep
|
|
13
|
+
// matching on the existing error codes.
|
|
19
14
|
export function splitFrontMatter(text) {
|
|
20
|
-
|
|
21
|
-
return { config: {}, body: text.trim() };
|
|
22
|
-
}
|
|
23
|
-
const lines = text.split(/\r?\n/);
|
|
24
|
-
// First and closing fences must be exactly `---` (with optional trailing whitespace),
|
|
25
|
-
// unindented. Otherwise an indented `---` inside a multiline YAML hook script would be
|
|
26
|
-
// mistaken for the closing fence.
|
|
27
|
-
const isFence = (line) => /^---\s*$/.test(line ?? '');
|
|
28
|
-
if (!isFence(lines[0])) {
|
|
29
|
-
return { config: {}, body: text.trim() };
|
|
30
|
-
}
|
|
31
|
-
let endIdx = -1;
|
|
32
|
-
for (let i = 1; i < lines.length; i++) {
|
|
33
|
-
if (isFence(lines[i])) {
|
|
34
|
-
endIdx = i;
|
|
35
|
-
break;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
if (endIdx < 0) {
|
|
39
|
-
throw new WorkflowError('workflow_parse_error', 'unterminated YAML front matter');
|
|
40
|
-
}
|
|
41
|
-
const fmText = lines.slice(1, endIdx).join('\n');
|
|
42
|
-
const body = lines.slice(endIdx + 1).join('\n').trim();
|
|
43
|
-
let parsed;
|
|
15
|
+
let fm;
|
|
44
16
|
try {
|
|
45
|
-
|
|
17
|
+
fm = parseFrontMatter(text);
|
|
46
18
|
}
|
|
47
19
|
catch (err) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
throw new WorkflowError('workflow_front_matter_not_a_map', 'YAML front matter must decode to a map');
|
|
20
|
+
if (err instanceof FrontMatterError) {
|
|
21
|
+
const code = err.code === 'not_a_map' ? 'workflow_front_matter_not_a_map' : 'workflow_parse_error';
|
|
22
|
+
throw new WorkflowError(code, err.message);
|
|
23
|
+
}
|
|
24
|
+
throw err;
|
|
54
25
|
}
|
|
55
|
-
return { config:
|
|
26
|
+
return { config: fm.fields, body: fm.body };
|
|
56
27
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
const { config, body }
|
|
66
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Pure entry point: split front matter, build the typed view, and return both
|
|
30
|
+
* shapes. The shell loader reads the file from disk and the operator's env,
|
|
31
|
+
* then calls this. `env` defaults to an empty map so tests that do not exercise
|
|
32
|
+
* `$VAR` expansion need not thread it through.
|
|
33
|
+
*/
|
|
34
|
+
export function parseWorkflow(text, workflowPath, env = {}) {
|
|
35
|
+
const { config: raw, body } = splitFrontMatter(text);
|
|
36
|
+
const definition = { config: raw, prompt_template: body };
|
|
37
|
+
const config = buildServiceConfig(raw, workflowPath, env);
|
|
38
|
+
return { definition, config };
|
|
67
39
|
}
|
|
68
|
-
// $VAR / ~ expansion for path/command fields
|
|
69
|
-
|
|
40
|
+
// $VAR / ~ expansion for path/command fields. `env` carries the variable map
|
|
41
|
+
// (the shell loader passes process.env; tests pass an explicit shape).
|
|
42
|
+
export function expandVar(value, env = {}) {
|
|
70
43
|
if (typeof value !== 'string')
|
|
71
44
|
return value;
|
|
72
45
|
let s = value;
|
|
@@ -75,7 +48,7 @@ export function expandVar(value) {
|
|
|
75
48
|
}
|
|
76
49
|
const m = s.match(/^\$([A-Z_][A-Z0-9_]*)$/);
|
|
77
50
|
if (m) {
|
|
78
|
-
const envVal =
|
|
51
|
+
const envVal = env[m[1]];
|
|
79
52
|
return envVal ?? '';
|
|
80
53
|
}
|
|
81
54
|
return s;
|
|
@@ -97,38 +70,53 @@ function asStringList(v, fallback) {
|
|
|
97
70
|
return v.filter((x) => typeof x === 'string');
|
|
98
71
|
return fallback;
|
|
99
72
|
}
|
|
100
|
-
function asMapStrPosInt(v) {
|
|
101
|
-
const out = {};
|
|
102
|
-
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
103
|
-
for (const [k, raw] of Object.entries(v)) {
|
|
104
|
-
const n = asInt(raw, 0);
|
|
105
|
-
if (n > 0)
|
|
106
|
-
out[k.toLowerCase()] = n;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
return out;
|
|
110
|
-
}
|
|
111
73
|
function getObject(parent, key) {
|
|
112
74
|
const v = parent[key];
|
|
113
75
|
if (v && typeof v === 'object' && !Array.isArray(v))
|
|
114
76
|
return v;
|
|
115
77
|
return {};
|
|
116
78
|
}
|
|
117
|
-
//
|
|
118
|
-
|
|
79
|
+
// workspace.github_repo: a GitHub `owner/repo` slug that enables push/PR mode,
|
|
80
|
+
// or null for local-only. There is no auto-detection — the value is either
|
|
81
|
+
// absent (local-only) or a literal slug. Empty string / "none"
|
|
82
|
+
// (case-insensitive) normalize to null so an operator can disable push mode
|
|
83
|
+
// without deleting the key. Any other value MUST be a GitHub owner/repo slug:
|
|
84
|
+
// `owner` is GitHub's `[alnum]`/`-` charset (no `.`, `:`, `@`, scheme, or host)
|
|
85
|
+
// and `repo` is `[alnum]`/`.`/`_`/`-`. This rejects whole-URL and SSH-style
|
|
86
|
+
// remotes (`https://github.com/foo/bar`, `git@github.com:foo/bar`) and bare
|
|
87
|
+
// names (`foo`) at parse time, so a typo can't slip through and build a broken
|
|
88
|
+
// `https://github.com/<garbage>.git` origin that fails later during setup.
|
|
89
|
+
// Takes the raw YAML value (not asString'd) so a present-but-wrong-type value
|
|
90
|
+
// (`github_repo: true`, `123`, `{}`) is rejected rather than coerced to null —
|
|
91
|
+
// otherwise the fail-fast contract has a hole that silently disables push/PR.
|
|
92
|
+
function parseGithubRepo(input) {
|
|
93
|
+
if (input === undefined || input === null)
|
|
94
|
+
return null;
|
|
95
|
+
if (typeof input !== 'string') {
|
|
96
|
+
throw new WorkflowError('workflow_parse_error', `workspace.github_repo must be a string "owner/repo" slug or "none" (got ${typeof input})`);
|
|
97
|
+
}
|
|
98
|
+
const trimmed = input.trim();
|
|
99
|
+
if (trimmed === '' || trimmed.toLowerCase() === 'none')
|
|
100
|
+
return null;
|
|
101
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/.test(trimmed)) {
|
|
102
|
+
throw new WorkflowError('workflow_parse_error', `workspace.github_repo must be a GitHub "owner/repo" slug or "none" (got: ${input})`);
|
|
103
|
+
}
|
|
104
|
+
return trimmed;
|
|
105
|
+
}
|
|
106
|
+
// Build a fully typed ServiceConfig from a parsed front matter map. `env`
|
|
107
|
+
// supplies the variable map for `$VAR` expansion; defaults to {} so pure
|
|
108
|
+
// callers don't need to thread one in.
|
|
109
|
+
export function buildServiceConfig(raw, workflowPath, env = {}) {
|
|
119
110
|
const workflowAbs = path.resolve(workflowPath);
|
|
120
111
|
const workflowDir = path.dirname(workflowAbs);
|
|
121
|
-
// tracker (§
|
|
112
|
+
// tracker (§4.3.1)
|
|
122
113
|
const trackerRaw = getObject(raw, 'tracker');
|
|
123
114
|
const trackerKind = (asString(trackerRaw['kind']) ?? '').trim();
|
|
124
|
-
const apiKeyRaw = asString(trackerRaw['api_key']);
|
|
125
|
-
const apiKeyResolved = apiKeyRaw ? expandVar(apiKeyRaw) : null;
|
|
126
|
-
const trackerEndpointDefault = trackerKind === 'linear' ? 'https://api.linear.app/graphql' : null;
|
|
127
115
|
// local-tracker extension: optional `tracker.root` path.
|
|
128
116
|
const trackerRootRaw = asString(trackerRaw['root']);
|
|
129
117
|
let trackerRoot = null;
|
|
130
118
|
if (trackerRootRaw) {
|
|
131
|
-
const expanded = expandVar(trackerRootRaw);
|
|
119
|
+
const expanded = expandVar(trackerRootRaw, env);
|
|
132
120
|
if (expanded === '') {
|
|
133
121
|
throw new WorkflowError('workflow_parse_error', `tracker.root references an unset variable: ${trackerRootRaw}`);
|
|
134
122
|
}
|
|
@@ -138,32 +126,23 @@ export function buildServiceConfig(raw, workflowPath) {
|
|
|
138
126
|
// Default local tracker root: <workflow-dir>/issues
|
|
139
127
|
trackerRoot = path.resolve(workflowDir, 'issues');
|
|
140
128
|
}
|
|
129
|
+
const states = parseStatesBlock(raw['states']);
|
|
141
130
|
const tracker = {
|
|
142
131
|
kind: trackerKind,
|
|
143
|
-
|
|
144
|
-
api_key: apiKeyResolved && apiKeyResolved.length > 0 ? apiKeyResolved : null,
|
|
145
|
-
project_slug: asString(trackerRaw['project_slug']),
|
|
146
|
-
active_states: asStringList(trackerRaw['active_states'], ['Todo', 'In Progress']),
|
|
147
|
-
terminal_states: asStringList(trackerRaw['terminal_states'], [
|
|
148
|
-
'Closed',
|
|
149
|
-
'Cancelled',
|
|
150
|
-
'Canceled',
|
|
151
|
-
'Duplicate',
|
|
152
|
-
'Done',
|
|
153
|
-
]),
|
|
132
|
+
states,
|
|
154
133
|
root: trackerRoot,
|
|
155
134
|
};
|
|
156
|
-
// polling (§
|
|
135
|
+
// polling (§4.3.2)
|
|
157
136
|
const pollingRaw = getObject(raw, 'polling');
|
|
158
137
|
const polling = {
|
|
159
138
|
interval_ms: asInt(pollingRaw['interval_ms'], 30_000),
|
|
160
139
|
};
|
|
161
|
-
// workspace (§
|
|
140
|
+
// workspace (§4.3.3)
|
|
162
141
|
const workspaceRaw = getObject(raw, 'workspace');
|
|
163
142
|
const wsRootInput = asString(workspaceRaw['root']);
|
|
164
143
|
let workspaceRoot;
|
|
165
144
|
if (wsRootInput) {
|
|
166
|
-
const expanded = expandVar(wsRootInput);
|
|
145
|
+
const expanded = expandVar(wsRootInput, env);
|
|
167
146
|
if (expanded === '') {
|
|
168
147
|
throw new WorkflowError('workflow_parse_error', `workspace.root references an unset variable: ${wsRootInput}`);
|
|
169
148
|
}
|
|
@@ -172,58 +151,108 @@ export function buildServiceConfig(raw, workflowPath) {
|
|
|
172
151
|
else {
|
|
173
152
|
workspaceRoot = path.join(os.tmpdir(), 'symphony_workspaces');
|
|
174
153
|
}
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
after_run: asString(hooksRaw['after_run']),
|
|
182
|
-
before_remove: asString(hooksRaw['before_remove']),
|
|
183
|
-
timeout_ms: asInt(hooksRaw['timeout_ms'], 60_000),
|
|
154
|
+
const baseBranchInput = asString(workspaceRaw['base_branch']);
|
|
155
|
+
const baseBranch = baseBranchInput && baseBranchInput.trim().length > 0 ? baseBranchInput.trim() : 'main';
|
|
156
|
+
const workspace = {
|
|
157
|
+
root: path.resolve(workspaceRoot),
|
|
158
|
+
github_repo: parseGithubRepo(workspaceRaw['github_repo']),
|
|
159
|
+
base_branch: baseBranch,
|
|
184
160
|
};
|
|
185
|
-
|
|
186
|
-
|
|
161
|
+
// logs (symphony extension): per-issue JSONL run logs. Default sits next to the workspace
|
|
162
|
+
// root under `.symphony/logs/` so all symphony-managed state for a project lives in one
|
|
163
|
+
// tree. Same expansion rules as workspace.root.
|
|
164
|
+
const logsRaw = getObject(raw, 'logs');
|
|
165
|
+
const logsRootInput = asString(logsRaw['root']);
|
|
166
|
+
let logsRoot;
|
|
167
|
+
if (logsRootInput) {
|
|
168
|
+
const expanded = expandVar(logsRootInput, env);
|
|
169
|
+
if (expanded === '') {
|
|
170
|
+
throw new WorkflowError('workflow_parse_error', `logs.root references an unset variable: ${logsRootInput}`);
|
|
171
|
+
}
|
|
172
|
+
logsRoot = path.isAbsolute(expanded) ? expanded : path.resolve(workflowDir, expanded);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
logsRoot = path.resolve(workflowDir, '.symphony', 'logs');
|
|
187
176
|
}
|
|
188
|
-
|
|
177
|
+
const logs = { root: path.resolve(logsRoot) };
|
|
178
|
+
// agent (§4.3.4)
|
|
189
179
|
const agentRaw = getObject(raw, 'agent');
|
|
190
180
|
const maxTurns = asInt(agentRaw['max_turns'], 20);
|
|
191
181
|
if (maxTurns <= 0) {
|
|
192
182
|
throw new WorkflowError('workflow_parse_error', 'agent.max_turns must be positive');
|
|
193
183
|
}
|
|
184
|
+
// Memory-aware admission cap (issue 27). Default-on with a 2 GiB host reserve — that's
|
|
185
|
+
// enough headroom for the orchestrator process, the per-VM Gondolin runners,
|
|
186
|
+
// and the kernel's working set on a typical workstation. Operators can disable the cap (set
|
|
187
|
+
// `memory_admission_enabled: false`) on hosts that don't expose /proc/meminfo or where
|
|
188
|
+
// the static cap is already the binding constraint.
|
|
189
|
+
const memAdmissionEnabledRaw = agentRaw['memory_admission_enabled'];
|
|
190
|
+
const memoryAdmissionEnabled = memAdmissionEnabledRaw === undefined ? true : memAdmissionEnabledRaw !== false;
|
|
191
|
+
const hostMemoryReserveMib = asInt(agentRaw['host_memory_reserve_mib'], 2048);
|
|
192
|
+
if (hostMemoryReserveMib < 0) {
|
|
193
|
+
throw new WorkflowError('workflow_parse_error', 'agent.host_memory_reserve_mib must be a non-negative integer');
|
|
194
|
+
}
|
|
195
|
+
// Circuit breaker (issue 128). Default 5: after five consecutive identical
|
|
196
|
+
// failures the orchestrator stops retrying and routes the issue to a holding
|
|
197
|
+
// state. 0 disables the breaker; 1 would trip on the first failure (no retry
|
|
198
|
+
// ever), which is rarely wanted, so the parser rejects it as a likely
|
|
199
|
+
// misconfiguration — use 0 to disable or >= 2 to bound the loop.
|
|
200
|
+
const circuitBreakerThreshold = asInt(agentRaw['circuit_breaker_threshold'], 5);
|
|
201
|
+
if (circuitBreakerThreshold < 0 || circuitBreakerThreshold === 1) {
|
|
202
|
+
throw new WorkflowError('workflow_parse_error', 'agent.circuit_breaker_threshold must be 0 (disabled) or an integer >= 2');
|
|
203
|
+
}
|
|
194
204
|
const agent = {
|
|
195
205
|
max_concurrent_agents: asInt(agentRaw['max_concurrent_agents'], 10),
|
|
196
206
|
max_turns: maxTurns,
|
|
197
207
|
max_retry_backoff_ms: asInt(agentRaw['max_retry_backoff_ms'], 300_000),
|
|
198
|
-
|
|
208
|
+
memory_admission_enabled: memoryAdmissionEnabled,
|
|
209
|
+
host_memory_reserve_mib: hostMemoryReserveMib,
|
|
210
|
+
circuit_breaker_threshold: circuitBreakerThreshold,
|
|
199
211
|
};
|
|
200
|
-
// acp (Symphony extension;
|
|
201
|
-
// one of symphony's known profiles (claude, codex)
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
212
|
+
// acp (Symphony extension; see §4.3.5). `adapter` selects
|
|
213
|
+
// one of symphony's known profiles (claude, codex, opencode); symphony auto-derives the
|
|
214
|
+
// launch command from the adapter profile. Credentials are NOT staged into the workspace:
|
|
215
|
+
// the guest only ever holds a token-shaped placeholder, and the host substitutes the real
|
|
216
|
+
// upstream token into the outbound request at Gondolin egress (TLS-MITM via
|
|
217
|
+
// `createHttpHooks` in src/agent/credential-secrets.ts). The real host credential
|
|
218
|
+
// (`~/.claude/.credentials.json` for claude; `~/.codex/auth.json` access token or
|
|
219
|
+
// `OPENAI_API_KEY` for codex; the GitHub Copilot token exchanged from
|
|
220
|
+
// `~/.local/share/opencode/auth.json` for opencode) never enters the VM.
|
|
221
|
+
//
|
|
222
|
+
// `acp.bridge` configures the host-side TCP listener that the in-VM agent dials back
|
|
223
|
+
// to for ACP traffic. The bridge replaced the earlier in-VM-exec stdio path; see
|
|
224
|
+
// src/acp-bridge.ts for rationale.
|
|
206
225
|
const acpRaw = getObject(raw, 'acp');
|
|
226
|
+
const bridgeRaw = getObject(acpRaw, 'bridge');
|
|
227
|
+
const modelRaw = asString(acpRaw['model']);
|
|
228
|
+
const modelTrimmed = modelRaw === null ? null : modelRaw.trim();
|
|
229
|
+
const effortRaw = asString(acpRaw['effort']);
|
|
230
|
+
const effortTrimmed = effortRaw === null ? null : effortRaw.trim();
|
|
207
231
|
const acp = {
|
|
208
232
|
adapter: asString(acpRaw['adapter']) ?? 'claude',
|
|
209
|
-
|
|
233
|
+
model: modelTrimmed && modelTrimmed.length > 0 ? modelTrimmed : null,
|
|
234
|
+
effort: effortTrimmed && effortTrimmed.length > 0 ? effortTrimmed : null,
|
|
210
235
|
shell: asString(acpRaw['shell']) ?? 'bash',
|
|
211
236
|
prompt_timeout_ms: asInt(acpRaw['prompt_timeout_ms'], 3_600_000),
|
|
212
237
|
read_timeout_ms: asInt(acpRaw['read_timeout_ms'], 30_000),
|
|
213
238
|
stall_timeout_ms: asInt(acpRaw['stall_timeout_ms'], 300_000),
|
|
239
|
+
bridge: {
|
|
240
|
+
bind_host: asString(bridgeRaw['bind_host']) ?? '0.0.0.0',
|
|
241
|
+
bind_port: asInt(bridgeRaw['bind_port'], 8788),
|
|
242
|
+
reach_host: asString(bridgeRaw['reach_host']) ?? '127.0.0.1',
|
|
243
|
+
reach_url: asString(bridgeRaw['reach_url']),
|
|
244
|
+
connect_timeout_ms: asInt(bridgeRaw['connect_timeout_ms'], 30_000),
|
|
245
|
+
},
|
|
214
246
|
};
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
from = path.isAbsolute(expanded) ? expanded : path.resolve(workflowDir, expanded);
|
|
225
|
-
}
|
|
226
|
-
const volumesRaw = smolvmRaw['volumes'];
|
|
247
|
+
// credentials extension (issue 113). Defaults work out of the box for the
|
|
248
|
+
// common case: run the host ticker every 6 hours.
|
|
249
|
+
const credentialsRaw = getObject(raw, 'credentials');
|
|
250
|
+
const credentials = {
|
|
251
|
+
ticker_interval_ms: asInt(credentialsRaw['ticker_interval_ms'], 6 * 60 * 60 * 1000),
|
|
252
|
+
};
|
|
253
|
+
// gondolin VM extension
|
|
254
|
+
const gondolinRaw = getObject(raw, 'gondolin');
|
|
255
|
+
const volumesRaw = gondolinRaw['volumes'];
|
|
227
256
|
const volumes = Array.isArray(volumesRaw)
|
|
228
257
|
? volumesRaw.flatMap((v) => {
|
|
229
258
|
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
@@ -233,7 +262,7 @@ export function buildServiceConfig(raw, workflowPath) {
|
|
|
233
262
|
const guest = asString(m['guest']);
|
|
234
263
|
if (!hostRaw || !guest)
|
|
235
264
|
return [];
|
|
236
|
-
const expandedHost = expandVar(hostRaw);
|
|
265
|
+
const expandedHost = expandVar(hostRaw, env);
|
|
237
266
|
if (expandedHost === '')
|
|
238
267
|
return [];
|
|
239
268
|
const host = path.isAbsolute(expandedHost)
|
|
@@ -243,32 +272,38 @@ export function buildServiceConfig(raw, workflowPath) {
|
|
|
243
272
|
return [{ host, guest, readonly }];
|
|
244
273
|
})
|
|
245
274
|
: [];
|
|
246
|
-
const
|
|
247
|
-
image: asString(
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
mem_mib: asInt(smolvmRaw['mem_mib'], 2048),
|
|
251
|
-
net: smolvmRaw['net'] !== false,
|
|
252
|
-
bin_path: asString(smolvmRaw['bin_path']),
|
|
275
|
+
const gondolin = {
|
|
276
|
+
image: asString(gondolinRaw['image']),
|
|
277
|
+
cpus: asInt(gondolinRaw['cpus'], 2),
|
|
278
|
+
mem_mib: asInt(gondolinRaw['mem_mib'], 2048),
|
|
253
279
|
volumes,
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
280
|
+
// `forward_env` is forwarded into the VM boot env, but the runner strips EVERY
|
|
281
|
+
// credential-bearing var (`stripCredentialEnv`) before boot — the guest holds
|
|
282
|
+
// only the token-shaped placeholder Gondolin substitutes at egress, never a real
|
|
283
|
+
// key. So even if an operator lists `OPENAI_API_KEY` here, it never reaches a VM.
|
|
284
|
+
// The defaults are retained for any future forward-env-strategy adapter.
|
|
285
|
+
forward_env: asStringList(gondolinRaw['forward_env'], [
|
|
258
286
|
'OPENAI_API_KEY',
|
|
259
287
|
'ANTHROPIC_API_KEY',
|
|
260
288
|
]),
|
|
261
|
-
endpoint: asString(smolvmRaw['endpoint']) ??
|
|
262
|
-
`unix://${process.env.XDG_RUNTIME_DIR ?? '/run/user/1000'}/smolvm.sock`,
|
|
263
289
|
};
|
|
264
|
-
//
|
|
290
|
+
// egress firewall: the general dev-tooling allowlist the in-VM agent may reach
|
|
291
|
+
// (npm/git/CDNs) for gates. DISTINCT from the credential layer's per-adapter
|
|
292
|
+
// substitution hosts — nothing listed here ever gets a real token substituted
|
|
293
|
+
// (see credential-secrets.ts buildAdapterHooksConfig). Empty default: the agent
|
|
294
|
+
// can reach only each adapter's inference host until the operator opts hosts in.
|
|
295
|
+
const egressRaw = getObject(raw, 'egress');
|
|
296
|
+
const egress = {
|
|
297
|
+
allowed_hosts: asStringList(egressRaw['allowed_hosts'], []),
|
|
298
|
+
};
|
|
299
|
+
// server extension (§9.5)
|
|
265
300
|
const serverRaw = getObject(raw, 'server');
|
|
266
301
|
const server = {
|
|
267
302
|
port: typeof serverRaw['port'] === 'number' ? serverRaw['port'] : null,
|
|
268
303
|
host: asString(serverRaw['host']) ?? '127.0.0.1',
|
|
269
304
|
};
|
|
270
|
-
// mcp extension: per-issue MCP server (
|
|
271
|
-
// into each ACP session. `host` defaults to the QEMU slirp gateway; the port is the
|
|
305
|
+
// mcp extension: per-issue MCP server (transition + request_human_steering + propose_issue
|
|
306
|
+
// tools) injected into each ACP session. `host` defaults to the QEMU slirp gateway; the port is the
|
|
272
307
|
// actually-bound HTTP server's port (resolved at runtime, not config-parse time, so
|
|
273
308
|
// `--port` and an unset server.port can never desync). `host_url` is an explicit full-URL
|
|
274
309
|
// override for cases where the VM can't reach the orchestrator via the host gateway.
|
|
@@ -277,109 +312,511 @@ export function buildServiceConfig(raw, workflowPath) {
|
|
|
277
312
|
const mcpEnabled = mcpEnabledRaw === undefined ? true : mcpEnabledRaw !== false;
|
|
278
313
|
const mcp = {
|
|
279
314
|
enabled: mcpEnabled,
|
|
280
|
-
// 127.0.0.1 works
|
|
281
|
-
//
|
|
315
|
+
// 127.0.0.1 works because Gondolin maps a synthetic guest host to the host's
|
|
316
|
+
// loopback (`tcp.hosts`). (Empirically verified;
|
|
282
317
|
// 10.0.2.2 — the QEMU slirp gateway — is NOT reachable here.) Other VMMs
|
|
283
318
|
// can override via the `host` field in the WORKFLOW.md mcp block.
|
|
284
319
|
host: asString(mcpRaw['host']) ?? '127.0.0.1',
|
|
285
320
|
explicit_host_url: asString(mcpRaw['host_url']),
|
|
286
321
|
};
|
|
322
|
+
// pr (issue 38, slimmed in issue 139). Optional block; default off. The slim
|
|
323
|
+
// host-global engine toggle: `pr: { enabled, poll_interval_ms }`. The
|
|
324
|
+
// merge/close/route targets and auto-merge strategy live ON the terminal
|
|
325
|
+
// states they describe (`states.<name>.pr`, parsed in parseStatesBlock) and
|
|
326
|
+
// are derived by scanning states (`derivePrRouting`), never named here.
|
|
327
|
+
const prRaw = getObject(raw, 'pr');
|
|
328
|
+
const pr = {
|
|
329
|
+
enabled: prRaw['enabled'] === true,
|
|
330
|
+
poll_interval_ms: asInt(prRaw['poll_interval_ms'], 30_000),
|
|
331
|
+
};
|
|
332
|
+
if (pr.poll_interval_ms < 0) {
|
|
333
|
+
throw new WorkflowError('workflow_parse_error', 'pr.poll_interval_ms must be non-negative');
|
|
334
|
+
}
|
|
335
|
+
// sleep_cycle (issue 125; retired in issue 140). The auto-arm trigger moved
|
|
336
|
+
// ONTO the active state it arms (`states.<name>.arm`, parsed in
|
|
337
|
+
// parseStatesBlock). A legacy top-level `sleep_cycle:` block is folded onto the
|
|
338
|
+
// reflect_state it named (foldLegacySleepCycle) for one release with a
|
|
339
|
+
// deprecation warning; there is no top-level sleep-cycle field on the resulting
|
|
340
|
+
// ServiceConfig anymore — the orchestrator derives the armed state by scanning
|
|
341
|
+
// states (`deriveArmRouting`).
|
|
342
|
+
foldLegacySleepCycle(states, getObject(raw, 'sleep_cycle'), Object.prototype.hasOwnProperty.call(raw, 'sleep_cycle'));
|
|
287
343
|
return {
|
|
288
344
|
workflow_path: workflowAbs,
|
|
289
345
|
workflow_dir: workflowDir,
|
|
290
346
|
tracker,
|
|
291
347
|
polling,
|
|
292
348
|
workspace,
|
|
293
|
-
|
|
349
|
+
logs,
|
|
294
350
|
agent,
|
|
295
351
|
acp,
|
|
296
|
-
|
|
352
|
+
gondolin,
|
|
353
|
+
egress,
|
|
297
354
|
server,
|
|
298
355
|
mcp,
|
|
356
|
+
pr,
|
|
357
|
+
credentials,
|
|
358
|
+
states,
|
|
299
359
|
};
|
|
300
360
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
361
|
+
/**
|
|
362
|
+
* Per-state `arm:` block (issue 140). Optional, valid on an active state.
|
|
363
|
+
* `issue` is the recurring issue armed into this state; `from` the holding state
|
|
364
|
+
* it rests in between runs; `on_idle` / `after_terminal` are the two triggers.
|
|
365
|
+
* The structural shape (types, `from` non-empty, `after_terminal` non-negative)
|
|
366
|
+
* is validated here; the cross-reference (arm only on active states, `from` is a
|
|
367
|
+
* declared holding state, `issue` required, at most one armed state) lives in
|
|
368
|
+
* `validateStates`. Returns `undefined` when the block is absent. `on_idle`
|
|
369
|
+
* defaults to false (opt-in, symmetric with `after_terminal: 0`).
|
|
370
|
+
*/
|
|
371
|
+
function parseStateArmBlock(stateName, raw) {
|
|
372
|
+
if (raw === undefined || raw === null)
|
|
373
|
+
return undefined;
|
|
374
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
375
|
+
throw new WorkflowError('workflow_parse_error', `state "${stateName}": arm must be a map (issue / from / on_idle / after_terminal)`);
|
|
376
|
+
}
|
|
377
|
+
const m = raw;
|
|
378
|
+
const issueRaw = asString(m['issue']);
|
|
379
|
+
const issue = issueRaw && issueRaw.trim().length > 0 ? issueRaw.trim() : null;
|
|
380
|
+
const fromRaw = asString(m['from']);
|
|
381
|
+
if (!fromRaw || fromRaw.trim().length === 0) {
|
|
382
|
+
throw new WorkflowError('workflow_parse_error', `state "${stateName}": arm.from must be a non-empty holding state name`);
|
|
383
|
+
}
|
|
384
|
+
const afterTerminal = asInt(m['after_terminal'], 0);
|
|
385
|
+
if (afterTerminal < 0) {
|
|
386
|
+
throw new WorkflowError('workflow_parse_error', `state "${stateName}": arm.after_terminal must be a non-negative integer (0 disables the terminal-count trigger)`);
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
issue,
|
|
390
|
+
from: fromRaw.trim(),
|
|
391
|
+
on_idle: m['on_idle'] === true,
|
|
392
|
+
after_terminal: afterTerminal,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
export function deriveArmRouting(states) {
|
|
396
|
+
for (const [name, sc] of Object.entries(states)) {
|
|
397
|
+
if (sc.role !== 'active' || !sc.arm)
|
|
398
|
+
continue;
|
|
399
|
+
return {
|
|
400
|
+
armState: name,
|
|
401
|
+
issue: sc.arm.issue,
|
|
402
|
+
from: sc.arm.from,
|
|
403
|
+
onIdle: sc.arm.on_idle,
|
|
404
|
+
afterTerminal: sc.arm.after_terminal,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
return { armState: null, issue: null, from: null, onIdle: false, afterTerminal: 0 };
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Fold a deprecated top-level `sleep_cycle:` block onto the active state it named
|
|
411
|
+
* (issue 140). Mirrors the now-removed legacy PR-autopilot fold: the trigger
|
|
412
|
+
* that used to live as named strings (`reflect_state` / `dormant_state` / `issue_id` /
|
|
413
|
+
* `arm_on_idle` / `arm_after_done`) is injected onto the reflect_state's `arm:`
|
|
414
|
+
* field so the state scan (`deriveArmRouting`) is the single runtime source of
|
|
415
|
+
* truth. A state that already declares `arm:` wins on conflict. Emits one
|
|
416
|
+
* deprecation warning whenever the block is present. Only folds when the legacy
|
|
417
|
+
* block is `enabled: true` — in the new model declaring `arm:` IS the enable, so
|
|
418
|
+
* a disabled legacy block produces no arm. The legacy `arm_on_idle` default of
|
|
419
|
+
* true is preserved (`!== false`). A `reflect_state` matching no declared state
|
|
420
|
+
* is a silent no-op (the warning already points at the new shape).
|
|
421
|
+
*/
|
|
422
|
+
function foldLegacySleepCycle(states, legacyRaw, present) {
|
|
423
|
+
if (!present)
|
|
424
|
+
return;
|
|
425
|
+
log.warn('sleep_cycle: is deprecated; declare the auto-arm trigger on the active state it arms as `states.<name>.arm` (issue / from / on_idle / after_terminal)', {});
|
|
426
|
+
// Preserve the original sleep-cycle parser's non-negative validation: a
|
|
427
|
+
// negative `arm_after_done` was a parse error, not a silent disable. Validated
|
|
428
|
+
// whenever the block is present (independent of `enabled`), matching the
|
|
429
|
+
// retired `validateSleepCycle` and the new `arm.after_terminal` parser.
|
|
430
|
+
const afterTerminal = asInt(legacyRaw['arm_after_done'], 0);
|
|
431
|
+
if (afterTerminal < 0) {
|
|
432
|
+
throw new WorkflowError('workflow_parse_error', 'sleep_cycle.arm_after_done must be a non-negative integer (0 disables the terminal-count trigger)');
|
|
433
|
+
}
|
|
434
|
+
if (legacyRaw['enabled'] !== true)
|
|
435
|
+
return;
|
|
436
|
+
const reflectRaw = asString(legacyRaw['reflect_state']);
|
|
437
|
+
const reflectState = reflectRaw && reflectRaw.trim().length > 0 ? reflectRaw.trim() : 'Reflect';
|
|
438
|
+
const dormantRaw = asString(legacyRaw['dormant_state']);
|
|
439
|
+
const dormantState = dormantRaw && dormantRaw.trim().length > 0 ? dormantRaw.trim() : 'Dormant';
|
|
440
|
+
const issueRaw = asString(legacyRaw['issue_id']);
|
|
441
|
+
const issue = issueRaw && issueRaw.trim().length > 0 ? issueRaw.trim() : null;
|
|
442
|
+
injectStateArm(states, reflectState, {
|
|
443
|
+
issue,
|
|
444
|
+
from: dormantState,
|
|
445
|
+
on_idle: legacyRaw['arm_on_idle'] !== false,
|
|
446
|
+
after_terminal: afterTerminal,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Inject a derived `arm:` block onto the (case-insensitively) named active state,
|
|
451
|
+
* but only when that state declares no `arm:` of its own — state-level config
|
|
452
|
+
* always wins over a folded legacy value. No-op when the name matches no state.
|
|
453
|
+
*/
|
|
454
|
+
function injectStateArm(states, name, arm) {
|
|
455
|
+
const lower = name.toLowerCase();
|
|
456
|
+
for (const [stateName, sc] of Object.entries(states)) {
|
|
457
|
+
if (stateName.toLowerCase() !== lower)
|
|
458
|
+
continue;
|
|
459
|
+
if (sc.arm)
|
|
460
|
+
return;
|
|
461
|
+
sc.arm = arm;
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// Parse the top-level `states:` block. The block is mandatory: every workflow
|
|
466
|
+
// must declare at least one `active`, one `terminal`, and one `holding` state
|
|
467
|
+
// (validation happens in `validateStates`). Insertion order matters —
|
|
468
|
+
// downstream consumers (dashboard, role-filtered active/terminal listings)
|
|
469
|
+
// follow declaration order — so we build a plain object incrementally rather
|
|
470
|
+
// than reconstructing via `Object.fromEntries`.
|
|
471
|
+
function parseStatesBlock(raw) {
|
|
472
|
+
if (raw === undefined || raw === null) {
|
|
473
|
+
throw new WorkflowError('workflow_parse_error', 'workflow YAML must declare a top-level `states:` block with at least one active, one terminal, and one holding state. See WORKFLOW.template.md for the schema.');
|
|
474
|
+
}
|
|
475
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
476
|
+
throw new WorkflowError('workflow_parse_error', 'states: must be a map of name → config');
|
|
477
|
+
}
|
|
478
|
+
const entries = Object.entries(raw);
|
|
479
|
+
if (entries.length === 0) {
|
|
480
|
+
throw new WorkflowError('workflow_parse_error', 'workflow YAML `states:` block is empty; declare at least one active, one terminal, and one holding state. See WORKFLOW.template.md for the schema.');
|
|
481
|
+
}
|
|
482
|
+
const out = {};
|
|
483
|
+
for (const [name, value] of entries) {
|
|
484
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
485
|
+
throw new WorkflowError('workflow_parse_error', `state "${name}": value must be a map`);
|
|
486
|
+
}
|
|
487
|
+
const m = value;
|
|
488
|
+
const roleRaw = asString(m['role']);
|
|
489
|
+
if (roleRaw !== 'active' && roleRaw !== 'terminal' && roleRaw !== 'holding') {
|
|
490
|
+
throw new WorkflowError('workflow_parse_error', `state "${name}": role must be one of active|terminal|holding (got: ${String(m['role'])})`);
|
|
491
|
+
}
|
|
492
|
+
const adapter = asString(m['adapter']);
|
|
493
|
+
const modelRaw = asString(m['model']);
|
|
494
|
+
const modelTrimmed = modelRaw === null ? undefined : modelRaw.trim();
|
|
495
|
+
const model = modelTrimmed === undefined ? undefined : modelTrimmed.length > 0 ? modelTrimmed : null;
|
|
496
|
+
// Same undefined-vs-null semantics as `model`: a missing key inherits the
|
|
497
|
+
// workflow-level `acp.effort`; a blank/whitespace string normalizes to null
|
|
498
|
+
// (an explicit "use the adapter default for this state" signal).
|
|
499
|
+
const effortRaw = asString(m['effort']);
|
|
500
|
+
const effortTrimmed = effortRaw === null ? undefined : effortRaw.trim();
|
|
501
|
+
const effort = effortTrimmed === undefined ? undefined : effortTrimmed.length > 0 ? effortTrimmed : null;
|
|
502
|
+
let maxTurns;
|
|
503
|
+
if (m['max_turns'] !== undefined) {
|
|
504
|
+
const n = asInt(m['max_turns'], -1);
|
|
505
|
+
if (n <= 0) {
|
|
506
|
+
throw new WorkflowError('workflow_parse_error', `state "${name}": max_turns must be a positive integer`);
|
|
507
|
+
}
|
|
508
|
+
maxTurns = n;
|
|
509
|
+
}
|
|
510
|
+
// Per-state concurrency cap (issue 137) — same positive-integer validation
|
|
511
|
+
// as max_turns. Undefined when omitted (no per-state cap; only the global
|
|
512
|
+
// agent.max_concurrent_agents ceiling applies).
|
|
513
|
+
let maxConcurrent;
|
|
514
|
+
if (m['max_concurrent'] !== undefined) {
|
|
515
|
+
const n = asInt(m['max_concurrent'], -1);
|
|
516
|
+
if (n <= 0) {
|
|
517
|
+
throw new WorkflowError('workflow_parse_error', `state "${name}": max_concurrent must be a positive integer`);
|
|
518
|
+
}
|
|
519
|
+
maxConcurrent = n;
|
|
520
|
+
}
|
|
521
|
+
let allowed;
|
|
522
|
+
if (m['allowed_transitions'] === undefined) {
|
|
523
|
+
allowed = undefined;
|
|
524
|
+
}
|
|
525
|
+
else if (m['allowed_transitions'] === null) {
|
|
526
|
+
allowed = null;
|
|
527
|
+
}
|
|
528
|
+
else if (Array.isArray(m['allowed_transitions'])) {
|
|
529
|
+
allowed = m['allowed_transitions'].filter((x) => typeof x === 'string');
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
throw new WorkflowError('workflow_parse_error', `state "${name}": allowed_transitions must be a list of state names (or null/omitted)`);
|
|
533
|
+
}
|
|
534
|
+
const stateActions = parseActionsBlock(name, m['actions']);
|
|
535
|
+
// eval_mode is a strict boolean opt-in: only true enables it, any other
|
|
536
|
+
// value (including undefined, null, "true" string) leaves it off. Strict
|
|
537
|
+
// typing here matches the rest of the YAML-flag plumbing in the parser
|
|
538
|
+
// and stops a YAML-quoting accident ("true") from silently enabling the
|
|
539
|
+
// mounts.
|
|
540
|
+
const evalModeRaw = m['eval_mode'];
|
|
541
|
+
if (evalModeRaw !== undefined && typeof evalModeRaw !== 'boolean') {
|
|
542
|
+
throw new WorkflowError('workflow_parse_error', `state "${name}": eval_mode must be a boolean (true/false)`);
|
|
543
|
+
}
|
|
544
|
+
const statePr = parseStatePrBlock(name, m['pr']);
|
|
545
|
+
const stateArm = parseStateArmBlock(name, m['arm']);
|
|
546
|
+
const sc = { role: roleRaw };
|
|
547
|
+
if (adapter !== null)
|
|
548
|
+
sc.adapter = adapter;
|
|
549
|
+
if (model !== undefined)
|
|
550
|
+
sc.model = model;
|
|
551
|
+
if (effort !== undefined)
|
|
552
|
+
sc.effort = effort;
|
|
553
|
+
if (maxTurns !== undefined)
|
|
554
|
+
sc.max_turns = maxTurns;
|
|
555
|
+
if (maxConcurrent !== undefined)
|
|
556
|
+
sc.max_concurrent = maxConcurrent;
|
|
557
|
+
if (allowed !== undefined)
|
|
558
|
+
sc.allowed_transitions = allowed;
|
|
559
|
+
if (stateActions !== undefined)
|
|
560
|
+
sc.actions = stateActions;
|
|
561
|
+
if (evalModeRaw === true)
|
|
562
|
+
sc.eval_mode = true;
|
|
563
|
+
if (statePr !== undefined)
|
|
564
|
+
sc.pr = statePr;
|
|
565
|
+
if (stateArm !== undefined)
|
|
566
|
+
sc.arm = stateArm;
|
|
567
|
+
out[name] = sc;
|
|
568
|
+
}
|
|
569
|
+
return out;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Per-state `pr:` block (issue 139). Optional, valid on a terminal state.
|
|
573
|
+
* `auto_merge` (squash|merge|rebase) marks the merge state and picks the
|
|
574
|
+
* `gh pr merge --auto` strategy; `on_conflict.route_to` names the active state
|
|
575
|
+
* a non-mergeable PR is routed back into; `close: true` marks the close state.
|
|
576
|
+
* Returns `undefined` when the block is absent or declares nothing meaningful
|
|
577
|
+
* (so an empty `pr: {}` doesn't shadow a legacy fold). The structural shape is
|
|
578
|
+
* validated here; the cross-reference (route_to is a declared state, pr only on
|
|
579
|
+
* terminal states, merge/close uniqueness) is in `validateStates`.
|
|
580
|
+
*/
|
|
581
|
+
function parseStatePrBlock(stateName, raw) {
|
|
582
|
+
if (raw === undefined || raw === null)
|
|
583
|
+
return undefined;
|
|
584
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
585
|
+
throw new WorkflowError('workflow_parse_error', `state "${stateName}": pr must be a map (auto_merge / on_conflict / close)`);
|
|
586
|
+
}
|
|
587
|
+
const m = raw;
|
|
588
|
+
const out = {};
|
|
589
|
+
if (m['auto_merge'] !== undefined && m['auto_merge'] !== null) {
|
|
590
|
+
const s = asString(m['auto_merge']);
|
|
591
|
+
if (s !== 'squash' && s !== 'merge' && s !== 'rebase') {
|
|
592
|
+
throw new WorkflowError('workflow_parse_error', `state "${stateName}": pr.auto_merge must be one of squash|merge|rebase`);
|
|
593
|
+
}
|
|
594
|
+
out.auto_merge = s;
|
|
595
|
+
}
|
|
596
|
+
if (m['on_conflict'] !== undefined && m['on_conflict'] !== null) {
|
|
597
|
+
const oc = m['on_conflict'];
|
|
598
|
+
if (typeof oc !== 'object' || Array.isArray(oc)) {
|
|
599
|
+
throw new WorkflowError('workflow_parse_error', `state "${stateName}": pr.on_conflict must be a map with a route_to field`);
|
|
600
|
+
}
|
|
601
|
+
const routeTo = asString(oc['route_to']);
|
|
602
|
+
if (!routeTo || routeTo.trim().length === 0) {
|
|
603
|
+
throw new WorkflowError('workflow_parse_error', `state "${stateName}": pr.on_conflict.route_to must be a non-empty state name`);
|
|
604
|
+
}
|
|
605
|
+
out.on_conflict = { route_to: routeTo.trim() };
|
|
606
|
+
}
|
|
607
|
+
if (m['close'] !== undefined && m['close'] !== null) {
|
|
608
|
+
if (typeof m['close'] !== 'boolean') {
|
|
609
|
+
throw new WorkflowError('workflow_parse_error', `state "${stateName}": pr.close must be a boolean`);
|
|
610
|
+
}
|
|
611
|
+
if (m['close'] === true)
|
|
612
|
+
out.close = true;
|
|
613
|
+
}
|
|
614
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
615
|
+
}
|
|
616
|
+
export function derivePrRouting(states) {
|
|
617
|
+
let mergeState = null;
|
|
618
|
+
let closeState = null;
|
|
619
|
+
let conflictRouteTo = null;
|
|
620
|
+
let strategy = 'squash';
|
|
621
|
+
for (const [name, sc] of Object.entries(states)) {
|
|
622
|
+
if (sc.role !== 'terminal' || !sc.pr)
|
|
623
|
+
continue;
|
|
624
|
+
if (sc.pr.auto_merge && mergeState === null) {
|
|
625
|
+
mergeState = name;
|
|
626
|
+
strategy = sc.pr.auto_merge;
|
|
627
|
+
conflictRouteTo = sc.pr.on_conflict?.route_to ?? null;
|
|
628
|
+
}
|
|
629
|
+
if (sc.pr.close && closeState === null) {
|
|
630
|
+
closeState = name;
|
|
319
631
|
}
|
|
320
632
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
633
|
+
return { mergeState, closeState, conflictRouteTo, strategy };
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Resolve the typed action list a given state should run on transition-in.
|
|
637
|
+
* Case-insensitive state lookup; returns the parsed `WorkflowAction[]` (or
|
|
638
|
+
* undefined when the state has no actions block). The runner consults this on
|
|
639
|
+
* transition into a terminal state to drive the push/PR handoff.
|
|
640
|
+
*/
|
|
641
|
+
export function resolveActionsForState(cfg, stateName) {
|
|
642
|
+
const states = cfg.states;
|
|
643
|
+
let key = null;
|
|
644
|
+
if (Object.prototype.hasOwnProperty.call(states, stateName)) {
|
|
645
|
+
key = stateName;
|
|
327
646
|
}
|
|
328
|
-
else
|
|
329
|
-
|
|
647
|
+
else {
|
|
648
|
+
const lower = stateName.toLowerCase();
|
|
649
|
+
for (const name of Object.keys(states)) {
|
|
650
|
+
if (name.toLowerCase() === lower) {
|
|
651
|
+
key = name;
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (key === null)
|
|
657
|
+
return undefined;
|
|
658
|
+
return states[key].actions;
|
|
659
|
+
}
|
|
660
|
+
// Dispatch preflight validation (structural, pure). The fs-touching probes —
|
|
661
|
+
// `tracker.root` existence and the adapter credential files — live in the shell
|
|
662
|
+
// loader's `validateDispatchIo`, which the orchestrator calls alongside this
|
|
663
|
+
// function. Both adapters are startup-probed: claude requires a single readable
|
|
664
|
+
// host file (`~/.claude/.credentials.json`); codex passes when either
|
|
665
|
+
// `~/.codex/auth.json` holds a token (ChatGPT-OAuth `tokens.access_token` or a
|
|
666
|
+
// top-level `OPENAI_API_KEY`) or the host `OPENAI_API_KEY` env var is set. Keeping this
|
|
667
|
+
// structural half pure means tests and the reload tick can re-run it cheaply on
|
|
668
|
+
// every reconcile without re-hitting the disk.
|
|
669
|
+
export function validateDispatch(cfg) {
|
|
670
|
+
if (cfg.tracker.kind !== 'local') {
|
|
671
|
+
return `unsupported_tracker_kind: ${cfg.tracker.kind || '<missing>'}`;
|
|
330
672
|
}
|
|
673
|
+
if (!cfg.tracker.root)
|
|
674
|
+
return 'tracker.root must be set for local tracker';
|
|
675
|
+
// `cfg.states` is always populated by buildServiceConfig — the parser refuses
|
|
676
|
+
// workflows without a `states:` block — so callers never need a fallback here.
|
|
677
|
+
const statesError = validateStates(cfg.states);
|
|
678
|
+
if (statesError)
|
|
679
|
+
return statesError;
|
|
680
|
+
// cfg.agent is always populated by buildServiceConfig; guard for legacy
|
|
681
|
+
// hand-built ServiceConfigs (older test fixtures) that omit the block.
|
|
682
|
+
const concurrencyError = validateConcurrencyCaps(cfg.states, cfg.agent?.max_concurrent_agents);
|
|
683
|
+
if (concurrencyError)
|
|
684
|
+
return concurrencyError;
|
|
685
|
+
if (!isKnownAdapter(cfg.acp.adapter)) {
|
|
686
|
+
return `acp.adapter "${cfg.acp.adapter}" is not a known profile; use one of: claude, codex, opencode`;
|
|
687
|
+
}
|
|
688
|
+
// PR autopilot routing (issue 139) is validated structurally inside
|
|
689
|
+
// `validateStates` (pr: only on terminal states; on_conflict.route_to must be
|
|
690
|
+
// a declared state; at most one merge/close state). The state's own `role` is
|
|
691
|
+
// authoritative, so the old `validatePrAutopilot` role re-validator is gone.
|
|
692
|
+
// Auto-arm trigger (issue 140) is validated structurally inside
|
|
693
|
+
// `validateStates` (arm: only on active states; arm.from must be a declared
|
|
694
|
+
// holding state; arm.issue required; at most one armed state). The state's own
|
|
695
|
+
// `role` is authoritative, so the old `validateSleepCycle` role re-validator is
|
|
696
|
+
// gone.
|
|
331
697
|
return null;
|
|
332
698
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
699
|
+
/**
|
|
700
|
+
* Validate that the sum of per-state `max_concurrent` caps does not exceed the
|
|
701
|
+
* global `agent.max_concurrent_agents` host ceiling (issue 137). A sum greater
|
|
702
|
+
* than the ceiling can never be satisfied — the global clamp binds first — so it
|
|
703
|
+
* is almost always a misconfiguration worth surfacing at startup. Returns null
|
|
704
|
+
* when in budget or when the ceiling is unknown (legacy hand-built configs that
|
|
705
|
+
* omit `agent`). The legacy by-name map is already folded into the per-state
|
|
706
|
+
* caps by the time this runs, so its entries count toward the sum too.
|
|
707
|
+
*/
|
|
708
|
+
function validateConcurrencyCaps(states, ceiling) {
|
|
709
|
+
if (typeof ceiling !== 'number')
|
|
710
|
+
return null;
|
|
711
|
+
let sum = 0;
|
|
712
|
+
for (const sc of Object.values(states)) {
|
|
713
|
+
if (typeof sc.max_concurrent === 'number')
|
|
714
|
+
sum += sc.max_concurrent;
|
|
715
|
+
}
|
|
716
|
+
if (sum > ceiling) {
|
|
717
|
+
return `sum of per-state max_concurrent caps (${sum}) exceeds agent.max_concurrent_agents (${ceiling})`;
|
|
718
|
+
}
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
// State-map validation, exposed as a string|null so it composes with the rest of
|
|
722
|
+
// `validateDispatch`. Checks declared in the same order the operator would hit
|
|
723
|
+
// them while iterating on a malformed workflow: structural (roles, uniqueness),
|
|
724
|
+
// then cross-references (allowed_transitions targets), then host-resource
|
|
725
|
+
// dependencies (adapter known + credential readable).
|
|
726
|
+
function validateStates(states) {
|
|
727
|
+
const names = Object.keys(states);
|
|
728
|
+
if (names.length === 0)
|
|
729
|
+
return 'states: at least one state must be declared';
|
|
730
|
+
let hasActive = false;
|
|
731
|
+
let hasTerminal = false;
|
|
732
|
+
let hasHolding = false;
|
|
733
|
+
for (const cfg of Object.values(states)) {
|
|
734
|
+
if (cfg.role === 'active')
|
|
735
|
+
hasActive = true;
|
|
736
|
+
else if (cfg.role === 'terminal')
|
|
737
|
+
hasTerminal = true;
|
|
738
|
+
else if (cfg.role === 'holding')
|
|
739
|
+
hasHolding = true;
|
|
740
|
+
}
|
|
741
|
+
if (!hasActive)
|
|
742
|
+
return 'states: at least one state must have role: active';
|
|
743
|
+
if (!hasTerminal)
|
|
744
|
+
return 'states: at least one state must have role: terminal';
|
|
745
|
+
// `holding` is required so `propose_issue` always has a declared landing
|
|
746
|
+
// directory; the dashboard's triage approve/discard surface also needs it.
|
|
747
|
+
if (!hasHolding)
|
|
748
|
+
return 'states: at least one state must have role: holding';
|
|
749
|
+
const seen = new Map();
|
|
750
|
+
for (const name of names) {
|
|
751
|
+
const key = name.toLowerCase();
|
|
752
|
+
const prior = seen.get(key);
|
|
753
|
+
if (prior !== undefined) {
|
|
754
|
+
return `states: duplicate state name (case-insensitive): "${prior}" and "${name}"`;
|
|
755
|
+
}
|
|
756
|
+
seen.set(key, name);
|
|
757
|
+
}
|
|
758
|
+
// PR autopilot routing (issue 139): `pr:` is only meaningful on a terminal
|
|
759
|
+
// state, `on_conflict.route_to` must name a declared state, and at most one
|
|
760
|
+
// terminal state may declare the merge (`auto_merge`) or close (`close`)
|
|
761
|
+
// behavior so `derivePrRouting`'s first-match is unambiguous.
|
|
762
|
+
let mergeStateCount = 0;
|
|
763
|
+
let closeStateCount = 0;
|
|
764
|
+
let armStateCount = 0;
|
|
765
|
+
for (const [name, cfg] of Object.entries(states)) {
|
|
766
|
+
if (cfg.allowed_transitions) {
|
|
767
|
+
for (const target of cfg.allowed_transitions) {
|
|
768
|
+
if (!seen.has(target.toLowerCase())) {
|
|
769
|
+
return `state "${name}": allowed_transitions references undeclared state "${target}"`;
|
|
770
|
+
}
|
|
356
771
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
772
|
+
}
|
|
773
|
+
if (cfg.adapter !== undefined && !isKnownAdapter(cfg.adapter)) {
|
|
774
|
+
return `state "${name}": adapter "${cfg.adapter}" is not a known profile; use one of: claude, codex, opencode`;
|
|
775
|
+
}
|
|
776
|
+
if (cfg.pr) {
|
|
777
|
+
if (cfg.role !== 'terminal') {
|
|
778
|
+
return `state "${name}": pr: is only valid on a terminal state (got role: ${cfg.role})`;
|
|
364
779
|
}
|
|
365
|
-
|
|
366
|
-
|
|
780
|
+
if (cfg.pr.auto_merge)
|
|
781
|
+
mergeStateCount += 1;
|
|
782
|
+
if (cfg.pr.close)
|
|
783
|
+
closeStateCount += 1;
|
|
784
|
+
const routeTo = cfg.pr.on_conflict?.route_to;
|
|
785
|
+
if (routeTo && !seen.has(routeTo.toLowerCase())) {
|
|
786
|
+
return `state "${name}": pr.on_conflict.route_to references undeclared state "${routeTo}"`;
|
|
367
787
|
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
788
|
+
}
|
|
789
|
+
// Auto-arm trigger (issue 140): `arm:` is only meaningful on an active state,
|
|
790
|
+
// `arm.issue` is required, `arm.from` must name a declared holding state, and
|
|
791
|
+
// at most one active state may declare arm so `deriveArmRouting`'s first-match
|
|
792
|
+
// is unambiguous. The state's own `role` is authoritative — no separate
|
|
793
|
+
// sleep-cycle role re-validator.
|
|
794
|
+
if (cfg.arm) {
|
|
795
|
+
armStateCount += 1;
|
|
796
|
+
if (cfg.role !== 'active') {
|
|
797
|
+
return `state "${name}": arm: is only valid on an active state (got role: ${cfg.role})`;
|
|
798
|
+
}
|
|
799
|
+
if (!cfg.arm.issue || cfg.arm.issue.trim().length === 0) {
|
|
800
|
+
return `state "${name}": arm.issue is required (the recurring issue to arm into this state)`;
|
|
801
|
+
}
|
|
802
|
+
const fromCanonical = seen.get(cfg.arm.from.toLowerCase());
|
|
803
|
+
if (!fromCanonical) {
|
|
804
|
+
return `state "${name}": arm.from references undeclared state "${cfg.arm.from}"`;
|
|
805
|
+
}
|
|
806
|
+
if (states[fromCanonical].role !== 'holding') {
|
|
807
|
+
return `state "${name}": arm.from "${cfg.arm.from}" must be a holding state (got role: ${states[fromCanonical].role})`;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
if (armStateCount > 1) {
|
|
812
|
+
return `states: at most one active state may declare arm (found ${armStateCount})`;
|
|
813
|
+
}
|
|
814
|
+
if (mergeStateCount > 1) {
|
|
815
|
+
return `states: at most one terminal state may declare pr.auto_merge (found ${mergeStateCount})`;
|
|
816
|
+
}
|
|
817
|
+
if (closeStateCount > 1) {
|
|
818
|
+
return `states: at most one terminal state may declare pr.close (found ${closeStateCount})`;
|
|
819
|
+
}
|
|
820
|
+
return null;
|
|
384
821
|
}
|
|
385
822
|
//# sourceMappingURL=workflow.js.map
|