@agentshouse/kit 0.2.1-alpha.8 → 0.2.1-c031c7457a661

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.
Files changed (54) hide show
  1. package/README.md +49 -24
  2. package/dist/abort.js +9 -0
  3. package/dist/acp.js +49 -86
  4. package/dist/agents.js +333 -52
  5. package/dist/api.js +40 -4
  6. package/dist/app-git.js +48 -0
  7. package/dist/bootstrap.json +10 -14
  8. package/dist/bridge.js +93 -78
  9. package/dist/calls.js +63 -0
  10. package/dist/chatgpt.js +32 -0
  11. package/dist/clis-main.js +23 -0
  12. package/dist/clis.js +217 -72
  13. package/dist/codex-main.js +58 -0
  14. package/dist/configure-main.js +6 -0
  15. package/dist/connection.js +126 -0
  16. package/dist/conversations.js +642 -261
  17. package/dist/copies.js +237 -173
  18. package/dist/end.js +27 -0
  19. package/dist/enrol-main.js +7 -1
  20. package/dist/events.js +525 -0
  21. package/dist/files.js +140 -17
  22. package/dist/git-main.js +2 -2
  23. package/dist/git.js +16 -10
  24. package/dist/home.js +141 -10
  25. package/dist/house-cli.js +300 -0
  26. package/dist/house-main.js +5 -102
  27. package/dist/instructions.js +16 -17
  28. package/dist/kit-cli.js +68 -0
  29. package/dist/kit-main.js +8 -16
  30. package/dist/login.js +128 -95
  31. package/dist/logout.js +19 -0
  32. package/dist/mcp.js +41 -0
  33. package/dist/operation.js +7 -4
  34. package/dist/parts.js +82 -0
  35. package/dist/placement.js +39 -0
  36. package/dist/refusals.js +53 -0
  37. package/dist/relay.js +20 -0
  38. package/dist/resident.js +140 -51
  39. package/dist/scope.js +206 -0
  40. package/dist/secret-input.js +7 -2
  41. package/dist/service-main.js +9 -0
  42. package/dist/service.js +55 -0
  43. package/dist/shell.js +3 -0
  44. package/dist/sign-in.js +50 -31
  45. package/dist/skills.js +66 -0
  46. package/dist/transcription.js +201 -0
  47. package/dist/translate.js +2 -8
  48. package/dist/update.js +138 -0
  49. package/dist/web-wizard.js +100 -0
  50. package/dist/wire.js +22 -0
  51. package/dist/work.js +346 -0
  52. package/package.json +8 -8
  53. package/dist/blocks.js +0 -51
  54. package/dist/stream.js +0 -47
