@anchrd/intel-api 0.24.0 → 0.26.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/README.md CHANGED
@@ -75,12 +75,29 @@ values it stops with `Error: GATE_URL and GATE_SERVICE_KEY must be set.` before
75
75
  It declares five interfaces — `intel`, `nodes`, `flows`, `tools`, `mcp` — with the functions listed
76
76
  under *Permission, layer one* below. It is idempotent and safe to repeat after an upgrade.
77
77
 
78
- ⚠️ **`bootstrap` declares interfaces and changes no grant.** It creates the permissions an
79
- administrator can hand out; it hands out none of them. This is step 8, and it is why a fresh
80
- installation shows an empty screen to everybody including the person who installed it.
78
+ ⚠️ **`bootstrap` hands out no grant, and it takes none away as long as the declared list does not
79
+ change.** It creates the permissions an administrator can hand out; it hands out none of them. This
80
+ is step 8, and it is why a fresh installation shows an empty screen to everybody including the
81
+ person who installed it.
81
82
 
82
- ⚠️ It also never revokes. An interface an older version declared stays declared in Gate; that is
83
- harmless, and removing one is an act in Gate, by hand.
83
+ ⚠️ **But a function that falls out of a still-declared interface takes its grants with it.**
84
+ Declaring is an upsert that REPLACES an interface's function list, and Gate deletes every grant on a
85
+ function the new list no longer names. So an upgrade that retires a function also retires every role
86
+ assignment on it — against a running installation that is a change to who may do what, not a
87
+ read. `bootstrap` reads the catalog before it writes and names each one as it goes:
88
+
89
+ ```text
90
+ Removed flows:approve from Gate; every grant on it was deleted with it.
91
+ Declared 5 Intel interfaces in Gate.
92
+ ```
93
+
94
+ A run that dropped nothing says so instead: `No declared function was dropped, so Gate deleted no
95
+ grant.` And if the catalog cannot be read, `bootstrap` writes nothing at all rather than delete
96
+ grants it would then be unable to name.
97
+
98
+ ⚠️ It never revokes a whole **interface**. One an older version declared stays declared in Gate:
99
+ `bootstrap` writes only the handles it names, so an interface outside that list keeps its functions
100
+ and its grants. That is harmless, and removing one is an act in Gate, by hand.
84
101
 
85
102
  ### 4. Copy the migrations out of `node_modules`
86
103
 
@@ -135,6 +152,19 @@ FAIL TOOL_SOURCE_ORIGINS is missing
135
152
  FAIL INTEL_SESSION_SECRET must contain at least 32 bytes
