clavarium 0.2.0-next.4 → 0.2.0-next.5

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.
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.0-next.4",
3
+ "version": "0.2.0-next.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.2.0-next.4",
9
+ "version": "0.2.0-next.5",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
13
13
  "@xterm/xterm": "^6.0.0",
14
- "clavarium-parley": "0.10.0-next.2",
14
+ "clavarium-parley": "0.10.0-next.3",
15
15
  "mermaid": "11.17.2",
16
16
  "node-pty": "^1.1.0",
17
17
  "pdfjs-dist": "6.4.299",
@@ -686,9 +686,9 @@
686
686
  }
687
687
  },
688
688
  "node_modules/clavarium-parley": {
689
- "version": "0.10.0-next.2",
690
- "resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.10.0-next.2.tgz",
691
- "integrity": "sha512-/6pAEMEKgug1FerqBKzqoMcWmnR6eYWtfiGt1Oa1gz9vWFuupxXlTeUFurukgfqLiEVOU6U+g1qJUff8PtTLBQ==",
689
+ "version": "0.10.0-next.3",
690
+ "resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.10.0-next.3.tgz",
691
+ "integrity": "sha512-a750ZvTUDngz8nMdmja3/slCYCUCxM24uFEdgM1X4dNZ666Pf3HEPBbN9GwGgU6F+Ozc1JML5o87tCAMqcRyGQ==",
692
692
  "license": "MIT",
693
693
  "bin": {
694
694
  "parley": "bin/parley.mjs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.0-next.4",
3
+ "version": "0.2.0-next.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@xterm/addon-fit": "^0.11.0",
45
45
  "@xterm/xterm": "^6.0.0",
46
- "clavarium-parley": "0.10.0-next.2",
46
+ "clavarium-parley": "0.10.0-next.3",
47
47
  "mermaid": "11.17.2",
48
48
  "node-pty": "^1.1.0",
49
49
  "pdfjs-dist": "6.4.299",
package/server/api.mjs CHANGED
@@ -38,7 +38,7 @@
38
38
  * POST /api/updates/back { version }: go back to a release that is kept (in the background)
39
39
  * GET /api/git/<name>/outgoing?target=<branch>&branch=<branch> what would go from the agent's branch, to that branch of the remote
40
40
  * GET /api/git/<name>/graph the branch diagram: branches and their last commits
41
- * GET /api/git/<name>/commit?id=<sha> one commit, as a review shows it
41
+ * GET /api/git/<name>/commit?id=<sha> one commit, as a review shows it (&base=<sha>: the commits from base to it)
42
42
  * POST /api/git/<name>/push {head, target?, sure?}: exactly that reviewed commit, to exactly that branch
43
43
  * GET /api/git/<name>/keep origin kept up to date, checks, automatic sending, log
44
44
  * POST /api/git/<name>/keep {refreshMinutes?, alert?, checkCommand?, checkSeconds?, runHook?, requireForSend?, fsck?}
@@ -218,7 +218,7 @@ export async function handleApi(request, response, conversations, headers, resou
218
218
  /* A repository: where its commits go, and what would go now. */
219
219
  const repo = /^\/api\/git\/([a-z0-9-]{1,32})(\/outgoing|\/keep)?$/.exec(pathname);
220
220
  const drawn = /^\/api\/git\/([a-z0-9-]{1,32})\/(graph|commit)$/.exec(pathname);
221
- if (drawn && gitReview) return send(200, drawn[2] === 'graph' ? await gitReview.graph(drawn[1]) : await gitReview.commit(drawn[1], query.get('id'))), true;
221
+ if (drawn && gitReview) return send(200, drawn[2] === 'graph' ? await gitReview.graph(drawn[1]) : await gitReview.commit(drawn[1], query.get('id'), query.get('base'))), true;
222
222
  if (repo && repo[2] === '/keep' && gitKeeper) return send(200, gitKeeper.about(repo[1])), true;
223
223
  if (repo && gitReview) return send(200, repo[2] ? await gitReview.outgoing(repo[1], { target: query.get('target') ?? undefined, branch: query.get('branch') ?? undefined }) : gitReview.settings(repo[1])), true;
224
224
  const watched = /^\/api\/conversations\/([^/]+)\/signin$/.exec(pathname);
@@ -198,6 +198,11 @@ const TODO = Object.freeze({
198
198
  /* Reasons whose sending stays stuck until somebody looks. */
199
199
  const GRAVE_WHY = new Set(['refused-forced', 'refused-unknown-unreachable', 'refused-workspace']);
200
200
  const commitsOf = (n) => `${n} commit${n === 1 ? '' : 's'}`;
201
+ /* A commit as a line of the chat names it: its first seven characters (the page makes them a link to it). */
202
+ const short = (id) => (typeof id === 'string' && /^[0-9a-f]{40}$/.test(id) ? id.slice(0, 7) : '');
203
+ const upTo = (id) => (short(id) ? ` (up to ${short(id)})` : '');
204
+ /* "See the N commits": from the tip before to the tip now, as the review shows them; none for a new branch. */
205
+ const seeCommits = (name, head, base, n) => (short(head) && short(base) && Number.isInteger(n) && n > 0 ? [`commits:${name}.${head.slice(0, 12)}.${base.slice(0, 12)}.${Math.min(n, 9999)}`] : []);
201
206
 
202
207
  export class GitKeeper {
203
208
  #store; #review; #resources; #checks; #alerts; #now; #tellAgent; #thread; #held = new Map(); #results = new Map(); #running = new Set(); #telling = new Map();
@@ -366,7 +371,7 @@ export class GitKeeper {
366
371
  #say(name, conversations, event) {
367
372
  let attached;
368
373
  try { attached = Object.keys(this.#resources.list().find((x) => x.name === name)?.attachments ?? {}); } catch { return; }
369
- for (const c of conversations) if (attached.includes(c)) { try { this.#thread(c, { ...event, actions: [`review:${name}`] }); } catch { /* a chat never stops git */ } }
374
+ for (const c of conversations) if (attached.includes(c)) { try { this.#thread(c, { ...event, actions: [...(event.actions ?? []), `review:${name}`] }); } catch { /* a chat never stops git */ } }
370
375
  }
371
376
  #attached(name) { try { return Object.entries(this.#resources.list().find((x) => x.name === name)?.attachments ?? {}).filter(([, access]) => access && access !== 'none').map(([c]) => c).sort(); } catch { return []; } }
372
377
  /* Marked as told only once somebody was: a line typed to no machine
@@ -427,8 +432,8 @@ export class GitKeeper {
427
432
  const brought = result.ok ? (result.brought ?? []) : [];
428
433
  if (brought.length) {
429
434
  const total = brought.reduce((n, x) => n + x.commits, 0);
430
- const where = brought.slice(0, 4).map((x) => `${x.commits} on ${x.branch}`).join(', ') + (brought.length > 4 ? `, and ${brought.length - 4} more branch(es)` : '');
431
- this.#say(name, this.#attached(name), { kind: 'origin', text: `Origin of ${name} updated: ${total} new commit${total === 1 ? '' : 's'} (${where}).`, topic: 'Origin updated', brief: name });
435
+ const where = brought.slice(0, 4).map((x) => `${x.commits} on ${x.branch}${upTo(x.head)}`).join(', ') + (brought.length > 4 ? `, and ${brought.length - 4} more branch(es)` : '');
436
+ this.#say(name, this.#attached(name), { kind: 'origin', text: `Origin of ${name} updated: ${total} new commit${total === 1 ? '' : 's'} (${where}).`, topic: 'Origin updated', brief: name, actions: brought.length === 1 ? seeCommits(name, brought[0].head, brought[0].base, brought[0].commits) : [] });
432
437
  }
433
438
  if (result.ok && Object.keys(record.blocked ?? {}).length > 0) void this.#settle(name).catch(() => {});
434
439
  }
@@ -490,7 +495,7 @@ export class GitKeeper {
490
495
  }
491
496
  try {
492
497
  const sent = await this.#review.push(r.name, body);
493
- if (!sent.already) this.#say(r.name, this.#attached(r.name), { kind: 'sent-by-hand', text: `Sent by hand to ${sent.branch} of ${r.name}: ${commitsOf(sent.commits)}${sent.made ? ' (a new branch)' : ''}.`, topic: 'Sent', brief: sent.branch });
498
+ if (!sent.already) this.#say(r.name, this.#attached(r.name), { kind: 'sent-by-hand', text: `Sent by hand to ${sent.branch} of ${r.name}: ${commitsOf(sent.commits)}${upTo(sent.head)}${sent.made ? ' (a new branch)' : ''}.`, topic: 'Sent', brief: sent.branch, actions: seeCommits(r.name, sent.head, sent.base, sent.commits) });
494
499
  return sent;
495
500
  } catch (error) {
496
501
  if (error instanceof GitFailure) this.#sendFailed(r.name, error, { branch: body?.target, head: body?.head });
@@ -604,7 +609,7 @@ export class GitKeeper {
604
609
  if (sent.already) { this.#log(name, { branch: b, head: tip.id, conversation, ok: true, what: `Already on the remote: ${b}.` }, { sent: true, by: conversation }); return true; }
605
610
  this.#log(name, { branch: b, head: tip.id, conversation, ok: true, what: `Sent automatically for ${conversation}: ${sent.commits} commit(s) to ${sent.branch}${sent.made ? ' (a new branch)' : ''}, ${checked}.${sent.note ? ` ${sent.note}` : ''}` }, { sent: true, by: conversation });
606
611
  if (alert) this.#tell(`${name}: sent to ${sent.branch}`, `${sent.commits} commit(s), automatically for ${conversation}, ${checked}.`);
607
- this.#say(name, [conversation], { kind: 'sent', text: `Sent to ${sent.branch} of ${name}: ${commitsOf(sent.commits)}${sent.made ? ' (a new branch)' : ''}, ${checked}.`, topic: 'Sent', brief: sent.branch });
612
+ this.#say(name, [conversation], { kind: 'sent', text: `Sent to ${sent.branch} of ${name}: ${commitsOf(sent.commits)}${upTo(sent.head)}${sent.made ? ' (a new branch)' : ''}, ${checked}.`, topic: 'Sent', brief: sent.branch, actions: seeCommits(name, sent.head, sent.base, sent.commits) });
608
613
  return true;
609
614
  } catch (error) {
610
615
  const kind = error instanceof GitFailure ? error.kind : 'error';
@@ -259,7 +259,7 @@ export class GitReview {
259
259
  if (before.get(b) === now || rewritten.some((w) => w.branch === b) || (ours && ours.branch === b && ours.head === now)) continue;
260
260
  const counted = await this.#runner.in(reference, ['rev-list', '--count', '--stdin', '--end-of-options'], { input: `${now}\n${had}\n`, deadlineMs: 120_000 }).catch(() => null);
261
261
  const commits = Number(counted?.code === 0 ? counted.stdout.toString('latin1').trim() : 0) || 0;
262
- if (commits > 0) brought.push({ branch: b, commits });
262
+ if (commits > 0) brought.push({ branch: b, commits, head: now, base: before.get(b) ?? null });
263
263
  }
264
264
  }
265
265
  if (made) await this.#resources.shareOrigin(r.name).catch(() => {});
@@ -456,18 +456,31 @@ export class GitReview {
456
456
  }
457
457
 
458
458
  /* ONE COMMIT, as a review shows it: against its first parent (or
459
- nothing, for the first commit). Never sent from here. */
460
- async commit(name, id) {
459
+ nothing, for the first commit). Or, given `base`, the commits from
460
+ base to it (what a line of the chat said was sent or brought). Named
461
+ by their identifier, whole or its first 7 characters at least, as a
462
+ line of the chat shows them. Never sent from here. */
463
+ async commit(name, id, base) {
461
464
  const r = this.#repository(name);
462
465
  const asked = String(id ?? '');
463
- if (!isId(asked)) throw new Refusal(400, 'A commit is named by its full identifier.');
466
+ const from = base === null || base === undefined || base === '' ? '' : String(base);
467
+ const SHORT = /^[0-9a-f]{7,40}$/;
468
+ if (!SHORT.test(asked) || (from && !SHORT.test(from))) throw new Refusal(400, 'A commit is named by its identifier (7 to 40 hexadecimal characters).');
464
469
  return this.#withRead(r, async (got) => {
465
470
  const p = await this.#runner.in(got.path, ['rev-list', '--parents', '--max-count=1', '--end-of-options', asked], { deadlineMs: 60_000 });
466
471
  const ids = p.stdout.toString('latin1').trim().split(' ');
467
- if (p.code !== 0 || ids[0] !== asked) throw new Refusal(404, 'This commit is not in the repository.');
472
+ if (p.code !== 0 || !isId(ids[0]) || !ids[0].startsWith(asked)) throw new Refusal(404, 'This commit is not in the repository.');
473
+ const whole = ids[0];
474
+ if (from) {
475
+ const q = await this.#runner.in(got.path, ['rev-list', '--max-count=1', '--end-of-options', from], { deadlineMs: 60_000 });
476
+ const was = q.stdout.toString('latin1').trim();
477
+ if (q.code !== 0 || !isId(was) || !was.startsWith(from)) throw new Refusal(404, 'This commit is not in the repository.');
478
+ const shown = await this.#compare(got.path, { head: whole, base: was, range: [whole], negatives: [was] });
479
+ return { ...shown, id: whole, base: was, parents: [was], head: whole, state: 'commit', message: '' };
480
+ }
468
481
  const parents = ids.slice(1).filter(isId);
469
- const shown = await this.#compare(got.path, { head: asked, base: parents[0] ?? EMPTY_TREE, range: [asked], negatives: parents });
470
- return { ...shown, id: asked, parents, head: asked, state: 'commit', message: parents.length > 1 ? 'A merge: its changes against its first parent.' : '' };
482
+ const shown = await this.#compare(got.path, { head: whole, base: parents[0] ?? EMPTY_TREE, range: [whole], negatives: parents });
483
+ return { ...shown, id: whole, parents, head: whole, state: 'commit', message: parents.length > 1 ? 'A merge: its changes against its first parent.' : '' };
471
484
  });
472
485
  }
473
486
 
@@ -578,7 +591,7 @@ export class GitReview {
578
591
  if (!isId(head) || (review.remoteTip !== null && review.remoteTip !== undefined && !isId(review.remoteTip))) throw new GitFailure(409, 'look', 'Look again at what would be sent.');
579
592
  let remoteTip = review.remoteTip ?? null;
580
593
  let count = review.count;
581
- const sent = (extra) => ({ ok: true, branch: target, from: branch, head, commits: count ?? 0, made: !remoteTip, already: false, ...extra });
594
+ const sent = (extra) => ({ ok: true, branch: target, from: branch, head, base: remoteTip, commits: count ?? 0, made: !remoteTip, already: false, ...extra });
582
595
  /* (1) What the agent has now, read again into a fresh workspace. */
583
596
  let got;
584
597
  try { got = await this.#read(r); } catch (error) {