@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
package/src/cli.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parse, bool, type Parsed } from './cli/args.ts';
|
|
3
|
+
import { UserError, bold, dim, error as logError, info, out, setVerbose, yellow } from './util/log.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `pipe` — a CLI, and only a CLI. The one
|
|
7
|
+
* thing a GUI buys today is *seeing* a diff, which `pipe diff --open` does.
|
|
8
|
+
*
|
|
9
|
+
* Zero runtime dependencies and no build step: Node strips the types itself, so
|
|
10
|
+
* `node src/cli.ts` works on a clean checkout. That matters for a tool an FDE
|
|
11
|
+
* runs on a client's laptop.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
type Command = {
|
|
15
|
+
summary: string;
|
|
16
|
+
help: () => Promise<string>;
|
|
17
|
+
run: (p: Parsed) => Promise<number | void>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const commands: Record<string, Command> = {
|
|
21
|
+
login: {
|
|
22
|
+
summary: 'store a Pipefy API token',
|
|
23
|
+
help: async () => (await import('./commands/misc.ts')).loginHelp,
|
|
24
|
+
run: async (p) => (await import('./commands/misc.ts')).login(p),
|
|
25
|
+
},
|
|
26
|
+
doctor: {
|
|
27
|
+
summary: 'report what this endpoint can actually do',
|
|
28
|
+
help: async () => (await import('./commands/misc.ts')).doctorHelp,
|
|
29
|
+
run: async (p) => (await import('./commands/misc.ts')).doctor(p),
|
|
30
|
+
},
|
|
31
|
+
skills: {
|
|
32
|
+
summary: 'the Claude Code skills this install ships with; install them for every project',
|
|
33
|
+
help: async () => (await import('./commands/skills.ts')).help,
|
|
34
|
+
run: async (p) => (await import('./commands/skills.ts')).run(p),
|
|
35
|
+
},
|
|
36
|
+
create: {
|
|
37
|
+
summary: 'create an empty pipe and pull it, ready to be built from files',
|
|
38
|
+
help: async () => (await import('./commands/create.ts')).help,
|
|
39
|
+
run: async (p) => (await import('./commands/create.ts')).run(p),
|
|
40
|
+
},
|
|
41
|
+
pull: {
|
|
42
|
+
summary: 'pull a pipe and everything it connects to into a workspace',
|
|
43
|
+
help: async () => (await import('./commands/pull.ts')).help,
|
|
44
|
+
run: async (p) => (await import('./commands/pull.ts')).run(p),
|
|
45
|
+
},
|
|
46
|
+
export: {
|
|
47
|
+
summary: 'export one repo as a single snapshot-shaped payload',
|
|
48
|
+
help: async () => (await import('./commands/misc.ts')).exportHelp,
|
|
49
|
+
run: async (p) => (await import('./commands/misc.ts')).exportCmd(p),
|
|
50
|
+
},
|
|
51
|
+
validate: {
|
|
52
|
+
summary: 'check JSON shape and referential integrity, offline',
|
|
53
|
+
help: async () => (await import('./commands/misc.ts')).validateHelp,
|
|
54
|
+
run: async (p) => (await import('./commands/misc.ts')).validate(p),
|
|
55
|
+
},
|
|
56
|
+
diff: {
|
|
57
|
+
summary: 'what changed — against the baseline, live, or another pipe',
|
|
58
|
+
help: async () => (await import('./commands/diff.ts')).help,
|
|
59
|
+
run: async (p) => (await import('./commands/diff.ts')).run(p),
|
|
60
|
+
},
|
|
61
|
+
plan: {
|
|
62
|
+
summary: 'classify every change and order the applicable ones',
|
|
63
|
+
help: async () => (await import('./commands/plan.ts')).help,
|
|
64
|
+
run: async (p) => (await import('./commands/plan.ts')).run(p),
|
|
65
|
+
},
|
|
66
|
+
apply: {
|
|
67
|
+
summary: 'snapshot, apply step by step, verify',
|
|
68
|
+
help: async () => (await import('./commands/apply.ts')).help,
|
|
69
|
+
run: async (p) => (await import('./commands/apply.ts')).run(p),
|
|
70
|
+
},
|
|
71
|
+
verify: {
|
|
72
|
+
summary: 'read the pipe back and diff it against the workspace',
|
|
73
|
+
help: async () => (await import('./commands/verify.ts')).help,
|
|
74
|
+
run: async (p) => (await import('./commands/verify.ts')).run(p),
|
|
75
|
+
},
|
|
76
|
+
rollback: {
|
|
77
|
+
summary: 'restore a snapshot',
|
|
78
|
+
help: async () => (await import('./commands/misc.ts')).rollbackHelp,
|
|
79
|
+
run: async (p) => (await import('./commands/misc.ts')).rollback(p),
|
|
80
|
+
},
|
|
81
|
+
runs: {
|
|
82
|
+
summary: 'every run recorded in this workspace, and what each one did',
|
|
83
|
+
help: async () => (await import('./commands/runs.ts')).help,
|
|
84
|
+
run: async (p) => (await import('./commands/runs.ts')).run(p),
|
|
85
|
+
},
|
|
86
|
+
status: {
|
|
87
|
+
summary: 'what is in this workspace and how it was read',
|
|
88
|
+
help: async () => (await import('./commands/misc.ts')).statusHelp,
|
|
89
|
+
run: async (p) => (await import('./commands/misc.ts')).status(p),
|
|
90
|
+
},
|
|
91
|
+
capabilities: {
|
|
92
|
+
summary: 'the write-coverage registry',
|
|
93
|
+
help: async () => (await import('./commands/misc.ts')).capsHelp,
|
|
94
|
+
run: async (p) => (await import('./commands/misc.ts')).capabilities(p),
|
|
95
|
+
},
|
|
96
|
+
roundtrip: {
|
|
97
|
+
summary: 'codec self-check: pack(unpack(P)) must equal P',
|
|
98
|
+
help: async () => (await import('./commands/misc.ts')).roundtripHelp,
|
|
99
|
+
run: async (p) => (await import('./commands/misc.ts')).roundtrip(p),
|
|
100
|
+
},
|
|
101
|
+
viewer: {
|
|
102
|
+
summary: 'build the HTML diff view and open it',
|
|
103
|
+
help: async () => (await import('./commands/misc.ts')).viewerHelp,
|
|
104
|
+
run: async (p) => (await import('./commands/misc.ts')).viewer(p),
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const usage = () => {
|
|
109
|
+
const width = Math.max(...Object.keys(commands).map((k) => k.length)) + 2;
|
|
110
|
+
out(bold('pipe') + ' — Pipefy process coder');
|
|
111
|
+
out('');
|
|
112
|
+
out(' Pull a pipe to disk as readable JSON, edit it with Claude, diff it, push it back.');
|
|
113
|
+
out('');
|
|
114
|
+
out(bold('usage'));
|
|
115
|
+
out(' pipe <command> [options]');
|
|
116
|
+
out('');
|
|
117
|
+
out(bold('commands'));
|
|
118
|
+
for (const [name, cmd] of Object.entries(commands)) {
|
|
119
|
+
out(` ${name.padEnd(width)} ${dim(cmd.summary)}`);
|
|
120
|
+
}
|
|
121
|
+
out('');
|
|
122
|
+
out(bold('the loop'));
|
|
123
|
+
out(` pipe pull <pipeId> --out ./my-pipe ${dim('# snapshot, or GraphQL if there are none')}`);
|
|
124
|
+
out(` pipe validate ${dim('# offline, instant')}`);
|
|
125
|
+
out(` pipe diff --open ${dim('# see it')}`);
|
|
126
|
+
out(` pipe plan --dry-run ${dim('# refuses what cannot be applied')}`);
|
|
127
|
+
out(` pipe apply ${dim('# snapshot, apply, verify')}`);
|
|
128
|
+
out('');
|
|
129
|
+
out(bold('global options'));
|
|
130
|
+
out(` --verbose ${dim('log every request, retry and dropped argument')}`);
|
|
131
|
+
out(` --help ${dim('help for a command')}`);
|
|
132
|
+
out('');
|
|
133
|
+
out(dim('First run: pipe login --token <token>, then pipe doctor.'));
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const main = async () => {
|
|
137
|
+
const argv = process.argv.slice(2);
|
|
138
|
+
const parsed = parse(argv);
|
|
139
|
+
setVerbose(bool(parsed, 'verbose') || bool(parsed, 'v'));
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `--version` before anything else. It is the first thing anyone types to check
|
|
143
|
+
* an install worked, and answering it with `unknown command "--version"` makes a
|
|
144
|
+
* working install look broken.
|
|
145
|
+
*
|
|
146
|
+
* The path is included because `npm link` puts a symlink on PATH: when two
|
|
147
|
+
* clones exist, or one was moved, "which one am I running" is the actual
|
|
148
|
+
* question behind the version check.
|
|
149
|
+
*/
|
|
150
|
+
if (parsed.command === '--version' || parsed.command === '-V' || parsed.command === 'version') {
|
|
151
|
+
const { readFile } = await import('node:fs/promises');
|
|
152
|
+
const { fileURLToPath } = await import('node:url');
|
|
153
|
+
const { dirname, join } = await import('node:path');
|
|
154
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
155
|
+
const pkg = JSON.parse(await readFile(join(here, '..', 'package.json'), 'utf8')) as {
|
|
156
|
+
name: string;
|
|
157
|
+
version: string;
|
|
158
|
+
};
|
|
159
|
+
out(`${pkg.name} ${pkg.version}`);
|
|
160
|
+
out(dim(` node ${process.version} (needs >=22.18 for native type stripping)`));
|
|
161
|
+
out(dim(` running ${join(here, 'cli.ts')}`));
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (parsed.command === 'help' || parsed.command === '--help' || parsed.command === '-h' || !argv.length) {
|
|
166
|
+
const topic = parsed.positionals[0];
|
|
167
|
+
if (topic && commands[topic]) {
|
|
168
|
+
out(await (commands[topic] as Command).help());
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
usage();
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const cmd = commands[parsed.command];
|
|
176
|
+
if (!cmd) {
|
|
177
|
+
logError(`unknown command "${parsed.command}"`);
|
|
178
|
+
const near = Object.keys(commands).filter((k) => k.startsWith(parsed.command[0] ?? ''));
|
|
179
|
+
if (near.length) info(dim(`did you mean: ${near.join(', ')}?`));
|
|
180
|
+
info(dim('run `pipe help` for the list'));
|
|
181
|
+
return 64;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (bool(parsed, 'help') || bool(parsed, 'h')) {
|
|
185
|
+
out(await cmd.help());
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const code = await cmd.run(parsed);
|
|
190
|
+
return typeof code === 'number' ? code : 0;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
main()
|
|
194
|
+
.then((code) => {
|
|
195
|
+
process.exitCode = code;
|
|
196
|
+
})
|
|
197
|
+
.catch((e) => {
|
|
198
|
+
if (e instanceof UserError) {
|
|
199
|
+
logError(e.message);
|
|
200
|
+
if (e.hint) info(`${yellow('hint')} ${e.hint}`);
|
|
201
|
+
process.exitCode = 65;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
logError((e as Error).message);
|
|
205
|
+
if (process.env.PPC_DEBUG || bool(parse(process.argv.slice(2)), 'verbose')) {
|
|
206
|
+
info(dim(String((e as Error).stack)));
|
|
207
|
+
} else {
|
|
208
|
+
info(dim('run again with --verbose for a stack trace'));
|
|
209
|
+
}
|
|
210
|
+
process.exitCode = 70;
|
|
211
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { canonicalString } from '../util/json.ts';
|
|
2
|
+
import type { Row } from '../model/payload.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The flow codec: a linked list of nodes on the wire, an ordered array of files on
|
|
6
|
+
* disk, and back again without loss.
|
|
7
|
+
*
|
|
8
|
+
* This is the one place the workspace's "folding is movement only" rule does not
|
|
9
|
+
* hold. A pipe's fields are moved under their phase unchanged, which makes
|
|
10
|
+
* `pack(unpack(P)) == P` true by construction. A flow's chaining lives *in* the
|
|
11
|
+
* nodes — `nextAction`, `firstLoopAction`, `children[]` — so splitting them into
|
|
12
|
+
* one file per node is a transformation, and the guarantee has to be earned by
|
|
13
|
+
* `assertFlowRoundTrip` rather than assumed.
|
|
14
|
+
*
|
|
15
|
+
* The links are preserved as `_`-prefixed metadata, which every comparator already
|
|
16
|
+
* ignores and nothing sends to the API:
|
|
17
|
+
*
|
|
18
|
+
* _nextAction : the name of the next node, or absent at the end of a chain
|
|
19
|
+
* _firstLoopAction : the first node inside a LOOP_ON_ITEMS
|
|
20
|
+
* _children : one name per router branch, index-aligned with
|
|
21
|
+
* settings.branches, null for an empty branch
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type FlowNode = Row & { name: string; type: string };
|
|
25
|
+
|
|
26
|
+
/** A flow as the workspace holds it: the row, its version, and its nodes in order. */
|
|
27
|
+
export type FlowTree = {
|
|
28
|
+
/** The flow row, without `version`. */
|
|
29
|
+
flow: Row;
|
|
30
|
+
/** The version row, without `trigger`. */
|
|
31
|
+
version: Row;
|
|
32
|
+
/** Execution order: trigger first, then depth-first through the chain. */
|
|
33
|
+
nodes: FlowNode[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const LINK_KEYS = ['nextAction', 'firstLoopAction', 'children'] as const;
|
|
37
|
+
|
|
38
|
+
/** Split the linked list into an ordered array, recording the links as metadata. */
|
|
39
|
+
export const unlinkFlow = (flowRow: Row): FlowTree => {
|
|
40
|
+
const { version, ...flow } = flowRow;
|
|
41
|
+
const v = (version ?? {}) as Row;
|
|
42
|
+
const { trigger, ...versionRest } = v;
|
|
43
|
+
|
|
44
|
+
const nodes: FlowNode[] = [];
|
|
45
|
+
const visit = (n: Row | null | undefined) => {
|
|
46
|
+
if (!n) return;
|
|
47
|
+
const { nextAction, firstLoopAction, children, ...rest } = n;
|
|
48
|
+
const node: FlowNode = {
|
|
49
|
+
...(rest as Row),
|
|
50
|
+
name: String(n['name'] ?? ''),
|
|
51
|
+
type: String(n['type'] ?? ''),
|
|
52
|
+
};
|
|
53
|
+
if (firstLoopAction) node['_firstLoopAction'] = String((firstLoopAction as Row)['name']);
|
|
54
|
+
if (children) {
|
|
55
|
+
node['_children'] = (children as Array<Row | null>).map((c) => (c ? String(c['name']) : null));
|
|
56
|
+
}
|
|
57
|
+
if (nextAction) node['_nextAction'] = String((nextAction as Row)['name']);
|
|
58
|
+
nodes.push(node);
|
|
59
|
+
|
|
60
|
+
if (firstLoopAction) visit(firstLoopAction as Row);
|
|
61
|
+
for (const c of (children ?? []) as Array<Row | null>) visit(c);
|
|
62
|
+
visit(nextAction as Row | undefined);
|
|
63
|
+
};
|
|
64
|
+
visit(trigger as Row | undefined);
|
|
65
|
+
|
|
66
|
+
return { flow: flow as Row, version: versionRest as Row, nodes };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Reassemble the linked list from the ordered array. The inverse of `unlinkFlow`. */
|
|
70
|
+
export const linkFlow = (tree: FlowTree): Row => {
|
|
71
|
+
const byName = new Map<string, FlowNode>();
|
|
72
|
+
for (const n of tree.nodes) byName.set(n.name, n);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Rebuilt depth-first from the trigger, so a node the chain does not reach is
|
|
76
|
+
* simply not in the result — and `assertFlowRoundTrip` then reports it, rather
|
|
77
|
+
* than the node vanishing silently into a flow that looks fine.
|
|
78
|
+
*/
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
const build = (name: string | null | undefined): Row | undefined => {
|
|
81
|
+
if (!name) return undefined;
|
|
82
|
+
const node = byName.get(name);
|
|
83
|
+
if (!node) return undefined;
|
|
84
|
+
if (seen.has(name)) {
|
|
85
|
+
throw new Error(`flow codec: node ${JSON.stringify(name)} is reachable twice — the chain has a cycle`);
|
|
86
|
+
}
|
|
87
|
+
seen.add(name);
|
|
88
|
+
|
|
89
|
+
const { _nextAction, _firstLoopAction, _children, ...rest } = node;
|
|
90
|
+
const out: Row = { ...(rest as Row) };
|
|
91
|
+
if (_firstLoopAction) {
|
|
92
|
+
const first = build(String(_firstLoopAction));
|
|
93
|
+
if (first) out['firstLoopAction'] = first;
|
|
94
|
+
}
|
|
95
|
+
if (_children) {
|
|
96
|
+
out['children'] = (_children as Array<string | null>).map((c) => build(c ?? undefined) ?? null);
|
|
97
|
+
}
|
|
98
|
+
if (_nextAction) {
|
|
99
|
+
const next = build(String(_nextAction));
|
|
100
|
+
if (next) out['nextAction'] = next;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const trigger = build(tree.nodes[0]?.name);
|
|
106
|
+
return { ...tree.flow, version: { ...tree.version, ...(trigger ? { trigger } : {}) } };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export type RoundTripResult = { ok: boolean; differences: Array<{ where: string; detail: string }> };
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `link(unlink(F))` must equal `F`. The exit criterion PLAN-IPAAS sets for the
|
|
113
|
+
* read path, checked per flow rather than asserted once.
|
|
114
|
+
*
|
|
115
|
+
* A linear chain is the easy case; routers with empty branches and nested loops
|
|
116
|
+
* are where a fold like this breaks, so this runs on every pull.
|
|
117
|
+
*/
|
|
118
|
+
export const assertFlowRoundTrip = (flowRow: Row): RoundTripResult => {
|
|
119
|
+
const differences: Array<{ where: string; detail: string }> = [];
|
|
120
|
+
let rebuilt: Row;
|
|
121
|
+
try {
|
|
122
|
+
rebuilt = linkFlow(unlinkFlow(flowRow));
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return { ok: false, differences: [{ where: '$', detail: (e as Error).message }] };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const a = canonicalString(flowRow);
|
|
128
|
+
const b = canonicalString(rebuilt);
|
|
129
|
+
if (a === b) return { ok: true, differences: [] };
|
|
130
|
+
|
|
131
|
+
// Narrow it to the node that drifted; "the flow differs" is not actionable.
|
|
132
|
+
const before = unlinkFlow(flowRow);
|
|
133
|
+
const after = unlinkFlow(rebuilt);
|
|
134
|
+
if (before.nodes.length !== after.nodes.length) {
|
|
135
|
+
differences.push({
|
|
136
|
+
where: 'nodes',
|
|
137
|
+
detail:
|
|
138
|
+
`${before.nodes.length} nodes before, ${after.nodes.length} after — ` +
|
|
139
|
+
`${before.nodes
|
|
140
|
+
.map((n) => n.name)
|
|
141
|
+
.filter((n) => !after.nodes.some((m) => m.name === n))
|
|
142
|
+
.join(', ') || 'order changed'} did not survive`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
for (const n of before.nodes) {
|
|
146
|
+
const m = after.nodes.find((x) => x.name === n.name);
|
|
147
|
+
if (!m) continue;
|
|
148
|
+
if (canonicalString(n) !== canonicalString(m)) {
|
|
149
|
+
const keys = [...new Set([...Object.keys(n), ...Object.keys(m)])].filter(
|
|
150
|
+
(k) => canonicalString(n[k]) !== canonicalString(m[k]),
|
|
151
|
+
);
|
|
152
|
+
differences.push({ where: `node ${n.name}`, detail: `differs at ${keys.join(', ')}` });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (canonicalString(before.version) !== canonicalString(after.version)) {
|
|
156
|
+
differences.push({ where: 'version', detail: 'the version row differs' });
|
|
157
|
+
}
|
|
158
|
+
if (!differences.length) differences.push({ where: '$', detail: 'canonical forms differ but no node did' });
|
|
159
|
+
return { ok: false, differences };
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** The node names a flow reaches, in execution order. */
|
|
163
|
+
export const nodeOrder = (tree: FlowTree): string[] => tree.nodes.map((n) => n.name);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Which nodes run strictly before a given one.
|
|
167
|
+
*
|
|
168
|
+
* Execution order is the array order, which is depth-first through the chain — so
|
|
169
|
+
* a node in one router branch does precede a node in a later branch positionally
|
|
170
|
+
* while never running with it. That is why this walks the links instead: only the
|
|
171
|
+
* nodes on the path from the trigger to this node can be referenced by it.
|
|
172
|
+
*/
|
|
173
|
+
export const ancestorsOf = (tree: FlowTree, name: string): Set<string> => {
|
|
174
|
+
const byName = new Map(tree.nodes.map((n) => [n.name, n]));
|
|
175
|
+
const path: string[] = [];
|
|
176
|
+
const found = new Set<string>();
|
|
177
|
+
|
|
178
|
+
const walk = (current: string | null | undefined): boolean => {
|
|
179
|
+
if (!current) return false;
|
|
180
|
+
const node = byName.get(current);
|
|
181
|
+
if (!node) return false;
|
|
182
|
+
if (current === name) {
|
|
183
|
+
for (const p of path) found.add(p);
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
path.push(current);
|
|
187
|
+
const branches: Array<string | null | undefined> = [
|
|
188
|
+
node['_firstLoopAction'] as string | undefined,
|
|
189
|
+
...(((node['_children'] as Array<string | null>) ?? []) as Array<string | null>),
|
|
190
|
+
node['_nextAction'] as string | undefined,
|
|
191
|
+
];
|
|
192
|
+
for (const b of branches) if (walk(b ?? undefined)) return true;
|
|
193
|
+
path.pop();
|
|
194
|
+
return false;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
walk(tree.nodes[0]?.name);
|
|
198
|
+
return found;
|
|
199
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { emptyRelations, type PayloadEnvelope, type RepoPayload, type Row } from '../model/payload.ts';
|
|
2
|
+
import type { ConditionNode, Tree } from '../model/tree.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* tree → payload. The exact inverse of unpack: it restores the owner-pointer
|
|
6
|
+
* columns that folding dropped and re-flattens the arrays.
|
|
7
|
+
*
|
|
8
|
+
* PLAN.md §3 — pack is not on v1's write path, and is built anyway, because it
|
|
9
|
+
* is the only rigorous proof that unpack is lossless, and because it is v2's
|
|
10
|
+
* engine arriving pre-tested.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const num = (v: unknown): number | null => {
|
|
14
|
+
if (v === null || v === undefined || v === '') return null;
|
|
15
|
+
const n = Number(v);
|
|
16
|
+
return Number.isFinite(n) ? n : null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const unfoldCondition = (
|
|
20
|
+
cond: ConditionNode,
|
|
21
|
+
sourceType: 'Automation' | 'FieldCondition',
|
|
22
|
+
sourceId: unknown,
|
|
23
|
+
conditions: Row[],
|
|
24
|
+
expressions: Row[],
|
|
25
|
+
) => {
|
|
26
|
+
const { expressions: exprs, ...rest } = cond;
|
|
27
|
+
const condRow: Row = { ...rest, source_type: sourceType, source_id: sourceId };
|
|
28
|
+
conditions.push(condRow);
|
|
29
|
+
for (const e of exprs ?? []) {
|
|
30
|
+
expressions.push({ ...e, condition_id: condRow['id'] ?? null });
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const pack = (tree: Tree): RepoPayload => {
|
|
35
|
+
const relations = emptyRelations();
|
|
36
|
+
|
|
37
|
+
for (const phase of tree.phases) {
|
|
38
|
+
const { fields, jump_target_ids, team_member_ids, ...phaseRow } = phase;
|
|
39
|
+
const memberKey = String((phaseRow as Row)['_team_member_key'] ?? 'user_id');
|
|
40
|
+
relations.phases.push(phaseRow as Row);
|
|
41
|
+
|
|
42
|
+
for (const f of fields ?? []) {
|
|
43
|
+
relations.fields.push({ ...f, phase_id: num(phaseRow['id']) ?? phaseRow['id'] });
|
|
44
|
+
}
|
|
45
|
+
for (const t of jump_target_ids ?? []) {
|
|
46
|
+
relations.phase_jumps.push({ source_phase_id: num(phaseRow['id']), target_phase_id: num(t) });
|
|
47
|
+
}
|
|
48
|
+
for (const m of team_member_ids ?? []) {
|
|
49
|
+
relations.phase_team_memberships.push({ phase_id: num(phaseRow['id']), [memberKey]: num(m) });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const auto of tree.automations) {
|
|
54
|
+
const { condition, field_maps, distribute_assignments, response_schemas, ...autoRow } = auto;
|
|
55
|
+
relations.automations.push(autoRow as Row);
|
|
56
|
+
const id = autoRow['id'];
|
|
57
|
+
if (condition) unfoldCondition(condition, 'Automation', id, relations.conditions, relations.condition_expressions);
|
|
58
|
+
for (const m of field_maps ?? []) {
|
|
59
|
+
relations.field_maps.push({ ...m, owner_type: 'Automation', owner_id: id });
|
|
60
|
+
}
|
|
61
|
+
for (const d of distribute_assignments ?? []) {
|
|
62
|
+
relations.distribute_assignments.push({ ...d, automation_id: id });
|
|
63
|
+
}
|
|
64
|
+
for (const s of response_schemas ?? []) {
|
|
65
|
+
relations.response_schemas.push({ ...s, automation_id: id });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const fc of tree.field_conditions) {
|
|
70
|
+
const { actions, condition, ...fcRow } = fc;
|
|
71
|
+
relations.field_conditions.push(fcRow as Row);
|
|
72
|
+
for (const a of actions ?? []) {
|
|
73
|
+
relations.field_condition_actions.push({ ...a, field_condition_id: fcRow['id'] });
|
|
74
|
+
}
|
|
75
|
+
if (condition) {
|
|
76
|
+
unfoldCondition(condition, 'FieldCondition', fcRow['id'], relations.conditions, relations.condition_expressions);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
relations.labels = tree.labels.map((r) => ({ ...r }));
|
|
81
|
+
relations.webhooks = tree.webhooks.map((r) => ({ ...r }));
|
|
82
|
+
relations.pipe_relations = tree.pipe_relations.map((r) => ({ ...r }));
|
|
83
|
+
relations.public_forms = tree.public_form ? [{ ...tree.public_form }] : [];
|
|
84
|
+
relations.repo_preferences = tree.preferences ? [{ ...tree.preferences }] : [];
|
|
85
|
+
relations.email_templates = tree.readonly.email_templates.map((r) => ({ ...r }));
|
|
86
|
+
relations.email_inboxes = tree.readonly.email_inboxes.map((r) => ({ ...r }));
|
|
87
|
+
relations.visibilities = tree.readonly.visibilities.map((r) => ({ ...r }));
|
|
88
|
+
// Restores the non-Automation field_maps rows the loop above never sees.
|
|
89
|
+
for (const m of tree.readonly.field_maps) relations.field_maps.push({ ...m });
|
|
90
|
+
|
|
91
|
+
return { ...tree.repo, id: Number(tree.repo['id']), relations };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Pack back into an envelope, preserving the provenance the tree carries. */
|
|
95
|
+
export const packEnvelope = (tree: Tree): PayloadEnvelope => ({
|
|
96
|
+
source: tree.meta.source,
|
|
97
|
+
versionId: tree.meta.versionId ?? null,
|
|
98
|
+
readAt: tree.meta.readAt,
|
|
99
|
+
repoKind: tree.meta.repoKind,
|
|
100
|
+
coverage: tree.meta.coverage,
|
|
101
|
+
notes: tree.meta.notes,
|
|
102
|
+
payload: pack(tree),
|
|
103
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { canonical, canonicalString, type Json } from '../util/json.ts';
|
|
2
|
+
import { ENTITIES, ENTITY_NAMES, type EntityName, type RepoPayload, type Row } from '../model/payload.ts';
|
|
3
|
+
import { VOLATILE_KEYS } from '../model/volatile.ts';
|
|
4
|
+
import { pack } from './pack.ts';
|
|
5
|
+
import { unpack } from './unpack.ts';
|
|
6
|
+
import type { PayloadEnvelope } from '../model/payload.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `pack(unpack(P)) ≡ P` under a canonical form — PLAN.md §3, corrected there
|
|
10
|
+
* from byte equality to semantic equality. Chasing byte-identity would waste a
|
|
11
|
+
* week and then fail: JSON.parse turns `index: 6.0` into `6`.
|
|
12
|
+
*
|
|
13
|
+
* Two normalisations beyond canonical JSON, both principled rather than
|
|
14
|
+
* convenient:
|
|
15
|
+
*
|
|
16
|
+
* 1. Volatile columns are dropped from both sides. The codec strips them by
|
|
17
|
+
* design (PLAN.md §9); comparing them would only ever report noise.
|
|
18
|
+
* 2. For `owned` entities — the fourteen arrays with no `uuid` and no
|
|
19
|
+
* identity of their own — `id` is dropped. ROUTE-B-V2.md §4 argues these
|
|
20
|
+
* are replace-sets under their owner, which is precisely the claim that
|
|
21
|
+
* their `id` is not identity. Rows are then compared as sorted sets.
|
|
22
|
+
*
|
|
23
|
+
* Anything else that differs is a real codec bug and blocks planning.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const stripDeep = (v: unknown): unknown => {
|
|
27
|
+
if (Array.isArray(v)) return v.map(stripDeep);
|
|
28
|
+
if (v && typeof v === 'object') {
|
|
29
|
+
const out: Row = {};
|
|
30
|
+
for (const [k, val] of Object.entries(v as Row)) {
|
|
31
|
+
if (VOLATILE_KEYS.has(k)) continue;
|
|
32
|
+
if (k.startsWith('_')) continue; // tool metadata, never pipe structure
|
|
33
|
+
out[k] = stripDeep(val);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
return v;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const normaliseRows = (name: EntityName, rows: Row[]): Json => {
|
|
41
|
+
const owned = ENTITIES[name].identity === 'owned';
|
|
42
|
+
const cleaned = rows.map((r) => {
|
|
43
|
+
const row = stripDeep(r) as Row;
|
|
44
|
+
if (owned) delete row['id'];
|
|
45
|
+
return canonicalString(row);
|
|
46
|
+
});
|
|
47
|
+
// Owned rows are sets; identified rows keep their order-independent identity
|
|
48
|
+
// too, so sorting is safe for both and removes ordering noise the codec
|
|
49
|
+
// introduces by grouping fields under phases.
|
|
50
|
+
return cleaned.sort() as unknown as Json;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type RoundTripDiff = {
|
|
54
|
+
ok: boolean;
|
|
55
|
+
differences: Array<{ where: string; detail: string }>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const compare = (before: RepoPayload, after: RepoPayload): RoundTripDiff => {
|
|
59
|
+
const differences: RoundTripDiff['differences'] = [];
|
|
60
|
+
|
|
61
|
+
const rootBefore = canonical(stripDeep({ ...before, relations: undefined })) as Record<string, Json>;
|
|
62
|
+
const rootAfter = canonical(stripDeep({ ...after, relations: undefined })) as Record<string, Json>;
|
|
63
|
+
const rootKeys = new Set([...Object.keys(rootBefore), ...Object.keys(rootAfter)]);
|
|
64
|
+
for (const k of [...rootKeys].sort()) {
|
|
65
|
+
const a = JSON.stringify(rootBefore[k]);
|
|
66
|
+
const b = JSON.stringify(rootAfter[k]);
|
|
67
|
+
if (a !== b) differences.push({ where: `repo.${k}`, detail: `${a} -> ${b}` });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const name of ENTITY_NAMES) {
|
|
71
|
+
const a = normaliseRows(name, before.relations?.[name] ?? []) as unknown as string[];
|
|
72
|
+
const b = normaliseRows(name, after.relations?.[name] ?? []) as unknown as string[];
|
|
73
|
+
if (JSON.stringify(a) === JSON.stringify(b)) continue;
|
|
74
|
+
|
|
75
|
+
if (a.length !== b.length) {
|
|
76
|
+
differences.push({ where: name, detail: `${a.length} rows before, ${b.length} after` });
|
|
77
|
+
}
|
|
78
|
+
const setB = new Set(b);
|
|
79
|
+
const lost = a.filter((r) => !setB.has(r));
|
|
80
|
+
const setA = new Set(a);
|
|
81
|
+
const gained = b.filter((r) => !setA.has(r));
|
|
82
|
+
for (const l of lost.slice(0, 3)) differences.push({ where: `${name} (lost)`, detail: l.slice(0, 300) });
|
|
83
|
+
for (const g of gained.slice(0, 3)) differences.push({ where: `${name} (gained)`, detail: g.slice(0, 300) });
|
|
84
|
+
if (lost.length + gained.length > 6) {
|
|
85
|
+
differences.push({ where: name, detail: `…and ${lost.length + gained.length - 6} more row differences` });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { ok: differences.length === 0, differences };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** The Phase 0 exit criterion, callable on any payload. */
|
|
93
|
+
export const assertRoundTrip = (env: PayloadEnvelope): RoundTripDiff =>
|
|
94
|
+
compare(env.payload, pack(unpack(env)));
|