@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 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/connect-linux.sh)
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 the reconciling Working Copy, Kit has one explicit synchronization mode.
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.13",
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.13/connect-linux.sh)",
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.13/connect-linux.sh"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentshouse/kit",
3
- "version": "0.1.0-alpha.13",
3
+ "version": "0.1.0-alpha.14",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
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
+ }
@@ -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 === "agents") available();
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 watchKitAgentWork(session, signal, available, dependencies = {}) {
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, false, signal);
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 { watchKitAgentWork } from "./kit-work-stream.js";
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
- let pointerPending = false;
964
- let pointerTask = null;
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
- pointerPending = true;
968
- if (pointerTask !== null) return;
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
- const watching = (dependencies.watchKitAgentWork ?? watchKitAgentWork)(
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
- pointer,
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 (session.refused || (!served.serving() && logins.size === 0 && pointerTask === null)) return;
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
- if (pointerTask !== null) await pointerTask;
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
+ }