@pipefy/pipefy-process-coder 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -0
- package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -0
- package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -0
- package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -0
- package/AGENTS.md +456 -0
- package/README.md +808 -0
- package/bin/pipe.js +13 -0
- package/package.json +35 -0
- package/src/apply/adopt.ts +150 -0
- package/src/apply/agentops.ts +71 -0
- package/src/apply/compile.ts +875 -0
- package/src/apply/execute.ts +336 -0
- package/src/apply/flowops.ts +399 -0
- package/src/apply/idmap.ts +241 -0
- package/src/apply/mutations.ts +955 -0
- package/src/apply/registry.ts +430 -0
- package/src/apply/types.ts +134 -0
- package/src/cli/args.ts +88 -0
- package/src/cli.ts +211 -0
- package/src/codec/flow.ts +199 -0
- package/src/codec/pack.ts +103 -0
- package/src/codec/roundtrip.ts +94 -0
- package/src/codec/unpack.ts +198 -0
- package/src/commands/agents.ts +184 -0
- package/src/commands/apply.ts +1144 -0
- package/src/commands/context.ts +119 -0
- package/src/commands/create.ts +79 -0
- package/src/commands/diff.ts +314 -0
- package/src/commands/flows.ts +414 -0
- package/src/commands/misc.ts +644 -0
- package/src/commands/plan.ts +331 -0
- package/src/commands/pull.ts +567 -0
- package/src/commands/runs.ts +83 -0
- package/src/commands/skills.ts +137 -0
- package/src/commands/verify.ts +253 -0
- package/src/config.ts +168 -0
- package/src/diff/agents.ts +122 -0
- package/src/diff/diff.ts +1130 -0
- package/src/diff/flow.ts +318 -0
- package/src/diff/html.ts +322 -0
- package/src/diff/render.ts +101 -0
- package/src/model/payload.ts +154 -0
- package/src/model/tree.ts +99 -0
- package/src/model/volatile.ts +55 -0
- package/src/pipefy/agents.ts +165 -0
- package/src/pipefy/automations.ts +219 -0
- package/src/pipefy/capability.ts +119 -0
- package/src/pipefy/client.ts +267 -0
- package/src/pipefy/discovery.ts +209 -0
- package/src/pipefy/internal.ts +380 -0
- package/src/pipefy/ipaas.ts +365 -0
- package/src/pipefy/reconstruct.ts +775 -0
- package/src/pipefy/reference.ts +251 -0
- package/src/pipefy/snapshot.ts +245 -0
- package/src/pipefy/toolkit.ts +200 -0
- package/src/pipefy/toolkit_bearer.py +137 -0
- package/src/report/integrations.ts +231 -0
- package/src/report/run.ts +475 -0
- package/src/util/fsx.ts +45 -0
- package/src/util/git.ts +32 -0
- package/src/util/json.ts +55 -0
- package/src/util/log.ts +76 -0
- package/src/util/pool.ts +48 -0
- package/src/util/slug.ts +26 -0
- package/src/util/tui.ts +335 -0
- package/src/validate/index.ts +123 -0
- package/src/validate/integrity.ts +387 -0
- package/src/validate/reference.ts +136 -0
- package/src/validate/schema.ts +328 -0
- package/src/workspace/agents.ts +290 -0
- package/src/workspace/docs.ts +407 -0
- package/src/workspace/flows.ts +191 -0
- package/src/workspace/layout.ts +165 -0
- package/src/workspace/lock.ts +148 -0
- package/src/workspace/read.ts +165 -0
- package/src/workspace/reference.ts +24 -0
- package/src/workspace/stamp.ts +301 -0
- package/src/workspace/write.ts +225 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { mkdirp, writeFile, writeJson, exists } from '../util/fsx.ts';
|
|
4
|
+
import { paths } from '../workspace/layout.ts';
|
|
5
|
+
import type { PayloadEnvelope } from '../model/payload.ts';
|
|
6
|
+
import type { Plan } from '../apply/types.ts';
|
|
7
|
+
import { integrationsFrom, renderIntegrations, type IntegrationsView } from './integrations.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A run record: what one invocation of the tool did to a pipe, kept in the
|
|
11
|
+
* workspace next to the state it produced.
|
|
12
|
+
*
|
|
13
|
+
* The reason this is a first-class artifact rather than terminal scrollback is
|
|
14
|
+
* that the write path is non-atomic (PLAN.md §5). When an apply half-succeeds,
|
|
15
|
+
* the question a week later is not "what did the plan say" — the plan file
|
|
16
|
+
* already answers that — but "which of those calls actually landed, in what
|
|
17
|
+
* order, and what did the pipe look like on either side of it". The plan file
|
|
18
|
+
* holds the requests; the run folder holds the *outcome*, plus the state.
|
|
19
|
+
*
|
|
20
|
+
* Each run gets a directory:
|
|
21
|
+
*
|
|
22
|
+
* runs/2026-08-30T00-12-04Z--apply--a1b2c3/
|
|
23
|
+
* report.md what happened, readable, credentials redacted
|
|
24
|
+
* run.json the same thing structured
|
|
25
|
+
* pipe.json the payload envelope as of the end of the run
|
|
26
|
+
* integrations.json the outward-facing surface, raw
|
|
27
|
+
* pipe.before.json the payload the run started from (writes only)
|
|
28
|
+
* integrations.before.json
|
|
29
|
+
*
|
|
30
|
+
* `report.md` is written to be shareable — it names hosts and ids but never a
|
|
31
|
+
* credential. The two `.json` payloads are not: they are a full copy of the
|
|
32
|
+
* pipe, HTTP-request auth values included.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export type RunKind = 'pull' | 'plan' | 'apply' | 'dry-run' | 'verify' | 'scaffold';
|
|
36
|
+
|
|
37
|
+
export type RunOutcome = 'ok' | 'partial' | 'failed' | 'refused' | 'no-op';
|
|
38
|
+
|
|
39
|
+
export type RunStepRecord = {
|
|
40
|
+
index: number;
|
|
41
|
+
id: string;
|
|
42
|
+
description: string;
|
|
43
|
+
entity: string;
|
|
44
|
+
op: string;
|
|
45
|
+
kind: string;
|
|
46
|
+
status: string;
|
|
47
|
+
/** Destructive / unverified / internal-api, as the plan tagged it. */
|
|
48
|
+
tags: string[];
|
|
49
|
+
error?: string;
|
|
50
|
+
/** The id the step created, when it created one. */
|
|
51
|
+
produced?: string | number;
|
|
52
|
+
durationMs?: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type RunRecord = {
|
|
56
|
+
id: string;
|
|
57
|
+
kind: RunKind;
|
|
58
|
+
/** The command as invoked, for reproduction. Flag values are kept; tokens are not. */
|
|
59
|
+
command: string;
|
|
60
|
+
tool: { name: string; version: string };
|
|
61
|
+
startedAt: string;
|
|
62
|
+
finishedAt: string;
|
|
63
|
+
durationMs: number;
|
|
64
|
+
workspace: string;
|
|
65
|
+
repo: { id: number; uuid: string | null; name: string; kind: 'pipe' | 'table' };
|
|
66
|
+
outcome: RunOutcome;
|
|
67
|
+
/** One line, the way the terminal summarised it. */
|
|
68
|
+
summary: string;
|
|
69
|
+
planId?: string;
|
|
70
|
+
preApplyVersionId?: string | null;
|
|
71
|
+
counts: { steps: number; done: number; failed: number; skipped: number; pending: number; blocked: number };
|
|
72
|
+
steps: RunStepRecord[];
|
|
73
|
+
blocked: Array<{ label: string; entity: string; op: string; reason?: string; ask?: number }>;
|
|
74
|
+
warnings: string[];
|
|
75
|
+
notes: string[];
|
|
76
|
+
verification?: { ran: boolean; ok: boolean; detail?: string };
|
|
77
|
+
/** Read coverage of the state this run ended with. */
|
|
78
|
+
coverage?: Record<string, string>;
|
|
79
|
+
integrations?: { before?: IntegrationsView['counts']; after?: IntegrationsView['counts'] };
|
|
80
|
+
artifacts: string[];
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type RunContext = {
|
|
84
|
+
workspace: string;
|
|
85
|
+
repo: { id: number; uuid: string | null; name: string; kind: 'pipe' | 'table' };
|
|
86
|
+
command: string;
|
|
87
|
+
/** When the command began, for commands that learn their workspace late. */
|
|
88
|
+
startedAt?: Date;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const stamp = (d = new Date()) => d.toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z');
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The command as typed, for reproduction — with any token elided. `--token` is
|
|
95
|
+
* the one flag whose value must never reach a file, and `pipe login --token` is
|
|
96
|
+
* a real invocation someone might run inside a workspace.
|
|
97
|
+
*/
|
|
98
|
+
export const invocation = (argv = process.argv.slice(2)): string => {
|
|
99
|
+
const parts: string[] = [];
|
|
100
|
+
for (let i = 0; i < argv.length; i++) {
|
|
101
|
+
const a = argv[i] as string;
|
|
102
|
+
if (/^--token(=|$)/.test(a)) {
|
|
103
|
+
parts.push('--token <elided>');
|
|
104
|
+
if (!a.includes('=') && argv[i + 1] && !argv[i + 1]!.startsWith('-')) i++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
parts.push(a);
|
|
108
|
+
}
|
|
109
|
+
return `pipe ${parts.join(' ')}`.trim();
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** `runs/` lives at the workspace root, not under `.ppc/` — these are for reading. */
|
|
113
|
+
export const runsDir = (root: string) => join(paths(root).root, 'runs');
|
|
114
|
+
|
|
115
|
+
export const runDir = (root: string, id: string) => join(runsDir(root), id);
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Collects what happened while a command runs.
|
|
119
|
+
*
|
|
120
|
+
* Deliberately tolerant: a recorder that throws would turn a successful apply
|
|
121
|
+
* into a failed command, and the record is worth less than the work it
|
|
122
|
+
* describes. Everything here is additive and total.
|
|
123
|
+
*/
|
|
124
|
+
export class RunRecorder {
|
|
125
|
+
readonly id: string;
|
|
126
|
+
readonly startedAt: Date;
|
|
127
|
+
private notes: string[] = [];
|
|
128
|
+
private warnings: string[] = [];
|
|
129
|
+
private plan: Plan | null = null;
|
|
130
|
+
private verification: RunRecord['verification'] | undefined;
|
|
131
|
+
private beforeEnv: PayloadEnvelope | null = null;
|
|
132
|
+
private afterEnv: PayloadEnvelope | null = null;
|
|
133
|
+
|
|
134
|
+
readonly kind: RunKind;
|
|
135
|
+
readonly ctx: RunContext;
|
|
136
|
+
|
|
137
|
+
constructor(kind: RunKind, ctx: RunContext) {
|
|
138
|
+
this.kind = kind;
|
|
139
|
+
this.ctx = ctx;
|
|
140
|
+
/**
|
|
141
|
+
* `startedAt` is passed in by commands that only learn where the workspace
|
|
142
|
+
* is at the end — a pull cannot name its own output directory until
|
|
143
|
+
* discovery has run, and a run that reports 0.0s for a 60-second snapshot is
|
|
144
|
+
* worse than no timing at all.
|
|
145
|
+
*/
|
|
146
|
+
this.startedAt = ctx.startedAt ?? new Date();
|
|
147
|
+
this.id = `${stamp(this.startedAt)}--${kind}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
note(text: string) {
|
|
151
|
+
this.notes.push(text);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
warn(text: string) {
|
|
155
|
+
this.warnings.push(text);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The plan is the step list; recording it is how the report gets its detail. */
|
|
159
|
+
fromPlan(plan: Plan) {
|
|
160
|
+
this.plan = plan;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
verified(ok: boolean, detail?: string) {
|
|
164
|
+
this.verification = { ran: true, ok, ...(detail ? { detail } : {}) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The payload the run started from, when the command knows it. */
|
|
168
|
+
before(env: PayloadEnvelope | null) {
|
|
169
|
+
this.beforeEnv = env;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
after(env: PayloadEnvelope | null) {
|
|
173
|
+
this.afterEnv = env;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
build(outcome: RunOutcome, summary: string): RunRecord {
|
|
177
|
+
const finished = new Date();
|
|
178
|
+
const steps = (this.plan?.steps ?? []).map((s): RunStepRecord => {
|
|
179
|
+
const started = s.startedAt ? Date.parse(s.startedAt) : NaN;
|
|
180
|
+
const ended = s.finishedAt ? Date.parse(s.finishedAt) : NaN;
|
|
181
|
+
return {
|
|
182
|
+
index: s.index,
|
|
183
|
+
id: s.id,
|
|
184
|
+
description: s.description,
|
|
185
|
+
entity: String(s.entity),
|
|
186
|
+
op: s.op,
|
|
187
|
+
kind: s.kind,
|
|
188
|
+
status: s.status,
|
|
189
|
+
tags: [
|
|
190
|
+
...(s.destructive ? ['destructive'] : []),
|
|
191
|
+
...(s.unverified ? ['unverified'] : []),
|
|
192
|
+
...(s.kind.startsWith('internal') ? ['internal-api'] : []),
|
|
193
|
+
],
|
|
194
|
+
...(s.error ? { error: s.error } : {}),
|
|
195
|
+
...(producedId(s.result) !== undefined ? { produced: producedId(s.result) } : {}),
|
|
196
|
+
...(Number.isFinite(started) && Number.isFinite(ended) ? { durationMs: ended - started } : {}),
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const count = (status: string) => steps.filter((s) => s.status === status).length;
|
|
201
|
+
|
|
202
|
+
const env = this.afterEnv;
|
|
203
|
+
return {
|
|
204
|
+
id: this.id,
|
|
205
|
+
kind: this.kind,
|
|
206
|
+
command: this.ctx.command,
|
|
207
|
+
tool: { name: 'pipefy-process-coder', version: '0.1.0' },
|
|
208
|
+
startedAt: this.startedAt.toISOString(),
|
|
209
|
+
finishedAt: finished.toISOString(),
|
|
210
|
+
durationMs: finished.getTime() - this.startedAt.getTime(),
|
|
211
|
+
workspace: this.ctx.workspace,
|
|
212
|
+
repo: this.ctx.repo,
|
|
213
|
+
outcome,
|
|
214
|
+
summary,
|
|
215
|
+
...(this.plan ? { planId: this.plan.id, preApplyVersionId: this.plan.preApplyVersionId ?? null } : {}),
|
|
216
|
+
counts: {
|
|
217
|
+
steps: steps.length,
|
|
218
|
+
done: count('done'),
|
|
219
|
+
failed: count('failed'),
|
|
220
|
+
skipped: count('skipped'),
|
|
221
|
+
pending: count('pending'),
|
|
222
|
+
blocked: this.plan?.blocked.length ?? 0,
|
|
223
|
+
},
|
|
224
|
+
steps,
|
|
225
|
+
blocked: (this.plan?.blocked ?? []).map((b) => ({
|
|
226
|
+
label: b.label,
|
|
227
|
+
entity: String(b.entity),
|
|
228
|
+
op: b.op,
|
|
229
|
+
...(b.reason ? { reason: b.reason } : {}),
|
|
230
|
+
...(b.ask ? { ask: b.ask } : {}),
|
|
231
|
+
})),
|
|
232
|
+
// Deduplicated: a multi-repo pull collects the same coverage note from
|
|
233
|
+
// every tree, and three copies of it read as three problems.
|
|
234
|
+
warnings: [...new Set([...(this.plan?.warnings ?? []), ...this.warnings])],
|
|
235
|
+
notes: [...new Set(this.notes)],
|
|
236
|
+
...(this.verification ? { verification: this.verification } : {}),
|
|
237
|
+
...(env ? { coverage: env.coverage as unknown as Record<string, string> } : {}),
|
|
238
|
+
integrations: {
|
|
239
|
+
...(this.beforeEnv ? { before: integrationsFrom(this.beforeEnv).counts } : {}),
|
|
240
|
+
...(this.afterEnv ? { after: integrationsFrom(this.afterEnv).counts } : {}),
|
|
241
|
+
},
|
|
242
|
+
artifacts: [],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Write the run folder. Never throws: a failure to record is reported as a
|
|
248
|
+
* note and nothing more, because the alternative is a command that did its
|
|
249
|
+
* work and then reported failure.
|
|
250
|
+
*/
|
|
251
|
+
async write(outcome: RunOutcome, summary: string): Promise<{ dir: string; error?: string } | null> {
|
|
252
|
+
try {
|
|
253
|
+
const record = this.build(outcome, summary);
|
|
254
|
+
const dir = runDir(this.ctx.workspace, record.id);
|
|
255
|
+
await mkdirp(dir);
|
|
256
|
+
|
|
257
|
+
const written: string[] = [];
|
|
258
|
+
const put = async (name: string, value: unknown) => {
|
|
259
|
+
await writeJson(join(dir, name), value);
|
|
260
|
+
written.push(name);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
if (this.afterEnv) {
|
|
264
|
+
await put('pipe.json', this.afterEnv);
|
|
265
|
+
await put('integrations.json', integrationsFrom(this.afterEnv));
|
|
266
|
+
}
|
|
267
|
+
if (this.beforeEnv && this.kind !== 'pull') {
|
|
268
|
+
await put('pipe.before.json', this.beforeEnv);
|
|
269
|
+
await put('integrations.before.json', integrationsFrom(this.beforeEnv));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
record.artifacts = [...written, 'report.md', 'run.json'];
|
|
273
|
+
await writeJson(join(dir, 'run.json'), record);
|
|
274
|
+
await writeFile(join(dir, 'report.md'), renderRunReport(record, this.afterEnv, this.beforeEnv));
|
|
275
|
+
await appendIndex(this.ctx.workspace, record);
|
|
276
|
+
return { dir };
|
|
277
|
+
} catch (e) {
|
|
278
|
+
return { dir: runDir(this.ctx.workspace, this.id), error: (e as Error).message };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The created id, from whatever shape the mutation answered with. */
|
|
284
|
+
const producedId = (result: unknown): string | number | undefined => {
|
|
285
|
+
if (!result || typeof result !== 'object') return undefined;
|
|
286
|
+
const seen = new Set<unknown>();
|
|
287
|
+
const walk = (v: unknown, depth: number): string | number | undefined => {
|
|
288
|
+
if (depth > 4 || !v || typeof v !== 'object' || seen.has(v)) return undefined;
|
|
289
|
+
seen.add(v);
|
|
290
|
+
const o = v as Record<string, unknown>;
|
|
291
|
+
if (typeof o['id'] === 'string' || typeof o['id'] === 'number') return o['id'] as string | number;
|
|
292
|
+
for (const val of Object.values(o)) {
|
|
293
|
+
const hit = walk(val, depth + 1);
|
|
294
|
+
if (hit !== undefined) return hit;
|
|
295
|
+
}
|
|
296
|
+
return undefined;
|
|
297
|
+
};
|
|
298
|
+
return walk(result, 0);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const OUTCOME_WORD: Record<RunOutcome, string> = {
|
|
302
|
+
ok: 'succeeded',
|
|
303
|
+
partial: 'partially applied',
|
|
304
|
+
failed: 'failed',
|
|
305
|
+
refused: 'refused before sending anything',
|
|
306
|
+
'no-op': 'had nothing to do',
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
export const renderRunReport = (
|
|
310
|
+
r: RunRecord,
|
|
311
|
+
after: PayloadEnvelope | null,
|
|
312
|
+
before: PayloadEnvelope | null,
|
|
313
|
+
): string => {
|
|
314
|
+
const L: string[] = [];
|
|
315
|
+
const secs = (r.durationMs / 1000).toFixed(1);
|
|
316
|
+
|
|
317
|
+
L.push(`# ${r.kind} — ${r.repo.name} (${r.repo.id})`, '');
|
|
318
|
+
L.push(`**${OUTCOME_WORD[r.outcome]}** · ${r.summary}`, '');
|
|
319
|
+
L.push('| | |', '| --- | --- |');
|
|
320
|
+
L.push(`| Run | \`${r.id}\` |`);
|
|
321
|
+
L.push(`| Command | \`${r.command}\` |`);
|
|
322
|
+
L.push(`| Started | ${r.startedAt} |`);
|
|
323
|
+
L.push(`| Duration | ${secs}s |`);
|
|
324
|
+
L.push(`| Workspace | \`${r.workspace}\` |`);
|
|
325
|
+
if (r.planId) L.push(`| Plan | \`${r.planId}\` |`);
|
|
326
|
+
if (r.preApplyVersionId) L.push(`| Pre-apply snapshot | \`${r.preApplyVersionId}\` |`);
|
|
327
|
+
if (after) L.push(`| Read path | ${after.source}${after.versionId ? ` \`${after.versionId.slice(0, 8)}\`` : ''} |`);
|
|
328
|
+
if (r.counts.steps) {
|
|
329
|
+
L.push(
|
|
330
|
+
`| Steps | ${r.counts.steps} — ${r.counts.done} done, ${r.counts.failed} failed, ` +
|
|
331
|
+
`${r.counts.skipped} skipped${r.counts.pending ? `, ${r.counts.pending} never attempted` : ''} |`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
if (r.verification) L.push(`| Verification | ${r.verification.ok ? 'clean' : 'found differences'} |`);
|
|
335
|
+
L.push('');
|
|
336
|
+
|
|
337
|
+
if (r.steps.length) {
|
|
338
|
+
L.push('## Steps', '');
|
|
339
|
+
L.push('| # | Step | Entity | Op | Status | Result |', '| --- | --- | --- | --- | --- | --- |');
|
|
340
|
+
for (const s of r.steps) {
|
|
341
|
+
const result = s.error
|
|
342
|
+
? `\`${s.error.replace(/\|/g, '\\|').slice(0, 160)}\``
|
|
343
|
+
: s.produced !== undefined
|
|
344
|
+
? `id ${s.produced}`
|
|
345
|
+
: s.status === 'done'
|
|
346
|
+
? 'applied'
|
|
347
|
+
: '—';
|
|
348
|
+
const tags = s.tags.length ? ` <sub>${s.tags.join(' ')}</sub>` : '';
|
|
349
|
+
L.push(
|
|
350
|
+
`| ${s.index} | ${s.description.replace(/\|/g, '\\|')}${tags} | ${s.entity} | ${s.op} | ` +
|
|
351
|
+
`${statusMark(s.status)} | ${result} |`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
L.push('');
|
|
355
|
+
|
|
356
|
+
const failures = r.steps.filter((s) => s.status === 'failed' || s.status === 'skipped');
|
|
357
|
+
if (failures.length) {
|
|
358
|
+
L.push('### What did not land', '');
|
|
359
|
+
for (const s of failures) {
|
|
360
|
+
L.push(`- **${s.description}** (${s.status}) — ${s.error ?? 'no reason recorded'}`);
|
|
361
|
+
}
|
|
362
|
+
L.push('');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (r.blocked.length) {
|
|
367
|
+
L.push('## Refused before sending', '');
|
|
368
|
+
L.push('These changes are in the workspace and have no write path. Nothing was attempted.', '');
|
|
369
|
+
for (const b of r.blocked) {
|
|
370
|
+
L.push(`- **${b.label}** — ${b.reason ?? 'no write path'}${b.ask ? ` (ask ${b.ask})` : ''}`);
|
|
371
|
+
}
|
|
372
|
+
L.push('');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (r.warnings.length) {
|
|
376
|
+
L.push('## Warnings', '');
|
|
377
|
+
for (const w of r.warnings) L.push(`- ${w}`);
|
|
378
|
+
L.push('');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (after) {
|
|
382
|
+
const t = after.payload.relations;
|
|
383
|
+
L.push('## The pipe, as of the end of this run', '');
|
|
384
|
+
L.push('| Group | Count | Coverage |', '| --- | --- | --- |');
|
|
385
|
+
for (const [name, rows] of Object.entries(t)) {
|
|
386
|
+
const cov = (r.coverage ?? {})[name] ?? 'unknown';
|
|
387
|
+
if (!rows.length && cov === 'complete') continue;
|
|
388
|
+
L.push(`| ${name} | ${rows.length} | ${cov}${cov === 'complete' ? '' : ' ⚠'} |`);
|
|
389
|
+
}
|
|
390
|
+
L.push('');
|
|
391
|
+
const gaps = Object.entries(r.coverage ?? {}).filter(([, c]) => c !== 'complete');
|
|
392
|
+
if (gaps.length) {
|
|
393
|
+
L.push(
|
|
394
|
+
`⚠ ${gaps.length} group${gaps.length === 1 ? '' : 's'} not fully read. ` +
|
|
395
|
+
'A count of 0 there means "not read", not "empty".',
|
|
396
|
+
'',
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
L.push('## Integrations', '');
|
|
401
|
+
if (before) {
|
|
402
|
+
const b = integrationsFrom(before).counts;
|
|
403
|
+
const a = integrationsFrom(after).counts;
|
|
404
|
+
const moved = (Object.keys(a) as Array<keyof typeof a>).filter((k) => a[k] !== b[k]);
|
|
405
|
+
if (moved.length) {
|
|
406
|
+
L.push(`Changed by this run: ${moved.map((k) => `${k} ${b[k]} → ${a[k]}`).join(', ')}.`, '');
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
L.push(...renderIntegrations(integrationsFrom(after)));
|
|
410
|
+
L.push('');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (r.notes.length) {
|
|
414
|
+
L.push('## Notes', '');
|
|
415
|
+
for (const n of r.notes) L.push(`- ${n}`);
|
|
416
|
+
L.push('');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
L.push('---', '', `Artifacts in this folder: ${r.artifacts.map((a) => `\`${a}\``).join(', ')}.`);
|
|
420
|
+
L.push(
|
|
421
|
+
'',
|
|
422
|
+
'`pipe.json` and `integrations.json` are full copies of the pipe and include ' +
|
|
423
|
+
'webhook URLs and HTTP-request credentials. This report does not.',
|
|
424
|
+
);
|
|
425
|
+
return L.join('\n') + '\n';
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const statusMark = (status: string) =>
|
|
429
|
+
status === 'done' ? 'done' : status === 'failed' ? '**failed**' : status === 'skipped' ? 'skipped' : status;
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* One line per run in `runs/index.md`, newest last.
|
|
433
|
+
*
|
|
434
|
+
* Appended rather than regenerated so that a run folder someone has deleted or
|
|
435
|
+
* moved does not silently vanish from the history.
|
|
436
|
+
*/
|
|
437
|
+
const appendIndex = async (root: string, r: RunRecord) => {
|
|
438
|
+
const path = join(runsDir(root), 'index.md');
|
|
439
|
+
const header = [
|
|
440
|
+
'# Runs',
|
|
441
|
+
'',
|
|
442
|
+
'Every invocation that read or wrote this pipe, oldest first. Each row links to',
|
|
443
|
+
'the run folder, which holds the report, the payload, and the integration surface',
|
|
444
|
+
'as of the end of that run.',
|
|
445
|
+
'',
|
|
446
|
+
'| When | Run | Kind | Outcome | Steps | Summary |',
|
|
447
|
+
'| --- | --- | --- | --- | --- | --- |',
|
|
448
|
+
].join('\n');
|
|
449
|
+
|
|
450
|
+
if (!(await exists(path))) await writeFile(path, header + '\n');
|
|
451
|
+
|
|
452
|
+
const row =
|
|
453
|
+
`| ${r.startedAt.replace('T', ' ').slice(0, 19)} | [${r.id}](${r.id}/report.md) | ${r.kind} | ` +
|
|
454
|
+
`${r.outcome} | ${r.counts.steps ? `${r.counts.done}/${r.counts.steps}` : '—'} | ` +
|
|
455
|
+
`${r.summary.replace(/\|/g, '\\|')} |\n`;
|
|
456
|
+
await fs.appendFile(path, row, 'utf8');
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/** Read every run record in a workspace, oldest first. */
|
|
460
|
+
export const listRuns = async (root: string): Promise<RunRecord[]> => {
|
|
461
|
+
const dir = runsDir(root);
|
|
462
|
+
if (!(await exists(dir))) return [];
|
|
463
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
464
|
+
const out: RunRecord[] = [];
|
|
465
|
+
for (const e of entries) {
|
|
466
|
+
if (!e.isDirectory()) continue;
|
|
467
|
+
try {
|
|
468
|
+
out.push(JSON.parse(await fs.readFile(join(dir, e.name, 'run.json'), 'utf8')) as RunRecord);
|
|
469
|
+
} catch {
|
|
470
|
+
// A run folder without a readable run.json is skipped, not fatal: the
|
|
471
|
+
// report beside it may still be the thing someone wants to read.
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return out.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
475
|
+
};
|
package/src/util/fsx.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { pretty } from './json.ts';
|
|
4
|
+
|
|
5
|
+
export const mkdirp = (p: string) => fs.mkdir(p, { recursive: true });
|
|
6
|
+
|
|
7
|
+
export const writeFile = async (p: string, content: string) => {
|
|
8
|
+
await mkdirp(dirname(p));
|
|
9
|
+
await fs.writeFile(p, content, 'utf8');
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const writeJson = (p: string, v: unknown) => writeFile(p, pretty(v));
|
|
13
|
+
|
|
14
|
+
export const readJson = async <T>(p: string): Promise<T> => {
|
|
15
|
+
const raw = await fs.readFile(p, 'utf8');
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(raw) as T;
|
|
18
|
+
} catch (e) {
|
|
19
|
+
throw new Error(`${p}: invalid JSON - ${(e as Error).message}`);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const exists = async (p: string) => {
|
|
24
|
+
try { await fs.stat(p); return true; } catch { return false; }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Every .json file under a directory, recursively, sorted. */
|
|
28
|
+
export const listJson = async (dir: string): Promise<string[]> => {
|
|
29
|
+
const acc: string[] = [];
|
|
30
|
+
const walk = async (d: string) => {
|
|
31
|
+
let entries: Array<{ name: string; isDirectory(): boolean }>;
|
|
32
|
+
try {
|
|
33
|
+
entries = (await fs.readdir(d, { withFileTypes: true })) as unknown as Array<{ name: string; isDirectory(): boolean }>;
|
|
34
|
+
} catch { return; }
|
|
35
|
+
for (const e of entries) {
|
|
36
|
+
const full = join(d, e.name);
|
|
37
|
+
if (e.isDirectory()) await walk(full);
|
|
38
|
+
else if (e.name.endsWith('.json')) acc.push(full);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
await walk(dir);
|
|
42
|
+
return acc.sort();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const rmrf = (p: string) => fs.rm(p, { recursive: true, force: true });
|
package/src/util/git.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export type GitStatus = {
|
|
7
|
+
/** `git` resolves at all — on PATH, runnable. */
|
|
8
|
+
installed: boolean;
|
|
9
|
+
/** `root` sits inside a working tree git already tracks. */
|
|
10
|
+
tracked: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Best-effort, and never throws: a workspace with no local version control is
|
|
15
|
+
* an advisory, not a blocker. `pipe rollback` only restores the *latest*
|
|
16
|
+
* snapshot (commands/misc.ts), so it stops covering a rollback the moment
|
|
17
|
+
* anything re-snapshots the repo — which a normal `pipe apply` does on its own
|
|
18
|
+
* read-back. A local git history is what covers the gap.
|
|
19
|
+
*/
|
|
20
|
+
export const checkGit = async (root: string): Promise<GitStatus> => {
|
|
21
|
+
try {
|
|
22
|
+
await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root });
|
|
23
|
+
return { installed: true, tracked: true };
|
|
24
|
+
} catch {
|
|
25
|
+
try {
|
|
26
|
+
await execFileAsync('git', ['--version']);
|
|
27
|
+
return { installed: true, tracked: false };
|
|
28
|
+
} catch {
|
|
29
|
+
return { installed: false, tracked: false };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
package/src/util/json.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
|
|
7
|
+
export type Json = null | boolean | number | string | Json[] | { [k: string]: Json };
|
|
8
|
+
|
|
9
|
+
/** Normalise a number to a stable representation. -0 -> 0, 6.0 -> 6. */
|
|
10
|
+
const num = (n: number): number => {
|
|
11
|
+
if (!Number.isFinite(n)) return n;
|
|
12
|
+
if (Object.is(n, -0)) return 0;
|
|
13
|
+
// Collapse float noise past 10 significant decimals; index and lateness are
|
|
14
|
+
// the only fractional numbers in the payload and never need more.
|
|
15
|
+
const r = Number(n.toFixed(10));
|
|
16
|
+
return Object.is(r, -0) ? 0 : r;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const canonical = (v: unknown): Json => {
|
|
20
|
+
if (v === null || v === undefined) return null;
|
|
21
|
+
if (typeof v === 'number') return num(v);
|
|
22
|
+
if (typeof v === 'boolean' || typeof v === 'string') return v;
|
|
23
|
+
if (Array.isArray(v)) return v.map(canonical);
|
|
24
|
+
if (typeof v === 'object') {
|
|
25
|
+
const o = v as Record<string, unknown>;
|
|
26
|
+
const res: { [k: string]: Json } = {};
|
|
27
|
+
for (const k of Object.keys(o).sort()) {
|
|
28
|
+
if (o[k] === undefined) continue;
|
|
29
|
+
res[k] = canonical(o[k]);
|
|
30
|
+
}
|
|
31
|
+
return res;
|
|
32
|
+
}
|
|
33
|
+
return String(v);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const canonicalString = (v: unknown): string => JSON.stringify(canonical(v));
|
|
37
|
+
|
|
38
|
+
export const deepEqual = (a: unknown, b: unknown): boolean => canonicalString(a) === canonicalString(b);
|
|
39
|
+
|
|
40
|
+
/** Stable pretty JSON for files a human and Claude will read and edit. */
|
|
41
|
+
export const pretty = (v: unknown): string => JSON.stringify(v, null, 2) + '\n';
|
|
42
|
+
|
|
43
|
+
export const clone = <T>(v: T): T => (v === undefined ? v : (JSON.parse(JSON.stringify(v)) as T));
|
|
44
|
+
|
|
45
|
+
/** Walk every string in a structure, replacing it. Used by the %{id} rewriter. */
|
|
46
|
+
export const mapStrings = <T>(v: T, fn: (s: string) => string): T => {
|
|
47
|
+
if (typeof v === 'string') return fn(v) as unknown as T;
|
|
48
|
+
if (Array.isArray(v)) return v.map((x) => mapStrings(x, fn)) as unknown as T;
|
|
49
|
+
if (v && typeof v === 'object') {
|
|
50
|
+
const res: Record<string, unknown> = {};
|
|
51
|
+
for (const [k, val] of Object.entries(v as Record<string, unknown>)) res[k] = mapStrings(val, fn);
|
|
52
|
+
return res as unknown as T;
|
|
53
|
+
}
|
|
54
|
+
return v;
|
|
55
|
+
};
|
package/src/util/log.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const ESC = '\x1b[';
|
|
2
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
3
|
+
const c = (code: string) => (s: string) => (useColor ? `${ESC}${code}m${s}${ESC}0m` : s);
|
|
4
|
+
|
|
5
|
+
export const dim = c('2');
|
|
6
|
+
export const bold = c('1');
|
|
7
|
+
export const red = c('31');
|
|
8
|
+
export const green = c('32');
|
|
9
|
+
export const yellow = c('33');
|
|
10
|
+
export const blue = c('36');
|
|
11
|
+
export const magenta = c('35');
|
|
12
|
+
|
|
13
|
+
let verbose = false;
|
|
14
|
+
export const setVerbose = (v: boolean) => { verbose = v; };
|
|
15
|
+
export const isVerbose = () => verbose;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* While a live progress display owns the screen, ordinary log lines cannot go
|
|
19
|
+
* straight to stderr: the display repaints a block of lines in place, and an
|
|
20
|
+
* interleaved write lands inside that block and garbles the frame. A claimed
|
|
21
|
+
* sink receives them instead — see src/util/tui.ts.
|
|
22
|
+
*
|
|
23
|
+
* `step` is dropped rather than forwarded, being the progress chatter the
|
|
24
|
+
* display is already showing. Anything the user must not miss — warn, error,
|
|
25
|
+
* ok — is forwarded.
|
|
26
|
+
*/
|
|
27
|
+
export type LogSink = { note: (text: string) => void };
|
|
28
|
+
|
|
29
|
+
let sink: LogSink | null = null;
|
|
30
|
+
export const claimLogSink = (s: LogSink | null) => {
|
|
31
|
+
sink = s;
|
|
32
|
+
};
|
|
33
|
+
export const hasLogSink = () => sink !== null;
|
|
34
|
+
|
|
35
|
+
/** Colour is for a terminal. A report is a file, so it gets none. */
|
|
36
|
+
export const stripAnsi = (s: string) => s.replace(/\u001b\[[0-9;]*m/g, '');
|
|
37
|
+
export const out = (s = '') => process.stdout.write(s + '\n');
|
|
38
|
+
export const info = (s: string) => {
|
|
39
|
+
if (sink) sink.note(s);
|
|
40
|
+
else process.stderr.write(s + '\n');
|
|
41
|
+
};
|
|
42
|
+
export const debug = (s: string) => { if (verbose) process.stderr.write(dim('· ' + s) + '\n'); };
|
|
43
|
+
export const warn = (s: string) => {
|
|
44
|
+
if (sink) sink.note(yellow('! ') + s);
|
|
45
|
+
else process.stderr.write(yellow('! ') + s + '\n');
|
|
46
|
+
};
|
|
47
|
+
export const error = (s: string) => {
|
|
48
|
+
if (sink) sink.note(red('x ') + s);
|
|
49
|
+
else process.stderr.write(red('x ') + s + '\n');
|
|
50
|
+
};
|
|
51
|
+
export const ok = (s: string) => {
|
|
52
|
+
if (sink) sink.note(green('+ ') + s);
|
|
53
|
+
else process.stderr.write(green('+ ') + s + '\n');
|
|
54
|
+
};
|
|
55
|
+
export const step = (s: string) => {
|
|
56
|
+
if (sink) return;
|
|
57
|
+
process.stderr.write(blue('> ') + s + '\n');
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const CLEAR_LINE = `\r${ESC}2K`;
|
|
61
|
+
|
|
62
|
+
/** Single-line progress that collapses to nothing on a non-tty. */
|
|
63
|
+
export const progress = (s: string) => {
|
|
64
|
+
if (!process.stderr.isTTY) return;
|
|
65
|
+
process.stderr.write(CLEAR_LINE + dim(' ' + s));
|
|
66
|
+
};
|
|
67
|
+
export const progressDone = () => { if (process.stderr.isTTY) process.stderr.write(CLEAR_LINE); };
|
|
68
|
+
|
|
69
|
+
export class UserError extends Error {
|
|
70
|
+
hint: string | undefined;
|
|
71
|
+
constructor(message: string, hint?: string) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = 'UserError';
|
|
74
|
+
this.hint = hint;
|
|
75
|
+
}
|
|
76
|
+
}
|