toolroll 0.9.31 → 0.9.33

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 (68) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assignment.d.ts +3 -0
  3. package/dist/assignment.js +8 -2
  4. package/dist/batch-checks.d.ts +15 -0
  5. package/dist/batch-checks.js +244 -0
  6. package/dist/batch-policy.d.ts +23 -0
  7. package/dist/batch-policy.js +25 -0
  8. package/dist/browser/workspace.js +13 -13
  9. package/dist/builder.js +14 -3
  10. package/dist/chat-actions.js +2 -2
  11. package/dist/check-levels-ui.d.ts +4 -0
  12. package/dist/check-levels-ui.js +21 -2
  13. package/dist/checkout-cleanup.d.ts +2 -1
  14. package/dist/checkout-cleanup.js +24 -8
  15. package/dist/coding-context.js +2 -2
  16. package/dist/coding-handoff.js +2 -2
  17. package/dist/desktop-update.d.ts +1 -1
  18. package/dist/exec.d.ts +2 -0
  19. package/dist/exec.js +11 -0
  20. package/dist/guides.js +3 -0
  21. package/dist/knowledge-cli.d.ts +31 -0
  22. package/dist/knowledge-cli.js +35 -2
  23. package/dist/knowledge-ui.d.ts +1 -1
  24. package/dist/knowledge-ui.js +7 -0
  25. package/dist/lead-capabilities.d.ts +0 -7
  26. package/dist/lead-capabilities.js +11 -1
  27. package/dist/mate-doors.js +2 -2
  28. package/dist/mate.d.ts +6 -2
  29. package/dist/mate.js +63 -38
  30. package/dist/never-index.d.ts +3 -0
  31. package/dist/never-index.js +21 -0
  32. package/dist/operate.js +99 -5
  33. package/dist/orphan-sweep.d.ts +63 -0
  34. package/dist/orphan-sweep.js +235 -0
  35. package/dist/project-delete.js +1 -1
  36. package/dist/project-knowledge.d.ts +28 -3
  37. package/dist/project-knowledge.js +64 -25
  38. package/dist/project-learning.d.ts +33 -0
  39. package/dist/project-learning.js +105 -15
  40. package/dist/project-skills.js +27 -21
  41. package/dist/result-follow-ups.d.ts +22 -2
  42. package/dist/result-follow-ups.js +44 -10
  43. package/dist/serve.js +33 -11
  44. package/dist/shared-deps.d.ts +18 -2
  45. package/dist/shared-deps.js +129 -15
  46. package/dist/staged-runtimes.d.ts +42 -0
  47. package/dist/staged-runtimes.js +191 -0
  48. package/dist/storage-sweep.d.ts +50 -0
  49. package/dist/storage-sweep.js +203 -0
  50. package/dist/storage-ui.d.ts +5 -0
  51. package/dist/storage-ui.js +30 -4
  52. package/dist/storage.d.ts +2 -0
  53. package/dist/storage.js +15 -3
  54. package/dist/store.d.ts +17 -2
  55. package/dist/store.js +37 -5
  56. package/dist/subscription-chat.d.ts +3 -1
  57. package/dist/subscription-chat.js +1 -0
  58. package/dist/surface.js +2 -1
  59. package/dist/task-status.d.ts +13 -0
  60. package/dist/task-status.js +37 -3
  61. package/dist/team-leads.js +6 -2
  62. package/dist/team-runtime.js +2 -1
  63. package/dist/test-temp.d.ts +20 -2
  64. package/dist/test-temp.js +53 -13
  65. package/dist/toolroll-update.js +2 -0
  66. package/dist/workspace-ui.js +3 -1
  67. package/dist/worktree.js +9 -0
  68. package/package.json +1 -1
@@ -54,13 +54,100 @@ const git = (repo, args, seen) => {
54
54
  seen?.set(key, out);
55
55
  return out;
56
56
  };
57
+ const sameValue = (n) => typeof n === 'bigint' ? n.toString() : n;
57
58
  /** Path plus the physical shared Git database: replacing a repository does not inherit its lessons. */
