@pipefy/pipefy-process-coder 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -262
  2. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -247
  3. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -589
  4. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -1391
  5. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -623
  6. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -636
  7. package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -3642
  8. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -863
  9. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -799
  10. package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -42
  11. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -581
  12. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -503
  13. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -562
  14. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -557
  15. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -609
  16. package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -146
  17. package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -127
  18. package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -91
  19. package/AGENTS.md +465 -456
  20. package/README.md +825 -807
  21. package/bin/pipe.js +25 -4
  22. package/dist/apply/adopt.js +128 -0
  23. package/dist/apply/agentops.js +42 -0
  24. package/dist/apply/compile.js +722 -0
  25. package/dist/apply/execute.js +277 -0
  26. package/dist/apply/flowops.js +293 -0
  27. package/dist/apply/idmap.js +233 -0
  28. package/dist/apply/mutations.js +704 -0
  29. package/dist/apply/registry.js +348 -0
  30. package/dist/apply/types.js +18 -0
  31. package/dist/cli/args.js +79 -0
  32. package/dist/cli.js +187 -0
  33. package/dist/codec/flow.js +164 -0
  34. package/dist/codec/pack.js +88 -0
  35. package/dist/codec/roundtrip.js +86 -0
  36. package/dist/codec/unpack.js +181 -0
  37. package/dist/commands/agents.js +145 -0
  38. package/dist/commands/apply.js +982 -0
  39. package/dist/commands/context.js +79 -0
  40. package/dist/commands/create.js +63 -0
  41. package/dist/commands/diff.js +294 -0
  42. package/dist/commands/flows.js +348 -0
  43. package/dist/commands/misc.js +528 -0
  44. package/dist/commands/plan.js +301 -0
  45. package/dist/commands/pull.js +511 -0
  46. package/dist/commands/runs.js +74 -0
  47. package/dist/commands/skills.js +131 -0
  48. package/dist/commands/verify.js +205 -0
  49. package/dist/config.js +125 -0
  50. package/dist/diff/agents.js +80 -0
  51. package/dist/diff/diff.js +983 -0
  52. package/dist/diff/flow.js +258 -0
  53. package/{src/diff/html.ts → dist/diff/html.js} +274 -322
  54. package/dist/diff/render.js +90 -0
  55. package/dist/model/payload.js +84 -0
  56. package/dist/model/tree.js +7 -0
  57. package/dist/model/volatile.js +54 -0
  58. package/dist/pipefy/agents.js +122 -0
  59. package/dist/pipefy/automations.js +155 -0
  60. package/dist/pipefy/capability.js +84 -0
  61. package/dist/pipefy/client.js +204 -0
  62. package/dist/pipefy/discovery.js +148 -0
  63. package/dist/pipefy/internal.js +315 -0
  64. package/dist/pipefy/ipaas.js +269 -0
  65. package/dist/pipefy/reconstruct.js +674 -0
  66. package/dist/pipefy/reference.js +172 -0
  67. package/dist/pipefy/snapshot.js +185 -0
  68. package/dist/pipefy/toolkit.js +158 -0
  69. package/dist/report/integrations.js +165 -0
  70. package/dist/report/run.js +355 -0
  71. package/dist/util/fsx.js +50 -0
  72. package/dist/util/git.js +25 -0
  73. package/dist/util/json.js +56 -0
  74. package/dist/util/log.js +69 -0
  75. package/dist/util/pool.js +42 -0
  76. package/dist/util/slug.js +23 -0
  77. package/dist/util/tui.js +287 -0
  78. package/dist/validate/index.js +86 -0
  79. package/dist/validate/integrity.js +370 -0
  80. package/dist/validate/reference.js +120 -0
  81. package/dist/validate/schema.js +278 -0
  82. package/dist/workspace/agents.js +245 -0
  83. package/{src/workspace/docs.ts → dist/workspace/docs.js} +367 -407
  84. package/dist/workspace/flows.js +149 -0
  85. package/dist/workspace/layout.js +146 -0
  86. package/dist/workspace/lock.js +83 -0
  87. package/dist/workspace/read.js +168 -0
  88. package/dist/workspace/reference.js +24 -0
  89. package/dist/workspace/stamp.js +234 -0
  90. package/dist/workspace/write.js +194 -0
  91. package/package.json +37 -35
  92. package/src/apply/adopt.ts +0 -150
  93. package/src/apply/agentops.ts +0 -71
  94. package/src/apply/compile.ts +0 -875
  95. package/src/apply/execute.ts +0 -336
  96. package/src/apply/flowops.ts +0 -399
  97. package/src/apply/idmap.ts +0 -241
  98. package/src/apply/mutations.ts +0 -955
  99. package/src/apply/registry.ts +0 -430
  100. package/src/apply/types.ts +0 -134
  101. package/src/cli/args.ts +0 -88
  102. package/src/cli.ts +0 -211
  103. package/src/codec/flow.ts +0 -199
  104. package/src/codec/pack.ts +0 -103
  105. package/src/codec/roundtrip.ts +0 -94
  106. package/src/codec/unpack.ts +0 -198
  107. package/src/commands/agents.ts +0 -184
  108. package/src/commands/apply.ts +0 -1144
  109. package/src/commands/context.ts +0 -119
  110. package/src/commands/create.ts +0 -79
  111. package/src/commands/diff.ts +0 -314
  112. package/src/commands/flows.ts +0 -414
  113. package/src/commands/misc.ts +0 -644
  114. package/src/commands/plan.ts +0 -331
  115. package/src/commands/pull.ts +0 -567
  116. package/src/commands/runs.ts +0 -83
  117. package/src/commands/skills.ts +0 -137
  118. package/src/commands/verify.ts +0 -253
  119. package/src/config.ts +0 -168
  120. package/src/diff/agents.ts +0 -122
  121. package/src/diff/diff.ts +0 -1130
  122. package/src/diff/flow.ts +0 -318
  123. package/src/diff/render.ts +0 -101
  124. package/src/model/payload.ts +0 -154
  125. package/src/model/tree.ts +0 -99
  126. package/src/model/volatile.ts +0 -55
  127. package/src/pipefy/agents.ts +0 -165
  128. package/src/pipefy/automations.ts +0 -219
  129. package/src/pipefy/capability.ts +0 -119
  130. package/src/pipefy/client.ts +0 -267
  131. package/src/pipefy/discovery.ts +0 -209
  132. package/src/pipefy/internal.ts +0 -380
  133. package/src/pipefy/ipaas.ts +0 -365
  134. package/src/pipefy/reconstruct.ts +0 -775
  135. package/src/pipefy/reference.ts +0 -251
  136. package/src/pipefy/snapshot.ts +0 -245
  137. package/src/pipefy/toolkit.ts +0 -200
  138. package/src/report/integrations.ts +0 -231
  139. package/src/report/run.ts +0 -475
  140. package/src/util/fsx.ts +0 -45
  141. package/src/util/git.ts +0 -32
  142. package/src/util/json.ts +0 -55
  143. package/src/util/log.ts +0 -76
  144. package/src/util/pool.ts +0 -48
  145. package/src/util/slug.ts +0 -26
  146. package/src/util/tui.ts +0 -335
  147. package/src/validate/index.ts +0 -123
  148. package/src/validate/integrity.ts +0 -387
  149. package/src/validate/reference.ts +0 -136
  150. package/src/validate/schema.ts +0 -328
  151. package/src/workspace/agents.ts +0 -290
  152. package/src/workspace/flows.ts +0 -191
  153. package/src/workspace/layout.ts +0 -165
  154. package/src/workspace/lock.ts +0 -148
  155. package/src/workspace/read.ts +0 -165
  156. package/src/workspace/reference.ts +0 -24
  157. package/src/workspace/stamp.ts +0 -301
  158. package/src/workspace/write.ts +0 -225
  159. /package/{src → dist}/pipefy/toolkit_bearer.py +0 -0
