@pipefy/pipefy-process-coder 0.1.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/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -0
- package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -0
- package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -0
- package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -0
- package/AGENTS.md +456 -0
- package/README.md +808 -0
- package/bin/pipe.js +13 -0
- package/package.json +35 -0
- package/src/apply/adopt.ts +150 -0
- package/src/apply/agentops.ts +71 -0
- package/src/apply/compile.ts +875 -0
- package/src/apply/execute.ts +336 -0
- package/src/apply/flowops.ts +399 -0
- package/src/apply/idmap.ts +241 -0
- package/src/apply/mutations.ts +955 -0
- package/src/apply/registry.ts +430 -0
- package/src/apply/types.ts +134 -0
- package/src/cli/args.ts +88 -0
- package/src/cli.ts +211 -0
- package/src/codec/flow.ts +199 -0
- package/src/codec/pack.ts +103 -0
- package/src/codec/roundtrip.ts +94 -0
- package/src/codec/unpack.ts +198 -0
- package/src/commands/agents.ts +184 -0
- package/src/commands/apply.ts +1144 -0
- package/src/commands/context.ts +119 -0
- package/src/commands/create.ts +79 -0
- package/src/commands/diff.ts +314 -0
- package/src/commands/flows.ts +414 -0
- package/src/commands/misc.ts +644 -0
- package/src/commands/plan.ts +331 -0
- package/src/commands/pull.ts +567 -0
- package/src/commands/runs.ts +83 -0
- package/src/commands/skills.ts +137 -0
- package/src/commands/verify.ts +253 -0
- package/src/config.ts +168 -0
- package/src/diff/agents.ts +122 -0
- package/src/diff/diff.ts +1130 -0
- package/src/diff/flow.ts +318 -0
- package/src/diff/html.ts +322 -0
- package/src/diff/render.ts +101 -0
- package/src/model/payload.ts +154 -0
- package/src/model/tree.ts +99 -0
- package/src/model/volatile.ts +55 -0
- package/src/pipefy/agents.ts +165 -0
- package/src/pipefy/automations.ts +219 -0
- package/src/pipefy/capability.ts +119 -0
- package/src/pipefy/client.ts +267 -0
- package/src/pipefy/discovery.ts +209 -0
- package/src/pipefy/internal.ts +380 -0
- package/src/pipefy/ipaas.ts +365 -0
- package/src/pipefy/reconstruct.ts +775 -0
- package/src/pipefy/reference.ts +251 -0
- package/src/pipefy/snapshot.ts +245 -0
- package/src/pipefy/toolkit.ts +200 -0
- package/src/pipefy/toolkit_bearer.py +137 -0
- package/src/report/integrations.ts +231 -0
- package/src/report/run.ts +475 -0
- package/src/util/fsx.ts +45 -0
- package/src/util/git.ts +32 -0
- package/src/util/json.ts +55 -0
- package/src/util/log.ts +76 -0
- package/src/util/pool.ts +48 -0
- package/src/util/slug.ts +26 -0
- package/src/util/tui.ts +335 -0
- package/src/validate/index.ts +123 -0
- package/src/validate/integrity.ts +387 -0
- package/src/validate/reference.ts +136 -0
- package/src/validate/schema.ts +328 -0
- package/src/workspace/agents.ts +290 -0
- package/src/workspace/docs.ts +407 -0
- package/src/workspace/flows.ts +191 -0
- package/src/workspace/layout.ts +165 -0
- package/src/workspace/lock.ts +148 -0
- package/src/workspace/read.ts +165 -0
- package/src/workspace/reference.ts +24 -0
- package/src/workspace/stamp.ts +301 -0
- package/src/workspace/write.ts +225 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { writeJson } from '../util/fsx.ts';
|
|
2
|
+
import { debug, dim, green, red, yellow } from '../util/log.ts';
|
|
3
|
+
import { Progress } from '../util/tui.ts';
|
|
4
|
+
import type { PipefyClient } from '../pipefy/client.ts';
|
|
5
|
+
import type { InternalApi } from '../pipefy/internal.ts';
|
|
6
|
+
import { IdMap } from './idmap.ts';
|
|
7
|
+
import type { Plan, Step } from './types.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The executor. Non-atomic forward by necessity, so:
|
|
11
|
+
*
|
|
12
|
+
* - every step's status is persisted to `.ppc/plans/<id>.json` as it runs, which
|
|
13
|
+
* is what makes a failed apply resumable rather than a mystery
|
|
14
|
+
* - ids created by a step are substituted into later steps at send time, not at
|
|
15
|
+
* compile time (PLAN.md §9)
|
|
16
|
+
* - a step that still depends on an unresolved placeholder is skipped with a
|
|
17
|
+
* reason, never sent with a literal `%{_new:...}` in it
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type ExecuteOpts = {
|
|
21
|
+
client: PipefyClient;
|
|
22
|
+
internal: InternalApi;
|
|
23
|
+
/** Where to persist step status. */
|
|
24
|
+
planPath: string;
|
|
25
|
+
dryRun: boolean;
|
|
26
|
+
/** Stop at the first failure (default) or keep going. */
|
|
27
|
+
continueOnError?: boolean;
|
|
28
|
+
onProgress?: (step: Step, plan: Plan) => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ExecuteResult = {
|
|
32
|
+
plan: Plan;
|
|
33
|
+
done: number;
|
|
34
|
+
failed: number;
|
|
35
|
+
skipped: number;
|
|
36
|
+
/** Steps never attempted because an earlier one failed. */
|
|
37
|
+
pending: number;
|
|
38
|
+
/**
|
|
39
|
+
* What each placeholder became. Verification needs it: the intent says
|
|
40
|
+
* `%{_new:<uuid>}` and the pipe holds the id the server chose, so without this
|
|
41
|
+
* the only options are to skip those values or to report them as differences.
|
|
42
|
+
*/
|
|
43
|
+
idMap: IdMap;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const pluck = (obj: unknown, path: string[]): unknown => {
|
|
47
|
+
let cur: unknown = obj;
|
|
48
|
+
for (const key of path) {
|
|
49
|
+
if (cur && typeof cur === 'object' && key in (cur as Record<string, unknown>)) {
|
|
50
|
+
cur = (cur as Record<string, unknown>)[key];
|
|
51
|
+
} else {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return cur;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const execute = async (plan: Plan, opts: ExecuteOpts): Promise<ExecuteResult> => {
|
|
59
|
+
const idMap = new IdMap(plan.idMap);
|
|
60
|
+
plan.status = 'running';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The step list, live. A plan is a queue of network calls whose count and
|
|
64
|
+
* order the user has already seen in `pipe plan`, so showing that same list
|
|
65
|
+
* fill in is the honest progress indicator — and in a dry run it shows exactly
|
|
66
|
+
* what would be sent, in order, without sending it.
|
|
67
|
+
*/
|
|
68
|
+
const ui = new Progress({
|
|
69
|
+
title: opts.dryRun ? `dry run · ${plan.steps.length} steps` : `applying ${plan.steps.length} steps`,
|
|
70
|
+
});
|
|
71
|
+
for (const s of plan.steps) {
|
|
72
|
+
ui.add({
|
|
73
|
+
id: s.id,
|
|
74
|
+
label: s.description,
|
|
75
|
+
state: s.status === 'done' ? 'done' : 'pending',
|
|
76
|
+
tags: [
|
|
77
|
+
...(s.destructive ? ['destructive'] : []),
|
|
78
|
+
...(s.unverified ? ['unverified'] : []),
|
|
79
|
+
...(s.kind.startsWith('internal') ? ['internal-api'] : []),
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
ui.start();
|
|
84
|
+
|
|
85
|
+
const persist = async () => {
|
|
86
|
+
if (!opts.dryRun) await writeJson(opts.planPath, plan);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
for (const s of plan.steps) {
|
|
90
|
+
if (s.status === 'done') {
|
|
91
|
+
// Resume: a completed create still has to re-register its placeholder so
|
|
92
|
+
// later steps can resolve against it.
|
|
93
|
+
if (s.produces && s.result !== undefined) {
|
|
94
|
+
const id = Number(pluck(s.result, s.produces.path));
|
|
95
|
+
if (Number.isFinite(id)) idMap.resolvePlaceholder(s.produces.placeholder, id, s.produces.aliases ?? []);
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const unresolved = idMap.unresolved(s.request.variables ?? s.request.form ?? s.request.path ?? null);
|
|
101
|
+
if (unresolved.length) {
|
|
102
|
+
s.status = 'skipped';
|
|
103
|
+
s.error = `depends on ids that were never created: ${unresolved.join(', ')}`;
|
|
104
|
+
ui.update(s.id, { state: 'skipped', detail: s.error });
|
|
105
|
+
await persist();
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const request = {
|
|
110
|
+
...s.request,
|
|
111
|
+
variables: s.request.variables ? idMap.rewrite(s.request.variables) : undefined,
|
|
112
|
+
form: s.request.form ? idMap.rewrite(s.request.form) : undefined,
|
|
113
|
+
path: s.request.path ? (idMap.rewrite({ p: s.request.path }) as { p: string }).p : undefined,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Nothing leaves here still carrying a placeholder.
|
|
118
|
+
*
|
|
119
|
+
* A *registered* placeholder passes the unresolved check above, but is only
|
|
120
|
+
* substituted if its key is known to hold an id — so a new id-bearing key
|
|
121
|
+
* that nobody added to ID_BEARING_KEYS sends the marker to the API verbatim.
|
|
122
|
+
* That happened with `fieldConditionIds`, and the resulting error named the
|
|
123
|
+
* field but said nothing about the cause. This refuses to send, and says
|
|
124
|
+
* which key.
|
|
125
|
+
*/
|
|
126
|
+
const leftovers: string[] = [];
|
|
127
|
+
const findMarkers = (v: unknown, path: string) => {
|
|
128
|
+
if (typeof v === 'string') {
|
|
129
|
+
if (v.includes('_new:')) leftovers.push(`${path} = ${v}`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (Array.isArray(v)) {
|
|
133
|
+
v.forEach((x, i) => findMarkers(x, `${path}[${i}]`));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (v && typeof v === 'object') {
|
|
137
|
+
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
|
138
|
+
findMarkers(val, path ? `${path}.${k}` : k);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
findMarkers(request.variables ?? null, '');
|
|
143
|
+
findMarkers(request.form ?? null, '');
|
|
144
|
+
|
|
145
|
+
if (leftovers.length) {
|
|
146
|
+
s.status = 'failed';
|
|
147
|
+
s.error =
|
|
148
|
+
`unsubstituted placeholder at ${leftovers.join(', ')} — that key is not in ID_BEARING_KEYS, ` +
|
|
149
|
+
'so nothing replaced it. Not sent.';
|
|
150
|
+
s.finishedAt = new Date().toISOString();
|
|
151
|
+
ui.update(s.id, { state: 'failed', detail: s.error });
|
|
152
|
+
await persist();
|
|
153
|
+
if (!opts.continueOnError) break;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (opts.dryRun) {
|
|
158
|
+
s.status = 'skipped';
|
|
159
|
+
/**
|
|
160
|
+
* A dry run marks each step done in the display rather than skipped: the
|
|
161
|
+
* step is complete as far as this run goes, and painting a whole plan
|
|
162
|
+
* yellow suggests something went wrong when nothing did. The title says
|
|
163
|
+
* "dry run", and nothing was sent.
|
|
164
|
+
*/
|
|
165
|
+
ui.update(s.id, { state: 'done', detail: 'not sent' });
|
|
166
|
+
debug(JSON.stringify(request.variables ?? request.form ?? {}).slice(0, 400));
|
|
167
|
+
opts.onProgress?.(s, plan);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
s.status = 'running';
|
|
172
|
+
s.startedAt = new Date().toISOString();
|
|
173
|
+
await persist();
|
|
174
|
+
ui.update(s.id, { state: 'running' });
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
let result: unknown;
|
|
178
|
+
if (s.kind === 'graphql') {
|
|
179
|
+
try {
|
|
180
|
+
result = await opts.client.raw(request.query as string, request.variables, s.request.mutation);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
/**
|
|
183
|
+
* `phaseSettings` is known to answer "Something went wrong" for a change
|
|
184
|
+
* it has just made — observed twice, on two different runs, with the
|
|
185
|
+
* `phase_team_memberships` row present in the read-back both times and
|
|
186
|
+
* timestamped to the failing call. Its failures are therefore
|
|
187
|
+
* inconclusive, not negative, and saying so is the difference between
|
|
188
|
+
* an operator re-running a harmless write and an operator hunting a
|
|
189
|
+
* change that already exists.
|
|
190
|
+
*/
|
|
191
|
+
if (s.request.mutation === 'phaseSettings') {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`${(e as Error).message} — note that phaseSettings has been observed reporting this ` +
|
|
194
|
+
'for a change it did apply. Re-read the phase before assuming nothing happened.',
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
throw e;
|
|
198
|
+
}
|
|
199
|
+
} else if (s.kind === 'internal-graphql') {
|
|
200
|
+
result = await runInternalAutomationStep(opts.internal, s, request.variables ?? {});
|
|
201
|
+
} else if (s.kind === 'internal-rest') {
|
|
202
|
+
result = await runInternalRestStep(opts.internal, s, request);
|
|
203
|
+
} else {
|
|
204
|
+
result = { noop: true };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
s.result = result;
|
|
208
|
+
s.status = 'done';
|
|
209
|
+
s.finishedAt = new Date().toISOString();
|
|
210
|
+
ui.update(s.id, { state: 'done' });
|
|
211
|
+
|
|
212
|
+
if (s.produces) {
|
|
213
|
+
const raw = pluck(result, s.produces.path);
|
|
214
|
+
const id = Number(raw);
|
|
215
|
+
if (Number.isFinite(id)) {
|
|
216
|
+
idMap.resolvePlaceholder(s.produces.placeholder, id, s.produces.aliases ?? []);
|
|
217
|
+
} else {
|
|
218
|
+
ui.note(yellow('! ') + `${s.id} succeeded but produced no id at ${s.produces.path.join('.')} — later steps may skip`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
opts.onProgress?.(s, plan);
|
|
222
|
+
} catch (e) {
|
|
223
|
+
s.status = 'failed';
|
|
224
|
+
s.error = (e as Error).message;
|
|
225
|
+
s.finishedAt = new Date().toISOString();
|
|
226
|
+
ui.update(s.id, { state: 'failed', detail: s.error });
|
|
227
|
+
await persist();
|
|
228
|
+
opts.onProgress?.(s, plan);
|
|
229
|
+
if (!opts.continueOnError) break;
|
|
230
|
+
}
|
|
231
|
+
await persist();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const counts = plan.steps.reduce(
|
|
235
|
+
(acc, s) => {
|
|
236
|
+
acc[s.status] = (acc[s.status] ?? 0) + 1;
|
|
237
|
+
return acc;
|
|
238
|
+
},
|
|
239
|
+
{} as Record<string, number>,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
plan.status =
|
|
243
|
+
(counts['failed'] ?? 0) > 0
|
|
244
|
+
? (counts['done'] ?? 0) > 0
|
|
245
|
+
? 'partially-applied'
|
|
246
|
+
: 'failed'
|
|
247
|
+
: opts.dryRun
|
|
248
|
+
? 'planned'
|
|
249
|
+
: 'applied';
|
|
250
|
+
await persist();
|
|
251
|
+
|
|
252
|
+
const done = counts['done'] ?? 0;
|
|
253
|
+
const failed = counts['failed'] ?? 0;
|
|
254
|
+
const skipped = counts['skipped'] ?? 0;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* A dry run's steps are recorded as skipped, which is right for the plan file
|
|
258
|
+
* and wrong as a headline: "0 done, 2 skipped" reads like something went
|
|
259
|
+
* wrong. Nothing did — nothing was sent.
|
|
260
|
+
*/
|
|
261
|
+
ui.stop(
|
|
262
|
+
opts.dryRun
|
|
263
|
+
? ' ' + green(`${plan.steps.length} step${plan.steps.length === 1 ? '' : 's'} simulated`) + dim(', nothing sent')
|
|
264
|
+
: ' ' +
|
|
265
|
+
[
|
|
266
|
+
done ? green(`${done} done`) : dim('0 done'),
|
|
267
|
+
failed ? red(`${failed} failed`) : dim('0 failed'),
|
|
268
|
+
skipped ? yellow(`${skipped} skipped`) : dim('0 skipped'),
|
|
269
|
+
].join(dim(' · ')) +
|
|
270
|
+
dim(` · ${(ui.elapsed / 1000).toFixed(1)}s`),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
plan,
|
|
275
|
+
done: counts['done'] ?? 0,
|
|
276
|
+
failed: counts['failed'] ?? 0,
|
|
277
|
+
skipped: counts['skipped'] ?? 0,
|
|
278
|
+
pending: counts['pending'] ?? 0,
|
|
279
|
+
idMap,
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const runInternalAutomationStep = async (internal: InternalApi, s: Step, variables: Record<string, unknown>) => {
|
|
284
|
+
if (s.request.mutation === 'createAutomation') {
|
|
285
|
+
const created = await internal.createAutomation(variables);
|
|
286
|
+
if (!created) throw new Error('createAutomation returned no automation (internal API unavailable or rejected)');
|
|
287
|
+
return { createAutomation: { automation: created } };
|
|
288
|
+
}
|
|
289
|
+
if (s.request.mutation === 'deleteAutomation') {
|
|
290
|
+
const success = await internal.deleteAutomation(String(variables['id']));
|
|
291
|
+
if (!success) throw new Error(`deleteAutomation(${String(variables['id'])}) did not report success`);
|
|
292
|
+
return { deleteAutomation: { success } };
|
|
293
|
+
}
|
|
294
|
+
if (s.request.mutation === 'updateAutomation') {
|
|
295
|
+
const res = await internal.gql<Record<string, unknown>>(
|
|
296
|
+
`mutation updateAutomation($input: UpdateAutomationInput!) {
|
|
297
|
+
updateAutomation(input: $input) { automation { id } error_details { object_key messages } }
|
|
298
|
+
}`,
|
|
299
|
+
{ input: variables },
|
|
300
|
+
'updateAutomation',
|
|
301
|
+
);
|
|
302
|
+
if (!res) throw new Error('updateAutomation failed on the internal endpoint');
|
|
303
|
+
return res;
|
|
304
|
+
}
|
|
305
|
+
throw new Error(`unknown internal mutation ${s.request.mutation}`);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const runInternalRestStep = async (internal: InternalApi, s: Step, request: Step['request']) => {
|
|
309
|
+
const phaseId = /\/phases\/([^/?]+)/.exec(request.path ?? '')?.[1];
|
|
310
|
+
if (!phaseId) throw new Error(`could not read a phase id out of ${request.path}`);
|
|
311
|
+
const targets = (request.form?.['phase[jump_target_ids][]'] ?? []).filter((t) => t !== '');
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* A jump target is an id and nothing else.
|
|
315
|
+
*
|
|
316
|
+
* The endpoint answers a malformed target with a bare 404, which reads as
|
|
317
|
+
* "that phase does not exist" and sends you looking in the wrong place. Every
|
|
318
|
+
* such 404 so far has been a substitution that did not happen — a placeholder
|
|
319
|
+
* rewritten to `%{344123894}` rather than to `344123894`. Checking the shape
|
|
320
|
+
* here names the real cause.
|
|
321
|
+
*/
|
|
322
|
+
const malformed = targets.filter((t) => !/^[0-9]+$/.test(t));
|
|
323
|
+
if (malformed.length) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`jump target(s) ${malformed.join(', ')} are not ids — an id substitution did not happen. Not sent.`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const res = await internal.setPhaseJumpTargets(phaseId, targets);
|
|
330
|
+
/**
|
|
331
|
+
* Assert on the echoed next_phase_ids, not on the HTTP status — PLAN.md §2.1.
|
|
332
|
+
* A 200 from an unversioned endpoint is not evidence that anything changed.
|
|
333
|
+
*/
|
|
334
|
+
if (!res.ok) throw new Error(res.error ?? 'phase jump write could not be confirmed from the response');
|
|
335
|
+
return res;
|
|
336
|
+
};
|