@agentshouse/kit 0.1.0-alpha.12 → 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 +60 -8
- package/bin/kit.js +3 -1
- package/bootstrap.json +3 -3
- package/package.json +1 -1
- package/src/contract.js +9 -0
- package/src/git.js +62 -0
- package/src/house.js +17 -7
- 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/telegram.js +5 -3
- package/src/worker.js +80 -27
- 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
|
|
@@ -27,16 +27,18 @@ installed container runs another image, the bootstrap pulls this release's
|
|
|
27
27
|
image and asks the running resident Kit to drain with `kit resident drain`. The
|
|
28
28
|
resident stops acquiring Runs, resumptions, setup and agent CLI work, while the
|
|
29
29
|
Sessions it supervises keep their renewal, owner stops, answers and reports.
|
|
30
|
-
While any Agent Session is still running or unreported,
|
|
31
|
-
|
|
30
|
+
While any Agent Session is still running or unreported, including one whose Run
|
|
31
|
+
was being acquired when the drain began, the bootstrap names it and leaves the
|
|
32
|
+
container as it is; rerun it once they end. Once nothing remains,
|
|
32
33
|
the resident stops renewing, releases its exact lease and stays quiescent, and
|
|
33
34
|
the bootstrap replaces the container with one on this release's image over the
|
|
34
35
|
same Kit home and workspace. The credential, Environment, Kit-home state and
|
|
35
36
|
everything under the workspace are kept; anything installed elsewhere in the old
|
|
36
37
|
container is not. A stopped container is replaced without a drain.
|
|
37
38
|
|
|
38
|
-
If House refuses the installed Kit with `kit_upgrade_required`,
|
|
39
|
-
|
|
39
|
+
If House refuses the installed Kit with `kit_upgrade_required`, including in
|
|
40
|
+
its answer to a command the resident serves, the resident calls House no more,
|
|
41
|
+
neither to release nor to report. It keeps supervising
|
|
40
42
|
every running Session locally and never stops one to make progress. A Session
|
|
41
43
|
that ends keeps its exact binding, stop observation and outcome in Kit home,
|
|
42
44
|
and the drain answers ready once no supervised process runs. The updated Kit
|
|
@@ -1170,8 +1172,13 @@ A resident whose lease House refuses answers the command with that refusal; the
|
|
|
1170
1172
|
command never acquires a lease of its own while a resident serves it. While an
|
|
1171
1173
|
update drains, `state/update-drain` refuses every such command, `kit login` and
|
|
1172
1174
|
`kit worker run` with `kit_update_draining` before it enrolls, acquires or
|
|
1173
|
-
delegates anything
|
|
1174
|
-
|
|
1175
|
+
delegates anything. A command already served keeps the resident's lease until
|
|
1176
|
+
it ends, while the resident refuses its acquisition of new work and its
|
|
1177
|
+
Telegram updates with `kit_update_draining`, which ends a served
|
|
1178
|
+
`kit telegram serve` once each of its activities reports the deliveries and
|
|
1179
|
+
files it already acquired; until it ends, the drain answers that it waits for
|
|
1180
|
+
that command and the bootstrap leaves the container as it is. The bootstrap
|
|
1181
|
+
removes the file when it replaces the container.
|
|
1175
1182
|
|
|
1176
1183
|
Interrupting the command cancels the running connector without advancing the
|
|
1177
1184
|
checkpoint.
|
|
@@ -1210,9 +1217,52 @@ interrupted run is never resumed: the next run opens a new manifest and House
|
|
|
1210
1217
|
answers with what is still needed. The command reports the Publication
|
|
1211
1218
|
reference, the paths delivered, the items refused and the ending.
|
|
1212
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
|
+
|
|
1213
1263
|
## Explicit synchronization
|
|
1214
1264
|
|
|
1215
|
-
Beside
|
|
1265
|
+
Beside [working copies](#working-copies), Kit has one explicit synchronization mode.
|
|
1216
1266
|
It selects prefixes over the User root, materializes them into a named local
|
|
1217
1267
|
directory, enumerates whole content and removals after the recorded positions,
|
|
1218
1268
|
and submits a resolved exact-base change set the caller prepared. Kit does no
|
|
@@ -1495,6 +1545,8 @@ $HOUSE_KIT_HOME/
|
|
|
1495
1545
|
state/repositories/<repository identity>.json
|
|
1496
1546
|
state/sync/<replica identity>.json
|
|
1497
1547
|
state/sync/<replica identity>.pending.json
|
|
1548
|
+
state/working-copies/rooms/<room ref>.json
|
|
1549
|
+
state/working-copies/private.json
|
|
1498
1550
|
state/attachments/<upload identity>.json
|
|
1499
1551
|
state/conversation-originals/<attachment identity>.json
|
|
1500
1552
|
state/sessions/<tmux session>.json
|
package/bin/kit.js
CHANGED
|
@@ -177,7 +177,9 @@ async function runResidentDrain(args, directory) {
|
|
|
177
177
|
write(
|
|
178
178
|
answer.sessions.length > 0
|
|
179
179
|
? `House Kit is draining for its update and waits for Agent Sessions ${answer.sessions.join(", ")}.`
|
|
180
|
-
:
|
|
180
|
+
: answer.serving === true
|
|
181
|
+
? "House Kit is draining for its update and waits for the Kit command it serves."
|
|
182
|
+
: `House Kit is draining for its update; House refused its lease release (${answer.refused}).`,
|
|
181
183
|
);
|
|
182
184
|
process.exitCode = 1;
|
|
183
185
|
}
|
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/house.js
CHANGED
|
@@ -56,6 +56,10 @@ async function readCredential(directory) {
|
|
|
56
56
|
return stored;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
function latchRefusal(answer, dependencies) {
|
|
60
|
+
if (answer?.error?.code === UPGRADE_REQUIRED) dependencies.contractRefused?.();
|
|
61
|
+
}
|
|
62
|
+
|
|
59
63
|
async function houseAnswer(house, path, init, dependencies, signal) {
|
|
60
64
|
const fetcher = dependencies.fetch ?? fetch;
|
|
61
65
|
let response;
|
|
@@ -73,7 +77,7 @@ async function houseAnswer(house, path, init, dependencies, signal) {
|
|
|
73
77
|
} catch {
|
|
74
78
|
answer = null;
|
|
75
79
|
}
|
|
76
|
-
|
|
80
|
+
latchRefusal(answer, dependencies);
|
|
77
81
|
throwHouseAnswer(response.status, answer, path);
|
|
78
82
|
if (answer === null) throw new KitRefusal("kit_house_unavailable", path);
|
|
79
83
|
return answer;
|
|
@@ -130,11 +134,18 @@ function residentAnswer(status, code) {
|
|
|
130
134
|
return { status, type: "application/json", body };
|
|
131
135
|
}
|
|
132
136
|
|
|
133
|
-
|
|
137
|
+
function newWork(path) {
|
|
138
|
+
return path === "/kit/telegram/updates" || (path.endsWith("/acquire") && path !== "/kit/commands/acquire");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function residentRelay(session, request, signal, draining) {
|
|
134
142
|
const path = typeof request.path === "string" ? request.path : "";
|
|
135
143
|
if (!path.startsWith("/kit/") || path.startsWith("/kit/lease")) {
|
|
136
144
|
return residentAnswer(403, "kit_resident_route_refused");
|
|
137
145
|
}
|
|
146
|
+
if (draining && newWork(path)) {
|
|
147
|
+
return residentAnswer(409, UPDATE_DRAINING);
|
|
148
|
+
}
|
|
138
149
|
let authorization;
|
|
139
150
|
try {
|
|
140
151
|
authorization = await heldControl(session, signal);
|
|
@@ -149,6 +160,7 @@ async function residentRelay(session, request, signal) {
|
|
|
149
160
|
signal,
|
|
150
161
|
});
|
|
151
162
|
const body = Buffer.from(await response.arrayBuffer());
|
|
163
|
+
if (response.status === 409) latchRefusal(JSON.parse(body.toString("utf8")), session.dependencies);
|
|
152
164
|
return {
|
|
153
165
|
status: response.status,
|
|
154
166
|
type: response.headers.get("content-type"),
|
|
@@ -168,9 +180,7 @@ export async function serveResident(session, directory, signal, drain) {
|
|
|
168
180
|
let draining = false;
|
|
169
181
|
function answerDrain(connection) {
|
|
170
182
|
draining = true;
|
|
171
|
-
|
|
172
|
-
holder?.destroy();
|
|
173
|
-
answered.then(
|
|
183
|
+
drain(holder !== null).then(
|
|
174
184
|
(answer) => {
|
|
175
185
|
if (!connection.destroyed) writeFrame(connection, answer);
|
|
176
186
|
connection.end();
|
|
@@ -181,7 +191,7 @@ export async function serveResident(session, directory, signal, drain) {
|
|
|
181
191
|
}
|
|
182
192
|
function hold(connection) {
|
|
183
193
|
if (holder !== null || draining || existsSync(updateDrain(directory))) {
|
|
184
|
-
writeFrame(connection, { refused: holder === null ? UPDATE_DRAINING : LEASE_ACTIVE });
|
|
194
|
+
writeFrame(connection, { refused: draining || holder === null ? UPDATE_DRAINING : LEASE_ACTIVE });
|
|
185
195
|
connection.end();
|
|
186
196
|
return false;
|
|
187
197
|
}
|
|
@@ -218,7 +228,7 @@ export async function serveResident(session, directory, signal, drain) {
|
|
|
218
228
|
}
|
|
219
229
|
const cancelled = new AbortController();
|
|
220
230
|
relayed.set(request.id, cancelled);
|
|
221
|
-
void residentRelay(session, request, AbortSignal.any([signal, cancelled.signal])).then(
|
|
231
|
+
void residentRelay(session, request, AbortSignal.any([signal, cancelled.signal]), draining).then(
|
|
222
232
|
(answer) => {
|
|
223
233
|
relayed.delete(request.id);
|
|
224
234
|
if (!connection.destroyed) writeFrame(connection, { id: request.id, ...answer });
|
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/telegram.js
CHANGED
|
@@ -863,20 +863,21 @@ async function onePass(gateway, activity, work) {
|
|
|
863
863
|
try {
|
|
864
864
|
await work(gateway);
|
|
865
865
|
} catch (error) {
|
|
866
|
-
if (cancelled(error) || !(error instanceof KitRefusal)) throw error;
|
|
866
|
+
if (cancelled(error) || !(error instanceof KitRefusal) || error.code === "kit_update_draining") throw error;
|
|
867
867
|
gateway.summary.failures.push({ activity, item: null, code: error.code });
|
|
868
868
|
}
|
|
869
869
|
}
|
|
870
870
|
|
|
871
871
|
async function paceActivity(gateway, stopping, activity, work) {
|
|
872
872
|
try {
|
|
873
|
-
for (let index = 0; index < gateway.passes; index += 1) {
|
|
873
|
+
for (let index = 0; index < gateway.passes && !gateway.draining; index += 1) {
|
|
874
874
|
if (gateway.signal?.aborted === true) throw new KitRefusal("kit_command_cancelled");
|
|
875
875
|
await onePass(gateway, activity, work);
|
|
876
876
|
if (index + 1 < gateway.passes) await gateway.wait(TELEGRAM_GATEWAY_POLL_MS, gateway.signal);
|
|
877
877
|
}
|
|
878
878
|
} catch (error) {
|
|
879
|
-
|
|
879
|
+
if (error.code === "kit_update_draining") gateway.draining = true;
|
|
880
|
+
else stopping.abort();
|
|
880
881
|
throw error;
|
|
881
882
|
}
|
|
882
883
|
}
|
|
@@ -964,6 +965,7 @@ function gatewayContext(session, options, signal, summary = emptySummary()) {
|
|
|
964
965
|
gate: rateGate(() => session.now().getTime()),
|
|
965
966
|
wait: session.dependencies.wait ?? pause,
|
|
966
967
|
passes: options.passes ?? Number.POSITIVE_INFINITY,
|
|
968
|
+
draining: false,
|
|
967
969
|
summary,
|
|
968
970
|
};
|
|
969
971
|
}
|
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 {
|
|
@@ -177,6 +178,7 @@ async function supervise(context, bound, bridge) {
|
|
|
177
178
|
while (live) {
|
|
178
179
|
await Promise.race([terminal, delay(WORKER_SUPERVISION_POLL_MS)]);
|
|
179
180
|
if (!live) return null;
|
|
181
|
+
await context.update?.announce();
|
|
180
182
|
if (Date.now() >= commanded) {
|
|
181
183
|
commanded = Date.now() + WORKER_COMMAND_POLL_MS;
|
|
182
184
|
const polled = await pendingCommands(context, ["stop", "interrupt", "answer"]);
|
|
@@ -435,6 +437,11 @@ function assignmentRoute(context, assignment) {
|
|
|
435
437
|
return assignment.route;
|
|
436
438
|
}
|
|
437
439
|
|
|
440
|
+
async function appliedWorkingCopies(context) {
|
|
441
|
+
await context.workingCopiesApplied?.();
|
|
442
|
+
return workingCopyPaths(context.directory);
|
|
443
|
+
}
|
|
444
|
+
|
|
438
445
|
async function prepareAssignment(context, assignment, resumed) {
|
|
439
446
|
const route = assignmentRoute(context, assignment);
|
|
440
447
|
const command = await installedAgentCommand(context.directory, route.adapter.kind);
|
|
@@ -462,6 +469,7 @@ async function prepareAssignment(context, assignment, resumed) {
|
|
|
462
469
|
const authority = { token: assignment.session.token, expiresAt: assignment.session.expiresAt };
|
|
463
470
|
const opened = await openRunBridge(context, bound, authority, {
|
|
464
471
|
working_directory: assignment.workingDirectory,
|
|
472
|
+
working_copies: await appliedWorkingCopies(context),
|
|
465
473
|
execution_authority: context.configuration.executionAuthority,
|
|
466
474
|
adapter: {
|
|
467
475
|
kind: route.adapter.kind,
|
|
@@ -793,6 +801,7 @@ export async function runWorkers(options, dependencies = {}) {
|
|
|
793
801
|
const { context, tmux } = await workerContext(options, dependencies);
|
|
794
802
|
const { session, runtime } = context;
|
|
795
803
|
const supervised = await maintainControl(session, async (signal) => {
|
|
804
|
+
await applySavedWorkingCopies(context, signal);
|
|
796
805
|
return controlledPass(context, dependencies, context.configuration, signal);
|
|
797
806
|
}, session.signal);
|
|
798
807
|
return {
|
|
@@ -809,6 +818,41 @@ export async function runWorkers(options, dependencies = {}) {
|
|
|
809
818
|
};
|
|
810
819
|
}
|
|
811
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
|
+
|
|
812
856
|
function residentWake() {
|
|
813
857
|
let pending = false;
|
|
814
858
|
let release = null;
|
|
@@ -849,20 +893,26 @@ function residentUpdate(context, wake) {
|
|
|
849
893
|
get requested() {
|
|
850
894
|
return requested;
|
|
851
895
|
},
|
|
852
|
-
async request() {
|
|
896
|
+
async request(serving) {
|
|
853
897
|
if (!requested) {
|
|
854
898
|
requested = true;
|
|
855
899
|
await writeText(updateDrain(context.directory), "");
|
|
856
900
|
}
|
|
857
901
|
wake.notify();
|
|
858
|
-
if (ready !== null) return ready;
|
|
859
902
|
const sessions = await heldSessions(context);
|
|
903
|
+
if (ready !== null) return ready;
|
|
904
|
+
if (serving) return { ready: false, sessions, serving };
|
|
860
905
|
if (sessions.length > 0) return { ready: false, sessions };
|
|
861
906
|
return new Promise((resolve) => waiting.push(resolve));
|
|
862
907
|
},
|
|
863
908
|
answer(answer) {
|
|
864
909
|
for (const resolve of waiting.splice(0)) resolve(answer);
|
|
865
910
|
},
|
|
911
|
+
async announce() {
|
|
912
|
+
if (waiting.length === 0) return;
|
|
913
|
+
const sessions = await heldSessions(context);
|
|
914
|
+
if (sessions.length > 0) this.answer({ ready: false, sessions });
|
|
915
|
+
},
|
|
866
916
|
quiesce() {
|
|
867
917
|
ready = { ready: true, sessions: [] };
|
|
868
918
|
this.answer(ready);
|
|
@@ -902,7 +952,7 @@ export async function runResident(options, dependencies = {}) {
|
|
|
902
952
|
const wake = residentWake();
|
|
903
953
|
const update = residentUpdate(context, wake);
|
|
904
954
|
context.update = update;
|
|
905
|
-
const served = await serveResident(session, context.directory, options.signal, () => update.request());
|
|
955
|
+
const served = await serveResident(session, context.directory, options.signal, (serving) => update.request(serving));
|
|
906
956
|
const serve = async (signal) => {
|
|
907
957
|
context.busy = true;
|
|
908
958
|
const watcher = new AbortController();
|
|
@@ -953,33 +1003,30 @@ export async function runResident(options, dependencies = {}) {
|
|
|
953
1003
|
context.waitAgentLogin = async (kind) => {
|
|
954
1004
|
await logins.get(kind)?.task;
|
|
955
1005
|
};
|
|
956
|
-
|
|
957
|
-
|
|
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
|
+
);
|
|
958
1015
|
const pointer = () => {
|
|
1016
|
+
if (!update.requested) agentRefresh.trigger();
|
|
1017
|
+
};
|
|
1018
|
+
const available = (subject) => {
|
|
959
1019
|
if (update.requested) return;
|
|
960
|
-
|
|
961
|
-
if (
|
|
962
|
-
pointerTask = (async () => {
|
|
963
|
-
while (pointerPending && !signal.aborted) {
|
|
964
|
-
pointerPending = false;
|
|
965
|
-
const needsLogin = await queueAgentRefresh(context, dependencies, signal, undefined, true);
|
|
966
|
-
context.startLogins(needsLogin);
|
|
967
|
-
}
|
|
968
|
-
})()
|
|
969
|
-
.catch((error) => {
|
|
970
|
-
if (signal.aborted) return;
|
|
971
|
-
fail(error);
|
|
972
|
-
})
|
|
973
|
-
.finally(() => {
|
|
974
|
-
pointerTask = null;
|
|
975
|
-
if (pointerPending && !signal.aborted) pointer();
|
|
976
|
-
});
|
|
1020
|
+
if (subject !== "working_copy") agentRefresh.trigger();
|
|
1021
|
+
if (subject !== "agents") copies.trigger();
|
|
977
1022
|
};
|
|
978
1023
|
context.onIdle = pointer;
|
|
979
|
-
|
|
1024
|
+
context.workingCopiesApplied = copies.settled;
|
|
1025
|
+
if (!update.requested) copies.trigger();
|
|
1026
|
+
const watching = (dependencies.watchKitWork ?? watchKitWork)(
|
|
980
1027
|
context.session,
|
|
981
1028
|
AbortSignal.any([signal, watcher.signal]),
|
|
982
|
-
|
|
1029
|
+
available,
|
|
983
1030
|
dependencies,
|
|
984
1031
|
).catch(fail);
|
|
985
1032
|
try {
|
|
@@ -994,14 +1041,20 @@ export async function runResident(options, dependencies = {}) {
|
|
|
994
1041
|
if (session.refused || update.requested) {
|
|
995
1042
|
const sessions = await heldSessions(context);
|
|
996
1043
|
if (sessions.length > 0) update.answer({ ready: false, sessions });
|
|
997
|
-
else if (
|
|
1044
|
+
else if (
|
|
1045
|
+
session.refused ||
|
|
1046
|
+
(!served.serving() && logins.size === 0 && agentRefresh.idle() && copies.idle())
|
|
1047
|
+
) {
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
998
1050
|
}
|
|
999
1051
|
await wake.wait(interval, signal);
|
|
1000
1052
|
}
|
|
1001
1053
|
} finally {
|
|
1002
1054
|
watcher.abort();
|
|
1003
1055
|
await watching;
|
|
1004
|
-
|
|
1056
|
+
await agentRefresh.settled();
|
|
1057
|
+
await copies.settled();
|
|
1005
1058
|
for (const running of logins.values()) running.controller.abort();
|
|
1006
1059
|
await Promise.all([...logins.values()].map((running) => running.task));
|
|
1007
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
|
+
}
|