@pipefy/pipefy-process-coder 0.1.1 → 0.1.3

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.
@@ -13,7 +13,7 @@ import { RECONSTRUCTION_GAPS } from "../pipefy/reconstruct.js";
13
13
  import { readTree } from "../workspace/read.js";
14
14
  import { registryRows } from "../apply/registry.js";
15
15
  import { validateTree, renderFindings, blockingErrors } from "../validate/index.js";
16
- import { connect, openWorkspace } from "./context.js";
16
+ import { connect, openWorkspace, readLive } from "./context.js";
17
17
  // ── login ────────────────────────────────────────────────────────────────────
18
18
  export const loginHelp = `pipe login [options]
19
19
 
@@ -36,11 +36,14 @@ export const login = async (p) => {
36
36
  const token = str(p, 'token');
37
37
  // ── the intended path: delegate to the toolkit's OAuth flow ───────────────
38
38
  if (!token) {
39
- const { inspectToolkit, runToolkitLogin, INSTALL_HINT } = await import("../pipefy/toolkit.js");
39
+ const { inspectToolkit, runToolkitLogin, INSTALL_HINT, UPGRADE_HINT } = await import("../pipefy/toolkit.js");
40
40
  const toolkit = await inspectToolkit();
41
41
  if (!toolkit.installed) {
42
42
  throw new UserError('the Pipefy AI toolkit is not installed, so there is no browser login to run', `${INSTALL_HINT} — or use \`pipe login --token <token>\` for a personal API token`);
43
43
  }
44
+ if (!toolkit.usable) {
45
+ throw new UserError(`the Pipefy AI toolkit is installed but this tool cannot use it: ${toolkit.error}`, `${UPGRADE_HINT} — or use \`pipe login --token <token>\` for a personal API token`);
46
+ }
44
47
  step('running `pipefy auth login` (browser sign-in)');
45
48
  const code = await runToolkitLogin(bool(p, 'device') ? ['--device'] : []);
