@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.
Files changed (92) hide show
  1. package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -0
  2. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -0
  3. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -0
  4. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -0
  5. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -0
  6. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -0
  7. package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -0
  8. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -0
  9. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -0
  10. package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -0
  11. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -0
  12. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -0
  13. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -0
  14. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -0
  15. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -0
  16. package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -0
  17. package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -0
  18. package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -0
  19. package/AGENTS.md +456 -0
  20. package/README.md +808 -0
  21. package/bin/pipe.js +13 -0
  22. package/package.json +35 -0
  23. package/src/apply/adopt.ts +150 -0
  24. package/src/apply/agentops.ts +71 -0
  25. package/src/apply/compile.ts +875 -0
  26. package/src/apply/execute.ts +336 -0
  27. package/src/apply/flowops.ts +399 -0
  28. package/src/apply/idmap.ts +241 -0
  29. package/src/apply/mutations.ts +955 -0
  30. package/src/apply/registry.ts +430 -0
  31. package/src/apply/types.ts +134 -0
  32. package/src/cli/args.ts +88 -0
  33. package/src/cli.ts +211 -0
  34. package/src/codec/flow.ts +199 -0
  35. package/src/codec/pack.ts +103 -0
  36. package/src/codec/roundtrip.ts +94 -0
  37. package/src/codec/unpack.ts +198 -0
  38. package/src/commands/agents.ts +184 -0
  39. package/src/commands/apply.ts +1144 -0
  40. package/src/commands/context.ts +119 -0
  41. package/src/commands/create.ts +79 -0
  42. package/src/commands/diff.ts +314 -0
  43. package/src/commands/flows.ts +414 -0
  44. package/src/commands/misc.ts +644 -0
  45. package/src/commands/plan.ts +331 -0
  46. package/src/commands/pull.ts +567 -0
  47. package/src/commands/runs.ts +83 -0
  48. package/src/commands/skills.ts +137 -0
  49. package/src/commands/verify.ts +253 -0
  50. package/src/config.ts +168 -0
  51. package/src/diff/agents.ts +122 -0
  52. package/src/diff/diff.ts +1130 -0
  53. package/src/diff/flow.ts +318 -0
  54. package/src/diff/html.ts +322 -0
  55. package/src/diff/render.ts +101 -0
  56. package/src/model/payload.ts +154 -0
  57. package/src/model/tree.ts +99 -0
  58. package/src/model/volatile.ts +55 -0
  59. package/src/pipefy/agents.ts +165 -0
  60. package/src/pipefy/automations.ts +219 -0
  61. package/src/pipefy/capability.ts +119 -0
  62. package/src/pipefy/client.ts +267 -0
  63. package/src/pipefy/discovery.ts +209 -0
  64. package/src/pipefy/internal.ts +380 -0
  65. package/src/pipefy/ipaas.ts +365 -0
  66. package/src/pipefy/reconstruct.ts +775 -0
  67. package/src/pipefy/reference.ts +251 -0
  68. package/src/pipefy/snapshot.ts +245 -0
  69. package/src/pipefy/toolkit.ts +200 -0
  70. package/src/pipefy/toolkit_bearer.py +137 -0
  71. package/src/report/integrations.ts +231 -0
  72. package/src/report/run.ts +475 -0
  73. package/src/util/fsx.ts +45 -0
  74. package/src/util/git.ts +32 -0
  75. package/src/util/json.ts +55 -0
  76. package/src/util/log.ts +76 -0
  77. package/src/util/pool.ts +48 -0
  78. package/src/util/slug.ts +26 -0
  79. package/src/util/tui.ts +335 -0
  80. package/src/validate/index.ts +123 -0
  81. package/src/validate/integrity.ts +387 -0
  82. package/src/validate/reference.ts +136 -0
  83. package/src/validate/schema.ts +328 -0
  84. package/src/workspace/agents.ts +290 -0
  85. package/src/workspace/docs.ts +407 -0
  86. package/src/workspace/flows.ts +191 -0
  87. package/src/workspace/layout.ts +165 -0
  88. package/src/workspace/lock.ts +148 -0
  89. package/src/workspace/read.ts +165 -0
  90. package/src/workspace/reference.ts +24 -0
  91. package/src/workspace/stamp.ts +301 -0
  92. package/src/workspace/write.ts +225 -0