@@ -0,0 +1,204 @@
1
+ import { ENDPOINTS } from "../config.js";
2
+ import { debug } from "../util/log.js";
3
+ import { retry } from "../util/pool.js";
4
+ export class GraphQLRequestError extends Error {
5
+ errors;
6
+ retryable;
7
+ status;
8
+ constructor(message, errors, status, retryable) {
9
+ super(message);
10
+ this.name = 'GraphQLRequestError';
11
+ this.errors = errors;
12
+ this.status = status;
13
+ this.retryable = retryable;
14
+ }
15
+ }
16
+ const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
17
+ /** Rate limiting and transient upstream faults are retryable; a bad query is not. */
18
+ const isRetryableMessage = (m) => /rate limit|too many requests|timeout|timed out|temporarily|try again|internal server error/i.test(m);
19
+ export class PipefyClient {
20
+ tokenProvider;
21
+ endpoint;
22
+ /** Every request, for `--stats` and for measuring the rate-limit ceiling. */
23
+ requestCount = 0;
24
+ retryCount = 0;
25
+ /** Times a 401 forced a credential refresh. Surfaced by `pipe doctor`. */
26
+ reauthCount = 0;
27
+ typeFieldCache = new Map();
28
+ inputFieldCache = new Map();
29
+ requiredInputCache = new Map();
30
+ mutationInputTypeCache = new Map();
31
+ constructor(opts) {
32
+ this.tokenProvider = typeof opts.token === 'string' ? async () => opts.token : opts.token;
33
+ this.endpoint = opts.endpoint ?? ENDPOINTS.graphql;
34
+ }
35
+ /** The current bearer, refreshed only when something rejected the last one. */
36
+ async token(forceRefresh = false) {
37
+ if (forceRefresh)
38
+ this.reauthCount++;
39
+ return this.tokenProvider({ forceRefresh });
40
+ }
41
+ async raw(query, variables, label) {
42
+ let refreshed = false;
43
+ return retry(async () => {
44
+ this.requestCount++;
45
+ const res = await fetch(this.endpoint, {
46
+ method: 'POST',
47
+ headers: {
48
+ authorization: `Bearer ${await this.token()}`,
49
+ 'content-type': 'application/json',
50
+ accept: 'application/json',
51
+ },
52
+ body: JSON.stringify({ query, variables: variables ?? {} }),
53
+ });
54
+ /**
55
+ * An expired OAuth access token looks like a 401. Refresh once and let
56
+ * the retry loop send it again; a second 401 is a real permission
57
+ * problem and must not spin.
58
+ */
59
+ if ((res.status === 401 || res.status === 403) && !refreshed) {
60
+ refreshed = true;
61
+ await this.token(true);
62
+ const err = new Error(`HTTP ${res.status} — credential refreshed, retrying`);
63
+ err.retryable = true;
64
+ throw err;
65
+ }
66
+ const text = await res.text();
67
+ let body = {};
68
+ try {
69
+ body = text ? JSON.parse(text) : {};
70
+ }
71
+ catch {
72
+ const retryable = RETRYABLE_STATUS.has(res.status);
73
+ throw new GraphQLRequestError(`HTTP ${res.status} from ${this.endpoint}: ${text.slice(0, 200)}`, [], res.status, retryable);
74
+ }
75
+ if (body.errors?.length) {
76
+ const msg = body.errors.map((e) => e.message).join('; ');
77
+ const retryable = RETRYABLE_STATUS.has(res.status) || isRetryableMessage(msg);
78
+ throw new GraphQLRequestError(msg, body.errors, res.status, retryable);
79
+ }
80
+ if (!res.ok) {
81
+ throw new GraphQLRequestError(`HTTP ${res.status} from ${this.endpoint}`, [], res.status, RETRYABLE_STATUS.has(res.status));
82
+ }
83
+ if (body.data === undefined || body.data === null) {
84
+ throw new GraphQLRequestError('GraphQL response had no data', [], res.status, false);
85
+ }
86
+ return body.data;
87
+ }, { label: label ?? 'graphql' });
88
+ }
89
+ /** A query whose failure is a fact about the endpoint, not an error. */
90
+ async tryRaw(query, variables, label) {
91
+ try {
92
+ return await this.raw(query, variables, label);
93
+ }
94
+ catch (e) {
95
+ debug(`probe failed (${label ?? 'unnamed'}): ${e.message}`);
96
+ return null;
97
+ }
98
+ }
99
+ /**
100
+ * Which fields a type actually has.
101
+ *
102
+ * The reconstruction path (reconstruct.ts) asks for columns that PLAN.md marks
103
+ * unverified. Rather than sending a query that fails wholesale on one unknown
104
+ * field, we introspect once and ask only for what exists. One cheap query
105
+ * buys a read path that degrades field-by-field instead of all-or-nothing.
106
+ */
107
+ async typeFields(typeName) {
108
+ if (this.typeFieldCache.has(typeName))
109
+ return this.typeFieldCache.get(typeName) ?? null;
110
+ const data = await this.tryRaw('query($n:String!){ __type(name:$n){ fields{ name } } }', { n: typeName }, `introspect ${typeName}`);
111
+ const fields = data?.__type?.fields;
112
+ const set = fields ? new Set(fields.map((f) => f.name)) : null;
113
+ this.typeFieldCache.set(typeName, set);
114
+ return set;
115
+ }
116
+ /** Whether a mutation exists — the basis of capability detection (PLAN.md §1.2). */
117
+ async hasMutation(name) {
118
+ const fields = await this.typeFields('Mutation');
119
+ if (!fields)
120
+ return false;
121
+ return fields.has(name);
122
+ }
123
+ /**
124
+ * The named type of a mutation's `input` argument.
125
+ *
126
+ * Never guessed. The registry documents traps like `createFieldConditionInput`
127
+ * being lowercase-c while `UpdateFieldConditionInput` is not; asking the schema
128
+ * removes a whole class of "mutation exists, input type name wrong" failures.
129
+ */
130
+ async mutationInputType(mutation) {
131
+ if (this.mutationInputTypeCache.has(mutation))
132
+ return this.mutationInputTypeCache.get(mutation) ?? null;
133
+ const data = await this.tryRaw(`query {
134
+ __type(name: "Mutation") {
135
+ fields {
136
+ name
137
+ args { name type { kind name ofType { kind name ofType { kind name } } } }
138
+ }
139
+ }
140
+ }`, undefined, 'introspect Mutation args');
141
+ const field = data?.__type?.fields?.find((f) => f.name === mutation);
142
+ const arg = field?.args.find((a) => a.name === 'input') ?? field?.args[0];
143
+ const named = arg ? unwrapTypeName(arg.type) : null;
144
+ this.mutationInputTypeCache.set(mutation, named);
145
+ return named;
146
+ }
147
+ /**
148
+ * Input fields that are NON_NULL with no default — the ones a request must
149
+ * carry a value for.
150
+ *
151
+ * CreatePipeRelationInput makes every boolean required, including
152
+ * autoFillFieldEnabled, which the snapshot payload has no column for at all.
153
+ * Sent without it the mutation is rejected mid-apply; knowing up front turns
154
+ * that into something `pipe plan` can say before anything is written.
155
+ */
156
+ async requiredInputFields(typeName) {
157
+ const cached = this.requiredInputCache.get(typeName);
158
+ if (cached)
159
+ return cached;
160
+ const data = await this.tryRaw('query($n:String!){ __type(name:$n){ inputFields { name defaultValue type { kind } } } }', { n: typeName }, `introspect required ${typeName}`);
161
+ const req = new Set();
162
+ for (const fld of data?.__type?.inputFields ?? []) {
163
+ if (fld.type.kind === 'NON_NULL' && fld.defaultValue === null)
164
+ req.add(fld.name);
165
+ }
166
+ this.requiredInputCache.set(typeName, req);
167
+ return req;
168
+ }
169
+ /** The fields an input object type accepts. */
170
+ async inputFields(typeName) {
171
+ if (this.inputFieldCache.has(typeName))
172
+ return this.inputFieldCache.get(typeName) ?? null;
173
+ const data = await this.tryRaw('query($n:String!){ __type(name:$n){ inputFields{ name } } }', { n: typeName }, `introspect input ${typeName}`);
174
+ const fields = data?.__type?.inputFields;
175
+ const set = fields ? new Set(fields.map((f) => f.name)) : null;
176
+ this.inputFieldCache.set(typeName, set);
177
+ return set;
178
+ }
179
+ /**
180
+ * Filter a wanted selection down to what the type has. `selection` entries may
181
+ * be a bare field (`"label"`) or a field with a sub-selection
182
+ * (`["connectedRepo", "{ id name }"]`).
183
+ */
184
+ async prune(typeName, selection) {
185
+ const have = await this.typeFields(typeName);
186
+ const keep = [];
187
+ for (const s of selection) {
188
+ const [name, sub] = Array.isArray(s) ? s : [s, ''];
189
+ if (have && !have.has(name)) {
190
+ debug(`skipping ${typeName}.${name} — not in schema`);
191
+ continue;
192
+ }
193
+ keep.push(sub ? `${name} ${sub}` : name);
194
+ }
195
+ return keep.join('\n ');
196
+ }
197
+ }
198
+ /** Unwrap NON_NULL / LIST wrappers down to the named type. */
199
+ const unwrapTypeName = (t) => {
200
+ let cur = t;
201
+ while (cur && !cur.name)
202
+ cur = cur.ofType ?? null;
203
+ return cur?.name ?? null;
204
+ };
@@ -0,0 +1,148 @@
1
+ import { debug, step, warn } from "../util/log.js";
2
+ import { rel } from "../model/payload.js";
3
+ const asNum = (v) => {
4
+ if (v === null || v === undefined || v === '')
5
+ return null;
6
+ const n = Number(v);
7
+ return Number.isFinite(n) ? n : null;
8
+ };
9
+ /** The three sources, applied to one already-read payload. */
10
+ export const edgesFromPayload = (p) => {
11
+ const self = Number(p.id);
12
+ const out = [];
13
+ // 1. pipe_relations
14
+ for (const r of rel(p, 'pipe_relations')) {
15
+ for (const key of ['parent_id', 'child_id']) {
16
+ const id = asNum(r[key]);
17
+ if (id !== null && id !== self)
18
+ out.push({ to: id, kind: 'pipe_relation', label: String(r['name'] ?? '') });
19
+ }
20
+ }
21
+ // 2. connector fields — the only source that finds the test pipe's two
22
+ // connections, and the reason this function exists.
23
+ for (const f of rel(p, 'fields')) {
24
+ if (f['type_id'] !== 'connector')
25
+ continue;
26
+ const id = asNum(f['connected_pipe_id']);
27
+ if (id !== null && id !== self)
28
+ out.push({ to: id, kind: 'connector_field', label: String(f['label'] ?? '') });
29
+ }
30
+ // 3. cross-repo automations
31
+ for (const a of rel(p, 'automations')) {
32
+ for (const key of ['event_repo_id', 'action_repo_id']) {
33
+ const id = asNum(a[key]);
34
+ if (id !== null && id !== self)
35
+ out.push({ to: id, kind: 'automation', label: String(a['name'] ?? '') });
36
+ }
37
+ }
38
+ return out;
39
+ };
40
+ /**
41
+ * Resolve a numeric repo id to what it actually is.
42
+ *
43
+ * **Neither root field discriminates.** Verified live: `pipe(id:)` and
44
+ * `table(id:)` both answer for *every* repo id, and both return plausible data —
45
+ * `pipe.phases` on a database returns its status phases, and `table.table_fields`
46
+ * on a pipe returns its start-form fields. A repo is one object viewed two ways.
47
+ * Trying `pipe` first and taking the win, as an earlier version did, classified
48
+ * every database as a pipe.
49
+ *
50
+ * `Table.url` is the real discriminator: a pipe lives at `/pipes/<id>` and a
51
+ * database at `/apollo_databases/<id>`. One query settles both the kind and
52
+ * PLAN.md §2.2's id duality, since `Table` carries the slug as `id` and the
53
+ * numeric form as `internal_id`.
54
+ */
55
+ /**
56
+ * Resolve one repo id, in **either** spelling.
57
+ *
58
+ * `table(id:)` accepts a database's numeric internal id and its api id (a slug
59
+ * like `dzmxcuxz`) and always answers with the slug — measured both ways. So
60
+ * `pipe pull 302908356` and `pipe pull 3LhkZu_h` must reach the same database,
61
+ * and the `RepoRef` this returns normalises to the numeric id either way, which
62
+ * is what keeps one database from being pulled twice under two identities.
63
+ */
64
+ export const resolveRepo = async (client, id) => {
65
+ const res = await client.tryRaw(`query($id: ID!) { table(id: $id) { id internal_id name uuid url } }`, { id: String(id) }, `resolve repo ${id}`);
66
+ const repo = res?.table;
67
+ if (!repo)
68
+ return null;
69
+ const url = String(repo.url ?? '');
70
+ const kind = /apollo_databases/.test(url) ? 'table' : 'pipe';
71
+ debug(`repo ${id} is a ${kind} (${url || 'no url'})`);
72
+ return {
73
+ kind,
74
+ id: Number(repo.internal_id ?? id),
75
+ /** The slug for a database, the numeric string for a pipe. */
76
+ apiId: kind === 'table' ? String(repo.id) : String(repo.internal_id ?? id),
77
+ name: repo.name,
78
+ uuid: repo.uuid ?? null,
79
+ via: [],
80
+ depth: 0,
81
+ };
82
+ };
83
+ /**
84
+ * Walk outward from a root repo. `read` is injected so discovery works
85
+ * identically whether payloads come from snapshots or from reconstruction.
86
+ */
87
+ export const discover = async (client, rootId, opts) => {
88
+ const found = new Map();
89
+ const edges = [];
90
+ const warnings = [];
91
+ const root = await resolveRepo(client, rootId);
92
+ if (!root)
93
+ throw new Error(`repo ${rootId} is neither a readable pipe nor a readable table`);
94
+ root.via = ['root'];
95
+ found.set(rootId, root);
96
+ let frontier = [root];
97
+ for (let depth = 0; depth <= opts.depth && frontier.length; depth++) {
98
+ const next = [];
99
+ for (const ref of frontier) {
100
+ if (depth === opts.depth)
101
+ break;
102
+ const payload = await opts.read(ref);
103
+ if (!payload) {
104
+ warnings.push(`${ref.kind} ${ref.id} (${ref.name ?? '?'}) could not be read — its connections are unexplored`);
105
+ continue;
106
+ }
107
+ for (const e of edgesFromPayload(payload)) {
108
+ edges.push({ from: ref.id, to: e.to, kind: e.kind, label: e.label });
109
+ const seen = found.get(e.to);
110
+ if (seen) {
111
+ if (!seen.via.includes(e.kind))
112
+ seen.via.push(e.kind);
113
+ continue;
114
+ }
115
+ const resolved = await resolveRepo(client, e.to);
116
+ if (!resolved) {
117
+ warnings.push(`${ref.kind} ${ref.id} references repo ${e.to} via ${e.kind}, but the token cannot read it — ` +
118
+ `the pull is partial (PLAN.md §9, cross-repo permission)`);
119
+ continue;
120
+ }
121
+ resolved.via = [e.kind];
122
+ resolved.depth = depth + 1;
123
+ found.set(e.to, resolved);
124
+ next.push(resolved);
125
+ debug(`discovered ${resolved.kind} ${resolved.id} "${resolved.name}" via ${e.kind}`);
126
+ }
127
+ }
128
+ frontier = next;
129
+ }
130
+ const repos = [...found.values()].sort((a, b) => a.depth - b.depth || a.id - b.id);
131
+ step(`discovered ${repos.length} repo${repos.length === 1 ? '' : 's'} within depth ${opts.depth}`);
132
+ for (const w of warnings)
133
+ warn(w);
134
+ return { repos, edges, warnings };
135
+ };
136
+ /**
137
+ * `pipeMapGraph` is kept for the graph view and for `hasHiddenAutomations` —
138
+ * never as the discovery mechanism. PLAN.md §2.2 and ask 7.
139
+ */
140
+ export const mapGraph = async (client, repoId, depth = 2) => {
141
+ const data = await client.tryRaw(`query($id: ID!, $depth: Int) {
142
+ pipeMapGraph(pipeId: $id, depth: $depth) {
143
+ nodes { id name __typename hasHiddenAutomations hasHiddenIntegrations }
144
+ edges { from to }
145
+ }
146
+ }`, { id: String(repoId), depth }, 'pipeMapGraph');
147
+ return data?.pipeMapGraph ?? null;
148
+ };
@@ -0,0 +1,315 @@
1
+ import { ENDPOINTS } from "../config.js";
2
+ import { debug, warn } from "../util/log.js";
3
+ import { retry } from "../util/pool.js";
4
+ export class InternalApi {
5
+ tokenProvider;
6
+ status = 'unknown';
7
+ requestCount = 0;
8
+ /** Set once, so a broken endpoint produces one warning and not one per call. */
9
+ warned = false;
10
+ constructor(token) {
11
+ this.tokenProvider = typeof token === 'string' ? async () => token : token;
12
+ }
13
+ async bearer() {
14
+ return this.tokenProvider();
15
+ }
16
+ markUnavailable(reason) {
17
+ this.status = 'unavailable';
18
+ if (!this.warned) {
19
+ this.warned = true;
20
+ warn(`internal API unavailable (${reason}). Automation details and phase jumps ` +
21
+ `will be reported as unknown rather than empty — see PLAN.md §2.1.`);
22
+ }
23
+ }
24
+ /** GraphQL over the internal endpoint. Returns null when the endpoint fails. */
25
+ async gql(query, variables, label) {
26
+ try {
27
+ const data = await retry(async () => {
28
+ this.requestCount++;
29
+ const res = await fetch(ENDPOINTS.internal, {
30
+ method: 'POST',
31
+ headers: {
32
+ authorization: `Bearer ${await this.bearer()}`,
33
+ 'content-type': 'application/json',
34
+ accept: 'application/json',
35
+ },
36
+ body: JSON.stringify({ query, variables }),
37
+ });
38
+ const text = await res.text();
39
+ if (!res.ok) {
40
+ const err = new Error(`HTTP ${res.status}: ${text.slice(0, 160)}`);
41
+ err.retryable = res.status >= 500 || res.status === 429;
42
+ throw err;
43
+ }
44
+ const body = JSON.parse(text);
45
+ if (body.errors?.length) {
46
+ const err = new Error(body.errors.map((e) => e.message).join('; '));
47
+ err.retryable = false;
48
+ throw err;
49
+ }
50
+ return body.data ?? null;
51
+ }, { label });
52
+ if (data)
53
+ this.status = 'ok';
54
+ return data;
55
+ }
56
+ catch (e) {
57
+ debug(`internal gql ${label} failed: ${e.message}`);
58
+ this.markUnavailable(`${label}: ${e.message}`);
59
+ return null;
60
+ }
61
+ }
62
+ // ── Automations ────────────────────────────────────────────────────────────
63
+ /** Recipe `Pipe Data Treatment` step 156. */
64
+ async listAutomations(orgId, repoId) {
65
+ const q = `
66
+ query getAutomations($orgId: ID!, $repoId: ID) {
67
+ automations(organizationId: $orgId, repoId: $repoId) {
68
+ id
69
+ name
70
+ action_id
71
+ event_id
72
+ created_at
73
+ active
74
+ action_repo: action_repo_v2 {
75
+ __typename
76
+ ... on Pipe { id }
77
+ ... on Table { id }
78
+ }
79
+ event_repo { __typename id }
80
+ }
81
+ }`;
82
+ const data = await this.gql(q, { orgId: String(orgId), repoId: String(repoId) }, 'listAutomations');
83
+ return data?.automations ?? null;
84
+ }
85
+ /**
86
+ * Recipe `Apply Changes` step 118 — the full editable definition. This is the
87
+ * only read path that returns `action_params.field_map`, `searchFor`,
88
+ * `responseSchema` and `aiParams`, which is why the reconstruction path
89
+ * cannot avoid the internal API for automations.
90
+ */
91
+ async loadAutomation(id) {
92
+ const q = `
93
+ query loadAutomationToEdit($id: ID!) {
94
+ automation(id: $id) {
95
+ name
96
+ event_id
97
+ active
98
+ event_repo { id }
99
+ event_params {
100
+ toPhaseId: to_phase_id
101
+ fromPhaseId
102
+ inPhaseId
103
+ triggerFieldIds
104
+ kindOfSla
105
+ triggerAutomationId
106
+ }
107
+ action_id
108
+ action_repo: action_repo_v2 {
109
+ ... on Pipe { id uuid }
110
+ ... on Table { id uuid }
111
+ }
112
+ scheduler_frequency
113
+ schedulerCron { minute hour dayOfWeek dayOfMonth month }
114
+ searchFor { id field operation value }
115
+ responseSchema
116
+ action_params {
117
+ toPhaseId: to_phase_id
118
+ fieldsMapOrder: fields_map_order
119
+ email_template_id
120
+ fieldMap: field_map { fieldId value inputMode }
121
+ cardId: card_id
122
+ headers
123
+ body
124
+ url
125
+ httpMethod
126
+ authenticationKey
127
+ hasAuthenticationValue
128
+ authenticationAddTo
129
+ authenticationType
130
+ strategy
131
+ # taskParams is not in the recipe's query, and the omission is not
132
+ # cosmetic: every send_a_task automation came back with an empty
133
+ # action_params, so a reconstructed read lost its recipients and
134
+ # title, and a recreate would have rebuilt it blank. Introspected
135
+ # from AutomationActionParams on the internal endpoint.
136
+ taskParams { recipients title }
137
+ aiParams { fieldIds value skillsIds }
138
+ aiBehaviorParams
139
+ slaParams {
140
+ monday { startHour endHour enabled }
141
+ tuesday { startHour endHour enabled }
142
+ wednesday { startHour endHour enabled }
143
+ thursday { startHour endHour enabled }
144
+ friday { startHour endHour enabled }
145
+ saturday { startHour endHour enabled }
146
+ sunday { startHour endHour enabled }
147
+ timezone
148
+ holidays { date recurrence description }
149
+ }
150
+ }
151
+ condition {
152
+ expressions_structure
153
+ expressions { id field_address operation value structure_id }
154
+ }
155
+ }
156
+ }`;
157
+ const data = await this.gql(q, { id: String(id) }, `loadAutomation ${id}`);
158
+ return data?.automation ?? null;
159
+ }
160
+ /** Recipe `Apply Changes` step 121. `input` is the AutomationInput variable set. */
161
+ async createAutomation(vars) {
162
+ const q = `
163
+ mutation createAutomation(
164
+ $name: String!, $action_id: ID!, $event_id: ID!,
165
+ $action_repo_id: ID, $event_repo_id: ID,
166
+ $event_params: AutomationEventParamsInput,
167
+ $action_params: AutomationActionParamsInput,
168
+ $condition: ConditionInput,
169
+ $scheduler_frequency: String,
170
+ $searchFor: [SearchConditionInput],
171
+ $schedulerCron: CronInput,
172
+ $responseSchema: JSON
173
+ ) {
174
+ createAutomation(input: {
175
+ name: $name, action_id: $action_id, event_id: $event_id,
176
+ action_repo_id: $action_repo_id, event_repo_id: $event_repo_id,
177
+ event_params: $event_params, action_params: $action_params,
178
+ condition: $condition, scheduler_frequency: $scheduler_frequency,
179
+ searchFor: $searchFor, schedulerCron: $schedulerCron,
180
+ responseSchema: $responseSchema
181
+ }) {
182
+ automation { id }
183
+ error_details { object_key messages }
184
+ }
185
+ }`;
186
+ const data = await this.gql(q, vars, 'createAutomation');
187
+ const payload = data?.createAutomation;
188
+ if (!payload)
189
+ return null;
190
+ if (payload.error_details?.length) {
191
+ const detail = payload.error_details.map((d) => `${d.object_key}: ${d.messages.join(', ')}`).join(' | ');
192
+ throw new Error(`createAutomation rejected — ${detail}`);
193
+ }
194
+ return payload.automation;
195
+ }
196
+ /** Recipe `Apply Changes` step 126. */
197
+ async deleteAutomation(id) {
198
+ const q = `
199
+ mutation deleteAutomation($id: ID!) {
200
+ deleteAutomation(input: { id: $id }) { success }
201
+ }`;
202
+ const data = await this.gql(q, { id: String(id) }, `deleteAutomation ${id}`);
203
+ return Boolean(data?.deleteAutomation?.success);
204
+ }
205
+ // ── Phase jumps ────────────────────────────────────────────────────────────
206
+ /**
207
+ * `GET /internal_api/settings/phases/:id` — recipe step 23.
208
+ *
209
+ * **The GET and the PUT answer in different shapes.** Both captured live:
210
+ *
211
+ * GET { data: { attributes: { name, phases: [[id, name, done], …],
212
+ * jump_targets: [344054275] } } }
213
+ * PUT { data: { name, back_phase_ids: [], next_phase_ids: [344054275] } }
214
+ *
215
+ * The GET's `jump_targets` is one merged set, which is exactly what the
216
+ * payload's `phase_jumps` array holds. Reading it as `data.next_phase_ids` —
217
+ * the shape PLAN.md §2.1 recorded, from the PUT — yields an empty set for
218
+ * every phase without any error, and a diff that then proposes deleting every
219
+ * jump in the pipe.
220
+ */
221
+ async getPhaseSettings(phaseId) {
222
+ try {
223
+ const res = await retry(async () => {
224
+ this.requestCount++;
225
+ const r = await fetch(`${ENDPOINTS.internalSettings}/phases/${phaseId}`, {
226
+ method: 'GET',
227
+ headers: { authorization: `Bearer ${await this.bearer()}`, accept: 'application/json' },
228
+ });
229
+ if (!r.ok) {
230
+ const err = new Error(`HTTP ${r.status}`);
231
+ err.retryable = r.status >= 500 || r.status === 429;
232
+ throw err;
233
+ }
234
+ return (await r.json());
235
+ }, { label: `getPhaseSettings ${phaseId}` });
236
+ const d = res.data ?? {};
237
+ const attrs = d.attributes ?? {};
238
+ const targets = attrs.jump_targets ?? d.jump_targets ?? [...(d.next_phase_ids ?? []), ...(d.back_phase_ids ?? [])];
239
+ this.status = 'ok';
240
+ return {
241
+ jump_targets: [...new Set(targets.map(Number).filter((n) => Number.isFinite(n)))],
242
+ phases: (attrs.phases ?? []).map(([id, name, done]) => ({ id: Number(id), name: String(name), done: Boolean(done) })),
243
+ };
244
+ }
245
+ catch (e) {
246
+ debug(`getPhaseSettings ${phaseId} failed: ${e.message}`);
247
+ this.markUnavailable(`getPhaseSettings: ${e.message}`);
248
+ return null;
249
+ }
250
+ }
251
+ /**
252
+ * `PUT /internal_api/settings/phases/:id` with the complete desired jump set.
253
+ * Recipe step 24 does the same thing in Python. Set-replacing, so we send the
254
+ * whole set and assert on the echoed `next_phase_ids` rather than on the HTTP
255
+ * status — PLAN.md §2.1.
256
+ */
257
+ async setPhaseJumpTargets(phaseId, targetPhaseIds) {
258
+ const params = new URLSearchParams();
259
+ if (targetPhaseIds.length === 0) {
260
+ // An empty set still has to be expressible, or "remove the last jump"
261
+ // becomes impossible. The form convention for an empty array is a single
262
+ // blank entry.
263
+ params.append('phase[jump_target_ids][]', '');
264
+ }
265
+ for (const t of targetPhaseIds)
266
+ params.append('phase[jump_target_ids][]', String(t));
267
+ try {
268
+ const res = await retry(async () => {
269
+ this.requestCount++;
270
+ const r = await fetch(`${ENDPOINTS.internalSettings}/phases/${phaseId}`, {
271
+ method: 'PUT',
272
+ headers: {
273
+ authorization: `Bearer ${await this.bearer()}`,
274
+ 'content-type': 'application/x-www-form-urlencoded',
275
+ accept: 'application/json',
276
+ },
277
+ body: params.toString(),
278
+ });
279
+ const text = await r.text();
280
+ if (!r.ok) {
281
+ const err = new Error(`HTTP ${r.status}: ${text.slice(0, 160)}`);
282
+ err.retryable = r.status >= 500 || r.status === 429;
283
+ throw err;
284
+ }
285
+ return JSON.parse(text);
286
+ }, { label: `setPhaseJumpTargets ${phaseId}` });
287
+ /**
288
+ * The echo is the union of both directions.
289
+ *
290
+ * `next_phase_ids` alone is forward-only, so a jump to an *earlier* phase
291
+ * echoes back as an empty next list even though the write succeeded — which
292
+ * this assertion reported as a failed write. Asserting on the union is what
293
+ * the sent set actually means.
294
+ */
295
+ const d = res.data ?? {};
296
+ const echoed = [
297
+ ...(d.attributes?.jump_targets ?? []),
298
+ ...(d.jump_targets ?? []),
299
+ ...(d.next_phase_ids ?? []),
300
+ ...(d.back_phase_ids ?? []),
301
+ ].map(Number);
302
+ const want = [...new Set(targetPhaseIds.map(Number))].sort((a, b) => a - b);
303
+ const got = [...new Set(echoed)].sort((a, b) => a - b);
304
+ const ok = want.length === got.length && want.every((v, i) => v === got[i]);
305
+ this.status = 'ok';
306
+ return ok
307
+ ? { ok, echoed }
308
+ : { ok, echoed, error: `echo mismatch: wanted [${want.join(',')}], endpoint reports [${got.join(',')}]` };
309
+ }
310
+ catch (e) {
311
+ this.markUnavailable(`setPhaseJumpTargets: ${e.message}`);
312
+ return { ok: false, echoed: [], error: e.message };
313
+ }
314
+ }
315
+ }