@nocobase/app-plugin-agents 0.1.0-beta.10 → 0.1.0-beta.11

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 (156) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +23 -1
  3. package/dist/client/locales/en-US.d.ts +1 -0
  4. package/dist/client/locales/en-US.d.ts.map +1 -1
  5. package/dist/client/locales/en-US.js +1 -0
  6. package/dist/client/locales/en-US.js.map +1 -1
  7. package/dist/client/locales/runtimes.en-US.d.ts +0 -1
  8. package/dist/client/locales/runtimes.en-US.d.ts.map +1 -1
  9. package/dist/client/locales/runtimes.en-US.js +0 -1
  10. package/dist/client/locales/runtimes.en-US.js.map +1 -1
  11. package/dist/client/locales/runtimes.zh-CN.d.ts.map +1 -1
  12. package/dist/client/locales/runtimes.zh-CN.js +0 -1
  13. package/dist/client/locales/runtimes.zh-CN.js.map +1 -1
  14. package/dist/client/locales/skills.en-US.d.ts +1 -0
  15. package/dist/client/locales/skills.en-US.d.ts.map +1 -1
  16. package/dist/client/locales/skills.en-US.js +2 -1
  17. package/dist/client/locales/skills.en-US.js.map +1 -1
  18. package/dist/client/locales/skills.zh-CN.d.ts.map +1 -1
  19. package/dist/client/locales/skills.zh-CN.js +2 -1
  20. package/dist/client/locales/skills.zh-CN.js.map +1 -1
  21. package/dist/client/locales/zh-CN.d.ts.map +1 -1
  22. package/dist/client/locales/zh-CN.js +1 -0
  23. package/dist/client/locales/zh-CN.js.map +1 -1
  24. package/dist/client/pages/runtimes/workspace-diagnostics.d.ts.map +1 -1
  25. package/dist/client/pages/runtimes/workspace-diagnostics.js +3 -5
  26. package/dist/client/pages/runtimes/workspace-diagnostics.js.map +1 -1
  27. package/dist/client/pages/skills/detail/index.d.ts.map +1 -1
  28. package/dist/client/pages/skills/detail/index.js +7 -10
  29. package/dist/client/pages/skills/detail/index.js.map +1 -1
  30. package/dist/client/pages/skills/index.d.ts.map +1 -1
  31. package/dist/client/pages/skills/index.js +2 -2
  32. package/dist/client/pages/skills/index.js.map +1 -1
  33. package/dist/database/migrations/.manifest.json +14 -2
  34. package/dist/database/migrations/202610020003_ag_create_runs.d.ts.map +1 -1
  35. package/dist/database/migrations/202610020003_ag_create_runs.js +0 -1
  36. package/dist/database/migrations/202610020003_ag_create_runs.js.map +1 -1
  37. package/dist/database/migrations/202610250001_ag_comment_runner_consent.d.ts +4 -0
  38. package/dist/database/migrations/202610250001_ag_comment_runner_consent.d.ts.map +1 -0
  39. package/dist/database/migrations/202610250001_ag_comment_runner_consent.js +41 -0
  40. package/dist/database/migrations/202610250001_ag_comment_runner_consent.js.map +1 -0
  41. package/dist/database/migrations/202610260001_ag_drop_directory_index.d.ts +4 -0
  42. package/dist/database/migrations/202610260001_ag_drop_directory_index.d.ts.map +1 -0
  43. package/dist/database/migrations/202610260001_ag_drop_directory_index.js +24 -0
  44. package/dist/database/migrations/202610260001_ag_drop_directory_index.js.map +1 -0
  45. package/dist/database/migrations/202610260001_ag_run_revision.d.ts +4 -0
  46. package/dist/database/migrations/202610260001_ag_run_revision.d.ts.map +1 -0
  47. package/dist/database/migrations/202610260001_ag_run_revision.js +19 -0
  48. package/dist/database/migrations/202610260001_ag_run_revision.js.map +1 -0
  49. package/dist/package.json +1 -1
  50. package/dist/server/composition.d.ts +2 -0
  51. package/dist/server/composition.d.ts.map +1 -1
  52. package/dist/server/composition.js +13 -1
  53. package/dist/server/composition.js.map +1 -1
  54. package/dist/server/core/agents/agent.store.d.ts +1 -1
  55. package/dist/server/core/agents/agent.store.d.ts.map +1 -1
  56. package/dist/server/core/agents/agent.store.js +3 -6
  57. package/dist/server/core/agents/agent.store.js.map +1 -1
  58. package/dist/server/core/brief/brief.js +1 -1
  59. package/dist/server/core/brief/brief.js.map +1 -1
  60. package/dist/server/core/runs/claim.d.ts +15 -3
  61. package/dist/server/core/runs/claim.d.ts.map +1 -1
  62. package/dist/server/core/runs/claim.js +257 -24
  63. package/dist/server/core/runs/claim.js.map +1 -1
  64. package/dist/server/core/runs/comment-consent.d.ts +24 -0
  65. package/dist/server/core/runs/comment-consent.d.ts.map +1 -0
  66. package/dist/server/core/runs/comment-consent.js +116 -0
  67. package/dist/server/core/runs/comment-consent.js.map +1 -0
  68. package/dist/server/core/runs/identity.d.ts +3 -0
  69. package/dist/server/core/runs/identity.d.ts.map +1 -1
  70. package/dist/server/core/runs/identity.js +38 -12
  71. package/dist/server/core/runs/identity.js.map +1 -1
  72. package/dist/server/core/runs/input-gate.d.ts.map +1 -1
  73. package/dist/server/core/runs/input-gate.js +4 -0
  74. package/dist/server/core/runs/input-gate.js.map +1 -1
  75. package/dist/server/core/runs/lifecycle.d.ts.map +1 -1
  76. package/dist/server/core/runs/lifecycle.js +24 -18
  77. package/dist/server/core/runs/lifecycle.js.map +1 -1
  78. package/dist/server/core/runs/ports.d.ts +8 -1
  79. package/dist/server/core/runs/ports.d.ts.map +1 -1
  80. package/dist/server/core/runs/ports.js.map +1 -1
  81. package/dist/server/core/runs/run-data-purge.d.ts +15 -0
  82. package/dist/server/core/runs/run-data-purge.d.ts.map +1 -0
  83. package/dist/server/core/runs/run-data-purge.js +82 -0
  84. package/dist/server/core/runs/run-data-purge.js.map +1 -0
  85. package/dist/server/core/runs/run-requests.d.ts +25 -0
  86. package/dist/server/core/runs/run-requests.d.ts.map +1 -1
  87. package/dist/server/core/runs/run-requests.js +131 -63
  88. package/dist/server/core/runs/run-requests.js.map +1 -1
  89. package/dist/server/core/runs/run.service.d.ts +12 -0
  90. package/dist/server/core/runs/run.service.d.ts.map +1 -1
  91. package/dist/server/core/runs/run.service.js +518 -33
  92. package/dist/server/core/runs/run.service.js.map +1 -1
  93. package/dist/server/core/runs/run.store.d.ts +5 -0
  94. package/dist/server/core/runs/run.store.d.ts.map +1 -1
  95. package/dist/server/core/runs/run.store.js +3 -0
  96. package/dist/server/core/runs/run.store.js.map +1 -1
  97. package/dist/server/core/runs/sweeper.d.ts +1 -0
  98. package/dist/server/core/runs/sweeper.d.ts.map +1 -1
  99. package/dist/server/core/runs/sweeper.js +1 -0
  100. package/dist/server/core/runs/sweeper.js.map +1 -1
  101. package/dist/server/core/runs/transitions.d.ts +1 -1
  102. package/dist/server/core/runs/transitions.d.ts.map +1 -1
  103. package/dist/server/core/runs/transitions.js +11 -3
  104. package/dist/server/core/runs/transitions.js.map +1 -1
  105. package/dist/server/core/skills/skill.service.d.ts +6 -2
  106. package/dist/server/core/skills/skill.service.d.ts.map +1 -1
  107. package/dist/server/core/skills/skill.service.js +43 -58
  108. package/dist/server/core/skills/skill.service.js.map +1 -1
  109. package/dist/server/core/skills/skill.store.d.ts +14 -0
  110. package/dist/server/core/skills/skill.store.d.ts.map +1 -1
  111. package/dist/server/core/skills/skill.store.js +30 -0
  112. package/dist/server/core/skills/skill.store.js.map +1 -1
  113. package/dist/server/kernel/scopes.d.ts.map +1 -1
  114. package/dist/server/kernel/scopes.js +3 -1
  115. package/dist/server/kernel/scopes.js.map +1 -1
  116. package/dist/server/providers/agents.d.ts +4 -0
  117. package/dist/server/providers/agents.d.ts.map +1 -1
  118. package/dist/server/providers/agents.js +3 -0
  119. package/dist/server/providers/agents.js.map +1 -1
  120. package/dist/server/providers/runners.d.ts.map +1 -1
  121. package/dist/server/providers/runners.js +23 -0
  122. package/dist/server/providers/runners.js.map +1 -1
  123. package/dist/server/routes/admin.d.ts.map +1 -1
  124. package/dist/server/routes/admin.js +18 -8
  125. package/dist/server/routes/admin.js.map +1 -1
  126. package/dist/server/routes/run-requests.js +3 -3
  127. package/dist/server/routes/run-requests.js.map +1 -1
  128. package/dist/server/routes/runners/runner.d.ts.map +1 -1
  129. package/dist/server/routes/runners/runner.js +2 -0
  130. package/dist/server/routes/runners/runner.js.map +1 -1
  131. package/dist/server/routes/schemas.d.ts.map +1 -1
  132. package/dist/server/routes/schemas.js +14 -2
  133. package/dist/server/routes/schemas.js.map +1 -1
  134. package/dist/server/runners/runner.service.d.ts +11 -1
  135. package/dist/server/runners/runner.service.d.ts.map +1 -1
  136. package/dist/server/runners/runner.service.js +19 -1
  137. package/dist/server/runners/runner.service.js.map +1 -1
  138. package/dist/server/tokens.d.ts +1 -0
  139. package/dist/server/tokens.d.ts.map +1 -1
  140. package/dist/server/tokens.js +1 -0
  141. package/dist/server/tokens.js.map +1 -1
  142. package/dist/shared/agents.d.ts +0 -2
  143. package/dist/shared/agents.d.ts.map +1 -1
  144. package/dist/shared/agents.js.map +1 -1
  145. package/dist/shared/runs.d.ts +10 -0
  146. package/dist/shared/runs.d.ts.map +1 -1
  147. package/dist/shared/runs.js.map +1 -1
  148. package/dist/shared/skills.d.ts +8 -4
  149. package/dist/shared/skills.d.ts.map +1 -1
  150. package/dist/shared/skills.js +7 -2
  151. package/dist/shared/skills.js.map +1 -1
  152. package/dist/shared/variables.js +1 -1
  153. package/dist/shared/variables.js.map +1 -1
  154. package/package.json +8 -8
  155. package/skills/nocobase-app-plugin-agents/references/extension-points.md +19 -19
  156. package/skills/nocobase-app-plugin-agents/references/variables-skills-usage.md +3 -1