@@ -0,0 +1,209 @@
1
+ import { debug, step, warn } from '../util/log.ts';
2
+ import { rel, type RepoPayload, type Row } from '../model/payload.ts';
3
+ import type { PipefyClient } from './client.ts';
4
+
5
+ /**
6
+ * Connection discovery. PLAN.md §2.2 — the finding that would have silently
7
+ * broken the core requirement:
8
+ *
9
+ * - `relations.pipe_relations` is [] even for a pipe with two live connections
10
+ * - `pipeMapGraph(depth: 3)` returns one node and zero edges for that pipe
11
+ *
12
+ * So neither is the source of truth. Discovery is the union of three sources,
13
+ * walked transitively and deduplicated by repo identity.
14
+ */
15
+
16
+ export type RepoKind = 'pipe' | 'table';
17
+
18
+ export type RepoRef = {
19
+ kind: RepoKind;
20
+ /** Numeric id, which both the pipe and table APIs accept. */
21
+ id: number;
22
+ /** Table API ids round-trip as a slug (`dzmxcuxz`); pipes return numbers. */
23
+ apiId: string;
24
+ name?: string | null;
25
+ uuid?: string | null;
26
+ /** How this repo was found, for the manifest and the UI. */
27
+ via: string[];
28
+ depth: number;
29
+ };
30
+
31
+ export type DiscoveryResult = {
32
+ /** Every repo to pull, root first. */
33
+ repos: RepoRef[];
34
+ /** Edges, for the graph view and for MAPPING.md. */
35
+ edges: Array<{ from: number; to: number; kind: string; label?: string }>;
36
+ warnings: string[];
37
+ };
38
+
39
+ const asNum = (v: unknown): number | null => {
40
+ if (v === null || v === undefined || v === '') return null;
41
+ const n = Number(v);
42
+ return Number.isFinite(n) ? n : null;
43
+ };
44
+
45
+ /** The three sources, applied to one already-read payload. */
46
+ export const edgesFromPayload = (p: RepoPayload): Array<{ to: number; kind: string; label?: string }> => {
47
+ const self = Number(p.id);
48
+ const out: Array<{ to: number; kind: string; label?: string }> = [];
49
+
50
+ // 1. pipe_relations
51
+ for (const r of rel(p, 'pipe_relations')) {
52
+ for (const key of ['parent_id', 'child_id']) {
53
+ const id = asNum(r[key]);
54
+ if (id !== null && id !== self) out.push({ to: id, kind: 'pipe_relation', label: String(r['name'] ?? '') });
55
+ }
56
+ }
57
+
58
+ // 2. connector fields — the only source that finds the test pipe's two
59
+ // connections, and the reason this function exists.
60
+ for (const f of rel(p, 'fields')) {
61
+ if (f['type_id'] !== 'connector') continue;
62
+ const id = asNum(f['connected_pipe_id']);
63
+ if (id !== null && id !== self) out.push({ to: id, kind: 'connector_field', label: String(f['label'] ?? '') });
64
+ }
65
+
66
+ // 3. cross-repo automations
67
+ for (const a of rel(p, 'automations')) {
68
+ for (const key of ['event_repo_id', 'action_repo_id']) {
69
+ const id = asNum(a[key]);
70
+ if (id !== null && id !== self) out.push({ to: id, kind: 'automation', label: String(a['name'] ?? '') });
71
+ }
72
+ }
73
+
74
+ return out;
75
+ };
76
+
77
+ /**
78
+ * Resolve a numeric repo id to what it actually is.
79
+ *
80
+ * **Neither root field discriminates.** Verified live: `pipe(id:)` and
81
+ * `table(id:)` both answer for *every* repo id, and both return plausible data —
82
+ * `pipe.phases` on a database returns its status phases, and `table.table_fields`
83
+ * on a pipe returns its start-form fields. A repo is one object viewed two ways.
84
+ * Trying `pipe` first and taking the win, as an earlier version did, classified
85
+ * every database as a pipe.
86
+ *
87
+ * `Table.url` is the real discriminator: a pipe lives at `/pipes/<id>` and a
88
+ * database at `/apollo_databases/<id>`. One query settles both the kind and
89
+ * PLAN.md §2.2's id duality, since `Table` carries the slug as `id` and the
90
+ * numeric form as `internal_id`.
91
+ */
92
+ /**
93
+ * Resolve one repo id, in **either** spelling.
94
+ *
95
+ * `table(id:)` accepts a database's numeric internal id and its api id (a slug
96
+ * like `dzmxcuxz`) and always answers with the slug — measured both ways. So
97
+ * `pipe pull 302908356` and `pipe pull 3LhkZu_h` must reach the same database,
98
+ * and the `RepoRef` this returns normalises to the numeric id either way, which
99
+ * is what keeps one database from being pulled twice under two identities.
100
+ */
101
+ export const resolveRepo = async (client: PipefyClient, id: number | string): Promise<RepoRef | null> => {
102
+ const res = await client.tryRaw<{
103
+ table: { id: string; internal_id?: string | null; name: string; uuid?: string | null; url?: string | null } | null;
104
+ }>(
105
+ `query($id: ID!) { table(id: $id) { id internal_id name uuid url } }`,
106
+ { id: String(id) },
107
+ `resolve repo ${id}`,
108
+ );
109
+ const repo = res?.table;
110
+ if (!repo) return null;
111
+
112
+ const url = String(repo.url ?? '');
113
+ const kind: RepoKind = /apollo_databases/.test(url) ? 'table' : 'pipe';
114
+ debug(`repo ${id} is a ${kind} (${url || 'no url'})`);
115
+
116
+ return {
117
+ kind,
118
+ id: Number(repo.internal_id ?? id),
119
+ /** The slug for a database, the numeric string for a pipe. */
120
+ apiId: kind === 'table' ? String(repo.id) : String(repo.internal_id ?? id),
121
+ name: repo.name,
122
+ uuid: repo.uuid ?? null,
123
+ via: [],
124
+ depth: 0,
125
+ };
126
+ };
127
+
128
+ export type DiscoverOpts = {
129
+ depth: number;
130
+ /** Read a repo's payload; supplied by the pull command so both read paths work. */
131
+ read: (ref: RepoRef) => Promise<RepoPayload | null>;
132
+ };
133
+
134
+ /**
135
+ * Walk outward from a root repo. `read` is injected so discovery works
136
+ * identically whether payloads come from snapshots or from reconstruction.
137
+ */
138
+ export const discover = async (
139
+ client: PipefyClient,
140
+ rootId: number,
141
+ opts: DiscoverOpts,
142
+ ): Promise<DiscoveryResult> => {
143
+ const found = new Map<number, RepoRef>();
144
+ const edges: DiscoveryResult['edges'] = [];
145
+ const warnings: string[] = [];
146
+
147
+ const root = await resolveRepo(client, rootId);
148
+ if (!root) throw new Error(`repo ${rootId} is neither a readable pipe nor a readable table`);
149
+ root.via = ['root'];
150
+ found.set(rootId, root);
151
+
152
+ let frontier: RepoRef[] = [root];
153
+ for (let depth = 0; depth <= opts.depth && frontier.length; depth++) {
154
+ const next: RepoRef[] = [];
155
+ for (const ref of frontier) {
156
+ if (depth === opts.depth) break;
157
+ const payload = await opts.read(ref);
158
+ if (!payload) {
159
+ warnings.push(`${ref.kind} ${ref.id} (${ref.name ?? '?'}) could not be read — its connections are unexplored`);
160
+ continue;
161
+ }
162
+ for (const e of edgesFromPayload(payload)) {
163
+ edges.push({ from: ref.id, to: e.to, kind: e.kind, label: e.label });
164
+ const seen = found.get(e.to);
165
+ if (seen) {
166
+ if (!seen.via.includes(e.kind)) seen.via.push(e.kind);
167
+ continue;
168
+ }
169
+ const resolved = await resolveRepo(client, e.to);
170
+ if (!resolved) {
171
+ warnings.push(
172
+ `${ref.kind} ${ref.id} references repo ${e.to} via ${e.kind}, but the token cannot read it — ` +
173
+ `the pull is partial (PLAN.md §9, cross-repo permission)`,
174
+ );
175
+ continue;
176
+ }
177
+ resolved.via = [e.kind];
178
+ resolved.depth = depth + 1;
179
+ found.set(e.to, resolved);
180
+ next.push(resolved);
181
+ debug(`discovered ${resolved.kind} ${resolved.id} "${resolved.name}" via ${e.kind}`);
182
+ }
183
+ }
184
+ frontier = next;
185
+ }
186
+
187
+ const repos = [...found.values()].sort((a, b) => a.depth - b.depth || a.id - b.id);
188
+ step(`discovered ${repos.length} repo${repos.length === 1 ? '' : 's'} within depth ${opts.depth}`);
189
+ for (const w of warnings) warn(w);
190
+ return { repos, edges, warnings };
191
+ };
192
+
193
+ /**
194
+ * `pipeMapGraph` is kept for the graph view and for `hasHiddenAutomations` —
195
+ * never as the discovery mechanism. PLAN.md §2.2 and ask 7.
196
+ */
197
+ export const mapGraph = async (client: PipefyClient, repoId: number, depth = 2) => {
198
+ const data = await client.tryRaw<{ pipeMapGraph: unknown }>(
199
+ `query($id: ID!, $depth: Int) {
200
+ pipeMapGraph(pipeId: $id, depth: $depth) {
201
+ nodes { id name __typename hasHiddenAutomations hasHiddenIntegrations }
202
+ edges { from to }
203
+ }
204
+ }`,
205
+ { id: String(repoId), depth },
206
+ 'pipeMapGraph',
207
+ );
208
+ return data?.pipeMapGraph ?? null;
209
+ };
@@ -0,0 +1,380 @@
1
+ import { ENDPOINTS, type TokenProvider } from '../config.ts';
2
+ import { debug, warn } from '../util/log.ts';
3
+ import { retry } from '../util/pool.ts';
4
+ import type { Row } from '../model/payload.ts';
5
+
6
+ /**
7
+ * The internal-API adapter. PLAN.md §2.1 flags this as an unversioned endpoint
8
+ * with no compatibility contract, so it is isolated here, behind one module,
9
+ * and every entry point degrades to `null`/`unsupported` rather than throwing.
10
+ *
11
+ * Two things live only here — both taken from the Change Migrator recipes,
12
+ * which have been running them in production:
13
+ *
14
+ * 1. Automations. Public `getAutomations` returns a summary; the recipes use
15
+ * `loadAutomationToEdit` to get `action_params.field_map`, `searchFor`,
16
+ * `responseSchema`, `schedulerCron` and `aiParams` — everything a
17
+ * round trip needs and the summary omits.
18
+ * 2. Phase jumps. `GET/PUT /internal_api/settings/phases/:id`, set-replacing.
19
+ *
20
+ * Both accept the same personal API token as the public endpoint, so there is
21
+ * no session-cookie or CSRF subsystem here.
22
+ */
23
+
24
+ export type InternalStatus = 'unknown' | 'ok' | 'unavailable';
25
+
26
+ export class InternalApi {
27
+ private readonly tokenProvider: TokenProvider;
28
+ status: InternalStatus = 'unknown';
29
+ requestCount = 0;
30
+ /** Set once, so a broken endpoint produces one warning and not one per call. */
31
+ private warned = false;
32
+
33
+ constructor(token: string | TokenProvider) {
34
+ this.tokenProvider = typeof token === 'string' ? async () => token : token;
35
+ }
36
+
37
+ private async bearer(): Promise<string> {
38
+ return this.tokenProvider();
39
+ }
40
+
41
+ private markUnavailable(reason: string) {
42
+ this.status = 'unavailable';
43
+ if (!this.warned) {
44
+ this.warned = true;
45
+ warn(
46
+ `internal API unavailable (${reason}). Automation details and phase jumps ` +
47
+ `will be reported as unknown rather than empty — see PLAN.md §2.1.`,
48
+ );
49
+ }
50
+ }
51
+
52
+ /** GraphQL over the internal endpoint. Returns null when the endpoint fails. */
53
+ async gql<T>(query: string, variables: Record<string, unknown>, label: string): Promise<T | null> {
54
+ try {
55
+ const data = await retry(
56
+ async () => {
57
+ this.requestCount++;
58
+ const res = await fetch(ENDPOINTS.internal, {
59
+ method: 'POST',
60
+ headers: {
61
+ authorization: `Bearer ${await this.bearer()}`,
62
+ 'content-type': 'application/json',
63
+ accept: 'application/json',
64
+ },
65
+ body: JSON.stringify({ query, variables }),
66
+ });
67
+ const text = await res.text();
68
+ if (!res.ok) {
69
+ const err = new Error(`HTTP ${res.status}: ${text.slice(0, 160)}`);
70
+ (err as { retryable?: boolean }).retryable = res.status >= 500 || res.status === 429;
71
+ throw err;
72
+ }
73
+ const body = JSON.parse(text) as { data?: T; errors?: Array<{ message: string }> };
74
+ if (body.errors?.length) {
75
+ const err = new Error(body.errors.map((e) => e.message).join('; '));
76
+ (err as { retryable?: boolean }).retryable = false;
77
+ throw err;
78
+ }
79
+ return body.data ?? null;
80
+ },
81
+ { label },
82
+ );
83
+ if (data) this.status = 'ok';
84
+ return data;
85
+ } catch (e) {
86
+ debug(`internal gql ${label} failed: ${(e as Error).message}`);
87
+ this.markUnavailable(`${label}: ${(e as Error).message}`);
88
+ return null;
89
+ }
90
+ }
91
+
92
+ // ── Automations ────────────────────────────────────────────────────────────
93
+
94
+ /** Recipe `Pipe Data Treatment` step 156. */
95
+ async listAutomations(orgId: string, repoId: string | number): Promise<Row[] | null> {
96
+ const q = `
97
+ query getAutomations($orgId: ID!, $repoId: ID) {
98
+ automations(organizationId: $orgId, repoId: $repoId) {
99
+ id
100
+ name
101
+ action_id
102
+ event_id
103
+ created_at
104
+ active
105
+ action_repo: action_repo_v2 {
106
+ __typename
107
+ ... on Pipe { id }
108
+ ... on Table { id }
109
+ }
110
+ event_repo { __typename id }
111
+ }
112
+ }`;
113
+ const data = await this.gql<{ automations: Row[] }>(q, { orgId: String(orgId), repoId: String(repoId) }, 'listAutomations');
114
+ return data?.automations ?? null;
115
+ }
116
+
117
+ /**
118
+ * Recipe `Apply Changes` step 118 — the full editable definition. This is the
119
+ * only read path that returns `action_params.field_map`, `searchFor`,
120
+ * `responseSchema` and `aiParams`, which is why the reconstruction path
121
+ * cannot avoid the internal API for automations.
122
+ */
123
+ async loadAutomation(id: string | number): Promise<Row | null> {
124
+ const q = `
125
+ query loadAutomationToEdit($id: ID!) {
126
+ automation(id: $id) {
127
+ name
128
+ event_id
129
+ active
130
+ event_repo { id }
131
+ event_params {
132
+ toPhaseId: to_phase_id
133
+ fromPhaseId
134
+ inPhaseId
135
+ triggerFieldIds
136
+ kindOfSla
137
+ triggerAutomationId
138
+ }
139
+ action_id
140
+ action_repo: action_repo_v2 {
141
+ ... on Pipe { id uuid }
142
+ ... on Table { id uuid }
143
+ }
144
+ scheduler_frequency
145
+ schedulerCron { minute hour dayOfWeek dayOfMonth month }
146
+ searchFor { id field operation value }
147
+ responseSchema
148
+ action_params {
149
+ toPhaseId: to_phase_id
150
+ fieldsMapOrder: fields_map_order
151
+ email_template_id
152
+ fieldMap: field_map { fieldId value inputMode }
153
+ cardId: card_id
154
+ headers
155
+ body
156
+ url
157
+ httpMethod
158
+ authenticationKey
159
+ hasAuthenticationValue
160
+ authenticationAddTo
161
+ authenticationType
162
+ strategy
163
+ # taskParams is not in the recipe's query, and the omission is not
164
+ # cosmetic: every send_a_task automation came back with an empty
165
+ # action_params, so a reconstructed read lost its recipients and
166
+ # title, and a recreate would have rebuilt it blank. Introspected
167
+ # from AutomationActionParams on the internal endpoint.
168
+ taskParams { recipients title }
169
+ aiParams { fieldIds value skillsIds }
170
+ aiBehaviorParams
171
+ slaParams {
172
+ monday { startHour endHour enabled }
173
+ tuesday { startHour endHour enabled }
174
+ wednesday { startHour endHour enabled }
175
+ thursday { startHour endHour enabled }
176
+ friday { startHour endHour enabled }
177
+ saturday { startHour endHour enabled }
178
+ sunday { startHour endHour enabled }
179
+ timezone
180
+ holidays { date recurrence description }
181
+ }
182
+ }
183
+ condition {
184
+ expressions_structure
185
+ expressions { id field_address operation value structure_id }
186
+ }
187
+ }
188
+ }`;
189
+ const data = await this.gql<{ automation: Row | null }>(q, { id: String(id) }, `loadAutomation ${id}`);
190
+ return data?.automation ?? null;
191
+ }
192
+
193
+ /** Recipe `Apply Changes` step 121. `input` is the AutomationInput variable set. */
194
+ async createAutomation(vars: Record<string, unknown>): Promise<{ id: string } | null> {
195
+ const q = `
196
+ mutation createAutomation(
197
+ $name: String!, $action_id: ID!, $event_id: ID!,
198
+ $action_repo_id: ID, $event_repo_id: ID,
199
+ $event_params: AutomationEventParamsInput,
200
+ $action_params: AutomationActionParamsInput,
201
+ $condition: ConditionInput,
202
+ $scheduler_frequency: String,
203
+ $searchFor: [SearchConditionInput],
204
+ $schedulerCron: CronInput,
205
+ $responseSchema: JSON
206
+ ) {
207
+ createAutomation(input: {
208
+ name: $name, action_id: $action_id, event_id: $event_id,
209
+ action_repo_id: $action_repo_id, event_repo_id: $event_repo_id,
210
+ event_params: $event_params, action_params: $action_params,
211
+ condition: $condition, scheduler_frequency: $scheduler_frequency,
212
+ searchFor: $searchFor, schedulerCron: $schedulerCron,
213
+ responseSchema: $responseSchema
214
+ }) {
215
+ automation { id }
216
+ error_details { object_key messages }
217
+ }
218
+ }`;
219
+ const data = await this.gql<{
220
+ createAutomation: { automation: { id: string } | null; error_details?: Array<{ object_key: string; messages: string[] }> };
221
+ }>(q, vars, 'createAutomation');
222
+ const payload = data?.createAutomation;
223
+ if (!payload) return null;
224
+ if (payload.error_details?.length) {
225
+ const detail = payload.error_details.map((d) => `${d.object_key}: ${d.messages.join(', ')}`).join(' | ');
226
+ throw new Error(`createAutomation rejected — ${detail}`);
227
+ }
228
+ return payload.automation;
229
+ }
230
+
231
+ /** Recipe `Apply Changes` step 126. */
232
+ async deleteAutomation(id: string | number): Promise<boolean> {
233
+ const q = `
234
+ mutation deleteAutomation($id: ID!) {
235
+ deleteAutomation(input: { id: $id }) { success }
236
+ }`;
237
+ const data = await this.gql<{ deleteAutomation: { success: boolean } }>(q, { id: String(id) }, `deleteAutomation ${id}`);
238
+ return Boolean(data?.deleteAutomation?.success);
239
+ }
240
+
241
+ // ── Phase jumps ────────────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * `GET /internal_api/settings/phases/:id` — recipe step 23.
245
+ *
246
+ * **The GET and the PUT answer in different shapes.** Both captured live:
247
+ *
248
+ * GET { data: { attributes: { name, phases: [[id, name, done], …],
249
+ * jump_targets: [344054275] } } }
250
+ * PUT { data: { name, back_phase_ids: [], next_phase_ids: [344054275] } }
251
+ *
252
+ * The GET's `jump_targets` is one merged set, which is exactly what the
253
+ * payload's `phase_jumps` array holds. Reading it as `data.next_phase_ids` —
254
+ * the shape PLAN.md §2.1 recorded, from the PUT — yields an empty set for
255
+ * every phase without any error, and a diff that then proposes deleting every
256
+ * jump in the pipe.
257
+ */
258
+ async getPhaseSettings(
259
+ phaseId: string | number,
260
+ ): Promise<{ jump_targets: number[]; phases: Array<{ id: number; name: string; done: boolean }> } | null> {
261
+ try {
262
+ const res = await retry(
263
+ async () => {
264
+ this.requestCount++;
265
+ const r = await fetch(`${ENDPOINTS.internalSettings}/phases/${phaseId}`, {
266
+ method: 'GET',
267
+ headers: { authorization: `Bearer ${await this.bearer()}`, accept: 'application/json' },
268
+ });
269
+ if (!r.ok) {
270
+ const err = new Error(`HTTP ${r.status}`);
271
+ (err as { retryable?: boolean }).retryable = r.status >= 500 || r.status === 429;
272
+ throw err;
273
+ }
274
+ return (await r.json()) as {
275
+ data?: {
276
+ attributes?: { jump_targets?: number[]; phases?: Array<[number, string, boolean]> };
277
+ /** The PUT-style shape, accepted in case a deployment answers with it here. */
278
+ jump_targets?: number[];
279
+ next_phase_ids?: number[];
280
+ back_phase_ids?: number[];
281
+ };
282
+ };
283
+ },
284
+ { label: `getPhaseSettings ${phaseId}` },
285
+ );
286
+
287
+ const d = res.data ?? {};
288
+ const attrs = d.attributes ?? {};
289
+ const targets = attrs.jump_targets ?? d.jump_targets ?? [...(d.next_phase_ids ?? []), ...(d.back_phase_ids ?? [])];
290
+
291
+ this.status = 'ok';
292
+ return {
293
+ jump_targets: [...new Set(targets.map(Number).filter((n) => Number.isFinite(n)))],
294
+ phases: (attrs.phases ?? []).map(([id, name, done]) => ({ id: Number(id), name: String(name), done: Boolean(done) })),
295
+ };
296
+ } catch (e) {
297
+ debug(`getPhaseSettings ${phaseId} failed: ${(e as Error).message}`);
298
+ this.markUnavailable(`getPhaseSettings: ${(e as Error).message}`);
299
+ return null;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * `PUT /internal_api/settings/phases/:id` with the complete desired jump set.
305
+ * Recipe step 24 does the same thing in Python. Set-replacing, so we send the
306
+ * whole set and assert on the echoed `next_phase_ids` rather than on the HTTP
307
+ * status — PLAN.md §2.1.
308
+ */
309
+ async setPhaseJumpTargets(
310
+ phaseId: string | number,
311
+ targetPhaseIds: Array<string | number>,
312
+ ): Promise<{ ok: boolean; echoed: number[]; error?: string }> {
313
+ const params = new URLSearchParams();
314
+ if (targetPhaseIds.length === 0) {
315
+ // An empty set still has to be expressible, or "remove the last jump"
316
+ // becomes impossible. The form convention for an empty array is a single
317
+ // blank entry.
318
+ params.append('phase[jump_target_ids][]', '');
319
+ }
320
+ for (const t of targetPhaseIds) params.append('phase[jump_target_ids][]', String(t));
321
+
322
+ try {
323
+ const res = await retry(
324
+ async () => {
325
+ this.requestCount++;
326
+ const r = await fetch(`${ENDPOINTS.internalSettings}/phases/${phaseId}`, {
327
+ method: 'PUT',
328
+ headers: {
329
+ authorization: `Bearer ${await this.bearer()}`,
330
+ 'content-type': 'application/x-www-form-urlencoded',
331
+ accept: 'application/json',
332
+ },
333
+ body: params.toString(),
334
+ });
335
+ const text = await r.text();
336
+ if (!r.ok) {
337
+ const err = new Error(`HTTP ${r.status}: ${text.slice(0, 160)}`);
338
+ (err as { retryable?: boolean }).retryable = r.status >= 500 || r.status === 429;
339
+ throw err;
340
+ }
341
+ return JSON.parse(text) as {
342
+ data?: {
343
+ next_phase_ids?: number[];
344
+ back_phase_ids?: number[];
345
+ jump_targets?: number[];
346
+ attributes?: { jump_targets?: number[] };
347
+ };
348
+ };
349
+ },
350
+ { label: `setPhaseJumpTargets ${phaseId}` },
351
+ );
352
+
353
+ /**
354
+ * The echo is the union of both directions.
355
+ *
356
+ * `next_phase_ids` alone is forward-only, so a jump to an *earlier* phase
357
+ * echoes back as an empty next list even though the write succeeded — which
358
+ * this assertion reported as a failed write. Asserting on the union is what
359
+ * the sent set actually means.
360
+ */
361
+ const d = res.data ?? {};
362
+ const echoed = [
363
+ ...(d.attributes?.jump_targets ?? []),
364
+ ...(d.jump_targets ?? []),
365
+ ...(d.next_phase_ids ?? []),
366
+ ...(d.back_phase_ids ?? []),
367
+ ].map(Number);
368
+ const want = [...new Set(targetPhaseIds.map(Number))].sort((a, b) => a - b);
369
+ const got = [...new Set(echoed)].sort((a, b) => a - b);
370
+ const ok = want.length === got.length && want.every((v, i) => v === got[i]);
371
+ this.status = 'ok';
372
+ return ok
373
+ ? { ok, echoed }
374
+ : { ok, echoed, error: `echo mismatch: wanted [${want.join(',')}], endpoint reports [${got.join(',')}]` };
375
+ } catch (e) {
376
+ this.markUnavailable(`setPhaseJumpTargets: ${(e as Error).message}`);
377
+ return { ok: false, echoed: [], error: (e as Error).message };
378
+ }
379
+ }
380
+ }