@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 +37 -7
- package/dist/adapters/db/db-flows.js +16 -0
- package/dist/auth/auth.js +46 -19
- package/dist/build/build.js +39 -0
- package/dist/bundle/bundle.js +165 -24
- package/dist/cli/cli.js +114 -16
- package/dist/flows/flows.js +85 -28
- package/dist/flows/flows.types.d.ts +21 -0
- package/dist/http/http.js +24 -4
- package/dist/intel/intel.js +31 -3
- package/dist/mcp/mcp.js +117 -4
- package/dist/nodes/nodes.js +25 -7
- package/dist/shared/callers/callers.d.ts +28 -0
- package/dist/shared/callers/callers.js +30 -0
- package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
- package/dist/shared/grant-expiry/grant-expiry.js +26 -0
- package/dist/shared/intel-error/intel-error.d.ts +18 -1
- package/dist/shared/intel-error/intel-error.js +18 -1
- package/dist/shared/problem-details/problem-details.d.ts +22 -0
- package/dist/shared/problem-details/problem-details.js +14 -0
- package/package.json +3 -3
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`
|
|
79
|
-
administrator can hand out; it hands out none of them. This
|
|
80
|
-
installation shows an empty screen to everybody including the
|
|
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
|
-
⚠️
|
|
83
|
-
|
|
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` | `
|
|
288
|
+
| `intel` | `admin` |
|
|
259
289
|
| `nodes` | `read`, `create`, `write`, `share` |
|
|
260
290
|
| `flows` | `read`, `create`, `write`, `publish`, `run`, `share` |
|
|
261
|
-
| `tools` | `read`, `test`, `execute
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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));
|
package/dist/build/build.js
CHANGED
|
@@ -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;
|
package/dist/bundle/bundle.js
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
938
|
-
|
|
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 {
|
|
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
|
}
|