@yolo-labs/yolo-cli 0.23.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 +57 -0
- package/dist/artifact-get.js +131 -0
- package/dist/artifact-list.js +133 -0
- package/dist/auth-context.js +60 -0
- package/dist/canonicalizer.js +208 -0
- package/dist/cli.js +1403 -0
- package/dist/context.js +58 -0
- package/dist/deploy-bundle.js +394 -0
- package/dist/deploy-cli.js +1341 -0
- package/dist/deploy-client.js +460 -0
- package/dist/deploy-config.js +301 -0
- package/dist/deploy-detect.js +498 -0
- package/dist/deploy-ship.js +389 -0
- package/dist/lockfile.js +203 -0
- package/dist/plan-diff.js +162 -0
- package/dist/plan-export.js +261 -0
- package/dist/plan-frontmatter.schema.json +184 -0
- package/dist/plan-get.js +244 -0
- package/dist/plan-import.js +420 -0
- package/dist/plan-list.js +153 -0
- package/dist/plan-open.js +100 -0
- package/dist/plan-state.js +228 -0
- package/dist/plan-validate.js +270 -0
- package/dist/revision.js +43 -0
- package/dist/run-get.js +130 -0
- package/dist/run-lifecycle.js +133 -0
- package/dist/run-list.js +148 -0
- package/dist/run-start.js +110 -0
- package/dist/run-transfer.js +128 -0
- package/dist/serve.js +227 -0
- package/dist/tileapp-developer.js +222 -0
- package/dist/tileapp-oci-assembler.js +591 -0
- package/dist/tileapp-personal.js +461 -0
- package/dist/tileapp-publisher.js +134 -0
- package/dist/tileapp-validator.js +239 -0
- package/dist/vendored/flexdb.mjs +1087 -0
- package/dist/webapp-url.js +61 -0
- package/dist/work-client.js +195 -0
- package/package.json +39 -0
package/dist/plan-get.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yolo plan get <planId>` — read a Plan from the substrate.
|
|
3
|
+
*
|
|
4
|
+
* The thinnest of the three workflow commands. No file I/O, no
|
|
5
|
+
* lockfile mutation, no canonicalization. Mints a session-bound
|
|
6
|
+
* delegated token (work.get_plan scope) and prints the plan.
|
|
7
|
+
*
|
|
8
|
+
* Three output modes:
|
|
9
|
+
* - default (`--summary`): one-line plan header + per-step rows
|
|
10
|
+
* in declared order, with stepId, mode, and dependency
|
|
11
|
+
* stepIds. Good for an operator's "is the import what I
|
|
12
|
+
* expected" sanity check.
|
|
13
|
+
* - `--waves`: same header, but steps grouped by topological
|
|
14
|
+
* wave (computed from dependency gates). A wave is the set of
|
|
15
|
+
* steps whose dependencies are all satisfied by earlier waves;
|
|
16
|
+
* a step's wave is `1 + max(wave of any dependency)`. Good for
|
|
17
|
+
* "what runs in parallel at each stage" reading.
|
|
18
|
+
* - `--json`: pretty-printed `work.get_plan` response. Useful in
|
|
19
|
+
* pipelines / for `jq` consumers.
|
|
20
|
+
*
|
|
21
|
+
* Errors map to exit codes via the CLI wrapper:
|
|
22
|
+
* - 0 = success
|
|
23
|
+
* - 1 = http (plan not found, etc.)
|
|
24
|
+
* - 64 = usage error (bad planId, missing env trio,
|
|
25
|
+
* --workspace mismatch)
|
|
26
|
+
*/
|
|
27
|
+
import { SUBSTRATE_CLI_PLAN_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
|
|
28
|
+
import { resolveSubstrateContext } from './auth-context.js';
|
|
29
|
+
import { planDagUrl } from './webapp-url.js';
|
|
30
|
+
// ─── Public entry ─────────────────────────────────────────────────────────
|
|
31
|
+
const PLAN_ID_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
32
|
+
export async function runPlanGet(options) {
|
|
33
|
+
const env = options.env ?? process.env;
|
|
34
|
+
const format = options.outputFormat ?? 'summary';
|
|
35
|
+
// 1) planId shape (matches substrate's PLAN_ID_REGEX)
|
|
36
|
+
if (!PLAN_ID_REGEX.test(options.planId)) {
|
|
37
|
+
return fail('usage', `invalid planId '${options.planId}': must match /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/`);
|
|
38
|
+
}
|
|
39
|
+
// 2) Substrate context
|
|
40
|
+
const auth = resolveSubstrateContext(env);
|
|
41
|
+
if (!auth.ok)
|
|
42
|
+
return fail('auth', auth.message);
|
|
43
|
+
const { sessionId, commonApiUrl, userToken } = auth.context;
|
|
44
|
+
// 3) Mint token
|
|
45
|
+
let mint;
|
|
46
|
+
try {
|
|
47
|
+
mint = await mintSubstrateToken({
|
|
48
|
+
commonApiUrl,
|
|
49
|
+
userToken,
|
|
50
|
+
sessionId,
|
|
51
|
+
scopes: SUBSTRATE_CLI_PLAN_SCOPES,
|
|
52
|
+
fetchImpl: options.fetchImpl,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (err instanceof WorkClientError) {
|
|
57
|
+
return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
|
|
58
|
+
}
|
|
59
|
+
return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
|
|
60
|
+
}
|
|
61
|
+
// 4) --workspace assertion
|
|
62
|
+
if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
|
|
63
|
+
return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
|
|
64
|
+
}
|
|
65
|
+
// 5) work.get_plan
|
|
66
|
+
const ctx = {
|
|
67
|
+
commonApiUrl,
|
|
68
|
+
delegatedToken: mint.token,
|
|
69
|
+
fetchImpl: options.fetchImpl,
|
|
70
|
+
};
|
|
71
|
+
const response = await authenticatedRequest(ctx, `/workspaces/${mint.workspaceId}/plans/${options.planId}`, { method: 'GET' });
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
const text = await safeReadText(response);
|
|
74
|
+
return fail('http', `work.get_plan failed: HTTP ${response.status} — ${text}`, { status: response.status });
|
|
75
|
+
}
|
|
76
|
+
const json = (await response.json());
|
|
77
|
+
if (!json.plan) {
|
|
78
|
+
return fail('http', 'work.get_plan response missing `plan` field');
|
|
79
|
+
}
|
|
80
|
+
let output;
|
|
81
|
+
if (format === 'json')
|
|
82
|
+
output = formatJson(json.plan);
|
|
83
|
+
else if (format === 'waves')
|
|
84
|
+
output = formatWaves(json.plan, mint.workspaceId, env);
|
|
85
|
+
else
|
|
86
|
+
output = formatSummary(json.plan, mint.workspaceId, env);
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
output,
|
|
90
|
+
plan: json.plan,
|
|
91
|
+
workspaceId: mint.workspaceId,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// ─── Output formatting ────────────────────────────────────────────────────
|
|
95
|
+
/**
|
|
96
|
+
* One-line header + per-step rows. Each row: `<stepId> (<mode>)`
|
|
97
|
+
* with `← deps: <predId1>, <predId2>` if the step has dependency
|
|
98
|
+
* gates. Matches the substrate's actual gate shape (single
|
|
99
|
+
* `config.stepId` per dependency gate, per gate-readiness.ts:180);
|
|
100
|
+
* one row per step lists ALL its dependency-gate stepIds.
|
|
101
|
+
*/
|
|
102
|
+
export function formatSummary(plan, workspaceId, env = process.env) {
|
|
103
|
+
const lines = [];
|
|
104
|
+
lines.push(`Plan '${plan.planId}' (workspace ${workspaceId}, version ${plan.version}, ${plan.state})`);
|
|
105
|
+
const dagUrl = planDagUrl(env, workspaceId, plan.planId);
|
|
106
|
+
if (dagUrl)
|
|
107
|
+
lines.push(` view: ${dagUrl}`);
|
|
108
|
+
if (plan.failurePolicy)
|
|
109
|
+
lines.push(` failurePolicy: ${plan.failurePolicy}`);
|
|
110
|
+
if (typeof plan.autoRetryCap === 'number')
|
|
111
|
+
lines.push(` autoRetryCap: ${plan.autoRetryCap}`);
|
|
112
|
+
const inputCount = Array.isArray(plan.inputs) ? plan.inputs.length : 0;
|
|
113
|
+
lines.push(` inputs: ${inputCount}`);
|
|
114
|
+
lines.push(` steps: ${plan.steps.length}`);
|
|
115
|
+
for (const step of plan.steps) {
|
|
116
|
+
const deps = depStepIds(step);
|
|
117
|
+
const depTag = deps.length > 0 ? ` ← deps: ${deps.join(', ')}` : '';
|
|
118
|
+
lines.push(` - ${step.stepId} (${step.mode ?? '?'})${depTag}`);
|
|
119
|
+
}
|
|
120
|
+
return lines.join('\n');
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Wave-grouped renderer. Same header as formatSummary, then steps
|
|
124
|
+
* grouped by their topological wave. Within a wave, step order
|
|
125
|
+
* matches the declared order in `plan.steps` so output is stable
|
|
126
|
+
* across runs.
|
|
127
|
+
*
|
|
128
|
+
* Defensive on bad shapes the substrate would normally reject:
|
|
129
|
+
* unknown predecessor stepIds collapse to wave 1, cycles short-circuit
|
|
130
|
+
* to wave 1 for the visiting node (so we never recurse forever).
|
|
131
|
+
*/
|
|
132
|
+
export function formatWaves(plan, workspaceId, env = process.env) {
|
|
133
|
+
const lines = [];
|
|
134
|
+
lines.push(`Plan '${plan.planId}' (workspace ${workspaceId}, version ${plan.version}, ${plan.state})`);
|
|
135
|
+
const dagUrl = planDagUrl(env, workspaceId, plan.planId);
|
|
136
|
+
if (dagUrl)
|
|
137
|
+
lines.push(` view: ${dagUrl}`);
|
|
138
|
+
if (plan.failurePolicy)
|
|
139
|
+
lines.push(` failurePolicy: ${plan.failurePolicy}`);
|
|
140
|
+
if (typeof plan.autoRetryCap === 'number')
|
|
141
|
+
lines.push(` autoRetryCap: ${plan.autoRetryCap}`);
|
|
142
|
+
const inputCount = Array.isArray(plan.inputs) ? plan.inputs.length : 0;
|
|
143
|
+
lines.push(` inputs: ${inputCount}`);
|
|
144
|
+
const waves = computeWaves(plan.steps);
|
|
145
|
+
const waveCount = waves.length;
|
|
146
|
+
const stepWord = plan.steps.length === 1 ? 'step' : 'steps';
|
|
147
|
+
lines.push(` steps: ${plan.steps.length} ${stepWord} in ${waveCount} ${waveCount === 1 ? 'wave' : 'waves'}`);
|
|
148
|
+
lines.push('');
|
|
149
|
+
for (let i = 0; i < waves.length; i++) {
|
|
150
|
+
const stepsInWave = waves[i];
|
|
151
|
+
const count = stepsInWave.length;
|
|
152
|
+
const parallelTag = count > 1 ? ', parallel' : '';
|
|
153
|
+
lines.push(` Wave ${i + 1} (${count} ${count === 1 ? 'step' : 'steps'}${parallelTag}):`);
|
|
154
|
+
for (const step of stepsInWave) {
|
|
155
|
+
const deps = depStepIds(step);
|
|
156
|
+
const depTag = deps.length > 0 ? ` ← deps: ${deps.join(', ')}` : '';
|
|
157
|
+
lines.push(` - ${step.stepId} (${step.mode ?? '?'})${depTag}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return lines.join('\n');
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Topological wave grouping. Returns an array of arrays — index 0 is
|
|
164
|
+
* wave 1, index 1 is wave 2, etc. Within each wave, steps appear in
|
|
165
|
+
* the order they were declared in `plan.steps`.
|
|
166
|
+
*/
|
|
167
|
+
function computeWaves(steps) {
|
|
168
|
+
const stepById = new Map();
|
|
169
|
+
for (const s of steps)
|
|
170
|
+
stepById.set(s.stepId, s);
|
|
171
|
+
const waveOfStep = new Map();
|
|
172
|
+
const waveOf = (stepId, visiting) => {
|
|
173
|
+
const cached = waveOfStep.get(stepId);
|
|
174
|
+
if (cached !== undefined)
|
|
175
|
+
return cached;
|
|
176
|
+
const step = stepById.get(stepId);
|
|
177
|
+
if (!step)
|
|
178
|
+
return 1; // unknown predecessor — substrate rejects on import
|
|
179
|
+
visiting.add(stepId);
|
|
180
|
+
let max = 0;
|
|
181
|
+
for (const dep of depStepIds(step)) {
|
|
182
|
+
if (!stepById.has(dep))
|
|
183
|
+
continue; // unknown predecessor → ignore
|
|
184
|
+
if (visiting.has(dep))
|
|
185
|
+
continue; // cycle: ignore the back-edge
|
|
186
|
+
const w = waveOf(dep, visiting);
|
|
187
|
+
if (w > max)
|
|
188
|
+
max = w;
|
|
189
|
+
}
|
|
190
|
+
visiting.delete(stepId);
|
|
191
|
+
const wave = max + 1;
|
|
192
|
+
waveOfStep.set(stepId, wave);
|
|
193
|
+
return wave;
|
|
194
|
+
};
|
|
195
|
+
for (const s of steps)
|
|
196
|
+
waveOf(s.stepId, new Set());
|
|
197
|
+
const grouped = [];
|
|
198
|
+
for (const s of steps) {
|
|
199
|
+
const w = waveOfStep.get(s.stepId) ?? 1;
|
|
200
|
+
while (grouped.length < w)
|
|
201
|
+
grouped.push([]);
|
|
202
|
+
grouped[w - 1].push(s);
|
|
203
|
+
}
|
|
204
|
+
return grouped;
|
|
205
|
+
}
|
|
206
|
+
function depStepIds(step) {
|
|
207
|
+
const out = [];
|
|
208
|
+
for (const gate of step.gates ?? []) {
|
|
209
|
+
if (gate.type === 'dependency') {
|
|
210
|
+
const predId = gate.config?.stepId;
|
|
211
|
+
if (typeof predId === 'string')
|
|
212
|
+
out.push(predId);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
function formatJson(plan) {
|
|
218
|
+
return JSON.stringify(plan, null, 2);
|
|
219
|
+
}
|
|
220
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
221
|
+
function fail(kind, message, detail) {
|
|
222
|
+
return { ok: false, kind, message, detail };
|
|
223
|
+
}
|
|
224
|
+
function describeError(err) {
|
|
225
|
+
return err instanceof Error ? err.message : String(err);
|
|
226
|
+
}
|
|
227
|
+
async function safeReadText(response) {
|
|
228
|
+
try {
|
|
229
|
+
return await response.text();
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return '<no body>';
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export function exitCodeForFailure(kind) {
|
|
236
|
+
switch (kind) {
|
|
237
|
+
case 'usage':
|
|
238
|
+
case 'auth':
|
|
239
|
+
case 'workspace_mismatch':
|
|
240
|
+
return 64;
|
|
241
|
+
case 'http':
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yolo plan import <file>` orchestration (Phase 8c.3).
|
|
3
|
+
*
|
|
4
|
+
* Composes everything from earlier slices:
|
|
5
|
+
* - `plan-validate` (8c.1) — parse + schema + canonical + no-coercion
|
|
6
|
+
* - `revision` (8c.2) — sha256 over canonical bytes
|
|
7
|
+
* - `lockfile` (8c.2) — `(workspaceId, planId) → entry` storage
|
|
8
|
+
* - `work-client` (8c.2) — `mintSubstrateToken` + `authenticatedRequest`
|
|
9
|
+
* - `plan-diff` (8c.3) — DB plan vs file plan → mutation list
|
|
10
|
+
*
|
|
11
|
+
* High-level flow:
|
|
12
|
+
*
|
|
13
|
+
* 1. Validate file (offline: parse + schema + canonical + no-coercion).
|
|
14
|
+
* 2. Enforce planId equals the file stem (semantic check, design F8.1).
|
|
15
|
+
* 3. Resolve substrate context (env trio); error otherwise.
|
|
16
|
+
* 4. Mint a session-bound delegated token; the response carries the
|
|
17
|
+
* authoritative `workspaceId` (Phase 8 design: workspace binding
|
|
18
|
+
* is derived from session, not env/args).
|
|
19
|
+
* 5. If `--workspace` was passed, assert it matches the minted
|
|
20
|
+
* workspaceId — otherwise the user's mental model and the
|
|
21
|
+
* session's binding diverge silently.
|
|
22
|
+
* 6. Read the lockfile (gitignored default unless `--env` is set).
|
|
23
|
+
* 7. Decide the action:
|
|
24
|
+
* - **NO_CHANGE** — file revision == lockfile.lastImportedRevision.
|
|
25
|
+
* Refresh the timestamp on the lockfile entry,
|
|
26
|
+
* do not touch the DB.
|
|
27
|
+
* - **CREATE** — no lockfile entry. Call `work.create_plan`.
|
|
28
|
+
* On 409 (plan already exists in DB), refuse
|
|
29
|
+
* unless `--force`.
|
|
30
|
+
* - **UPDATE** — lockfile entry exists, file revision differs.
|
|
31
|
+
* GET the DB plan, check `version` against
|
|
32
|
+
* `lastImportedVersion` to detect drift, then
|
|
33
|
+
* `work.update_plan` with computed mutations.
|
|
34
|
+
* - **DIVERGED** — lockfile entry exists but DB version >
|
|
35
|
+
* lastImportedVersion. Refuse unless `--force`.
|
|
36
|
+
* 8. On success, write the new lockfile entry
|
|
37
|
+
* (`{ revision, version, importedAt }`).
|
|
38
|
+
*
|
|
39
|
+
* Errors map to exit codes via the CLI wrapper:
|
|
40
|
+
* - 0 = success (CREATE / UPDATE / NO_CHANGE all success)
|
|
41
|
+
* - 1 = validation failure / refused divergence / DB error
|
|
42
|
+
* - 64 = usage error (missing file, planId/stem mismatch, --workspace
|
|
43
|
+
* mismatch, missing env trio)
|
|
44
|
+
*
|
|
45
|
+
* Out of scope (deferred to a later slice if real demand emerges):
|
|
46
|
+
* - Step REORDER detection. Substrate executor reads
|
|
47
|
+
* `gate.config.requires`, not array order; reordering is a
|
|
48
|
+
* semantic no-op. New steps land at the end via `add-step`.
|
|
49
|
+
* - Outside-container login flow. v1 errors out without `SESSION_ID`
|
|
50
|
+
* (substrate CLI is container-only — design Round 5).
|
|
51
|
+
* - Concurrent import safety. The lockfile read-modify-write is not
|
|
52
|
+
* atomic across processes; multi-developer concurrent imports to
|
|
53
|
+
* the same workspace are rare enough to defer.
|
|
54
|
+
*/
|
|
55
|
+
import { readFileSync } from 'node:fs';
|
|
56
|
+
import path from 'node:path';
|
|
57
|
+
import { canonicalizePlanFile } from './canonicalizer.js';
|
|
58
|
+
import { computeMutations } from './plan-diff.js';
|
|
59
|
+
import { validatePlanText, formatErrors } from './plan-validate.js';
|
|
60
|
+
import { computeRevisionHash } from './revision.js';
|
|
61
|
+
import { LockfileError, getEntry, readLockfile, resolveLockfilePath, setEntry, writeLockfile, } from './lockfile.js';
|
|
62
|
+
import { SUBSTRATE_CLI_PLAN_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
|
|
63
|
+
import { resolveSubstrateContext } from './auth-context.js';
|
|
64
|
+
import yaml from 'js-yaml';
|
|
65
|
+
import { planDagUrl } from './webapp-url.js';
|
|
66
|
+
// ─── Public entry ─────────────────────────────────────────────────────────
|
|
67
|
+
export async function runPlanImport(options) {
|
|
68
|
+
const env = options.env ?? process.env;
|
|
69
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
70
|
+
// 1) Validate file
|
|
71
|
+
let fileText;
|
|
72
|
+
try {
|
|
73
|
+
fileText = readFileSync(options.filePath, 'utf8');
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
return fail('usage', `cannot read plan file '${options.filePath}': ${describeError(err)}`);
|
|
77
|
+
}
|
|
78
|
+
const validation = validatePlanText(fileText);
|
|
79
|
+
if (!validation.ok) {
|
|
80
|
+
return fail('validation', `plan-file validation failed:\n${formatErrors(validation.errors)}`);
|
|
81
|
+
}
|
|
82
|
+
// Parse the file once we know it's valid+canonical so we can hand
|
|
83
|
+
// both the frontmatter and body to the diff.
|
|
84
|
+
let frontmatter;
|
|
85
|
+
let body;
|
|
86
|
+
try {
|
|
87
|
+
const parsed = parsePlanFile(fileText);
|
|
88
|
+
frontmatter = parsed.frontmatter;
|
|
89
|
+
body = parsed.body;
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
// validatePlanText already ruled this path out; defense-in-depth.
|
|
93
|
+
return fail('validation', `plan-file parse failed: ${describeError(err)}`);
|
|
94
|
+
}
|
|
95
|
+
const filePlan = makeFilePlan(frontmatter, body);
|
|
96
|
+
// 2) planId vs file stem (design F8.1)
|
|
97
|
+
const stem = path.basename(options.filePath, path.extname(options.filePath));
|
|
98
|
+
if (filePlan.planId !== stem) {
|
|
99
|
+
return fail('planid_stem_mismatch', `frontmatter planId '${filePlan.planId}' must equal the file stem '${stem}' (file '${options.filePath}')`);
|
|
100
|
+
}
|
|
101
|
+
// 3) Substrate context
|
|
102
|
+
const auth = resolveSubstrateContext(env);
|
|
103
|
+
if (!auth.ok)
|
|
104
|
+
return fail('auth', auth.message);
|
|
105
|
+
const { sessionId, commonApiUrl, userToken } = auth.context;
|
|
106
|
+
// 4) Mint token
|
|
107
|
+
let mint;
|
|
108
|
+
try {
|
|
109
|
+
mint = await mintSubstrateToken({
|
|
110
|
+
commonApiUrl,
|
|
111
|
+
userToken,
|
|
112
|
+
sessionId,
|
|
113
|
+
scopes: SUBSTRATE_CLI_PLAN_SCOPES,
|
|
114
|
+
fetchImpl: options.fetchImpl,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err instanceof WorkClientError) {
|
|
119
|
+
return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
|
|
120
|
+
}
|
|
121
|
+
return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
|
|
122
|
+
}
|
|
123
|
+
// 5) --workspace assertion
|
|
124
|
+
if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
|
|
125
|
+
return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}). ` +
|
|
126
|
+
`Either omit the flag (it's optional in containers) or open a session for the right workspace.`);
|
|
127
|
+
}
|
|
128
|
+
// 6) Lockfile
|
|
129
|
+
const plansDir = options.plansDir ?? path.dirname(options.filePath);
|
|
130
|
+
let lockfilePath;
|
|
131
|
+
let lockfile;
|
|
132
|
+
try {
|
|
133
|
+
lockfilePath = resolveLockfilePath(plansDir, options.lockfileFlag);
|
|
134
|
+
lockfile = readLockfile(lockfilePath);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
if (err instanceof LockfileError) {
|
|
138
|
+
return fail('lockfile', err.message, { code: err.code });
|
|
139
|
+
}
|
|
140
|
+
return fail('lockfile', describeError(err));
|
|
141
|
+
}
|
|
142
|
+
// 7) Decide action
|
|
143
|
+
const canonicalBytes = canonicalizePlanFile(frontmatter, body);
|
|
144
|
+
const fileRevision = computeRevisionHash(canonicalBytes);
|
|
145
|
+
const existingEntry = getEntry(lockfile, mint.workspaceId, filePlan.planId);
|
|
146
|
+
const requestCtx = {
|
|
147
|
+
commonApiUrl,
|
|
148
|
+
delegatedToken: mint.token,
|
|
149
|
+
fetchImpl: options.fetchImpl,
|
|
150
|
+
};
|
|
151
|
+
let resultAction;
|
|
152
|
+
let resultVersion;
|
|
153
|
+
if (!existingEntry) {
|
|
154
|
+
// CREATE path
|
|
155
|
+
const created = await tryCreate(requestCtx, mint.workspaceId, filePlan);
|
|
156
|
+
if (created.ok) {
|
|
157
|
+
resultAction = 'created';
|
|
158
|
+
resultVersion = created.version;
|
|
159
|
+
}
|
|
160
|
+
else if (created.conflict409) {
|
|
161
|
+
if (!options.force) {
|
|
162
|
+
return fail('conflict_no_force', `plan '${filePlan.planId}' already exists in workspace ${mint.workspaceId} but the lockfile has no entry. ` +
|
|
163
|
+
`Run with --force to fast-forward import (will rewrite the DB plan with the file's contents), or ` +
|
|
164
|
+
`run \`yolo plan export ${filePlan.planId}\` first to capture the DB shape.`);
|
|
165
|
+
}
|
|
166
|
+
// --force on 409: pull the DB plan, run the UPDATE path with whatever's there as the base.
|
|
167
|
+
const fetched = await tryGet(requestCtx, mint.workspaceId, filePlan.planId);
|
|
168
|
+
if (!fetched.ok)
|
|
169
|
+
return fetched.error;
|
|
170
|
+
const updated = await tryUpdate(requestCtx, mint.workspaceId, filePlan, fetched.plan);
|
|
171
|
+
if (!updated.ok)
|
|
172
|
+
return updated.error;
|
|
173
|
+
resultAction = 'updated';
|
|
174
|
+
resultVersion = updated.version;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
return created.error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
else if (existingEntry.lastImportedRevision === fileRevision) {
|
|
181
|
+
// NO_CHANGE path: refresh timestamp, write lockfile, exit.
|
|
182
|
+
resultAction = 'no-change';
|
|
183
|
+
resultVersion = existingEntry.lastImportedVersion;
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
// UPDATE path: GET DB, check drift, compute mutations, PATCH.
|
|
187
|
+
const fetched = await tryGet(requestCtx, mint.workspaceId, filePlan.planId);
|
|
188
|
+
if (!fetched.ok)
|
|
189
|
+
return fetched.error;
|
|
190
|
+
if (fetched.plan.version !== existingEntry.lastImportedVersion) {
|
|
191
|
+
if (!options.force) {
|
|
192
|
+
return fail('diverged', `DB has plan '${filePlan.planId}' at version ${fetched.plan.version}, but lockfile expected version ${existingEntry.lastImportedVersion}. ` +
|
|
193
|
+
`Run with --force to fast-forward (overwrites DB changes), or \`yolo plan export\` first to capture the DB shape.`, { dbVersion: fetched.plan.version, expectedVersion: existingEntry.lastImportedVersion });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const updated = await tryUpdate(requestCtx, mint.workspaceId, filePlan, fetched.plan);
|
|
197
|
+
if (!updated.ok)
|
|
198
|
+
return updated.error;
|
|
199
|
+
resultAction = updated.action;
|
|
200
|
+
resultVersion = updated.version;
|
|
201
|
+
}
|
|
202
|
+
// 8) Update lockfile (idempotent — same input → same bytes).
|
|
203
|
+
const importedAt = now();
|
|
204
|
+
const newEntry = {
|
|
205
|
+
lastImportedRevision: fileRevision,
|
|
206
|
+
lastImportedVersion: resultVersion,
|
|
207
|
+
lastImportedAt: importedAt,
|
|
208
|
+
};
|
|
209
|
+
setEntry(lockfile, mint.workspaceId, filePlan.planId, newEntry);
|
|
210
|
+
try {
|
|
211
|
+
writeLockfile(lockfilePath, lockfile);
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
if (err instanceof LockfileError) {
|
|
215
|
+
return fail('lockfile', err.message, { code: err.code });
|
|
216
|
+
}
|
|
217
|
+
return fail('lockfile', describeError(err));
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
ok: true,
|
|
221
|
+
action: resultAction,
|
|
222
|
+
planId: filePlan.planId,
|
|
223
|
+
workspaceId: mint.workspaceId,
|
|
224
|
+
version: resultVersion,
|
|
225
|
+
revision: fileRevision,
|
|
226
|
+
importedAt,
|
|
227
|
+
lockfilePath,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
async function tryCreate(ctx, workspaceId, filePlan) {
|
|
231
|
+
// `filePlan.description` is the SEPARATOR-NORMALIZED body
|
|
232
|
+
// (`makeFilePlan` strips the leading `\n` that the regex captures
|
|
233
|
+
// along with the body). Sending it raw was the Codex 8c.3 R1 bug —
|
|
234
|
+
// canonical files emit `---\n\n<body>` so the captured `body`
|
|
235
|
+
// starts with `\n`, which would make imported descriptions begin
|
|
236
|
+
// with a phantom blank line and break round-trips.
|
|
237
|
+
const body = {
|
|
238
|
+
planId: filePlan.planId,
|
|
239
|
+
name: filePlan.name,
|
|
240
|
+
description: filePlan.description,
|
|
241
|
+
inputs: filePlan.inputs ?? [],
|
|
242
|
+
failurePolicy: filePlan.failurePolicy ?? 'pause-and-wait',
|
|
243
|
+
autoRetryCap: filePlan.autoRetryCap,
|
|
244
|
+
autoRetryFallback: filePlan.autoRetryFallback,
|
|
245
|
+
integrationPolicy: filePlan.integrationPolicy,
|
|
246
|
+
steps: filePlan.steps,
|
|
247
|
+
// Send the file's state so a Plan authored as 'active' lands at
|
|
248
|
+
// 'active' instead of silently downgrading to 'draft'. Surfaced
|
|
249
|
+
// in Phase 8d.2 verification: the substrate's create_plan didn't
|
|
250
|
+
// accept the field until this slice (validatePlanCreateFields now
|
|
251
|
+
// normalizes it via PLAN_STATES). Omitting the field keeps the
|
|
252
|
+
// substrate's draft-first default for hand-authored callers that
|
|
253
|
+
// don't supply one.
|
|
254
|
+
//
|
|
255
|
+
// Renamed from `authoringState` 2026-05-09 (item 17 of
|
|
256
|
+
// `docs/SUBSTRATE_IMPROVEMENTS.md`).
|
|
257
|
+
state: filePlan.state,
|
|
258
|
+
};
|
|
259
|
+
const response = await authenticatedRequest(ctx, `/workspaces/${workspaceId}/plans`, {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
jsonBody: body,
|
|
262
|
+
});
|
|
263
|
+
if (response.ok) {
|
|
264
|
+
const json = (await response.json());
|
|
265
|
+
if (typeof json.version !== 'number') {
|
|
266
|
+
return { ok: false, error: fail('http', 'create_plan response missing version field') };
|
|
267
|
+
}
|
|
268
|
+
return { ok: true, version: json.version };
|
|
269
|
+
}
|
|
270
|
+
if (response.status === 409) {
|
|
271
|
+
return { ok: false, conflict409: true };
|
|
272
|
+
}
|
|
273
|
+
const text = await safeReadText(response);
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
error: fail('http', `work.create_plan failed: HTTP ${response.status} — ${text}`, {
|
|
277
|
+
status: response.status,
|
|
278
|
+
}),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
async function tryGet(ctx, workspaceId, planId) {
|
|
282
|
+
const response = await authenticatedRequest(ctx, `/workspaces/${workspaceId}/plans/${planId}`, {
|
|
283
|
+
method: 'GET',
|
|
284
|
+
});
|
|
285
|
+
if (!response.ok) {
|
|
286
|
+
const text = await safeReadText(response);
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
error: fail('http', `work.get_plan failed: HTTP ${response.status} — ${text}`, { status: response.status }),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const json = (await response.json());
|
|
293
|
+
if (!json.plan) {
|
|
294
|
+
return { ok: false, error: fail('http', 'work.get_plan response missing `plan` field') };
|
|
295
|
+
}
|
|
296
|
+
return { ok: true, plan: json.plan };
|
|
297
|
+
}
|
|
298
|
+
async function tryUpdate(ctx, workspaceId, filePlan, dbPlan) {
|
|
299
|
+
const mutations = computeMutations(dbPlan, filePlan);
|
|
300
|
+
if (mutations.length === 0) {
|
|
301
|
+
// Lockfile knew the file was different (otherwise we'd be on the
|
|
302
|
+
// NO_CHANGE path), but the diff says nothing to change. Likely:
|
|
303
|
+
// the file's revision changed cosmetically (e.g., the
|
|
304
|
+
// canonicalizer's output changed across versions). Treat as
|
|
305
|
+
// no-change, and the caller will refresh the lockfile entry.
|
|
306
|
+
return { ok: true, action: 'no-change', version: dbPlan.version };
|
|
307
|
+
}
|
|
308
|
+
const response = await authenticatedRequest(ctx, `/workspaces/${workspaceId}/plans/${filePlan.planId}`, {
|
|
309
|
+
method: 'PATCH',
|
|
310
|
+
jsonBody: { baseVersion: dbPlan.version, mutations: mutations },
|
|
311
|
+
});
|
|
312
|
+
if (!response.ok) {
|
|
313
|
+
const text = await safeReadText(response);
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
error: fail('http', `work.update_plan failed: HTTP ${response.status} — ${text}`, { status: response.status }),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const json = (await response.json());
|
|
320
|
+
if (typeof json.version !== 'number') {
|
|
321
|
+
return { ok: false, error: fail('http', 'work.update_plan response missing version field') };
|
|
322
|
+
}
|
|
323
|
+
return { ok: true, action: 'updated', version: json.version };
|
|
324
|
+
}
|
|
325
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
326
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
|
|
327
|
+
/**
|
|
328
|
+
* Re-split the file into frontmatter object + body string. Mirrors
|
|
329
|
+
* the regex used by validatePlanText so that a successful validate
|
|
330
|
+
* implies a successful parse here. Throws on failure (defense-in-depth
|
|
331
|
+
* against future drift between the two parsers).
|
|
332
|
+
*/
|
|
333
|
+
function parsePlanFile(text) {
|
|
334
|
+
const match = text.match(FRONTMATTER_RE);
|
|
335
|
+
if (!match)
|
|
336
|
+
throw new Error('frontmatter delimiters missing');
|
|
337
|
+
const fm = yaml.load(match[1]);
|
|
338
|
+
return { frontmatter: fm, body: match[2] };
|
|
339
|
+
}
|
|
340
|
+
function makeFilePlan(frontmatter, body) {
|
|
341
|
+
// Body is Plan.description (Phase 8b design). The frontmatter→body
|
|
342
|
+
// separator (the blank line after the closing `---`) is OWNED by
|
|
343
|
+
// the canonicalizer (`canonicalizePlanFile` builds `---\n\n<body>`
|
|
344
|
+
// and strips leading `\n+` from the captured body before re-emit).
|
|
345
|
+
// Mirror that normalization here so `description` we send to
|
|
346
|
+
// `work.create_plan` / `work.update_plan` is exactly what
|
|
347
|
+
// round-tripping back through the canonicalizer would produce —
|
|
348
|
+
// otherwise an imported description gets a phantom leading blank
|
|
349
|
+
// line, and 8c.4 export round-trips churn (Codex 8c.3 R1 Medium).
|
|
350
|
+
const description = body
|
|
351
|
+
.replace(/\r\n/g, '\n') // CRLF → LF (validate already rejects CRLF; defense in depth)
|
|
352
|
+
.replace(/^\n+/, '') // strip separator-owned leading newline(s)
|
|
353
|
+
.replace(/\s+$/, '') + // collapse trailing whitespace…
|
|
354
|
+
'\n'; // …and ensure exactly one trailing newline.
|
|
355
|
+
return {
|
|
356
|
+
planId: String(frontmatter.planId ?? ''),
|
|
357
|
+
name: String(frontmatter.name ?? ''),
|
|
358
|
+
description,
|
|
359
|
+
state: frontmatter.state ?? 'draft',
|
|
360
|
+
failurePolicy: frontmatter.failurePolicy,
|
|
361
|
+
autoRetryCap: frontmatter.autoRetryCap,
|
|
362
|
+
autoRetryFallback: frontmatter.autoRetryFallback,
|
|
363
|
+
inputs: frontmatter.inputs ?? [],
|
|
364
|
+
integrationPolicy: frontmatter.integrationPolicy,
|
|
365
|
+
steps: (frontmatter.steps ?? []),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function fail(kind, message, detail) {
|
|
369
|
+
return { ok: false, kind, message, detail };
|
|
370
|
+
}
|
|
371
|
+
function describeError(err) {
|
|
372
|
+
return err instanceof Error ? err.message : String(err);
|
|
373
|
+
}
|
|
374
|
+
async function safeReadText(response) {
|
|
375
|
+
try {
|
|
376
|
+
return await response.text();
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return '<no body>';
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Map an `ImportFailure.kind` to a CLI exit code. Used by cli.ts and
|
|
384
|
+
* exported for tests.
|
|
385
|
+
*/
|
|
386
|
+
export function exitCodeForFailure(kind) {
|
|
387
|
+
switch (kind) {
|
|
388
|
+
case 'usage':
|
|
389
|
+
case 'auth':
|
|
390
|
+
case 'planid_stem_mismatch':
|
|
391
|
+
case 'workspace_mismatch':
|
|
392
|
+
return 64; // EX_USAGE
|
|
393
|
+
case 'validation':
|
|
394
|
+
case 'lockfile':
|
|
395
|
+
case 'diverged':
|
|
396
|
+
case 'conflict_no_force':
|
|
397
|
+
case 'http':
|
|
398
|
+
return 1;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Render an `ImportSuccess` as a CLI summary. The first line is the
|
|
403
|
+
* existing one-liner; if a webapp URL can be derived from the env, a
|
|
404
|
+
* second line prints the Plan-DAG link so operators can click straight
|
|
405
|
+
* through to the visual topology view. Caller appends `\n` if needed.
|
|
406
|
+
*/
|
|
407
|
+
export function formatSuccess(result, env = process.env) {
|
|
408
|
+
const headline = (() => {
|
|
409
|
+
switch (result.action) {
|
|
410
|
+
case 'created':
|
|
411
|
+
return `OK: created plan '${result.planId}' in workspace ${result.workspaceId} at version ${result.version} (revision ${result.revision.slice(0, 14)}…)`;
|
|
412
|
+
case 'updated':
|
|
413
|
+
return `OK: updated plan '${result.planId}' in workspace ${result.workspaceId} to version ${result.version} (revision ${result.revision.slice(0, 14)}…)`;
|
|
414
|
+
case 'no-change':
|
|
415
|
+
return `OK: plan '${result.planId}' already at file revision (workspace ${result.workspaceId}, version ${result.version})`;
|
|
416
|
+
}
|
|
417
|
+
})();
|
|
418
|
+
const dagUrl = planDagUrl(env, result.workspaceId, result.planId);
|
|
419
|
+
return dagUrl ? `${headline}\n view: ${dagUrl}` : headline;
|
|
420
|
+
}
|