@@ -9,16 +9,17 @@
9
9
  */
10
10
  import { ProtocolError, TERMINAL_RUN_STATUSES, } from '@nocobase/agent-protocol';
11
11
  import { conflict, forbidden, invalid, notFound, precondition, } from '../../kernel/errors.js';
12
- import { asJson, cleanList, jsonObject } from '../../kernel/values.js';
12
+ import { asJson, cleanList, jsonObject, stringArray, } from '../../kernel/values.js';
13
13
  import { findAgent, lockAgentForClaim } from '../agents/index.js';
14
14
  import { agentNamesOf } from '../agents/agent.store.js';
15
15
  import { createRunRequestService, insertRunRequest, } from './run-requests.js';
16
16
  import { authenticateRunToken } from './run-tokens.js';
17
- import { ACTIVE, eventsRepo, findRunRecord, inputsOnSince, inputsRepo, isActive, isTerminal, lastSummary, openRunsOn, pendingInputs, reposRepo, runsOfKey, runsRepo, toInput, toInputView, toRepo, toRun, usageRepo, } from './run.store.js';
17
+ import { ACTIVE, eventsRepo, findRunRecord, inputsOnSince, inputsRepo, isActive, isTerminal, lastSummary, openRunsOn, openRunsOnEach, pendingInputs, reposRepo, runsOfKey, runsRepo, toInput, toInputView, toRepo, toRun, usageRepo, } from './run.store.js';
18
18
  import { readWorkload } from './workload.js';
19
19
  import { finishRun } from './transitions.js';
