@agentshouse/kit 0.2.1-cb6679d048e60 → 0.2.1-cc988e43abeeb

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/dist/copies.js CHANGED
@@ -2,15 +2,15 @@ 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';
5
+ import { finalRefusal, HouseRefusal, retrying } from './api.js';
6
6
  import { blobId, blobText, git, 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
9
  import { refusalOf } from './refusals.js';
10
10
  import { changed } from './translate.js';
11
+ import { relayed } from './relay.js';
11
12
  const BRANCH = 'main';
12
13
  const PROVENANCE = '_provenance';
13
- const REPLICA_RETRY_MS = 30_000;
14
14
  const IDENTITY = { GIT_COMMITTER_NAME: 'House Kit', GIT_COMMITTER_EMAIL: 'house-kit@localhost' };
15
15
  function causeOf(error) {
16
16
  return error instanceof Error ? error.message : String(error);
@@ -32,6 +32,22 @@ function rebaseFixes(refusal) {
32
32
  function short(commit) {
33
33
  return commit.slice(0, 12);
34
34
  }
35
+ function moved(before, after) {
36
+ if (before.house !== after.house)
37
+ return [`${rootOf(after.room_handle)}: ${HOUSE_REF} is at ${short(after.house)}`];
38
+ return before.position === after.position ? [] : [`${rootOf(after.room_handle)}: read-only files refreshed`];
39
+ }
40
+ function expiredAuthority(error) {
41
+ if (!(error instanceof HouseRefusal))
42
+ return undefined;
43
+ try {
44
+ const { error: refused } = JSON.parse(error.text);
45
+ return refused?.code === 'expired' && typeof refused.authority === 'string' ? refused.authority : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
35
51
  function covers(path, prefix) {
36
52
  return prefix === '' || path === prefix || path.startsWith(`${prefix}/`);
37
53
  }
@@ -40,11 +56,11 @@ function sidecar(path) {
40
56
  segments.splice(-1, 0, PROVENANCE);
41
57
  return segments.join('/');
42
58
  }
43
- function rootOf(copy) {
44
- return copy.private ? '/private' : `/rooms/${copy.room_handle}`;
59
+ function rootOf(handle) {
60
+ return `/rooms/${handle}`;
45
61
  }
46
62
  function relative(path) {
47
- return path.replace(/^\/private\//, '').replace(/^\/rooms\/[^/]+\//, '');
63
+ return path.replace(/^\/rooms\/[^/]+\//, '');
48
64
  }
49
65
  function declaredWritable(path, copy) {
50
66
  const deepest = (scopes) => Math.max(-1, ...scopes.filter((scope) => covers(path, scope)).map((scope) => scope.length));
@@ -152,19 +168,18 @@ export class LocalCopies {
152
168
  house;
153
169
  copies = new Map();
154
170
  queues = new Map();
155
- delivering = new Set();
156
- selecting = Promise.resolve();
171
+ answering = Promise.resolve();
157
172
  constructor(house) {
158
173
  this.house = house;
159
174
  this.owner = {
160
175
  key: 'owner',
161
176
  submit: async (operation, changes) => {
162
177
  try {
163
- const outcome = await house.post('/kit/door/batch', { operation_id: operation, changes });
178
+ const outcome = await retrying(() => house.post('/kit/door/batch', { operation_id: operation, changes }));
164
179
  return { accepted: outcome.contents };
165
180
  }
166
181
  catch (error) {
167
- if (!(error instanceof HouseRefusal) || error.status >= 500)
182
+ if (!finalRefusal(error))
168
183
  throw error;
169
184
  return {
170
185
  refused: refusalOf(error.text) ?? { code: '', text: `House refused it with ${error.status}`, conflicts: [] },
@@ -176,7 +191,7 @@ export class LocalCopies {
176
191
  async load() {
177
192
  const home = join(kitHome(), 'local-copies');
178
193
  const names = (await readdir(join(home, 'rooms')).catch(() => [])).filter((name) => name.endsWith('.json'));
179
- for (const path of [...names.map((name) => join(home, 'rooms', name)), join(home, 'private.json')]) {
194
+ for (const path of names.map((name) => join(home, 'rooms', name))) {
180
195
  const text = await readFile(path, 'utf8').catch(() => null);
181
196
  if (text === null)
182
197
  continue;
@@ -185,47 +200,68 @@ export class LocalCopies {
185
200
  }
186
201
  }
187
202
  async mapping() {
188
- await this.selecting;
203
+ await this.answering;
189
204
  return [...this.copies.values()]
190
205
  .filter((copy) => copy.active)
191
- .map((copy) => `${rootOf(copy)}: ${copy.repository}`)
206
+ .map((copy) => `${rootOf(copy.room_handle)}: ${copy.repository}`)
192
207
  .sort();
193
208
  }
194
- select() {
195
- const selected = this.selecting.then(() => this.reselect());
196
- this.selecting = selected.catch(() => undefined);
197
- return selected;
198
- }
199
- async reselect() {
200
- const { local_copy: chosen } = await this.house.post('/kit/local-copy/selection', {});
201
- const selected = [
202
- ...(chosen?.rooms ?? [])
203
- .filter((room) => room.room_handle !== null)
204
- .map((room) => ({ key: `rooms/${room.room_ref}`, private: false, handle: room.room_handle })),
205
- ...(chosen?.private === true ? [{ key: 'private', private: true, handle: null }] : []),
206
- ];
207
- const keys = new Set(selected.map((scope) => scope.key));
208
- const work = [];
209
- for (const key of this.copies.keys()) {
210
- if (keys.has(key))
211
- continue;
212
- work.push(this.exclusive(key, async () => {
213
- const copy = this.copies.get(key);
214
- if (copy.active)
215
- await this.save({ ...copy, active: false });
216
- }));
217
- }
218
- for (const scope of selected)
219
- work.push(this.exclusive(scope.key, () => this.apply(scope)).catch(logged));
220
- await Promise.all(work);
209
+ async connected() {
210
+ const copied = await this.answered(() => this.fetched(false));
211
+ if (copied.refused)
212
+ logged(copied.text);
221
213
  }
222
- entries(frame) {
223
- const copy = [...this.copies.values()].find((held) => held.active && held.room_ref === frame.authority);
224
- if (copy === undefined)
225
- return;
226
- if (copy.log_epoch === frame.log_epoch && BigInt(frame.position) <= BigInt(copy.position))
227
- return;
228
- this.deliver(copy.key);
214
+ fetch() {
215
+ return this.answered(() => this.fetched(true));
216
+ }
217
+ answered(work) {
218
+ const answered = this.answering.then(work);
219
+ this.answering = answered.catch(() => undefined);
220
+ return answered;
221
+ }
222
+ async fetched(refreshing) {
223
+ const { local_copy: answer } = await this.house.post('/kit/local-copy/selection', {});
224
+ const synced = new Map(answer.rooms.map((room) => [`rooms/${room.room_ref}`, room.room_handle]));
225
+ return this.holding([...new Set([...this.copies.keys(), ...synced.keys()])].sort(), async () => {
226
+ const said = [];
227
+ let refused = false;
228
+ for (const copy of this.copies.values()) {
229
+ if (synced.has(copy.key) || !copy.active)
230
+ continue;
231
+ await this.save({ ...copy, active: false });
232
+ said.push(`${rootOf(copy.room_handle)}: no longer synced to this Environment; its files stay`);
233
+ }
234
+ const current = [];
235
+ for (const [key, handle] of synced) {
236
+ try {
237
+ const held = this.copies.get(key);
238
+ if (held === undefined || !(await exists(held.repository))) {
239
+ said.push(await this.materialize(key, handle));
240
+ continue;
241
+ }
242
+ const copy = held.room_handle === handle ? held : await this.save({ ...held, room_handle: handle });
243
+ if (copy.active)
244
+ current.push(copy);
245
+ else
246
+ said.push(...moved(copy, await this.save(await this.received({ ...copy, active: true }))));
247
+ }
248
+ catch (error) {
249
+ refused = true;
250
+ said.push(`${rootOf(handle)}: ${causeOf(error)}`);
251
+ }
252
+ }
253
+ if (refreshing && current.length > 0) {
254
+ try {
255
+ const refreshed = await this.refreshed(current);
256
+ current.forEach((copy, index) => said.push(...moved(copy, refreshed[index])));
257
+ }
258
+ catch (error) {
259
+ refused = true;
260
+ said.push(error instanceof HouseRefusal ? (refusalOf(error.text)?.text ?? causeOf(error)) : causeOf(error));
261
+ }
262
+ }
263
+ return { refused, text: said.length === 0 ? 'Nothing new from House.' : said.join('\n') };
264
+ });
229
265
  }
230
266
  async observed(report) {
231
267
  const key = await this.keyAt(dirname(report.common));
@@ -275,19 +311,12 @@ export class LocalCopies {
275
311
  this.queues.set(key, queued.catch(() => undefined));
276
312
  return queued;
277
313
  }
278
- deliver(key) {
279
- if (this.delivering.has(key))
280
- return;
281
- this.delivering.add(key);
282
- void this.exclusive(key, async () => {
283
- this.delivering.delete(key);
284
- const copy = this.copies.get(key);
285
- if (copy.active)
286
- await this.save(await this.caughtUp(copy));
287
- }).catch(logged);
314
+ holding(keys, work) {
315
+ const [key, ...rest] = keys;
316
+ return key === undefined ? work() : this.exclusive(key, () => this.holding(rest, work));
288
317
  }
289
318
  async save(copy) {
290
- const path = join(kitHome(), 'local-copies', copy.private ? 'private.json' : `${copy.key}.json`);
319
+ const path = join(kitHome(), 'local-copies', `${copy.key}.json`);
291
320
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
292
321
  const temporary = `${path}.${process.pid}`;
293
322
  await writeFile(temporary, JSON.stringify(copy), { mode: 0o600 });
@@ -295,12 +324,12 @@ export class LocalCopies {
295
324
  this.copies.set(copy.key, copy);
296
325
  return copy;
297
326
  }
298
- async read(selected) {
299
- const prefixes = [rootOf(selected)];
327
+ async read(handle) {
328
+ const prefixes = [rootOf(handle)];
300
329
  const discovery = await this.house.post('/kit/door/discover', { prefixes });
301
330
  let bundle = await this.house.post('/kit/door/bootstrap', { prefixes });
302
331
  if ('transfer' in bundle)
303
- bundle = (await (await fetch(bundle.transfer.url)).json());
332
+ bundle = (await (await fetch(relayed(bundle.transfer.url))).json());
304
333
  const [view] = bundle.scopes;
305
334
  if (view === undefined)
306
335
  return null;
@@ -330,46 +359,13 @@ export class LocalCopies {
330
359
  }
331
360
  return read;
332
361
  }
333
- async apply(scope) {
334
- let copy = this.copies.get(scope.key);
335
- if (copy === undefined || !(await exists(copy.repository))) {
336
- await this.materialize(scope);
337
- return;
338
- }
339
- if (copy.room_handle !== scope.handle)
340
- copy = await this.save({ ...copy, room_handle: scope.handle });
341
- if (!copy.active) {
342
- await this.save(await this.received({ ...copy, active: true }));
343
- return;
344
- }
345
- const discovery = await this.house.post('/kit/door/discover', {
346
- prefixes: [rootOf(copy)],
347
- });
348
- const authority = discovery.authorities.find((entry) => entry.room_ref === copy.room_ref);
349
- if (authority === undefined)
350
- return;
351
- if (authority.managed_replication !== 'admitted') {
352
- logged(`operation_denied: ${rootOf(copy)} is not synchronized to this Environment`);
353
- return;
354
- }
355
- if (JSON.stringify([authority.operations.write, authority.protected_paths]) !==
356
- JSON.stringify([copy.write, copy.protected])) {
357
- await this.save(await this.received(copy));
358
- return;
359
- }
360
- await this.save(await this.caughtUp(copy));
361
- }
362
- async materialize(scope) {
363
- const read = await this.read({ private: scope.private, room_handle: scope.handle });
364
- if (read === null) {
365
- logged(`House serves nothing of ${rootOf({ private: scope.private, room_handle: scope.handle })} to copy`);
366
- return;
367
- }
368
- const repository = scope.private ? join(localCopies(), 'private') : join(localCopies(), 'rooms', read.anchor);
369
- if (await exists(repository)) {
370
- logged(`${repository} already exists and House Kit leaves it as it is`);
371
- return;
372
- }
362
+ async materialize(key, handle) {
363
+ const read = await this.read(handle);
364
+ if (read === null)
365
+ throw new Error('House serves nothing of it to copy');
366
+ const repository = join(localCopies(), 'rooms', read.anchor);
367
+ if (await exists(repository))
368
+ throw new Error(`${repository} already exists and House Kit leaves it as it is`);
373
369
  const staging = join(dirname(repository), `.${randomUUID()}`);
374
370
  await mkdir(staging, { recursive: true });
375
371
  let house;
@@ -377,7 +373,7 @@ export class LocalCopies {
377
373
  try {
378
374
  await git(staging, ['init', '-q', '-b', BRANCH]);
379
375
  house = await importCommits(staging, [
380
- { message: `House ${rootOf({ private: scope.private, room_handle: scope.handle })}`, from: null, files: read.writable },
376
+ { message: `House ${rootOf(handle)}`, from: null, files: read.writable },
381
377
  ]);
382
378
  await git(staging, ['update-ref', `refs/heads/${BRANCH}`, house]);
383
379
  await git(staging, ['reset', '-q', '--hard']);
@@ -390,10 +386,9 @@ export class LocalCopies {
390
386
  throw error;
391
387
  }
392
388
  await this.save({
393
- key: scope.key,
394
- private: scope.private,
389
+ key,
395
390
  room_ref: read.anchor,
396
- room_handle: scope.handle,
391
+ room_handle: handle,
397
392
  repository,
398
393
  active: true,
399
394
  house,
@@ -405,13 +400,14 @@ export class LocalCopies {
405
400
  manifests: { [house]: { parent: null, files: read.revisions } },
406
401
  moves: [],
407
402
  });
403
+ return `${rootOf(handle)}: copied to ${repository}`;
408
404
  }
409
405
  async received(copy) {
410
- const read = await this.read(copy);
406
+ const read = await this.read(copy.room_handle);
411
407
  if (read === null)
412
408
  return copy;
413
409
  const house = await importCommits(copy.repository, [
414
- { message: `House ${rootOf(copy)} at position ${read.position}`, from: copy.house, replaces: true, files: read.writable },
410
+ { message: `House ${rootOf(copy.room_handle)} at position ${read.position}`, from: copy.house, replaces: true, files: read.writable },
415
411
  ]);
416
412
  const removed = copy.read_only.filter((path) => !read.readOnly.has(path));
417
413
  const readOnly = await readOnlyPlaced(copy.repository, copy.read_only, read.readOnly, removed);
@@ -428,30 +424,52 @@ export class LocalCopies {
428
424
  await ignoreRules(copy.repository, [...read.writable.keys()], next);
429
425
  return next;
430
426
  }
431
- async caughtUp(start) {
432
- let copy = start;
433
- for (;;) {
427
+ async refreshed(copies) {
428
+ const discovery = await this.house.post('/kit/door/discover', {
429
+ prefixes: copies.map((copy) => rootOf(copy.room_handle)),
430
+ });
431
+ const regranted = new Map();
432
+ for (const copy of copies) {
433
+ const authority = discovery.authorities.find((entry) => entry.room_ref === copy.room_ref);
434
+ if (authority !== undefined &&
435
+ JSON.stringify([authority.operations.write, authority.protected_paths]) !== JSON.stringify([copy.write, copy.protected])) {
436
+ regranted.set(copy.key, await this.save(await this.received(copy)));
437
+ }
438
+ }
439
+ const caught = await this.caughtUp(copies.filter((copy) => !regranted.has(copy.key)), false);
440
+ return copies.map((copy) => regranted.get(copy.key) ?? caught.find((held) => held.key === copy.key));
441
+ }
442
+ async caughtUp(copies, whole) {
443
+ const caught = new Map(copies.map((copy) => [copy.key, copy]));
444
+ let asking = [...copies];
445
+ while (asking.length > 0) {
434
446
  let answer;
435
447
  try {
436
448
  answer = await this.house.post('/kit/door/enumerate', {
437
- prefixes: [rootOf(copy)],
438
- positions: [{ authority: copy.room_ref, position: copy.position, log_epoch: copy.log_epoch }],
449
+ prefixes: asking.map((copy) => rootOf(copy.room_handle)),
450
+ positions: asking.map((copy) => ({ authority: copy.room_ref, position: copy.position, log_epoch: copy.log_epoch })),
439
451
  });
440
452
  }
441
453
  catch (error) {
442
- const code = error instanceof HouseRefusal ? (refusalOf(error.text)?.code ?? null) : null;
443
- if (code === 'position_expired')
444
- return this.received(copy);
445
- if (code !== 'replica_behind')
454
+ const expired = asking.find((copy) => copy.room_ref === expiredAuthority(error));
455
+ if (expired === undefined)
446
456
  throw error;
447
- setTimeout(() => this.deliver(copy.key), REPLICA_RETRY_MS).unref();
448
- return copy;
457
+ caught.set(expired.key, await this.save(await this.received(expired)));
458
+ asking = asking.filter((copy) => copy !== expired);
459
+ continue;
460
+ }
461
+ const advanced = [];
462
+ for (const copy of asking) {
463
+ const view = answer.authorities.find((entry) => entry.authority === copy.room_ref);
464
+ if (view === undefined || (view.position === copy.position && view.log_epoch === copy.log_epoch))
465
+ continue;
466
+ const next = await this.save(await this.incorporated(copy, view.changes, view.position, view.log_epoch));
467
+ caught.set(copy.key, next);
468
+ advanced.push(next);
449
469
  }
450
- const view = answer.authorities.find((entry) => entry.authority === copy.room_ref);
451
- if (view === undefined || (view.position === copy.position && view.log_epoch === copy.log_epoch))
452
- return copy;
453
- copy = await this.save(await this.incorporated(copy, view.changes, view.position, view.log_epoch));
470
+ asking = whole ? advanced : [];
454
471
  }
472
+ return copies.map((copy) => caught.get(copy.key));
455
473
  }
456
474
  async incorporated(copy, changes, position, logEpoch) {
457
475
  const houseTree = await tree(copy.repository, copy.house);
@@ -484,7 +502,7 @@ export class LocalCopies {
484
502
  let { house, manifests } = copy;
485
503
  if (files.size > 0) {
486
504
  house = await importCommits(copy.repository, [
487
- { message: `House ${rootOf(copy)} at position ${position}`, from: copy.house, files },
505
+ { message: `House ${rootOf(copy.room_handle)} at position ${position}`, from: copy.house, files },
488
506
  ]);
489
507
  manifests = { ...manifests, [house]: { parent: copy.house, files: revisions } };
490
508
  }
@@ -515,7 +533,7 @@ export class LocalCopies {
515
533
  copy = await this.save(without(copy));
516
534
  }
517
535
  if (!copy.active) {
518
- return { refused: true, text: `${rootOf(copy)} is no longer synced to this Environment, so House takes no push from it` };
536
+ return { refused: true, text: `${rootOf(copy.room_handle)} is no longer synced to this Environment, so House takes no push from it` };
519
537
  }
520
538
  const listed = (await git(copy.repository, ['rev-list', '--topo-order', commit])).split('\n');
521
539
  const base = listed.find((ancestor) => ancestor in copy.manifests);
@@ -549,7 +567,7 @@ export class LocalCopies {
549
567
  return this.settle(submitted, caller);
550
568
  }
551
569
  async derived(copy, base, commit) {
552
- const root = rootOf(copy);
570
+ const root = rootOf(copy.room_handle);
553
571
  const before = await tree(copy.repository, base);
554
572
  const after = await tree(copy.repository, commit);
555
573
  const moved = movedPaths(copy.moves, before, after);
@@ -605,12 +623,12 @@ export class LocalCopies {
605
623
  const { upload, ...unstaged } = copy.push;
606
624
  return this.settle(await this.save({ ...copy, push: unstaged }), caller, true);
607
625
  }
608
- if (outcome.refused.code === 'operation_expired')
626
+ if (outcome.refused.code === 'expired')
609
627
  return this.recovered(copy);
610
628
  const rebased = rebaseFixes(outcome.refused);
611
629
  let settled = await this.save(without(copy));
612
630
  try {
613
- settled = await this.save(await this.caughtUp(settled));
631
+ settled = (await this.caughtUp([settled], true))[0];
614
632
  }
615
633
  catch {
616
634
  return {
@@ -629,9 +647,9 @@ export class LocalCopies {
629
647
  }
630
648
  async recovered(copy) {
631
649
  const push = copy.push;
632
- const current = await this.save(await this.caughtUp(copy));
650
+ const current = (await this.caughtUp([copy], true))[0];
633
651
  const house = await tree(copy.repository, current.house);
634
- const root = rootOf(current);
652
+ const root = rootOf(current.room_handle);
635
653
  if (!(await this.landed(copy.repository, push.changes, house, await tree(copy.repository, push.commit)))) {
636
654
  await this.save(without(current));
637
655
  return {
@@ -673,7 +691,7 @@ export class LocalCopies {
673
691
  const push = copy.push;
674
692
  let written = push.written;
675
693
  if (push.changes.some((change) => change.op === 'rename')) {
676
- copy = await this.save(await this.caughtUp(copy));
694
+ copy = (await this.caughtUp([copy], true))[0];
677
695
  const house = await tree(copy.repository, copy.house);
678
696
  const sent = await tree(copy.repository, push.house);
679
697
  if (push.changes.some((change) => change.op === 'rename' && !house.has(relative(change.to)))) {
@@ -687,7 +705,7 @@ export class LocalCopies {
687
705
  written = [
688
706
  ...written,
689
707
  {
690
- path: `${rootOf(copy)}/${path}`,
708
+ path: `${rootOf(copy.room_handle)}/${path}`,
691
709
  revision: entry === undefined ? null : (revisionAt(copy, copy.house, path) ?? null),
692
710
  content: entry === undefined ? null : await blobText(copy.repository, entry, path),
693
711
  },
@@ -707,7 +725,7 @@ export class LocalCopies {
707
725
  }
708
726
  const edits = new Map([...files].filter(([path, content]) => (content === null ? selected.has(path) : selected.get(path) !== blobId(content))));
709
727
  const received = [...files.keys()].filter((path) => revisionAt(copy, copy.house, path) === revisionAt(copy, push.house, path));
710
- const accepted = `House ${rootOf(copy)} with ${push.commit} accepted`;
728
+ const accepted = `House ${rootOf(copy.room_handle)} with ${push.commit} accepted`;
711
729
  const house = await importCommits(copy.repository, [
712
730
  ...(edits.size === 0 ? [] : [{ message: accepted, from: push.commit, files: edits }]),
713
731
  {
package/dist/end.js ADDED
@@ -0,0 +1,8 @@
1
+ import { killDescendants } from './scope.js';
2
+ export const REPLACED = 'another Kit started for this Environment';
3
+ export const SIGN_IN_AGAIN = 'sign in again';
4
+ export function end(reason) {
5
+ process.stderr.write(`kit: ${reason}\n`);
6
+ killDescendants();
7
+ process.exit(0);
8
+ }