package/dist/copies.js CHANGED
@@ -2,32 +2,64 @@ import { parse } from '@agentshouse/mdmodel';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { mkdir, readFile, readdir, realpath, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
- import { HouseRefusal } from './api.js';
6
- import { blobId, blobText, git, HOUSE_REF, importCommits, revision, run, succeeded, tree, WORKING_COPIES, } from './git.js';
5
+ import { finalRefusal, HouseRefusal, retrying } from './api.js';
6
+ import { blobId, blobText, git, isText, HOUSE_REF, importCommits, localCopies, revision, run, succeeded, tree, } from './git.js';
7
7
  import { kitHome } from './home.js';
8
8
  import { operationId } from './operation.js';
9
+ import { refusalOf } from './refusals.js';
9
10
  import { changed } from './translate.js';
11
+ import { relayed } from './relay.js';
10
12
  const BRANCH = 'main';
11
- const ROOM_HOMES = ['capture', 'library', 'collaboration'];
12
13
  const PROVENANCE = '_provenance';
13
- const REPLICA_RETRY_MS = 30_000;
14
- const IDENTITY = { GIT_COMMITTER_NAME: 'House Kit', GIT_COMMITTER_EMAIL: 'house-kit@localhost' };
14
+ const IDENTITY = { GIT_COMMITTER_NAME: 'Kit', GIT_COMMITTER_EMAIL: 'house-kit@localhost' };
15
15
  function causeOf(error) {
16
16
  return error instanceof Error ? error.message : String(error);
17
17
  }
18
+ function houseCause(error) {
19
+ return error instanceof HouseRefusal ? (refusalOf(error.text)?.text ?? causeOf(error)) : causeOf(error);
20
+ }
18
21
  function logged(error) {
19
22
  process.stderr.write(`kit: ${causeOf(error)}\n`);
20
23
  }
21
- export function refusalCode(text) {
24
+ const REBASED = new Set([
25
+ 'field_base_stale',
26
+ 'section_base_stale',
27
+ 'preamble_base_stale',
28
+ 'base_revision_stale',
29
+ 'path_exists',
30
+ 'path_absent',
31
+ ]);
32
+ function rebaseFixes(refusal) {
33
+ return refusal.code === 'edit_conflict' && refusal.conflicts.some((code) => REBASED.has(code));
34
+ }
35
+ function textOnly(paths) {
36
+ const [named, them] = paths.length === 1 ? [`${paths[0]} is`, 'it'] : [`${paths.join(', ')} are`, 'them'];
37
+ return `${named} not text, and house push sends text only; remove ${them} from the commit and place ${them} with house upload`;
38
+ }
39
+ function outside(path) {
40
+ return { refused: true, text: `${path} is not a House Local copy; run house push inside one` };
41
+ }
42
+ function short(commit) {
43
+ return commit.slice(0, 12);
44
+ }
45
+ function moved(before, after) {
46
+ if (before.house !== after.house)
47
+ return [`${rootOf(after.room_handle)}: ${HOUSE_REF} is at ${short(after.house)}`];
48
+ return before.position === after.position ? [] : [`${rootOf(after.room_handle)}: read-only files refreshed`];
49
+ }
50
+ function expiredAuthority(error) {
51
+ if (!(error instanceof HouseRefusal))
52
+ return undefined;
22
53
  try {
23
- return String(JSON.parse(text).error.code);
54
+ const { error: refused } = JSON.parse(error.text);
55
+ return refused?.code === 'expired' && typeof refused.authority === 'string' ? refused.authority : undefined;
24
56
  }
25
57
  catch {
26
- return text.split(':')[0].trim();
58
+ return undefined;
27
59
  }
28
60
  }
29
- function short(commit) {
30
- return commit.slice(0, 12);
61
+ function grouped(items, size) {
62
+ return items.flatMap((_, index) => (index % size === 0 ? [items.slice(index, index + size)] : []));
31
63
  }
32
64
  function covers(path, prefix) {
33
65
  return prefix === '' || path === prefix || path.startsWith(`${prefix}/`);
@@ -37,14 +69,14 @@ function sidecar(path) {
37
69
  segments.splice(-1, 0, PROVENANCE);
38
70
  return segments.join('/');
39
71
  }
40
- function rootOf(copy) {
41
- return copy.private ? '/private' : `/rooms/${copy.room_handle}`;
72
+ function recordOf(key) {
73
+ return join(kitHome(), 'local-copies', `${key}.json`);
42
74
  }
43
- function relative(path) {
44
- return path.replace(/^\/private\//, '').replace(/^\/rooms\/[^/]+\//, '');
75
+ function rootOf(handle) {
76
+ return `/rooms/${handle}`;
45
77
  }
46
- function prefixesOf(selected) {
47
- return selected.private ? ['/private'] : ROOM_HOMES.map((home) => `${rootOf(selected)}/${home}`);
78
+ function relative(path) {
79
+ return path.replace(/^\/rooms\/[^/]+\//, '');
48
80
  }
49
81
  function declaredWritable(path, copy) {
50
82
  const deepest = (scopes) => Math.max(-1, ...scopes.filter((scope) => covers(path, scope)).map((scope) => scope.length));
@@ -101,7 +133,7 @@ async function ignoreRules(repository, writable, copy) {
101
133
  ].map(pattern);
102
134
  const granted = readOnly.filter((path) => !protectedPath(path)).map(pattern);
103
135
  await ruled(join(repository, '.gitignore'), [
104
- '# House Kit writes this file and .ignore. The paths below are read-only House',
136
+ '# Kit writes this file and .ignore. The paths below are read-only House',
105
137
  '# content: Git does not track them, and House refuses a change to them even',
106
138
  '# when this file is edited or a path is force-added.',
107
139
  '/.gitignore',
@@ -116,7 +148,7 @@ async function ignoreRules(repository, writable, copy) {
116
148
  '',
117
149
  ].join('\n'));
118
150
  await ruled(join(repository, '.ignore'), [
119
- '# House Kit writes this file so native search still reads the read-only House',
151
+ '# Kit writes this file so native search still reads the read-only House',
120
152
  '# content that .gitignore keeps out of Git.',
121
153
  `!${PROVENANCE}`,
122
154
  ...[...house, ...granted].map((rule) => `!${rule}`),
@@ -147,34 +179,35 @@ function movedPaths(moves, before, after) {
147
179
  .filter((pair) => pairs.filter((other) => other.from === pair.from || other.to === pair.to).length === 1)
148
180
  .map(({ from, to }) => [from, to]));
149
181
  }
150
- export class WorkingCopies {
182
+ export class LocalCopies {
151
183
  owner;
152
184
  house;
153
185
  copies = new Map();
154
186
  queues = new Map();
155
- delivering = new Set();
156
- selecting = Promise.resolve();
187
+ answering = Promise.resolve();
157
188
  constructor(house) {
158
189
  this.house = house;
159
190
  this.owner = {
160
191
  key: 'owner',
161
192
  submit: async (operation, changes) => {
162
193
  try {
163
- const outcome = await house.post('/kit/door/batch', { operation_id: operation, changes });
194
+ const outcome = await retrying(() => house.post('/kit/door/batch', { operation_id: operation, changes }));
164
195
  return { accepted: outcome.contents };
165
196
  }
166
197
  catch (error) {
167
- if (!(error instanceof HouseRefusal) || error.status >= 500)
198
+ if (!finalRefusal(error))
168
199
  throw error;
169
- return { refused: { code: refusalCode(error.text), text: error.message } };
200
+ return {
201
+ refused: refusalOf(error.text) ?? { code: '', text: `House refused it with ${error.status}`, conflicts: [] },
202
+ };
170
203
  }
171
204
  },
172
205
  };
173
206
  }
174
207
  async load() {
175
- const home = join(kitHome(), 'working-copies');
208
+ const home = join(kitHome(), 'local-copies');
176
209
  const names = (await readdir(join(home, 'rooms')).catch(() => [])).filter((name) => name.endsWith('.json'));
177
- for (const path of [...names.map((name) => join(home, 'rooms', name)), join(home, 'private.json')]) {
210
+ for (const path of names.map((name) => join(home, 'rooms', name))) {
178
211
  const text = await readFile(path, 'utf8').catch(() => null);
179
212
  if (text === null)
180
213
  continue;
@@ -183,47 +216,75 @@ export class WorkingCopies {
183
216
  }
184
217
  }
185
218
  async mapping() {
186
- await this.selecting;
219
+ await this.answering;
187
220
  return [...this.copies.values()]
188
- .filter((copy) => copy.active)
189
- .map((copy) => `${rootOf(copy)}: ${copy.repository}`)
221
+ .filter((copy) => copy.unfollowed === undefined)
222
+ .map((copy) => `${rootOf(copy.room_handle)}: ${copy.repository}`)
190
223
  .sort();
191
224
  }
192
- select() {
193
- const selected = this.selecting.then(() => this.reselect());
194
- this.selecting = selected.catch(() => undefined);
195
- return selected;
196
- }
197
- async reselect() {
198
- const { working_copy: chosen } = await this.house.post('/kit/working-copy/selection', {});
199
- const selected = [
200
- ...(chosen?.rooms ?? [])
201
- .filter((room) => room.room_handle !== null)
202
- .map((room) => ({ key: `rooms/${room.room_ref}`, private: false, handle: room.room_handle })),
203
- ...(chosen?.private === true ? [{ key: 'private', private: true, handle: null }] : []),
204
- ];
205
- const keys = new Set(selected.map((scope) => scope.key));
206
- const work = [];
207
- for (const key of this.copies.keys()) {
208
- if (keys.has(key))
209
- continue;
210
- work.push(this.exclusive(key, async () => {
211
- const copy = this.copies.get(key);
212
- if (copy.active)
213
- await this.save({ ...copy, active: false });
214
- }));
215
- }
216
- for (const scope of selected)
217
- work.push(this.exclusive(scope.key, () => this.apply(scope)).catch(logged));
218
- await Promise.all(work);
219
- }
220
- entries(frame) {
221
- const copy = [...this.copies.values()].find((held) => held.active && held.room_ref === frame.authority);
222
- if (copy === undefined)
223
- return;
224
- if (copy.log_epoch === frame.log_epoch && BigInt(frame.position) <= BigInt(copy.position))
225
- return;
226
- this.deliver(copy.key);
225
+ connected() {
226
+ return this.selected();
227
+ }
228
+ async changed(rooms) {
229
+ if (rooms.length > 0)
230
+ await this.selected(rooms);
231
+ }
232
+ fetch() {
233
+ return this.answered(() => this.fetched(true));
234
+ }
235
+ async selected(rooms) {
236
+ const copied = await this.answered(() => this.fetched(false, rooms));
237
+ if (copied.refused)
238
+ logged(copied.text);
239
+ }
240
+ answered(work) {
241
+ const answered = this.answering.then(work);
242
+ this.answering = answered.catch(() => undefined);
243
+ return answered;
244
+ }
245
+ async fetched(refreshing, rooms) {
246
+ const { local_copy: answer } = await this.house.post('/kit/local-copy/selection', rooms === undefined ? {} : { rooms });
247
+ const synced = new Map(answer.rooms.map((room) => [`rooms/${room.room_ref}`, room.room_handle]));
248
+ const named = rooms === undefined ? null : new Set(rooms.map((room) => `rooms/${room}`));
249
+ const held = () => [...this.copies.values()].filter((copy) => named === null || named.has(copy.key));
250
+ return this.holding([...new Set([...held().map((copy) => copy.key), ...synced.keys()])].sort(), async () => {
251
+ const said = [];
252
+ let refused = false;
253
+ const mark = refreshing ? 'said' : 'unsaid';
254
+ for (const copy of held()) {
255
+ if (synced.has(copy.key) || copy.unfollowed === 'said' || copy.unfollowed === mark)
256
+ continue;
257
+ await this.save({ ...copy, unfollowed: mark });
258
+ if (refreshing)
259
+ said.push(`${rootOf(copy.room_handle)}: no longer followed; kept as it is`);
260
+ }
261
+ const current = [];
262
+ for (const [key, handle] of synced) {
263
+ try {
264
+ const held = this.copies.get(key);
265
+ if (held === undefined || !(await exists(held.repository))) {
266
+ said.push(await this.materialize(key, handle));
267
+ continue;
268
+ }
269
+ const { unfollowed, ...followed } = held;
270
+ current.push(unfollowed === undefined && held.room_handle === handle ? held : await this.save({ ...followed, room_handle: handle }));
271
+ }
272
+ catch (error) {
273
+ refused = true;
274
+ said.push(`${rootOf(handle)}: ${causeOf(error)}`);
275
+ }
276
+ }
277
+ if (refreshing && current.length > 0) {
278
+ const failed = await this.refreshed(current).then(() => null, houseCause);
279
+ for (const copy of current)
280
+ said.push(...moved(copy, this.copies.get(copy.key)));
281
+ if (failed !== null) {
282
+ refused = true;
283
+ said.push(failed);
284
+ }
285
+ }
286
+ return { refused, text: said.length === 0 ? 'Nothing new from House.' : said.join('\n') };
287
+ });
227
288
  }
228
289
  async observed(report) {
229
290
  const key = await this.keyAt(dirname(report.common));
@@ -253,11 +314,11 @@ export class WorkingCopies {
253
314
  async push(cwd, selector, caller) {
254
315
  const located = await run(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel']);
255
316
  if (located.status !== 0)
256
- return { refused: true, text: `${cwd} is not in a Git repository` };
317
+ return outside(cwd);
257
318
  const [common, worktree] = located.stdout.toString('utf8').split('\n');
258
319
  const key = await this.keyAt(dirname(common));
259
320
  if (key === undefined)
260
- return { refused: true, text: `${worktree} is not a House working copy` };
321
+ return outside(worktree);
261
322
  return this.exclusive(key, () => this.pushed(key, worktree, selector, caller));
262
323
  }
263
324
  async keyAt(repository) {
@@ -273,19 +334,12 @@ export class WorkingCopies {
273
334
  this.queues.set(key, queued.catch(() => undefined));
274
335
  return queued;
275
336
  }
276
- deliver(key) {
277
- if (this.delivering.has(key))
278
- return;
279
- this.delivering.add(key);
280
- void this.exclusive(key, async () => {
281
- this.delivering.delete(key);
282
- const copy = this.copies.get(key);
283
- if (copy.active)
284
- await this.save(await this.caughtUp(copy));
285
- }).catch(logged);
337
+ holding(keys, work) {
338
+ const [key, ...rest] = keys;
339
+ return key === undefined ? work() : this.exclusive(key, () => this.holding(rest, work));
286
340
  }
287
341
  async save(copy) {
288
- const path = join(kitHome(), 'working-copies', copy.private ? 'private.json' : `${copy.key}.json`);
342
+ const path = recordOf(copy.key);
289
343
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
290
344
  const temporary = `${path}.${process.pid}`;
291
345
  await writeFile(temporary, JSON.stringify(copy), { mode: 0o600 });
@@ -293,12 +347,12 @@ export class WorkingCopies {
293
347
  this.copies.set(copy.key, copy);
294
348
  return copy;
295
349
  }
296
- async read(selected) {
297
- const prefixes = prefixesOf(selected);
350
+ async read(handle) {
351
+ const prefixes = [rootOf(handle)];
298
352
  const discovery = await this.house.post('/kit/door/discover', { prefixes });
299
353
  let bundle = await this.house.post('/kit/door/bootstrap', { prefixes });
300
354
  if ('transfer' in bundle)
301
- bundle = (await (await fetch(bundle.transfer.url)).json());
355
+ bundle = (await (await fetch(relayed(bundle.transfer.url))).json());
302
356
  const [view] = bundle.scopes;
303
357
  if (view === undefined)
304
358
  return null;
@@ -328,46 +382,13 @@ export class WorkingCopies {
328
382
  }
329
383
  return read;
330
384
  }
331
- async apply(scope) {
332
- let copy = this.copies.get(scope.key);
333
- if (copy === undefined || !(await exists(copy.repository))) {
334
- await this.materialize(scope);
335
- return;
336
- }
337
- if (copy.room_handle !== scope.handle)
338
- copy = await this.save({ ...copy, room_handle: scope.handle });
339
- if (!copy.active) {
340
- await this.save(await this.received({ ...copy, active: true }));
341
- return;
342
- }
343
- const discovery = await this.house.post('/kit/door/discover', {
344
- prefixes: prefixesOf(copy),
345
- });
346
- const authority = discovery.authorities.find((entry) => entry.room_ref === copy.room_ref);
347
- if (authority === undefined)
348
- return;
349
- if (authority.managed_replication !== 'admitted') {
350
- logged(`operation_denied: ${rootOf(copy)} is not synchronized to this Environment`);
351
- return;
352
- }
353
- if (JSON.stringify([authority.operations.write, authority.protected_paths]) !==
354
- JSON.stringify([copy.write, copy.protected])) {
355
- await this.save(await this.received(copy));
356
- return;
357
- }
358
- await this.save(await this.caughtUp(copy));
359
- }
360
- async materialize(scope) {
361
- const read = await this.read({ private: scope.private, room_handle: scope.handle });
362
- if (read === null) {
363
- logged(`House serves nothing of ${rootOf({ private: scope.private, room_handle: scope.handle })} to copy`);
364
- return;
365
- }
366
- const repository = scope.private ? join(WORKING_COPIES, 'private') : join(WORKING_COPIES, 'rooms', read.anchor);
367
- if (await exists(repository)) {
368
- logged(`${repository} already exists and House Kit leaves it as it is`);
369
- return;
370
- }
385
+ async materialize(key, handle) {
386
+ const read = await this.read(handle);
387
+ if (read === null)
388
+ throw new Error('House serves nothing of it to copy');
389
+ const repository = join(localCopies(), 'rooms', read.anchor);
390
+ if (await exists(repository))
391
+ throw new Error(`${repository} already exists and Kit leaves it as it is`);
371
392
  const staging = join(dirname(repository), `.${randomUUID()}`);
372
393
  await mkdir(staging, { recursive: true });
373
394
  let house;
@@ -375,7 +396,7 @@ export class WorkingCopies {
375
396
  try {
376
397
  await git(staging, ['init', '-q', '-b', BRANCH]);
377
398
  house = await importCommits(staging, [
378
- { message: `House ${rootOf({ private: scope.private, room_handle: scope.handle })}`, from: null, files: read.writable },
399
+ { message: `House ${rootOf(handle)}`, from: null, files: read.writable },
379
400
  ]);
380
401
  await git(staging, ['update-ref', `refs/heads/${BRANCH}`, house]);
381
402
  await git(staging, ['reset', '-q', '--hard']);
@@ -388,12 +409,10 @@ export class WorkingCopies {
388
409
  throw error;
389
410
  }
390
411
  await this.save({
391
- key: scope.key,
392
- private: scope.private,
412
+ key,
393
413
  room_ref: read.anchor,
394
- room_handle: scope.handle,
414
+ room_handle: handle,
395
415
  repository,
396
- active: true,
397
416
  house,
398
417
  position: read.position,
399
418
  log_epoch: read.log_epoch,
@@ -403,13 +422,14 @@ export class WorkingCopies {
403
422
  manifests: { [house]: { parent: null, files: read.revisions } },
404
423
  moves: [],
405
424
  });
425
+ return `${rootOf(handle)}: copied to ${repository}`;
406
426
  }
407
427
  async received(copy) {
408
- const read = await this.read(copy);
428
+ const read = await this.read(copy.room_handle);
409
429
  if (read === null)
410
430
  return copy;
411
431
  const house = await importCommits(copy.repository, [
412
- { message: `House ${rootOf(copy)} at position ${read.position}`, from: copy.house, replaces: true, files: read.writable },
432
+ { message: `House ${rootOf(copy.room_handle)} at position ${read.position}`, from: copy.house, replaces: true, files: read.writable },
413
433
  ]);
414
434
  const removed = copy.read_only.filter((path) => !read.readOnly.has(path));
415
435
  const readOnly = await readOnlyPlaced(copy.repository, copy.read_only, read.readOnly, removed);
@@ -426,30 +446,57 @@ export class WorkingCopies {
426
446
  await ignoreRules(copy.repository, [...read.writable.keys()], next);
427
447
  return next;
428
448
  }
429
- async caughtUp(start) {
430
- let copy = start;
431
- for (;;) {
449
+ async refreshed(copies) {
450
+ const discover = (group) => this.house.post('/kit/door/discover', { prefixes: group.map((copy) => rootOf(copy.room_handle)) });
451
+ const [first, ...rest] = copies;
452
+ const { bounds, authorities } = await discover([first]);
453
+ for (const group of grouped(rest, bounds.max_prefixes))
454
+ authorities.push(...(await discover(group)).authorities);
455
+ const unchanged = [];
456
+ for (const copy of copies) {
457
+ const authority = authorities.find((entry) => entry.room_ref === copy.room_ref);
458
+ if (authority !== undefined &&
459
+ JSON.stringify([authority.operations.write, authority.protected_paths]) !== JSON.stringify([copy.write, copy.protected])) {
460
+ await this.save(await this.received(copy));
461
+ }
462
+ else {
463
+ unchanged.push(copy);
464
+ }
465
+ }
466
+ for (const group of grouped(unchanged, bounds.max_prefixes))
467
+ await this.caughtUp(group, false);
468
+ }
469
+ async caughtUp(copies, whole) {
470
+ const caught = new Map(copies.map((copy) => [copy.key, copy]));
471
+ let asking = [...copies];
472
+ while (asking.length > 0) {
432
473
  let answer;
433
474
  try {
434
475
  answer = await this.house.post('/kit/door/enumerate', {
435
- prefixes: prefixesOf(copy),
436
- positions: [{ authority: copy.room_ref, position: copy.position, log_epoch: copy.log_epoch }],
476
+ prefixes: asking.map((copy) => rootOf(copy.room_handle)),
477
+ positions: asking.map((copy) => ({ authority: copy.room_ref, position: copy.position, log_epoch: copy.log_epoch })),
437
478
  });
438
479
  }
439
480
  catch (error) {
440
- const code = error instanceof HouseRefusal ? refusalCode(error.text) : null;
441
- if (code === 'position_expired')
442
- return this.received(copy);
443
- if (code !== 'replica_behind')
481
+ const expired = asking.find((copy) => copy.room_ref === expiredAuthority(error));
482
+ if (expired === undefined)
444
483
  throw error;
445
- setTimeout(() => this.deliver(copy.key), REPLICA_RETRY_MS).unref();
446
- return copy;
484
+ caught.set(expired.key, await this.save(await this.received(expired)));
485
+ asking = asking.filter((copy) => copy !== expired);
486
+ continue;
447
487
  }
448
- const view = answer.authorities.find((entry) => entry.authority === copy.room_ref);
449
- if (view === undefined || (view.position === copy.position && view.log_epoch === copy.log_epoch))
450
- return copy;
451
- copy = await this.save(await this.incorporated(copy, view.changes, view.position, view.log_epoch));
488
+ const advanced = [];
489
+ for (const copy of asking) {
490
+ const view = answer.authorities.find((entry) => entry.authority === copy.room_ref);
491
+ if (view === undefined || (view.position === copy.position && view.log_epoch === copy.log_epoch))
492
+ continue;
493
+ const next = await this.save(await this.incorporated(copy, view.changes, view.position, view.log_epoch));
494
+ caught.set(copy.key, next);
495
+ advanced.push(next);
496
+ }
497
+ asking = whole ? advanced : [];
452
498
  }
499
+ return copies.map((copy) => caught.get(copy.key));
453
500
  }
454
501
  async incorporated(copy, changes, position, logEpoch) {
455
502
  const houseTree = await tree(copy.repository, copy.house);
@@ -482,7 +529,7 @@ export class WorkingCopies {
482
529
  let { house, manifests } = copy;
483
530
  if (files.size > 0) {
484
531
  house = await importCommits(copy.repository, [
485
- { message: `House ${rootOf(copy)} at position ${position}`, from: copy.house, files },
532
+ { message: `House ${rootOf(copy.room_handle)} at position ${position}`, from: copy.house, files },
486
533
  ]);
487
534
  manifests = { ...manifests, [house]: { parent: copy.house, files: revisions } };
488
535
  }
@@ -512,8 +559,6 @@ export class WorkingCopies {
512
559
  }
513
560
  copy = await this.save(without(copy));
514
561
  }
515
- if (!copy.active)
516
- return { refused: true, text: `${rootOf(copy)} is no longer kept in this Environment` };
517
562
  const listed = (await git(copy.repository, ['rev-list', '--topo-order', commit])).split('\n');
518
563
  const base = listed.find((ancestor) => ancestor in copy.manifests);
519
564
  if (base === undefined) {
@@ -524,7 +569,7 @@ export class WorkingCopies {
524
569
  changes = await this.derived(copy, base, commit);
525
570
  }
526
571
  catch (error) {
527
- return { refused: true, text: causeOf(error) };
572
+ return { refused: true, text: houseCause(error) };
528
573
  }
529
574
  if (changes.length === 0)
530
575
  return { refused: false, text: `Nothing to submit: ${short(commit)} matches House.` };
@@ -546,12 +591,23 @@ export class WorkingCopies {
546
591
  return this.settle(submitted, caller);
547
592
  }
548
593
  async derived(copy, base, commit) {
549
- const root = rootOf(copy);
594
+ const root = rootOf(copy.room_handle);
550
595
  const before = await tree(copy.repository, base);
551
596
  const after = await tree(copy.repository, commit);
552
597
  const moved = movedPaths(copy.moves, before, after);
553
598
  const arrived = new Set(moved.values());
554
- const text = (entry, path) => blobText(copy.repository, entry, path);
599
+ const arriving = [...after].filter(([path, entry]) => before.get(path) !== entry);
600
+ if (arriving.length > 0) {
601
+ const { bounds } = await this.house.post('/kit/door/discover', { prefixes: [root] });
602
+ const binary = [];
603
+ for (const [path, entry] of arriving) {
604
+ if (!(await isText(copy.repository, entry, bounds.max_authored_file_bytes)))
605
+ binary.push(path);
606
+ }
607
+ if (binary.length > 0)
608
+ throw new Error(textOnly(binary));
609
+ }
610
+ const text = (entry) => blobText(copy.repository, entry);
555
611
  const changes = [];
556
612
  for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
557
613
  if (arrived.has(path))
@@ -564,7 +620,7 @@ export class WorkingCopies {
564
620
  changes.push({ op: 'rename', path: `${root}/${path}`, to: `${root}/${to}`, base: known });
565
621
  const moving = after.get(to);
566
622
  if (moving !== prior) {
567
- changes.push({ op: 'replace', path: `${root}/${to}`, base: known, content: await text(moving, to) });
623
+ changes.push({ op: 'replace', path: `${root}/${to}`, base: known, content: await text(moving) });
568
624
  }
569
625
  continue;
570
626
  }
@@ -573,18 +629,17 @@ export class WorkingCopies {
573
629
  if (next === undefined)
574
630
  changes.push({ op: 'remove', path: `${root}/${path}`, base: known });
575
631
  else if (prior === undefined)
576
- changes.push({ op: 'create', path: `${root}/${path}`, content: await text(next, path) });
632
+ changes.push({ op: 'create', path: `${root}/${path}`, content: await text(next) });
577
633
  else
578
- changes.push(...changed(`${root}/${path}`, path, known, await text(prior, path), await text(next, path)));
634
+ changes.push(...changed(`${root}/${path}`, path, known, await text(prior), await text(next)));
579
635
  }
580
636
  return changes;
581
637
  }
582
638
  async settle(start, caller, restaged = false) {
583
639
  let copy = start;
584
640
  const push = copy.push;
585
- if (push.caller !== caller.key) {
586
- return this.recovered(copy, `${short(push.commit)} was pushed by another caller and awaited its outcome`);
587
- }
641
+ if (push.caller !== caller.key)
642
+ return this.recovered(copy);
588
643
  let outcome;
589
644
  try {
590
645
  outcome = await caller.submit(push.operation_id, push.changes, push.upload, async (upload) => {
@@ -592,9 +647,10 @@ export class WorkingCopies {
592
647
  });
593
648
  }
594
649
  catch (error) {
650
+ const refusal = error instanceof HouseRefusal ? refusalOf(error.text) : null;
595
651
  return {
596
652
  refused: true,
597
- text: `House's answer to ${short(push.commit)} did not arrive (${causeOf(error)}); run house git push again to recover its outcome.`,
653
+ text: refusal?.text ?? `House's answer to ${short(push.commit)} did not arrive; run house push again`,
598
654
  };
599
655
  }
600
656
  if ('refused' in outcome) {
@@ -602,32 +658,40 @@ export class WorkingCopies {
602
658
  const { upload, ...unstaged } = copy.push;
603
659
  return this.settle(await this.save({ ...copy, push: unstaged }), caller, true);
604
660
  }
605
- if (outcome.refused.code === 'operation_expired')
606
- return this.recovered(copy, outcome.refused.text);
661
+ if (outcome.refused.code === 'expired')
662
+ return this.recovered(copy);
607
663
  let settled = await this.save(without(copy));
664
+ if (settled.unfollowed !== undefined)
665
+ return { refused: true, text: outcome.refused.text };
666
+ const rebased = rebaseFixes(outcome.refused);
608
667
  try {
609
- settled = await this.save(await this.caughtUp(settled));
668
+ settled = (await this.caughtUp([settled], true))[0];
610
669
  }
611
- catch (error) {
612
- return { refused: true, text: `${outcome.refused.text}\n${HOUSE_REF} is unchanged: ${causeOf(error)}` };
670
+ catch {
671
+ return {
672
+ refused: true,
673
+ text: rebased ? `${outcome.refused.text}\nHouse's current state did not arrive; push again later` : outcome.refused.text,
674
+ };
613
675
  }
614
676
  return {
615
677
  refused: true,
616
- text: `${outcome.refused.text}\n${HOUSE_REF} holds House's current state; merge or rebase onto it, commit and push again.`,
678
+ text: rebased
679
+ ? `${outcome.refused.text}\n${HOUSE_REF} holds House's current state; rebase onto it and push again`
680
+ : outcome.refused.text,
617
681
  };
618
682
  }
619
683
  return this.finished(await this.save({ ...copy, push: { ...copy.push, state: 'accepted', written: outcome.accepted } }));
620
684
  }
621
- async recovered(copy, reason) {
685
+ async recovered(copy) {
622
686
  const push = copy.push;
623
- const current = await this.save(await this.caughtUp(copy));
687
+ const current = (await this.caughtUp([copy], true))[0];
624
688
  const house = await tree(copy.repository, current.house);
625
- const root = rootOf(current);
689
+ const root = rootOf(current.room_handle);
626
690
  if (!(await this.landed(copy.repository, push.changes, house, await tree(copy.repository, push.commit)))) {
627
691
  await this.save(without(current));
628
692
  return {
629
693
  refused: true,
630
- text: `${reason}; House does not hold ${short(push.commit)}; run house git push again to submit it as a new operation.`,
694
+ text: `House does not hold ${short(push.commit)}; run house push again`,
631
695
  };
632
696
  }
633
697
  const written = [];
@@ -636,7 +700,7 @@ export class WorkingCopies {
636
700
  written.push({
637
701
  path: `${root}/${path}`,
638
702
  revision: entry === undefined ? null : (revisionAt(current, current.house, path) ?? null),
639
- content: entry === undefined ? null : await blobText(copy.repository, entry, path),
703
+ content: entry === undefined ? null : await blobText(copy.repository, entry),
640
704
  });
641
705
  }
642
706
  return this.finished(await this.save({ ...current, push: { ...push, state: 'accepted', written } }));
@@ -652,10 +716,10 @@ export class WorkingCopies {
652
716
  await this.save(without(copy));
653
717
  return { refused: false, text: integrated.text };
654
718
  }
655
- catch (error) {
719
+ catch {
656
720
  return {
657
721
  refused: false,
658
- text: `House accepted ${short(commit)}; local integration is pending (${causeOf(error)}); run house git push again to finish it.`,
722
+ text: `House accepted ${short(commit)}; run house push again to finish integrating it here`,
659
723
  };
660
724
  }
661
725
  }
@@ -664,7 +728,7 @@ export class WorkingCopies {
664
728
  const push = copy.push;
665
729
  let written = push.written;
666
730
  if (push.changes.some((change) => change.op === 'rename')) {
667
- copy = await this.save(await this.caughtUp(copy));
731
+ copy = (await this.caughtUp([copy], true))[0];
668
732
  const house = await tree(copy.repository, copy.house);
669
733
  const sent = await tree(copy.repository, push.house);
670
734
  if (push.changes.some((change) => change.op === 'rename' && !house.has(relative(change.to)))) {
@@ -678,9 +742,9 @@ export class WorkingCopies {
678
742
  written = [
679
743
  ...written,
680
744
  {
681
- path: `${rootOf(copy)}/${path}`,
745
+ path: `${rootOf(copy.room_handle)}/${path}`,
682
746
  revision: entry === undefined ? null : (revisionAt(copy, copy.house, path) ?? null),
683
- content: entry === undefined ? null : await blobText(copy.repository, entry, path),
747
+ content: entry === undefined ? null : await blobText(copy.repository, entry),
684
748
  },
685
749
  ];
686
750
  }
@@ -698,7 +762,7 @@ export class WorkingCopies {
698
762
  }
699
763
  const edits = new Map([...files].filter(([path, content]) => (content === null ? selected.has(path) : selected.get(path) !== blobId(content))));
700
764
  const received = [...files.keys()].filter((path) => revisionAt(copy, copy.house, path) === revisionAt(copy, push.house, path));
701
- const accepted = `House ${rootOf(copy)} with ${push.commit} accepted`;
765
+ const accepted = `House ${rootOf(copy.room_handle)} with ${push.commit} accepted`;
702
766
  const house = await importCommits(copy.repository, [
703
767
  ...(edits.size === 0 ? [] : [{ message: accepted, from: push.commit, files: edits }]),
704
768
  {
@@ -725,7 +789,7 @@ export class WorkingCopies {
725
789
  async landed(repository, changes, house, selected) {
726
790
  const text = async (path) => {
727
791
  const entry = house.get(relative(path));
728
- return entry === undefined ? undefined : blobText(repository, entry, path);
792
+ return entry === undefined ? undefined : blobText(repository, entry);
729
793
  };
730
794
  for (const change of changes) {
731
795
  const current = await text(change.path);