@agentshouse/kit 0.1.0-alpha.13 → 0.1.0-alpha.14
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 +47 -2
- package/bootstrap.json +3 -3
- package/package.json +1 -1
- package/src/contract.js +9 -0
- package/src/git.js +62 -0
- package/src/kit-work-stream.js +6 -3
- package/src/launcher.js +7 -0
- package/src/repository.js +3 -61
- package/src/sync.js +3 -3
- package/src/worker.js +70 -24
- package/src/working-copy.js +389 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ On Linux `amd64` or `arm64` with a working Docker Engine, run the published,
|
|
|
10
10
|
versioned bootstrap once:
|
|
11
11
|
|
|
12
12
|
```sh
|
|
13
|
-
bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.
|
|
13
|
+
bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.14/connect-linux.sh)
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
The release asset pins one immutable multi-architecture image digest. It creates
|
|
@@ -1217,9 +1217,52 @@ interrupted run is never resumed: the next run opens a new manifest and House
|
|
|
1217
1217
|
answers with what is still needed. The command reports the Publication
|
|
1218
1218
|
reference, the paths delivered, the items refused and the ending.
|
|
1219
1219
|
|
|
1220
|
+
## Working copies
|
|
1221
|
+
|
|
1222
|
+
The Environment owner chooses in House which Rooms this Environment keeps
|
|
1223
|
+
locally, and whether it keeps `/private`. An Environment whose owner never chose
|
|
1224
|
+
copies nothing, and a Room joined later stays uncopied until the owner selects
|
|
1225
|
+
it. The resident Kit reads the saved choice through
|
|
1226
|
+
`POST /kit/working-copy/selection` when it starts, whenever its control stream
|
|
1227
|
+
connects or reconnects, and whenever House points it there after a save;
|
|
1228
|
+
`kit worker run` reads it once before its pass.
|
|
1229
|
+
|
|
1230
|
+
Each selected Room becomes one ordinary Git repository at
|
|
1231
|
+
`/agents/house/working-copies/rooms/<room_ref>`, and `/private` one at
|
|
1232
|
+
`/agents/house/working-copies/private`. The Room reference names the location,
|
|
1233
|
+
so a Room handle change moves nothing. Every Agent launched afterwards receives
|
|
1234
|
+
the House path and local repository of each active copy in its context, one
|
|
1235
|
+
line each, such as `/rooms/work: /agents/house/working-copies/rooms/r_1x.q`.
|
|
1236
|
+
|
|
1237
|
+
A Room repository holds the Room's complete readable content: `ROOM.md`,
|
|
1238
|
+
`library/`, `capture/` with its provenance sidecars, and `collaboration/`. The
|
|
1239
|
+
`/private` repository holds its Library, collaboration, each Room's private
|
|
1240
|
+
`AGENTS.md`, the `agents/` cards and `house/`. Attached settings and attachment
|
|
1241
|
+
bytes are not copied. The branch `main` starts at the commit House served, which
|
|
1242
|
+
tracks exactly the files House declares writable under the Environment's
|
|
1243
|
+
current grant. Every other file is read-only House content: the generated
|
|
1244
|
+
`.gitignore` names and explains it, so Git never tracks it, and the matching
|
|
1245
|
+
`.ignore` keeps it visible to ripgrep and other native search. Neither file
|
|
1246
|
+
grants anything, and House refuses a change to a read-only path however it is
|
|
1247
|
+
committed.
|
|
1248
|
+
|
|
1249
|
+
Local edits, staging and commits change nothing in House. `refs/house/received`
|
|
1250
|
+
records the House content last received. The per-file manifest, with each
|
|
1251
|
+
file's House revision, digest and writability, and the commit it corresponds to
|
|
1252
|
+
stay in Kit home under `state/working-copies/`, outside the repository. A local
|
|
1253
|
+
commit is a local tree, never a House revision.
|
|
1254
|
+
|
|
1255
|
+
Deselecting a Room, or losing read access to it, marks its copy inactive:
|
|
1256
|
+
launches omit it and nothing local is deleted. Selecting it again reads the Room
|
|
1257
|
+
again, advances `refs/house/received`, and replaces only the read-only content
|
|
1258
|
+
Kit wrote, never the branch, the index or a tracked file. A Room the owner may
|
|
1259
|
+
read without sync is refused as `operation_denied` and stays uncopied, while its
|
|
1260
|
+
ordinary remote reads are unaffected. Kit names each refused copy on its
|
|
1261
|
+
standard error.
|
|
1262
|
+
|
|
1220
1263
|
## Explicit synchronization
|
|
1221
1264
|
|
|
1222
|
-
Beside
|
|
1265
|
+
Beside [working copies](#working-copies), Kit has one explicit synchronization mode.
|
|
1223
1266
|
It selects prefixes over the User root, materializes them into a named local
|
|
1224
1267
|
directory, enumerates whole content and removals after the recorded positions,
|
|
1225
1268
|
and submits a resolved exact-base change set the caller prepared. Kit does no
|
|
@@ -1502,6 +1545,8 @@ $HOUSE_KIT_HOME/
|
|
|
1502
1545
|
state/repositories/<repository identity>.json
|
|
1503
1546
|
state/sync/<replica identity>.json
|
|
1504
1547
|
state/sync/<replica identity>.pending.json
|
|
1548
|
+
state/working-copies/rooms/<room ref>.json
|
|
1549
|
+
state/working-copies/private.json
|
|
1505
1550
|
state/attachments/<upload identity>.json
|
|
1506
1551
|
state/conversation-originals/<attachment identity>.json
|
|
1507
1552
|
state/sessions/<tmux session>.json
|
package/bootstrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.14",
|
|
4
4
|
"hosts": {
|
|
5
5
|
"linux": {
|
|
6
|
-
"command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.
|
|
6
|
+
"command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.14/connect-linux.sh)",
|
|
7
7
|
"asset": {
|
|
8
8
|
"name": "connect-linux.sh",
|
|
9
|
-
"url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.
|
|
9
|
+
"url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.14/connect-linux.sh"
|
|
10
10
|
},
|
|
11
11
|
"workspace": {
|
|
12
12
|
"argument": "--workspace",
|
package/package.json
CHANGED
package/src/contract.js
CHANGED
|
@@ -291,6 +291,15 @@ export const KIT_DOOR_BATCH_PATH = "/kit/door/batch";
|
|
|
291
291
|
export const EXPLICIT_SYNC_VERSION = "explicit-sync/2";
|
|
292
292
|
export const KIT_DOOR_UNMOUNT_PATH = "/kit/door/unmount";
|
|
293
293
|
|
|
294
|
+
export const KIT_WORKING_COPY_SELECTION_PATH = "/kit/working-copy/selection";
|
|
295
|
+
export const WORKING_COPY_VERSION = "working-copy-state/1";
|
|
296
|
+
export const WORKING_COPIES_HOME = "working-copies";
|
|
297
|
+
export const WORKING_COPY_ROOM_HOMES = ["capture", "library", "collaboration"];
|
|
298
|
+
export const WORKING_COPY_PRIVATE_HOMES = ["library", "collaboration", "rooms", "agents", "house"];
|
|
299
|
+
export const WORKING_COPY_RECEIVED_REF = "refs/house/received";
|
|
300
|
+
export const WORKING_COPY_BRANCH = "main";
|
|
301
|
+
export const ROOM_REFERENCE = /^r_[0-9a-z]+\.[0-9a-z]$/;
|
|
302
|
+
|
|
294
303
|
export const MIGRATOR_CONTRACT = "kit-migrator/1";
|
|
295
304
|
export const MIGRATOR_CATEGORY = "migrator";
|
|
296
305
|
export const MIGRATOR_MANIFEST = "migrator.json";
|
package/src/git.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { KitRefusal } from "./refusal.js";
|
|
3
|
+
|
|
4
|
+
function cancelledOr(signal, error, code) {
|
|
5
|
+
return signal?.aborted === true || error?.name === "AbortError"
|
|
6
|
+
? new KitRefusal("kit_command_cancelled")
|
|
7
|
+
: new KitRefusal(code);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function stdio(capture, input) {
|
|
11
|
+
if (input !== null) return ["pipe", capture ? "pipe" : "inherit", "inherit"];
|
|
12
|
+
return capture ? ["ignore", "pipe", "inherit"] : "inherit";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function runGit(cwd, args, options = {}) {
|
|
16
|
+
const { dependencies = {}, capture = false, input = null, signal, failure } = options;
|
|
17
|
+
if (signal?.aborted === true) return Promise.reject(new KitRefusal("kit_command_cancelled"));
|
|
18
|
+
const launcher = dependencies.spawn ?? spawn;
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
let child;
|
|
21
|
+
try {
|
|
22
|
+
child = launcher("git", args, {
|
|
23
|
+
cwd,
|
|
24
|
+
env: process.env,
|
|
25
|
+
stdio: stdio(capture, input),
|
|
26
|
+
signal,
|
|
27
|
+
killSignal: "SIGKILL",
|
|
28
|
+
});
|
|
29
|
+
} catch (error) {
|
|
30
|
+
reject(cancelledOr(signal, error, "kit_git_unavailable"));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const output = [];
|
|
34
|
+
let settled = false;
|
|
35
|
+
const finish = (operation) => {
|
|
36
|
+
if (settled) return;
|
|
37
|
+
settled = true;
|
|
38
|
+
operation();
|
|
39
|
+
};
|
|
40
|
+
child.stdout?.on("data", (chunk) => output.push(Buffer.from(chunk)));
|
|
41
|
+
child.once("error", (error) =>
|
|
42
|
+
finish(() => reject(cancelledOr(signal, error, "kit_git_unavailable"))),
|
|
43
|
+
);
|
|
44
|
+
child.once("close", (status) => {
|
|
45
|
+
finish(() => {
|
|
46
|
+
if (signal?.aborted === true) {
|
|
47
|
+
reject(new KitRefusal("kit_command_cancelled"));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (status !== 0) {
|
|
51
|
+
reject(new KitRefusal(failure));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
resolve(Buffer.concat(output));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
if (input !== null) {
|
|
58
|
+
child.stdin.on("error", () => undefined);
|
|
59
|
+
child.stdin.end(input);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
package/src/kit-work-stream.js
CHANGED
|
@@ -5,6 +5,7 @@ import { heldControl } from "./house.js";
|
|
|
5
5
|
import { KitRefusal } from "./refusal.js";
|
|
6
6
|
|
|
7
7
|
const PROTOCOL = "house.kit.stream.1";
|
|
8
|
+
const WATCHED_SUBJECTS = ["agents", "working_copy"];
|
|
8
9
|
|
|
9
10
|
function streamAddress(house) {
|
|
10
11
|
const url = new URL("/kit/stream", house);
|
|
@@ -37,7 +38,7 @@ async function connected(session, signal, available, dependencies) {
|
|
|
37
38
|
finish();
|
|
38
39
|
};
|
|
39
40
|
signal.addEventListener("abort", stop, { once: true });
|
|
40
|
-
socket.on("open", available);
|
|
41
|
+
socket.on("open", () => available(null));
|
|
41
42
|
socket.on("message", (data) => {
|
|
42
43
|
let frame;
|
|
43
44
|
try {
|
|
@@ -45,7 +46,9 @@ async function connected(session, signal, available, dependencies) {
|
|
|
45
46
|
} catch {
|
|
46
47
|
return;
|
|
47
48
|
}
|
|
48
|
-
if (frame?.type === "work_available" && frame.subject
|
|
49
|
+
if (frame?.type === "work_available" && WATCHED_SUBJECTS.includes(frame.subject)) {
|
|
50
|
+
available(frame.subject);
|
|
51
|
+
}
|
|
49
52
|
});
|
|
50
53
|
socket.on("unexpected-response", (_request, response) => {
|
|
51
54
|
response.resume();
|
|
@@ -64,7 +67,7 @@ async function connected(session, signal, available, dependencies) {
|
|
|
64
67
|
});
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
export async function
|
|
70
|
+
export async function watchKitWork(session, signal, available, dependencies = {}) {
|
|
68
71
|
let retryMs = 500;
|
|
69
72
|
while (!signal.aborted) {
|
|
70
73
|
const began = Date.now();
|
package/src/launcher.js
CHANGED
|
@@ -129,6 +129,12 @@ async function configureSession(acp, contract, sessionId, adapter) {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
function workingCopyContext(copies) {
|
|
133
|
+
if (!Array.isArray(copies) || copies.length === 0) return [];
|
|
134
|
+
const lines = copies.map((copy) => `${copy.path}: ${copy.repository}`);
|
|
135
|
+
return [{ type: "text", text: ["Local Git working copies of House paths:", ...lines].join("\n") }];
|
|
136
|
+
}
|
|
137
|
+
|
|
132
138
|
async function runTurn(channel, start, bridge, turn) {
|
|
133
139
|
const contract = compiledContract(start.adapter?.kind);
|
|
134
140
|
const adapter = startCommandAdapter(
|
|
@@ -193,6 +199,7 @@ async function runTurn(channel, start, bridge, turn) {
|
|
|
193
199
|
prompt: [
|
|
194
200
|
{ type: "text", text: JSON.stringify(request) },
|
|
195
201
|
{ type: "text", text: instructions },
|
|
202
|
+
...workingCopyContext(start.working_copies),
|
|
196
203
|
],
|
|
197
204
|
});
|
|
198
205
|
await new Promise((resolve) => setImmediate(resolve));
|
package/src/repository.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
2
|
import { constants } from "node:fs";
|
|
4
3
|
import { open, readlink, realpath } from "node:fs/promises";
|
|
5
4
|
import { join } from "node:path";
|
|
5
|
+
import { runGit } from "./git.js";
|
|
6
6
|
import { deliverPublicationChunk, maintainControl, openPublicationManifest, openSession } from "./house.js";
|
|
7
7
|
import { KitRefusal } from "./refusal.js";
|
|
8
8
|
import { readRepositoryState, writeRepositoryState } from "./state.js";
|
|
@@ -258,69 +258,11 @@ async function* chunkArchive(checkout, entries, signal) {
|
|
|
258
258
|
yield Buffer.alloc(BLOCK_BYTES * 2);
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
-
function runGit(checkout, args, dependencies, capture, signal) {
|
|
262
|
-
active(signal);
|
|
263
|
-
const launcher = dependencies.spawn ?? spawn;
|
|
264
|
-
return new Promise((resolve, reject) => {
|
|
265
|
-
let child;
|
|
266
|
-
try {
|
|
267
|
-
child = launcher("git", args, {
|
|
268
|
-
cwd: checkout,
|
|
269
|
-
env: process.env,
|
|
270
|
-
stdio: capture ? ["ignore", "pipe", "inherit"] : "inherit",
|
|
271
|
-
signal,
|
|
272
|
-
killSignal: "SIGKILL",
|
|
273
|
-
});
|
|
274
|
-
} catch (error) {
|
|
275
|
-
reject(
|
|
276
|
-
signal?.aborted === true || error?.name === "AbortError"
|
|
277
|
-
? new KitRefusal("kit_command_cancelled")
|
|
278
|
-
: new KitRefusal("kit_git_unavailable"),
|
|
279
|
-
);
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
const output = [];
|
|
283
|
-
let settled = false;
|
|
284
|
-
const finish = (operation) => {
|
|
285
|
-
if (settled) return;
|
|
286
|
-
settled = true;
|
|
287
|
-
operation();
|
|
288
|
-
};
|
|
289
|
-
child.stdout?.on("data", (chunk) => output.push(Buffer.from(chunk)));
|
|
290
|
-
child.once("error", (error) =>
|
|
291
|
-
finish(() =>
|
|
292
|
-
reject(
|
|
293
|
-
signal?.aborted === true || error?.name === "AbortError"
|
|
294
|
-
? new KitRefusal("kit_command_cancelled")
|
|
295
|
-
: new KitRefusal("kit_git_unavailable"),
|
|
296
|
-
),
|
|
297
|
-
),
|
|
298
|
-
);
|
|
299
|
-
child.once("close", (status) => {
|
|
300
|
-
finish(() => {
|
|
301
|
-
if (signal?.aborted === true) {
|
|
302
|
-
reject(new KitRefusal("kit_command_cancelled"));
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
if (status !== 0) {
|
|
306
|
-
reject(
|
|
307
|
-
new KitRefusal(args[0] === "pull" ? "kit_git_pull_failed" : "kit_git_selection_failed"),
|
|
308
|
-
);
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
resolve(Buffer.concat(output));
|
|
312
|
-
});
|
|
313
|
-
});
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
|
|
317
261
|
async function selectedPaths(checkout, dependencies, signal) {
|
|
318
262
|
const output = await runGit(
|
|
319
263
|
checkout,
|
|
320
264
|
["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
|
321
|
-
dependencies,
|
|
322
|
-
true,
|
|
323
|
-
signal,
|
|
265
|
+
{ dependencies, capture: true, signal, failure: "kit_git_selection_failed" },
|
|
324
266
|
);
|
|
325
267
|
const paths = [];
|
|
326
268
|
let offset = 0;
|
|
@@ -469,7 +411,7 @@ export async function captureRepository(options, dependencies = {}) {
|
|
|
469
411
|
session,
|
|
470
412
|
async (signal) => {
|
|
471
413
|
const state = stateOf(await readRepositoryState(options.directory, key), identity);
|
|
472
|
-
await runGit(identity.checkout, ["pull"], dependencies,
|
|
414
|
+
await runGit(identity.checkout, ["pull"], { dependencies, signal, failure: "kit_git_pull_failed" });
|
|
473
415
|
const now = dependencies.now ?? (() => new Date());
|
|
474
416
|
let sourceRef = state.source_ref;
|
|
475
417
|
let publication = null;
|
package/src/sync.js
CHANGED
|
@@ -46,7 +46,7 @@ async function withSession(options, dependencies, operation) {
|
|
|
46
46
|
return maintainControl(session, (signal) => operation(session, signal), options.signal);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
async function door(session, path, body, signal) {
|
|
49
|
+
export async function door(session, path, body, signal) {
|
|
50
50
|
return houseRequest(
|
|
51
51
|
session.house,
|
|
52
52
|
await heldControl(session, signal),
|
|
@@ -83,7 +83,7 @@ function changePosition(value, declaration) {
|
|
|
83
83
|
return { position: value.position, log_epoch: value.log_epoch };
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
function discoveryOf(value) {
|
|
86
|
+
export function discoveryOf(value) {
|
|
87
87
|
if (typeof value?.md_model_version !== "string" || !Array.isArray(value.authorities)) {
|
|
88
88
|
throw new KitRefusal("kit_sync_rejected", "discover");
|
|
89
89
|
}
|
|
@@ -93,7 +93,7 @@ function discoveryOf(value) {
|
|
|
93
93
|
return value;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
async function bootstrapBundle(session, prefixes, signal) {
|
|
96
|
+
export async function bootstrapBundle(session, prefixes, signal) {
|
|
97
97
|
const answer = await door(session, KIT_DOOR_BOOTSTRAP_PATH, { prefixes }, signal);
|
|
98
98
|
if (typeof answer?.transfer?.url === "string") {
|
|
99
99
|
const granted = await houseGetJson(answer.transfer.url, session.dependencies, signal);
|
package/src/worker.js
CHANGED
|
@@ -17,7 +17,8 @@ import { openAssistantDrafts } from "./draft.js";
|
|
|
17
17
|
import { localReply, localRequest, reconcileInbound } from "./inbound.js";
|
|
18
18
|
import { localFiles } from "./local-files.js";
|
|
19
19
|
import { inheritedEnvironment } from "./command.js";
|
|
20
|
-
import {
|
|
20
|
+
import { watchKitWork } from "./kit-work-stream.js";
|
|
21
|
+
import { applyWorkingCopies, workingCopyPaths } from "./working-copy.js";
|
|
21
22
|
import { readConfiguration } from "./configuration.js";
|
|
22
23
|
import { launchBase, readyLaunchDirectory } from "./directory.js";
|
|
23
24
|
import {
|
|
@@ -436,6 +437,11 @@ function assignmentRoute(context, assignment) {
|
|
|
436
437
|
return assignment.route;
|
|
437
438
|
}
|
|
438
439
|
|
|
440
|
+
async function appliedWorkingCopies(context) {
|
|
441
|
+
await context.workingCopiesApplied?.();
|
|
442
|
+
return workingCopyPaths(context.directory);
|
|
443
|
+
}
|
|
444
|
+
|
|
439
445
|
async function prepareAssignment(context, assignment, resumed) {
|
|
440
446
|
const route = assignmentRoute(context, assignment);
|
|
441
447
|
const command = await installedAgentCommand(context.directory, route.adapter.kind);
|
|
@@ -463,6 +469,7 @@ async function prepareAssignment(context, assignment, resumed) {
|
|
|
463
469
|
const authority = { token: assignment.session.token, expiresAt: assignment.session.expiresAt };
|
|
464
470
|
const opened = await openRunBridge(context, bound, authority, {
|
|
465
471
|
working_directory: assignment.workingDirectory,
|
|
472
|
+
working_copies: await appliedWorkingCopies(context),
|
|
466
473
|
execution_authority: context.configuration.executionAuthority,
|
|
467
474
|
adapter: {
|
|
468
475
|
kind: route.adapter.kind,
|
|
@@ -794,6 +801,7 @@ export async function runWorkers(options, dependencies = {}) {
|
|
|
794
801
|
const { context, tmux } = await workerContext(options, dependencies);
|
|
795
802
|
const { session, runtime } = context;
|
|
796
803
|
const supervised = await maintainControl(session, async (signal) => {
|
|
804
|
+
await applySavedWorkingCopies(context, signal);
|
|
797
805
|
return controlledPass(context, dependencies, context.configuration, signal);
|
|
798
806
|
}, session.signal);
|
|
799
807
|
return {
|
|
@@ -810,6 +818,41 @@ export async function runWorkers(options, dependencies = {}) {
|
|
|
810
818
|
};
|
|
811
819
|
}
|
|
812
820
|
|
|
821
|
+
function reported(line) {
|
|
822
|
+
process.stderr.write(`kit: ${line}\n`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function applySavedWorkingCopies(context, signal) {
|
|
826
|
+
const applied = await applyWorkingCopies(context, signal);
|
|
827
|
+
for (const refused of applied.refused) {
|
|
828
|
+
reported(`working copy ${refused.path} refused: ${refused.code}`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function coalescedTask(signal, fail, work) {
|
|
833
|
+
let pending = false;
|
|
834
|
+
let task = null;
|
|
835
|
+
const trigger = () => {
|
|
836
|
+
pending = true;
|
|
837
|
+
if (task !== null) return;
|
|
838
|
+
task = (async () => {
|
|
839
|
+
while (pending && !signal.aborted) {
|
|
840
|
+
pending = false;
|
|
841
|
+
await work();
|
|
842
|
+
}
|
|
843
|
+
})()
|
|
844
|
+
.catch((error) => {
|
|
845
|
+
if (signal.aborted) return;
|
|
846
|
+
fail(error);
|
|
847
|
+
})
|
|
848
|
+
.finally(() => {
|
|
849
|
+
task = null;
|
|
850
|
+
if (pending && !signal.aborted) trigger();
|
|
851
|
+
});
|
|
852
|
+
};
|
|
853
|
+
return { trigger, idle: () => task === null, settled: () => task };
|
|
854
|
+
}
|
|
855
|
+
|
|
813
856
|
function residentWake() {
|
|
814
857
|
let pending = false;
|
|
815
858
|
let release = null;
|
|
@@ -960,33 +1003,30 @@ export async function runResident(options, dependencies = {}) {
|
|
|
960
1003
|
context.waitAgentLogin = async (kind) => {
|
|
961
1004
|
await logins.get(kind)?.task;
|
|
962
1005
|
};
|
|
963
|
-
|
|
964
|
-
|
|
1006
|
+
const agentRefresh = coalescedTask(signal, fail, async () => {
|
|
1007
|
+
const needsLogin = await queueAgentRefresh(context, dependencies, signal, undefined, true);
|
|
1008
|
+
context.startLogins(needsLogin);
|
|
1009
|
+
});
|
|
1010
|
+
const copies = coalescedTask(
|
|
1011
|
+
signal,
|
|
1012
|
+
(error) => reported(`working copies unavailable: ${error?.message ?? error}`),
|
|
1013
|
+
() => applySavedWorkingCopies(context, signal),
|
|
1014
|
+
);
|
|
965
1015
|
const pointer = () => {
|
|
1016
|
+
if (!update.requested) agentRefresh.trigger();
|
|
1017
|
+
};
|
|
1018
|
+
const available = (subject) => {
|
|
966
1019
|
if (update.requested) return;
|
|
967
|
-
|
|
968
|
-
if (
|
|
969
|
-
pointerTask = (async () => {
|
|
970
|
-
while (pointerPending && !signal.aborted) {
|
|
971
|
-
pointerPending = false;
|
|
972
|
-
const needsLogin = await queueAgentRefresh(context, dependencies, signal, undefined, true);
|
|
973
|
-
context.startLogins(needsLogin);
|
|
974
|
-
}
|
|
975
|
-
})()
|
|
976
|
-
.catch((error) => {
|
|
977
|
-
if (signal.aborted) return;
|
|
978
|
-
fail(error);
|
|
979
|
-
})
|
|
980
|
-
.finally(() => {
|
|
981
|
-
pointerTask = null;
|
|
982
|
-
if (pointerPending && !signal.aborted) pointer();
|
|
983
|
-
});
|
|
1020
|
+
if (subject !== "working_copy") agentRefresh.trigger();
|
|
1021
|
+
if (subject !== "agents") copies.trigger();
|
|
984
1022
|
};
|
|
985
1023
|
context.onIdle = pointer;
|
|
986
|
-
|
|
1024
|
+
context.workingCopiesApplied = copies.settled;
|
|
1025
|
+
if (!update.requested) copies.trigger();
|
|
1026
|
+
const watching = (dependencies.watchKitWork ?? watchKitWork)(
|
|
987
1027
|
context.session,
|
|
988
1028
|
AbortSignal.any([signal, watcher.signal]),
|
|
989
|
-
|
|
1029
|
+
available,
|
|
990
1030
|
dependencies,
|
|
991
1031
|
).catch(fail);
|
|
992
1032
|
try {
|
|
@@ -1001,14 +1041,20 @@ export async function runResident(options, dependencies = {}) {
|
|
|
1001
1041
|
if (session.refused || update.requested) {
|
|
1002
1042
|
const sessions = await heldSessions(context);
|
|
1003
1043
|
if (sessions.length > 0) update.answer({ ready: false, sessions });
|
|
1004
|
-
else if (
|
|
1044
|
+
else if (
|
|
1045
|
+
session.refused ||
|
|
1046
|
+
(!served.serving() && logins.size === 0 && agentRefresh.idle() && copies.idle())
|
|
1047
|
+
) {
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1005
1050
|
}
|
|
1006
1051
|
await wake.wait(interval, signal);
|
|
1007
1052
|
}
|
|
1008
1053
|
} finally {
|
|
1009
1054
|
watcher.abort();
|
|
1010
1055
|
await watching;
|
|
1011
|
-
|
|
1056
|
+
await agentRefresh.settled();
|
|
1057
|
+
await copies.settled();
|
|
1012
1058
|
for (const running of logins.values()) running.controller.abort();
|
|
1013
1059
|
await Promise.all([...logins.values()].map((running) => running.task));
|
|
1014
1060
|
await context.agentRefresh;
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir, rename, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
KIT_DOOR_DISCOVER_PATH,
|
|
6
|
+
KIT_WORKING_COPY_SELECTION_PATH,
|
|
7
|
+
ROOM_REFERENCE,
|
|
8
|
+
WORKING_COPIES_HOME,
|
|
9
|
+
WORKING_COPY_BRANCH,
|
|
10
|
+
WORKING_COPY_PRIVATE_HOMES,
|
|
11
|
+
WORKING_COPY_RECEIVED_REF,
|
|
12
|
+
WORKING_COPY_ROOM_HOMES,
|
|
13
|
+
WORKING_COPY_VERSION,
|
|
14
|
+
} from "./contract.js";
|
|
15
|
+
import { runGit } from "./git.js";
|
|
16
|
+
import { houseAnswered, KitRefusal } from "./refusal.js";
|
|
17
|
+
import { readNote, writeNote, writeText } from "./state.js";
|
|
18
|
+
import { bootstrapBundle, discoveryOf, door } from "./sync.js";
|
|
19
|
+
import { sidecarPath } from "./sync-library.js";
|
|
20
|
+
import { locate, removeReplicaFile, writeReplicaFile } from "./sync-state.js";
|
|
21
|
+
|
|
22
|
+
const COMMITTER = "House Kit <house-kit@localhost>";
|
|
23
|
+
const GIT_FAILED = "kit_working_copy_git_failed";
|
|
24
|
+
|
|
25
|
+
function rejected(detail = null) {
|
|
26
|
+
return new KitRefusal("kit_working_copy_rejected", detail);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stateHome(directory) {
|
|
30
|
+
return join(directory, "state", "working-copies");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function statePath(directory, scope) {
|
|
34
|
+
if (scope.kind === "private") return join(stateHome(directory), "private.json");
|
|
35
|
+
if (!ROOM_REFERENCE.test(scope.ref)) throw new KitRefusal("kit_state_name_invalid", scope.ref);
|
|
36
|
+
return join(stateHome(directory), "rooms", `${scope.ref}.json`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function repositoryPath(base, scope) {
|
|
40
|
+
const root = join(base, WORKING_COPIES_HOME);
|
|
41
|
+
return scope.kind === "private" ? join(root, "private") : join(root, "rooms", scope.ref);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function housePath(scope) {
|
|
45
|
+
return scope.kind === "private" ? "/private" : `/rooms/${scope.handle}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sameScope(copy, scope) {
|
|
49
|
+
return copy.scope === scope.kind && (scope.kind === "private" || copy.room_ref === scope.ref);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function selectedScope(room) {
|
|
53
|
+
if (
|
|
54
|
+
typeof room?.room_ref !== "string" ||
|
|
55
|
+
!ROOM_REFERENCE.test(room.room_ref) ||
|
|
56
|
+
(room.room_handle !== null && (typeof room.room_handle !== "string" || room.room_handle === ""))
|
|
57
|
+
) {
|
|
58
|
+
throw rejected("selection");
|
|
59
|
+
}
|
|
60
|
+
return { kind: "room", ref: room.room_ref, handle: room.room_handle };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function savedSelection(session, signal) {
|
|
64
|
+
const answer = await door(session, KIT_WORKING_COPY_SELECTION_PATH, {}, signal);
|
|
65
|
+
const copy = answer?.working_copy;
|
|
66
|
+
if (copy === null) return [];
|
|
67
|
+
if (!Array.isArray(copy?.rooms) || typeof copy.private !== "boolean") throw rejected("selection");
|
|
68
|
+
return [
|
|
69
|
+
...copy.rooms.map(selectedScope),
|
|
70
|
+
...(copy.private ? [{ kind: "private", ref: null, handle: null }] : []),
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function selectedPrefixes(scope) {
|
|
75
|
+
const homes = scope.kind === "private" ? WORKING_COPY_PRIVATE_HOMES : WORKING_COPY_ROOM_HOMES;
|
|
76
|
+
return homes.map((home) => `${housePath(scope)}/${home}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function receivedFiles(view) {
|
|
80
|
+
const manifest = view?.manifest;
|
|
81
|
+
if (
|
|
82
|
+
!Array.isArray(manifest?.files) ||
|
|
83
|
+
!Array.isArray(view.contents) ||
|
|
84
|
+
typeof manifest.anchor !== "string" ||
|
|
85
|
+
typeof manifest.position !== "string" ||
|
|
86
|
+
typeof manifest.log_epoch !== "string"
|
|
87
|
+
) {
|
|
88
|
+
throw rejected("bootstrap");
|
|
89
|
+
}
|
|
90
|
+
const contents = new Map();
|
|
91
|
+
for (const entry of view.contents) {
|
|
92
|
+
if (typeof entry?.path !== "string" || typeof entry.content !== "string") throw rejected("bootstrap");
|
|
93
|
+
contents.set(entry.path, entry.content);
|
|
94
|
+
}
|
|
95
|
+
return manifest.files.map((file) => {
|
|
96
|
+
const content = contents.get(file?.path);
|
|
97
|
+
if (
|
|
98
|
+
content === undefined ||
|
|
99
|
+
typeof file.revision !== "string" ||
|
|
100
|
+
typeof file.sha256 !== "string" ||
|
|
101
|
+
typeof file.writable !== "boolean"
|
|
102
|
+
) {
|
|
103
|
+
throw rejected("bootstrap");
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
path: file.path,
|
|
107
|
+
revision: file.revision,
|
|
108
|
+
sha256: file.sha256,
|
|
109
|
+
writable: file.writable,
|
|
110
|
+
content,
|
|
111
|
+
provenance: typeof file.provenance === "string" ? file.provenance : null,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function readScope(session, scope, signal) {
|
|
117
|
+
const prefixes = selectedPrefixes(scope);
|
|
118
|
+
const discovery = discoveryOf(await door(session, KIT_DOOR_DISCOVER_PATH, { prefixes }, signal));
|
|
119
|
+
const bundle = await bootstrapBundle(session, prefixes, signal);
|
|
120
|
+
if (bundle.scopes.length === 0) throw new KitRefusal("kit_working_copy_unavailable", housePath(scope));
|
|
121
|
+
if (bundle.scopes.length !== 1) throw rejected("bootstrap");
|
|
122
|
+
const [view] = bundle.scopes;
|
|
123
|
+
const files = receivedFiles(view);
|
|
124
|
+
const authority = discovery.authorities.find(
|
|
125
|
+
(entry) => entry?.room_ref === view.manifest.anchor,
|
|
126
|
+
);
|
|
127
|
+
if (
|
|
128
|
+
authority === undefined ||
|
|
129
|
+
!Array.isArray(authority.protected_paths) ||
|
|
130
|
+
(scope.kind === "room" && view.manifest.anchor !== scope.ref)
|
|
131
|
+
) {
|
|
132
|
+
throw rejected("discover");
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
anchor: view.manifest.anchor,
|
|
136
|
+
position: view.manifest.position,
|
|
137
|
+
logEpoch: view.manifest.log_epoch,
|
|
138
|
+
protectedPaths: authority.protected_paths.filter((path) => typeof path === "string"),
|
|
139
|
+
files,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function importData(text) {
|
|
144
|
+
const bytes = Buffer.from(text, "utf8");
|
|
145
|
+
return [Buffer.from(`data ${bytes.length}\n`), bytes, Buffer.from("\n")];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function quotedPath(path) {
|
|
149
|
+
return `"${path.replace(/[\\"]/g, "\\$&").replace(/\n/g, "\\n")}"`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function importReceived(repository, scope, read, parent, signal) {
|
|
153
|
+
const message = `House ${housePath(scope)} at position ${read.position}`;
|
|
154
|
+
const lines = [
|
|
155
|
+
Buffer.from(`commit ${WORKING_COPY_RECEIVED_REF}\n`),
|
|
156
|
+
Buffer.from(`committer ${COMMITTER} ${Math.floor(Date.now() / 1000)} +0000\n`),
|
|
157
|
+
...importData(message),
|
|
158
|
+
...(parent === null ? [] : [Buffer.from(`from ${parent}\n`)]),
|
|
159
|
+
Buffer.from("deleteall\n"),
|
|
160
|
+
];
|
|
161
|
+
for (const file of read.files.filter((entry) => entry.writable)) {
|
|
162
|
+
lines.push(Buffer.from(`M 100644 inline ${quotedPath(file.path)}\n`), ...importData(file.content));
|
|
163
|
+
}
|
|
164
|
+
lines.push(Buffer.from("\n"));
|
|
165
|
+
await runGit(repository, ["fast-import", "--quiet", "--force"], {
|
|
166
|
+
input: Buffer.concat(lines),
|
|
167
|
+
signal,
|
|
168
|
+
failure: GIT_FAILED,
|
|
169
|
+
});
|
|
170
|
+
const commit = await runGit(repository, ["rev-parse", "--verify", `${WORKING_COPY_RECEIVED_REF}^{commit}`], {
|
|
171
|
+
capture: true,
|
|
172
|
+
signal,
|
|
173
|
+
failure: GIT_FAILED,
|
|
174
|
+
});
|
|
175
|
+
return commit.toString("utf8").trim();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function readOnlyContents(read) {
|
|
179
|
+
const contents = new Map();
|
|
180
|
+
for (const file of read.files) {
|
|
181
|
+
if (!file.writable) contents.set(file.path, file.content);
|
|
182
|
+
if (file.provenance !== null) contents.set(sidecarPath(file.path), file.provenance);
|
|
183
|
+
}
|
|
184
|
+
return contents;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function present(path) {
|
|
188
|
+
try {
|
|
189
|
+
await stat(path);
|
|
190
|
+
return true;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error.code === "ENOENT") return false;
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function trackedPaths(repository, signal) {
|
|
198
|
+
const listed = await runGit(repository, ["ls-files", "-z"], { capture: true, signal, failure: GIT_FAILED });
|
|
199
|
+
return new Set(listed.toString("utf8").split("\0").filter((path) => path !== ""));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function replaceReadOnly(repository, previous, next, signal) {
|
|
203
|
+
const tracked = await trackedPaths(repository, signal);
|
|
204
|
+
const held = new Set(previous);
|
|
205
|
+
for (const [path, content] of next) {
|
|
206
|
+
if (tracked.has(path)) continue;
|
|
207
|
+
const target = locate(repository, "", path);
|
|
208
|
+
if (!held.has(path) && (await present(target))) continue;
|
|
209
|
+
await writeReplicaFile(target, content, signal);
|
|
210
|
+
}
|
|
211
|
+
for (const path of held) {
|
|
212
|
+
if (next.has(path) || tracked.has(path)) continue;
|
|
213
|
+
await removeReplicaFile(locate(repository, "", path));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function covers(path, prefix) {
|
|
218
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function ignorePattern(path) {
|
|
222
|
+
return `/${path.replace(/[\\*?[]/g, "\\$&").replace(/ +$/, (spaces) => spaces.replace(/ /g, "\\ "))}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function ignoreGroups(read) {
|
|
226
|
+
const writable = read.files.filter((file) => file.writable).map((file) => file.path);
|
|
227
|
+
const readOnly = read.files.filter((file) => !file.writable).map((file) => file.path);
|
|
228
|
+
const whole = read.protectedPaths.filter((path) => !writable.some((file) => covers(file, path)));
|
|
229
|
+
const protectedPath = (path) => read.protectedPaths.some((prefix) => covers(path, prefix));
|
|
230
|
+
const house = [
|
|
231
|
+
...whole,
|
|
232
|
+
...readOnly.filter((path) => protectedPath(path) && !whole.some((prefix) => covers(path, prefix))),
|
|
233
|
+
];
|
|
234
|
+
const granted = readOnly.filter((path) => !protectedPath(path));
|
|
235
|
+
return { house: house.map(ignorePattern), granted: granted.map(ignorePattern) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function writeIgnoreRules(repository, read, signal) {
|
|
239
|
+
const { house, granted } = ignoreGroups(read);
|
|
240
|
+
const gitignore = [
|
|
241
|
+
"# House Kit writes this file and .ignore. The paths below are read-only House",
|
|
242
|
+
"# content: Git does not track them, and House refuses a change to them even",
|
|
243
|
+
"# when this file is edited or a path is force-added.",
|
|
244
|
+
"/.gitignore",
|
|
245
|
+
"/.ignore",
|
|
246
|
+
"_provenance",
|
|
247
|
+
"",
|
|
248
|
+
"# House maintains or protects these paths.",
|
|
249
|
+
...house,
|
|
250
|
+
"",
|
|
251
|
+
"# Your current House grant does not allow writing these paths.",
|
|
252
|
+
...granted,
|
|
253
|
+
"",
|
|
254
|
+
];
|
|
255
|
+
const ignore = [
|
|
256
|
+
"# House Kit writes this file so native search still reads the read-only House",
|
|
257
|
+
"# content that .gitignore keeps out of Git.",
|
|
258
|
+
"!_provenance",
|
|
259
|
+
...[...house, ...granted].map((pattern) => `!${pattern}`),
|
|
260
|
+
"",
|
|
261
|
+
];
|
|
262
|
+
await writeText(join(repository, ".gitignore"), gitignore.join("\n"), signal, 0o644);
|
|
263
|
+
await writeText(join(repository, ".ignore"), ignore.join("\n"), signal, 0o644);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function manifest(read, commit) {
|
|
267
|
+
return {
|
|
268
|
+
commit,
|
|
269
|
+
position: read.position,
|
|
270
|
+
log_epoch: read.logEpoch,
|
|
271
|
+
files: read.files.map(({ path, revision, sha256, writable }) => ({ path, revision, sha256, writable })),
|
|
272
|
+
read_only: [...readOnlyContents(read).keys()],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function writeCopy(directory, copy, signal) {
|
|
277
|
+
await writeNote(statePath(directory, { kind: copy.scope, ref: copy.room_ref }), copy, signal);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function heldCopies(directory) {
|
|
281
|
+
const rooms = join(stateHome(directory), "rooms");
|
|
282
|
+
let names = [];
|
|
283
|
+
try {
|
|
284
|
+
names = await readdir(rooms);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (error.code !== "ENOENT") throw new KitRefusal("kit_state_unreadable", rooms);
|
|
287
|
+
}
|
|
288
|
+
const held = [];
|
|
289
|
+
for (const name of names.filter((entry) => entry.endsWith(".json")).sort()) {
|
|
290
|
+
held.push(await readNote(join(rooms, name), null));
|
|
291
|
+
}
|
|
292
|
+
held.push(await readNote(statePath(directory, { kind: "private" }), null));
|
|
293
|
+
return held.filter((copy) => copy?.version === WORKING_COPY_VERSION);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function materialize(context, scope, signal) {
|
|
297
|
+
const read = await readScope(context.session, scope, signal);
|
|
298
|
+
const identity = { kind: scope.kind, ref: read.anchor };
|
|
299
|
+
const repository = repositoryPath(context.launchBase, identity);
|
|
300
|
+
if (await present(repository)) throw new KitRefusal("kit_working_copy_unmanaged", repository);
|
|
301
|
+
const staging = join(dirname(repository), `.${randomUUID()}.staging`);
|
|
302
|
+
await mkdir(staging, { recursive: true });
|
|
303
|
+
let commit;
|
|
304
|
+
try {
|
|
305
|
+
await runGit(staging, ["init", "-q", "-b", WORKING_COPY_BRANCH], { signal, failure: GIT_FAILED });
|
|
306
|
+
commit = await importReceived(staging, scope, read, null, signal);
|
|
307
|
+
await runGit(staging, ["update-ref", `refs/heads/${WORKING_COPY_BRANCH}`, commit], {
|
|
308
|
+
signal,
|
|
309
|
+
failure: GIT_FAILED,
|
|
310
|
+
});
|
|
311
|
+
await runGit(staging, ["read-tree", "-m", "-u", "HEAD"], { signal, failure: GIT_FAILED });
|
|
312
|
+
await replaceReadOnly(staging, [], readOnlyContents(read), signal);
|
|
313
|
+
await writeIgnoreRules(staging, read, signal);
|
|
314
|
+
await rename(staging, repository);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
await rm(staging, { recursive: true, force: true });
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
const received = manifest(read, commit);
|
|
320
|
+
await writeCopy(context.directory, {
|
|
321
|
+
version: WORKING_COPY_VERSION,
|
|
322
|
+
scope: scope.kind,
|
|
323
|
+
room_ref: read.anchor,
|
|
324
|
+
room_handle: scope.handle,
|
|
325
|
+
repository,
|
|
326
|
+
active: true,
|
|
327
|
+
base: received,
|
|
328
|
+
received,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function refresh(context, copy, scope, signal) {
|
|
333
|
+
if (!(await present(copy.repository))) {
|
|
334
|
+
await rm(statePath(context.directory, { kind: copy.scope, ref: copy.room_ref }), { force: true });
|
|
335
|
+
await materialize(context, scope, signal);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const read = await readScope(context.session, scope, signal);
|
|
339
|
+
if (read.anchor !== copy.room_ref) throw rejected("bootstrap");
|
|
340
|
+
const commit = await importReceived(copy.repository, scope, read, copy.received.commit, signal);
|
|
341
|
+
await replaceReadOnly(copy.repository, copy.received.read_only, readOnlyContents(read), signal);
|
|
342
|
+
await writeIgnoreRules(copy.repository, read, signal);
|
|
343
|
+
await writeCopy(
|
|
344
|
+
context.directory,
|
|
345
|
+
{ ...copy, room_handle: scope.handle, active: true, received: manifest(read, commit) },
|
|
346
|
+
signal,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function scopeRefused(error) {
|
|
351
|
+
return error instanceof KitRefusal && (houseAnswered(error) || error.code.startsWith("kit_working_copy_"));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export async function applyWorkingCopies(context, signal) {
|
|
355
|
+
const selected = (await savedSelection(context.session, signal)).filter(
|
|
356
|
+
(scope) => scope.kind === "private" || scope.handle !== null,
|
|
357
|
+
);
|
|
358
|
+
const held = await heldCopies(context.directory);
|
|
359
|
+
for (const copy of held) {
|
|
360
|
+
if (copy.active && !selected.some((scope) => sameScope(copy, scope))) {
|
|
361
|
+
await writeCopy(context.directory, { ...copy, active: false }, signal);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const refused = [];
|
|
365
|
+
for (const scope of selected) {
|
|
366
|
+
const copy = held.find((entry) => sameScope(entry, scope));
|
|
367
|
+
try {
|
|
368
|
+
if (copy === undefined) await materialize(context, scope, signal);
|
|
369
|
+
else if (!copy.active) await refresh(context, copy, scope, signal);
|
|
370
|
+
else if (copy.room_handle !== scope.handle) {
|
|
371
|
+
await writeCopy(context.directory, { ...copy, room_handle: scope.handle }, signal);
|
|
372
|
+
}
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (!scopeRefused(error)) throw error;
|
|
375
|
+
refused.push({ path: housePath(scope), code: error.code });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return { refused };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function workingCopyPaths(directory) {
|
|
382
|
+
return (await heldCopies(directory))
|
|
383
|
+
.filter((copy) => copy.active)
|
|
384
|
+
.map((copy) => ({
|
|
385
|
+
path: housePath({ kind: copy.scope, handle: copy.room_handle }),
|
|
386
|
+
repository: copy.repository,
|
|
387
|
+
}))
|
|
388
|
+
.sort((left, right) => (left.path < right.path ? -1 : 1));
|
|
389
|
+
}
|