20
20
  import { gateInputs, gatePending } from './input-gate.js';
21
21
  import { findBrief, requestReset } from './workspace.store.js';
22
+ import { findCommentRoot, currentCommentOwner, commentConsent, commentFromOwner, } from './comment-consent.js';
22
23
  export const DEFAULT_THREAD = 'main';
23
24
  /**
24
25
  * The run work went into, for a caller that names no responsible (a conversation, a consultation): such work is never
@@ -31,7 +32,9 @@ export function queuedRun(result) {
31
32
  }
32
33
  /** Who a chain of work a run belongs to started with: who confirmed it, else who it was requested by. */
33
34
  function chainSourceOf(run) {
34
- return run.confirmedByUserId ?? run.requestedByUserId ?? run.actorUserId;
35
+ return run.commentRootRunId
36
+ ? (run.requestedByUserId ?? run.actorUserId)
37
+ : (run.confirmedByUserId ?? run.requestedByUserId ?? run.actorUserId);
35
38
  }
36
39
  /** A delay as the moment a run may be claimed, or null for now (and for any moment already past). */
37
40
  function fireAtOf(value, now) {
@@ -42,8 +45,20 @@ function fireAtOf(value, now) {
42
45
  throw invalid('fireAt is not a date.');
43
46
  return at.getTime() > now.getTime() ? at.toISOString() : null;
44
47
  }
48
+ const COMMENT_CANCEL_DETAIL = 'The personal runner request was declined or switched to the commenter.';
45
49
  export function createRunService(deps) {
46
50
  const { tx, ids, clock } = deps;
51
+ /** Ends one unfinished run of a declined chain; a run that moved since it was read rolls the decision back. */
52
+ async function endCommentRun(unit, run, byUserId) {
53
+ const ended = await finishRun(unit, deps, run, {
54
+ status: 'cancelled',
55
+ reason: null,
56
+ detail: COMMENT_CANCEL_DETAIL,
57
+ cancelledById: byUserId,
58
+ });
59
+ if (!ended)
60
+ throw conflict('The comment work changed; read it again.');
61
+ }
47
62
  const require = async (conn, id) => {
48
63
  const run = await findRunRecord(conn, id);
49
64
  if (!run)
@@ -104,6 +119,11 @@ export function createRunService(deps) {
104
119
  subjectKind: values.subject.kind,
105
120
  subjectId: values.subject.id,
106
121
  threadScope: values.threadScope,
122
+ commentRootRunId: values.commentRootRunId === ''
123
+ ? id
124
+ : (values.commentRootRunId ?? null),
125
+ commentExecution: values.commentExecution ?? null,
126
+ commentOwnerKey: values.commentOwnerKey ?? null,
107
127
  actorUserId: values.actorUserId,
108
128
  requestedByUserId: values.requestedByUserId,
109
129
  confirmedByUserId: values.confirmedByUserId,
@@ -207,13 +227,93 @@ export function createRunService(deps) {
207
227
  actualModels: models.get(run.id) ?? [],
208
228
  });
209
229
  }
230
+ /** Cross-root appends require both sources to retain the same owner's authorization. */
231
+ async function equivalentCommentAuthority(unit, run, work) {
232
+ if (run.ownerUserId !== work.ownerUserId ||
233
+ (run.commentOwnerKey ?? null) !== (work.commentOwnerKey ?? null) ||
234
+ (run.commentExecution ?? 'owner') !== (work.commentExecution ?? 'owner'))
235
+ return false;
236
+ if ((run.commentRootRunId ?? null) === (work.commentRootRunId ?? null)) {
237
+ if (!run.commentRootRunId)
238
+ return true;
239
+ const root = await findCommentRoot(unit.conn, run.commentRootRunId);
240
+ // Requester execution keeps its own identity; it does not borrow the owner's consent.
241
+ if (root.commentExecution === 'requester')
242
+ return (root.actorUserId === work.actorUserId &&
243
+ (await currentCommentOwner(unit.conn, deps.subjects, root)));
244
+ return await authorizedRoot(root);
245
+ }
246
+ if (work.commentExecution === 'requester' ||
247
+ run.commentExecution === 'requester' ||
248
+ !work.sourceRunId)
249
+ return false;
250
+ const source = await findRunRecord(unit.conn, work.sourceRunId);
251
+ if (!source ||
252
+ source.subjectKind !== run.subjectKind ||
253
+ source.subjectId !== run.subjectId ||
254
+ source.ownerUserId !== run.ownerUserId ||
255
+ source.actorUserId !== run.actorUserId)
256
+ return false;
257
+ const sourceRoot = source.commentRootRunId
258
+ ? await findCommentRoot(unit.conn, source.commentRootRunId)
259
+ : source;
260
+ const targetRoot = run.commentRootRunId
261
+ ? await findCommentRoot(unit.conn, run.commentRootRunId)
262
+ : run;
263
+ return ((await authorizedRoot(sourceRoot)) && (await authorizedRoot(targetRoot)));
264
+ async function authorizedRoot(root) {
265
+ if (!(await currentCommentOwner(unit.conn, deps.subjects, root)))
266
+ return false;
267
+ if (!root.commentRootRunId)
268
+ return chainSourceOf(root) === root.ownerUserId;
269
+ const { decision } = await commentConsent(unit.conn, root);
270
+ if (decision === 'pending' || decision === 'rejected')
271
+ return false;
272
+ return (decision === 'confirmed' || (await commentFromOwner(unit.conn, root)));
273
+ }
274
+ }
210
275
  /**
211
276
  * Queues `work` for `agent`: it joins the run working on its key while that run can still be told, else the run
212
277
  * waiting for it, else starts a new run.
213
278
  */
214
279
  async function queue(unit, agent, work) {
280
+ if (work.commentRootRunId) {
281
+ // A plain read: the root's subject never changes, and the agent lock below serializes the chain's enqueues.
282
+ const root = await findCommentRoot(unit.conn, work.commentRootRunId);
283
+ if (root.subjectKind !== work.subject.kind ||
284
+ root.subjectId !== work.subject.id)
285
+ throw invalid('A comment authorization cannot cross subjects.');
286
+ }
215
287
  // Serializes enqueues and claims for this agent, so a key never gets two waiting runs.
216
288
  await lockAgentForClaim(unit.conn, agent.id, clock.now().toISOString());
289
+ if (work.preferActive) {
290
+ const commentId = jsonObject(work.input.payload).commentId;
291
+ if (typeof commentId === 'string') {
292
+ const prior = await inputsRepo(unit.conn).findOne({
293
+ filter: (f) => f.and([
294
+ f.string('type').eq('comment'),
295
+ f.json('payload').path(['commentId']).eq(commentId),
296
+ f
297
+ .relation('run')
298
+ .some((r) => r.and([
299
+ r.string('agentId').eq(agent.id),
300
+ r.string('subjectKind').eq(work.subject.kind),
301
+ r.string('subjectId').eq(work.subject.id),
302
+ ])),
303
+ ]),
304
+ });
305
+ if (prior) {
306
+ const run = (await findRunRecord(unit.conn, prior.runId));
307
+ return {
308
+ runId: run.id,
309
+ inputId: prior.id,
310
+ outcome: 'appended',
311
+ status: run.status,
312
+ duplicate: true,
313
+ };
314
+ }
315
+ }
316
+ }
217
317
  const key = {
218
318
  agentId: agent.id,
219
319
  subjectKind: work.subject.kind,
@@ -222,24 +322,87 @@ export function createRunService(deps) {
222
322
  };
223
323
  // Only into work done as the same person: merged into another's run, it would borrow their identity, or wait
224
324
  // for a runner that never takes it.
225
- const open = await runsOfKey(unit.conn, key, ['queued', ...ACTIVE], work.actorUserId);
226
- const candidate = open.find((run) => !work.freshSession &&
227
- (run.status === 'dispatched' ||
228
- (run.status === 'running' && Boolean(run.acceptsInput))));
229
- // Joins the working run only when the subject would let the agent act on it now; otherwise it waits in a queued run,
230
- // which the claim admits or cancels.
231
- const working = candidate &&
232
- (await gateInputs(unit.conn, deps, candidate, agent, [
233
- {
325
+ const unfiltered = await runsOfKey(unit.conn, key, ['queued', ...ACTIVE], work.actorUserId);
326
+ const open = unfiltered.filter((run) => (run.commentRootRunId ?? null) === (work.commentRootRunId ?? null) &&
327
+ (run.commentExecution ?? null) === (work.commentExecution ?? null));
328
+ let working;
329
+ for (let candidate of work.preferActive ? unfiltered : open) {
330
+ if (work.freshSession ||
331
+ candidate.cancelRequestedAt ||
332
+ !(candidate.status === 'dispatched' ||
333
+ (candidate.status === 'running' && Boolean(candidate.acceptsInput))))
334
+ continue;
335
+ if (work.preferActive) {
336
+ if (!candidate.runnerId ||
337
+ !['claude', 'codex', 'pi', 'opencode'].includes(candidate.tool ?? ''))
338
+ continue;
339
+ const runner = await deps.runners.find(unit.conn, candidate.runnerId);
340
+ if (!runner?.features.includes('steer'))
341
+ continue;
342
+ }
343
+ for (let attempt = 0; attempt < 4; attempt += 1) {
344
+ if (work.preferActive &&
345
+ !(await equivalentCommentAuthority(unit, candidate, work)))
346
+ break;
347
+ const input = {
234
348
  id: '',
235
349
  at: clock.now().toISOString(),
236
350
  ...work.input,
237
- },
238
- ])).length > 0
239
- ? candidate
240
- : undefined;
351
+ ...(candidate.commentRootRunId
352
+ ? {
353
+ payload: {
354
+ ...jsonObject(work.input.payload),
355
+ commentRootRunId: candidate.commentRootRunId,
356
+ },
357
+ }
358
+ : {}),
359
+ };
360
+ if ((await gateInputs(unit.conn, deps, candidate, agent, [input]))
361
+ .length === 0)
362
+ break;
363
+ const touched = await runsRepo(unit.conn).updateMany({
364
+ filter: (f) => f.and([
365
+ f.string('id').eq(candidate.id),
366
+ f.string('status').eq(candidate.status),
367
+ f.number('revision').eq(candidate.revision),
368
+ f.date('cancelRequestedAt').empty(),
369
+ ]),
370
+ values: { updatedAt: clock.now().toISOString() },
371
+ });
372
+ if (touched.updatedCount === 1) {
373
+ working = candidate;
374
+ break;
375
+ }
376
+ const current = await findRunRecord(unit.conn, candidate.id);
377
+ if (!current ||
378
+ current.cancelRequestedAt ||
379
+ !(current.status === 'dispatched' ||
380
+ (current.status === 'running' && current.acceptsInput)))
381
+ break;
382
+ // A claim/start may change the holder or tool; re-evaluate those on a subsequent dispatch.
383
+ if (current.actorUserId !== work.actorUserId ||
384
+ current.threadScope !== work.threadScope ||
385
+ current.runnerId !== candidate.runnerId ||
386
+ current.tool !== candidate.tool)
387
+ break;
388
+ candidate = current;
389
+ if (attempt === 3)
390
+ throw conflict('The run revision changed during append; retry the dispatch.');
391
+ }
392
+ if (working)
393
+ break;
394
+ }
241
395
  const waiting = open.find((run) => run.status === 'queued');
242
396
  const target = working ?? waiting;
397
+ const commentInput = (rootId) => rootId
398
+ ? {
399
+ ...work.input,
400
+ payload: {
401
+ ...jsonObject(work.input.payload),
402
+ commentRootRunId: rootId,
403
+ },
404
+ }
405
+ : work.input;
243
406
  if (!working && waiting)
244
407
  await hurry(unit, waiting, work.priority, work.fireAt);
245
408
  if (target) {
@@ -248,7 +411,7 @@ export function createRunService(deps) {
248
411
  filter: { id: target.id, status: 'queued' },
249
412
  values: { freshSession: true, updatedAt: clock.now().toISOString() },
250
413
  });
251
- const inputId = await insertInput(unit, target.id, work.input);
414
+ const inputId = await insertInput(unit, target.id, commentInput(target.commentRootRunId));
252
415
  return {
253
416
  runId: target.id,
254
417
  outcome: working ? 'appended' : 'merged',
@@ -257,6 +420,9 @@ export function createRunService(deps) {
257
420
  };
258
421
  }
259
422
  const runId = await createRun(unit, agent, {
423
+ commentRootRunId: work.commentRootRunId,
424
+ commentExecution: work.commentExecution,
425
+ commentOwnerKey: work.commentOwnerKey,
260
426
  freshSession: work.freshSession,
261
427
  subject: work.subject,
262
428
  threadScope: work.threadScope,
@@ -271,16 +437,23 @@ export function createRunService(deps) {
271
437
  ...(work.parentRunId ? { parentRunId: work.parentRunId } : {}),
272
438
  ...(work.maxAttempts ? { maxAttempts: work.maxAttempts } : {}),
273
439
  });
274
- const inputId = await insertInput(unit, runId, work.input);
440
+ const inputId = await insertInput(unit, runId, commentInput(work.commentRootRunId === '' ? runId : work.commentRootRunId));
275
441
  return { runId, outcome: 'created', status: 'queued', inputId };
276
442
  }
277
443
  /** The source of the chain of work a request belongs to (`EnqueueRequest.requestedByUserId`). */
278
444
  async function sourceOf(unit, request) {
445
+ if (request.comment &&
446
+ request.input.actor.kind === 'user' &&
447
+ jsonObject(request.input.payload).byAgent !== true &&
448
+ (request.requestedByUserId ?? request.actorUserId))
449
+ return (request.requestedByUserId ?? request.actorUserId);
279
450
  if (request.causedByRunId) {
280
451
  const cause = await findRunRecord(unit.conn, request.causedByRunId);
281
452
  if (!cause)
282
453
  throw invalid('causedByRunId names no run.');
283
- return chainSourceOf(cause);
454
+ return request.comment
455
+ ? (cause.requestedByUserId ?? cause.actorUserId)
456
+ : chainSourceOf(cause);
284
457
  }
285
458
  const source = request.requestedByUserId ??
286
459
  request.actorUserId ??
@@ -289,13 +462,15 @@ export function createRunService(deps) {
289
462
  throw invalid('Name who woke the agent: actorUserId, requestedByUserId or responsibleUserId.');
290
463
  return source;
291
464
  }
292
- async function selectedIdentity(unit, request) {
465
+ async function selectedIdentity(unit, request, commentExecution) {
293
466
  const identity = await deps.subjects
294
467
  .get(request.subject.kind)
295
468
  ?.actsAs?.(unit.conn, {
296
469
  subjectId: request.subject.id,
297
470
  agentId: request.agentId,
298
471
  causedByRunId: request.causedByRunId,
472
+ commentExecution,
473
+ actorUserId: request.actorUserId,
299
474
  inputs: [request.input],
300
475
  });
301
476
  if (identity && !identity.actorUserId)
@@ -322,11 +497,185 @@ export function createRunService(deps) {
322
497
  mayInvoke: (agent, userId) => deps.agents.mayInvoke(agent, userId),
323
498
  requireInvocable,
324
499
  requireRunnerFor,
500
+ prepareCommentActor: async (record, byUserId) => {
501
+ const mayRead = deps.subjects.get(record.subjectKind)?.commentActorMayRead;
502
+ if (mayRead && !(await mayRead(record.subjectId, byUserId)))
503
+ throw forbidden('The acting user may no longer read this issue.');
504
+ },
505
+ checkCommentRequest: async (unit, record) => {
506
+ const root = await findCommentRoot(unit.conn, record.commentRootRunId);
507
+ if (!(await currentCommentOwner(unit.conn, deps.subjects, root)) ||
508
+ root.commentExecution !== 'owner' ||
509
+ root.ownerUserId !== record.responsibleUserId ||
510
+ (root.commentOwnerKey ?? null) !== (record.commentOwnerKey ?? null))
511
+ throw precondition('RUN_REQUEST_SETTLED', 'This comment request belongs to an earlier owner assignment.');
512
+ },
513
+ releaseCommentRequest: async (unit, record, byUserId) => {
514
+ const run = await findRunRecord(unit.conn, record.commentRunId);
515
+ if (!run ||
516
+ run.status !== 'queued' ||
517
+ run.commentRootRunId !== record.commentRootRunId)
518
+ throw precondition('RUN_REQUEST_SETTLED', 'The requested work is no longer waiting.');
519
+ const inputs = await pendingInputs(unit.conn, run.id);
520
+ const now = clock.now().toISOString();
521
+ // Guarded on the requested run itself: a claim that took it first wins, and this confirmation rolls back.
522
+ const released = await runsRepo(unit.conn).updateMany({
523
+ filter: { id: run.id, status: 'queued' },
524
+ values: { confirmedByUserId: byUserId, updatedAt: now },
525
+ });
526
+ if (released.updatedCount !== 1)
527
+ throw precondition('RUN_REQUEST_SETTLED', 'The requested work is no longer waiting.');
528
+ await runsRepo(unit.conn).updateMany({
529
+ filter: {
530
+ commentRootRunId: record.commentRootRunId,
531
+ status: 'queued',
532
+ actorUserId: record.responsibleUserId,
533
+ },
534
+ values: { confirmedByUserId: byUserId, updatedAt: now },
535
+ });
536
+ unit.emit({ type: 'run.changed', runId: run.id, status: 'queued' });
537
+ return {
538
+ runId: run.id,
539
+ outcome: 'merged',
540
+ status: 'queued',
541
+ inputId: inputs[0].id,
542
+ };
543
+ },
544
+ cancelCommentRequest: async (unit, record, byUserId) => {
545
+ for (const run of await runsRepo(unit.conn).findMany({
546
+ filter: { commentRootRunId: record.commentRootRunId },
547
+ }))
548
+ if (!isTerminal(run.status))
549
+ await endCommentRun(unit, run, byUserId);
550
+ },
551
+ runCommentAsRequester: async (unit, record, byUserId) => {
552
+ // Queuing the replacements takes their agents' rows, as claims do before they take a run. Taking every agent of
553
+ // the chain first, in one order, keeps this from holding a run a claim waits on while it waits for that claim.
554
+ const agentIds = new Set((await runsRepo(unit.conn).findMany({
555
+ filter: { commentRootRunId: record.commentRootRunId },
556
+ })).map((run) => run.agentId));
557
+ for (const agentId of [...agentIds].sort())
558
+ await lockAgentForClaim(unit.conn, agentId, clock.now().toISOString());
559
+ const chain = await runsRepo(unit.conn).findMany({
560
+ filter: { commentRootRunId: record.commentRootRunId },
561
+ sort: (sort) => [sort.field('createdAt').asc(), sort.field('id').asc()],
562
+ });
563
+ const targets = [];
564
+ for (const old of chain) {
565
+ // Rejection ends the queued chain, but leaves its inputs unhandled. Do not revive independently ended work.
566
+ const declined = record.status === 'rejected' &&
567
+ old.status === 'cancelled' &&
568
+ old.cancelledById === record.responsibleUserId &&
569
+ old.failureDetail === COMMENT_CANCEL_DETAIL;
570
+ if (isTerminal(old.status) && !declined)
571
+ continue;
572
+ const inputs = await pendingInputs(unit.conn, old.id);
573
+ if (inputs.length === 0)
574
+ continue;
575
+ await selectedIdentity(unit, {
576
+ agentId: old.agentId,
577
+ subject: { kind: old.subjectKind, id: old.subjectId },
578
+ input: toInput(inputs[0]),
579
+ actorUserId: byUserId,
580
+ }, 'requester');
581
+ const agent = await requireInvocable(unit, old.agentId, byUserId);
582
+ const requires = stringArray(old.requires);
583
+ await requireRunnerFor(unit, agent, byUserId, requires);
584
+ targets.push({ old, inputs, agent, requires });
585
+ }
586
+ if (!targets.length)
587
+ throw precondition('RUN_REQUEST_SETTLED', 'No comment work is still waiting.');
588
+ // Validate all targets before stopping any of them. Each stop is guarded on the status read, so a run a claim took
589
+ // meanwhile rolls this back rather than being replaced twice; the settled request covers repeat clicks.
590
+ for (const old of chain)
591
+ if (!isTerminal(old.status))
592
+ await endCommentRun(unit, old, byUserId);
593
+ const replacements = new Map();
594
+ let rootId;
595
+ for (const { old, inputs, agent, requires } of targets) {
596
+ const first = inputs[0];
597
+ const next = await queue(unit, agent, {
598
+ commentRootRunId: rootId ?? '',
599
+ commentExecution: 'requester',
600
+ commentOwnerKey: record.commentOwnerKey ?? null,
601
+ freshSession: Boolean(old.freshSession),
602
+ subject: { kind: old.subjectKind, id: old.subjectId },
603
+ threadScope: old.threadScope,
604
+ actorUserId: byUserId,
605
+ requestedByUserId: byUserId,
606
+ confirmedByUserId: null,
607
+ ownerUserId: old.ownerUserId,
608
+ priority: Number(old.priority),
609
+ fireAt: fireAtOf(old.availableAt ?? undefined, clock.now()),
610
+ requires,
611
+ maxAttempts: Number(old.maxAttempts),
612
+ input: toInput(first),
613
+ });
614
+ rootId ??= next.runId;
615
+ replacements.set(old.id, next);
616
+ for (const input of inputs.slice(1))
617
+ await insertInput(unit, next.runId, {
618
+ ...toInput(input),
619
+ payload: { ...jsonObject(input.payload), commentRootRunId: rootId },
620
+ });
621
+ }
622
+ // Remap causal references only for replaced work; completed ancestors remain the original historical runs.
623
+ for (const { old } of targets) {
624
+ const next = replacements.get(old.id);
625
+ if (old.parentRunId)
626
+ await runsRepo(unit.conn).updateMany({
627
+ filter: { id: next.runId },
628
+ values: {
629
+ parentRunId: replacements.get(old.parentRunId)?.runId ?? old.parentRunId,
630
+ },
631
+ });
632
+ for (const input of await pendingInputs(unit.conn, next.runId)) {
633
+ const payload = jsonObject(input.payload);
634
+ const cause = typeof payload.causedByRunId === 'string'
635
+ ? replacements.get(payload.causedByRunId)
636
+ : undefined;
637
+ if (cause)
638
+ await inputsRepo(unit.conn).updateMany({
639
+ filter: { id: input.id },
640
+ values: {
641
+ payload: asJson({ ...payload, causedByRunId: cause.runId }),
642
+ },
643
+ });
644
+ }
645
+ }
646
+ const rootInputs = await pendingInputs(unit.conn, rootId);
647
+ if (!rootInputs.some((input) => input.type === record.inputType &&
648
+ input.text === record.inputText &&
649
+ jsonObject(input.payload).commentId ===
650
+ jsonObject(record.inputPayload).commentId)) {
651
+ const sourceId = await insertInput(unit, rootId, {
652
+ type: record.inputType,
653
+ actor: {
654
+ kind: record.inputActorKind,
655
+ id: record.inputActorId,
656
+ name: record.inputActorName,
657
+ },
658
+ text: record.inputText,
659
+ payload: {
660
+ ...jsonObject(record.inputPayload),
661
+ commentSourceSnapshot: true,
662
+ commentRootRunId: rootId,
663
+ },
664
+ });
665
+ await inputsRepo(unit.conn).updateMany({
666
+ filter: { id: sourceId },
667
+ values: { handledAt: clock.now().toISOString() },
668
+ });
669
+ }
670
+ return (replacements.get(record.commentRunId) ??
671
+ replacements.values().next().value);
672
+ },
325
673
  direct: async (unit, run) => {
326
674
  const identity = await selectedIdentity(unit, {
327
675
  agentId: run.agent.id,
328
676
  subject: run.subject,
329
677
  input: run.input,
678
+ actorUserId: run.actorUserId,
330
679
  });
331
680
  const actorUserId = identity?.actorUserId ?? run.actorUserId;
332
681
  const agent = await requireInvocable(unit, run.agent.id, actorUserId);
@@ -339,7 +688,6 @@ export function createRunService(deps) {
339
688
  confirmedByUserId: run.confirmedByUserId,
340
689
  ownerUserId: identity ? actorUserId : run.ownerUserId,
341
690
  priority: run.priority,
342
- // As it was asked: a moment already past when it is confirmed means now.
343
691
  fireAt: fireAtOf(run.fireAt ?? undefined, clock.now()),
344
692
  requires: run.requires,
345
693
  ...(run.maxAttempts === null ? {} : { maxAttempts: run.maxAttempts }),
@@ -350,7 +698,21 @@ export function createRunService(deps) {
350
698
  return {
351
699
  enqueue: (request, outer) => tx.run(async (unit) => {
352
700
  const source = await sourceOf(unit, request);
353
- const identity = await selectedIdentity(unit, request);
701
+ const cause = request.causedByRunId
702
+ ? await findRunRecord(unit.conn, request.causedByRunId)
703
+ : null;
704
+ const inherited = !request.comment &&
705
+ cause?.commentRootRunId &&
706
+ cause.subjectKind === request.subject.kind &&
707
+ cause.subjectId === request.subject.id
708
+ ? cause
709
+ : null;
710
+ const commentRootRunId = request.comment?.rootRunId ??
711
+ inherited?.commentRootRunId ??
712
+ (request.comment ? '' : null);
713
+ const commentExecution = inherited?.commentExecution ?? (request.comment ? 'owner' : null);
714
+ const commentOwnerKey = request.comment?.ownerKey ?? inherited?.commentOwnerKey ?? null;
715
+ const identity = await selectedIdentity(unit, request, commentExecution);
354
716
  const responsible = identity
355
717
  ? null
356
718
  : (request.responsibleUserId ?? null);
@@ -388,19 +750,30 @@ export function createRunService(deps) {
388
750
  }
389
751
  // As the responsible when they caused it; as the source when they run it as themselves (`mine`); as whoever
390
752
  // woke the agent when nobody answers for the subject.
391
- const actorUserId = identity?.actorUserId ??
753
+ const actorUserId = (commentExecution === 'requester'
754
+ ? inherited?.actorUserId
755
+ : identity?.actorUserId) ??
392
756
  (foreign ? source : (responsible ?? request.actorUserId ?? source));
393
757
  const agent = await requireInvocable(unit, request.agentId, actorUserId);
394
758
  if (foreign)
395
759
  await requireRunnerFor(unit, agent, actorUserId, request.requires ?? []);
396
- return queue(unit, agent, {
760
+ const result = await queue(unit, agent, {
761
+ preferActive: request.preferActive,
762
+ sourceRunId: request.causedByRunId,
763
+ commentRootRunId,
764
+ commentExecution,
765
+ commentOwnerKey,
397
766
  freshSession: request.freshSession,
398
767
  subject: request.subject,
399
768
  threadScope,
400
769
  actorUserId,
401
770
  requestedByUserId: source,
402
- confirmedByUserId: null,
403
- ownerUserId: identity ? actorUserId : (request.ownerUserId ?? null),
771
+ confirmedByUserId: inherited?.confirmedByUserId ?? null,
772
+ ownerUserId: commentExecution === 'requester'
773
+ ? (inherited?.ownerUserId ?? request.ownerUserId ?? null)
774
+ : identity
775
+ ? actorUserId
776
+ : (request.ownerUserId ?? null),
404
777
  priority: request.priority ?? 0,
405
778
  fireAt: fireAtOf(request.fireAt, clock.now()),
406
779
  requires: request.requires ?? [],
@@ -408,6 +781,9 @@ export function createRunService(deps) {
408
781
  ...(request.maxAttempts ? { maxAttempts: request.maxAttempts } : {}),
409
782
  input: request.input,
410
783
  });
784
+ if (commentRootRunId !== null)
785
+ await deps.reconcileCommentConsent?.(unit, result.runId);
786
+ return result;
411
787
  }, outer),
412
788
  requests,
413
789
  identityInputs: async (conn, runId) => {
@@ -419,8 +795,31 @@ export function createRunService(deps) {
419
795
  },
420
796
  addInput: (runId, input, outer) => tx.run(async (unit) => {
421
797
  const run = await require(unit.conn, runId);
798
+ if (run.commentRootRunId &&
799
+ input.type === 'comment' &&
800
+ jsonObject(input.payload).commentRootRunId !== run.commentRootRunId)
801
+ throw forbidden('A comment must be queued in its own authorized run chain.');
422
802
  if (isTerminal(run.status))
423
803
  throw new ProtocolError('RUN_NOT_ACTIVE', `The run is ${run.status}.`);
804
+ if (isActive(run.status)) {
805
+ let current = run;
806
+ for (let attempt = 0; attempt < 4; attempt += 1) {
807
+ const updated = await runsRepo(unit.conn).updateMany({
808
+ filter: {
809
+ id: current.id,
810
+ status: current.status,
811
+ revision: current.revision,
812
+ },
813
+ values: { updatedAt: clock.now().toISOString() },
814
+ });
815
+ if (updated.updatedCount === 1)
816
+ return insertInput(unit, current.id, input);
817
+ current = await require(unit.conn, run.id);
818
+ if (!isActive(current.status))
819
+ throw conflict('The run moved on before input was appended.');
820
+ }
821
+ throw conflict('The run revision changed during append; retry the input.');
822
+ }
424
823
  return insertInput(unit, run.id, input);
425
824
  }, outer),
426
825
  cancel: (runId, byUserId, outer) => tx.run(async (unit) => {
@@ -514,6 +913,24 @@ export function createRunService(deps) {
514
913
  const previousInputs = await inputsRepo(unit.conn).findMany({
515
914
  filter: { runId: run.id },
516
915
  });
916
+ if (!run.commentRootRunId &&
917
+ deps.subjects.get(run.subjectKind)?.commentOwnerKey &&
918
+ previousInputs.some((input) => input.type === 'comment' &&
919
+ !(input.actorKind === 'user' &&
920
+ input.actorId === run.ownerUserId &&
921
+ jsonObject(input.payload).byAgent !== true)))
922
+ throw precondition('RUN_NOT_RETRYABLE', 'Post the comment again to start a new authorized comment chain.');
923
+ const oldRoot = run.commentRootRunId
924
+ ? await findRunRecord(unit.conn, run.commentRootRunId)
925
+ : null;
926
+ const renewComment = oldRoot
927
+ ? !(await currentCommentOwner(unit.conn, deps.subjects, oldRoot))
928
+ : false;
929
+ const commentExecution = renewComment &&
930
+ run.commentExecution === 'requester' &&
931
+ byUserId !== run.actorUserId
932
+ ? 'owner'
933
+ : run.commentExecution;
517
934
  const identity = await deps.subjects
518
935
  .get(run.subjectKind)
519
936
  ?.actsAs?.(unit.conn, {
@@ -521,34 +938,87 @@ export function createRunService(deps) {
521
938
  agentId: run.agentId,
522
939
  inputs: previousInputs.map(toInput),
523
940
  causedByRunId: run.retryOfRunId ?? undefined,
941
+ commentExecution,
942
+ actorUserId: run.actorUserId,
524
943
  });
525
944
  if (identity && !identity.actorUserId)
526
945
  throw forbidden(identity.reason ?? 'noOwner');
527
- const actorUserId = identity?.actorUserId ?? byUserId;
946
+ const actorUserId = commentExecution === 'requester'
947
+ ? run.actorUserId
948
+ : (identity?.actorUserId ?? byUserId);
528
949
  const agent = await requireInvocable(unit, run.agentId, actorUserId);
529
950
  const open = await runsOfKey(unit.conn, run, ['queued', ...ACTIVE], actorUserId);
530
- if (open.length > 0)
951
+ const sameChain = run.commentRootRunId
952
+ ? open.filter((candidate) => candidate.commentRootRunId === run.commentRootRunId)
953
+ : open;
954
+ if (sameChain.length > 0)
531
955
  throw precondition('AGENT_BUSY', 'The agent is already working on this.', {
532
- runId: open[0].id,
956
+ runId: sameChain[0].id,
533
957
  });
534
958
  const newId = await createRun(unit, agent, {
535
959
  subject: { kind: run.subjectKind, id: run.subjectId },
536
960
  threadScope: run.threadScope,
537
961
  actorUserId,
538
- requestedByUserId: byUserId,
962
+ commentRootRunId: renewComment ? '' : run.commentRootRunId,
963
+ commentExecution,
964
+ commentOwnerKey: renewComment
965
+ ? ((await deps.subjects
966
+ .get(run.subjectKind)
967
+ ?.commentOwnerKey?.(unit.conn, run.subjectId)) ?? null)
968
+ : run.commentOwnerKey,
969
+ requestedByUserId: run.commentRootRunId
970
+ ? (run.requestedByUserId ?? run.actorUserId)
971
+ : byUserId,
539
972
  confirmedByUserId: null,
540
- ownerUserId: identity ? actorUserId : run.ownerUserId,
973
+ ownerUserId: run.commentRootRunId
974
+ ? ((await deps.subjects
975
+ .get(run.subjectKind)
976
+ ?.responsibleUserId?.(unit.conn, run.subjectId)) ?? null)
977
+ : identity
978
+ ? actorUserId
979
+ : run.ownerUserId,
541
980
  priority: Number(run.priority),
542
981
  requires: toRun(run).requires,
543
982
  retryOfRunId: run.id,
544
983
  freshSession: run.freshSession,
545
984
  });
985
+ if (renewComment && oldRoot) {
986
+ const origins = await inputsRepo(unit.conn).findMany({
987
+ filter: { runId: oldRoot.id },
988
+ sort: (s) => [s.field('createdAt').asc(), s.field('id').asc()],
989
+ });
990
+ const origin = origins.find((input) => input.type === 'comment' &&
991
+ jsonObject(input.payload).commentSourceSnapshot === true) ?? origins.find((input) => input.type === 'comment');
992
+ if (origin) {
993
+ const snapshotId = await insertInput(unit, newId, {
994
+ type: 'comment',
995
+ actor: toInput(origin).actor,
996
+ text: origin.text,
997
+ payload: {
998
+ ...jsonObject(origin.payload),
999
+ commentRootRunId: newId,
1000
+ commentSourceSnapshot: true,
1001
+ },
1002
+ });
1003
+ await inputsRepo(unit.conn).updateMany({
1004
+ filter: { id: snapshotId },
1005
+ values: { handledAt: clock.now().toISOString() },
1006
+ });
1007
+ }
1008
+ }
546
1009
  for (const input of await pendingInputs(unit.conn, run.id))
547
1010
  await insertInput(unit, newId, {
548
1011
  type: input.type,
549
1012
  actor: toInput(input).actor,
550
1013
  text: input.text,
551
- ...(input.payload === null ? {} : { payload: input.payload }),
1014
+ ...(input.payload === null
1015
+ ? {}
1016
+ : {
1017
+ payload: {
1018
+ ...jsonObject(input.payload),
1019
+ ...(renewComment ? { commentRootRunId: newId } : {}),
1020
+ },
1021
+ }),
552
1022
  });
553
1023
  await insertInput(unit, newId, {
554
1024
  type: 'retry',
@@ -571,6 +1041,8 @@ export function createRunService(deps) {
571
1041
  retryOfRunId: run.id,
572
1042
  previousStatus: run.status,
573
1043
  });
1044
+ if (run.commentRootRunId)
1045
+ await deps.reconcileCommentConsent?.(unit, newId);
574
1046
  return toRun(await require(unit.conn, newId));
575
1047
  });
576
1048
  },
@@ -621,6 +1093,9 @@ export function createRunService(deps) {
621
1093
  return {
622
1094
  ...(await readRuns([run]))[0],
623
1095
  actorName: (await deps.people.names(conn, [run.actorUserId])).get(run.actorUserId) ?? run.actorUserId,
1096
+ confirmedByName: run.confirmedByUserId
1097
+ ? ((await deps.people.names(conn, [run.confirmedByUserId])).get(run.confirmedByUserId) ?? run.confirmedByUserId)
1098
+ : undefined,
624
1099
  requestedByName: (await deps.people.names(conn, [
625
1100
  run.requestedByUserId ?? run.actorUserId,
626
1101
  ])).get(run.requestedByUserId ?? run.actorUserId) ??
@@ -760,7 +1235,16 @@ export function createRunService(deps) {
760
1235
  lastSeq: events.length > 0 ? events[events.length - 1].seq : afterSeq,
761
1236
  };
762
1237
  },
763
- authenticateToken: (value) => authenticateRunToken(tx.read(), clock, value),
1238
+ authenticateToken: async (value) => {
1239
+ const conn = tx.read();
1240
+ const identity = await authenticateRunToken(conn, clock, value);
1241
+ if (identity.run.commentRootRunId) {
1242
+ const root = await findRunRecord(conn, identity.run.commentRootRunId);
1243
+ if (!root || !(await currentCommentOwner(conn, deps.subjects, root)))
1244
+ throw precondition('RUN_TOKEN_INVALID', 'The comment authorization belongs to an earlier owner assignment.');
1245
+ }
1246
+ return identity;
1247
+ },
764
1248
  async self({ run }) {
765
1249
  const conn = tx.read();
766
1250
  const agent = await findAgent(conn, run.agentId);
@@ -805,6 +1289,7 @@ export function createRunService(deps) {
805
1289
  return assembly.data;
806
1290
  },
807
1291
  openOn: openRunsOn,
1292
+ openOnEach: openRunsOnEach,
808
1293
  pendingInputs: async (conn, runId) => (await pendingInputs(conn, runId)).map(toInput),
809
1294
  inputsSince: inputsOnSince,
810
1295
  lastSummary,