@agentshouse/kit 0.2.1-ce145e9aadbf6 → 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
  }
@@ -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: [] },
@@ -186,42 +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
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 = (chosen?.rooms ?? []).flatMap((room) => room.room_handle === null ? [] : [{ key: `rooms/${room.room_ref}`, handle: room.room_handle }]);
203
- const keys = new Set(selected.map((scope) => scope.key));
204
- const work = [];
205
- for (const key of this.copies.keys()) {
206
- if (keys.has(key))
207
- continue;
208
- work.push(this.exclusive(key, async () => {
209
- const copy = this.copies.get(key);
210
- if (copy.active)
211
- await this.save({ ...copy, active: false });
212
- }));
213
- }
214
- for (const scope of selected)
215
- work.push(this.exclusive(scope.key, () => this.apply(scope)).catch(logged));
216
- await Promise.all(work);
209
+ async connected() {
210
+ const copied = await this.answered(() => this.fetched(false));
211
+ if (copied.refused)
212
+ logged(copied.text);
217
213
  }
218
- entries(frame) {
219
- const copy = [...this.copies.values()].find((held) => held.active && held.room_ref === frame.authority);
220
- if (copy === undefined)
221
- return;
222
- if (copy.log_epoch === frame.log_epoch && BigInt(frame.position) <= BigInt(copy.position))
223
- return;
224
- 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
+ });
225
265
  }
