@pipefy/pipefy-process-coder 0.1.0 → 0.1.2

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 (162) 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 +476 -456
  20. package/LICENSE +202 -0
  21. package/NOTICE +11 -0
  22. package/README.md +830 -808
  23. package/bin/pipe.js +25 -4
  24. package/dist/apply/adopt.js +128 -0
  25. package/dist/apply/agentops.js +42 -0
  26. package/dist/apply/compile.js +722 -0
  27. package/dist/apply/execute.js +277 -0
  28. package/dist/apply/flowops.js +293 -0
  29. package/dist/apply/idmap.js +233 -0
  30. package/dist/apply/mutations.js +704 -0
  31. package/dist/apply/registry.js +348 -0
  32. package/dist/apply/types.js +18 -0
  33. package/dist/cli/args.js +79 -0
  34. package/dist/cli.js +196 -0
  35. package/dist/codec/flow.js +164 -0
  36. package/dist/codec/pack.js +88 -0
  37. package/dist/codec/roundtrip.js +86 -0
  38. package/dist/codec/unpack.js +181 -0
  39. package/dist/commands/agents.js +145 -0
  40. package/dist/commands/apply.js +982 -0
  41. package/dist/commands/context.js +79 -0
  42. package/dist/commands/create.js +63 -0
  43. package/dist/commands/diff.js +294 -0
  44. package/dist/commands/flows.js +348 -0
  45. package/dist/commands/misc.js +562 -0
  46. package/dist/commands/plan.js +301 -0
  47. package/dist/commands/pull.js +549 -0
  48. package/dist/commands/runs.js +74 -0
  49. package/dist/commands/skills.js +131 -0
  50. package/dist/commands/verify.js +205 -0
  51. package/dist/config.js +125 -0
  52. package/dist/diff/agents.js +80 -0
  53. package/dist/diff/diff.js +983 -0
  54. package/dist/diff/flow.js +258 -0
  55. package/{src/diff/html.ts → dist/diff/html.js} +274 -322
  56. package/dist/diff/render.js +90 -0
  57. package/dist/model/payload.js +84 -0
  58. package/dist/model/tree.js +7 -0
  59. package/dist/model/volatile.js +54 -0
  60. package/dist/pipefy/agents.js +131 -0
  61. package/dist/pipefy/automations.js +155 -0
  62. package/dist/pipefy/capability.js +84 -0
  63. package/dist/pipefy/client.js +204 -0
  64. package/dist/pipefy/discovery.js +148 -0
  65. package/dist/pipefy/internal.js +406 -0
  66. package/dist/pipefy/ipaas.js +269 -0
  67. package/dist/pipefy/reconstruct.js +676 -0
  68. package/dist/pipefy/reference.js +172 -0
  69. package/dist/pipefy/snapshot.js +185 -0
  70. package/dist/pipefy/toolkit.js +174 -0
  71. package/{src → dist}/pipefy/toolkit_bearer.py +60 -14
  72. package/dist/report/integrations.js +165 -0
  73. package/dist/report/run.js +356 -0
  74. package/dist/util/fsx.js +50 -0
  75. package/dist/util/git.js +25 -0
  76. package/dist/util/json.js +56 -0
  77. package/dist/util/log.js +69 -0
  78. package/dist/util/pool.js +42 -0
  79. package/dist/util/slug.js +23 -0
  80. package/dist/util/tui.js +287 -0
  81. package/dist/validate/index.js +86 -0
  82. package/dist/validate/integrity.js +370 -0
  83. package/dist/validate/reference.js +120 -0
  84. package/dist/validate/schema.js +278 -0
  85. package/dist/version.js +18 -0
  86. package/dist/workspace/agents.js +245 -0
  87. package/{src/workspace/docs.ts → dist/workspace/docs.js} +367 -407
  88. package/dist/workspace/flows.js +149 -0
  89. package/dist/workspace/layout.js +146 -0
  90. package/dist/workspace/lock.js +83 -0
  91. package/dist/workspace/read.js +168 -0
  92. package/dist/workspace/reference.js +24 -0
  93. package/dist/workspace/stamp.js +234 -0
  94. package/dist/workspace/write.js +194 -0
  95. package/package.json +38 -35
  96. package/src/apply/adopt.ts +0 -150
  97. package/src/apply/agentops.ts +0 -71
  98. package/src/apply/compile.ts +0 -875
  99. package/src/apply/execute.ts +0 -336
  100. package/src/apply/flowops.ts +0 -399
  101. package/src/apply/idmap.ts +0 -241
  102. package/src/apply/mutations.ts +0 -955
  103. package/src/apply/registry.ts +0 -430
  104. package/src/apply/types.ts +0 -134
  105. package/src/cli/args.ts +0 -88
  106. package/src/cli.ts +0 -211
  107. package/src/codec/flow.ts +0 -199
  108. package/src/codec/pack.ts +0 -103
  109. package/src/codec/roundtrip.ts +0 -94
  110. package/src/codec/unpack.ts +0 -198
  111. package/src/commands/agents.ts +0 -184
  112. package/src/commands/apply.ts +0 -1144
  113. package/src/commands/context.ts +0 -119
  114. package/src/commands/create.ts +0 -79
  115. package/src/commands/diff.ts +0 -314
  116. package/src/commands/flows.ts +0 -414
  117. package/src/commands/misc.ts +0 -644
  118. package/src/commands/plan.ts +0 -331
  119. package/src/commands/pull.ts +0 -567
  120. package/src/commands/runs.ts +0 -83
  121. package/src/commands/skills.ts +0 -137
  122. package/src/commands/verify.ts +0 -253
  123. package/src/config.ts +0 -168
  124. package/src/diff/agents.ts +0 -122
  125. package/src/diff/diff.ts +0 -1130
  126. package/src/diff/flow.ts +0 -318
  127. package/src/diff/render.ts +0 -101
  128. package/src/model/payload.ts +0 -154
  129. package/src/model/tree.ts +0 -99
  130. package/src/model/volatile.ts +0 -55
  131. package/src/pipefy/agents.ts +0 -165
  132. package/src/pipefy/automations.ts +0 -219
  133. package/src/pipefy/capability.ts +0 -119
  134. package/src/pipefy/client.ts +0 -267
  135. package/src/pipefy/discovery.ts +0 -209
  136. package/src/pipefy/internal.ts +0 -380
  137. package/src/pipefy/ipaas.ts +0 -365
  138. package/src/pipefy/reconstruct.ts +0 -775
  139. package/src/pipefy/reference.ts +0 -251
  140. package/src/pipefy/snapshot.ts +0 -245
  141. package/src/pipefy/toolkit.ts +0 -200
  142. package/src/report/integrations.ts +0 -231
  143. package/src/report/run.ts +0 -475
  144. package/src/util/fsx.ts +0 -45
  145. package/src/util/git.ts +0 -32
  146. package/src/util/json.ts +0 -55
  147. package/src/util/log.ts +0 -76
  148. package/src/util/pool.ts +0 -48
  149. package/src/util/slug.ts +0 -26
  150. package/src/util/tui.ts +0 -335
  151. package/src/validate/index.ts +0 -123
  152. package/src/validate/integrity.ts +0 -387
  153. package/src/validate/reference.ts +0 -136
  154. package/src/validate/schema.ts +0 -328
  155. package/src/workspace/agents.ts +0 -290
  156. package/src/workspace/flows.ts +0 -191
  157. package/src/workspace/layout.ts +0 -165
  158. package/src/workspace/lock.ts +0 -148
  159. package/src/workspace/read.ts +0 -165
  160. package/src/workspace/reference.ts +0 -24
  161. package/src/workspace/stamp.ts +0 -301
  162. package/src/workspace/write.ts +0 -225
package/src/cli.ts DELETED
@@ -1,211 +0,0 @@
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
- });
package/src/codec/flow.ts DELETED
@@ -1,199 +0,0 @@
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
- };
package/src/codec/pack.ts DELETED
@@ -1,103 +0,0 @@
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
- });
@@ -1,94 +0,0 @@
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)));