@agentshouse/kit 0.1.0-alpha.20 → 0.1.0-alpha.21

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.20/connect-linux.sh)
13
+ bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.21/connect-linux.sh)
14
14
  ```
15
15
 
16
16
  The release asset pins one immutable multi-architecture image digest. It creates
@@ -1261,9 +1261,35 @@ committed.
1261
1261
  Local edits, staging and commits change nothing in House until a commit is
1262
1262
  pushed. `refs/house/received` records the House content last received; each
1263
1263
  commit Kit wrote there, and each commit it acknowledged for a push, is a House
1264
- state. The House revision of every file content Kit received, keyed by path and
1265
- Git blob, stays in Kit home under `state/working-copies/`, outside the
1266
- repository. A local commit is a local tree, never a House revision.
1264
+ state. The House revision of every file content Kit received, keyed by path,
1265
+ Git blob and the House state that received it, stays in Kit home under
1266
+ `state/working-copies/`, outside the repository. A local commit is a local tree, never a House revision.
1267
+
1268
+ The resident Kit keeps every active copy current. On each pass, and whenever
1269
+ it reads the saved choice, one `POST /kit/door/discover` tells it where each
1270
+ copied Room stands, and for a Room that moved on it reads the changes after the
1271
+ copy's recorded position through `POST /kit/door/enumerate`. A changed, new or
1272
+ removed authored file becomes a commit on `refs/house/received`, its House
1273
+ revision kept in Kit home; changed, new or removed read-only content, such as
1274
+ Capture with its provenance sidecar or a House projection, is written into or
1275
+ removed from the ignored local tree, and `.gitignore` and `.ignore` follow it.
1276
+ Delivery never touches the branch, the index or a tracked file, and a push
1277
+ waiting for its outcome or its local integration keeps its record. A push takes
1278
+ each file's revision from the newest House state in its own base's history that
1279
+ received that content, so a local commit keeps the base it was made on even when
1280
+ House returns to the same text under a newer revision. A file new to the copy is tracked when a write scope
1281
+ House declares for the Room covers it more closely than a declared protected
1282
+ path, and a local file already sitting where new read-only content arrives
1283
+ stays yours: Kit neither overwrites nor later removes it. When the write scopes
1284
+ or protected paths House declares for the Room change, Kit reads the Room again
1285
+ as a reselection does. A position older than House
1286
+ retains, or from before House's database was restored, makes Kit read the Room
1287
+ again as a reselection does; while House's replica has not reached the position
1288
+ the copy waits for the next pass. A Room House no longer answers under its
1289
+ recorded handle makes Kit read the saved choice again, which follows a handle
1290
+ change or marks the copy inactive, and a Room that lost sync is named on
1291
+ standard error as `operation_denied` and keeps what it holds. `kit worker run`
1292
+ delivers once, after reading the saved choice.
1267
1293
 
1268
1294
  `house git push [commit]`, run anywhere inside a copy or one of its linked
1269
1295
  worktrees, submits one commit, `HEAD` by default, resolved once. Kit compares
@@ -1319,7 +1345,10 @@ commits, and pushes again. After acceptance Kit records House's result for the
1319
1345
  pushed paths, the contents an owner push receives or each accepted revision
1320
1346
  read by name, in a commit that covers the pushed one and in
1321
1347
  `refs/house/received`; a read-only path House regenerates, such as the Wiki
1322
- index, stays out of Git. Kit then
1348
+ index, stays out of Git. The copy's recorded position goes back to where it
1349
+ stood when the push was sent, so the next delivery brings again anything House
1350
+ changed after the push's result, including what delivery received while the
1351
+ push awaited its answer. Kit then
1323
1352
  fast-forwards a clean branch still at the pushed commit, or rebases only its
1324
1353
  later commits. A result not yet readable, uncommitted tracked changes,
1325
1354
  a moved branch or a rebase conflict leave the branch as it was, with no stash or
@@ -1335,8 +1364,8 @@ Room again: if House holds the pushed content the push finishes as accepted,
1335
1364
  otherwise it is dropped and the next push submits it as a new operation.
1336
1365
 
1337
1366
  Deselecting a Room, or losing read access to it, marks its copy inactive:
1338
- launches omit it, a new push is refused as `kit_working_copy_inactive`, and
1339
- nothing local is deleted; a push already sent still settles and integrates. Selecting it again reads the Room
1367
+ launches omit it, delivery stops, a new push is refused as
1368
+ `kit_working_copy_inactive`, and nothing local is deleted; a push already sent still settles and integrates. Selecting it again reads the Room
1340
1369
  again, advances `refs/house/received`, and replaces only the read-only content
1341
1370
  Kit wrote, never the branch, the index or a tracked file. A Room the owner may
1342
1371
  read without sync is refused as `operation_denied` and stays uncopied, while its
package/bootstrap.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.1.0-alpha.20",
3
+ "version": "0.1.0-alpha.21",
4
4
  "hosts": {
5
5
  "linux": {
6
- "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.20/connect-linux.sh)",
6
+ "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.21/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.20/connect-linux.sh"
9
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.21/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.20",
3
+ "version": "0.1.0-alpha.21",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/contract.js CHANGED
@@ -297,7 +297,7 @@ export const EXPLICIT_SYNC_VERSION = "explicit-sync/2";
297
297
  export const KIT_DOOR_UNMOUNT_PATH = "/kit/door/unmount";
298
298
 
299
299
  export const KIT_WORKING_COPY_SELECTION_PATH = "/kit/working-copy/selection";
300
- export const WORKING_COPY_VERSION = "working-copy-state/2";
300
+ export const WORKING_COPY_VERSION = "working-copy-state/3";
301
301
  export const WORKING_COPIES_HOME = "working-copies";
302
302
  export const WORKING_COPY_ROOM_HOMES = ["capture", "library", "collaboration"];
303
303
  export const WORKING_COPY_PRIVATE_HOMES = ["library", "collaboration", "rooms", "agents", "house"];
package/src/worker.js CHANGED
@@ -19,7 +19,14 @@ import { localFiles } from "./local-files.js";
19
19
  import { answerText, answerYaml } from "./mcp.js";
20
20
  import { inheritedEnvironment } from "./command.js";
21
21
  import { watchKitWork } from "./kit-work-stream.js";
22
- import { applyWorkingCopies, ownerCaller, pushWorkingCopy, recordIndexChange, workingCopyPaths } from "./working-copy.js";
22
+ import {
23
+ applyWorkingCopies,
24
+ deliverWorkingCopies,
25
+ ownerCaller,
26
+ pushWorkingCopy,
27
+ recordIndexChange,
28
+ workingCopyPaths,
29
+ } from "./working-copy.js";
23
30
  import { readConfiguration } from "./configuration.js";
24
31
  import { launchBase, readyLaunchDirectory } from "./directory.js";
25
32
  import {
@@ -863,7 +870,7 @@ export async function runWorkers(options, dependencies = {}) {
863
870
  const { context, tmux } = await workerContext(options, dependencies);
864
871
  const { session, runtime } = context;
865
872
  const supervised = await maintainControl(session, async (signal) => {
866
- await applySavedWorkingCopies(context, signal);
873
+ await reportedWorkingCopies(applyWorkingCopies, context, signal);
867
874
  return controlledPass(context, dependencies, context.configuration, signal);
868
875
  }, session.signal);
869
876
  return {
@@ -884,9 +891,8 @@ function reported(line) {
884
891
  process.stderr.write(`kit: ${line}\n`);
885
892
  }
886
893
 
887
- async function applySavedWorkingCopies(context, signal) {
888
- const applied = await applyWorkingCopies(context, signal);
889
- for (const refused of applied.refused) {
894
+ async function reportedWorkingCopies(act, context, signal) {
895
+ for (const refused of (await act(context, signal)).refused) {
890
896
  reported(`working copy ${refused.path} refused: ${refused.code}`);
891
897
  }
892
898
  }
@@ -1078,10 +1084,12 @@ export async function runResident(options, dependencies = {}) {
1078
1084
  const needsLogin = await queueAgentRefresh(context, dependencies, signal, undefined, true);
1079
1085
  context.startLogins(needsLogin);
1080
1086
  });
1081
- const copies = coalescedTask(
1082
- signal,
1083
- (error) => reported(`working copies unavailable: ${error?.message ?? error}`),
1084
- () => applySavedWorkingCopies(context, signal),
1087
+ const unavailable = (error) => reported(`working copies unavailable: ${error?.message ?? error}`);
1088
+ const copies = coalescedTask(signal, unavailable, () =>
1089
+ reportedWorkingCopies(applyWorkingCopies, context, signal),
1090
+ );
1091
+ const deliveries = coalescedTask(signal, unavailable, () =>
1092
+ reportedWorkingCopies(deliverWorkingCopies, context, signal),
1085
1093
  );
1086
1094
  const pointer = () => {
1087
1095
  if (!update.requested) agentRefresh.trigger();
@@ -1114,18 +1122,20 @@ export async function runResident(options, dependencies = {}) {
1114
1122
  if (sessions.length > 0) update.answer({ ready: false, sessions });
1115
1123
  else if (
1116
1124
  session.refused ||
1117
- (!served.serving() && logins.size === 0 && agentRefresh.idle() && copies.idle())
1125
+ (!served.serving() && logins.size === 0 && agentRefresh.idle() && copies.idle() && deliveries.idle())
1118
1126
  ) {
1119
1127
  return;
1120
1128
  }
1121
1129
  }
1122
1130
  await wake.wait(interval, signal);
1131
+ if (!update.requested && !session.refused) deliveries.trigger();
1123
1132
  }
1124
1133
  } finally {
1125
1134
  watcher.abort();
1126
1135
  await watching;
1127
1136
  await agentRefresh.settled();
1128
1137
  await copies.settled();
1138
+ await deliveries.settled();
1129
1139
  for (const running of logins.values()) running.controller.abort();
1130
1140
  await Promise.all([...logins.values()].map((running) => running.task));
1131
1141
  await context.agentRefresh;
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdir, readdir, realpath, rename, rm, stat } from "node:fs/promises";
2
+ import { mkdir, readdir, readFile, realpath, rename, rm, stat } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
4
  import { readBindings } from "./binding.js";
5
5
  import {
@@ -21,7 +21,7 @@ import { operationId } from "./operation.js";
21
21
  import { formatCommandError, houseAnswered, houseFailed, KitRefusal } from "./refusal.js";
22
22
  import { readNote, writeNote, writeText } from "./state.js";
23
23
  import { bootstrapBundle, discoveryOf, door, withSession } from "./sync.js";
24
- import { sidecarPath, structuredOrReplace } from "./sync-library.js";
24
+ import { isGeneratedProvenance, sidecarPath, structuredOrReplace } from "./sync-library.js";
25
25
  import { locate, removeReplicaFile, writeReplicaFile } from "./sync-state.js";
26
26
 
27
27
  const COMMITTER_NAME = "House Kit";
@@ -134,6 +134,7 @@ async function readScope(session, scope, signal) {
134
134
  if (
135
135
  authority === undefined ||
136
136
  !Array.isArray(authority.protected_paths) ||
137
+ !Array.isArray(authority.operations?.write) ||
137
138
  (scope.kind === "room" && view.manifest.anchor !== scope.ref)
138
139
  ) {
139
140
  throw rejected("discover");
@@ -142,11 +143,16 @@ async function readScope(session, scope, signal) {
142
143
  anchor: view.manifest.anchor,
143
144
  position: view.manifest.position,
144
145
  logEpoch: view.manifest.log_epoch,
145
- protectedPaths: authority.protected_paths.filter((path) => typeof path === "string"),
146
+ protectedPaths: authority.protected_paths,
147
+ declared: declaration(authority),
146
148
  files,
147
149
  };
148
150
  }
149
151
 
152
+ function declaration(authority) {
153
+ return { write: authority.operations.write, protected: authority.protected_paths };
154
+ }
155
+
150
156
  function importData(text) {
151
157
  const bytes = Buffer.from(text, "utf8");
152
158
  return [Buffer.from(`data ${bytes.length}\n`), bytes, Buffer.from("\n")];
@@ -210,16 +216,28 @@ async function commitOf(repository, name, signal) {
210
216
  return commit.toString("utf8").trim();
211
217
  }
212
218
 
213
- async function remembered(repository, revisions, commit, files, signal) {
219
+ async function remembered(repository, revisions, commit, files, signal, acknowledged = false) {
214
220
  const tree = await treeOf(repository, commit, signal);
215
221
  const next = { ...revisions };
216
222
  for (const { path, revision } of files) {
217
223
  const entry = tree.get(path);
218
- if (entry !== undefined && revision !== null) next[path] = { ...next[path], [entry.object]: revision };
224
+ if (entry === undefined || revision === null) continue;
225
+ const seen = next[path]?.[entry.object] ?? [];
226
+ if (acknowledged || seen.at(-1)?.revision !== revision) {
227
+ next[path] = { ...next[path], [entry.object]: [...seen, { commit, revision }] };
228
+ }
219
229
  }
220
230
  return next;
221
231
  }
222
232
 
233
+ async function revisionAt(copy, path, entry, base, signal) {
234
+ const seen = entry === undefined ? [] : (copy.revisions[path]?.[entry.object] ?? []);
235
+ for (const known of seen.toReversed()) {
236
+ if (seen.length === 1 || (await contains(copy.repository, known.commit, base, signal))) return known.revision;
237
+ }
238
+ return undefined;
239
+ }
240
+
223
241
  function readOnlyContents(read) {
224
242
  const contents = new Map();
225
243
  for (const file of read.files) {
@@ -244,19 +262,22 @@ async function trackedPaths(repository, signal) {
244
262
  return new Set(listed.toString("utf8").split("\0").filter((path) => path !== ""));
245
263
  }
246
264
 
247
- async function replaceReadOnly(repository, previous, next, signal) {
265
+ async function replaceReadOnly(repository, held, written, removed, signal) {
248
266
  const tracked = await trackedPaths(repository, signal);
249
- const held = new Set(previous);
250
- for (const [path, content] of next) {
267
+ const holding = new Set(held);
268
+ for (const [path, content] of written) {
251
269
  if (tracked.has(path)) continue;
252
270
  const target = locate(repository, "", path);
253
271
  if (!held.has(path) && (await present(target))) continue;
254
272
  await writeReplicaFile(target, content, signal);
273
+ holding.add(path);
255
274
  }
256
- for (const path of held) {
257
- if (next.has(path) || tracked.has(path)) continue;
258
- await removeReplicaFile(locate(repository, "", path));
275
+ for (const path of removed) {
276
+ if (!held.has(path)) continue;
277
+ if (!tracked.has(path)) await removeReplicaFile(locate(repository, "", path));
278
+ holding.delete(path);
259
279
  }
280
+ return [...holding];
260
281
  }
261
282
 
262
283
  function covers(path, prefix) {
@@ -304,17 +325,16 @@ async function writeIgnoreRules(repository, read, signal) {
304
325
  ...[...house, ...granted].map((pattern) => `!${pattern}`),
305
326
  "",
306
327
  ];
307
- await writeText(join(repository, ".gitignore"), gitignore.join("\n"), signal, 0o644);
308
- await writeText(join(repository, ".ignore"), ignore.join("\n"), signal, 0o644);
328
+ await ruled(join(repository, ".gitignore"), gitignore.join("\n"), signal);
329
+ await ruled(join(repository, ".ignore"), ignore.join("\n"), signal);
309
330
  }
310
331
 
311
- function manifest(read, commit) {
312
- return {
313
- commit,
314
- position: read.position,
315
- log_epoch: read.logEpoch,
316
- read_only: [...readOnlyContents(read).keys()],
317
- };
332
+ async function ruled(path, rules, signal) {
333
+ if ((await readFile(path, "utf8").catch(() => null)) !== rules) await writeText(path, rules, signal, 0o644);
334
+ }
335
+
336
+ function manifest(read, commit, readOnly) {
337
+ return { commit, position: read.position, log_epoch: read.logEpoch, read_only: readOnly, declared: read.declared };
318
338
  }
319
339
 
320
340
  function copyPath(directory, copy) {
@@ -376,6 +396,7 @@ async function materialize(context, scope, signal) {
376
396
  const imported = received(scope, read, []);
377
397
  let commit;
378
398
  let revisions;
399
+ let readOnly;
379
400
  try {
380
401
  await runGit(staging, ["init", "-q", "-b", WORKING_COPY_BRANCH], { signal, failure: GIT_FAILED });
381
402
  commit = (await importReceived(staging, imported, signal)).received;
@@ -385,7 +406,7 @@ async function materialize(context, scope, signal) {
385
406
  failure: GIT_FAILED,
386
407
  });
387
408
  await runGit(staging, ["read-tree", "-m", "-u", "HEAD"], { signal, failure: GIT_FAILED });
388
- await replaceReadOnly(staging, [], readOnlyContents(read), signal);
409
+ readOnly = await replaceReadOnly(staging, new Set(), readOnlyContents(read), [], signal);
389
410
  await writeIgnoreRules(staging, read, signal);
390
411
  await rename(staging, repository);
391
412
  } catch (error) {
@@ -399,7 +420,7 @@ async function materialize(context, scope, signal) {
399
420
  room_handle: scope.handle,
400
421
  repository,
401
422
  active: true,
402
- received: manifest(read, commit),
423
+ received: manifest(read, commit, readOnly),
403
424
  revisions,
404
425
  });
405
426
  }
@@ -409,11 +430,14 @@ async function receive(context, copy, scope, signal) {
409
430
  if (read.anchor !== copy.room_ref) throw rejected("bootstrap");
410
431
  const imported = received(scope, read, [copy.received.commit]);
411
432
  const commit = (await importReceived(copy.repository, imported, signal)).received;
412
- await replaceReadOnly(copy.repository, copy.received.read_only, readOnlyContents(read), signal);
433
+ const next = readOnlyContents(read);
434
+ const held = copy.received.read_only;
435
+ const removed = held.filter((path) => !next.has(path));
436
+ const readOnly = await replaceReadOnly(copy.repository, new Set(held), next, removed, signal);
413
437
  await writeIgnoreRules(copy.repository, read, signal);
414
438
  return {
415
439
  ...copy,
416
- received: manifest(read, commit),
440
+ received: manifest(read, commit, readOnly),
417
441
  revisions: await remembered(copy.repository, copy.revisions, commit, imported.files, signal),
418
442
  };
419
443
  }
@@ -432,6 +456,117 @@ function scopeRefused(error) {
432
456
  return error instanceof KitRefusal && (houseAnswered(error) || error.code.startsWith("kit_working_copy_"));
433
457
  }
434
458
 
459
+ function declaredWritable(path, authority) {
460
+ const deepest = (scopes) =>
461
+ Math.max(-1, ...scopes.filter((scope) => scope === "" || covers(path, scope)).map((scope) => scope.length));
462
+ return deepest(authority.operations.write) > deepest(authority.protected_paths);
463
+ }
464
+
465
+ async function incorporated(copy, view, authority, signal) {
466
+ const tree = await treeOf(copy.repository, copy.received.commit, signal);
467
+ const held = new Set(copy.received.read_only);
468
+ const files = [];
469
+ const written = new Map();
470
+ const removed = [];
471
+ for (const change of view.changes) {
472
+ const gone = change.removed === true;
473
+ if (tree.has(change.path) || (!gone && !held.has(change.path) && declaredWritable(change.path, authority))) {
474
+ files.push({ path: change.path, revision: gone ? null : change.revision, content: gone ? null : change.content });
475
+ } else if (gone) {
476
+ removed.push(change.path, sidecarPath(change.path));
477
+ } else {
478
+ written.set(change.path, change.content);
479
+ if (typeof change.provenance === "string") written.set(sidecarPath(change.path), change.provenance);
480
+ }
481
+ }
482
+ const message = `House ${housePath(scopeOf(copy))} at position ${view.position}`;
483
+ const commit =
484
+ files.length === 0
485
+ ? copy.received.commit
486
+ : (await importReceived(copy.repository, { message, parents: [copy.received.commit], files }, signal)).received;
487
+ const readOnly = await replaceReadOnly(copy.repository, held, written, removed, signal);
488
+ if (view.changes.length > 0) {
489
+ const listed = [
490
+ ...[...(await treeOf(copy.repository, commit, signal)).keys()].map((path) => ({ path, writable: true })),
491
+ ...readOnly.filter((path) => !isGeneratedProvenance(path)).map((path) => ({ path, writable: false })),
492
+ ].sort((left, right) => (left.path < right.path ? -1 : 1));
493
+ await writeIgnoreRules(copy.repository, { files: listed, protectedPaths: authority.protected_paths }, signal);
494
+ }
495
+ return {
496
+ ...copy,
497
+ received: { ...copy.received, commit, position: view.position, log_epoch: view.log_epoch, read_only: readOnly },
498
+ revisions: await remembered(copy.repository, copy.revisions, commit, files, signal),
499
+ };
500
+ }
501
+
502
+ async function deliver(context, path, authority, signal) {
503
+ if (
504
+ typeof authority.position !== "string" ||
505
+ typeof authority.log_epoch !== "string" ||
506
+ !Array.isArray(authority.protected_paths) ||
507
+ !Array.isArray(authority.operations?.write)
508
+ ) {
509
+ throw rejected("discover");
510
+ }
511
+ let copy = await readCopy(path);
512
+ if (copy?.active && JSON.stringify(copy.received.declared) !== JSON.stringify(declaration(authority))) {
513
+ await writeCopy(context.directory, await receive(context, copy, scopeOf(copy), signal), signal);
514
+ return;
515
+ }
516
+ while (
517
+ copy?.active &&
518
+ (copy.received.log_epoch !== authority.log_epoch || BigInt(copy.received.position) < BigInt(authority.position))
519
+ ) {
520
+ const since = { position: copy.received.position, log_epoch: copy.received.log_epoch };
521
+ let view;
522
+ try {
523
+ view = await enumerated(context, copy, since, signal);
524
+ } catch (error) {
525
+ if (error.code === "replica_behind") return;
526
+ if (error.code !== "position_expired") throw error;
527
+ await writeCopy(context.directory, await receive(context, copy, scopeOf(copy), signal), signal);
528
+ return;
529
+ }
530
+ if (view.position === since.position) return;
531
+ copy = await incorporated(copy, view, authority, signal);
532
+ await writeCopy(context.directory, copy, signal);
533
+ }
534
+ }
535
+
536
+ async function delivering(context, signal) {
537
+ const copies = (await heldCopies(context.directory)).filter((copy) => copy.active);
538
+ if (copies.length === 0) return { refused: [], lost: false };
539
+ const prefixes = copies.flatMap((copy) => selectedPrefixes(scopeOf(copy)));
540
+ const discovery = discoveryOf(await door(context.session, KIT_DOOR_DISCOVER_PATH, { prefixes }, signal));
541
+ const refused = [];
542
+ let lost = false;
543
+ for (const copy of copies) {
544
+ const at = housePath(scopeOf(copy));
545
+ const authority = discovery.authorities.find((entry) => entry?.room_ref === copy.room_ref);
546
+ if (authority === undefined) {
547
+ lost = true;
548
+ continue;
549
+ }
550
+ if (authority.managed_replication !== "admitted") {
551
+ refused.push({ path: at, code: "operation_denied" });
552
+ continue;
553
+ }
554
+ const path = copyPath(context.directory, copy);
555
+ try {
556
+ await exclusively(path, () => deliver(context, path, authority, signal));
557
+ } catch (error) {
558
+ if (!scopeRefused(error)) throw error;
559
+ refused.push({ path: at, code: error.code });
560
+ }
561
+ }
562
+ return { refused, lost };
563
+ }
564
+
565
+ export async function deliverWorkingCopies(context, signal) {
566
+ const delivered = await delivering(context, signal);
567
+ return delivered.lost ? applyWorkingCopies(context, signal) : { refused: delivered.refused };
568
+ }
569
+
435
570
  export async function applyWorkingCopies(context, signal) {
436
571
  const selected = (await savedSelection(context.session, signal)).filter(
437
572
  (scope) => scope.kind === "private" || scope.handle !== null,
@@ -461,7 +596,7 @@ export async function applyWorkingCopies(context, signal) {
461
596
  refused.push({ path: housePath(scope), code: error.code });
462
597
  }
463
598
  }
464
- return { refused };
599
+ return { refused: [...refused, ...(await delivering(context, signal)).refused] };
465
600
  }
466
601
 
467
602
  export async function workingCopyPaths(directory) {
@@ -694,8 +829,8 @@ function renamed(chains, reached, before, after) {
694
829
  return new Map(pairs.filter(([from, to]) => pairs.filter((pair) => pair[0] === from || pair[1] === to).length === 1));
695
830
  }
696
831
 
697
- async function relocated(copy, root, [from, to], prior, next, signal) {
698
- const base = copy.revisions[from]?.[prior.object];
832
+ async function relocated(copy, root, [from, to], [prior, next], house, signal) {
833
+ const base = await revisionAt(copy, from, prior, house, signal);
699
834
  const rename = { op: "rename", path: `${root}/${from}`, to: `${root}/${to}`, base };
700
835
  if (prior.object === next.object) return [rename];
701
836
  return [rename, { op: "replace", path: `${root}/${to}`, base, content: await blobText(copy.repository, next, to, signal) }];
@@ -713,13 +848,13 @@ async function derived(copy, base, commit, signal) {
713
848
  const prior = before.get(path);
714
849
  if (moved.has(path)) {
715
850
  const to = moved.get(path);
716
- changes.push(...(await relocated(copy, root, [path, to], prior, after.get(to), signal)));
851
+ changes.push(...(await relocated(copy, root, [path, to], [prior, after.get(to)], base, signal)));
717
852
  continue;
718
853
  }
719
854
  const next = after.get(path);
720
855
  if (prior?.object === next?.object) continue;
721
856
  const target = `${root}/${path}`;
722
- const revision = copy.revisions[path]?.[prior?.object];
857
+ const revision = await revisionAt(copy, path, prior, base, signal);
723
858
  if (next === undefined) {
724
859
  changes.push({ op: "remove", path: target, base: revision });
725
860
  continue;
@@ -761,8 +896,15 @@ async function recorded(copy, files, message, acknowledging, signal) {
761
896
  return {
762
897
  copy: {
763
898
  ...copy,
764
- received: { ...copy.received, commit: imported.received },
765
- revisions: await remembered(copy.repository, copy.revisions, imported.received, files, signal),
899
+ received: { ...copy.received, commit: imported.received, ...(acknowledging === null ? {} : copy.push.from) },
900
+ revisions: await remembered(
901
+ copy.repository,
902
+ copy.revisions,
903
+ imported.acknowledged ?? imported.received,
904
+ files,
905
+ signal,
906
+ imported.acknowledged !== null,
907
+ ),
766
908
  },
767
909
  acknowledged: imported.acknowledged,
768
910
  };
@@ -952,11 +1094,12 @@ async function expired(context, copy, text, signal) {
952
1094
  const entry = house.get(path);
953
1095
  written.push({
954
1096
  path: `${root}/${path}`,
955
- revision: entry === undefined ? null : confirmed.revisions[path][entry.object],
1097
+ revision: entry === undefined ? null : await revisionAt(confirmed, path, entry, confirmed.received.commit, signal),
956
1098
  content: await blobText(copy.repository, entry, path, signal),
957
1099
  });
958
1100
  }
959
- const accepted = { ...confirmed, push: { ...push, state: "accepted", written } };
1101
+ const from = { position: confirmed.received.position, log_epoch: confirmed.received.log_epoch };
1102
+ const accepted = { ...confirmed, push: { ...push, state: "accepted", written, from } };
960
1103
  await writeCopy(context.directory, accepted, signal);
961
1104
  return finish(context, accepted, signal);
962
1105
  }
@@ -1030,6 +1173,7 @@ async function pushed(context, path, at, selector, caller, signal) {
1030
1173
  operation_id: operationId(),
1031
1174
  changes,
1032
1175
  commit,
1176
+ from: { position: copy.received.position, log_epoch: copy.received.log_epoch },
1033
1177
  worktree: at.worktree,
1034
1178
  branch,
1035
1179
  ...(since === null ? {} : { since }),