@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.
- package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -262
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -247
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -589
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -1391
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -623
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -636
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -3642
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -863
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -799
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -42
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -581
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -503
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -562
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -557
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -609
- package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -146
- package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -127
- package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -91
- package/AGENTS.md +476 -456
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +830 -808
- package/bin/pipe.js +25 -4
- package/dist/apply/adopt.js +128 -0
- package/dist/apply/agentops.js +42 -0
- package/dist/apply/compile.js +722 -0
- package/dist/apply/execute.js +277 -0
- package/dist/apply/flowops.js +293 -0
- package/dist/apply/idmap.js +233 -0
- package/dist/apply/mutations.js +704 -0
- package/dist/apply/registry.js +348 -0
- package/dist/apply/types.js +18 -0
- package/dist/cli/args.js +79 -0
- package/dist/cli.js +196 -0
- package/dist/codec/flow.js +164 -0
- package/dist/codec/pack.js +88 -0
- package/dist/codec/roundtrip.js +86 -0
- package/dist/codec/unpack.js +181 -0
- package/dist/commands/agents.js +145 -0
- package/dist/commands/apply.js +982 -0
- package/dist/commands/context.js +79 -0
- package/dist/commands/create.js +63 -0
- package/dist/commands/diff.js +294 -0
- package/dist/commands/flows.js +348 -0
- package/dist/commands/misc.js +562 -0
- package/dist/commands/plan.js +301 -0
- package/dist/commands/pull.js +549 -0
- package/dist/commands/runs.js +74 -0
- package/dist/commands/skills.js +131 -0
- package/dist/commands/verify.js +205 -0
- package/dist/config.js +125 -0
- package/dist/diff/agents.js +80 -0
- package/dist/diff/diff.js +983 -0
- package/dist/diff/flow.js +258 -0
- package/{src/diff/html.ts → dist/diff/html.js} +274 -322
- package/dist/diff/render.js +90 -0
- package/dist/model/payload.js +84 -0
- package/dist/model/tree.js +7 -0
- package/dist/model/volatile.js +54 -0
- package/dist/pipefy/agents.js +131 -0
- package/dist/pipefy/automations.js +155 -0
- package/dist/pipefy/capability.js +84 -0
- package/dist/pipefy/client.js +204 -0
- package/dist/pipefy/discovery.js +148 -0
- package/dist/pipefy/internal.js +406 -0
- package/dist/pipefy/ipaas.js +269 -0
- package/dist/pipefy/reconstruct.js +676 -0
- package/dist/pipefy/reference.js +172 -0
- package/dist/pipefy/snapshot.js +185 -0
- package/dist/pipefy/toolkit.js +174 -0
- package/{src → dist}/pipefy/toolkit_bearer.py +60 -14
- package/dist/report/integrations.js +165 -0
- package/dist/report/run.js +356 -0
- package/dist/util/fsx.js +50 -0
- package/dist/util/git.js +25 -0
- package/dist/util/json.js +56 -0
- package/dist/util/log.js +69 -0
- package/dist/util/pool.js +42 -0
- package/dist/util/slug.js +23 -0
- package/dist/util/tui.js +287 -0
- package/dist/validate/index.js +86 -0
- package/dist/validate/integrity.js +370 -0
- package/dist/validate/reference.js +120 -0
- package/dist/validate/schema.js +278 -0
- package/dist/version.js +18 -0
- package/dist/workspace/agents.js +245 -0
- package/{src/workspace/docs.ts → dist/workspace/docs.js} +367 -407
- package/dist/workspace/flows.js +149 -0
- package/dist/workspace/layout.js +146 -0
- package/dist/workspace/lock.js +83 -0
- package/dist/workspace/read.js +168 -0
- package/dist/workspace/reference.js +24 -0
- package/dist/workspace/stamp.js +234 -0
- package/dist/workspace/write.js +194 -0
- package/package.json +38 -35
- package/src/apply/adopt.ts +0 -150
- package/src/apply/agentops.ts +0 -71
- package/src/apply/compile.ts +0 -875
- package/src/apply/execute.ts +0 -336
- package/src/apply/flowops.ts +0 -399
- package/src/apply/idmap.ts +0 -241
- package/src/apply/mutations.ts +0 -955
- package/src/apply/registry.ts +0 -430
- package/src/apply/types.ts +0 -134
- package/src/cli/args.ts +0 -88
- package/src/cli.ts +0 -211
- package/src/codec/flow.ts +0 -199
- package/src/codec/pack.ts +0 -103
- package/src/codec/roundtrip.ts +0 -94
- package/src/codec/unpack.ts +0 -198
- package/src/commands/agents.ts +0 -184
- package/src/commands/apply.ts +0 -1144
- package/src/commands/context.ts +0 -119
- package/src/commands/create.ts +0 -79
- package/src/commands/diff.ts +0 -314
- package/src/commands/flows.ts +0 -414
- package/src/commands/misc.ts +0 -644
- package/src/commands/plan.ts +0 -331
- package/src/commands/pull.ts +0 -567
- package/src/commands/runs.ts +0 -83
- package/src/commands/skills.ts +0 -137
- package/src/commands/verify.ts +0 -253
- package/src/config.ts +0 -168
- package/src/diff/agents.ts +0 -122
- package/src/diff/diff.ts +0 -1130
- package/src/diff/flow.ts +0 -318
- package/src/diff/render.ts +0 -101
- package/src/model/payload.ts +0 -154
- package/src/model/tree.ts +0 -99
- package/src/model/volatile.ts +0 -55
- package/src/pipefy/agents.ts +0 -165
- package/src/pipefy/automations.ts +0 -219
- package/src/pipefy/capability.ts +0 -119
- package/src/pipefy/client.ts +0 -267
- package/src/pipefy/discovery.ts +0 -209
- package/src/pipefy/internal.ts +0 -380
- package/src/pipefy/ipaas.ts +0 -365
- package/src/pipefy/reconstruct.ts +0 -775
- package/src/pipefy/reference.ts +0 -251
- package/src/pipefy/snapshot.ts +0 -245
- package/src/pipefy/toolkit.ts +0 -200
- package/src/report/integrations.ts +0 -231
- package/src/report/run.ts +0 -475
- package/src/util/fsx.ts +0 -45
- package/src/util/git.ts +0 -32
- package/src/util/json.ts +0 -55
- package/src/util/log.ts +0 -76
- package/src/util/pool.ts +0 -48
- package/src/util/slug.ts +0 -26
- package/src/util/tui.ts +0 -335
- package/src/validate/index.ts +0 -123
- package/src/validate/integrity.ts +0 -387
- package/src/validate/reference.ts +0 -136
- package/src/validate/schema.ts +0 -328
- package/src/workspace/agents.ts +0 -290
- package/src/workspace/flows.ts +0 -191
- package/src/workspace/layout.ts +0 -165
- package/src/workspace/lock.ts +0 -148
- package/src/workspace/read.ts +0 -165
- package/src/workspace/reference.ts +0 -24
- package/src/workspace/stamp.ts +0 -301
- package/src/workspace/write.ts +0 -225
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The integration surface of a pipe, pulled out of the payload as its own
|
|
3
|
+
* artifact.
|
|
4
|
+
*
|
|
5
|
+
* "Integration" here means every way this pipe touches something outside
|
|
6
|
+
* itself, because those are the parts of a change whose blast radius leaves the
|
|
7
|
+
* pipe: a webhook fires at a third party, a pipe relation writes cards into
|
|
8
|
+
* another process, a cross-repo automation does both. They are also the parts
|
|
9
|
+
* that carry credentials, which is why the rendered summary is redacted and the
|
|
10
|
+
* raw rows go in a file the workspace .gitignore covers.
|
|
11
|
+
*
|
|
12
|
+
* Recorded per run rather than derived on demand: a run is the only moment when
|
|
13
|
+
* "what did this pipe reach, before and after" is knowable, and by the next
|
|
14
|
+
* apply the answer has changed.
|
|
15
|
+
*/
|
|
16
|
+
/** Automation actions that reach outside the pipe they are triggered in. */
|
|
17
|
+
const OUTBOUND_ACTIONS = new Set([
|
|
18
|
+
'http_request',
|
|
19
|
+
'send_email',
|
|
20
|
+
'send_email_template',
|
|
21
|
+
'create_card',
|
|
22
|
+
'create_card_relation',
|
|
23
|
+
'move_single_card',
|
|
24
|
+
'create_table_record',
|
|
25
|
+
'update_table_record',
|
|
26
|
+
]);
|
|
27
|
+
const s = (v) => (v === null || v === undefined ? null : String(v));
|
|
28
|
+
const hostOf = (url) => {
|
|
29
|
+
const raw = s(url);
|
|
30
|
+
if (!raw)
|
|
31
|
+
return '(no url)';
|
|
32
|
+
try {
|
|
33
|
+
return new URL(raw).host;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return raw.slice(0, 40);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
/** Whether a row holds anything bearer-equivalent. */
|
|
40
|
+
const bearsSecret = (row) => {
|
|
41
|
+
const j = JSON.stringify(row).toLowerCase();
|
|
42
|
+
return (j.includes('authentication_value') ||
|
|
43
|
+
j.includes('authentication_key') ||
|
|
44
|
+
j.includes('token') ||
|
|
45
|
+
j.includes('secret') ||
|
|
46
|
+
/[?&](key|token|sig|signature|auth)=/.test(j));
|
|
47
|
+
};
|
|
48
|
+
export const integrationsFrom = (env) => {
|
|
49
|
+
const rel = env.payload.relations;
|
|
50
|
+
const repoId = Number(env.payload.id);
|
|
51
|
+
const webhooks = rel.webhooks ?? [];
|
|
52
|
+
const relations = rel.pipe_relations ?? [];
|
|
53
|
+
const automations = rel.automations ?? [];
|
|
54
|
+
const inboxes = rel.email_inboxes ?? [];
|
|
55
|
+
const fields = rel.fields ?? [];
|
|
56
|
+
const outbound = automations.filter((a) => {
|
|
57
|
+
const action = s(a['action_id']) ?? '';
|
|
58
|
+
const target = s(a['action_repo_id']);
|
|
59
|
+
const crossRepo = target !== null && target !== s(a['event_repo_id']);
|
|
60
|
+
return crossRepo || OUTBOUND_ACTIONS.has(action);
|
|
61
|
+
});
|
|
62
|
+
const connectedFields = fields.filter((f) => s(f['connected_repo_id']) ?? s(f['connected_pipe_id']));
|
|
63
|
+
const edges = [
|
|
64
|
+
...webhooks.map((w) => ({
|
|
65
|
+
kind: 'webhook',
|
|
66
|
+
target: hostOf(w['url']),
|
|
67
|
+
id: s(w['id']),
|
|
68
|
+
detail: `${s(w['name']) ?? 'unnamed'} on ` +
|
|
69
|
+
`${Array.isArray(w['actions']) ? w['actions'].join(', ') : (s(w['actions']) ?? 'no actions')}`,
|
|
70
|
+
bearsSecret: bearsSecret(w),
|
|
71
|
+
})),
|
|
72
|
+
...relations.map((r) => ({
|
|
73
|
+
kind: 'pipe_relation',
|
|
74
|
+
target: `repo ${s(r['child_id']) ?? s(r['parent_id']) ?? '?'}`,
|
|
75
|
+
id: s(r['id']),
|
|
76
|
+
detail: `${s(r['name']) ?? 'unnamed'} — parent ${s(r['parent_id'])} to child ${s(r['child_id'])}`,
|
|
77
|
+
})),
|
|
78
|
+
...outbound.map((a) => ({
|
|
79
|
+
kind: 'automation',
|
|
80
|
+
target: s(a['action_repo_id']) && s(a['action_repo_id']) !== String(repoId)
|
|
81
|
+
? `repo ${s(a['action_repo_id'])}`
|
|
82
|
+
: (s(a['action_id']) ?? 'action'),
|
|
83
|
+
id: s(a['id']),
|
|
84
|
+
detail: `${s(a['name']) ?? 'unnamed'} — ${s(a['event_id'])} to ${s(a['action_id'])}` +
|
|
85
|
+
`${a['active'] === false ? ' (inactive)' : ''}`,
|
|
86
|
+
bearsSecret: bearsSecret(a),
|
|
87
|
+
})),
|
|
88
|
+
...connectedFields.map((f) => ({
|
|
89
|
+
kind: 'connected_field',
|
|
90
|
+
target: `repo ${s(f['connected_repo_id']) ?? s(f['connected_pipe_id'])}`,
|
|
91
|
+
id: s(f['id']),
|
|
92
|
+
// Payload rows spell the type `type_id`; a reconstructed row may spell it
|
|
93
|
+
// `type`. Neither is guaranteed, so both are tried before giving up.
|
|
94
|
+
detail: `field "${s(f['label']) ?? s(f['name']) ?? '?'}" (${s(f['type_id']) ?? s(f['type']) ?? 'unknown type'})`,
|
|
95
|
+
})),
|
|
96
|
+
...inboxes.map((i) => ({
|
|
97
|
+
kind: 'email_inbox',
|
|
98
|
+
target: s(i['email']) ?? s(i['name']) ?? 'inbox',
|
|
99
|
+
id: s(i['id']),
|
|
100
|
+
detail: 'inbound email',
|
|
101
|
+
})),
|
|
102
|
+
];
|
|
103
|
+
const connected = new Set();
|
|
104
|
+
const note = (v) => {
|
|
105
|
+
const n = Number(v);
|
|
106
|
+
if (Number.isFinite(n) && n !== repoId && n !== 0)
|
|
107
|
+
connected.add(n);
|
|
108
|
+
};
|
|
109
|
+
for (const r of relations) {
|
|
110
|
+
note(r['parent_id']);
|
|
111
|
+
note(r['child_id']);
|
|
112
|
+
}
|
|
113
|
+
for (const a of outbound)
|
|
114
|
+
note(a['action_repo_id']);
|
|
115
|
+
for (const f of connectedFields)
|
|
116
|
+
note(f['connected_repo_id'] ?? f['connected_pipe_id']);
|
|
117
|
+
const form = (rel.public_forms ?? [])[0] ?? null;
|
|
118
|
+
return {
|
|
119
|
+
generatedAt: new Date().toISOString(),
|
|
120
|
+
repoId,
|
|
121
|
+
repoName: String(env.payload['name'] ?? repoId),
|
|
122
|
+
coverage: {
|
|
123
|
+
webhooks: env.coverage.webhooks,
|
|
124
|
+
pipe_relations: env.coverage.pipe_relations,
|
|
125
|
+
automations: env.coverage.automations,
|
|
126
|
+
email_inboxes: env.coverage.email_inboxes,
|
|
127
|
+
},
|
|
128
|
+
counts: {
|
|
129
|
+
webhooks: webhooks.length,
|
|
130
|
+
pipe_relations: relations.length,
|
|
131
|
+
outbound_automations: outbound.length,
|
|
132
|
+
connected_fields: connectedFields.length,
|
|
133
|
+
},
|
|
134
|
+
edges,
|
|
135
|
+
connected_repo_ids: [...connected].sort((a, b) => a - b),
|
|
136
|
+
webhooks,
|
|
137
|
+
pipe_relations: relations,
|
|
138
|
+
outbound_automations: outbound,
|
|
139
|
+
email_inboxes: inboxes,
|
|
140
|
+
public_form: form ? { open: Boolean(env.payload['public']), title: form['title'] ?? null } : null,
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
/** The integration surface as report prose. Hosts kept, credentials never. */
|
|
144
|
+
export const renderIntegrations = (v) => {
|
|
145
|
+
const lines = [];
|
|
146
|
+
const gaps = Object.entries(v.coverage).filter(([, c]) => c !== 'complete');
|
|
147
|
+
if (gaps.length) {
|
|
148
|
+
lines.push(`> Coverage: ${gaps.map(([k, c]) => `${k} is ${c}`).join(', ')}. ` +
|
|
149
|
+
'An absent row below is not evidence that nothing is there.', '');
|
|
150
|
+
}
|
|
151
|
+
if (!v.edges.length) {
|
|
152
|
+
lines.push('This pipe reaches nothing outside itself.');
|
|
153
|
+
return lines;
|
|
154
|
+
}
|
|
155
|
+
lines.push('| Kind | Target | Id | Detail |', '| --- | --- | --- | --- |');
|
|
156
|
+
for (const e of v.edges) {
|
|
157
|
+
lines.push(`| ${e.kind.replace(/_/g, ' ')} | ${e.target}${e.bearsSecret ? ' (key)' : ''} | ${e.id ?? '-'} | ` +
|
|
158
|
+
`${e.detail.replace(/\|/g, '\\|')} |`);
|
|
159
|
+
}
|
|
160
|
+
if (v.edges.some((e) => e.bearsSecret)) {
|
|
161
|
+
lines.push('', '(key) marks a row that carries a credential. The value is in `integrations.json`, ' +
|
|
162
|
+
'which the workspace .gitignore covers.');
|
|
163
|
+
}
|
|
164
|
+
return lines;
|
|
165
|
+
};
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { mkdirp, writeFile, writeJson, exists } from "../util/fsx.js";
|
|
4
|
+
import { paths } from "../workspace/layout.js";
|
|
5
|
+
import { integrationsFrom, renderIntegrations } from "./integrations.js";
|
|
6
|
+
import { TOOL } from "../version.js";
|
|
7
|
+
const stamp = (d = new Date()) => d.toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z');
|
|
8
|
+
/**
|
|
9
|
+
* The command as typed, for reproduction — with any token elided. `--token` is
|
|
10
|
+
* the one flag whose value must never reach a file, and `pipe login --token` is
|
|
11
|
+
* a real invocation someone might run inside a workspace.
|
|
12
|
+
*/
|
|
13
|
+
export const invocation = (argv = process.argv.slice(2)) => {
|
|
14
|
+
const parts = [];
|
|
15
|
+
for (let i = 0; i < argv.length; i++) {
|
|
16
|
+
const a = argv[i];
|
|
17
|
+
if (/^--token(=|$)/.test(a)) {
|
|
18
|
+
parts.push('--token <elided>');
|
|
19
|
+
if (!a.includes('=') && argv[i + 1] && !argv[i + 1].startsWith('-'))
|
|
20
|
+
i++;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
parts.push(a);
|
|
24
|
+
}
|
|
25
|
+
return `pipe ${parts.join(' ')}`.trim();
|
|
26
|
+
};
|
|
27
|
+
/** `runs/` lives at the workspace root, not under `.ppc/` — these are for reading. */
|
|
28
|
+
export const runsDir = (root) => join(paths(root).root, 'runs');
|
|
29
|
+
export const runDir = (root, id) => join(runsDir(root), id);
|
|
30
|
+
/**
|
|
31
|
+
* Collects what happened while a command runs.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately tolerant: a recorder that throws would turn a successful apply
|
|
34
|
+
* into a failed command, and the record is worth less than the work it
|
|
35
|
+
* describes. Everything here is additive and total.
|
|
36
|
+
*/
|
|
37
|
+
export class RunRecorder {
|
|
38
|
+
id;
|
|
39
|
+
startedAt;
|
|
40
|
+
notes = [];
|
|
41
|
+
warnings = [];
|
|
42
|
+
plan = null;
|
|
43
|
+
verification;
|
|
44
|
+
beforeEnv = null;
|
|
45
|
+
afterEnv = null;
|
|
46
|
+
kind;
|
|
47
|
+
ctx;
|
|
48
|
+
constructor(kind, ctx) {
|
|
49
|
+
this.kind = kind;
|
|
50
|
+
this.ctx = ctx;
|
|
51
|
+
/**
|
|
52
|
+
* `startedAt` is passed in by commands that only learn where the workspace
|
|
53
|
+
* is at the end — a pull cannot name its own output directory until
|
|
54
|
+
* discovery has run, and a run that reports 0.0s for a 60-second snapshot is
|
|
55
|
+
* worse than no timing at all.
|
|
56
|
+
*/
|
|
57
|
+
this.startedAt = ctx.startedAt ?? new Date();
|
|
58
|
+
this.id = `${stamp(this.startedAt)}--${kind}`;
|
|
59
|
+
}
|
|
60
|
+
note(text) {
|
|
61
|
+
this.notes.push(text);
|
|
62
|
+
}
|
|
63
|
+
warn(text) {
|
|
64
|
+
this.warnings.push(text);
|
|
65
|
+
}
|
|
66
|
+
/** The plan is the step list; recording it is how the report gets its detail. */
|
|
67
|
+
fromPlan(plan) {
|
|
68
|
+
this.plan = plan;
|
|
69
|
+
}
|
|
70
|
+
verified(ok, detail) {
|
|
71
|
+
this.verification = { ran: true, ok, ...(detail ? { detail } : {}) };
|
|
72
|
+
}
|
|
73
|
+
/** The payload the run started from, when the command knows it. */
|
|
74
|
+
before(env) {
|
|
75
|
+
this.beforeEnv = env;
|
|
76
|
+
}
|
|
77
|
+
after(env) {
|
|
78
|
+
this.afterEnv = env;
|
|
79
|
+
}
|
|
80
|
+
build(outcome, summary) {
|
|
81
|
+
const finished = new Date();
|
|
82
|
+
const steps = (this.plan?.steps ?? []).map((s) => {
|
|
83
|
+
const started = s.startedAt ? Date.parse(s.startedAt) : NaN;
|
|
84
|
+
const ended = s.finishedAt ? Date.parse(s.finishedAt) : NaN;
|
|
85
|
+
return {
|
|
86
|
+
index: s.index,
|
|
87
|
+
id: s.id,
|
|
88
|
+
description: s.description,
|
|
89
|
+
entity: String(s.entity),
|
|
90
|
+
op: s.op,
|
|
91
|
+
kind: s.kind,
|
|
92
|
+
status: s.status,
|
|
93
|
+
tags: [
|
|
94
|
+
...(s.destructive ? ['destructive'] : []),
|
|
95
|
+
...(s.unverified ? ['unverified'] : []),
|
|
96
|
+
...(s.kind.startsWith('internal') ? ['internal-api'] : []),
|
|
97
|
+
],
|
|
98
|
+
...(s.error ? { error: s.error } : {}),
|
|
99
|
+
...(producedId(s.result) !== undefined ? { produced: producedId(s.result) } : {}),
|
|
100
|
+
...(Number.isFinite(started) && Number.isFinite(ended) ? { durationMs: ended - started } : {}),
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
const count = (status) => steps.filter((s) => s.status === status).length;
|
|
104
|
+
const env = this.afterEnv;
|
|
105
|
+
return {
|
|
106
|
+
id: this.id,
|
|
107
|
+
kind: this.kind,
|
|
108
|
+
command: this.ctx.command,
|
|
109
|
+
tool: { ...TOOL },
|
|
110
|
+
startedAt: this.startedAt.toISOString(),
|
|
111
|
+
finishedAt: finished.toISOString(),
|
|
112
|
+
durationMs: finished.getTime() - this.startedAt.getTime(),
|
|
113
|
+
workspace: this.ctx.workspace,
|
|
114
|
+
repo: this.ctx.repo,
|
|
115
|
+
outcome,
|
|
116
|
+
summary,
|
|
117
|
+
...(this.plan ? { planId: this.plan.id, preApplyVersionId: this.plan.preApplyVersionId ?? null } : {}),
|
|
118
|
+
counts: {
|
|
119
|
+
steps: steps.length,
|
|
120
|
+
done: count('done'),
|
|
121
|
+
failed: count('failed'),
|
|
122
|
+
skipped: count('skipped'),
|
|
123
|
+
pending: count('pending'),
|
|
124
|
+
blocked: this.plan?.blocked.length ?? 0,
|
|
125
|
+
},
|
|
126
|
+
steps,
|
|
127
|
+
blocked: (this.plan?.blocked ?? []).map((b) => ({
|
|
128
|
+
label: b.label,
|
|
129
|
+
entity: String(b.entity),
|
|
130
|
+
op: b.op,
|
|
131
|
+
...(b.reason ? { reason: b.reason } : {}),
|
|
132
|
+
...(b.ask ? { ask: b.ask } : {}),
|
|
133
|
+
})),
|
|
134
|
+
// Deduplicated: a multi-repo pull collects the same coverage note from
|
|
135
|
+
// every tree, and three copies of it read as three problems.
|
|
136
|
+
warnings: [...new Set([...(this.plan?.warnings ?? []), ...this.warnings])],
|
|
137
|
+
notes: [...new Set(this.notes)],
|
|
138
|
+
...(this.verification ? { verification: this.verification } : {}),
|
|
139
|
+
...(env ? { coverage: env.coverage } : {}),
|
|
140
|
+
integrations: {
|
|
141
|
+
...(this.beforeEnv ? { before: integrationsFrom(this.beforeEnv).counts } : {}),
|
|
142
|
+
...(this.afterEnv ? { after: integrationsFrom(this.afterEnv).counts } : {}),
|
|
143
|
+
},
|
|
144
|
+
artifacts: [],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Write the run folder. Never throws: a failure to record is reported as a
|
|
149
|
+
* note and nothing more, because the alternative is a command that did its
|
|
150
|
+
* work and then reported failure.
|
|
151
|
+
*/
|
|
152
|
+
async write(outcome, summary) {
|
|
153
|
+
try {
|
|
154
|
+
const record = this.build(outcome, summary);
|
|
155
|
+
const dir = runDir(this.ctx.workspace, record.id);
|
|
156
|
+
await mkdirp(dir);
|
|
157
|
+
const written = [];
|
|
158
|
+
const put = async (name, value) => {
|
|
159
|
+
await writeJson(join(dir, name), value);
|
|
160
|
+
written.push(name);
|
|
161
|
+
};
|
|
162
|
+
if (this.afterEnv) {
|
|
163
|
+
await put('pipe.json', this.afterEnv);
|
|
164
|
+
await put('integrations.json', integrationsFrom(this.afterEnv));
|
|
165
|
+
}
|
|
166
|
+
if (this.beforeEnv && this.kind !== 'pull') {
|
|
167
|
+
await put('pipe.before.json', this.beforeEnv);
|
|
168
|
+
await put('integrations.before.json', integrationsFrom(this.beforeEnv));
|
|
169
|
+
}
|
|
170
|
+
record.artifacts = [...written, 'report.md', 'run.json'];
|
|
171
|
+
await writeJson(join(dir, 'run.json'), record);
|
|
172
|
+
await writeFile(join(dir, 'report.md'), renderRunReport(record, this.afterEnv, this.beforeEnv));
|
|
173
|
+
await appendIndex(this.ctx.workspace, record);
|
|
174
|
+
return { dir };
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
return { dir: runDir(this.ctx.workspace, this.id), error: e.message };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** The created id, from whatever shape the mutation answered with. */
|
|
182
|
+
const producedId = (result) => {
|
|
183
|
+
if (!result || typeof result !== 'object')
|
|
184
|
+
return undefined;
|
|
185
|
+
const seen = new Set();
|
|
186
|
+
const walk = (v, depth) => {
|
|
187
|
+
if (depth > 4 || !v || typeof v !== 'object' || seen.has(v))
|
|
188
|
+
return undefined;
|
|
189
|
+
seen.add(v);
|
|
190
|
+
const o = v;
|
|
191
|
+
if (typeof o['id'] === 'string' || typeof o['id'] === 'number')
|
|
192
|
+
return o['id'];
|
|
193
|
+
for (const val of Object.values(o)) {
|
|
194
|
+
const hit = walk(val, depth + 1);
|
|
195
|
+
if (hit !== undefined)
|
|
196
|
+
return hit;
|
|
197
|
+
}
|
|
198
|
+
return undefined;
|
|
199
|
+
};
|
|
200
|
+
return walk(result, 0);
|
|
201
|
+
};
|
|
202
|
+
const OUTCOME_WORD = {
|
|
203
|
+
ok: 'succeeded',
|
|
204
|
+
partial: 'partially applied',
|
|
205
|
+
failed: 'failed',
|
|
206
|
+
refused: 'refused before sending anything',
|
|
207
|
+
'no-op': 'had nothing to do',
|
|
208
|
+
};
|
|
209
|
+
export const renderRunReport = (r, after, before) => {
|
|
210
|
+
const L = [];
|
|
211
|
+
const secs = (r.durationMs / 1000).toFixed(1);
|
|
212
|
+
L.push(`# ${r.kind} — ${r.repo.name} (${r.repo.id})`, '');
|
|
213
|
+
L.push(`**${OUTCOME_WORD[r.outcome]}** · ${r.summary}`, '');
|
|
214
|
+
L.push('| | |', '| --- | --- |');
|
|
215
|
+
L.push(`| Run | \`${r.id}\` |`);
|
|
216
|
+
L.push(`| Command | \`${r.command}\` |`);
|
|
217
|
+
L.push(`| Started | ${r.startedAt} |`);
|
|
218
|
+
L.push(`| Duration | ${secs}s |`);
|
|
219
|
+
L.push(`| Workspace | \`${r.workspace}\` |`);
|
|
220
|
+
if (r.planId)
|
|
221
|
+
L.push(`| Plan | \`${r.planId}\` |`);
|
|
222
|
+
if (r.preApplyVersionId)
|
|
223
|
+
L.push(`| Pre-apply snapshot | \`${r.preApplyVersionId}\` |`);
|
|
224
|
+
if (after)
|
|
225
|
+
L.push(`| Read path | ${after.source}${after.versionId ? ` \`${after.versionId.slice(0, 8)}\`` : ''} |`);
|
|
226
|
+
if (r.counts.steps) {
|
|
227
|
+
L.push(`| Steps | ${r.counts.steps} — ${r.counts.done} done, ${r.counts.failed} failed, ` +
|
|
228
|
+
`${r.counts.skipped} skipped${r.counts.pending ? `, ${r.counts.pending} never attempted` : ''} |`);
|
|
229
|
+
}
|
|
230
|
+
if (r.verification)
|
|
231
|
+
L.push(`| Verification | ${r.verification.ok ? 'clean' : 'found differences'} |`);
|
|
232
|
+
L.push('');
|
|
233
|
+
if (r.steps.length) {
|
|
234
|
+
L.push('## Steps', '');
|
|
235
|
+
L.push('| # | Step | Entity | Op | Status | Result |', '| --- | --- | --- | --- | --- | --- |');
|
|
236
|
+
for (const s of r.steps) {
|
|
237
|
+
const result = s.error
|
|
238
|
+
? `\`${s.error.replace(/\|/g, '\\|').slice(0, 160)}\``
|
|
239
|
+
: s.produced !== undefined
|
|
240
|
+
? `id ${s.produced}`
|
|
241
|
+
: s.status === 'done'
|
|
242
|
+
? 'applied'
|
|
243
|
+
: '—';
|
|
244
|
+
const tags = s.tags.length ? ` <sub>${s.tags.join(' ')}</sub>` : '';
|
|
245
|
+
L.push(`| ${s.index} | ${s.description.replace(/\|/g, '\\|')}${tags} | ${s.entity} | ${s.op} | ` +
|
|
246
|
+
`${statusMark(s.status)} | ${result} |`);
|
|
247
|
+
}
|
|
248
|
+
L.push('');
|
|
249
|
+
const failures = r.steps.filter((s) => s.status === 'failed' || s.status === 'skipped');
|
|
250
|
+
if (failures.length) {
|
|
251
|
+
L.push('### What did not land', '');
|
|
252
|
+
for (const s of failures) {
|
|
253
|
+
L.push(`- **${s.description}** (${s.status}) — ${s.error ?? 'no reason recorded'}`);
|
|
254
|
+
}
|
|
255
|
+
L.push('');
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (r.blocked.length) {
|
|
259
|
+
L.push('## Refused before sending', '');
|
|
260
|
+
L.push('These changes are in the workspace and have no write path. Nothing was attempted.', '');
|
|
261
|
+
for (const b of r.blocked) {
|
|
262
|
+
L.push(`- **${b.label}** — ${b.reason ?? 'no write path'}${b.ask ? ` (ask ${b.ask})` : ''}`);
|
|
263
|
+
}
|
|
264
|
+
L.push('');
|
|
265
|
+
}
|
|
266
|
+
if (r.warnings.length) {
|
|
267
|
+
L.push('## Warnings', '');
|
|
268
|
+
for (const w of r.warnings)
|
|
269
|
+
L.push(`- ${w}`);
|
|
270
|
+
L.push('');
|
|
271
|
+
}
|
|
272
|
+
if (after) {
|
|
273
|
+
const t = after.payload.relations;
|
|
274
|
+
L.push('## The pipe, as of the end of this run', '');
|
|
275
|
+
L.push('| Group | Count | Coverage |', '| --- | --- | --- |');
|
|
276
|
+
for (const [name, rows] of Object.entries(t)) {
|
|
277
|
+
const cov = (r.coverage ?? {})[name] ?? 'unknown';
|
|
278
|
+
if (!rows.length && cov === 'complete')
|
|
279
|
+
continue;
|
|
280
|
+
L.push(`| ${name} | ${rows.length} | ${cov}${cov === 'complete' ? '' : ' ⚠'} |`);
|
|
281
|
+
}
|
|
282
|
+
L.push('');
|
|
283
|
+
const gaps = Object.entries(r.coverage ?? {}).filter(([, c]) => c !== 'complete');
|
|
284
|
+
if (gaps.length) {
|
|
285
|
+
L.push(`⚠ ${gaps.length} group${gaps.length === 1 ? '' : 's'} not fully read. ` +
|
|
286
|
+
'A count of 0 there means "not read", not "empty".', '');
|
|
287
|
+
}
|
|
288
|
+
L.push('## Integrations', '');
|
|
289
|
+
if (before) {
|
|
290
|
+
const b = integrationsFrom(before).counts;
|
|
291
|
+
const a = integrationsFrom(after).counts;
|
|
292
|
+
const moved = Object.keys(a).filter((k) => a[k] !== b[k]);
|
|
293
|
+
if (moved.length) {
|
|
294
|
+
L.push(`Changed by this run: ${moved.map((k) => `${k} ${b[k]} → ${a[k]}`).join(', ')}.`, '');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
L.push(...renderIntegrations(integrationsFrom(after)));
|
|
298
|
+
L.push('');
|
|
299
|
+
}
|
|
300
|
+
if (r.notes.length) {
|
|
301
|
+
L.push('## Notes', '');
|
|
302
|
+
for (const n of r.notes)
|
|
303
|
+
L.push(`- ${n}`);
|
|
304
|
+
L.push('');
|
|
305
|
+
}
|
|
306
|
+
L.push('---', '', `Artifacts in this folder: ${r.artifacts.map((a) => `\`${a}\``).join(', ')}.`);
|
|
307
|
+
L.push('', '`pipe.json` and `integrations.json` are full copies of the pipe and include ' +
|
|
308
|
+
'webhook URLs and HTTP-request credentials. This report does not.');
|
|
309
|
+
return L.join('\n') + '\n';
|
|
310
|
+
};
|
|
311
|
+
const statusMark = (status) => status === 'done' ? 'done' : status === 'failed' ? '**failed**' : status === 'skipped' ? 'skipped' : status;
|
|
312
|
+
/**
|
|
313
|
+
* One line per run in `runs/index.md`, newest last.
|
|
314
|
+
*
|
|
315
|
+
* Appended rather than regenerated so that a run folder someone has deleted or
|
|
316
|
+
* moved does not silently vanish from the history.
|
|
317
|
+
*/
|
|
318
|
+
const appendIndex = async (root, r) => {
|
|
319
|
+
const path = join(runsDir(root), 'index.md');
|
|
320
|
+
const header = [
|
|
321
|
+
'# Runs',
|
|
322
|
+
'',
|
|
323
|
+
'Every invocation that read or wrote this pipe, oldest first. Each row links to',
|
|
324
|
+
'the run folder, which holds the report, the payload, and the integration surface',
|
|
325
|
+
'as of the end of that run.',
|
|
326
|
+
'',
|
|
327
|
+
'| When | Run | Kind | Outcome | Steps | Summary |',
|
|
328
|
+
'| --- | --- | --- | --- | --- | --- |',
|
|
329
|
+
].join('\n');
|
|
330
|
+
if (!(await exists(path)))
|
|
331
|
+
await writeFile(path, header + '\n');
|
|
332
|
+
const row = `| ${r.startedAt.replace('T', ' ').slice(0, 19)} | [${r.id}](${r.id}/report.md) | ${r.kind} | ` +
|
|
333
|
+
`${r.outcome} | ${r.counts.steps ? `${r.counts.done}/${r.counts.steps}` : '—'} | ` +
|
|
334
|
+
`${r.summary.replace(/\|/g, '\\|')} |\n`;
|
|
335
|
+
await fs.appendFile(path, row, 'utf8');
|
|
336
|
+
};
|
|
337
|
+
/** Read every run record in a workspace, oldest first. */
|
|
338
|
+
export const listRuns = async (root) => {
|
|
339
|
+
const dir = runsDir(root);
|
|
340
|
+
if (!(await exists(dir)))
|
|
341
|
+
return [];
|
|
342
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
343
|
+
const out = [];
|
|
344
|
+
for (const e of entries) {
|
|
345
|
+
if (!e.isDirectory())
|
|
346
|
+
continue;
|
|
347
|
+
try {
|
|
348
|
+
out.push(JSON.parse(await fs.readFile(join(dir, e.name, 'run.json'), 'utf8')));
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// A run folder without a readable run.json is skipped, not fatal: the
|
|
352
|
+
// report beside it may still be the thing someone wants to read.
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return out.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
356
|
+
};
|
package/dist/util/fsx.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { pretty } from "./json.js";
|
|
4
|
+
export const mkdirp = (p) => fs.mkdir(p, { recursive: true });
|
|
5
|
+
export const writeFile = async (p, content) => {
|
|
6
|
+
await mkdirp(dirname(p));
|
|
7
|
+
await fs.writeFile(p, content, 'utf8');
|
|
8
|
+
};
|
|
9
|
+
export const writeJson = (p, v) => writeFile(p, pretty(v));
|
|
10
|
+
export const readJson = async (p) => {
|
|
11
|
+
const raw = await fs.readFile(p, 'utf8');
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(raw);
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
throw new Error(`${p}: invalid JSON - ${e.message}`);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
export const exists = async (p) => {
|
|
20
|
+
try {
|
|
21
|
+
await fs.stat(p);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
/** Every .json file under a directory, recursively, sorted. */
|
|
29
|
+
export const listJson = async (dir) => {
|
|
30
|
+
const acc = [];
|
|
31
|
+
const walk = async (d) => {
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = (await fs.readdir(d, { withFileTypes: true }));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
const full = join(d, e.name);
|
|
41
|
+
if (e.isDirectory())
|
|
42
|
+
await walk(full);
|
|
43
|
+
else if (e.name.endsWith('.json'))
|
|
44
|
+
acc.push(full);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
await walk(dir);
|
|
48
|
+
return acc.sort();
|
|
49
|
+
};
|
|
50
|
+
export const rmrf = (p) => fs.rm(p, { recursive: true, force: true });
|
package/dist/util/git.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
/**
|
|
5
|
+
* Best-effort, and never throws: a workspace with no local version control is
|
|
6
|
+
* an advisory, not a blocker. `pipe rollback` only restores the *latest*
|
|
7
|
+
* snapshot (commands/misc.ts), so it stops covering a rollback the moment
|
|
8
|
+
* anything re-snapshots the repo — which a normal `pipe apply` does on its own
|
|
9
|
+
* read-back. A local git history is what covers the gap.
|
|
10
|
+
*/
|
|
11
|
+
export const checkGit = async (root) => {
|
|
12
|
+
try {
|
|
13
|
+
await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root });
|
|
14
|
+
return { installed: true, tracked: true };
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
try {
|
|
18
|
+
await execFileAsync('git', ['--version']);
|
|
19
|
+
return { installed: true, tracked: false };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return { installed: false, tracked: false };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON. PLAN.md §3: the round-trip exit criterion is *semantic*
|
|
3
|
+
* equality under a canonical form, never byte equality — `JSON.parse` turns
|
|
4
|
+
* `index: 6.0` into `6`, and fractional indices invite float noise.
|
|
5
|
+
*/
|
|
6
|
+
/** Normalise a number to a stable representation. -0 -> 0, 6.0 -> 6. */
|
|
7
|
+
const num = (n) => {
|
|
8
|
+
if (!Number.isFinite(n))
|
|
9
|
+
return n;
|
|
10
|
+
if (Object.is(n, -0))
|
|
11
|
+
return 0;
|
|
12
|
+
// Collapse float noise past 10 significant decimals; index and lateness are
|
|
13
|
+
// the only fractional numbers in the payload and never need more.
|
|
14
|
+
const r = Number(n.toFixed(10));
|
|
15
|
+
return Object.is(r, -0) ? 0 : r;
|
|
16
|
+
};
|
|
17
|
+
export const canonical = (v) => {
|
|
18
|
+
if (v === null || v === undefined)
|
|
19
|
+
return null;
|
|
20
|
+
if (typeof v === 'number')
|
|
21
|
+
return num(v);
|
|
22
|
+
if (typeof v === 'boolean' || typeof v === 'string')
|
|
23
|
+
return v;
|
|
24
|
+
if (Array.isArray(v))
|
|
25
|
+
return v.map(canonical);
|
|
26
|
+
if (typeof v === 'object') {
|
|
27
|
+
const o = v;
|
|
28
|
+
const res = {};
|
|
29
|
+
for (const k of Object.keys(o).sort()) {
|
|
30
|
+
if (o[k] === undefined)
|
|
31
|
+
continue;
|
|
32
|
+
res[k] = canonical(o[k]);
|
|
33
|
+
}
|
|
34
|
+
return res;
|
|
35
|
+
}
|
|
36
|
+
return String(v);
|
|
37
|
+
};
|
|
38
|
+
export const canonicalString = (v) => JSON.stringify(canonical(v));
|
|
39
|
+
export const deepEqual = (a, b) => canonicalString(a) === canonicalString(b);
|
|
40
|
+
/** Stable pretty JSON for files a human and Claude will read and edit. */
|
|
41
|
+
export const pretty = (v) => JSON.stringify(v, null, 2) + '\n';
|
|
42
|
+
export const clone = (v) => (v === undefined ? v : JSON.parse(JSON.stringify(v)));
|
|
43
|
+
/** Walk every string in a structure, replacing it. Used by the %{id} rewriter. */
|
|
44
|
+
export const mapStrings = (v, fn) => {
|
|
45
|
+
if (typeof v === 'string')
|
|
46
|
+
return fn(v);
|
|
47
|
+
if (Array.isArray(v))
|
|
48
|
+
return v.map((x) => mapStrings(x, fn));
|
|
49
|
+
if (v && typeof v === 'object') {
|
|
50
|
+
const res = {};
|
|
51
|
+
for (const [k, val] of Object.entries(v))
|
|
52
|
+
res[k] = mapStrings(val, fn);
|
|
53
|
+
return res;
|
|
54
|
+
}
|
|
55
|
+
return v;
|
|
56
|
+
};
|