@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,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
|
+
}
|