@fungi.computer/bonsai 0.0.0-stage → 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/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +196 -2
- package/backend/background.ts +259 -0
- package/backend/change-copy.ts +99 -0
- package/backend/changes.ts +530 -0
- package/backend/diff.ts +228 -0
- package/backend/file-edits.ts +850 -0
- package/backend/files-copy.ts +361 -0
- package/backend/gills.ts +125 -0
- package/backend/git.ts +123 -0
- package/backend/graph-commit-copies.ts +321 -0
- package/backend/history.ts +178 -0
- package/backend/index.ts +144 -0
- package/backend/insights.ts +256 -0
- package/backend/merge.ts +999 -0
- package/backend/methods.ts +848 -0
- package/backend/policy.ts +160 -0
- package/backend/read-copy-page.ts +77 -0
- package/backend/repository-copy.ts +70 -0
- package/backend/reviews.ts +317 -0
- package/backend/service.ts +892 -0
- package/backend/state.ts +914 -0
- package/demo/koi-pond/README.md +17 -0
- package/demo/koi-pond/index.html +11 -0
- package/demo/koi-pond/package.json +13 -0
- package/demo/koi-pond/pnpm-lock.yaml +450 -0
- package/demo/koi-pond/src/fish.ts +32 -0
- package/demo/koi-pond/src/main.ts +24 -0
- package/demo/koi-pond/src/pond.ts +27 -0
- package/docs/media/bonsai-insights.png +0 -0
- package/docs/media/overview-phone.png +0 -0
- package/docs/media/overview.png +0 -0
- package/package.json +152 -3
- package/scripts/agent.sh +55 -0
- package/scripts/demo.sh +233 -0
- package/scripts/rebuild-copies.mjs +34 -0
- package/scripts/release.mjs +52 -0
- package/scripts/runtime-proof.mjs +299 -0
- package/scripts/seed-demo.mjs +724 -0
- package/scripts/seed-step.mjs +37 -0
- package/scripts/shed-terminal-proof.mjs +774 -0
- package/shared/contract.ts +404 -0
- package/shared/hooks.ts +208 -0
- package/shared/insights.ts +90 -0
- package/shared/picker-options.ts +23 -0
- package/shared/presentation.ts +21 -0
- package/shared/repository-deletion.ts +4 -0
- package/test/apps-build-install.test.ts +302 -0
- package/test/background-runtime.test.ts +206 -0
- package/test/browser/board.browser.mjs +52 -0
- package/test/browser/capture.browser.mjs +1264 -0
- package/test/browser/edit-in-inkcap.browser.mjs +83 -0
- package/test/browser/repository-settings-fixture.tsx +108 -0
- package/test/browser/repository-settings.browser.mjs +136 -0
- package/test/browser/repository-settings.html +10 -0
- package/test/browser/visual-fixture.tsx +667 -0
- package/test/browser/visual.html +10 -0
- package/test/browser/vite.config.mjs +9 -0
- package/test/change-page.test.ts +916 -0
- package/test/changes.test.ts +1063 -0
- package/test/checks-workflow-template.test.mjs +234 -0
- package/test/diff.test.ts +70 -0
- package/test/edit-in-inkcap.test.ts +227 -0
- package/test/file-edits.test.ts +2714 -0
- package/test/files-read-copy-calls.test.ts +374 -0
- package/test/fork-adoption.test.ts +111 -0
- package/test/gills.test.ts +125 -0
- package/test/git-binding.fixture.ts +30 -0
- package/test/git-push.config.jsonc +9 -0
- package/test/git-push.native.mjs +413 -0
- package/test/git-push.worker.ts +280 -0
- package/test/graph-commit-read-copy-calls.test.ts +401 -0
- package/test/home-page.test.ts +170 -0
- package/test/hooks.test.ts +250 -0
- package/test/insight-data.test.ts +51 -0
- package/test/insight-layout.test.mjs +230 -0
- package/test/insights.test.ts +154 -0
- package/test/merge-background.test.ts +756 -0
- package/test/merge-conflict.test.ts +337 -0
- package/test/merge-declaration.test.ts +46 -0
- package/test/merge-repro.test.ts +561 -0
- package/test/merge-template-build.test.mjs +234 -0
- package/test/network-layout.test.ts +200 -0
- package/test/outbox.test.ts +424 -0
- package/test/parts.test.ts +86 -0
- package/test/pkt-line.test.ts +54 -0
- package/test/policy.test.ts +172 -0
- package/test/present.test.ts +27 -0
- package/test/read-copy-calls.test.ts +691 -0
- package/test/read-copy-composer.test.ts +388 -0
- package/test/read-copy-lifetimes.test.ts +214 -0
- package/test/read-copy-rpc.config.jsonc +12 -0
- package/test/read-copy-rpc.native.mjs +47 -0
- package/test/read-copy-rpc.worker.ts +92 -0
- package/test/recent-activity.test.ts +62 -0
- package/test/repository-deletion.test.ts +500 -0
- package/test/seed-resume.test.mjs +91 -0
- package/test/storyboard-save-https-git.fixture.mjs +119 -0
- package/test/storyboard-save-template.test.mjs +545 -0
- package/test/ui-errors.test.ts +976 -0
- package/test/workflow-store.test.ts +115 -0
- package/tsconfig.json +36 -0
- package/turbo.jsonc +25 -0
- package/vite.config.ts +19 -0
- package/vitest.config.ts +47 -0
- package/web/api.ts +224 -0
- package/web/app-build-panel.tsx +171 -0
- package/web/board.tsx +259 -0
- package/web/bonsai.css +642 -0
- package/web/change-page.tsx +719 -0
- package/web/change-rows.tsx +135 -0
- package/web/code-tab.tsx +267 -0
- package/web/commit-tree.tsx +152 -0
- package/web/format.ts +54 -0
- package/web/home-page.tsx +274 -0
- package/web/index.html +17 -0
- package/web/insight-data.ts +76 -0
- package/web/insight-tiles.tsx +534 -0
- package/web/insights.tsx +250 -0
- package/web/issues-panel.tsx +305 -0
- package/web/main.tsx +72 -0
- package/web/network-graph.tsx +647 -0
- package/web/overview.tsx +339 -0
- package/web/pages.tsx +469 -0
- package/web/parts.tsx +381 -0
- package/web/public/favicon.svg +1 -0
- package/web/repo-navigation.tsx +256 -0
- package/web/repo-settings.tsx +145 -0
- package/web/router.tsx +151 -0
- package/web/transport.ts +91 -0
- package/web/views.tsx +151 -0
- package/web/working-now.tsx +98 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import process from "node:process";
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
12
|
+
import { URL } from "node:url";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
import { Script } from "node:vm";
|
|
15
|
+
import * as S from "effect/Schema";
|
|
16
|
+
import * as z from "zod";
|
|
17
|
+
import {
|
|
18
|
+
catalogEvents,
|
|
19
|
+
catalogMethods,
|
|
20
|
+
compile,
|
|
21
|
+
validate,
|
|
22
|
+
workflowDefinitionSchema,
|
|
23
|
+
} from "@fungi.computer/braid";
|
|
24
|
+
// Shed's, Prints', Inkcap's and Bonsai's sources are pinned at runtime only;
|
|
25
|
+
// Bonsai's compiler options do not own them.
|
|
26
|
+
import { buildAppApi } from "@fungi.computer/agar/backend-declaration";
|
|
27
|
+
import { mintComputerProcessId } from "@fungi.computer/computer/operations";
|
|
28
|
+
import { appBackend as bonsaiBackend } from "../backend/methods.ts";
|
|
29
|
+
import { appBackend as shedBackend } from "../../shed/backend/methods.ts";
|
|
30
|
+
import { createShedRuns } from "../../shed/backend/runs.ts";
|
|
31
|
+
import { appBackend as printsBackend } from "../../prints/backend/methods.ts";
|
|
32
|
+
import { appBackend as inkcapBackend } from "../../inkcap/backend/methods.ts";
|
|
33
|
+
import {
|
|
34
|
+
startHttpsGitServer,
|
|
35
|
+
initBareRepo,
|
|
36
|
+
} from "./storyboard-save-https-git.fixture.mjs";
|
|
37
|
+
|
|
38
|
+
const execFileAsync = promisify(execFile);
|
|
39
|
+
|
|
40
|
+
async function readTemplate(name) {
|
|
41
|
+
const template = z
|
|
42
|
+
.object({ definition: z.json() })
|
|
43
|
+
.parse(
|
|
44
|
+
JSON.parse(
|
|
45
|
+
await readFile(
|
|
46
|
+
new URL(`../../trellis/templates/${name}.json`, import.meta.url),
|
|
47
|
+
"utf8",
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
return S.decodeUnknownSync(workflowDefinitionSchema)(template.definition);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function findNode(definition, id) {
|
|
55
|
+
const node = definition.nodes.find((candidate) => candidate.id === id);
|
|
56
|
+
assert.ok(node, `missing node ${id}`);
|
|
57
|
+
return node;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Only the external Code execution boundary is replaced by Node VM here,
|
|
61
|
+
// exactly as checks-workflow-template.test.mjs proves bonsai-checks.json's
|
|
62
|
+
// command. The commands this produces then run through Shed's and Inkcap's
|
|
63
|
+
// real App method handlers below, not through a hand-run shell.
|
|
64
|
+
async function evaluate(code, input) {
|
|
65
|
+
return z.json().parse(
|
|
66
|
+
await new Script(`(async () => { ${code} })()`).runInNewContext({
|
|
67
|
+
env: { INPUT: { read: () => Promise.resolve(input) } },
|
|
68
|
+
crypto: globalThis.crypto,
|
|
69
|
+
TextEncoder: globalThis.TextEncoder,
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test("pins the Storyboard + Save to git template's Save step to Bonsai's, Shed's and Inkcap's maintained declarations, and keeps the stock Storyboard template Save-free", async () => {
|
|
75
|
+
const bonsaiApi = buildAppApi(bonsaiBackend);
|
|
76
|
+
const shedApi = buildAppApi(shedBackend);
|
|
77
|
+
const printsApi = buildAppApi(printsBackend);
|
|
78
|
+
const inkcapApi = buildAppApi(inkcapBackend);
|
|
79
|
+
const catalog = {
|
|
80
|
+
app_bonsai: {
|
|
81
|
+
methods: (await catalogMethods(bonsaiApi.methods)).methods,
|
|
82
|
+
events: (await catalogEvents(bonsaiApi.events)).events,
|
|
83
|
+
},
|
|
84
|
+
app_shed: { methods: (await catalogMethods(shedApi.methods)).methods },
|
|
85
|
+
app_prints: { methods: (await catalogMethods(printsApi.methods)).methods },
|
|
86
|
+
app_inkcap: { methods: (await catalogMethods(inkcapApi.methods)).methods },
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// The stock Storyboard template is unchanged: it declares only Prints, has
|
|
90
|
+
// no Git step, and compiles as an ordinary run with only Prints bound.
|
|
91
|
+
const stock = await readTemplate("storyboard");
|
|
92
|
+
assert.deepEqual(Object.keys(stock.apps), ["prints"]);
|
|
93
|
+
assert.ok(
|
|
94
|
+
!stock.nodes.some((node) => node.kind === "app" && "gitAccess" in node),
|
|
95
|
+
"the stock Storyboard template must declare no Git step",
|
|
96
|
+
);
|
|
97
|
+
assert.deepEqual(validate(stock, { app_prints: catalog.app_prints }), []);
|
|
98
|
+
const stockCompiled = await compile(
|
|
99
|
+
stock,
|
|
100
|
+
{ app_prints: catalog.app_prints },
|
|
101
|
+
{ prints: { appId: "app_prints", installationId: "prints" } },
|
|
102
|
+
);
|
|
103
|
+
assert.ok(stockCompiled.ok, JSON.stringify(stockCompiled));
|
|
104
|
+
|
|
105
|
+
// The separate, opt-in "Storyboard + Save to git" template carries the Git
|
|
106
|
+
// step and needs Bonsai, Shed and Inkcap in addition to Prints.
|
|
107
|
+
const withSave = await readTemplate("storyboard-save");
|
|
108
|
+
assert.deepEqual(Object.keys(withSave.apps).sort(), [
|
|
109
|
+
"bonsai",
|
|
110
|
+
"inkcap",
|
|
111
|
+
"prints",
|
|
112
|
+
"shed",
|
|
113
|
+
]);
|
|
114
|
+
assert.deepEqual(validate(withSave, catalog), []);
|
|
115
|
+
const compiled = await compile(withSave, catalog, {
|
|
116
|
+
prints: { appId: "app_prints", installationId: "prints" },
|
|
117
|
+
bonsai: { appId: "app_bonsai", installationId: "bonsai" },
|
|
118
|
+
shed: { appId: "app_shed", installationId: "shed" },
|
|
119
|
+
inkcap: { appId: "app_inkcap", installationId: "inkcap" },
|
|
120
|
+
});
|
|
121
|
+
assert.ok(compiled.ok, JSON.stringify(compiled));
|
|
122
|
+
const clone = findNode(withSave, "clone");
|
|
123
|
+
const commitPush = findNode(withSave, "commitPush");
|
|
124
|
+
const writeSheet = findNode(withSave, "writeSheet");
|
|
125
|
+
assert.equal(clone.app, "shed");
|
|
126
|
+
assert.equal(clone.method, "run");
|
|
127
|
+
assert.deepEqual(clone.gitAccess, {
|
|
128
|
+
binding: "bonsai",
|
|
129
|
+
repository: "storyboard",
|
|
130
|
+
branches: ["refs/heads/main", "refs/heads/storyboard-drafts"],
|
|
131
|
+
});
|
|
132
|
+
assert.deepEqual(commitPush.gitAccess, clone.gitAccess);
|
|
133
|
+
assert.equal(writeSheet.app, "inkcap");
|
|
134
|
+
assert.equal(writeSheet.method, "writeFile");
|
|
135
|
+
assert.equal(
|
|
136
|
+
"gitAccess" in writeSheet,
|
|
137
|
+
false,
|
|
138
|
+
"the write step needs no Git access of its own",
|
|
139
|
+
);
|
|
140
|
+
const branchInput = withSave.input.properties.branch;
|
|
141
|
+
assert.deepEqual(
|
|
142
|
+
branchInput.enum.map((branch) => `refs/heads/${branch}`),
|
|
143
|
+
clone.gitAccess.branches,
|
|
144
|
+
);
|
|
145
|
+
assert.equal(
|
|
146
|
+
withSave.input.properties.repository.default,
|
|
147
|
+
clone.gitAccess.repository,
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("the Save to git flow runs through Shed's real run handler and Inkcap's real writeFile handler, and lands exact bytes", async (t) => {
|
|
152
|
+
const definition = await readTemplate("storyboard-save");
|
|
153
|
+
const saveSetup = findNode(definition, "saveSetup");
|
|
154
|
+
const cloneCommand = findNode(definition, "cloneCommand");
|
|
155
|
+
const commitCommand = findNode(definition, "commitCommand");
|
|
156
|
+
|
|
157
|
+
const work = await mkdtemp(join(tmpdir(), "storyboard-save-"));
|
|
158
|
+
t.after(() => rm(work, { recursive: true, force: true }));
|
|
159
|
+
const reposRoot = join(work, "repos");
|
|
160
|
+
await mkdir(reposRoot);
|
|
161
|
+
initBareRepo(reposRoot, "storyboard");
|
|
162
|
+
const server = await startHttpsGitServer(reposRoot);
|
|
163
|
+
t.after(() => server.close());
|
|
164
|
+
const remote = `${server.origin}/storyboard.git`;
|
|
165
|
+
|
|
166
|
+
// Production runs this inside the Computer sandbox, where /workspace is
|
|
167
|
+
// real; locally, a real (non-sync, so the in-process HTTPS server above
|
|
168
|
+
// can still answer requests concurrently) exec backend redirects the same
|
|
169
|
+
// absolute paths under a writable root instead of touching /workspace.
|
|
170
|
+
const sandboxRoot = join(work, "sandbox");
|
|
171
|
+
await mkdir(sandboxRoot);
|
|
172
|
+
const localize = (absolute) => absolute.replace("/workspace", sandboxRoot);
|
|
173
|
+
|
|
174
|
+
function createRealComputer() {
|
|
175
|
+
const procs = new Map();
|
|
176
|
+
return {
|
|
177
|
+
async processStart({ command, cwd }) {
|
|
178
|
+
const id = mintComputerProcessId();
|
|
179
|
+
const localCommand = command.replaceAll("/workspace", sandboxRoot);
|
|
180
|
+
const startedAt = Date.now();
|
|
181
|
+
const record = {
|
|
182
|
+
id,
|
|
183
|
+
port: null,
|
|
184
|
+
command,
|
|
185
|
+
cwd: cwd ?? null,
|
|
186
|
+
state: "running",
|
|
187
|
+
exitCode: null,
|
|
188
|
+
sync: "unknown",
|
|
189
|
+
startedAt,
|
|
190
|
+
endedAt: null,
|
|
191
|
+
};
|
|
192
|
+
procs.set(id, record);
|
|
193
|
+
const child = spawn("bash", ["-c", localCommand], {
|
|
194
|
+
cwd: cwd ? localize(cwd) : undefined,
|
|
195
|
+
env: { ...process.env, GIT_SSL_NO_VERIFY: "true" },
|
|
196
|
+
});
|
|
197
|
+
let stderr = "";
|
|
198
|
+
child.stderr.on("data", (chunk) => {
|
|
199
|
+
stderr += chunk;
|
|
200
|
+
});
|
|
201
|
+
child.on("exit", (code) => {
|
|
202
|
+
record.state = "exited";
|
|
203
|
+
record.exitCode = code;
|
|
204
|
+
record.endedAt = Date.now();
|
|
205
|
+
if (code !== 0) record.stderr = stderr.slice(-4000);
|
|
206
|
+
});
|
|
207
|
+
return record;
|
|
208
|
+
},
|
|
209
|
+
processes() {
|
|
210
|
+
return Promise.resolve({ processes: [...procs.values()] });
|
|
211
|
+
},
|
|
212
|
+
workflowGitPass() {
|
|
213
|
+
return Promise.resolve({
|
|
214
|
+
passId: "pass_test",
|
|
215
|
+
remote,
|
|
216
|
+
expiresAt: Date.now() + 300000,
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
async write({ path, content }) {
|
|
220
|
+
const target = localize(path);
|
|
221
|
+
await mkdir(dirname(target), { recursive: true });
|
|
222
|
+
await writeFile(target, content, "utf8");
|
|
223
|
+
return { path, bytes: Buffer.byteLength(content, "utf8") };
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const database = new DatabaseSync(":memory:");
|
|
229
|
+
t.after(() => database.close());
|
|
230
|
+
const sql = {
|
|
231
|
+
exec(query, ...values) {
|
|
232
|
+
const rows = database.prepare(query).all(...values);
|
|
233
|
+
return { toArray: () => rows };
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
const computer = createRealComputer();
|
|
237
|
+
const shedContext = {
|
|
238
|
+
runs: createShedRuns(sql),
|
|
239
|
+
env: { COMPUTER: computer },
|
|
240
|
+
};
|
|
241
|
+
const inkcapContext = { env: { COMPUTER: computer } };
|
|
242
|
+
const actor = { kind: "member", memberId: "member_one" };
|
|
243
|
+
const computerId = "computer_one";
|
|
244
|
+
const gitAccess = {
|
|
245
|
+
repository: {
|
|
246
|
+
kind: "installation",
|
|
247
|
+
installationId: "bonsai",
|
|
248
|
+
repository: "storyboard",
|
|
249
|
+
},
|
|
250
|
+
branches: ["refs/heads/main", "refs/heads/storyboard-drafts"],
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const shedRunReplySchema = z.union([
|
|
254
|
+
z.strictObject({ kind: z.literal("pending"), retryAfterMs: z.number() }),
|
|
255
|
+
z.strictObject({ exitCode: z.number(), logPath: z.string() }),
|
|
256
|
+
z.looseObject({ error: z.string() }),
|
|
257
|
+
]);
|
|
258
|
+
const writeReplySchema = z.union([
|
|
259
|
+
z.strictObject({ path: z.string(), bytes: z.number() }),
|
|
260
|
+
z.looseObject({ error: z.string() }),
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
async function runShed(command, idempotencyKey) {
|
|
264
|
+
const caller = { actor, computerId, idempotencyKey };
|
|
265
|
+
const input = { command, gitAccess };
|
|
266
|
+
const deadline = Date.now() + 20000;
|
|
267
|
+
for (;;) {
|
|
268
|
+
const result = shedRunReplySchema.parse(
|
|
269
|
+
await shedBackend.call("run", input, shedContext, caller),
|
|
270
|
+
);
|
|
271
|
+
if ("kind" in result && result.kind === "pending") {
|
|
272
|
+
if (Date.now() > deadline)
|
|
273
|
+
throw new Error(`${idempotencyKey} timed out`);
|
|
274
|
+
await delay(25);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
assert.ok(
|
|
278
|
+
!("error" in result),
|
|
279
|
+
`${idempotencyKey} refused: ${JSON.stringify(result)}`,
|
|
280
|
+
);
|
|
281
|
+
assert.equal(
|
|
282
|
+
result.exitCode,
|
|
283
|
+
0,
|
|
284
|
+
`${idempotencyKey}: ${JSON.stringify(result)}`,
|
|
285
|
+
);
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function save(contactSheet, branch, saveIndex) {
|
|
291
|
+
const setup = await evaluate(saveSetup.source, {
|
|
292
|
+
contactSheet,
|
|
293
|
+
repository: "storyboard",
|
|
294
|
+
branch,
|
|
295
|
+
});
|
|
296
|
+
const clone = await evaluate(cloneCommand.source, {
|
|
297
|
+
base: setup.base,
|
|
298
|
+
repoDir: setup.repoDir,
|
|
299
|
+
dir: setup.dir,
|
|
300
|
+
branch: setup.branch,
|
|
301
|
+
});
|
|
302
|
+
await runShed(clone.command, `clone-${saveIndex}`);
|
|
303
|
+
const written = writeReplySchema.parse(
|
|
304
|
+
await inkcapBackend.call(
|
|
305
|
+
"writeFile",
|
|
306
|
+
{ path: setup.filePath, content: contactSheet },
|
|
307
|
+
inkcapContext,
|
|
308
|
+
{ actor, computerId },
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
assert.ok(
|
|
312
|
+
!("error" in written),
|
|
313
|
+
`write refused: ${JSON.stringify(written)}`,
|
|
314
|
+
);
|
|
315
|
+
const commit = await evaluate(commitCommand.source, {
|
|
316
|
+
repoDir: setup.repoDir,
|
|
317
|
+
dir: setup.dir,
|
|
318
|
+
saveId: setup.saveId,
|
|
319
|
+
branch: setup.branch,
|
|
320
|
+
});
|
|
321
|
+
await runShed(commit.command, `commit-${saveIndex}`);
|
|
322
|
+
return setup;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// A sheet with apostrophes and a long line: nothing here goes through a
|
|
326
|
+
// shell, since the write step is Inkcap's structured writeFile, not a
|
|
327
|
+
// heredoc, but this proves the whole path keeps bytes exact regardless.
|
|
328
|
+
const longLine = "L".repeat(5000) + " end-of-long-line";
|
|
329
|
+
const sheet = [
|
|
330
|
+
"# Storyboard contact sheet and shot list",
|
|
331
|
+
"",
|
|
332
|
+
"## 1. The fox's den at dawn",
|
|
333
|
+
"",
|
|
334
|
+
"Decision: keep",
|
|
335
|
+
"",
|
|
336
|
+
"Review note: don't regenerate; it's perfect. \"Quotes\" and 'apostrophes' too.",
|
|
337
|
+
"",
|
|
338
|
+
longLine,
|
|
339
|
+
"",
|
|
340
|
+
].join("\n");
|
|
341
|
+
|
|
342
|
+
const first = await save(sheet, "storyboard-drafts", 1);
|
|
343
|
+
const checkout = join(work, "checkout-1");
|
|
344
|
+
await execFileAsync(
|
|
345
|
+
"git",
|
|
346
|
+
["clone", "-q", "--branch", "storyboard-drafts", remote, checkout],
|
|
347
|
+
{ env: { ...process.env, GIT_SSL_NO_VERIFY: "true" } },
|
|
348
|
+
);
|
|
349
|
+
const saved = await readFile(join(checkout, first.fileRel), "utf8");
|
|
350
|
+
assert.equal(
|
|
351
|
+
saved,
|
|
352
|
+
sheet,
|
|
353
|
+
"the saved bytes must exactly match the sheet, with no trimming",
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
// A second save to the same branch fetches the existing branch rather than
|
|
357
|
+
// starting an orphan, and keeps the first save's directory.
|
|
358
|
+
const secondSheet =
|
|
359
|
+
"# Storyboard contact sheet and shot list\n\nSecond run, with an apostrophe: it's done.\n";
|
|
360
|
+
const second = await save(secondSheet, "storyboard-drafts", 2);
|
|
361
|
+
const checkout2 = join(work, "checkout-2");
|
|
362
|
+
await execFileAsync(
|
|
363
|
+
"git",
|
|
364
|
+
["clone", "-q", "--branch", "storyboard-drafts", remote, checkout2],
|
|
365
|
+
{ env: { ...process.env, GIT_SSL_NO_VERIFY: "true" } },
|
|
366
|
+
);
|
|
367
|
+
assert.equal(
|
|
368
|
+
await readFile(join(checkout2, first.fileRel), "utf8"),
|
|
369
|
+
sheet,
|
|
370
|
+
"the first save's directory must survive a later save",
|
|
371
|
+
);
|
|
372
|
+
assert.equal(
|
|
373
|
+
await readFile(join(checkout2, second.fileRel), "utf8"),
|
|
374
|
+
secondSheet,
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("refuses a repository input that does not match the Save step's Git access", async () => {
|
|
379
|
+
const definition = await readTemplate("storyboard-save");
|
|
380
|
+
const saveSetup = findNode(definition, "saveSetup");
|
|
381
|
+
await assert.rejects(
|
|
382
|
+
evaluate(saveSetup.source, {
|
|
383
|
+
contactSheet: "x",
|
|
384
|
+
repository: "some-other-repo",
|
|
385
|
+
branch: "main",
|
|
386
|
+
}),
|
|
387
|
+
/authorized for repository 'storyboard'/,
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("caps the contact sheet by UTF-8 bytes, not characters, to match Inkcap's real writeFile limit", async () => {
|
|
392
|
+
const definition = await readTemplate("storyboard-save");
|
|
393
|
+
const saveSetup = findNode(definition, "saveSetup");
|
|
394
|
+
const writeSheet = findNode(definition, "writeSheet");
|
|
395
|
+
// Inkcap's own 48 KiB bound (apps/inkcap/backend/methods.ts:60,67-72).
|
|
396
|
+
const inkcapByteLimit = 48 * 1024;
|
|
397
|
+
|
|
398
|
+
// A non-ASCII sheet whose character count is far under the schema's old
|
|
399
|
+
// 49,152-character bound, but whose UTF-8 byte count is over Inkcap's
|
|
400
|
+
// real 48 KiB limit: "中" is 1 UTF-16 code unit and 3 UTF-8 bytes, so
|
|
401
|
+
// 20,000 of them are 20,000 characters but 60,000 bytes.
|
|
402
|
+
const nonAsciiSheet = "中".repeat(20000);
|
|
403
|
+
assert.ok(nonAsciiSheet.length < 49152);
|
|
404
|
+
assert.ok(
|
|
405
|
+
new globalThis.TextEncoder().encode(nonAsciiSheet).byteLength >
|
|
406
|
+
inkcapByteLimit,
|
|
407
|
+
);
|
|
408
|
+
await assert.rejects(
|
|
409
|
+
evaluate(saveSetup.source, {
|
|
410
|
+
contactSheet: nonAsciiSheet,
|
|
411
|
+
repository: "storyboard",
|
|
412
|
+
branch: "main",
|
|
413
|
+
}),
|
|
414
|
+
/UTF-8 bytes, over Inkcap's/,
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
// A sheet right at the byte boundary is accepted by saveSetup and would
|
|
418
|
+
// be accepted by Inkcap's own input schema too, so the two bounds agree.
|
|
419
|
+
const atLimitSheet = "a".repeat(inkcapByteLimit);
|
|
420
|
+
const setup = await evaluate(saveSetup.source, {
|
|
421
|
+
contactSheet: atLimitSheet,
|
|
422
|
+
repository: "storyboard",
|
|
423
|
+
branch: "main",
|
|
424
|
+
});
|
|
425
|
+
assert.ok(setup.saveId);
|
|
426
|
+
const inkcapApi = buildAppApi(inkcapBackend);
|
|
427
|
+
const inkcapCatalog = (await catalogMethods(inkcapApi.methods)).methods;
|
|
428
|
+
assert.equal(writeSheet.methodDigest, inkcapCatalog.writeFile.digest);
|
|
429
|
+
const writeInputSchema = z.looseObject({
|
|
430
|
+
path: z.string(),
|
|
431
|
+
content: z
|
|
432
|
+
.string()
|
|
433
|
+
.refine(
|
|
434
|
+
(value) =>
|
|
435
|
+
new globalThis.TextEncoder().encode(value).byteLength <=
|
|
436
|
+
inkcapByteLimit,
|
|
437
|
+
"exceeds Inkcap's byte limit",
|
|
438
|
+
),
|
|
439
|
+
});
|
|
440
|
+
assert.doesNotThrow(() =>
|
|
441
|
+
writeInputSchema.parse({ path: "/workspace/x.md", content: atLimitSheet }),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
// One byte over the limit is refused by saveSetup before ever reaching
|
|
445
|
+
// Inkcap, where it would have been refused anyway (spending no credit).
|
|
446
|
+
await assert.rejects(
|
|
447
|
+
evaluate(saveSetup.source, {
|
|
448
|
+
contactSheet: "a".repeat(inkcapByteLimit + 1),
|
|
449
|
+
repository: "storyboard",
|
|
450
|
+
branch: "main",
|
|
451
|
+
}),
|
|
452
|
+
/UTF-8 bytes, over Inkcap's/,
|
|
453
|
+
);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("keeps storyboard-save.json's copied Storyboard graph identical to storyboard.json, so they can't drift", async () => {
|
|
457
|
+
const stock = await readTemplate("storyboard");
|
|
458
|
+
const withSave = await readTemplate("storyboard-save");
|
|
459
|
+
|
|
460
|
+
// The shared run-form input (beats/aspect/style) is untouched; only the
|
|
461
|
+
// Save-specific repository/branch fields are added on top.
|
|
462
|
+
assert.deepEqual(
|
|
463
|
+
withSave.input.properties.beats,
|
|
464
|
+
stock.input.properties.beats,
|
|
465
|
+
);
|
|
466
|
+
assert.deepEqual(
|
|
467
|
+
withSave.input.properties.aspect,
|
|
468
|
+
stock.input.properties.aspect,
|
|
469
|
+
);
|
|
470
|
+
assert.deepEqual(
|
|
471
|
+
withSave.input.properties.style,
|
|
472
|
+
stock.input.properties.style,
|
|
473
|
+
);
|
|
474
|
+
assert.deepEqual(withSave.input.required, stock.input.required);
|
|
475
|
+
|
|
476
|
+
// The beat-generation graph (fan-out, map, review, animate, retry) is
|
|
477
|
+
// copied byte-for-byte: a full deep-equal on both subgraphs.
|
|
478
|
+
assert.deepEqual(withSave.subgraphs.beat, stock.subgraphs.beat);
|
|
479
|
+
assert.deepEqual(withSave.subgraphs.attempt, stock.subgraphs.attempt);
|
|
480
|
+
|
|
481
|
+
// beatInputs and beats are copied byte-for-byte too.
|
|
482
|
+
assert.deepEqual(
|
|
483
|
+
findNode(withSave, "beatInputs"),
|
|
484
|
+
findNode(stock, "beatInputs"),
|
|
485
|
+
);
|
|
486
|
+
assert.deepEqual(findNode(withSave, "beats"), findNode(stock, "beats"));
|
|
487
|
+
|
|
488
|
+
// contactSheet is identical except its output bound, deliberately lowered
|
|
489
|
+
// in the Save template to fit Inkcap's 48 KiB writeFile limit (see the
|
|
490
|
+
// byte-cap test above); the stock template keeps its original 64 KiB bound.
|
|
491
|
+
const stockContactSheet = findNode(stock, "contactSheet");
|
|
492
|
+
const saveContactSheet = findNode(withSave, "contactSheet");
|
|
493
|
+
assert.deepEqual(
|
|
494
|
+
{ ...saveContactSheet, output: undefined },
|
|
495
|
+
{ ...stockContactSheet, output: undefined },
|
|
496
|
+
);
|
|
497
|
+
assert.equal(stockContactSheet.output.maxLength, 65536);
|
|
498
|
+
assert.equal(saveContactSheet.output.maxLength, 49152);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("stops the run with Inkcap's refusal reason instead of falling through to commitPush's 'nothing to commit'", async () => {
|
|
502
|
+
const definition = await readTemplate("storyboard-save");
|
|
503
|
+
const writeFailure = findNode(definition, "writeFailure");
|
|
504
|
+
|
|
505
|
+
// Structural wiring: commitPush is gated on writeSheet's own `ok` port
|
|
506
|
+
// (not a plain completion-ordering edge), and a refusal is routed to a
|
|
507
|
+
// node that always throws, carrying the refusal through.
|
|
508
|
+
assert.deepEqual(definition.edges, [
|
|
509
|
+
{
|
|
510
|
+
from: { node: "clone", port: "after" },
|
|
511
|
+
to: { node: "writeSheet", port: "after" },
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
from: { node: "writeOk", port: "true" },
|
|
515
|
+
to: { node: "commitPush", port: "after" },
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
from: { node: "writeFailed", port: "true" },
|
|
519
|
+
to: { node: "writeFailure", port: "after" },
|
|
520
|
+
},
|
|
521
|
+
]);
|
|
522
|
+
const writeOk = findNode(definition, "writeOk");
|
|
523
|
+
const writeFailed = findNode(definition, "writeFailed");
|
|
524
|
+
assert.deepEqual(writeOk.test, {
|
|
525
|
+
op: "exists",
|
|
526
|
+
value: { from: { node: "writeSheet", port: "ok" } },
|
|
527
|
+
});
|
|
528
|
+
assert.deepEqual(writeFailed.test, {
|
|
529
|
+
op: "exists",
|
|
530
|
+
value: { from: { node: "writeSheet", port: "refused" } },
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// The failure node's own behavior: given Inkcap's refusal shape, it
|
|
534
|
+
// always throws with that reason, instead of silently completing.
|
|
535
|
+
const refusal = {
|
|
536
|
+
error: "attachment_budget_exceeded",
|
|
537
|
+
kind: "write",
|
|
538
|
+
retryAfterMs: 1000,
|
|
539
|
+
};
|
|
540
|
+
await assert.rejects(evaluate(writeFailure.source, { refusal }), (cause) => {
|
|
541
|
+
assert.match(cause.message, /could not write the contact sheet/);
|
|
542
|
+
assert.match(cause.message, /attachment_budget_exceeded/);
|
|
543
|
+
return true;
|
|
544
|
+
});
|
|
545
|
+
});
|