@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,165 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { slug } from '../util/slug.ts';
|
|
3
|
+
import type { RepoRef } from '../pipefy/discovery.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Workspace layout — PLAN.md §4.
|
|
7
|
+
*
|
|
8
|
+
* Two deliberate deviations from the plan's sketch, both to make the edit loop
|
|
9
|
+
* shorter, and both noted in the generated AGENTS.md:
|
|
10
|
+
*
|
|
11
|
+
* - `phase-jumps.json` is gone. A phase's jump set lives in the phase file as
|
|
12
|
+
* `jump_target_ids`, because the internal endpoint is per-phase and
|
|
13
|
+
* set-replacing (PLAN.md §2.1), so the set is the honest shape and there is
|
|
14
|
+
* no second file to keep consistent.
|
|
15
|
+
* - `webhooks.json` is one array file rather than a directory, matching
|
|
16
|
+
* `labels.json`. Webhooks are few and have no sub-entities.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const PPC_DIR = '.ppc';
|
|
20
|
+
|
|
21
|
+
export const paths = (root: string) => ({
|
|
22
|
+
root,
|
|
23
|
+
workspaceFile: join(root, 'workspace.json'),
|
|
24
|
+
/**
|
|
25
|
+
* `AGENTS.md`, not `CLAUDE.md`: the emerging cross-tool convention (this
|
|
26
|
+
* repo's own top-level docs already use it) — the file is read by whatever
|
|
27
|
+
* agent is editing the workspace, not only Claude Code.
|
|
28
|
+
*/
|
|
29
|
+
agentsMd: join(root, 'AGENTS.md'),
|
|
30
|
+
mappingMd: join(root, 'MAPPING.md'),
|
|
31
|
+
capabilitiesMd: join(root, 'CAPABILITIES.md'),
|
|
32
|
+
referenceMd: join(root, 'REFERENCE.md'),
|
|
33
|
+
pipe: join(root, PPC_DIR),
|
|
34
|
+
lock: join(root, PPC_DIR, 'lock.json'),
|
|
35
|
+
capabilities: join(root, PPC_DIR, 'capabilities.json'),
|
|
36
|
+
/**
|
|
37
|
+
* The closed sets the API publishes, read on the last pull. Cached so
|
|
38
|
+
* validate, plan and apply can refuse a wrong value offline and name it.
|
|
39
|
+
*/
|
|
40
|
+
reference: join(root, PPC_DIR, 'reference.json'),
|
|
41
|
+
baselineDir: join(root, PPC_DIR, 'baseline'),
|
|
42
|
+
plansDir: join(root, PPC_DIR, 'plans'),
|
|
43
|
+
pipesDir: join(root, 'pipes'),
|
|
44
|
+
databasesDir: join(root, 'databases'),
|
|
45
|
+
gitignore: join(root, '.gitignore'),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `pipes/<numericId>--<slug>` / `databases/<internalId>--<apiId>--<slug>`.
|
|
50
|
+
*
|
|
51
|
+
* A database carries two identities — a numeric internal id and an api id — and
|
|
52
|
+
* `table(id:)` answers to both. Putting both in the directory name is what makes
|
|
53
|
+
* the duality visible rather than a footnote: whichever spelling you pull with,
|
|
54
|
+
* you land in the same directory, and the one you did not use is right there when
|
|
55
|
+
* you need to cross-reference a connector field (which carries the numeric form)
|
|
56
|
+
* against an API response (which carries the slug).
|
|
57
|
+
*/
|
|
58
|
+
export const repoDirName = (ref: { kind: string; id: number; apiId?: string; name?: string | null }) =>
|
|
59
|
+
ref.kind === 'table' && ref.apiId && ref.apiId !== String(ref.id)
|
|
60
|
+
? `${ref.id}--${ref.apiId}--${slug(ref.name)}`
|
|
61
|
+
: `${ref.id}--${slug(ref.name)}`;
|
|
62
|
+
|
|
63
|
+
export const repoDir = (root: string, ref: RepoRef) =>
|
|
64
|
+
join(ref.kind === 'table' ? paths(root).databasesDir : paths(root).pipesDir, repoDirName(ref));
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The files of one repo.
|
|
68
|
+
*
|
|
69
|
+
* `kind` decides one name: a database's root row is `table.json`, not
|
|
70
|
+
* `pipe.json`. It is worth the parameter because a file called `pipe.json`
|
|
71
|
+
* inside `databases/` teaches a reader something false about the platform, and
|
|
72
|
+
* this workspace is read by agents that have no other source of truth.
|
|
73
|
+
*
|
|
74
|
+
* Required rather than defaulted: a caller who forgets would silently get pipe
|
|
75
|
+
* paths for a database and a mysteriously absent root file.
|
|
76
|
+
*/
|
|
77
|
+
export const repoPaths = (dir: string, kind: 'pipe' | 'table' = 'pipe') => ({
|
|
78
|
+
dir,
|
|
79
|
+
meta: join(dir, '_meta.json'),
|
|
80
|
+
repo: join(dir, kind === 'table' ? 'table.json' : 'pipe.json'),
|
|
81
|
+
/** Both spellings, so a directory can be recognised before its kind is known. */
|
|
82
|
+
repoCandidates: [join(dir, 'pipe.json'), join(dir, 'table.json')],
|
|
83
|
+
phasesDir: join(dir, 'phases'),
|
|
84
|
+
automationsDir: join(dir, 'automations'),
|
|
85
|
+
fieldConditionsDir: join(dir, 'field-conditions'),
|
|
86
|
+
labels: join(dir, 'labels.json'),
|
|
87
|
+
webhooks: join(dir, 'webhooks.json'),
|
|
88
|
+
publicForm: join(dir, 'public-form.json'),
|
|
89
|
+
preferences: join(dir, 'preferences.json'),
|
|
90
|
+
relations: join(dir, 'relations.json'),
|
|
91
|
+
readonlyDir: join(dir, '_readonly'),
|
|
92
|
+
agentsDir: join(dir, 'agents'),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** Baseline is stored as the payload envelope: one file, and diff-ready. */
|
|
96
|
+
export const baselineFile = (root: string, repoId: number) => join(paths(root).baselineDir, `${repoId}.json`);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The agent baseline: what a repo's AI agents looked like at the last pull.
|
|
100
|
+
*
|
|
101
|
+
* Stored separately from the payload envelope for the same reason flows are:
|
|
102
|
+
* agents are not in it, they come from a different query, keyed by the pipe's
|
|
103
|
+
* uuid rather than its id (src/pipefy/agents.ts).
|
|
104
|
+
*/
|
|
105
|
+
export const agentBaselineFile = (root: string, repoId: number) =>
|
|
106
|
+
join(paths(root).baselineDir, `${repoId}.agents.json`);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The flow baseline: what a repo's iPaaS flows looked like at the last pull.
|
|
110
|
+
*
|
|
111
|
+
* Stored separately from the payload envelope because flows are not in it —
|
|
112
|
+
* they come from a different endpoint. Without this the flow diff had no
|
|
113
|
+
* recorded past to compare against and had to re-read the pipe live every time,
|
|
114
|
+
* which meant no offline diff and no record of what was pulled.
|
|
115
|
+
*/
|
|
116
|
+
export const flowBaselineFile = (root: string, repoId: number) =>
|
|
117
|
+
join(paths(root).baselineDir, `${repoId}.flows.json`);
|
|
118
|
+
|
|
119
|
+
export const planFile = (root: string, planId: string) => join(paths(root).plansDir, `${planId}.json`);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* How a finished plan is named.
|
|
123
|
+
*
|
|
124
|
+
* A plan file is the record `--resume` replays, and it is written before the
|
|
125
|
+
* first request and rewritten after every step, so a crash mid-apply is
|
|
126
|
+
* resumable. That made `.ppc/plans/` a list of filenames that all look alike,
|
|
127
|
+
* with no way to tell an applied plan from one still waiting — the outcome was
|
|
128
|
+
* only inside the JSON, or via `pipe status`.
|
|
129
|
+
*
|
|
130
|
+
* So the outcome goes in the name once the apply is over. **No suffix means it
|
|
131
|
+
* was never applied**, which is the state a bare `pipe plan` leaves behind.
|
|
132
|
+
* The plan id does not change, so every `--resume <id>` instruction still works.
|
|
133
|
+
*/
|
|
134
|
+
export const PLAN_SUFFIX: Record<string, string> = {
|
|
135
|
+
applied: '--applied',
|
|
136
|
+
'partially-applied': '--partial',
|
|
137
|
+
failed: '--failed',
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const planFileFor = (root: string, planId: string, status: string) =>
|
|
141
|
+
join(paths(root).plansDir, `${planId}${PLAN_SUFFIX[status] ?? ''}.json`);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The file holding a plan, whatever it was renamed to. Returns null if there is
|
|
145
|
+
* none — the caller decides whether that is an error.
|
|
146
|
+
*/
|
|
147
|
+
export const findPlanFile = async (root: string, planId: string): Promise<string | null> => {
|
|
148
|
+
const { access } = await import('node:fs/promises');
|
|
149
|
+
const candidates = [planFile(root, planId), ...Object.values(PLAN_SUFFIX).map((sfx) => join(paths(root).plansDir, `${planId}${sfx}.json`))];
|
|
150
|
+
for (const c of candidates) {
|
|
151
|
+
try {
|
|
152
|
+
await access(c);
|
|
153
|
+
return c;
|
|
154
|
+
} catch {
|
|
155
|
+
// try the next spelling
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** Paths under a repo dir that must never be edited — checked by path alone. */
|
|
162
|
+
export const isReadonlyPath = (relPath: string) => {
|
|
163
|
+
const p = relPath.replace(/\\/g, '/');
|
|
164
|
+
return p.includes('/_readonly/') || p.startsWith('_readonly/');
|
|
165
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { relative } from 'node:path';
|
|
2
|
+
import { exists, readJson, writeJson } from '../util/fsx.ts';
|
|
3
|
+
import type { Coverage, EntityName } from '../model/payload.ts';
|
|
4
|
+
import type { RepoRef } from '../pipefy/discovery.ts';
|
|
5
|
+
import type { Tree } from '../model/tree.ts';
|
|
6
|
+
import { baselineFile, paths, repoDir } from './layout.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `.ppc/lock.json` — PLAN.md §4: id ↔ uuid ↔ path, the table-id duality, and
|
|
10
|
+
* the version ids of what was pulled.
|
|
11
|
+
*
|
|
12
|
+
* It is also where the applier looks up "what did this uuid become", which is
|
|
13
|
+
* the job the Change Migrator recipes do with a Pipefy table keyed
|
|
14
|
+
* `dev_pipe_field_internal_id → prod_pipe_field_internal_id` and one API call
|
|
15
|
+
* per id. Same idea, local and free.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type LockRepo = {
|
|
19
|
+
kind: 'pipe' | 'table';
|
|
20
|
+
/** Numeric id, which every API accepts. */
|
|
21
|
+
id: number;
|
|
22
|
+
/**
|
|
23
|
+
* What the API answers with. For tables this is a slug (`dzmxcuxz`) while the
|
|
24
|
+
* connector field says `301908697` — store both or a connected database gets
|
|
25
|
+
* pulled twice under two identities (PLAN.md §2.2).
|
|
26
|
+
*/
|
|
27
|
+
apiId: string;
|
|
28
|
+
uuid: string | null;
|
|
29
|
+
name: string | null;
|
|
30
|
+
dir: string;
|
|
31
|
+
via: string[];
|
|
32
|
+
depth: number;
|
|
33
|
+
source: 'snapshot' | 'reconstructed';
|
|
34
|
+
versionId: string | null;
|
|
35
|
+
readAt: string;
|
|
36
|
+
coverage: Record<EntityName, Coverage>;
|
|
37
|
+
/** uuid → numeric id, for the four arrays that have a uuid. */
|
|
38
|
+
identity: Partial<Record<EntityName, Record<string, number>>>;
|
|
39
|
+
/** field numeric id → slug, so `%{433255467}` can be explained to a human. */
|
|
40
|
+
fieldSlugs: Record<string, string>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type Lock = {
|
|
44
|
+
version: 1;
|
|
45
|
+
updatedAt: string;
|
|
46
|
+
rootRepoId: number;
|
|
47
|
+
organizationId: string | null;
|
|
48
|
+
depth: number;
|
|
49
|
+
repos: LockRepo[];
|
|
50
|
+
edges: Array<{ from: number; to: number; kind: string; label?: string }>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const emptyLock = (rootRepoId: number, organizationId: string | null, depth: number): Lock => ({
|
|
54
|
+
version: 1,
|
|
55
|
+
updatedAt: new Date().toISOString(),
|
|
56
|
+
rootRepoId,
|
|
57
|
+
organizationId,
|
|
58
|
+
depth,
|
|
59
|
+
repos: [],
|
|
60
|
+
edges: [],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const readLock = async (root: string): Promise<Lock | null> => {
|
|
64
|
+
const p = paths(root).lock;
|
|
65
|
+
if (!(await exists(p))) return null;
|
|
66
|
+
return readJson<Lock>(p);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const writeLock = async (root: string, lock: Lock) => {
|
|
70
|
+
lock.updatedAt = new Date().toISOString();
|
|
71
|
+
await writeJson(paths(root).lock, lock);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const identityFor = (tree: Tree): LockRepo['identity'] => {
|
|
75
|
+
const grab = (rows: Array<Record<string, unknown>>): Record<string, number> => {
|
|
76
|
+
const out: Record<string, number> = {};
|
|
77
|
+
for (const r of rows) {
|
|
78
|
+
const uuid = r['uuid'];
|
|
79
|
+
const id = Number(r['id']);
|
|
80
|
+
if (typeof uuid === 'string' && uuid && Number.isFinite(id)) out[uuid] = id;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
phases: grab(tree.phases),
|
|
86
|
+
fields: grab(tree.phases.flatMap((p) => p.fields)),
|
|
87
|
+
automations: grab(tree.automations),
|
|
88
|
+
labels: grab(tree.labels),
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const upsertRepo = (lock: Lock, ref: RepoRef, root: string, tree: Tree): LockRepo => {
|
|
93
|
+
const entry: LockRepo = {
|
|
94
|
+
kind: ref.kind,
|
|
95
|
+
id: ref.id,
|
|
96
|
+
apiId: ref.apiId,
|
|
97
|
+
uuid: (tree.repo['uuid'] as string | undefined) ?? ref.uuid ?? null,
|
|
98
|
+
name: (tree.repo['name'] as string | undefined) ?? ref.name ?? null,
|
|
99
|
+
dir: relative(root, repoDir(root, ref)).replace(/\\/g, '/'),
|
|
100
|
+
via: ref.via,
|
|
101
|
+
depth: ref.depth,
|
|
102
|
+
source: tree.meta.source,
|
|
103
|
+
versionId: tree.meta.versionId ?? null,
|
|
104
|
+
readAt: tree.meta.readAt,
|
|
105
|
+
coverage: tree.meta.coverage,
|
|
106
|
+
identity: identityFor(tree),
|
|
107
|
+
fieldSlugs: Object.fromEntries(
|
|
108
|
+
tree.phases
|
|
109
|
+
.flatMap((p) => p.fields)
|
|
110
|
+
.filter((f) => f['slug'])
|
|
111
|
+
.map((f) => [String(f['id']), String(f['slug'])]),
|
|
112
|
+
),
|
|
113
|
+
};
|
|
114
|
+
const i = lock.repos.findIndex((r) => r.id === ref.id);
|
|
115
|
+
if (i >= 0) lock.repos[i] = entry;
|
|
116
|
+
else lock.repos.push(entry);
|
|
117
|
+
return entry;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const lookupRepo = (lock: Lock, idOrName: string): LockRepo | undefined => {
|
|
121
|
+
const byId = lock.repos.find((r) => String(r.id) === idOrName || r.apiId === idOrName);
|
|
122
|
+
if (byId) return byId;
|
|
123
|
+
const needle = idOrName.toLowerCase();
|
|
124
|
+
return lock.repos.find((r) => (r.name ?? '').toLowerCase() === needle || r.dir.toLowerCase().includes(needle));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const rootRepo = (lock: Lock): LockRepo | undefined => lock.repos.find((r) => r.id === lock.rootRepoId);
|
|
128
|
+
|
|
129
|
+
export const baselinePathFor = (root: string, repoId: number) => baselineFile(root, repoId);
|
|
130
|
+
|
|
131
|
+
export type WorkspaceFile = {
|
|
132
|
+
name: string;
|
|
133
|
+
createdAt: string;
|
|
134
|
+
rootRepoId: number;
|
|
135
|
+
organizationId: string | null;
|
|
136
|
+
depth: number;
|
|
137
|
+
/** How the last pull read the structure — informational, per repo in lock.json. */
|
|
138
|
+
lastPullSource: 'snapshot' | 'reconstructed' | 'mixed';
|
|
139
|
+
tool: { name: 'pipefy-process-coder'; version: string };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const readWorkspace = async (root: string): Promise<WorkspaceFile | null> => {
|
|
143
|
+
const p = paths(root).workspaceFile;
|
|
144
|
+
if (!(await exists(p))) return null;
|
|
145
|
+
return readJson<WorkspaceFile>(p);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const writeWorkspace = (root: string, w: WorkspaceFile) => writeJson(paths(root).workspaceFile, w);
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { basename, dirname } from 'node:path';
|
|
2
|
+
import { exists, listJson, readJson } from '../util/fsx.ts';
|
|
3
|
+
import { UserError } from '../util/log.ts';
|
|
4
|
+
import { fullCoverage, type Row } from '../model/payload.ts';
|
|
5
|
+
import type { AutomationNode, FieldConditionNode, PhaseNode, Tree, TreeMeta } from '../model/tree.ts';
|
|
6
|
+
import { repoPaths } from './layout.ts';
|
|
7
|
+
import { AGENT_DIR_TAG } from './agents.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* files → tree. The exact inverse of write.ts, so that
|
|
11
|
+
* `readTree(writeTree(t)) ≡ t` and the edited workspace can be diffed against
|
|
12
|
+
* the baseline with no special cases.
|
|
13
|
+
*
|
|
14
|
+
* Ordering never comes from filenames: the index-prefixed names are for humans,
|
|
15
|
+
* and `index` inside the file is authoritative. A phase Claude renumbered by
|
|
16
|
+
* editing `index` therefore works without renaming any file.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const num = (v: unknown): number => {
|
|
20
|
+
const n = Number(v);
|
|
21
|
+
return Number.isFinite(n) ? n : 0;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An id, or the placeholder that stands for one.
|
|
26
|
+
*
|
|
27
|
+
* `num` collapses anything non-numeric to 0, which is right for an index and
|
|
28
|
+
* catastrophic for an id list: a phase jumping to a phase created in the same
|
|
29
|
+
* edit is written `"%{_new:<uuid>}"`, exactly as the generated AGENTS.md
|
|
30
|
+
* instructs, and `num` turned that into a jump to phase 0 — accepted by the
|
|
31
|
+
* validator, sent to the API, and impossible to explain. A typo met the same
|
|
32
|
+
* fate. Non-numeric values are kept verbatim so the validator can judge them.
|
|
33
|
+
*/
|
|
34
|
+
const idOrPlaceholder = (v: unknown): number | string => {
|
|
35
|
+
const n = Number(v);
|
|
36
|
+
if (Number.isFinite(n) && v !== '' && v !== null) return n;
|
|
37
|
+
return String(v);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const readArrayFile = async (path: string): Promise<Row[]> => {
|
|
41
|
+
if (!(await exists(path))) return [];
|
|
42
|
+
const v = await readJson<unknown>(path);
|
|
43
|
+
if (v === null) return [];
|
|
44
|
+
if (!Array.isArray(v)) throw new UserError(`${path}: expected a JSON array`);
|
|
45
|
+
return v as Row[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const readObjectFile = async (path: string): Promise<Row | null> => {
|
|
49
|
+
if (!(await exists(path))) return null;
|
|
50
|
+
const v = await readJson<unknown>(path);
|
|
51
|
+
if (v === null) return null;
|
|
52
|
+
if (typeof v !== 'object' || Array.isArray(v)) throw new UserError(`${path}: expected a JSON object or null`);
|
|
53
|
+
return v as Row;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const readDirOf = async <T>(dir: string): Promise<Array<{ path: string; value: T }>> => {
|
|
57
|
+
const files = (await listJson(dir)).filter((f) => basename(f) !== '_meta.json');
|
|
58
|
+
const out: Array<{ path: string; value: T }> = [];
|
|
59
|
+
for (const f of files) out.push({ path: f, value: await readJson<T>(f) });
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const readTree = async (dir: string): Promise<Tree> => {
|
|
64
|
+
/**
|
|
65
|
+
* The kind comes from `_meta.json`, which is written first and whose path does
|
|
66
|
+
* not depend on the kind. Falling back to probing both spellings keeps a
|
|
67
|
+
* hand-made directory readable.
|
|
68
|
+
*/
|
|
69
|
+
const meta = ((await readObjectFile(repoPaths(dir).meta)) ?? {}) as unknown as TreeMeta;
|
|
70
|
+
const p = repoPaths(dir, meta.repoKind === 'table' ? 'table' : 'pipe');
|
|
71
|
+
let repoFile = p.repo;
|
|
72
|
+
if (!(await exists(repoFile))) {
|
|
73
|
+
const found = [];
|
|
74
|
+
for (const c of p.repoCandidates) if (await exists(c)) found.push(c);
|
|
75
|
+
if (!found.length) {
|
|
76
|
+
throw new UserError(`${dir} is not a pulled repo directory (no pipe.json or table.json)`);
|
|
77
|
+
}
|
|
78
|
+
repoFile = found[0] as string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const repo = (await readObjectFile(repoFile)) ?? {};
|
|
82
|
+
|
|
83
|
+
const phaseFiles = await readDirOf<PhaseNode>(p.phasesDir);
|
|
84
|
+
const phases = phaseFiles
|
|
85
|
+
.map(({ path, value }) => {
|
|
86
|
+
if (!value || typeof value !== 'object') throw new UserError(`${path}: expected a phase object`);
|
|
87
|
+
return {
|
|
88
|
+
...value,
|
|
89
|
+
fields: Array.isArray(value.fields) ? value.fields : [],
|
|
90
|
+
jump_target_ids: Array.isArray(value.jump_target_ids) ? value.jump_target_ids.map(idOrPlaceholder) : [],
|
|
91
|
+
team_member_ids: Array.isArray(value.team_member_ids) ? value.team_member_ids.map(idOrPlaceholder) : [],
|
|
92
|
+
} as PhaseNode;
|
|
93
|
+
})
|
|
94
|
+
.sort((a, b) => num(a['index']) - num(b['index']) || num(a['id']) - num(b['id']));
|
|
95
|
+
|
|
96
|
+
const normaliseAutomation = (path: string, value: unknown): AutomationNode => {
|
|
97
|
+
if (!value || typeof value !== 'object') throw new UserError(`${path}: expected an automation object`);
|
|
98
|
+
const v = value as AutomationNode;
|
|
99
|
+
return {
|
|
100
|
+
...v,
|
|
101
|
+
condition: v.condition ?? null,
|
|
102
|
+
field_maps: Array.isArray(v.field_maps) ? v.field_maps : [],
|
|
103
|
+
distribute_assignments: Array.isArray(v.distribute_assignments) ? v.distribute_assignments : [],
|
|
104
|
+
response_schemas: Array.isArray(v.response_schemas) ? v.response_schemas : [],
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const automations = (await readDirOf<AutomationNode>(p.automationsDir)).map(({ path, value }) =>
|
|
109
|
+
normaliseAutomation(path, value),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A behavior — an automation folded under its agent's `behaviors/` instead
|
|
114
|
+
* of `automations/`, per workspace/agents.ts. `_agent_dir` is not read from
|
|
115
|
+
* the file (it is never written there); it is derived from where the file
|
|
116
|
+
* physically is, which is the only record of the link there is.
|
|
117
|
+
*/
|
|
118
|
+
for (const path of await listJson(p.agentsDir)) {
|
|
119
|
+
if (basename(dirname(path)) !== 'behaviors') continue;
|
|
120
|
+
const agentDir = basename(dirname(dirname(path)));
|
|
121
|
+
const value = await readJson<unknown>(path);
|
|
122
|
+
automations.push({ ...normaliseAutomation(path, value), [AGENT_DIR_TAG]: agentDir } as AutomationNode);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const field_conditions = (await readDirOf<FieldConditionNode>(p.fieldConditionsDir))
|
|
126
|
+
.map(({ path, value }) => {
|
|
127
|
+
if (!value || typeof value !== 'object') throw new UserError(`${path}: expected a field condition object`);
|
|
128
|
+
return {
|
|
129
|
+
...value,
|
|
130
|
+
actions: Array.isArray(value.actions) ? value.actions : [],
|
|
131
|
+
condition: value.condition ?? null,
|
|
132
|
+
} as FieldConditionNode;
|
|
133
|
+
})
|
|
134
|
+
.sort((a, b) => num(a['index']) - num(b['index']) || num(a['id']) - num(b['id']));
|
|
135
|
+
|
|
136
|
+
const relationsFile = (await readObjectFile(p.relations)) ?? {};
|
|
137
|
+
const pipe_relations = Array.isArray(relationsFile['pipe_relations']) ? (relationsFile['pipe_relations'] as Row[]) : [];
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
meta: {
|
|
141
|
+
source: meta.source ?? 'reconstructed',
|
|
142
|
+
versionId: meta.versionId ?? null,
|
|
143
|
+
readAt: meta.readAt ?? new Date().toISOString(),
|
|
144
|
+
repoKind: meta.repoKind ?? 'pipe',
|
|
145
|
+
coverage: meta.coverage ?? fullCoverage('unknown'),
|
|
146
|
+
repo_columns: meta.repo_columns ?? 'complete',
|
|
147
|
+
notes: meta.notes ?? [],
|
|
148
|
+
},
|
|
149
|
+
repo,
|
|
150
|
+
phases,
|
|
151
|
+
automations,
|
|
152
|
+
field_conditions,
|
|
153
|
+
labels: await readArrayFile(p.labels),
|
|
154
|
+
webhooks: await readArrayFile(p.webhooks),
|
|
155
|
+
pipe_relations,
|
|
156
|
+
public_form: await readObjectFile(p.publicForm),
|
|
157
|
+
preferences: await readObjectFile(p.preferences),
|
|
158
|
+
readonly: {
|
|
159
|
+
email_templates: await readArrayFile(`${p.readonlyDir}/email-templates.json`),
|
|
160
|
+
email_inboxes: await readArrayFile(`${p.readonlyDir}/email-inboxes.json`),
|
|
161
|
+
visibilities: await readArrayFile(`${p.readonlyDir}/visibilities.json`),
|
|
162
|
+
field_maps: await readArrayFile(`${p.readonlyDir}/field-maps.json`),
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { paths } from './layout.ts';
|
|
3
|
+
import { debug } from '../util/log.ts';
|
|
4
|
+
import type { Reference } from '../pipefy/reference.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load the cached closed sets, or null.
|
|
8
|
+
*
|
|
9
|
+
* Null on anything: no file, unreadable, malformed. Every consumer treats null
|
|
10
|
+
* as "these checks are skipped" and never as "this value is fine", so a
|
|
11
|
+
* workspace pulled by an older build loses the check rather than gaining a
|
|
12
|
+
* wrong answer.
|
|
13
|
+
*/
|
|
14
|
+
export const loadReference = async (root: string): Promise<Reference | null> => {
|
|
15
|
+
try {
|
|
16
|
+
const raw = await readFile(paths(root).reference, 'utf8');
|
|
17
|
+
const ref = JSON.parse(raw) as Reference;
|
|
18
|
+
if (!ref || typeof ref !== 'object' || !Array.isArray(ref.events)) return null;
|
|
19
|
+
return ref;
|
|
20
|
+
} catch (e) {
|
|
21
|
+
debug(`no cached reference: ${(e as Error).message}`);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
};
|