46
49
  if (code !== 0) {
@@ -91,7 +94,7 @@ export const doctorHelp = `pipe doctor [--repo <pipeId>]
91
94
  With --repo it also probes that pipe's read paths.`;
92
95
  export const doctor = async (p) => {
93
96
  const { resolveCredential } = await import("../config.js");
94
- const { inspectToolkit, toolkitAuthStatus, INSTALL_HINT } = await import("../pipefy/toolkit.js");
97
+ const { inspectToolkit, toolkitAuthStatus, INSTALL_HINT, UPGRADE_HINT } = await import("../pipefy/toolkit.js");
95
98
  out(bold('endpoints'));
96
99
  out(` graphql ${ENDPOINTS.graphql}`);
97
100
  out(` internal ${ENDPOINTS.internal}`);
@@ -99,9 +102,18 @@ export const doctor = async (p) => {
99
102
  out(bold('credential'));
100
103
  const toolkit = await inspectToolkit();
101
104
  const credential = await resolveCredential();
102
- out(` toolkit ${toolkit.installed ? green('installed') : red('not installed')}` +
103
- (toolkit.installed ? dim(` ${toolkit.cliPath ?? toolkit.interpreter}`) : dim(` ${INSTALL_HINT}`)));
104
- if (toolkit.installed) {
105
+ if (!toolkit.installed) {
106
+ out(` toolkit ${red('not installed')}${dim(` ${INSTALL_HINT}`)}`);
107
+ }
108
+ else if (!toolkit.usable) {
109
+ out(` toolkit ${yellow('installed, unusable')}${dim(` ${toolkit.cliPath ?? toolkit.interpreter}`)}`);
110
+ out(` ${dim(toolkit.error)}`);
111
+ out(` ${dim(UPGRADE_HINT)}`);
112
+ }
113
+ else {
114
+ out(` toolkit ${green('installed')}${dim(` ${toolkit.cliPath ?? toolkit.interpreter}`)}`);
115
+ }
116
+ if (toolkit.installed && toolkit.usable) {
105
117
  const status = await toolkitAuthStatus();
106
118
  if (status) {
107
119
  const identity = status['identity'];
@@ -118,7 +130,11 @@ export const doctor = async (p) => {
118
130
  dim(credential?.detail ? ` ${credential.detail}` : ''));
119
131
  if (!credential) {
120
132
  info('');
121
- throw new UserError('no credential', toolkit.installed ? 'run `pipe login` for the browser flow' : `${INSTALL_HINT}, or \`pipe login --token <token>\``);
133
+ throw new UserError('no credential', !toolkit.installed
134
+ ? `${INSTALL_HINT}, or \`pipe login --token <token>\``
135
+ : !toolkit.usable
136
+ ? `${UPGRADE_HINT}, or \`pipe login --token <token>\``
137
+ : 'run `pipe login` for the browser flow');
122
138
  }
123
139
  const net = await connect(p);
124
140
  const caps = await net.capabilities();
@@ -151,14 +167,35 @@ export const doctor = async (p) => {
151
167
  if (repoId) {
152
168
  out('');
153
169
  out(bold(`probing repo ${repoId}`));
154
- const jumps = await net.internal.getPhaseSettings(await firstPhaseId(net, repoId));
170
+ const repo = await probeRepo(net, repoId);
171
+ const jumps = await net.internal.getPhaseSettings(repo.firstPhaseId);
155
172
  out(` internal settings endpoint ${jumps ? green('answers') : red('no answer')}`);
156
- const autos = net.organizationId ? await net.internal.listAutomations(net.organizationId, repoId) : null;
157
- out(` internal automations query ${autos ? green(`answers (${autos.length} automations)`) : net.organizationId ? red('no answer') : yellow('needs --org')}`);
173
+ // The repo's own organization, as a pull uses — not the saved default.
174
+ const orgId = repo.organizationId ?? net.organizationId;
175
+ const autos = orgId ? await net.internal.listAutomations(orgId, repoId) : null;
176
+ out(` internal automations query ${autos ? green(`answers (${autos.length} automations)`) : orgId ? red('no answer') : yellow('needs --org')}`);
158
177
  }
159
178
  out('');
160
179
  out(bold('internal API'));
161
- out(` status ${net.internal.status === 'ok' ? green('ok') : net.internal.status === 'unavailable' ? red('unavailable') : dim('unknown')}`);
180
+ // Validates the query documents themselves, with ids that match nothing —
181
+ // "status unknown" used to be all this said, while every automation read in
182
+ // a pull was failing on a query the schema had stopped accepting.
183
+ const checks = await net.internal.checkQueries();
184
+ if (!checks)
185
+ out(` queries ${red('no answer')}${dim(' the endpoint could not be reached')}`);
186
+ else {
187
+ for (const c of checks) {
188
+ out(` ${c.ok ? green('ok ') : red('FAIL')} ${c.label.padEnd(18)}${c.ok ? '' : ` ${dim(c.message ?? '')}`}`);
189
+ }
190
+ }
191
+ const status = net.internal.status;
192
+ out(` status ${status === 'ok'
193
+ ? green('ok')
194
+ : status === 'incompatible'
195
+ ? red('incompatible') + dim(' a query this version of pipe sends is refused — a bug in pipe, worth reporting')
196
+ : status === 'unavailable'
197
+ ? red('unavailable')
198
+ : dim('unknown')}`);
162
199
  info(dim(' Unversioned, no compatibility contract (PLAN.md §2.1). When it breaks, automation\n' +
163
200
  ' detail and phase-jump editing degrade to unknown rather than failing an apply.'));
164
201
  if (!caps.snapshots) {
@@ -169,9 +206,12 @@ export const doctor = async (p) => {
169
206
  }
170
207
  return 0;
171
208
  };
172
- const firstPhaseId = async (net, repoId) => {
173
- const data = await net.client.tryRaw('query($id: ID!) { pipe(id: $id) { phases { id } } }', { id: repoId }, 'first phase');
174
- return data?.pipe?.phases?.[0]?.id ?? '0';
209
+ const probeRepo = async (net, repoId) => {
210
+ const data = await net.client.tryRaw('query($id: ID!) { pipe(id: $id) { organizationId phases { id } } }', { id: repoId }, 'probe repo');
211
+ return {
212
+ firstPhaseId: data?.pipe?.phases?.[0]?.id ?? '0',
213
+ organizationId: data?.pipe?.organizationId ? String(data.pipe.organizationId) : null,
214
+ };
175
215
  };
176
216
  // ── export ───────────────────────────────────────────────────────────────────
177
217
  export const exportHelp = `pipe export <pipeId> [options]
@@ -191,26 +231,7 @@ export const exportCmd = async (p) => {
191
231
  const net = await connect(p);
192
232
  const caps = await net.capabilities();
193
233
  const useSnapshot = caps.snapshots && !bool(p, 'no-snapshot');
194
- let env;
195
- if (useSnapshot) {
196
- const { payload, versionId } = await net.snapshots.pull(repoId);
197
- const { fullCoverage } = await import("../model/payload.js");
198
- env = {
199
- source: 'snapshot',
200
- versionId,
201
- readAt: new Date().toISOString(),
202
- repoKind: 'pipe',
203
- coverage: fullCoverage('complete'),
204
- notes: [],
205
- payload,
206
- };
207
- }
208
- else {
209
- env = await net.reconstructor.reconstruct(repoId, {
210
- organizationId: net.organizationId,
211
- noInternal: bool(p, 'no-internal'),
212
- });
213
- }
234
+ const env = await readLive(net, p, repoId, { preferSnapshot: useSnapshot, noInternal: bool(p, 'no-internal') });
214
235
  /**
215
236
  * A bare payload has no provenance, and for a reconstructed read that matters.
216
237
  *
@@ -10,7 +10,7 @@ import { compile } from "../apply/compile.js";
10
10
  import { readAutomationCatalogue } from "../pipefy/automations.js";
11
11
  import { validateTree, renderFindings, blockingErrors } from "../validate/index.js";
12
12
  import { connect, openWorkspace } from "./context.js";
13
- import { loadBaseline, writeHtml, openViewer } from "./diff.js";
13
+ import { loadBaseline, writeHtml, openViewer, refuseSuppressedEdits } from "./diff.js";
14
14
  export const help = `pipe plan [workspace] [options]
15
15
 
16
16
  --repo <id> which repo in the workspace (default: the root pipe)
@@ -115,6 +115,8 @@ export const run = async (p) => {
115
115
  throw new UserError(`${blocking.length} validation error${blocking.length === 1 ? '' : 's'} — fix these before planning`);
116
116
  }
117
117
  const changeSet = diffTrees(baseline, edited);
118
+ // Before the empty check: edits that were all left out are not "nothing to do".
119
+ refuseSuppressedEdits(changeSet, bool(p, 'force'));
118
120
  if (!changeSet.changes.length) {
119
121
  /**
120
122
  * The pipe half is clean, which is not the same as nothing to do: a flow-only
@@ -19,6 +19,7 @@ import { baselineFile, paths } from "../workspace/layout.js";
19
19
  import { connect } from "./context.js";
20
20
  import { RunRecorder, invocation } from "../report/run.js";
21
21
  import { pullAndWriteFlows } from "./flows.js";
22
+ import { TOOL } from "../version.js";
22
23
  /**
23
24
  * `pipe pull <pipeId>` — the read path.
24
25
  *
@@ -39,6 +40,7 @@ export const help = `pipe pull <pipeId> [options]
39
40
  --org <id> organization id, for the internal automations query
40
41
  --reuse-snapshot use the newest complete snapshot instead of creating one
41
42
  --no-snapshot force GraphQL reconstruction even if snapshots exist
43
+ --no-fallback if a snapshot read fails, fail rather than reconstruct over GraphQL
42
44
  --no-internal skip the internal API (automations and jumps become unknown)
43
45
  --label <text> label the snapshot this pull creates
44
46
  --skip-roundtrip do not run the codec self-check (not recommended)
@@ -57,6 +59,7 @@ export const run = async (p) => {
57
59
  const noInternal = bool(p, 'no-internal');
58
60
  const reuseSnapshot = bool(p, 'reuse-snapshot');
59
61
  const skipRoundTrip = bool(p, 'skip-roundtrip');
62
+ const noFallback = bool(p, 'no-fallback');
60
63
  const label = str(p, 'label');
61
64
  const startedAt = new Date();
62
65
  const net = await connect(p);
@@ -114,7 +117,28 @@ export const run = async (p) => {
114
117
  * relation arrays a pipe's does (VALIDATION.md §3g).
115
118
  */
116
119
  if (useSnapshots) {
117
- env = await readViaSnapshot(net, ref, { reuseSnapshot, label });
120
+ try {
121
+ env = await readViaSnapshot(net, ref, { reuseSnapshot, label });
122
+ }
123
+ catch (e) {
124
+ /**
125
+ * A failed snapshot read falls back to reconstruction rather than
126
+ * leaving the repo out. Found live: Pipefy's storage answered every
127
+ * signed snapshot URL with HTTP 500 — the creation succeeded, the
128
+ * download did not, on every pipe tried — and the pull wrote a
129
+ * workspace with no pipe in it. Reconstruction is less complete, and
130
+ * says so per group: what it cannot see is `unknown`, never empty, so
131
+ * the fallback cannot license a delete.
132
+ */
133
+ if (noFallback)
134
+ throw e;
135
+ warn(`snapshot read failed for ${ref.kind} ${ref.id} (${ref.name ?? '?'}): ${e.message} — ` +
136
+ 'reading it over GraphQL instead (--no-fallback to stop here)');
137
+ env = await net.reconstructor.reconstruct(ref.id, {
138
+ organizationId: net.organizationId,
139
+ noInternal,
140
+ });
141
+ }
118
142
  }
119
143
  else {
120
144
  env = await net.reconstructor.reconstruct(ref.id, {
@@ -164,8 +188,31 @@ export const run = async (p) => {
164
188
  });
165
189
  const rootRef = discovery.repos.find((r) => r.id === rootRepoId);
166
190
  const outDir = resolve(str(p, 'out') ?? `${slugName(rootRef.name ?? String(rootRepoId))}-workspace`);
191
+ /**
192
+ * The root repo is the workspace. Without it there is nothing to validate,
193
+ * diff or plan against — and writing lock.json and workspace.json anyway
194
+ * did worse than produce an empty directory: re-pulling into an existing
195
+ * workspace replaced its lock with one that no longer listed the pipe, so
196
+ * the next diff had no baseline. This used to finish with exit code 0 and a
197
+ * "next: pipe validate", which scripts and agents believed.
198
+ */
199
+ const rootEnvelope = await readRepo(rootRef);
200
+ if (!rootEnvelope) {
201
+ ui.stop();
202
+ throw new UserError(`could not read the root repo ${rootRepoId}${rootRef.name ? ` (${rootRef.name})` : ''} — nothing was written to ${outDir}`, useSnapshots && noFallback
203
+ ? 'drop --no-fallback to read it over GraphQL, or run with --verbose for the cause'
204
+ : 'run with --verbose for the cause');
205
+ }
167
206
  await mkdirp(outDir);
168
- const lock = emptyLock(rootRepoId, net.organizationId, depth);
207
+ /**
208
+ * The workspace's organization is the root pipe's, not the credential's
209
+ * default: `plan` hands this to the compiler, and a token can belong to
210
+ * several organizations. The default is only the fallback for a payload
211
+ * that does not say.
212
+ */
213
+ const rootOrg = rootEnvelope.payload['organization_id'];
214
+ const organizationId = rootOrg ? String(rootOrg) : net.organizationId;
215
+ const lock = emptyLock(rootRepoId, organizationId, depth);
169
216
  lock.edges = discovery.edges;
170
217
  const trees = [];
171
218
  const sources = new Set();
@@ -270,7 +317,8 @@ export const run = async (p) => {
270
317
  const detail = ref.kind === 'table'
271
318
  ? `${fieldCount} fields, ${Math.max(0, tree.phases.length - 1)} record statuses, ${written.length} files`
272
319
  : `${tree.phases.length} phases, ${fieldCount} fields, ` +
273
- `${tree.automations.length} automations, ${written.length} files`;
320
+ // "0 automations" beside an unknown group read as "this pipe has none".
321
+ `${env.coverage.automations === 'unknown' ? 'automations unknown' : `${tree.automations.length} automations`}, ${written.length} files`;
274
322
  ui.update(task, {
275
323
  state: 'done',
276
324
  detail: detail + (gaps.length ? `, ${gaps.length} groups partial or unknown` : ''),
@@ -281,10 +329,10 @@ export const run = async (p) => {
281
329
  name: rootRef.name ?? String(rootRepoId),
282
330
  createdAt: new Date().toISOString(),
283
331
  rootRepoId,
284
- organizationId: net.organizationId,
332
+ organizationId,
285
333
  depth,
286
334
  lastPullSource: sources.size > 1 ? 'mixed' : (sources.values().next().value ?? 'reconstructed'),
287
- tool: { name: 'pipefy-process-coder', version: '0.1.0' },
335
+ tool: { ...TOOL },
288
336
  });
289
337
  /**
290
338
  * Read the closed sets while the client is open. Two queries, and it is what
@@ -492,7 +540,7 @@ const pullFromFile = async (p, fromFile) => {
492
540
  organizationId: str(p, 'org') ?? null,
493
541
  depth: 0,
494
542
  lastPullSource: env.source,
495
- tool: { name: 'pipefy-process-coder', version: '0.1.0' },
543
+ tool: { ...TOOL },
496
544
  });
497
545
  await writeDocs(outDir, lock, [tree], null, await loadReference(outDir));
498
546
  ok(`${rootRef.kind} ${repoIdNum} ${green(rootRef.name ?? '')} — ${tree.phases.length} phases, ` +
@@ -1,13 +1,12 @@
1
1
  import { bool } from "../cli/args.js";
2
2
  import { writeJson } from "../util/fsx.js";
3
3
  import { dim, info, ok, out, stripAnsi, warn } from "../util/log.js";
4
- import { fullCoverage } from "../model/payload.js";
5
4
  import { unpack } from "../codec/unpack.js";
6
5
  import { readTree } from "../workspace/read.js";
7
6
  import { baselineFile } from "../workspace/layout.js";
8
7
  import { diffTrees } from "../diff/diff.js";
9
8
  import { renderChangeSet } from "../diff/render.js";
10
- import { connect, openWorkspace } from "./context.js";
9
+ import { connect, openWorkspace, readLive } from "./context.js";
11
10
  import { RunRecorder, invocation } from "../report/run.js";
12
11
  export const help = `pipe verify [workspace] [options]
13
12
 
@@ -40,20 +39,7 @@ export const run = async (p) => {
40
39
  const { writeTree } = await import("../workspace/write.js");
41
40
  const caps = await net.capabilities();
42
41
  info(dim(`reading pipe ${repo.id} to match against (${caps.snapshots ? 'snapshot' : 'GraphQL reconstruction'})`));
43
- const envelope = caps.snapshots
44
- ? await (async () => {
45
- const { payload, versionId } = await net.snapshots.pull(repo.id);
46
- return {
47
- source: 'snapshot',
48
- versionId,
49
- readAt: new Date().toISOString(),
50
- repoKind: repo.kind,
51
- coverage: fullCoverage('complete'),
52
- notes: [],
53
- payload,
54
- };
55
- })()
56
- : await net.reconstructor.reconstruct(repo.id, { organizationId: lock.organizationId });
42
+ const envelope = await readLive(net, p, repo.id, { kind: repo.kind, organizationId: lock.organizationId });
57
43
  const authored = await readTree(dir);
58
44
  const { loadBaselineEnvelope } = await import("./diff.js");
59
45
  const previous = await loadBaselineEnvelope(root, repo.id);
@@ -88,7 +74,7 @@ export const run = async (p) => {
88
74
  return 0;
89
75
  }
90
76
  const rec = recorderFor(p, root, repo, 'verify');
91
- const result = await verifyRepo({ net, root, lock, repo, expectedTreeDir: dir });
77
+ const result = await verifyRepo({ net, parsed: p, root, lock, repo, expectedTreeDir: dir });
92
78
  rec?.after(result.envelope);
93
79
  rec?.verified(result.ok, result.ok ? undefined : `${result.changeCount} difference(s) remain`);
94
80
  if (bool(p, 'json')) {
@@ -143,6 +129,8 @@ const write = async (rec, outcome, summary) => {
143
129
  else if (res)
144
130
  info(dim(` run recorded in runs/${rec.id}/`));
145
131
  };
132
+ /** No flags: a caller that passes none gets the defaults, fallback included. */
133
+ const EMPTY_ARGS = { command: 'verify', positionals: [], flags: {} };
146
134
  export const verifyRepo = async (args) => {
147
135
  const { net, lock, repo, expectedTreeDir, idMap, preferReconstruct } = args;
148
136
  const caps = await net.capabilities();
@@ -159,21 +147,12 @@ export const verifyRepo = async (args) => {
159
147
  * comparison and a verified-clean apply proves less than it appears to.
160
148
  * `preferReconstruct` trades that away deliberately — see its doc comment.
161
149
  */
162
- if (useSnapshot) {
163
- const { payload, versionId } = await net.snapshots.pull(repo.id, `post-apply/${new Date().toISOString()}`);
164
- envelope = {
165
- source: 'snapshot',
166
- versionId,
167
- readAt: new Date().toISOString(),
168
- repoKind: repo.kind,
169
- coverage: fullCoverage('complete'),
170
- notes: [],
171
- payload,
172
- };
173
- }
174
- else {
175
- envelope = await net.reconstructor.reconstruct(repo.id, { organizationId: lock.organizationId });
176
- }
150
+ envelope = await readLive(net, args.parsed ?? EMPTY_ARGS, repo.id, {
151
+ kind: repo.kind,
152
+ label: `post-apply/${new Date().toISOString()}`,
153
+ organizationId: lock.organizationId,
154
+ preferSnapshot: useSnapshot,
155
+ });
177
156
  const live = unpack(envelope);
178
157
  /**
179
158
  * Direction matters: live -> expected. A change in this diff means "the pipe
package/dist/diff/diff.js CHANGED
@@ -378,8 +378,19 @@ export const diffTrees = (before, after, opts = {}) => {
378
378
  * second would be catastrophic.
379
379
  */
380
380
  const skipSet = new Set(opts.skipEntities ?? []);
381
- /** Which sides read this group only partially. See deltasOf. */
381
+ /**
382
+ * Which sides read this group's *columns* only partially. See deltasOf.
383
+ * `meta.columns` when the read recorded it, otherwise the group's coverage —
384
+ * what a snapshot, and every workspace pulled before the two were split,
385
+ * carry.
386
+ */
387
+ const columnCoverage = (tree, entity) => tree.meta.columns?.[entity] ?? tree.meta.coverage?.[entity] ?? 'unknown';
382
388
  const partialSides = (entity) => ({
389
+ before: columnCoverage(before, entity) === 'partial',
390
+ after: columnCoverage(after, entity) === 'partial',
391
+ });
392
+ /** Which sides read this group's *rows* only partially — the row rule below. */
393
+ const partialRows = (entity) => ({
383
394
  before: (before.meta.coverage?.[entity] ?? 'unknown') === 'partial',
384
395
  after: (after.meta.coverage?.[entity] ?? 'unknown') === 'partial',
385
396
  });
@@ -392,12 +403,14 @@ export const diffTrees = (before, after, opts = {}) => {
392
403
  * "deletes" — and an apply would have deleted two live automations because one
393
404
  * read path could not see them.
394
405
  *
395
- * So on a partial side, absence means unseen: a delete is suppressed when the
396
- * *after* side is partial, and a create when the *before* side is partial.
406
+ * So on a side whose *rows* are partial, absence means unseen: a delete is
407
+ * suppressed when the *after* side is partial, and a create when the *before*
408
+ * side is partial. Missing columns say nothing about rows, so they do not
409
+ * enter into this (see Coverage on TreeMeta.columns).
397
410
  */
398
411
  const suppressed = [];
399
412
  const rowAbsenceIsUnknown = (entity, op, label) => {
400
- const sides = partialSides(entity);
413
+ const sides = partialRows(entity);
401
414
  const unknown = op === 'delete' ? sides.after : sides.before;
402
415
  if (unknown)
403
416
  suppressed.push({ entity, op, label });
@@ -963,6 +976,7 @@ export const diffTrees = (before, after, opts = {}) => {
963
976
  entity: s.entity,
964
977
  reason: `"${s.label}" would read as a ${s.op}, but that side's coverage is partial — ` +
965
978
  `absence there is unseen, not gone. Re-read from a snapshot to decide.`,
979
+ suppressed: { op: s.op, label: s.label },
966
980
  });
967
981
  }
968
982
  return {
@@ -975,6 +989,16 @@ export const diffTrees = (before, after, opts = {}) => {
975
989
  };
976
990
  };
977
991
  export const isEmpty = (cs) => cs.changes.length === 0;
992
+ /**
993
+ * The creates and deletes this change set had to leave out.
994
+ *
995
+ * Between a workspace and its own baseline every one of these is an edit
996
+ * someone made, and planning the rest without them is applying half of what
997
+ * was asked for. Found in a field run: 20 updates would have gone through and
998
+ * the 2 phase creates they pointed at would not, leaving a pipe with no final
999
+ * phase. `plan` and `apply` refuse on these unless told otherwise.
1000
+ */
1001
+ export const suppressedEdits = (cs) => cs.skipped.flatMap((s) => (s.suppressed ? [{ entity: s.entity, ...s.suppressed }] : []));
978
1002
  export const countByOp = (cs) => {
979
1003
  const out = { create: 0, update: 0, delete: 0, reorder: 0, archive: 0, unarchive: 0 };
980
1004
  for (const c of cs.changes)
@@ -1,4 +1,39 @@
1
1
  import { debug } from "../util/log.js";
2
+ /**
3
+ * The selection under `aiBehaviorParams`, aliased to the snake_case the files
4
+ * use. Shared with `internal.ts`'s `loadAutomation`, because `AiBehaviorParams`
5
+ * is the same object type on both endpoints — and the internal query once
6
+ * selected it bare, as though it were a scalar. GraphQL rejects that before
7
+ * resolving anything, so every automation in the pipe failed to load, not only
8
+ * the AI ones. One selection, two call sites, so the two cannot drift apart.
9
+ */
10
+ export const AI_BEHAVIOR_PARAMS_SELECTION = `{
11
+ data_source_ids: dataSourceIds
12
+ instruction
13
+ referenced_field_ids: referencedFieldIds
14
+ provider_id: providerId
15
+ system_provider_id: systemProviderId
16
+ actions_attributes: actionsAttributes {
17
+ id
18
+ name
19
+ action_type: actionType
20
+ reference_id: referenceId
21
+ metadata {
22
+ destination_phase_id: destinationPhaseId
23
+ pipe_id: pipeId
24
+ table_id: tableId
25
+ email_template_id: emailTemplateId
26
+ allow_template_modifications: allowTemplateModifications
27
+ mcp_server_id: mcpServerId
28
+ tool_name: toolName
29
+ title
30
+ emails
31
+ tool_inputs: toolInputs { name source value field_id: fieldId }
32
+ fields_attributes: fieldsAttributes { field_id: fieldId input_mode: inputMode value }
33
+ }
34
+ }
35
+ capabilities_attributes: capabilitiesAttributes { capability_type: capabilityType enabled }
36
+ }`;
2
37
  const QUERY = `
3
38
  query($repoUuid: ID!, $after: String) {
4
39
  aiAgents(repoUuid: $repoUuid, first: 50, after: $after) {
@@ -34,33 +69,7 @@ const QUERY = `
34
69
  expressions { id field_address operation value structure_id }
35
70
  }
36
71
  action_params {
37
- ai_behavior_params: aiBehaviorParams {
38
- data_source_ids: dataSourceIds
39
- instruction
40
- referenced_field_ids: referencedFieldIds
41
- provider_id: providerId
42
- system_provider_id: systemProviderId
43
- actions_attributes: actionsAttributes {
44
- id
45
- name
46
- action_type: actionType
47
- reference_id: referenceId
48
- metadata {
49
- destination_phase_id: destinationPhaseId
50
- pipe_id: pipeId
51
- table_id: tableId
52
- email_template_id: emailTemplateId
53
- allow_template_modifications: allowTemplateModifications
54
- mcp_server_id: mcpServerId
55
- tool_name: toolName
56
- title
57
- emails
58
- tool_inputs: toolInputs { name source value field_id: fieldId }
59
- fields_attributes: fieldsAttributes { field_id: fieldId input_mode: inputMode value }
60
- }
61
- }
62
- capabilities_attributes: capabilitiesAttributes { capability_type: capabilityType enabled }
63
- }
72
+ ai_behavior_params: aiBehaviorParams ${AI_BEHAVIOR_PARAMS_SELECTION}
64
73
  }
65
74
  }
66
75
  }