136
153
  ```
137
154
 
155
+ It also compares the Gate interfaces **in both directions**. A function or a whole interface Gate
156
+ carries that this version of Intel no longer declares is reported as a `NOTE`, never as a `FAIL`:
157
+ nothing is broken by it, and a check one knows red stops being read. Each note names the surplus,
158
+ why it went, and what to do about it:
159
+
160
+ ```text
161
+ NOTE Gate carries flows:approve, which Intel does not declare — the approval node is gone (#73).
162
+ NOTE Gate carries the interface knowledge (read, write), which Intel does not declare — the whole interface became `nodes` (#152, after #125).
163
+ NOTE The next `intel bootstrap` removes a surplus function and every grant on it (#474). Take the grant away in Gate first if anybody should keep it.
164
+ NOTE `bootstrap` never touches an interface Intel does not declare, so a surplus one stays until somebody removes it in Gate by hand — which takes its grants with it.
165
+ OK Intel packages, configuration, and Gate interfaces are ready.
166
+ ```
167
+
138
168
  ⚠️ **`doctor` cannot see the Worker's secrets.** It answers about the shell it runs in, so it says
139
169
  nothing about whether the deployed Worker is configured. `GET /health` on the deployed Worker
140
170
  answers `{"status":"ok"}`; a Worker missing one of the five required variables answers
@@ -255,10 +285,10 @@ grants them.
255
285
 
256
286
  | Interface | Functions |
257
287
  |---|---|
258
- | `intel` | `use`, `admin` |
288
+ | `intel` | `admin` |
259
289
  | `nodes` | `read`, `create`, `write`, `share` |
260
290
  | `flows` | `read`, `create`, `write`, `publish`, `run`, `share` |
261
- | `tools` | `read`, `test`, `execute`, `admin` |
291
+ | `tools` | `read`, `test`, `execute` |
262
292
  | `mcp` | `connect` |
263
293
 
264
294
  A missing capability answers **`403`**, and the screen says a permission is missing.
@@ -607,6 +607,22 @@ export function createFlowRepository(deps) {
607
607
  .all();
608
608
  return (result.results ?? []).map((row) => row.resource_id);
609
609
  },
610
+ // ⚠️ No actor and no `archived_at` condition, both on purpose (#509). The question is whether
611
+ // the row is still there at all, and archiving keeps it — a reference to something archived is
612
+ // not broken, it is waiting for a restore. The caller has already asked the visibility door for
613
+ // every one of these ids and reaches this only for the ones it answered nothing for.
614
+ //
615
+ // ⚠️ An empty list short-circuits rather than building `IN ()`, which SQLite refuses to parse.
616
+ async existingNodes(nodeIds) {
617
+ const wanted = [...new Set(nodeIds)];
618
+ if (wanted.length === 0)
619
+ return [];
620
+ const result = await deps.db
621
+ .prepare(`SELECT id FROM nodes WHERE id IN (${wanted.map(() => "?").join(", ")}) ORDER BY id`)
622
+ .bind(...wanted)
623
+ .all();
624
+ return (result.results ?? []).map((row) => row.id);
625
+ },
610
626
  async publishedCallees(flowId) {
611
627
  const row = await deps.db
612
628
  .prepare(`SELECT flow.title AS title, version.graph_json AS graph_json
package/dist/auth/auth.js CHANGED
@@ -218,28 +218,55 @@ export function createBrowserAuth(deps) {
218
218
  ? `/auth/connect?returnTo=${encodeURIComponent(pending.returnTo)}`
219
219
  : withConnectError(pending.returnTo));
220
220
  }
221
- const tokens = await deps.oauth.exchange({
222
- issuer: pending.kind === "connection-pending" ? pending.issuer : gateIssuer,
223
- clientId: pending.clientId,
224
- callbackUrl: requestUrl,
225
- redirectUri,
226
- resource: pending.kind === "connection-pending" ? pending.resource : deps.resource,
227
- codeVerifier: pending.codeVerifier,
228
- state: pending.state,
229
- });
230
- if (pending.kind === "connection-pending") {
231
- await deps.portalTokens.write(pending.userId, {
232
- accessToken: tokens.accessToken,
233
- refreshToken: tokens.refreshToken ?? null,
234
- expiresAt: deps.now().getTime() + Math.max(30, tokens.expiresIn) * 1_000,
235
- issuer: pending.issuer,
221
+ // ⚠️ Everything below runs with the handoff READ, and that is the whole difference between
222
+ // this catch and the one on the route (#444): here a failure knows where the person was
223
+ // going, and for a connection handoff it still holds their parked Intel session. The route
224
+ // can only send somebody to a decided screen, so whatever can be answered here is answered
225
+ // here — the token exchange, the portal token write and the sealing of the new session all
226
+ // used to end as a bare 500 in JSON, in the middle of a sign-in.
227
+ try {
228
+ const tokens = await deps.oauth.exchange({
229
+ issuer: pending.kind === "connection-pending" ? pending.issuer : gateIssuer,
236
230
  clientId: pending.clientId,
237
- resource: pending.resource,
231
+ callbackUrl: requestUrl,
232
+ redirectUri,
233
+ resource: pending.kind === "connection-pending" ? pending.resource : deps.resource,
234
+ codeVerifier: pending.codeVerifier,
235
+ state: pending.state,
238
236
  });
239
- return await restore(pending.user, pending.returnTo, pending.returnTo);
237
+ if (pending.kind === "connection-pending") {
238
+ await deps.portalTokens.write(pending.userId, {
239
+ accessToken: tokens.accessToken,
240
+ refreshToken: tokens.refreshToken ?? null,
241
+ expiresAt: deps.now().getTime() + Math.max(30, tokens.expiresIn) * 1_000,
242
+ issuer: pending.issuer,
243
+ clientId: pending.clientId,
244
+ resource: pending.resource,
245
+ });
246
+ return await restore(pending.user, pending.returnTo, pending.returnTo);
247
+ }
248
+ const session = await userSession(pending.clientId, tokens);
249
+ return redirect(pending.returnTo, sessionCookie(await deps.sessions.seal(session), secure, (session.expiresAt - deps.now().getTime()) / 1_000));
250
+ }
251
+ catch (error) {
252
+ // The exception is the only thing that still holds the reason: what the reader gets is a
253
+ // fixed marker, and a marker names a KIND of failure, never its cause (#93).
254
+ reportUnexpectedError(error);
255
+ // `portal_sign_in_failed` is the marker `/auth/connect` already writes for exactly this
256
+ // situation, so nothing new is invented here and no screen has to learn a word. The Tools
257
+ // screen reads it as "the sign-in is broken", which is true of both handoffs — a Gate login
258
+ // that could not be exchanged is no more the reader's doing than a portal one.
259
+ if (pending.kind === "connection-pending") {
260
+ // Every other way out of the callback puts the parked Intel session back, and so does
261
+ // this one: the portal attempt must never cost somebody their Intel login.
262
+ return await restore(pending.user, pending.returnTo, withConnectError(pending.returnTo, "portal_sign_in_failed"));
263
+ }
264
+ // A Gate login handoff parks nothing, so there is no session to put back — and the cookie
265
+ // is spent either way: its verifier belongs to an authorization code that has now been
266
+ // used. Clearing it is what makes the next attempt start a fresh one instead of replaying
267
+ // this one.
268
+ return redirect(withConnectError(pending.returnTo, "portal_sign_in_failed"), sessionCookie("", secure, 0));
240
269
  }
241
- const session = await userSession(pending.clientId, tokens);
242
- return redirect(pending.returnTo, sessionCookie(await deps.sessions.seal(session), secure, (session.expiresAt - deps.now().getTime()) / 1_000));
243
270
  },
244
271
  logout() {
245
272
  return redirect("/", sessionCookie("", secure, 0));
@@ -13,6 +13,42 @@ const IntelConfig = z.strictObject({
13
13
  const Catalog = z.record(z.string(), z.string());
14
14
  const defaultTheme = `/* Generated by \`intel build\`. Intel defaults are active. */\n`;
15
15
  const defaultLogo = `export const customLogoUrl: string | null = null;\n`;
16
+ // The `_headers` file of Workers Assets, written INTO THE BUILD OUTPUT (#543, sibling of
17
+ // anchrd/gate#329).
18
+ //
19
+ // Without it every asset answers with the platform default `public, max-age=0, must-revalidate`,
20
+ // and `public` lets a browser store an ERROR answer too. A `403` that appears once during setup is
21
+ // then replayed on every navigation WITHOUT a request leaving the browser — invisible in the worker
22
+ // log, in the Cloudflare Access log, in the firewall and in Cloudflare Analytics, while `curl`
23
+ // against the same address keeps answering `200`. The second rule matters more here than in Gate:
24
+ // the UI ships 1.9 MB of fingerprinted assets that are revalidated on every navigation today.
25
+ //
26
+ // ⚠️ `! Cache-Control` is not decoration, and the naive two-rule form from the ticket is WRONG.
27
+ // A request matching several rules inherits ALL their headers, and a header named twice is joined
28
+ // with a comma. Measured against `wrangler dev` 4.112.0 with both spellings side by side:
29
+ // without the detach /assets/app.js → `no-store, public, max-age=31536000, immutable`
30
+ // with the detach /assets/app.js → `public, max-age=31536000, immutable`
31
+ // The joined value carries `no-store`, so those 1.9 MB would never be cached at all — the exact
32
+ // opposite of the second rule's purpose.
33
+ //
34
+ // ⚠️ It has to be written into the TEMPORARY directory, before `replaceDir`. The next run's
35
+ // `--emptyOutDir` applies to that directory, so a file placed into the live outDir afterwards
36
+ // survives exactly one build. And `packages/ui/public/` is no way either: the `files` field of
37
+ // @anchrd/intel-ui does not list `public`, so the file would look right in the repository and never
38
+ // reach a customer.
39
+ //
40
+ // The three `cache-control` writes in this package do not contradict it: `auth/auth.ts` (redirect),
41
+ // `http/http.ts` (zipResponse, attachment bytes) all answer `no-store` under `/auth/*` and `/api/*`,
42
+ // which the assets layer never sees. Nothing serves an SPA route under `/assets/`, so the
43
+ // `immutable` rule cannot swallow one.
44
+ const headersFile = `# Generated by \`intel build\` — do not edit by hand.
45
+ /*
46
+ Cache-Control: no-store
47
+
48
+ /assets/*
49
+ ! Cache-Control
50
+ Cache-Control: public, max-age=31536000, immutable
51
+ `;
16
52
  // Customer paths from intel.json are written into generated CSS and TypeScript comments. A path
17
53
  // containing `*/` or a newline would close the comment early, leaving broken output or — in
18
54
  // custom-logo.ts — customer-controlled text outside the comment and inside the UI bundle.
@@ -130,6 +166,9 @@ export function createBuild(deps) {
130
166
  deps.log(`Error: Intel UI build exited with ${exitCode}.`);
131
167
  return 1;
132
168
  }
169
+ // Into the temporary directory, not into outDir — see `headersFile`. Vite has just emptied
170
+ // and refilled this directory; anything written here travels with the swap.
171
+ await deps.writeTextFile(`${temporaryOutDir}/_headers`, headersFile);
133
172
  await deps.replaceDir(temporaryOutDir, location.outDir);
134
173
  deps.log(`Intel UI built at ${location.outDir}.`);
135
174
  return 0;
@@ -7,9 +7,33 @@ import { documentLinkTargets } from "../nodes/document-links/document-links.js";
7
7
  import { parseCsv } from "../shared/csv/csv.js";
8
8
  import { IntelError } from "../shared/intel-error/intel-error.js";
9
9
  import { plainTitle } from "../shared/plain-title/plain-title.js";
10
- // What every bundle leaves out, by decision rather than by accident (#136). The manifest says so,
11
- // because a backup that is silent about what it does not hold will be trusted with exactly that.
12
- const Excluded = ["version-history", "grants", "flow-runs", "archived-nodes"];
10
+ /**
11
+ * What every bundle leaves out, by decision rather than by accident (#136, ADR-0006). The manifest
12
+ * says so, because a backup that is silent about what it does not hold will be trusted with exactly
13
+ * that.
14
+ *
15
+ * ⚠️ ONE list, read by both halves of the round trip: the export writes it into `manifest.json`,
16
+ * and the import answers with it in `BundleImportResult.excluded` (#433). The import does NOT read
17
+ * the word back out of the manifest it was handed — a bundle claiming `excluded: []` would
18
+ * otherwise make the import report that nothing was left behind, and a naked folder carries no
19
+ * manifest to ask at all. Every one of the five holds for every import this build performs: a node
20
+ * arrives with `sequence: 1` and nothing older, an import mints no grants, it starts no runs,
21
+ * nothing it creates is archived, and a published flow's graph is the one that was published.
22
+ *
23
+ * ⚠️ `unpublished-flow-changes` is a CONSTANT of the build like the other four, not a report about
24
+ * this particular bundle (#585). It says what a bundle never carries — the edits made to a flow
25
+ * after it was published — in the same way `version-history` says so for a node with a single
26
+ * version. A list that named only the losses this one export happens to suffer would be a different
27
+ * kind of statement, and the import could not make it at all: it answers before it knows what is in
28
+ * the zip, and from a naked folder it never finds out.
29
+ */
30
+ const Excluded = [
31
+ "version-history",
32
+ "grants",
33
+ "flow-runs",
34
+ "archived-nodes",
35
+ "unpublished-flow-changes",
36
+ ];
13
37
  /**
14
38
  * A title as a file name. Titles are free text and zip paths are not: a separator would move the
15
39
  * entry, a control character corrupts the archive listing, and a trailing dot breaks extraction on
@@ -336,6 +360,15 @@ export function createBundle(deps) {
336
360
  }
337
361
  async function tableCsv(nodeId) {
338
362
  const keys = await deps.repository.listVersionContentKeys(nodeId);
363
+ // ⚠️ `some` on an empty list is false, so no segments at all used to walk past the guard below
364
+ // and return `[].join("")` — the empty string, written into the zip as a file of zero bytes
365
+ // (#534). A table that has never been defined no longer reaches this function at all: it has no
366
+ // current version, and `plannedNode` marks it absent instead. An empty list HERE therefore means
367
+ // something else — a current version whose segments are gone — which is the loss the guard below
368
+ // already names, and it deserves the same answer rather than a silently empty file.
369
+ if (keys.length === 0) {
370
+ throw new IntelError(500, "content_missing", "Version content is missing");
371
+ }
339
372
  const segments = await Promise.all(keys.map(async (key) => await deps.content.get(key)));
340
373
  if (segments.some((segment) => segment === null)) {
341
374
  throw new IntelError(500, "content_missing", "Version content is missing");
@@ -351,6 +384,27 @@ export function createBundle(deps) {
351
384
  return body;
352
385
  };
353
386
  }
387
+ /**
388
+ * The bytes of an attachment version, or the same refusal the text path has always given (#560).
389
+ *
390
+ * ⚠️ A `contentKey` in the row and nothing behind it in R2 is data LOSS, and the attachment path
391
+ * was the one path that answered it with silence: `getStream` returned `null`, the zip got a file
392
+ * of zero bytes, and the export reported success. Whoever filed that zip away as a backup learns
393
+ * what it holds when they restore it — the one moment nothing can be done about it any more.
394
+ *
395
+ * This is deliberately NOT the `absence` answer. "Never uploaded" is a state of the node and
396
+ * travels in the manifest; "the object is gone" is a broken installation, and a bundle must not
397
+ * be able to describe it as if it were fine. Same distinction as `textLoader` and `tableCsv`.
398
+ */
399
+ function streamLoader(contentKey) {
400
+ return async () => {
401
+ const stream = await deps.content.getStream(contentKey);
402
+ if (stream === null) {
403
+ throw new IntelError(500, "content_missing", "Version content is missing");
404
+ }
405
+ return stream;
406
+ };
407
+ }
354
408
  function plannedNode(row, directory, used) {
355
409
  const { node, version } = row;
356
410
  const base = sanitizeName(node.title);
@@ -371,6 +425,30 @@ export function createBundle(deps) {
371
425
  }
372
426
  if (node.kind === "table") {
373
427
  const path = `${directory}${uniqueName(used, base, ".csv")}`;
428
+ // ⚠️ A table with no current version has never been given a header, and for a table the
429
+ // canonical content IS the CSV: there is nothing to write. It used to travel as a file of zero
430
+ // bytes, which the holder of the zip cannot tell apart from a table whose content was lost on
431
+ // the way — the very case `tableCsv` refuses as `content_missing`. One file, two meanings, no
432
+ // way to read which (#534).
433
+ //
434
+ // ⚠️ The entry STAYS in the manifest, and that is the half that matters: dropping it would
435
+ // take the node's title, its description and its place in the tree out of the backup too, and
436
+ // the import would recreate a subtree with a node missing from it without anybody noticing.
437
+ // What travels instead is the node without bytes, plus the word for what is not there.
438
+ if (version === null) {
439
+ return {
440
+ manifest: {
441
+ id: node.id,
442
+ kind: "table",
443
+ title: node.title,
444
+ description: node.description,
445
+ mediaType: TableMediaType,
446
+ path,
447
+ absence: "no-content",
448
+ },
449
+ content: { type: "absent" },
450
+ };
451
+ }
374
452
  return {
375
453
  manifest: {
376
454
  id: node.id,
@@ -387,19 +465,38 @@ export function createBundle(deps) {
387
465
  // Original bytes under the original name: an attachment's title IS its file name, so no
388
466
  // extension is imposed on it.
389
467
  const path = `${directory}${uniqueName(used, base, "")}`;
468
+ // ⚠️ An attachment with no current version was created and never uploaded to, and for an
469
+ // attachment the canonical content IS the bytes: there is nothing to write. It used to travel
470
+ // as a file of zero bytes — the same one file with two meanings the table branch above
471
+ // refuses, and here the second meaning is a lost R2 object (#560).
472
+ //
473
+ // ⚠️ The entry STAYS in the manifest for the same reason it does for a table: dropping it
474
+ // would take the node's title, its description and its place in the tree out of the backup,
475
+ // and the import would rebuild a subtree with a node missing from it and say nothing.
476
+ if (version === null) {
477
+ return {
478
+ manifest: {
479
+ id: node.id,
480
+ kind: "attachment",
481
+ title: node.title,
482
+ description: node.description,
483
+ mediaType: "application/octet-stream",
484
+ path,
485
+ absence: "no-content",
486
+ },
487
+ content: { type: "absent" },
488
+ };
489
+ }
390
490
  return {
391
491
  manifest: {
392
492
  id: node.id,
393
493
  kind: "attachment",
394
494
  title: node.title,
395
495
  description: node.description,
396
- mediaType: version?.mediaType ?? "application/octet-stream",
496
+ mediaType: version.mediaType ?? "application/octet-stream",
397
497
  path,
398
498
  },
399
- content: {
400
- type: "stream",
401
- load: async () => version === null ? null : await deps.content.getStream(version.contentKey),
402
- },
499
+ content: { type: "stream", load: streamLoader(version.contentKey) },
403
500
  };
404
501
  }
405
502
  // ⚠️ A row of a kind this build no longer makes — an `agent` or a `board` written before #390
@@ -425,6 +522,27 @@ export function createBundle(deps) {
425
522
  : { type: "text", load: textLoader(version.contentKey) },
426
523
  };
427
524
  }
525
+ /**
526
+ * Which graph of a flow travels: the PUBLISHED one when the flow has one, the draft otherwise.
527
+ * Three call sites read it through this function rather than spelling the fallback out, because
528
+ * it is one decision and not three (#585).
529
+ *
530
+ * ⚠️ For a flow that was published and edited afterwards, this deliberately leaves the newer draft
531
+ * behind — and that is NOT the `version-history` case, which is why `Excluded` names it with a
532
+ * word of its own. The draft is the newest state there is; what decides against it is that the
533
+ * import cannot carry both. It creates rather than restores (ADR-0006): every flow arrives as a
534
+ * draft with one version and `publishedVersionId: null`, so "published v1 plus draft v2" has no
535
+ * shape on the far side, and carrying both would be flow version history — the thing that ADR
536
+ * refuses for exactly the reasons it lists.
537
+ *
538
+ * Of the two single answers the published one wins because it is the one that was IN EFFECT. A
539
+ * draft need never have been validated — publishing is what checks a graph — so moving the draft
540
+ * instead would replace a running process with somebody's unfinished edit, silently, at the one
541
+ * moment nobody can compare against the source any more.
542
+ */
543
+ function exportedVersionId(flow) {
544
+ return flow.publishedVersionId ?? flow.currentVersionId;
545
+ }
428
546
  function plannedFlow(flow, version, directory, used) {
429
547
  const path = `${directory}${uniqueName(used, sanitizeName(flow.title), ".json")}`;
430
548
  return {
@@ -474,9 +592,7 @@ export function createBundle(deps) {
474
592
  if (actor.canReadFlows) {
475
593
  const folderIds = new Set(rows.filter((row) => row.node.kind === "folder").map((row) => row.node.id));
476
594
  const visible = (await deps.flows.listVisible(asFlowActor(actor))).filter((flow) => flow.parentId === null ? rootId === null : folderIds.has(flow.parentId));
477
- const versionIds = visible
478
- .map((flow) => flow.publishedVersionId ?? flow.currentVersionId)
479
- .filter((id) => id !== null);
595
+ const versionIds = visible.map(exportedVersionId).filter((id) => id !== null);
480
596
  for (const version of await deps.flows.getVersions(versionIds)) {
481
597
  flowVersions.set(version.id, version);
482
598
  }
@@ -518,7 +634,7 @@ export function createBundle(deps) {
518
634
  continue;
519
635
  }
520
636
  if (item.flow !== null) {
521
- const versionId = item.flow.publishedVersionId ?? item.flow.currentVersionId;
637
+ const versionId = exportedVersionId(item.flow);
522
638
  entries.push(plannedFlow(item.flow, versionId === null ? undefined : flowVersions.get(versionId), directory, used));
523
639
  }
524
640
  }
@@ -574,18 +690,25 @@ export function createBundle(deps) {
574
690
  await writer.ready;
575
691
  continue;
576
692
  }
693
+ // Nothing is written for it — not even an empty file, which is the whole point (#534). The
694
+ // manifest carries the node and says why its path is unoccupied.
695
+ if (entry.content.type === "absent")
696
+ continue;
577
697
  if (entry.content.type === "text") {
578
698
  await pushText(entry.manifest.path, await entry.content.load());
579
699
  continue;
580
700
  }
701
+ // ⚠️ No `null` branch here any more, and its removal is the repair (#560): it wrote a file
702
+ // of zero bytes for "the loader has nothing", which was true both for an attachment nobody
703
+ // ever uploaded to and for one whose object had left R2. The first is now `absent` above
704
+ // and never reaches this loop; the second throws out of `load()` and aborts the whole zip,
705
+ // which is what a backup that cannot be written is supposed to do.
706
+ //
707
+ // The loader runs BEFORE the entry is added, so a refusal leaves no half-opened file
708
+ // behind in the zip that nothing will ever terminate.
709
+ const stream = await entry.content.load();
581
710
  const file = new ZipPassThrough(entry.manifest.path);
582
711
  zip.add(file);
583
- const stream = await entry.content.load();
584
- if (stream === null) {
585
- file.push(new Uint8Array(0), true);
586
- await writer.ready;
587
- continue;
588
- }
589
712
  const reader = stream.getReader();
590
713
  for (;;) {
591
714
  const { done, value } = await reader.read();
@@ -648,7 +771,11 @@ export function createBundle(deps) {
648
771
  if (isFolder)
649
772
  folderIdByPath.set(entry.path.endsWith("/") ? entry.path : `${entry.path}/`, newId);
650
773
  let body = null;
651
- if (!isFolder) {
774
+ // ⚠️ An entry that declares an `absence` names a path with nothing at it ON PURPOSE, so the
775
+ // zip is complete rather than broken (#534). Without this the export written by this very
776
+ // build would refuse its own import — and the node it describes arrives the way it left: a
777
+ // table with a title, a place in the tree and no content.
778
+ if (!isFolder && entry.absence === undefined) {
652
779
  const found = files.get(entry.path);
653
780
  if (found === undefined) {
654
781
  throw new IntelError(400, "import_bundle_incomplete", `The manifest names a file the zip does not carry: ${entry.path}`);
@@ -739,6 +866,9 @@ export function createBundle(deps) {
739
866
  flows: typeof metadata.flows === "number" ? metadata.flows : 0,
740
867
  rootNodeIds: Array.isArray(metadata.rootNodeIds) ? metadata.rootNodeIds : [],
741
868
  replayed: true,
869
+ // Not read out of the stored metadata: a replay answers for an import this build performed,
870
+ // and the list is what this build leaves behind rather than something an earlier row said.
871
+ excluded: Excluded,
742
872
  });
743
873
  }
744
874
  return {
@@ -756,7 +886,7 @@ export function createBundle(deps) {
756
886
  if (!flow || flow.archivedAt !== null) {
757
887
  throw new IntelError(404, "flow_not_found", "Flow was not found");
758
888
  }
759
- const versionId = flow.publishedVersionId ?? flow.currentVersionId;
889
+ const versionId = exportedVersionId(flow);
760
890
  const version = versionId === null ? undefined : (await deps.flows.getVersions([versionId]))[0];
761
891
  const entries = [plannedFlow(flow, version, "", new Set())];
762
892
  // A single flow's bundle is rooted at the flow itself: the manifest names it as the root the
@@ -933,9 +1063,14 @@ export function createBundle(deps) {
933
1063
  */
934
1064
  throw new IntelError(400, "import_kind_parked", `Bundle entry “${entry.path}” is a ${entry.kind}, and ${entry.kind}s are not part of this installation any more (anchrd/intel#385). The bundle was exported from a version that still had them; the code is on the branch parked/agents-and-board. Remove the entry from the bundle to import the rest.`);
935
1065
  }
936
- else if (entry.kind === "attachment") {
937
- const bytes = entry.body ?? new Uint8Array(0);
938
- version = versionRowFor(entry, bytes, entry.mediaType ?? "application/octet-stream", await deps.hash(bytes), null);
1066
+ else if (entry.kind === "attachment" && entry.body !== null) {
1067
+ // ⚠️ `entry.body === null` reaches here only for a manifest entry that declared an
1068
+ // absence a naked folder always has the bytes of the file it names. It used to be read
1069
+ // as `new Uint8Array(0)` and given a version of zero bytes, which is a DIFFERENT node
1070
+ // from the one that was exported: the export said "never uploaded to", and the import
1071
+ // would answer with an upload of nothing. No version is what came out of that
1072
+ // installation and no version is what goes back in (#560).
1073
+ version = versionRowFor(entry, entry.body, entry.mediaType ?? "application/octet-stream", await deps.hash(entry.body), null);
939
1074
  }
940
1075
  if (version !== null)
941
1076
  versions.push(version);
@@ -1035,7 +1170,13 @@ export function createBundle(deps) {
1035
1170
  for (const version of versions) {
1036
1171
  await deps.indexing.enqueue(version.id);
1037
1172
  }
1038
- return { nodes: nodes.length, flows: flows.length, rootNodeIds, replayed: false };
1173
+ return {
1174
+ nodes: nodes.length,
1175
+ flows: flows.length,
1176
+ rootNodeIds,
1177
+ replayed: false,
1178
+ excluded: Excluded,
1179
+ };
1039
1180
  },
1040
1181
  };
1041
1182
  }