226
266
  async observed(report) {
227
267
  const key = await this.keyAt(dirname(report.common));
@@ -271,16 +311,9 @@ export class LocalCopies {
271
311
  this.queues.set(key, queued.catch(() => undefined));
272
312
  return queued;
273
313
  }
274
- deliver(key) {
275
- if (this.delivering.has(key))
276
- return;
277
- this.delivering.add(key);
278
- void this.exclusive(key, async () => {
279
- this.delivering.delete(key);
280
- const copy = this.copies.get(key);
281
- if (copy.active)
282
- await this.save(await this.caughtUp(copy));
283
- }).catch(logged);
314
+ holding(keys, work) {
315
+ const [key, ...rest] = keys;
316
+ return key === undefined ? work() : this.exclusive(key, () => this.holding(rest, work));
284
317
  }
285
318
  async save(copy) {
286
319
  const path = join(kitHome(), 'local-copies', `${copy.key}.json`);
@@ -326,46 +359,13 @@ export class LocalCopies {
326
359
  }
327
360
  return read;
328
361
  }
329
- async apply(scope) {
330
- let copy = this.copies.get(scope.key);
331
- if (copy === undefined || !(await exists(copy.repository))) {
332
- await this.materialize(scope);
333
- return;
334
- }
335
- if (copy.room_handle !== scope.handle)
336
- copy = await this.save({ ...copy, room_handle: scope.handle });
337
- if (!copy.active) {
338
- await this.save(await this.received({ ...copy, active: true }));
339
- return;
340
- }
341
- const discovery = await this.house.post('/kit/door/discover', {
342
- prefixes: [rootOf(copy.room_handle)],
343
- });
344
- const authority = discovery.authorities.find((entry) => entry.room_ref === copy.room_ref);
345
- if (authority === undefined)
346
- return;
347
- if (authority.managed_replication !== 'admitted') {
348
- logged(`operation_denied: ${rootOf(copy.room_handle)} is not synchronized to this Environment`);
349
- return;
350
- }
351
- if (JSON.stringify([authority.operations.write, authority.protected_paths]) !==
352
- JSON.stringify([copy.write, copy.protected])) {
353
- await this.save(await this.received(copy));
354
- return;
355
- }
356
- await this.save(await this.caughtUp(copy));
357
- }
358
- async materialize(scope) {
359
- const read = await this.read(scope.handle);
360
- if (read === null) {
361
- logged(`House serves nothing of ${rootOf(scope.handle)} to copy`);
362
- return;
363
- }
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');
364
366
  const repository = join(localCopies(), 'rooms', read.anchor);
365
- if (await exists(repository)) {
366
- logged(`${repository} already exists and House Kit leaves it as it is`);
367
- return;
368
- }
367
+ if (await exists(repository))
368
+ throw new Error(`${repository} already exists and House Kit leaves it as it is`);
369
369
  const staging = join(dirname(repository), `.${randomUUID()}`);
370
370
  await mkdir(staging, { recursive: true });
371
371
  let house;
@@ -373,7 +373,7 @@ export class LocalCopies {
373
373
  try {
374
374
  await git(staging, ['init', '-q', '-b', BRANCH]);
375
375
  house = await importCommits(staging, [
376
- { message: `House ${rootOf(scope.handle)}`, from: null, files: read.writable },
376
+ { message: `House ${rootOf(handle)}`, from: null, files: read.writable },
377
377
  ]);
378
378
  await git(staging, ['update-ref', `refs/heads/${BRANCH}`, house]);
379
379
  await git(staging, ['reset', '-q', '--hard']);
@@ -386,9 +386,9 @@ export class LocalCopies {
386
386
  throw error;
387
387
  }
388
388
  await this.save({
389
- key: scope.key,
389
+ key,
390
390
  room_ref: read.anchor,
391
- room_handle: scope.handle,
391
+ room_handle: handle,
392
392
  repository,
393
393
  active: true,
394
394
  house,
@@ -400,6 +400,7 @@ export class LocalCopies {
400
400
  manifests: { [house]: { parent: null, files: read.revisions } },
401
401
  moves: [],
402
402
  });
403
+ return `${rootOf(handle)}: copied to ${repository}`;
403
404
  }
404
405
  async received(copy) {
405
406
  const read = await this.read(copy.room_handle);
@@ -423,30 +424,52 @@ export class LocalCopies {
423
424
  await ignoreRules(copy.repository, [...read.writable.keys()], next);
424
425
  return next;
425
426
  }
426
- async caughtUp(start) {
427
- let copy = start;
428
- 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) {
429
446
  let answer;
430
447
  try {
431
448
  answer = await this.house.post('/kit/door/enumerate', {
432
- prefixes: [rootOf(copy.room_handle)],
433
- 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 })),
434
451
  });
435
452
  }
436
453
  catch (error) {
437
- const code = error instanceof HouseRefusal ? (refusalOf(error.text)?.code ?? null) : null;
438
- if (code === 'position_expired')
439
- return this.received(copy);
440
- if (code !== 'replica_behind')
454
+ const expired = asking.find((copy) => copy.room_ref === expiredAuthority(error));
455
+ if (expired === undefined)
441
456
  throw error;
442
- setTimeout(() => this.deliver(copy.key), REPLICA_RETRY_MS).unref();
443
- 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);
444
469
  }
445
- const view = answer.authorities.find((entry) => entry.authority === copy.room_ref);
446
- if (view === undefined || (view.position === copy.position && view.log_epoch === copy.log_epoch))
447
- return copy;
448
- copy = await this.save(await this.incorporated(copy, view.changes, view.position, view.log_epoch));
470
+ asking = whole ? advanced : [];
449
471
  }
472
+ return copies.map((copy) => caught.get(copy.key));
450
473
  }
451
474
  async incorporated(copy, changes, position, logEpoch) {
452
475
  const houseTree = await tree(copy.repository, copy.house);
@@ -605,7 +628,7 @@ export class LocalCopies {
605
628
  const rebased = rebaseFixes(outcome.refused);
606
629
  let settled = await this.save(without(copy));
607
630
  try {
608
- settled = await this.save(await this.caughtUp(settled));
631
+ settled = (await this.caughtUp([settled], true))[0];
609
632
  }
610
633
  catch {
611
634
  return {
@@ -624,7 +647,7 @@ export class LocalCopies {
624
647
  }
625
648
  async recovered(copy) {
626
649
  const push = copy.push;
627
- const current = await this.save(await this.caughtUp(copy));
650
+ const current = (await this.caughtUp([copy], true))[0];
628
651
  const house = await tree(copy.repository, current.house);
629
652
  const root = rootOf(current.room_handle);
630
653
  if (!(await this.landed(copy.repository, push.changes, house, await tree(copy.repository, push.commit)))) {
@@ -668,7 +691,7 @@ export class LocalCopies {
668
691
  const push = copy.push;
669
692
  let written = push.written;
670
693
  if (push.changes.some((change) => change.op === 'rename')) {
671
- copy = await this.save(await this.caughtUp(copy));
694
+ copy = (await this.caughtUp([copy], true))[0];
672
695
  const house = await tree(copy.repository, copy.house);
673
696
  const sent = await tree(copy.repository, push.house);
674
697
  if (push.changes.some((change) => change.op === 'rename' && !house.has(relative(change.to)))) {
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
+ }