@agentshouse/kit 0.2.1-cd9cbd8b6428c → 0.2.1-cfe564e35b4a5

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