58
- export function learningIdentity(repo, seen) {
59
+ export const identityOf = (repo, common, st) => learningSha(JSON.stringify([repo, common, sameValue(st.ino), st.birthtimeMs]));
60
+ /** The formula before 0.9.33, which also hashed st.dev; macOS may renumber a volume at restart. */
61
+ export const legacyIdentityOf = (repo, common, st) => learningSha(JSON.stringify([repo, common, sameValue(st.dev), sameValue(st.ino), st.birthtimeMs]));
62
+ function identityFacts(repo, seen) {
59
63
  if (canonicalProject(repo) !== repo)
60
64
  throw new Error('Project identity changed.');
61
65
  const common = realpathSync(resolve(repo, git(repo, ['rev-parse', '--git-common-dir'], seen)));
62
- const st = statSync(common);
63
- return learningSha(JSON.stringify([repo, common, st.dev, st.ino, st.birthtimeMs]));
66
+ return { common, st: statSync(common) };
67
+ }
68
+ export function learningIdentity(repo, seen) {
69
+ const { common, st } = identityFacts(repo, seen);
70
+ return identityOf(repo, common, st);
71
+ }
72
+ /** Projects whose rows have already been checked for a same-repository carry, per store. */
73
+ const reconciled = new WeakMap();
74
+ // Every table that saves a project identity. Rows are never rewritten: a carry aliases them.
75
+ const STORED_IDENTITY = `SELECT 1 FROM project_knowledge WHERE repo=?1 AND identity=?2
76
+ UNION ALL SELECT 1 FROM knowledge_change WHERE repo=?1 AND identity=?2
77
+ UNION ALL SELECT 1 FROM knowledge_snapshot WHERE repo=?1 AND identity=?2
78
+ UNION ALL SELECT 1 FROM project_skill_change WHERE repo=?1 AND identity=?2
79
+ UNION ALL SELECT 1 FROM skill_test JOIN task_ref ON task_ref.id=skill_test.task_ref WHERE task_ref.repo=?1 AND skill_test.identity=?2
80
+ UNION ALL SELECT 1 FROM learning_policy WHERE repo=?1 AND identity=?2
81
+ UNION ALL SELECT 1 FROM project_lesson WHERE repo=?1 AND identity=?2
82
+ UNION ALL SELECT 1 FROM learning_capture WHERE repo=?1 AND identity=?2
83
+ UNION ALL SELECT 1 FROM learning_snapshot WHERE repo=?1 AND identity=?2
84
+ UNION ALL SELECT 1 FROM project_decision WHERE repo=?1 AND identity=?2 LIMIT 1`;
85
+ /**
86
+ * Record that saved project rows under `from` belong to the project now known as `to`.
87
+ * Knowledge saved under `from` also gets a new revision under `to`; older revisions stay as they are.
88
+ * Returns the new knowledge revision, or null when knowledge needed none.
89
+ */
90
+ export function carryProject(store, repo, args, now = new Date()) {
91
+ return store.transact(() => {
92
+ store.handle.prepare('INSERT OR IGNORE INTO project_identity_carry(repo,from_identity,to_identity,actor,at,how) VALUES (?,?,?,?,?,?)').run(repo, args.from, args.to, args.actor, now.toISOString(), args.how);
93
+ const row = store.handle.prepare('SELECT * FROM project_knowledge WHERE repo=?').get(repo);
94
+ if (!row || row['identity'] !== args.from)
95
+ return null;
96
+ const history = store.handle.prepare('SELECT sha FROM knowledge_change WHERE repo=? AND identity=? AND revision=?').get(repo, args.from, row['revision']);
97
+ if (history?.['sha'] !== row['sha'] || learningSha(String(row['payload'])) !== row['sha'])
98
+ throw new Error('Project knowledge history could not be verified.');
99
+ const revision = Number(row['revision']) + 1;
100
+ store.handle.prepare('INSERT INTO knowledge_change(repo,identity,revision,actor,at,payload,sha) VALUES (?,?,?,?,?,?,?)').run(repo, args.to, revision, args.actor, now.toISOString(), String(row['payload']), String(row['sha']));
101
+ store.handle.prepare('UPDATE project_knowledge SET identity=?,revision=? WHERE repo=?').run(args.to, revision, repo);
102
+ return revision;
103
+ });
104
+ }
105
+ export const SAME_REPOSITORY = 'toolroll (same repository)';
106
+ /**
107
+ * Carry silently only what is provably the same repository: rows saved under the
108
+ * previous formula with this volume's current device number (no restart since).
109
+ */
110
+ export function reconcileIdentity(store, repo, seen, now = new Date()) {
111
+ const { common, st } = identityFacts(repo, seen);
112
+ const current = identityOf(repo, common, st), legacy = legacyIdentityOf(repo, common, st);
113
+ let done = reconciled.get(store);
114
+ if (!done)
115
+ reconciled.set(store, done = new Set());
116
+ const key = `${repo}\n${legacy}`;
117
+ if (legacy !== current && !done.has(key)) {
118
+ if (!store.handle.prepare('SELECT 1 FROM project_identity_carry WHERE repo=? AND from_identity=? AND to_identity=?').get(repo, legacy, current)
119
+ && store.handle.prepare(STORED_IDENTITY).get(repo, legacy)) {
120
+ // Damaged knowledge history keeps its own error; retry on the next read.
121
+ try {
122
+ carryProject(store, repo, { from: legacy, to: current, actor: SAME_REPOSITORY, how: 'automatic' }, now);
123
+ }
124
+ catch {
125
+ return { current, legacy };
126
+ }
127
+ }
128
+ done.add(key);
129
+ }
130
+ return { current, legacy };
131
+ }
132
+ /** The current identity and every identity carried into it. Checks accept these; writes use the current one. */
133
+ export function acceptedIdentities(store, repo, seen) {
134
+ const { current, legacy } = reconcileIdentity(store, repo, seen);
135
+ const accepted = new Set([current, legacy]);
136
+ const carries = store.handle.prepare('SELECT from_identity,to_identity FROM project_identity_carry WHERE repo=?').all(repo).map(r => [String(r['from_identity']), String(r['to_identity'])]);
137
+ for (let grew = true; grew;) {
138
+ grew = false;
139
+ for (const [from, to] of carries)
140
+ if (accepted.has(to) && !accepted.has(from)) {
141
+ accepted.add(from);
142
+ grew = true;
143
+ }
144
+ }
145
+ return accepted;
146
+ }
147
+ /** Without a database: the current identity, or the previous formula on this same volume. */
148
+ export function identityMatches(repo, identity) {
149
+ const { common, st } = identityFacts(repo);
150
+ return identity === identityOf(repo, common, st) || identity === legacyIdentityOf(repo, common, st);
64
151
  }
65
152
  function environmentFingerprint(store, repo) {
66
153
  return learningSha(JSON.stringify({ platform: process.platform, arch: process.arch, node: process.version,
@@ -158,7 +245,7 @@ export function queueLearning(store, source, reviewer, value, catalog, now, asse
158
245
  // Legacy reviews without a learning snapshot need no optional failure.
159
246
  if (empty && assessment === undefined && !snapshot)
160
247
  return;
161
- if (!snapshot || snapshot['repo'] !== repo || snapshot['identity'] !== learningIdentity(repo) || learningSha(String(snapshot['payload'])) !== snapshot['sha'])
248
+ if (!snapshot || snapshot['repo'] !== repo || !acceptedIdentities(store, repo).has(String(snapshot['identity'])) || learningSha(String(snapshot['payload'])) !== snapshot['sha'])
162
249
  throw new Error('Learning review identity is unavailable.');
163
250
  const result = assessmentOf(assessment, value);
164
251
  // A contradictory decision cannot publish suggestions. Keep its sealed
@@ -216,6 +303,8 @@ export function recoverLearning(store, root, repo, now = new Date(), seen = new
216
303
  projects.set(repo, cursor = { reviewer: 0, reviewerEnd: 0, source: 0, sourceEnd: 0 });
217
304
  // Freeze each sweep's end so a stream of new arrivals cannot postpone retry
218
305
  // of an earlier failed row indefinitely. Cursors never confer eligibility.
306
+ let accepted;
307
+ const carried = (identity) => (accepted ??= acceptedIdentities(store, repo, seen)).has(String(identity));
219
308
  if (!cursor.reviewerEnd)
220
309
  cursor.reviewerEnd = Number(store.handle.prepare('SELECT max(id) AS id FROM run').get()?.['id'] ?? 0);
221
310
  // If the optional outbox write failed, recover from the already sealed accepted response.
@@ -234,7 +323,7 @@ export function recoverLearning(store, root, repo, now = new Date(), seen = new
234
323
  if (!source || !reviewer || source.taskRef !== reviewer.taskRef)
235
324
  continue;
236
325
  const snapshot = reviewSnapshot(store, reviewer.id);
237
- if (!snapshot || snapshot['identity'] !== learningIdentity(repo, seen))
326
+ if (!snapshot || !carried(snapshot['identity']))
238
327
  continue;
239
328
  const response = store.artifactsFor(reviewer.id).find(a => a.kind === 'structured-output' && a.captureStatus === 'ok');
240
329
  const read = response && readVerifiedArtifact(root, response);
@@ -270,7 +359,7 @@ export function recoverLearning(store, root, repo, now = new Date(), seen = new
270
359
  throw new Error('Learning review provenance is unavailable.');
271
360
  const candidates = parseLearning(JSON.parse(String(row['payload'])));
272
361
  const identity = learningIdentity(repo, seen);
273
- if (identity !== row['identity'])
362
+ if (!carried(row['identity']))
274
363
  throw new Error('Learning project identity changed.');
275
364
  const head = run.headRevision ?? run.baseRevision ?? '';
276
365
  const diff = store.artifactsFor(source).find(a => a.kind === 'terminal-diff');
@@ -312,7 +401,7 @@ function lessonOf(row) {
312
401
  return { id: Number(row['id']), repo: String(row['repo']), source: Number(row['source']), reviewer: Number(row['reviewer']), status: String(row['status']), version: Number(row['version']), payload, sha: String(row['sha']), identity: String(row['identity']), adoptedBy: row['adopted_by'] == null ? null : String(row['adopted_by']) };
313
402
  }
314
403
  function supported(store, root, lesson, seen) {
315
- if (lesson.identity !== learningIdentity(lesson.repo, seen))
404
+ if (!acceptedIdentities(store, lesson.repo, seen).has(lesson.identity))
316
405
  throw new Error('Project identity changed.');
317
406
  const capture = store.handle.prepare('SELECT catalog FROM learning_capture WHERE source=? AND reviewer=? AND repo=?').get(lesson.source, lesson.reviewer, lesson.repo);
318
407
  if (!capture || reviewedSource(store, lesson.reviewer) !== lesson.source)
@@ -325,9 +414,9 @@ function supported(store, root, lesson, seen) {
325
414
  export function learningView(store, root, repo, actor, before = 0) {
326
415
  admission(store, repo, actor);
327
416
  recoverLearning(store, root, repo);
328
- const identity = learningIdentity(repo);
417
+ const identity = learningIdentity(repo), accepted = acceptedIdentities(store, repo);
329
418
  const p = store.handle.prepare('SELECT * FROM learning_policy WHERE repo=?').get(repo);
330
- const revision = Number(p?.['revision'] ?? 0), enabled = p?.['identity'] === identity && p?.['enabled'] === 1;
419
+ const revision = Number(p?.['revision'] ?? 0), enabled = accepted.has(String(p?.['identity'])) && p?.['enabled'] === 1;
331
420
  let damaged = false;
332
421
  const lessons = store.handle.prepare('SELECT * FROM project_lesson WHERE repo=? ORDER BY id DESC LIMIT 50').all(repo).flatMap(row => { try {
333
422
  return [lessonOf(row)];
@@ -344,8 +433,9 @@ export function changeLearning(store, root, args, now = new Date()) {
344
433
  admission(store, args.repo, args.actor, true);
345
434
  if (learningIdentity(args.repo) !== args.identity)
346
435
  throw new Error('Project identity changed. Reload Learning.');
436
+ const accepted = acceptedIdentities(store, args.repo);
347
437
  const p = store.handle.prepare('SELECT * FROM learning_policy WHERE repo=?').get(args.repo);
348
- if (Number(p?.['revision'] ?? 0) !== args.revision || (p && p['identity'] !== args.identity && args.action !== 'reset'))
438
+ if (Number(p?.['revision'] ?? 0) !== args.revision || (p && !accepted.has(String(p['identity'])) && args.action !== 'reset'))
349
439
  throw new Error('Learning changed. Reload before trying again.');
350
440
  let before = p?.['enabled'] === 1 ? 'enabled' : 'paused', after = before, ev = [], source = null;
351
441
  if (args.action === 'adopt' || args.action === 'disable') {
@@ -353,7 +443,7 @@ export function changeLearning(store, root, args, now = new Date()) {
353
443
  if (!row)
354
444
  throw new Error('Learning lesson is unavailable.');
355
445
  const l = lessonOf(row);
356
- if (l.version !== args.version || l.sha !== args.sha || l.identity !== args.identity)
446
+ if (l.version !== args.version || l.sha !== args.sha || !accepted.has(l.identity))
357
447
  throw new Error('Learning changed. Reload before trying again.');
358
448
  if (l.payload.kind !== 'project' || (args.action === 'adopt' ? l.status !== 'proposed' : l.status !== 'adopted'))
359
449
  throw new Error('This suggestion cannot take that action.');
@@ -396,14 +486,14 @@ export function learningContext(store, root, runId, phase, now = new Date(), val
396
486
  const runner = store.getRunner(run.runner)?.runner;
397
487
  if (!runner || runner.retiredAt !== null || !runner.repos.includes(repo) || (phase === 'review') !== (run.role === 'reviewer') || (phase === 'plan') !== (run.role === 'planner'))
398
488
  return '';
399
- const identity = learningIdentity(repo, seen);
489
+ const identity = learningIdentity(repo, seen), accepted = acceptedIdentities(store, repo, seen), carried = JSON.stringify([...accepted]);
400
490
  const existing = store.handle.prepare('SELECT * FROM learning_snapshot WHERE run=?').get(runId);
401
491
  if (existing) {
402
- if (existing['repo'] !== repo || existing['identity'] !== identity || learningSha(String(existing['payload'])) !== existing['sha'])
492
+ if (existing['repo'] !== repo || !accepted.has(String(existing['identity'])) || learningSha(String(existing['payload'])) !== existing['sha'])
403
493
  throw new Error('Learning snapshot no longer verifies.');
404
494
  return String(existing['payload']);
405
495
  }
406
- const policy = store.handle.prepare('SELECT * FROM learning_policy WHERE repo=? AND identity=? AND enabled=1').get(repo, identity);
496
+ const policy = store.handle.prepare('SELECT * FROM learning_policy WHERE repo=? AND identity IN (SELECT value FROM json_each(?)) AND enabled=1').get(repo, carried);
407
497
  const scope = ref && store.getScope(ref.externalId);
408
498
  const source = run.role === 'reviewer' && run.parentRun !== null ? store.getRun(run.parentRun) : run;
409
499
  // Prepared coding handoffs defer recording their validated base until
@@ -415,7 +505,7 @@ export function learningContext(store, root, runId, phase, now = new Date(), val
415
505
  const unscopedPlan = phase === 'plan' && scope === null && !run.scopeDigest;
416
506
  const eligible = [];
417
507
  if (policy && (paths.length > 0 || unscopedPlan)) {
418
- for (const row of store.handle.prepare("SELECT * FROM project_lesson WHERE repo=? AND identity=? AND status='adopted' ORDER BY id DESC LIMIT 100").all(repo, identity)) {
508
+ for (const row of store.handle.prepare("SELECT * FROM project_lesson WHERE repo=? AND identity IN (SELECT value FROM json_each(?)) AND status='adopted' ORDER BY id DESC LIMIT 100").all(repo, carried)) {
419
509
  try {
420
510
  const l = lessonOf(row);
421
511
  if (!store.handle.prepare("SELECT 1 FROM learning_event WHERE lesson=? AND action='adopt' AND actor=?").get(l.id, l.adoptedBy ?? '') || l.source === source?.id || l.payload.kind !== 'project' || !l.adoptedBy || !store.accountCanAccess(l.adoptedBy, repo) || store.accountOf(l.adoptedBy)?.role !== 'approver' || !l.payload.phases.includes(phase) || l.payload.platform !== process.platform || (!unscopedPlan && !l.payload.paths.some(p => paths.some(t => p === t || p.startsWith(t.replace(/\/$/, '') + '/')))))
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { mkdirSync, mkdtempSync, writeFileSync, chmodSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
5
  import { parseDocument } from "yaml";
6
- import { learningIdentity, learningSha } from "./project-learning.js";
6
+ import { acceptedIdentities, learningIdentity, learningSha } from "./project-learning.js";
7
7
  import { scanForSecrets } from "./evidence.js";
8
8
  import { fileTaskProposal } from "./proposal.js";
9
9
  export const SKILLS_SCHEMA = `
@@ -120,8 +120,11 @@ function admit(store, repo, actor, write = false) {
120
120
  !store.accountCanAccess(actor, repo) ||
121
121
  (write && store.accountOf(actor)?.role !== "approver"))
122
122
  throw Error("Skills are outside your project access.");
123
- return learningIdentity(repo);
123
+ return { identity: learningIdentity(repo), accepted: acceptedIdentities(store, repo) };
124
124
  }
125
+ /** Revisions under any identity this project accepts, as a bound JSON list. */
126
+ const IN_ACCEPTED = "identity IN (SELECT value FROM json_each(?))";
127
+ const listOf = (accepted) => JSON.stringify([...accepted]);
125
128
  function unpack(payload, sha) {
126
129
  if (typeof payload !== "string" || learningSha(payload) !== sha)
127
130
  throw Error("Saved skills could not be verified.");
@@ -135,13 +138,13 @@ function packageOf(store, sha) {
135
138
  throw Error("That skill version is unavailable.");
136
139
  return { ...unpack(row["payload"], sha), sha };
137
140
  }
138
- function current(store, repo, identity) {
141
+ function current(store, repo, accepted) {
139
142
  const row = store.handle
140
143
  .prepare("SELECT * FROM project_skill_change WHERE repo=? ORDER BY revision DESC LIMIT 1")
141
144
  .get(repo);
142
145
  if (!row)
143
146
  return { revision: 0, selection: {} };
144
- if (row["identity"] !== identity)
147
+ if (!accepted.has(String(row["identity"])))
145
148
  throw Error("The project changed. Its previous skills have not been applied.");
146
149
  return {
147
150
  revision: Number(row["revision"]),
@@ -149,7 +152,7 @@ function current(store, repo, identity) {
149
152
  };
150
153
  }
151
154
  export function skillsView(store, repo, actor) {
152
- const identity = admit(store, repo, actor), value = current(store, repo, identity);
155
+ const { identity, accepted } = admit(store, repo, actor), value = current(store, repo, accepted);
153
156
  const visible = new Set(store.handle
154
157
  .prepare("SELECT sha FROM skill_owner WHERE actor=? ORDER BY at,sha")
155
158
  .all(actor)
@@ -162,7 +165,7 @@ export function skillsView(store, repo, actor) {
162
165
  .get(project);
163
166
  if (row) {
164
167
  try {
165
- if (row["identity"] !== learningIdentity(project))
168
+ if (!acceptedIdentities(store, project).has(String(row["identity"])))
166
169
  continue;
167
170
  }
168
171
  catch {
@@ -183,8 +186,8 @@ export function skillsView(store, repo, actor) {
183
186
  .map((sha) => packageOf(store, sha))
184
187
  .sort((a, b) => a.name.localeCompare(b.name));
185
188
  const history = store.handle
186
- .prepare("SELECT revision,actor,at,payload,sha FROM project_skill_change WHERE repo=? AND identity=? ORDER BY revision DESC LIMIT 20")
187
- .all(repo, identity)
189
+ .prepare(`SELECT revision,actor,at,payload,sha FROM project_skill_change WHERE repo=? AND ${IN_ACCEPTED} ORDER BY revision DESC LIMIT 20`)
190
+ .all(repo, listOf(accepted))
188
191
  .map((r) => ({
189
192
  revision: Number(r["revision"]),
190
193
  actor: String(r["actor"]),
@@ -197,8 +200,8 @@ export function skillsView(store, repo, actor) {
197
200
  }
198
201
  /** Current enabled versions, admitted and hash-verified, without any worker-run writes. */
199
202
  export function selectProjectSkills(store, repo, actor) {
200
- const identity = admit(store, repo, actor);
201
- const value = current(store, repo, identity);
203
+ const { identity, accepted } = admit(store, repo, actor);
204
+ const value = current(store, repo, accepted);
202
205
  const packages = Object.values(value.selection).filter(choice => choice.enabled).map(choice => packageOf(store, choice.sha));
203
206
  if (packages.length > SKILL_LIMITS.enabled || Buffer.byteLength(JSON.stringify(packages)) > SKILL_LIMITS.selectionBytes)
204
207
  throw Error('Enable up to 8 skills, totaling at most 2 MB.');
@@ -206,10 +209,10 @@ export function selectProjectSkills(store, repo, actor) {
206
209
  }
207
210
  /** The same verified selection used by Restore, including disabled versions. */
208
211
  export function skillsVersion(store, repo, actor, revision) {
209
- const identity = admit(store, repo, actor);
212
+ const { accepted } = admit(store, repo, actor);
210
213
  if (!Number.isSafeInteger(revision) || revision < 1)
211
214
  throw Error("Choose a saved skills version.");
212
- const row = store.handle.prepare("SELECT payload,sha FROM project_skill_change WHERE repo=? AND identity=? AND revision=?").get(repo, identity, revision);
215
+ const row = store.handle.prepare(`SELECT payload,sha FROM project_skill_change WHERE repo=? AND ${IN_ACCEPTED} AND revision=?`).get(repo, listOf(accepted), revision);
213
216
  if (!row)
214
217
  throw Error("That skills version is unavailable.");
215
218
  const selection = unpack(row["payload"], row["sha"]);
@@ -238,7 +241,7 @@ export function importSkill(store, repo, actor, files, source, now = new Date())
238
241
  return { ...skill, sha };
239
242
  }
240
243
  export function changeSkills(store, args, now = new Date()) {
241
- const identity = admit(store, args.repo, args.actor, true);
244
+ const { identity, accepted } = admit(store, args.repo, args.actor, true);
242
245
  if (identity !== args.identity || !Number.isSafeInteger(args.revision))
243
246
  throw Error("The project changed. Reload Skills.");
244
247
  store.transact(() => {
@@ -248,8 +251,8 @@ export function changeSkills(store, args, now = new Date()) {
248
251
  let selection = view.selection;
249
252
  if (args.action === "restore") {
250
253
  const row = store.handle
251
- .prepare("SELECT * FROM project_skill_change WHERE repo=? AND identity=? AND revision=?")
252
- .get(args.repo, identity, args.restore ?? -1);
254
+ .prepare(`SELECT * FROM project_skill_change WHERE repo=? AND ${IN_ACCEPTED} AND revision=?`)
255
+ .get(args.repo, listOf(accepted), args.restore ?? -1);
253
256
  if (!row)
254
257
  throw Error("That version is unavailable.");
255
258
  selection = unpack(row["payload"], row["sha"]);
@@ -316,7 +319,7 @@ export function readSkillsSnapshot(store, runId) {
316
319
  const saved = unpack(row["payload"], row["sha"]);
317
320
  const { packageShas, ...facts } = saved;
318
321
  if (packageShas.length &&
319
- facts.identity !== learningIdentity(String(row["repo"])))
322
+ !acceptedIdentities(store, String(row["repo"])).has(facts.identity))
320
323
  throw Error("The project identity saved for this run changed.");
321
324
  return {
322
325
  ...facts,
@@ -370,7 +373,11 @@ export function freezeSkills(store, runId) {
370
373
  if (inherited)
371
374
  snapshot = { ...inherited, inheritedFrom: parent.id };
372
375
  else if (!parent || (run.role !== "reviewer" && test)) {
373
- if (test && test["identity"] !== learningIdentity(ref.repo))
376
+ const saved = store.handle
377
+ .prepare("SELECT identity FROM project_skill_change WHERE repo=? ORDER BY revision DESC LIMIT 1")
378
+ .get(ref.repo);
379
+ const accepted = test || saved ? acceptedIdentities(store, ref.repo) : new Set();
380
+ if (test && !accepted.has(String(test["identity"])))
374
381
  throw Error("The project for this test changed. Create a new test.");
375
382
  if (test)
376
383
  snapshot = {
@@ -380,10 +387,9 @@ export function freezeSkills(store, runId) {
380
387
  inheritedFrom: null,
381
388
  packages: [packageOf(store, String(test["package"]))],
382
389
  };
383
- else if (store.handle
384
- .prepare("SELECT 1 FROM project_skill_change WHERE repo=?")
385
- .get(ref.repo)) {
386
- const value = current(store, ref.repo, learningIdentity(ref.repo));
390
+ // Skills saved under another identity wait for an approver; the run proceeds without them.
391
+ else if (saved && accepted.has(String(saved["identity"]))) {
392
+ const value = current(store, ref.repo, accepted);
387
393
  snapshot = {
388
394
  ...snapshot,
389
395
  revision: value.revision,
@@ -8,6 +8,19 @@ export declare const FOLLOW_UP_LOG = "follow-up check log v1";
8
8
  export declare const FOLLOW_UP_RECEIPT = "follow-up check receipt v1";
9
9
  export declare const ADD_TESTS_ACTION = "tests task filed";
10
10
  export type RunLevel = Exclude<CheckLevel, "off">;
11
+ /** How a batch check (batch-checks.ts) reached this result's outcome. `together`: one check on a temporary
12
+ * merge of `members`; `split`: its batch failed, so it was checked on its own; `conflict`: it couldn't be merged
13
+ * with the others, so it was checked on its own; `alone`: nothing joined it within the window. */
14
+ export type BatchFacts = {
15
+ mode: "together" | "split" | "conflict" | "alone";
16
+ tested: string;
17
+ base: string | null;
18
+ members: {
19
+ task: string;
20
+ run: number;
21
+ head: string;
22
+ }[];
23
+ };
11
24
  export type FollowUpCheck = {
12
25
  request: number;
13
26
  runId: number;
@@ -15,13 +28,19 @@ export type FollowUpCheck = {
15
28
  head: string;
16
29
  actor: string;
17
30
  at: string;
18
- why: "person" | "pull-request";
31
+ why: "person" | "pull-request" | "batch";
19
32
  /** The approved command's digest when checks were asked for: only that exact command runs. */
20
33
  digest: string;
21
34
  state: "waiting" | "running" | "passed" | "failed" | "not-run";
22
35
  exitCode: number | null;
23
36
  logArtifactId: number | null;
24
37
  note: string | null;
38
+ /** A batch request: the project base it is merged onto. */
39
+ base?: string | null;
40
+ /** The commit the check actually ran on: the result's own, or a temporary batch commit. Null until it ran. */
41
+ tested?: string | null;
42
+ /** From the sealed receipt of a batch check. */
43
+ batch?: BatchFacts | null;
25
44
  };
26
45
  type Refusal = {
27
46
  ok: false;
@@ -43,8 +62,9 @@ export declare function requestFollowUpChecks(store: Store, input: {
43
62
  level: RunLevel;
44
63
  existing: boolean;
45
64
  } | Refusal;
46
- /** Requests nobody has started yet, oldest first. */
65
+ /** Requests nobody has started yet, oldest first. Batch requests wait for the batch pass (batch-checks.ts). */
47
66
  export declare function waitingCheckRequests(store: Store, now: Date): FollowUpCheck[];
67
+ export declare function waitingRequestsOfAnyKind(store: Store, now: Date): FollowUpCheck[];
48
68
  export type CheckRunDeps = {
49
69
  runner?: Runner;
50
70
  git?: Runner;
@@ -47,28 +47,34 @@ export function followUpChecksOf(store, runId, now = new Date(), root) {
47
47
  const body = JSON.parse(read.content.toString("utf8"));
48
48
  if (typeof body.request !== "number")
49
49
  continue;
50
+ const head = typeof body.head === "string" ? body.head : null;
50
51
  receipts.set(body.request, { ran: body.result?.ran === true, exitCode: typeof body.result?.exitCode === "number" ? body.result.exitCode : null,
51
- log: typeof body.log?.artifactId === "number" ? body.log.artifactId : null });
52
+ log: typeof body.log?.artifactId === "number" ? body.log.artifactId : null, head,
53
+ tested: typeof body.tested === "string" ? body.tested : head, batch: batchFactsOf(body.batch, runId, head) });
52
54
  }
53
55
  catch { /* an unreadable receipt upgrades nothing */ }
54
56
  }
55
57
  }
56
58
  return rows.filter(row => row.action === CHECKS_REQUESTED && (row.outcome === "quick" || row.outcome === "full")).map(request => {
57
- const [head = "", why = "person", digest = ""] = (request.detail ?? "").split(" · ");
59
+ const [head = "", why = "person", digest = "", recordedBase = ""] = (request.detail ?? "").split(" · ");
58
60
  const started = rows.find(row => row.action === CHECKS_STARTED && row.outcome === String(request.id)) ?? null;
59
61
  const finished = rows.find(row => row.action === CHECKS_FINISHED && row.outcome.startsWith(`${request.id}:`)) ?? null;
60
62
  const base = { request: request.id, runId, level: request.outcome, head, digest, actor: request.actor, at: request.at,
61
- why: why === "pull-request" ? "pull-request" : "person", exitCode: null, logArtifactId: null, note: null };
63
+ why: why === "pull-request" ? "pull-request" : why === "batch" ? "batch" : "person", exitCode: null, logArtifactId: null, note: null,
64
+ ...(why === "batch" ? { base: /^[a-f0-9]{40}$/.test(recordedBase) ? recordedBase : null } : {}) };
62
65
  if (finished !== null) {
63
66
  const status = finished.outcome.slice(String(request.id).length + 1);
64
67
  const sealed = receipts.get(request.id) ?? null;
65
- // With the evidence at hand, only a sealed receipt can say passed.
66
- if (status === "passed" && root !== undefined && (sealed === null || !sealed.ran || sealed.exitCode !== 0)) {
68
+ // With the evidence at hand, only a sealed receipt can say passed: for this result's own commit, and,
69
+ // for a batch, a batch that names this result.
70
+ if (status === "passed" && root !== undefined && (sealed === null || !sealed.ran || sealed.exitCode !== 0 || (sealed.head !== null && sealed.head !== head)
71
+ || (base.why === "batch" && sealed.batch === null))) {
67
72
  return { ...base, state: "not-run", note: "The saved check result could not be verified." };
68
73
  }
69
74
  const exit = /exit (\d+)/.exec(finished.detail ?? "");
70
75
  return { ...base, state: status === "passed" ? "passed" : status === "failed" ? "failed" : "not-run",
71
- exitCode: exit === null ? sealed?.exitCode ?? null : Number(exit[1]), logArtifactId: sealed?.log ?? null, note: status === "not-run" ? finished.detail : null };
76
+ exitCode: exit === null ? sealed?.exitCode ?? null : Number(exit[1]), logArtifactId: sealed?.log ?? null, note: status === "not-run" ? finished.detail : null,
77
+ ...(sealed === null ? {} : { tested: sealed.ran ? sealed.tested : null, batch: sealed.batch }) };
72
78
  }
73
79
  if (started !== null) {
74
80
  const limit = Number(/limit (\d+)/.exec(started.detail ?? "")?.[1] ?? 3_600_000);
@@ -79,6 +85,26 @@ export function followUpChecksOf(store, runId, now = new Date(), root) {
79
85
  return { ...base, state: "waiting" };
80
86
  });
81
87
  }
88
+ /** A receipt's batch facts, only when they are whole and name this result at its own commit. */
89
+ function batchFactsOf(value, runId, head) {
90
+ if (value === null || typeof value !== "object")
91
+ return null;
92
+ const raw = value;
93
+ const modes = ["together", "split", "conflict", "alone"];
94
+ const mode = modes.find(one => one === raw.mode);
95
+ if (mode === undefined || typeof raw.tested !== "string" || !/^[a-f0-9]{40}$/.test(raw.tested) || !Array.isArray(raw.members))
96
+ return null;
97
+ const members = raw.members.flatMap(one => {
98
+ const m = one;
99
+ return typeof m?.task === "string" && typeof m.run === "number" && typeof m.head === "string" ? [{ task: m.task, run: m.run, head: m.head }] : [];
100
+ });
101
+ if (members.length !== raw.members.length || !members.some(one => one.run === runId && one.head === head))
102
+ return null;
103
+ // Checked on its own: the commit tested is the result's own.
104
+ if (mode !== "together" && (members.length !== 1 || raw.tested !== head))
105
+ return null;
106
+ return { mode, tested: raw.tested, base: typeof raw.base === "string" ? raw.base : null, members };
107
+ }
82
108
  /** Whether a result can have checks run on it, and with which command. */
83
109
  function checkTarget(store, runId, level) {
84
110
  const run = store.getRun(runId);
@@ -110,8 +136,11 @@ export function requestFollowUpChecks(store, input, now) {
110
136
  return { ok: true, request, level: target.level, existing: false };
111
137
  });
112
138
  }
113
- /** Requests nobody has started yet, oldest first. */
139
+ /** Requests nobody has started yet, oldest first. Batch requests wait for the batch pass (batch-checks.ts). */
114
140
  export function waitingCheckRequests(store, now) {
141
+ return waitingRequestsOfAnyKind(store, now).filter(one => one.why !== "batch");
142
+ }
143
+ export function waitingRequestsOfAnyKind(store, now) {
115
144
  const runs = store.handle.prepare(`SELECT DISTINCT r.run_id AS run FROM action_ledger r WHERE r.action = ? AND r.run_id IS NOT NULL
116
145
  AND NOT EXISTS (SELECT 1 FROM action_ledger s WHERE s.run_id = r.run_id AND s.action = ? AND s.outcome = CAST(r.id AS TEXT)) ORDER BY r.id`)
117
146
  .all(CHECKS_REQUESTED, CHECKS_STARTED).map(row => Number(row["run"]));
@@ -204,7 +233,7 @@ export async function runFollowUpCheck(store, root, request, deps = {}) {
204
233
  const text = log.length === 0 ? "No command started." : log.join("\n\n");
205
234
  const logId = storeEvidence(store, root, runId, "structured-output", `follow-up-check-${request}.log`, Buffer.from(text, "utf8"), FOLLOW_UP_LOG, at, { captureStatus: "ok" });
206
235
  const logArtifact = store.getArtifact(logId);
207
- const receipt = JSON.stringify({ version: 1, request, run: runId, head: target.head, level: target.level,
236
+ const receipt = JSON.stringify({ version: 1, request, run: runId, head: target.head, tested: target.head, level: target.level,
208
237
  command: { repo: target.command.repo, command: target.command.command, digest: target.command.digest, approvedBy: target.command.approvedBy },
209
238
  result, log: { artifactId: logArtifact.id, sha256: logArtifact.sha256, bytesStored: logArtifact.bytesStored, truncated: logArtifact.truncated } }, null, 1);
210
239
  storeEvidence(store, root, runId, "structured-output", `follow-up-check-${request}.json`, Buffer.from(receipt, "utf8"), FOLLOW_UP_RECEIPT, at, { captureStatus: "ok" });
@@ -262,9 +291,14 @@ export function fullCheckGate(store, runId, now) {
262
291
  const level = runCheckLevel(store, runId);
263
292
  const repo = repoOfRun(store, runId);
264
293
  const run = store.getRun(runId);
265
- if (level === null || level === "full" || repo === null || run === null || store.liveVerifyCommand(repo) === null)
294
+ if (level === null || repo === null || run === null)
295
+ return { state: "clear" };
296
+ const fulls = followUpChecksOf(store, runId, now).filter(one => one.level === "full" && one.head === run.headRevision);
297
+ // A Full build that waited for a batch check has had no check of its own: Merge waits for the batch, like Quick does.
298
+ const batched = level === "full" && fulls.some(one => one.why === "batch");
299
+ if ((level === "full" && !batched) || (!batched && store.liveVerifyCommand(repo) === null))
266
300
  return { state: "clear" };
267
- const last = followUpChecksOf(store, runId, now).filter(one => one.level === "full" && one.head === run.headRevision).at(-1);
301
+ const last = fulls.at(-1);
268
302
  if (last === undefined)
269
303
  return { state: "not-requested", message: "The full check hasn't run on this commit yet. Merge once it passes, or merge anyway." };
270
304
  if (last.state === "passed")