@fungi.computer/bonsai 0.0.0-stage → 0.0.1
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 +17 -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 +835 -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/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 +5 -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 +715 -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,850 @@
|
|
|
1
|
+
/** Bonsai owns saved text, revision CAS and file-specific Git conflict decisions. */
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { AppError } from "@fungi.computer/agar/errors";
|
|
4
|
+
import { deterministicReplayKey, replayKey } from "@fungi.computer/cairn";
|
|
5
|
+
import {
|
|
6
|
+
begin,
|
|
7
|
+
runStep,
|
|
8
|
+
gitStepSchema,
|
|
9
|
+
sqliteGitStepMaterial,
|
|
10
|
+
dropMaterial,
|
|
11
|
+
GitStepMaterialError,
|
|
12
|
+
} from "@fungi.computer/agar/git-steps";
|
|
13
|
+
import {
|
|
14
|
+
actorLane,
|
|
15
|
+
mayEnqueueBackground,
|
|
16
|
+
backgroundOriginSchema,
|
|
17
|
+
type BackgroundOrigin,
|
|
18
|
+
type BackgroundBinding,
|
|
19
|
+
} from "@fungi.computer/puffball/background-replay";
|
|
20
|
+
import type {
|
|
21
|
+
ConcurrentWatchdogRuntime,
|
|
22
|
+
WatchdogRunningJob,
|
|
23
|
+
WatchdogExecutionDecision,
|
|
24
|
+
WatchdogTransactionalProjection,
|
|
25
|
+
WatchdogQueuedJob,
|
|
26
|
+
WatchdogSqliteOwner,
|
|
27
|
+
WatchdogQueueTransition,
|
|
28
|
+
WatchdogSettledJob,
|
|
29
|
+
} from "@fungi.computer/watchdog";
|
|
30
|
+
import type { AppMethodCallContext } from "@fungi.computer/puffball/app-method-runtime";
|
|
31
|
+
import {
|
|
32
|
+
fileSaveInput,
|
|
33
|
+
fileSaveResult,
|
|
34
|
+
fileSyncResult,
|
|
35
|
+
pendingFileEdit,
|
|
36
|
+
repositoryFileByteLimit,
|
|
37
|
+
type FileSaveInput,
|
|
38
|
+
type FileSaveResult,
|
|
39
|
+
} from "@fungi.computer/agar/roles/files";
|
|
40
|
+
import { invalidateFilesCopies } from "./files-copy.js";
|
|
41
|
+
import { mayPush } from "./policy.js";
|
|
42
|
+
import { isBinary } from "./diff.js";
|
|
43
|
+
import { AppMethodRefusal } from "@fungi.computer/agar/backend";
|
|
44
|
+
import type { Bonsai } from "./service.js";
|
|
45
|
+
|
|
46
|
+
const storedEdit = fileSaveInput.omit({ revision: true }).extend({
|
|
47
|
+
lane: z.string(),
|
|
48
|
+
revision: z.number().int().positive(),
|
|
49
|
+
state: pendingFileEdit.shape.state,
|
|
50
|
+
theirs: pendingFileEdit.shape.theirs,
|
|
51
|
+
failure: pendingFileEdit.shape.failure,
|
|
52
|
+
commit: fileSaveInput.shape.baseCommit.optional(),
|
|
53
|
+
retryOrder: z.number().int().positive().optional(),
|
|
54
|
+
origin: z.optional(backgroundOriginSchema),
|
|
55
|
+
publishing: z.literal(true).optional(),
|
|
56
|
+
retryable: z.literal(false).optional(),
|
|
57
|
+
});
|
|
58
|
+
/** One member-private saved edit row as stored. */
|
|
59
|
+
export type StoredEdit = z.output<typeof storedEdit>;
|
|
60
|
+
const row = z.strictObject({
|
|
61
|
+
revision: z.number().int().positive(),
|
|
62
|
+
body: z.string().nullable(),
|
|
63
|
+
});
|
|
64
|
+
/** The repository, branch and path that key one saved edit. */
|
|
65
|
+
export type EditKey = Pick<FileSaveInput, "repo" | "branch" | "path">;
|
|
66
|
+
// At 64 KiB per row, a member retains at most 8 MiB of saved file text.
|
|
67
|
+
const savedFileRowsPerMember = 128;
|
|
68
|
+
const countRow = z.strictObject({ count: z.number().int().nonnegative() });
|
|
69
|
+
const retryOrderRow = z.strictObject({ next: z.number().int().positive() });
|
|
70
|
+
|
|
71
|
+
function decodeEdit(raw: unknown): StoredEdit {
|
|
72
|
+
const saved = row.parse(raw);
|
|
73
|
+
if (saved.body === null) throw new Error("Discarded file has no saved text");
|
|
74
|
+
const body: unknown = JSON.parse(saved.body);
|
|
75
|
+
return storedEdit.parse({
|
|
76
|
+
...storedEdit.omit({ revision: true }).parse(body),
|
|
77
|
+
revision: saved.revision,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Rows use globally monotonic revisions, including across discard/recreate. */
|
|
82
|
+
export class FileEdits {
|
|
83
|
+
constructor(readonly owner: WatchdogSqliteOwner) {
|
|
84
|
+
owner.sql.exec(`CREATE TABLE IF NOT EXISTS file_edits (
|
|
85
|
+
revision INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
86
|
+
repo TEXT NOT NULL, branch TEXT NOT NULL, path TEXT NOT NULL, lane TEXT NOT NULL,
|
|
87
|
+
body TEXT, UNIQUE(repo, branch, path, lane)) STRICT;`);
|
|
88
|
+
owner.sql.exec(
|
|
89
|
+
"CREATE INDEX IF NOT EXISTS file_edits_lane ON file_edits(lane)",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private row(lane: string, key: EditKey) {
|
|
94
|
+
const raw = this.owner.sql
|
|
95
|
+
.exec(
|
|
96
|
+
"SELECT revision, body FROM file_edits WHERE repo=? AND branch=? AND path=? AND lane=?",
|
|
97
|
+
key.repo,
|
|
98
|
+
key.branch,
|
|
99
|
+
key.path,
|
|
100
|
+
lane,
|
|
101
|
+
)
|
|
102
|
+
.toArray()[0];
|
|
103
|
+
return raw === undefined ? null : row.parse(raw);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A discarded path retains its private CAS baseline without text or state. */
|
|
107
|
+
revision(lane: string, key: EditKey): number | null {
|
|
108
|
+
return this.row(lane, key)?.revision ?? null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
read(lane: string, key: EditKey): StoredEdit | null {
|
|
112
|
+
const saved = this.row(lane, key);
|
|
113
|
+
return saved?.body == null ? null : decodeEdit(saved);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
save(
|
|
117
|
+
lane: string,
|
|
118
|
+
input: FileSaveInput,
|
|
119
|
+
enqueue: (edit: StoredEdit) => void,
|
|
120
|
+
origin?: BackgroundOrigin,
|
|
121
|
+
): FileSaveResult {
|
|
122
|
+
return this.owner.transactionSync(() => {
|
|
123
|
+
const { revision, ...inputBody } = input;
|
|
124
|
+
const previous = this.read(lane, input);
|
|
125
|
+
const baseline = this.revision(lane, input);
|
|
126
|
+
if (baseline !== revision)
|
|
127
|
+
return { kind: "conflict", revision: baseline ?? 0 };
|
|
128
|
+
if (previous?.state === "pending" && previous.publishing === true)
|
|
129
|
+
return { kind: "conflict", revision: previous.revision };
|
|
130
|
+
if (previous === null) {
|
|
131
|
+
const rows = countRow.parse(
|
|
132
|
+
this.owner.sql
|
|
133
|
+
.exec(
|
|
134
|
+
"SELECT count(*) AS count FROM file_edits WHERE lane=? AND body IS NOT NULL",
|
|
135
|
+
lane,
|
|
136
|
+
)
|
|
137
|
+
.toArray()[0],
|
|
138
|
+
);
|
|
139
|
+
if (rows.count >= savedFileRowsPerMember)
|
|
140
|
+
throw new AppMethodRefusal(
|
|
141
|
+
"invalid",
|
|
142
|
+
"Discard a saved file before adding another.",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
this.owner.sql.exec(
|
|
146
|
+
"DELETE FROM file_edits WHERE repo=? AND branch=? AND path=? AND lane=?",
|
|
147
|
+
input.repo,
|
|
148
|
+
input.branch,
|
|
149
|
+
input.path,
|
|
150
|
+
lane,
|
|
151
|
+
);
|
|
152
|
+
this.owner.sql.exec(
|
|
153
|
+
"INSERT INTO file_edits(repo,branch,path,lane,body) VALUES(?,?,?,?,?)",
|
|
154
|
+
input.repo,
|
|
155
|
+
input.branch,
|
|
156
|
+
input.path,
|
|
157
|
+
lane,
|
|
158
|
+
JSON.stringify({ ...inputBody, lane, state: "pending", origin }),
|
|
159
|
+
);
|
|
160
|
+
const edit = this.read(lane, input);
|
|
161
|
+
if (edit === null) throw new Error("Saved file edit is missing");
|
|
162
|
+
enqueue(edit);
|
|
163
|
+
return { kind: "saved", revision: edit.revision, state: "pending" };
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
pending(lane: string, key: EditKey) {
|
|
168
|
+
const edit = this.read(lane, key);
|
|
169
|
+
if (edit === null || edit.state === "synced") return undefined;
|
|
170
|
+
const pending: z.output<typeof pendingFileEdit> = {
|
|
171
|
+
text: edit.text,
|
|
172
|
+
revision: edit.revision,
|
|
173
|
+
state: edit.state,
|
|
174
|
+
};
|
|
175
|
+
if (edit.theirs !== undefined) pending.theirs = edit.theirs;
|
|
176
|
+
if (edit.failure !== undefined) pending.failure = edit.failure;
|
|
177
|
+
return pending;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Fence replacement once a built write may reach the provider. */
|
|
181
|
+
markPublishing(edit: StoredEdit): void {
|
|
182
|
+
const current = this.read(edit.lane, edit);
|
|
183
|
+
if (current?.revision !== edit.revision || current.state !== "pending")
|
|
184
|
+
return;
|
|
185
|
+
const { revision, ...body } = current;
|
|
186
|
+
this.owner.sql.exec(
|
|
187
|
+
"UPDATE file_edits SET body=? WHERE revision=?",
|
|
188
|
+
JSON.stringify({ ...body, publishing: true }),
|
|
189
|
+
revision,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
settle(edit: StoredEdit, result: FileSaveResult, retryable = true): void {
|
|
194
|
+
this.owner.transactionSync(() => {
|
|
195
|
+
if (this.read(edit.lane, edit)?.revision !== edit.revision) return;
|
|
196
|
+
const next =
|
|
197
|
+
result.kind === "synced"
|
|
198
|
+
? { ...edit, state: "synced", commit: result.commit }
|
|
199
|
+
: result.kind === "conflict"
|
|
200
|
+
? { ...edit, state: "conflict", theirs: result.theirs }
|
|
201
|
+
: result.kind === "failed"
|
|
202
|
+
? { ...edit, state: "failed", failure: result.reason }
|
|
203
|
+
: edit;
|
|
204
|
+
const { revision, ...body } = next;
|
|
205
|
+
delete body.publishing;
|
|
206
|
+
if (retryable) delete body.retryable;
|
|
207
|
+
else body.retryable = false;
|
|
208
|
+
this.owner.sql.exec(
|
|
209
|
+
"UPDATE file_edits SET body=? WHERE revision=?",
|
|
210
|
+
JSON.stringify(body),
|
|
211
|
+
revision,
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
retryFailed(
|
|
217
|
+
lane: string,
|
|
218
|
+
enqueue: WatchdogTransactionalProjection["enqueueAcceptedJob"],
|
|
219
|
+
): void {
|
|
220
|
+
this.owner.transactionSync(() => {
|
|
221
|
+
const edits = this.owner.sql
|
|
222
|
+
.exec(
|
|
223
|
+
"SELECT revision,body FROM file_edits WHERE lane=? AND json_extract(body,'$.state')='failed' AND json_extract(body,'$.failure')!='access_changed' AND COALESCE(json_extract(body,'$.retryable'),1)!=0 ORDER BY COALESCE(json_extract(body,'$.retryOrder'),0),revision LIMIT 2",
|
|
224
|
+
lane,
|
|
225
|
+
)
|
|
226
|
+
.toArray()
|
|
227
|
+
.map(decodeEdit);
|
|
228
|
+
if (edits.length === 0) return;
|
|
229
|
+
const order = retryOrderRow.parse(
|
|
230
|
+
this.owner.sql
|
|
231
|
+
.exec(
|
|
232
|
+
"SELECT COALESCE(MAX(json_extract(body,'$.retryOrder')),0)+1 AS next FROM file_edits WHERE lane=?",
|
|
233
|
+
lane,
|
|
234
|
+
)
|
|
235
|
+
.toArray()[0],
|
|
236
|
+
);
|
|
237
|
+
for (const edit of edits) {
|
|
238
|
+
const { revision, ...body } = edit;
|
|
239
|
+
this.owner.sql.exec(
|
|
240
|
+
"UPDATE file_edits SET body=? WHERE revision=?",
|
|
241
|
+
JSON.stringify({
|
|
242
|
+
...body,
|
|
243
|
+
failure: undefined,
|
|
244
|
+
state: "pending",
|
|
245
|
+
retryOrder: order.next,
|
|
246
|
+
}),
|
|
247
|
+
revision,
|
|
248
|
+
);
|
|
249
|
+
enqueue(fileJob(payloadOf(edit)));
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
hasPending(lane: string): boolean {
|
|
255
|
+
return (
|
|
256
|
+
this.owner.sql
|
|
257
|
+
.exec(
|
|
258
|
+
"SELECT 1 FROM file_edits WHERE lane=? AND json_extract(body,'$.state')='pending' LIMIT 1",
|
|
259
|
+
lane,
|
|
260
|
+
)
|
|
261
|
+
.toArray().length !== 0
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
settled(lane: string): z.output<typeof fileSyncResult>["settled"] {
|
|
266
|
+
return this.owner.sql
|
|
267
|
+
.exec(
|
|
268
|
+
"SELECT revision,body FROM file_edits WHERE lane=? AND json_extract(body,'$.state')!='pending' ORDER BY revision LIMIT ?",
|
|
269
|
+
lane,
|
|
270
|
+
savedFileRowsPerMember,
|
|
271
|
+
)
|
|
272
|
+
.toArray()
|
|
273
|
+
.map(decodeEdit)
|
|
274
|
+
.map((edit) => ({
|
|
275
|
+
repo: edit.repo,
|
|
276
|
+
branch: edit.branch,
|
|
277
|
+
path: edit.path,
|
|
278
|
+
result: this.result(edit),
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
result(edit: StoredEdit): FileSaveResult {
|
|
283
|
+
switch (edit.state) {
|
|
284
|
+
case "pending":
|
|
285
|
+
return { kind: "saved", revision: edit.revision, state: "pending" };
|
|
286
|
+
case "synced":
|
|
287
|
+
if (edit.commit === undefined)
|
|
288
|
+
throw new Error("Synced file edit has no commit");
|
|
289
|
+
return { kind: "synced", revision: edit.revision, commit: edit.commit };
|
|
290
|
+
case "failed":
|
|
291
|
+
if (edit.failure === undefined)
|
|
292
|
+
throw new Error("Failed file edit has no reason");
|
|
293
|
+
return {
|
|
294
|
+
kind: "failed",
|
|
295
|
+
revision: edit.revision,
|
|
296
|
+
reason: edit.failure,
|
|
297
|
+
};
|
|
298
|
+
case "conflict": {
|
|
299
|
+
const result: FileSaveResult = {
|
|
300
|
+
kind: "conflict",
|
|
301
|
+
revision: edit.revision,
|
|
302
|
+
};
|
|
303
|
+
if (edit.theirs !== undefined) result.theirs = edit.theirs;
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
discard(lane: string, key: EditKey & { revision: number }) {
|
|
310
|
+
return this.owner.transactionSync(() => {
|
|
311
|
+
const edit = this.read(lane, key);
|
|
312
|
+
if (edit === null) return { kind: "not_found" as const };
|
|
313
|
+
if (
|
|
314
|
+
edit.revision !== key.revision ||
|
|
315
|
+
(edit.state === "pending" && edit.publishing === true)
|
|
316
|
+
)
|
|
317
|
+
return { kind: "conflict" as const, revision: edit.revision };
|
|
318
|
+
this.owner.sql.exec(
|
|
319
|
+
"UPDATE file_edits SET body=NULL WHERE revision=?",
|
|
320
|
+
edit.revision,
|
|
321
|
+
);
|
|
322
|
+
return { kind: "discarded" as const };
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Only an ordinary member may read-modify-write their private saved-file lane. */
|
|
328
|
+
export function assertOrdinaryFileMember(caller: AppMethodCallContext): void {
|
|
329
|
+
if (caller.actor.kind !== "member" || caller.workflow !== undefined)
|
|
330
|
+
throw new AppMethodRefusal(
|
|
331
|
+
"forbidden",
|
|
332
|
+
"Saved file edits are for ordinary members only",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function actor(caller: AppMethodCallContext) {
|
|
337
|
+
return caller.actor.kind === "member"
|
|
338
|
+
? { name: caller.actor.memberId, kind: "human" as const }
|
|
339
|
+
: {
|
|
340
|
+
name:
|
|
341
|
+
caller.actor.kind === "agent"
|
|
342
|
+
? caller.actor.agentId
|
|
343
|
+
: caller.actor.keyId,
|
|
344
|
+
kind: "agent" as const,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** A saved file is UTF-8 text, never a binary payload disguised as text. */
|
|
349
|
+
function admitFileEdit(
|
|
350
|
+
bonsai: Bonsai,
|
|
351
|
+
caller: AppMethodCallContext,
|
|
352
|
+
input: FileSaveInput,
|
|
353
|
+
): boolean {
|
|
354
|
+
if (!mayPush(actor(caller), bonsai.state.repo(input.repo))) return false;
|
|
355
|
+
const bytes = new TextEncoder().encode(input.text);
|
|
356
|
+
if (bytes.length > repositoryFileByteLimit || isBinary(bytes))
|
|
357
|
+
throw new AppMethodRefusal(
|
|
358
|
+
"invalid",
|
|
359
|
+
"Save a text file within the repository file byte limit.",
|
|
360
|
+
);
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const fileProbe = z.discriminatedUnion("phase", [
|
|
365
|
+
z.strictObject({
|
|
366
|
+
phase: z.literal("commit"),
|
|
367
|
+
head: fileSaveInput.shape.baseCommit,
|
|
368
|
+
}),
|
|
369
|
+
z.strictObject({
|
|
370
|
+
phase: z.literal("tree"),
|
|
371
|
+
head: fileSaveInput.shape.baseCommit,
|
|
372
|
+
tree: fileSaveInput.shape.baseCommit,
|
|
373
|
+
index: z.number().int().nonnegative(),
|
|
374
|
+
}),
|
|
375
|
+
z.strictObject({
|
|
376
|
+
phase: z.literal("blob"),
|
|
377
|
+
head: fileSaveInput.shape.baseCommit,
|
|
378
|
+
blob: fileSaveInput.shape.baseCommit,
|
|
379
|
+
}),
|
|
380
|
+
]);
|
|
381
|
+
|
|
382
|
+
/** Opaque cargo for the App's one shared actor-lane Watchdog. */
|
|
383
|
+
export const fileEditJobSchema = fileSaveInput
|
|
384
|
+
.pick({ repo: true, branch: true, path: true })
|
|
385
|
+
.extend({
|
|
386
|
+
kind: z.literal("file-edit"),
|
|
387
|
+
revision: z.number().int().positive(),
|
|
388
|
+
lane: z.string(),
|
|
389
|
+
origin: backgroundOriginSchema,
|
|
390
|
+
operationKey: z.uuid({ version: "v7" }),
|
|
391
|
+
timestamp: z.number().int().nonnegative(),
|
|
392
|
+
probe: fileProbe.optional(),
|
|
393
|
+
stage: z.number().int().nonnegative().default(0),
|
|
394
|
+
failures: z.number().int().min(0).max(2).default(0),
|
|
395
|
+
step: gitStepSchema.optional(),
|
|
396
|
+
});
|
|
397
|
+
const fileStageReceipt = z.object({
|
|
398
|
+
result: fileSaveResult.optional(),
|
|
399
|
+
next: fileEditJobSchema.optional(),
|
|
400
|
+
cleanup: gitStepSchema.optional(),
|
|
401
|
+
retryable: z.literal(false).optional(),
|
|
402
|
+
});
|
|
403
|
+
const fileStageKey = (jobId: string) => `file-edit-step:${jobId}`;
|
|
404
|
+
const fileJob = (
|
|
405
|
+
edit: z.output<typeof fileEditJobSchema>,
|
|
406
|
+
jobId = `file-edit:${edit.operationKey}:${String(edit.stage)}`,
|
|
407
|
+
): WatchdogQueuedJob => ({
|
|
408
|
+
state: "queued",
|
|
409
|
+
jobId,
|
|
410
|
+
queue: `file-edit:${String(edit.revision)}`,
|
|
411
|
+
lane: edit.lane,
|
|
412
|
+
priority: 0,
|
|
413
|
+
// One fresh frame may recover a vanished claim; exhaustion preserves text as failed.
|
|
414
|
+
recovery: { maxRecoveries: 1 },
|
|
415
|
+
payload: edit,
|
|
416
|
+
});
|
|
417
|
+
const payloadOf = (edit: StoredEdit) =>
|
|
418
|
+
fileEditJobSchema.parse({
|
|
419
|
+
kind: "file-edit",
|
|
420
|
+
repo: edit.repo,
|
|
421
|
+
branch: edit.branch,
|
|
422
|
+
path: edit.path,
|
|
423
|
+
revision: edit.revision,
|
|
424
|
+
lane: edit.lane,
|
|
425
|
+
origin: edit.origin,
|
|
426
|
+
operationKey: replayKey(),
|
|
427
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
function commitFileStage(
|
|
431
|
+
bonsai: Bonsai,
|
|
432
|
+
payload: z.output<typeof fileEditJobSchema>,
|
|
433
|
+
job: WatchdogRunningJob,
|
|
434
|
+
enqueue: WatchdogTransactionalProjection["enqueueAcceptedJob"],
|
|
435
|
+
receipt: z.output<typeof fileStageReceipt>,
|
|
436
|
+
) {
|
|
437
|
+
return bonsai.state.transition(
|
|
438
|
+
fileStageKey(job.jobId),
|
|
439
|
+
JSON.stringify(payload),
|
|
440
|
+
fileStageReceipt,
|
|
441
|
+
() => {
|
|
442
|
+
const current = bonsai.state.fileEdits.read(payload.lane, payload);
|
|
443
|
+
if (current?.revision !== payload.revision || current.state !== "pending")
|
|
444
|
+
return {
|
|
445
|
+
result:
|
|
446
|
+
receipt.next?.step !== undefined
|
|
447
|
+
? { cleanup: receipt.next.step }
|
|
448
|
+
: payload.step === undefined
|
|
449
|
+
? {}
|
|
450
|
+
: { cleanup: payload.step },
|
|
451
|
+
events: [],
|
|
452
|
+
};
|
|
453
|
+
if (receipt.next !== undefined) {
|
|
454
|
+
if (receipt.next.step !== undefined && "built" in receipt.next.step)
|
|
455
|
+
bonsai.state.fileEdits.markPublishing(current);
|
|
456
|
+
enqueue(fileJob(receipt.next));
|
|
457
|
+
}
|
|
458
|
+
return { result: receipt, events: [] };
|
|
459
|
+
},
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
function retryFileStage(
|
|
463
|
+
bonsai: Bonsai,
|
|
464
|
+
payload: z.output<typeof fileEditJobSchema>,
|
|
465
|
+
job: WatchdogRunningJob,
|
|
466
|
+
enqueue: WatchdogTransactionalProjection["enqueueAcceptedJob"],
|
|
467
|
+
): WatchdogExecutionDecision {
|
|
468
|
+
if (payload.failures === 2) return { kind: "failed", reason: "sync_failed" };
|
|
469
|
+
const recorded = commitFileStage(bonsai, payload, job, enqueue, {
|
|
470
|
+
next: {
|
|
471
|
+
...payload,
|
|
472
|
+
stage: payload.stage + 1,
|
|
473
|
+
failures: payload.failures + 1,
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
return recorded.next === undefined
|
|
477
|
+
? { kind: "completed" }
|
|
478
|
+
: { kind: "failed", reason: "sync_retry" };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export async function saveFileEdit(
|
|
482
|
+
bonsai: Bonsai,
|
|
483
|
+
caller: AppMethodCallContext,
|
|
484
|
+
input: FileSaveInput,
|
|
485
|
+
runtime: ConcurrentWatchdogRuntime | null,
|
|
486
|
+
): Promise<FileSaveResult> {
|
|
487
|
+
assertOrdinaryFileMember(caller);
|
|
488
|
+
if (!admitFileEdit(bonsai, caller, input))
|
|
489
|
+
return { kind: "refused", reason: "not-source-writer" };
|
|
490
|
+
if (!mayEnqueueBackground(caller) || runtime === null)
|
|
491
|
+
return { kind: "refused", reason: "background-unavailable" };
|
|
492
|
+
const lane = actorLane(caller.actor);
|
|
493
|
+
const save = () => ({
|
|
494
|
+
result: bonsai.state.fileEdits.save(
|
|
495
|
+
lane,
|
|
496
|
+
input,
|
|
497
|
+
(edit) => {
|
|
498
|
+
runtime.transactional.enqueueAcceptedJob(fileJob(payloadOf(edit)));
|
|
499
|
+
},
|
|
500
|
+
caller.backgroundOrigin,
|
|
501
|
+
),
|
|
502
|
+
events: [],
|
|
503
|
+
});
|
|
504
|
+
if (caller.idempotencyKey === undefined) return save().result;
|
|
505
|
+
return bonsai.state.transition(
|
|
506
|
+
`file-edit:${lane}:${caller.idempotencyKey}`,
|
|
507
|
+
await deterministicReplayKey("bonsai:file-edit-receipt:v1", [
|
|
508
|
+
lane,
|
|
509
|
+
JSON.stringify(input),
|
|
510
|
+
]),
|
|
511
|
+
// Domain receipt and enqueue share the same transaction.
|
|
512
|
+
fileSaveResult,
|
|
513
|
+
save,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function publishFileHead(
|
|
518
|
+
bonsai: Bonsai,
|
|
519
|
+
edit: StoredEdit,
|
|
520
|
+
commit: string,
|
|
521
|
+
): void {
|
|
522
|
+
invalidateFilesCopies(bonsai, edit.repo);
|
|
523
|
+
const saved = bonsai.state.repositoryCopies.read(edit.repo);
|
|
524
|
+
if (saved.kind !== "ready") return;
|
|
525
|
+
const name = `refs/heads/${edit.branch}`;
|
|
526
|
+
bonsai.state.repositoryCopies.write(
|
|
527
|
+
edit.repo,
|
|
528
|
+
saved.value.defaultBranch === edit.branch ? commit : saved.head,
|
|
529
|
+
{
|
|
530
|
+
...saved.value,
|
|
531
|
+
empty: false,
|
|
532
|
+
refs: [
|
|
533
|
+
...saved.value.refs.filter((ref) => ref.name !== name),
|
|
534
|
+
{ name, hash: commit },
|
|
535
|
+
],
|
|
536
|
+
},
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** The shared runner supplies its enqueue port; this module never constructs Watchdog. */
|
|
541
|
+
export async function executeQueuedFileEdit(
|
|
542
|
+
bonsai: Bonsai,
|
|
543
|
+
caller: AppMethodCallContext,
|
|
544
|
+
job: WatchdogRunningJob,
|
|
545
|
+
enqueue: WatchdogTransactionalProjection["enqueueAcceptedJob"],
|
|
546
|
+
background: BackgroundBinding | undefined,
|
|
547
|
+
signal: AbortSignal,
|
|
548
|
+
): Promise<WatchdogExecutionDecision> {
|
|
549
|
+
const decoded = fileEditJobSchema.safeParse(job.payload);
|
|
550
|
+
if (
|
|
551
|
+
!decoded.success ||
|
|
552
|
+
caller.actor.kind !== "member" ||
|
|
553
|
+
caller.workflow !== undefined
|
|
554
|
+
)
|
|
555
|
+
return { kind: "failed", reason: "access_changed" };
|
|
556
|
+
const payload = decoded.data;
|
|
557
|
+
if (payload.lane !== actorLane(caller.actor) || job.lane !== payload.lane)
|
|
558
|
+
return { kind: "failed", reason: "access_changed" };
|
|
559
|
+
if (background === undefined || signal.aborted)
|
|
560
|
+
return { kind: "failed", reason: "access_changed" };
|
|
561
|
+
let admission;
|
|
562
|
+
try {
|
|
563
|
+
admission = await background.admit(payload.origin);
|
|
564
|
+
signal.throwIfAborted();
|
|
565
|
+
} catch (error) {
|
|
566
|
+
signal.throwIfAborted();
|
|
567
|
+
if (error instanceof AppError && !error.retryable)
|
|
568
|
+
return { kind: "failed", reason: "access_changed" };
|
|
569
|
+
return retryFileStage(bonsai, payload, job, enqueue);
|
|
570
|
+
}
|
|
571
|
+
if (admission.kind !== "admitted")
|
|
572
|
+
return { kind: "failed", reason: "access_changed" };
|
|
573
|
+
const edit = bonsai.state.fileEdits.read(payload.lane, payload);
|
|
574
|
+
if (edit?.revision !== payload.revision || edit.state !== "pending")
|
|
575
|
+
return { kind: "completed" };
|
|
576
|
+
if (!mayPush(actor(caller), bonsai.state.repo(edit.repo)))
|
|
577
|
+
return { kind: "failed", reason: "access_changed" };
|
|
578
|
+
const digest = JSON.stringify(payload);
|
|
579
|
+
if (
|
|
580
|
+
bonsai.state.receipt(fileStageKey(job.jobId), digest, fileStageReceipt) !==
|
|
581
|
+
null
|
|
582
|
+
)
|
|
583
|
+
return { kind: "completed" };
|
|
584
|
+
const material = sqliteGitStepMaterial(bonsai.state.backgroundOwner.sql);
|
|
585
|
+
const content = new TextEncoder().encode(edit.text);
|
|
586
|
+
let receipt: z.output<typeof fileStageReceipt>;
|
|
587
|
+
try {
|
|
588
|
+
signal.throwIfAborted();
|
|
589
|
+
if (payload.step === undefined) {
|
|
590
|
+
const nextProbe = (probe: z.output<typeof fileProbe>) => ({
|
|
591
|
+
next: { ...payload, stage: payload.stage + 1, failures: 0, probe },
|
|
592
|
+
});
|
|
593
|
+
const finishProbe = async (
|
|
594
|
+
head: string,
|
|
595
|
+
blob: string | null,
|
|
596
|
+
bytes: Uint8Array | null,
|
|
597
|
+
): Promise<z.output<typeof fileStageReceipt>> => {
|
|
598
|
+
signal.throwIfAborted();
|
|
599
|
+
const retained = bonsai.state.fileEdits.read(edit.lane, edit);
|
|
600
|
+
if (
|
|
601
|
+
retained?.revision !== edit.revision ||
|
|
602
|
+
retained.state !== "pending"
|
|
603
|
+
)
|
|
604
|
+
return {};
|
|
605
|
+
if (
|
|
606
|
+
blob !== edit.baseBlob &&
|
|
607
|
+
!(
|
|
608
|
+
bytes !== null &&
|
|
609
|
+
bytes.length === content.length &&
|
|
610
|
+
bytes.every((byte, index) => byte === content[index])
|
|
611
|
+
)
|
|
612
|
+
)
|
|
613
|
+
return {
|
|
614
|
+
result: {
|
|
615
|
+
kind: "conflict",
|
|
616
|
+
revision: edit.revision,
|
|
617
|
+
theirs: { blob, commit: head },
|
|
618
|
+
},
|
|
619
|
+
};
|
|
620
|
+
const author = actor(caller);
|
|
621
|
+
const step = await begin(
|
|
622
|
+
{
|
|
623
|
+
kind: "commit",
|
|
624
|
+
requestKey: payload.operationKey,
|
|
625
|
+
timestamp: payload.timestamp,
|
|
626
|
+
request: {
|
|
627
|
+
repository: edit.repo,
|
|
628
|
+
branch: edit.branch,
|
|
629
|
+
expectedHead: head,
|
|
630
|
+
changes: [{ path: edit.path, content }],
|
|
631
|
+
message: `Edit ${edit.path}`,
|
|
632
|
+
author: {
|
|
633
|
+
name: author.name,
|
|
634
|
+
email: `${author.name}@bonsai.invalid`,
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
material,
|
|
639
|
+
);
|
|
640
|
+
return {
|
|
641
|
+
next: { ...payload, stage: payload.stage + 1, failures: 0, step },
|
|
642
|
+
};
|
|
643
|
+
};
|
|
644
|
+
const probe = payload.probe;
|
|
645
|
+
if (probe === undefined) {
|
|
646
|
+
const head = (await bonsai.store.refs(edit.repo)).find(
|
|
647
|
+
(ref) => ref.name === `refs/heads/${edit.branch}`,
|
|
648
|
+
)?.hash;
|
|
649
|
+
receipt =
|
|
650
|
+
head === undefined
|
|
651
|
+
? {
|
|
652
|
+
result: {
|
|
653
|
+
kind: "failed",
|
|
654
|
+
revision: edit.revision,
|
|
655
|
+
reason: "branch_missing",
|
|
656
|
+
},
|
|
657
|
+
}
|
|
658
|
+
: nextProbe({ phase: "commit", head });
|
|
659
|
+
} else if (probe.phase === "commit") {
|
|
660
|
+
const commit = await bonsai.store.commit(edit.repo, probe.head);
|
|
661
|
+
if (commit === null)
|
|
662
|
+
throw new AppMethodRefusal("not_found", "Branch commit is missing");
|
|
663
|
+
receipt = nextProbe({
|
|
664
|
+
phase: "tree",
|
|
665
|
+
head: probe.head,
|
|
666
|
+
tree: commit.treeHash,
|
|
667
|
+
index: 0,
|
|
668
|
+
});
|
|
669
|
+
} else if (probe.phase === "tree") {
|
|
670
|
+
const parts = edit.path.split("/");
|
|
671
|
+
const part = parts.at(probe.index);
|
|
672
|
+
if (part === undefined)
|
|
673
|
+
throw new AppMethodRefusal("invalid", "Invalid file frontier");
|
|
674
|
+
const entries = await bonsai.store.tree(edit.repo, probe.tree);
|
|
675
|
+
if (entries === null)
|
|
676
|
+
throw new AppMethodRefusal(
|
|
677
|
+
"not_found",
|
|
678
|
+
"Directory object is missing",
|
|
679
|
+
);
|
|
680
|
+
const entry = entries.find((item) => item.name === part);
|
|
681
|
+
if (entry === undefined)
|
|
682
|
+
receipt = await finishProbe(probe.head, null, null);
|
|
683
|
+
else if (probe.index === parts.length - 1) {
|
|
684
|
+
if (entry.type !== "blob")
|
|
685
|
+
throw new AppMethodRefusal("not_found", "Not a repository file");
|
|
686
|
+
receipt = nextProbe({
|
|
687
|
+
phase: "blob",
|
|
688
|
+
head: probe.head,
|
|
689
|
+
blob: entry.hash,
|
|
690
|
+
});
|
|
691
|
+
} else {
|
|
692
|
+
if (entry.type !== "tree")
|
|
693
|
+
throw new AppMethodRefusal(
|
|
694
|
+
"not_found",
|
|
695
|
+
"Not a repository directory",
|
|
696
|
+
);
|
|
697
|
+
receipt = nextProbe({
|
|
698
|
+
phase: "tree",
|
|
699
|
+
head: probe.head,
|
|
700
|
+
tree: entry.hash,
|
|
701
|
+
index: probe.index + 1,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
} else {
|
|
705
|
+
const bytes = await bonsai.store.blob(edit.repo, probe.blob);
|
|
706
|
+
if (bytes === null)
|
|
707
|
+
throw new AppMethodRefusal("not_found", "File object is missing");
|
|
708
|
+
receipt = await finishProbe(probe.head, probe.blob, bytes);
|
|
709
|
+
}
|
|
710
|
+
} else {
|
|
711
|
+
const outcome = await runStep(
|
|
712
|
+
payload.step,
|
|
713
|
+
{ git: bonsai.store.binding, material },
|
|
714
|
+
signal,
|
|
715
|
+
);
|
|
716
|
+
if ("next" in outcome)
|
|
717
|
+
receipt = {
|
|
718
|
+
next: {
|
|
719
|
+
...payload,
|
|
720
|
+
stage: payload.stage + 1,
|
|
721
|
+
failures: 0,
|
|
722
|
+
step: outcome.next,
|
|
723
|
+
},
|
|
724
|
+
};
|
|
725
|
+
else if ("yield" in outcome)
|
|
726
|
+
receipt = { next: { ...payload, stage: payload.stage + 1 } };
|
|
727
|
+
else {
|
|
728
|
+
const done = outcome.done;
|
|
729
|
+
receipt = {
|
|
730
|
+
cleanup: payload.step,
|
|
731
|
+
result:
|
|
732
|
+
done.kind === "committed" || done.kind === "unchanged"
|
|
733
|
+
? { kind: "synced", revision: edit.revision, commit: done.commit }
|
|
734
|
+
: done.kind === "stale"
|
|
735
|
+
? { kind: "conflict", revision: edit.revision }
|
|
736
|
+
: {
|
|
737
|
+
kind: "failed",
|
|
738
|
+
revision: edit.revision,
|
|
739
|
+
reason: "sync_failed",
|
|
740
|
+
},
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
signal.throwIfAborted();
|
|
745
|
+
} catch (error) {
|
|
746
|
+
signal.throwIfAborted();
|
|
747
|
+
if (error instanceof GitStepMaterialError) {
|
|
748
|
+
const terminal: z.output<typeof fileStageReceipt> = {
|
|
749
|
+
result: {
|
|
750
|
+
kind: "failed",
|
|
751
|
+
revision: edit.revision,
|
|
752
|
+
reason: "permanent_material",
|
|
753
|
+
},
|
|
754
|
+
retryable: false,
|
|
755
|
+
};
|
|
756
|
+
if (payload.step !== undefined) terminal.cleanup = payload.step;
|
|
757
|
+
commitFileStage(bonsai, payload, job, enqueue, terminal);
|
|
758
|
+
return { kind: "failed", reason: "sync_failed" };
|
|
759
|
+
}
|
|
760
|
+
if (
|
|
761
|
+
error instanceof AppMethodRefusal ||
|
|
762
|
+
(error instanceof AppError && !error.retryable)
|
|
763
|
+
)
|
|
764
|
+
return { kind: "failed", reason: "sync_failed" };
|
|
765
|
+
return retryFileStage(bonsai, payload, job, enqueue);
|
|
766
|
+
}
|
|
767
|
+
commitFileStage(bonsai, payload, job, enqueue, receipt);
|
|
768
|
+
return { kind: "completed" };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Foreground sync requests retry rows; the declared replay continuation owns claims. */
|
|
772
|
+
export function syncFileEdits(
|
|
773
|
+
bonsai: Bonsai,
|
|
774
|
+
caller: AppMethodCallContext,
|
|
775
|
+
runtime: ConcurrentWatchdogRuntime,
|
|
776
|
+
): Promise<z.output<typeof fileSyncResult>> {
|
|
777
|
+
assertOrdinaryFileMember(caller);
|
|
778
|
+
const lane = actorLane(caller.actor);
|
|
779
|
+
if (mayEnqueueBackground(caller))
|
|
780
|
+
bonsai.state.fileEdits.retryFailed(
|
|
781
|
+
lane,
|
|
782
|
+
runtime.transactional.enqueueAcceptedJob,
|
|
783
|
+
);
|
|
784
|
+
return Promise.resolve({
|
|
785
|
+
settled: bonsai.state.fileEdits.settled(lane),
|
|
786
|
+
more: bonsai.state.fileEdits.hasPending(lane),
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** Watchdog's terminal recovery/failure projection preserves the member's text. */
|
|
791
|
+
export function projectQueuedFileEdit(
|
|
792
|
+
bonsai: Bonsai,
|
|
793
|
+
transition: WatchdogQueueTransition,
|
|
794
|
+
): void {
|
|
795
|
+
const settled = transition.settled;
|
|
796
|
+
if (settled === undefined) return;
|
|
797
|
+
const payload = fileEditJobSchema.safeParse(settled.payload);
|
|
798
|
+
if (!payload.success) return;
|
|
799
|
+
const edit = bonsai.state.fileEdits.read(payload.data.lane, payload.data);
|
|
800
|
+
if (edit?.revision !== payload.data.revision || edit.state !== "pending")
|
|
801
|
+
return;
|
|
802
|
+
const recorded = bonsai.state.receipt(
|
|
803
|
+
fileStageKey(settled.jobId),
|
|
804
|
+
JSON.stringify(payload.data),
|
|
805
|
+
fileStageReceipt,
|
|
806
|
+
);
|
|
807
|
+
if (recorded?.result.result !== undefined) {
|
|
808
|
+
bonsai.state.fileEdits.settle(
|
|
809
|
+
edit,
|
|
810
|
+
recorded.result.result,
|
|
811
|
+
recorded.result.retryable !== false,
|
|
812
|
+
);
|
|
813
|
+
if (recorded.result.result.kind === "synced")
|
|
814
|
+
publishFileHead(bonsai, edit, recorded.result.result.commit);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (recorded?.result.next !== undefined || settled.outcome === "completed")
|
|
818
|
+
return;
|
|
819
|
+
bonsai.state.fileEdits.settle(edit, {
|
|
820
|
+
kind: "failed",
|
|
821
|
+
revision: edit.revision,
|
|
822
|
+
reason:
|
|
823
|
+
settled.failureReason === "access_changed"
|
|
824
|
+
? "access_changed"
|
|
825
|
+
: "sync_failed",
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** Dispose Git material only after Watchdog has durably settled a terminal file stage. */
|
|
830
|
+
export async function cleanupSettledFileEdit(
|
|
831
|
+
bonsai: Bonsai,
|
|
832
|
+
job: WatchdogSettledJob,
|
|
833
|
+
): Promise<void> {
|
|
834
|
+
const parsed = fileEditJobSchema.safeParse(job.payload);
|
|
835
|
+
if (!parsed.success) return;
|
|
836
|
+
const payload = parsed.data;
|
|
837
|
+
const digest = JSON.stringify(payload);
|
|
838
|
+
const receipt = bonsai.state.receipt(
|
|
839
|
+
fileStageKey(job.jobId),
|
|
840
|
+
digest,
|
|
841
|
+
fileStageReceipt,
|
|
842
|
+
);
|
|
843
|
+
if (receipt?.result.next !== undefined) return;
|
|
844
|
+
const step = receipt?.result.cleanup ?? payload.step;
|
|
845
|
+
if (step !== undefined)
|
|
846
|
+
await dropMaterial(
|
|
847
|
+
step,
|
|
848
|
+
sqliteGitStepMaterial(bonsai.state.backgroundOwner.sql),
|
|
849
|
+
);
|
|
850
|
+
}
|