@capybara-hq/kernel 0.1.0-rc.24 → 0.1.0-rc.26

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 (198) hide show
  1. package/dist/admin/admin.module.js +2 -0
  2. package/dist/admin/health.service.d.ts +2 -2
  3. package/dist/admin/health.service.js +6 -2
  4. package/dist/admin/outbox-dead.service.d.ts +7 -3
  5. package/dist/admin/outbox-dead.service.js +17 -6
  6. package/dist/admin/overview.service.d.ts +6 -6
  7. package/dist/admin/overview.service.js +7 -7
  8. package/dist/agents/agents.module.js +3 -0
  9. package/dist/agents/agents.service.d.ts +6 -1
  10. package/dist/agents/agents.service.js +11 -2
  11. package/dist/background/wake-rules.d.ts +5 -0
  12. package/dist/background/wake-rules.js +16 -5
  13. package/dist/catalog/catalog-import.module.js +0 -4
  14. package/dist/catalog/catalog-scope.service.d.ts +63 -26
  15. package/dist/catalog/catalog-scope.service.js +146 -71
  16. package/dist/catalog/catalog.module.js +4 -3
  17. package/dist/catalog/catalog.service.d.ts +11 -4
  18. package/dist/catalog/catalog.service.js +21 -5
  19. package/dist/catalog/cli.d.ts +4 -3
  20. package/dist/catalog/cli.js +8 -13
  21. package/dist/catalog/job-ids.d.ts +8 -20
  22. package/dist/catalog/job-ids.js +11 -16
  23. package/dist/catalog/marketplaces.service.d.ts +1 -1
  24. package/dist/catalog/org-plugin-uploads.d.ts +11 -4
  25. package/dist/catalog/org-plugin-uploads.js +21 -6
  26. package/dist/catalog/owned-items.d.ts +6 -13
  27. package/dist/catalog/owned-items.js +11 -29
  28. package/dist/catalog/owned-plugin-cleanup.d.ts +8 -17
  29. package/dist/catalog/owned-plugin-cleanup.js +12 -66
  30. package/dist/catalog/platform-components.d.ts +56 -34
  31. package/dist/catalog/platform-components.js +68 -78
  32. package/dist/catalog/plugin-import.d.ts +28 -25
  33. package/dist/catalog/plugin-import.js +90 -76
  34. package/dist/catalog/plugin-upgrade.d.ts +4 -4
  35. package/dist/catalog/plugin-upgrade.js +7 -19
  36. package/dist/catalog/scope-jobs.d.ts +59 -0
  37. package/dist/catalog/scope-jobs.js +65 -0
  38. package/dist/catalog/scope-sync-workers.d.ts +22 -0
  39. package/dist/catalog/scope-sync-workers.js +58 -2
  40. package/dist/catalog/scope-sync.d.ts +40 -65
  41. package/dist/catalog/scope-sync.js +114 -122
  42. package/dist/channels/channels.module.d.ts +9 -8
  43. package/dist/channels/channels.module.js +11 -10
  44. package/dist/channels/connections.service.d.ts +3 -3
  45. package/dist/channels/connections.service.js +6 -6
  46. package/dist/channels/deliver-job.d.ts +10 -3
  47. package/dist/channels/deliver-job.js +7 -3
  48. package/dist/channels/inbound.js +2 -2
  49. package/dist/channels/jobs.d.ts +11 -4
  50. package/dist/channels/jobs.js +18 -3
  51. package/dist/channels/outbound.d.ts +5 -2
  52. package/dist/channels/outbound.js +9 -2
  53. package/dist/channels/threads.d.ts +4 -3
  54. package/dist/channels/threads.js +4 -3
  55. package/dist/conversations/conversations.controller.d.ts +4 -2
  56. package/dist/conversations/conversations.controller.js +8 -4
  57. package/dist/conversations/conversations.service.d.ts +54 -9
  58. package/dist/conversations/conversations.service.js +94 -25
  59. package/dist/conversations/events.d.ts +27 -0
  60. package/dist/conversations/events.js +30 -1
  61. package/dist/conversations/source.d.ts +10 -0
  62. package/dist/conversations/source.js +17 -0
  63. package/dist/conversations/tree.d.ts +10 -0
  64. package/dist/conversations/tree.js +35 -0
  65. package/dist/db/kernel-schema.d.ts +19 -15
  66. package/dist/devices/devices.service.d.ts +25 -14
  67. package/dist/devices/devices.service.js +36 -30
  68. package/dist/evals/evals.module.js +5 -1
  69. package/dist/evals/jobs.d.ts +14 -6
  70. package/dist/evals/jobs.js +21 -19
  71. package/dist/evals/run-replay.d.ts +13 -2
  72. package/dist/evals/run-replay.js +28 -1
  73. package/dist/evals/runs.service.d.ts +5 -6
  74. package/dist/evals/runs.service.js +11 -33
  75. package/dist/evals/snapshot.d.ts +7 -1
  76. package/dist/evals/snapshot.js +2 -1
  77. package/dist/extensions/hooks.d.ts +22 -3
  78. package/dist/extensions/hooks.js +15 -1
  79. package/dist/extensions/lifecycle-hooks.d.ts +99 -0
  80. package/dist/extensions/lifecycle-hooks.js +273 -0
  81. package/dist/extensions/org-config.d.ts +13 -1
  82. package/dist/extensions/org-config.js +19 -2
  83. package/dist/extensions/permission-view.d.ts +9 -5
  84. package/dist/extensions/permission-view.js +9 -4
  85. package/dist/extensions/point-defaults.d.ts +28 -1
  86. package/dist/extensions/point-defaults.js +44 -13
  87. package/dist/inbox/inbox.service.d.ts +6 -20
  88. package/dist/inbox/inbox.service.js +0 -3
  89. package/dist/inbox/org-notices.d.ts +1 -1
  90. package/dist/inbox/platform-inbox.service.d.ts +3 -32
  91. package/dist/inbox/platform-inbox.service.js +5 -54
  92. package/dist/infra/outbox/outbox.service.d.ts +6 -0
  93. package/dist/infra/outbox/outbox.service.js +51 -17
  94. package/dist/infra/outbox/registry.d.ts +176 -0
  95. package/dist/infra/outbox/registry.js +183 -0
  96. package/dist/infra/queue/queue-names.d.ts +1 -1
  97. package/dist/infra/queue/queue-names.js +1 -0
  98. package/dist/local-devices/device-policy.service.d.ts +131 -26
  99. package/dist/local-devices/device-policy.service.js +341 -145
  100. package/dist/local-devices/machines.d.ts +17 -2
  101. package/dist/local-devices/machines.js +66 -5
  102. package/dist/local-devices/policy-op-jobs.d.ts +15 -0
  103. package/dist/local-devices/policy-op-jobs.js +12 -0
  104. package/dist/local-devices/policy-summary.d.ts +36 -3
  105. package/dist/local-devices/policy-summary.js +47 -1
  106. package/dist/orgs/directory-sync.js +1 -1
  107. package/dist/orgs/members.service.js +1 -1
  108. package/dist/orgs/offboarding.d.ts +50 -57
  109. package/dist/orgs/offboarding.js +117 -162
  110. package/dist/orgs/orgs.module.js +3 -0
  111. package/dist/orgs/orgs.service.d.ts +11 -5
  112. package/dist/orgs/orgs.service.js +19 -7
  113. package/dist/overrides.d.ts +7 -0
  114. package/dist/overrides.js +4 -0
  115. package/dist/platform/ops/conversations.ops.js +1 -1
  116. package/dist/platform/ops/inbox.ops.js +4 -20
  117. package/dist/platform/ops/platform.ops.d.ts +1 -0
  118. package/dist/platform/ops/platform.ops.js +6 -3
  119. package/dist/roles/worker-role.d.ts +10 -5
  120. package/dist/roles/worker-role.js +19 -9
  121. package/dist/roles/worker.module.js +2 -4
  122. package/dist/runtime/context/build.d.ts +3 -0
  123. package/dist/runtime/context/build.js +15 -0
  124. package/dist/runtime/context/plugin-context.d.ts +20 -0
  125. package/dist/runtime/context/plugin-context.js +30 -0
  126. package/dist/runtime/langgraph-loop.js +4 -0
  127. package/dist/runtime/loop.d.ts +8 -0
  128. package/dist/runtime/maintenance.d.ts +9 -4
  129. package/dist/runtime/maintenance.js +52 -13
  130. package/dist/runtime/model-call.d.ts +6 -1
  131. package/dist/runtime/model-call.js +1 -0
  132. package/dist/runtime/plugin-context.service.d.ts +75 -0
  133. package/dist/runtime/plugin-context.service.js +220 -0
  134. package/dist/runtime/run-context.service.d.ts +7 -3
  135. package/dist/runtime/run-context.service.js +12 -2
  136. package/dist/runtime/run-control.d.ts +6 -1
  137. package/dist/runtime/run-control.js +13 -0
  138. package/dist/runtime/run-finish.d.ts +2 -2
  139. package/dist/runtime/run-finish.js +11 -6
  140. package/dist/runtime/run-jobs.d.ts +21 -4
  141. package/dist/runtime/run-jobs.js +29 -6
  142. package/dist/runtime/run-orphan-check.job.d.ts +14 -11
  143. package/dist/runtime/run-orphan-check.job.js +16 -11
  144. package/dist/runtime/run-queue.d.ts +29 -24
  145. package/dist/runtime/run-queue.js +118 -57
  146. package/dist/runtime/run-status.d.ts +12 -0
  147. package/dist/runtime/run-status.js +21 -0
  148. package/dist/runtime/run-worker.d.ts +32 -3
  149. package/dist/runtime/run-worker.js +143 -21
  150. package/dist/runtime/runtime.module.js +2 -0
  151. package/dist/runtime/step-log.d.ts +6 -5
  152. package/dist/runtime/step-log.js +6 -5
  153. package/dist/runtime/subagents/dispatch.js +1 -0
  154. package/dist/runtime/subagents/skill-fork.js +3 -0
  155. package/dist/runtime/usage-report.d.ts +3 -1
  156. package/dist/runtime/usage-report.js +4 -2
  157. package/dist/runtime/waits.d.ts +16 -6
  158. package/dist/runtime/waits.js +37 -15
  159. package/dist/sandbox/jobs.d.ts +20 -10
  160. package/dist/sandbox/jobs.js +23 -17
  161. package/dist/schedules/fire.d.ts +25 -4
  162. package/dist/schedules/fire.js +87 -13
  163. package/dist/schedules/job-ids.d.ts +66 -17
  164. package/dist/schedules/job-ids.js +81 -13
  165. package/dist/schedules/run-tracker.js +2 -1
  166. package/dist/schedules/scan.job.d.ts +16 -35
  167. package/dist/schedules/scan.job.js +38 -147
  168. package/dist/schedules/scheduled-tasks.service.d.ts +6 -6
  169. package/dist/schedules/scheduled-tasks.service.js +17 -14
  170. package/dist/schedules/schedules.module.js +0 -4
  171. package/dist/skills/catalog.d.ts +7 -3
  172. package/dist/skills/catalog.js +12 -2
  173. package/dist/skills/changes.controller.d.ts +1 -1
  174. package/dist/skills/changes.controller.js +1 -1
  175. package/dist/tools/approvals.service.d.ts +111 -23
  176. package/dist/tools/approvals.service.js +326 -77
  177. package/dist/tools/builtins/sandbox/task-output.js +21 -6
  178. package/dist/tools/device-op-resolver.d.ts +40 -0
  179. package/dist/tools/device-op-resolver.js +121 -0
  180. package/dist/tools/network-asks.d.ts +1 -1
  181. package/dist/tools/network-asks.js +4 -3
  182. package/dist/tools/pipeline.d.ts +11 -2
  183. package/dist/tools/pipeline.js +5 -0
  184. package/dist/tools/questions.service.d.ts +15 -4
  185. package/dist/tools/questions.service.js +21 -19
  186. package/dist/tools/registry.d.ts +8 -1
  187. package/dist/tools/registry.js +9 -0
  188. package/dist/tools/tools.module.js +4 -0
  189. package/package.json +3 -3
  190. package/src/migrations/0121_drop_platform_migration_runs.sql +12 -0
  191. package/src/migrations/0122_drop_scheduled_task_runs_rechecked_at.sql +5 -0
  192. package/src/migrations/0123_pgvector.sql +27 -0
  193. package/src/migrations/0124_conversation_source.sql +36 -0
  194. package/src/migrations/0125_conversation_tree_stopped_at.sql +5 -0
  195. package/dist/catalog/platform-migration.d.ts +0 -115
  196. package/dist/catalog/platform-migration.js +0 -583
  197. package/dist/infra/queue/add-job-once.d.ts +0 -14
  198. package/dist/infra/queue/add-job-once.js +0 -16
@@ -8,25 +8,29 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
8
  return function (target, key) { decorator(target, key, paramIndex); }
9
9
  };
10
10
  import { randomUUID } from "node:crypto";
11
+ import { isDeepStrictEqual } from "node:util";
11
12
  import { connectorToolKey, parseToolKey, validateAllowHost, } from "@capybara-hq/plugin-sdk";
12
13
  import { Inject, Injectable } from "@nestjs/common";
13
14
  import { sql } from "kysely";
14
15
  import { EventLog } from "../conversations/event-log.js";
15
16
  import { NETWORK_ASK_MAX_HOSTS, NETWORK_HOST_MAX_CHARS, } from "../conversations/events.js";
17
+ import { conversationSourceOf } from "../conversations/source.js";
16
18
  import { ApiError } from "../errors.js";
17
19
  import { audit } from "../identity/audit.js";
18
20
  import { KernelConfigService } from "../infra/config/kernel-config.service.js";
21
+ import { afterCommit } from "../infra/database/after-commit.js";
19
22
  import { KernelDb } from "../infra/database/kernel-db.js";
20
23
  import { TransactionHost } from "../infra/database/transaction-host.js";
21
24
  import { Transactional } from "../infra/database/transactional.decorator.js";
22
25
  import { KernelEvents } from "../infra/events/kernel-events.js";
23
26
  import { LockService } from "../infra/lock/lock.service.js";
24
27
  import { WithLock } from "../infra/lock/with-lock.decorator.js";
25
- import { DevicePolicyService } from "../local-devices/device-policy.service.js";
28
+ import { DevicePolicyService, } from "../local-devices/device-policy.service.js";
29
+ import { pendingPolicyOpsOf } from "../local-devices/policy-summary.js";
26
30
  import { ORG_ROLES, roleAtLeast } from "../orgs/roles.js";
27
31
  import { RunResume } from "../runtime/run-jobs.js";
28
32
  import { rootConversationOf } from "../runtime/subagents/child.js";
29
- import { SandboxService } from "../sandbox/sandbox.service.js";
33
+ import { deviceNotFound, SandboxService } from "../sandbox/sandbox.service.js";
30
34
  import { readScheduleSettings } from "../schedules/effective-settings.js";
31
35
  import { grantRefusalOf, NETWORK_GRANT_PREFIX, NETWORK_GRANT_SPEC_HASH, networkGrantRefusal, } from "../schedules/grant-rules.js";
32
36
  import { ScheduledTaskStore } from "../schedules/store.js";
@@ -54,6 +58,18 @@ export const NETWORK_ASK_TOOL_KEY = "Bash";
54
58
  /** The spec hash of a network ask (no tool definition is approved). */
55
59
  const NETWORK_ASK_SPEC_HASH = "network";
56
60
  const notFound = () => new ApiError("approval.not_found", "Approval not found");
61
+ /**
62
+ * A decision refused before it wrote anything (the ask closed, expired or is not the account's to
63
+ * decide): the device op's outcome is still recorded in the same transaction.
64
+ */
65
+ const refusedBeforeWrite = (e) => e instanceof ApiError &&
66
+ (e.code === "approval.already_decided" || e.code === "approval.not_found");
67
+ /** A pending network ask's reason when its device change got no answer (`detail.attempt`). */
68
+ const UNCONFIRMED_ATTEMPT = "The device did not confirm the change to its network rules; its next connection tells whether it took effect.";
69
+ /** A pending network ask's reason when the device applied it but its chooser cannot decide. */
70
+ const APPLIED_UNDECIDED_ATTEMPT = "The device allowed it, but the person who chose this can no longer decide this request.";
71
+ /** A pending network ask's reason when its device change failed without a message. */
72
+ const NOT_APPLIED_ATTEMPT = "The device did not apply the change to its network rules.";
57
73
  const iso = (v) => new Date(v).toISOString();
58
74
  const isRole = (v) => v !== null && ORG_ROLES.includes(v);
59
75
  /** The scopes of an approval without stored ones: a role escalation is approved only once. */
@@ -125,7 +141,10 @@ let ApprovalsService = class ApprovalsService {
125
141
  constructor(db, config, eventLog, events, txHost, schedules,
126
142
  /** A trial approval is kept only for a tool the task may hold as a grant (its agent's tools). */
127
143
  registry,
128
- /** 「这台设备一直允许」 sends the `policy` op to the device before the decision is recorded. */
144
+ /**
145
+ * 「这台设备一直允许」 records the device change's intent, sends the `policy` op, then records
146
+ * its outcome with the decision.
147
+ */
129
148
  policies,
130
149
  /** One decision of a network ask at a time, across replicas (the op is sent before the transaction). */
131
150
  lockService,
@@ -390,12 +409,13 @@ let ApprovalsService = class ApprovalsService {
390
409
  * A network ask's decision (network ask-to-allow spec §5.2), one at a time per ask across
391
410
  * replicas: only the device owner (`requested_for`) decides, in a signed-in session (a personal
392
411
  * access token is refused; the route already refuses service and embed tokens); the allowed
393
- * hosts are a subset of those asked. 「这台设备一直允许」 sends the `policy` op first, outside any
394
- * transaction; when the device cannot be reached or lacks the feature the decision is recorded
395
- * as a run grant with the reason, and when the device refuses (its owner takes no remote grants)
396
- * as `always` with outcome `refused` and nothing granted. Then {@link decideIn} records what took
397
- * effect and the run resumes after the commit; a change the device applied after the ask closed
398
- * is audited (`late`) and answered with a 409 that says so.
412
+ * hosts are a subset of those asked. 「这台设备一直允许」 goes through the device change's intent
413
+ * ({@link decideAlways}): when the device cannot be reached or lacks the feature the decision is
414
+ * recorded as a run grant with the reason, and when the device refuses (its owner takes no
415
+ * remote grants) as `always` with outcome `refused` and nothing granted; the decision and the
416
+ * op's outcome are one transaction. Every decision resumes the run in its transaction and is
417
+ * announced after the commit; a change the device applied after the ask closed is audited
418
+ * (`late`) and answered with a 409 that says so.
399
419
  */
400
420
  async decideNetwork(orgId, id, accountId, body, actorTokenId, via) {
401
421
  const row = await this.db
@@ -457,18 +477,11 @@ let ApprovalsService = class ApprovalsService {
457
477
  }
458
478
  network = { wanted: granted, granted, outcome: "once", via };
459
479
  if (scope === "always") {
460
- const applied = await this.applyAlways(orgId, detail.deviceId, granted, {
461
- approvalId: id,
462
- accountId,
463
- via,
464
- });
465
- network =
466
- applied === null
467
- ? { wanted: granted, granted, outcome: "always", via }
468
- : applied === "refused"
469
- ? // the device's owner takes no remote grants: nothing is allowed, not even this run
470
- { wanted: granted, granted: [], outcome: "refused", fallback: applied, via }
471
- : { wanted: granted, granted, outcome: "once_fallback", fallback: applied, via };
480
+ const decided = await this.decideAlways(orgId, ask, accountId, body, granted, via);
481
+ // null: the resolver decided it as this owner meanwhile (announced by its commit)
482
+ if (decided.toSeq !== null)
483
+ await this.announceDecided(decided.record, decided.toSeq);
484
+ return decided.record;
472
485
  }
473
486
  }
474
487
  else {
@@ -479,46 +492,267 @@ let ApprovalsService = class ApprovalsService {
479
492
  }
480
493
  network = { wanted: [], granted: [], outcome: null, via };
481
494
  }
482
- let decided;
495
+ const decided = await this.decideIn(orgId, id, accountId, body, actorTokenId, network);
496
+ await this.announceDecided(decided.record, decided.toSeq);
497
+ return decided.record;
498
+ }
499
+ /**
500
+ * 「这台设备一直允许」 (transactional outbox spec §7): the intent of the device change (naming the
501
+ * ask) is recorded, the `policy` op sent (no transaction open), then {@link settleNetworkOp}
502
+ * decides the ask and records the op's outcome in one transaction. When nothing could be sent
503
+ * (the device is offline, deleted, or its program lacks the feature) the decision is a run
504
+ * grant with the reason. The device's answer decides: applied → `always`; refused → `always`
505
+ * with outcome `refused`, nothing granted; no answer → a run grant (`once_fallback`, `offline`)
506
+ * while the op stays `unknown` for the machine's next hello or the resolver. A device that
507
+ * reconnects during the call and reports the op applied lets the resolver approve the ask as
508
+ * this owner first: that decision is returned (`toSeq` null: already announced), never a 409;
509
+ * when it granted other hosts than this call's (another of the owner's changes) and this call's
510
+ * change was not confirmed, `approval.already_decided` says so. A device deleted during the
511
+ * call is `device.not_found` (the op recorded unknown).
512
+ */
513
+ async decideAlways(orgId, ask, accountId, body, granted, via) {
514
+ const detail = ask.detail;
515
+ const accountName = (await this.displayNameOf(accountId)) ?? accountId;
516
+ let begun;
483
517
  try {
484
- decided = await this.decideIn(orgId, id, accountId, body, actorTokenId, network);
518
+ begun = await this.policies.begin({
519
+ orgId,
520
+ deviceId: detail.deviceId,
521
+ change: { add: granted },
522
+ source: { approvalId: ask.id, accountId, accountName, via },
523
+ });
485
524
  }
486
525
  catch (e) {
487
- // the device took the hosts, but a reply, the expiry or a stop closed the ask meanwhile:
488
- // the change stands (the owner chose it), so it is audited and said
489
- if (network.outcome === "always" &&
490
- e instanceof ApiError &&
491
- e.code === "approval.already_decided") {
492
- await this.auditLateAllow(orgId, ask, network);
493
- throw new ApiError("approval.already_decided", `The device now allows ${network.granted.join(", ")}; the request had already closed.`);
526
+ // the device was deleted meanwhile: nothing to write to, the run cannot go on there either
527
+ if (!(e instanceof ApiError && e.code === "device.not_found"))
528
+ throw e;
529
+ begun = { outcome: { applied: false, reason: "offline", message: e.message } };
530
+ }
531
+ if ("outcome" in begun) {
532
+ // nothing was sent: a run grant with the reason
533
+ const fallback = begun.outcome.applied ? "offline" : begun.outcome.reason;
534
+ return this.decideIn(orgId, ask.id, accountId, body, undefined, {
535
+ wanted: granted,
536
+ granted,
537
+ outcome: "once_fallback",
538
+ fallback,
539
+ via,
540
+ });
541
+ }
542
+ const { intent } = begun;
543
+ const sent = await this.policies.send(intent);
544
+ if (sent.notFound) {
545
+ // deleted during the call: nothing to grant on, the run cannot go on there either
546
+ await this.settleOpOnly({ machineId: intent.machineId, opId: intent.opId }, sent.result);
547
+ throw deviceNotFound();
548
+ }
549
+ const network = sent.result.outcome === "applied"
550
+ ? { wanted: granted, granted, outcome: "always", via }
551
+ : sent.result.outcome === "refused"
552
+ ? // the device's owner takes no remote grants: nothing is allowed, not even this run
553
+ { wanted: granted, granted: [], outcome: "refused", fallback: "refused", via }
554
+ : { wanted: granted, granted, outcome: "once_fallback", fallback: "offline", via };
555
+ const settled = await this.settleNetworkOp(orgId, ask.id, accountId, body, network, {
556
+ machineId: intent.machineId,
557
+ opId: intent.opId,
558
+ result: sent.result,
559
+ });
560
+ if ("decided" in settled)
561
+ return settled.decided;
562
+ if (settled.closed.code === "approval.already_decided" && !settled.late) {
563
+ // the machine reconnected mid-call and reported the op: the resolver approved it as this
564
+ // owner, with this change; it is their decision, not a conflict
565
+ const now = await this.db
566
+ .selectFrom("approvals")
567
+ .selectAll()
568
+ .where("id", "=", ask.id)
569
+ .executeTakeFirst();
570
+ const record = now === undefined ? null : toRecord(now);
571
+ if (record?.status === "approved" &&
572
+ record.detail?.outcome === "always" &&
573
+ record.decidedBy === accountId) {
574
+ const stored = [...(record.detail.grantedHosts ?? [])].sort();
575
+ if (isDeepStrictEqual(stored, [...granted].sort()))
576
+ return { record, toSeq: null };
577
+ // approved by another of this owner's changes (other hosts), and this call's change was
578
+ // not confirmed by the device: never report it as done
579
+ throw new ApiError("approval.already_decided", `The request was already approved to always allow ${stored.join(", ") || "no hosts"}; ` +
580
+ `the device did not confirm allowing ${granted.join(", ")}.`);
494
581
  }
495
- throw e;
496
582
  }
497
- await this.announceDecided(decided.record, decided.toSeq);
498
- return decided.record;
583
+ // the device took the hosts, but a reply, the expiry or a stop closed the ask meanwhile: the
584
+ // change stands (the owner chose it), audited late in the settle transaction, and said
585
+ if (settled.late && settled.closed.code === "approval.already_decided") {
586
+ throw new ApiError("approval.already_decided", `The device now allows ${granted.join(", ")}; the request had already closed.`);
587
+ }
588
+ throw settled.closed;
499
589
  }
500
- /** `device.network_allowed` of a policy change that reached the device after the ask closed. */
501
- async auditLateAllow(orgId, ask, network) {
590
+ /** Records only the op's outcome, in its own transaction (the ask is left as it is). */
591
+ async settleOpOnly(op, result) {
592
+ await this.policies.settleIn(op, result);
593
+ }
594
+ /**
595
+ * The resolve transaction of a network ask's device change: the ask decided as `network` says
596
+ * ({@link decideTx}: lock order conversation → approval → run), then the op's outcome recorded
597
+ * (`DevicePolicyService.settleIn`: the machine's row). When the ask closed meanwhile (a reply,
598
+ * the expiry, a stop) only the op's outcome is recorded, and a change the device applied is
599
+ * audited `device.network_allowed` with `late: true` in the same transaction.
600
+ */
601
+ async settleNetworkOp(orgId, id, accountId, body, network, op) {
602
+ let decided;
603
+ try {
604
+ decided = await this.decideTx(orgId, id, accountId, body, undefined, network);
605
+ }
606
+ catch (e) {
607
+ // refused before any write (closed, expired, gone): the op's outcome is recorded anyway
608
+ if (!refusedBeforeWrite(e))
609
+ throw e;
610
+ const late = await this.settleClosedIn(id, op, network.via);
611
+ return { closed: e, late };
612
+ }
613
+ await this.policies.settleIn(op, op.result);
614
+ return { decided };
615
+ }
616
+ /**
617
+ * The op's outcome for an ask that is no longer pending (in the caller's transaction): recorded,
618
+ * and a change the device applied audited `device.network_allowed` `late` (the change stands:
619
+ * the owner chose it; no inverse change is sent). True when it was audited late. `undecided`:
620
+ * the ask is still pending but the op's account can no longer decide it; the allow is audited
621
+ * without `late` (it is not after the ask) and the caller gives the ask the reason.
622
+ */
623
+ async settleClosedIn(approvalId, op, via, undecided = false) {
624
+ const pendingOp = await this.pendingOpOf(op.machineId, op.opId);
625
+ const out = await this.policies.settleIn(op, op.result);
626
+ if (out === null || pendingOp === null || op.result.outcome !== "applied")
627
+ return false;
628
+ const row = await this.db
629
+ .selectFrom("approvals")
630
+ .selectAll()
631
+ .where("id", "=", approvalId)
632
+ .executeTakeFirst();
633
+ if (!row)
634
+ return false;
635
+ const ask = toRecord(row);
502
636
  if (ask.detail === null)
503
- return;
637
+ return false;
504
638
  await audit(this.db.db, {
505
- actorAccountId: ask.requestedFor,
506
- orgId,
639
+ actorAccountId: pendingOp.source.accountId,
640
+ orgId: ask.orgId,
507
641
  action: "device.network_allowed",
508
642
  target: ask.detail.deviceId,
509
643
  detail: {
510
644
  approvalId: ask.id,
511
645
  deviceId: ask.detail.deviceId,
512
- hosts: network.granted,
646
+ hosts: pendingOp.add,
513
647
  scope: "always",
514
648
  outcome: "always",
515
- late: true,
516
- by: ask.requestedFor,
517
- via: network.via,
649
+ ...(undecided ? { undecided: true } : { late: true }),
650
+ by: pendingOp.source.accountId,
651
+ via,
518
652
  conversationId: ask.conversationId,
519
653
  runId: ask.runId,
520
654
  },
521
655
  });
656
+ return true;
657
+ }
658
+ /** The live intent `opId` on machine `machineId` (null: settled already, or expired). */
659
+ async pendingOpOf(machineId, opId) {
660
+ const r = await this.db
661
+ .selectFrom("device_machines")
662
+ .select("pending_policy_ops")
663
+ .where("id", "=", machineId)
664
+ .executeTakeFirst();
665
+ const op = pendingPolicyOpsOf(r?.pending_policy_ops).find((o) => o.id === opId);
666
+ return op === undefined || op.until <= Date.now() ? null : op;
667
+ }
668
+ /**
669
+ * The `device-op-resolve` job's outcome of an op that answers network ask `op.source.approvalId`
670
+ * (transactional outbox spec §7.4), in the resolver's transaction: under the ask's locks
671
+ * (conversation, then the approval) the op is read again (settled meanwhile: nothing to do).
672
+ * Applied, and the ask still pending → approved `always` by the op's account (`via` as it
673
+ * asked), the run resumed in this transaction as every decision does; the ask closed → audited
674
+ * `late`; still pending but the op's account can no longer decide it → the allow audited (not
675
+ * late) and the ask left pending with `applied_undecided`. Not applied (refused, never received,
676
+ * interrupted) or still unknown → the op's outcome recorded and the ask left pending with the
677
+ * reason (`detail.attempt`) and an `approval_updated` event so the card shows it: no person is
678
+ * there to fall back to a run grant (D2).
679
+ */
680
+ async settleFromResolver(ref, result) {
681
+ const t = this.db.db;
682
+ const op = { machineId: ref.machineId, opId: ref.opId, result };
683
+ if (!(await this.lockConversationOf(ref.approvalId))) {
684
+ await this.policies.settleIn(op, result);
685
+ return;
686
+ }
687
+ const row = await t
688
+ .selectFrom("approvals")
689
+ .selectAll()
690
+ .select(sql `expires_at <= now()`.as("expired"))
691
+ .where("id", "=", ref.approvalId)
692
+ .forUpdate()
693
+ .executeTakeFirst();
694
+ const pendingOp = await this.pendingOpOf(ref.machineId, ref.opId);
695
+ if (row === undefined || pendingOp === null) {
696
+ await this.policies.settleIn(op, result);
697
+ return;
698
+ }
699
+ const ask = toRecord(row);
700
+ const open = ask.status === "pending" && !row.expired && ask.kind === "network";
701
+ const via = pendingOp.source.via;
702
+ if (result.outcome === "applied") {
703
+ if (!open) {
704
+ await this.settleClosedIn(ask.id, op, via);
705
+ return;
706
+ }
707
+ let decided;
708
+ try {
709
+ decided = await this.decideTx(ask.orgId, ask.id, pendingOp.source.accountId, { decision: "approve", scope: "always" }, undefined, { wanted: pendingOp.add, granted: pendingOp.add, outcome: "always", via });
710
+ }
711
+ catch (e) {
712
+ // the op's account may no longer decide it: the change stands, the ask waits with why
713
+ if (!refusedBeforeWrite(e))
714
+ throw e;
715
+ if (await this.settleClosedIn(ask.id, op, via, true)) {
716
+ await this.attemptIn(ask, ref.opId, {
717
+ outcome: "applied_undecided",
718
+ message: APPLIED_UNDECIDED_ATTEMPT,
719
+ });
720
+ }
721
+ return;
722
+ }
723
+ await this.policies.settleIn(op, result);
724
+ afterCommit(() => this.announceDecided(decided.record, decided.toSeq));
725
+ return;
726
+ }
727
+ const out = await this.policies.settleIn(op, result);
728
+ if (out === null || !open || ask.detail === null)
729
+ return;
730
+ await this.attemptIn(ask, ref.opId, result.outcome === "unknown"
731
+ ? { outcome: "unknown", message: UNCONFIRMED_ATTEMPT }
732
+ : { outcome: "failed", message: out.applied ? NOT_APPLIED_ATTEMPT : out.message });
733
+ }
734
+ /**
735
+ * A pending network ask's reason (`detail.attempt`) and its `approval_updated` event (one per
736
+ * op and outcome), in the caller's transaction with the ask locked; notified after the commit.
737
+ */
738
+ async attemptIn(ask, opId, attempt) {
739
+ if (ask.detail === null)
740
+ return;
741
+ await this.db.db
742
+ .updateTable("approvals")
743
+ .set({ detail: JSON.stringify({ ...ask.detail, attempt }) })
744
+ .where("id", "=", ask.id)
745
+ .execute();
746
+ const written = await this.eventLog.appendIn(ask.conversationId, [
747
+ {
748
+ type: "approval_updated",
749
+ runId: ask.runId,
750
+ idemKey: `approval_updated:${ask.id}:${opId}:${attempt.outcome}`,
751
+ payload: { approvalId: ask.id, callId: ask.callId },
752
+ },
753
+ ]);
754
+ const toSeq = Math.max(...written.map((e) => e.seq));
755
+ afterCommit(() => this.eventLog.notify(ask.conversationId, toSeq));
522
756
  }
523
757
  /** The owner of a live device of the org (null: none, deleted, or of another org). */
524
758
  async deviceOwnerOf(orgId, deviceId) {
@@ -531,28 +765,6 @@ let ApprovalsService = class ApprovalsService {
531
765
  .executeTakeFirst();
532
766
  return d?.owner_account_id ?? null;
533
767
  }
534
- /**
535
- * Adds the hosts to the device's local policy (the `policy` op; never inside a transaction).
536
- * Null when it was applied, else why not: the decision falls back to a run grant.
537
- */
538
- async applyAlways(orgId, deviceId, hosts, source) {
539
- const accountName = (await this.displayNameOf(source.accountId)) ?? source.accountId;
540
- try {
541
- const out = await this.policies.apply({
542
- orgId,
543
- deviceId,
544
- change: { add: hosts },
545
- source: { ...source, accountName },
546
- });
547
- return out.applied ? null : out.reason;
548
- }
549
- catch (e) {
550
- // the device was deleted meanwhile: nothing to write to, the run cannot go on there either
551
- if (e instanceof ApiError && e.code === "device.not_found")
552
- return "offline";
553
- throw e;
554
- }
555
- }
556
768
  /** After the commit: the decision's events reach the page, the run's waits and IM delivery. */
557
769
  async announceDecided(record, toSeq) {
558
770
  await this.eventLog.notify(record.conversationId, toSeq);
@@ -576,7 +788,14 @@ let ApprovalsService = class ApprovalsService {
576
788
  }
577
789
  // its own transaction (as the legacy one was): `approval.decided` is emitted only after the
578
790
  // decision committed, even when a caller has a transaction open
579
- async decideIn(orgId, id, accountId, body, actorTokenId, network) {
791
+ decideIn(orgId, id, accountId, body, actorTokenId, network) {
792
+ return this.decideTx(orgId, id, accountId, body, actorTokenId, network);
793
+ }
794
+ /**
795
+ * A decision in the caller's transaction: lock order conversation → approval → run (the resume).
796
+ * Refuses (`approval.already_decided`, `approval.not_found`, …) before any write.
797
+ */
798
+ async decideTx(orgId, id, accountId, body, actorTokenId, network) {
580
799
  const t = this.db.db;
581
800
  // lock order: the conversation, the approval, then the run (as a stop of the waiting run)
582
801
  if (!(await this.lockConversationOf(id, orgId)))
@@ -611,7 +830,7 @@ let ApprovalsService = class ApprovalsService {
611
830
  if (locked.expired) {
612
831
  throw new ApiError("approval.already_decided", "Approval has expired");
613
832
  }
614
- // a network ask is decided only through decideNetwork (its policy op went first)
833
+ // a network ask is decided only with what took effect (decideNetwork, the op's resolver)
615
834
  if ((before.kind === "network") !== (network !== undefined)) {
616
835
  throw new Error(`approval ${id} is of kind ${before.kind}`);
617
836
  }
@@ -814,6 +1033,8 @@ let ApprovalsService = class ApprovalsService {
814
1033
  disabledTools: def?.disabledTools ?? [],
815
1034
  subagentTools: def?.subagentTools,
816
1035
  subagents: def?.subagents,
1036
+ // the tools of the trial run's conversation, where the approval was asked
1037
+ source: await conversationSourceOf(t, a.conversationId),
817
1038
  });
818
1039
  const tool = tools.find((x) => x.key === a.toolKey);
819
1040
  const { forbiddenTools } = await readScheduleSettings(t, this.config, a.orgId);
@@ -975,21 +1196,34 @@ let ApprovalsService = class ApprovalsService {
975
1196
  return out;
976
1197
  }
977
1198
  /**
978
- * 在调用方的事务内(`mandatory`)取消某组织里指定由 `accountId` 处理(`requested_for`)的全部 pending 审批(成员离职),
979
- * 走与 {@link cancelPendingIn} 相同的取消路径:置 cancelled、写 `approval_decided{cancelled}`(处理人为 null)。
980
- * 返回被取消的记录与各会话写到的最大 seq;调用方提交后发布事件(并可续跑对应运行)。
1199
+ * The conversations of `orgId` holding a pending approval `accountId` is asked to decide
1200
+ * (`requested_for`), read without locks: offboarding locks them, by tree root first
1201
+ * (`lockConversationTrees`), before {@link cancelPendingFor}.
981
1202
  */
982
- async cancelPendingFor(orgId, accountId) {
983
- const t = this.db.db;
984
- // the conversations first, in id order (lock order: conversation, then approval rows)
985
- const convs = await this.lockConversations(t
1203
+ async pendingConversationsFor(orgId, accountId) {
1204
+ const rows = await this.db
986
1205
  .selectFrom("approvals")
987
1206
  .select("conversation_id")
1207
+ .distinct()
988
1208
  .where("org_id", "=", orgId)
989
1209
  .where("requested_for", "=", accountId)
990
- .where("status", "=", "pending"));
991
- // only the conversations locked above: an approval asked meanwhile in another one is left to
992
- // the run's stop or the sweep, so no conversation is locked after a row
1210
+ .where("status", "=", "pending")
1211
+ .execute();
1212
+ return rows.map((r) => r.conversation_id);
1213
+ }
1214
+ /**
1215
+ * In the caller's transaction (`mandatory`; offboarding): cancels every pending approval of
1216
+ * `orgId` that `accountId` is asked to decide (`requested_for`) in `conversationIds`, the
1217
+ * conversations the caller already locked (by tree root first, `lockConversationTrees`; lock
1218
+ * order: conversations, then approval rows), along the same path as {@link cancelPendingIn}:
1219
+ * `cancelled`, `approval_decided{cancelled}` with no decider. An approval asked meanwhile in
1220
+ * another conversation is left to its run's stop, so no conversation is locked after a row.
1221
+ * Returns the cancelled records and the highest seq written per conversation; the caller
1222
+ * publishes after the commit.
1223
+ */
1224
+ async cancelPendingFor(orgId, accountId, conversationIds) {
1225
+ const t = this.db.db;
1226
+ const convs = [...conversationIds];
993
1227
  if (convs.length === 0)
994
1228
  return { records: [], seqs: new Map() };
995
1229
  const rows = await t
@@ -1293,10 +1527,25 @@ __decorate([
1293
1527
  ], ApprovalsService.prototype, "decideNetwork", null);
1294
1528
  __decorate([
1295
1529
  Transactional()
1296
- ], ApprovalsService.prototype, "auditLateAllow", null);
1530
+ ], ApprovalsService.prototype, "settleOpOnly", null);
1531
+ __decorate([
1532
+ Transactional()
1533
+ ], ApprovalsService.prototype, "settleNetworkOp", null);
1534
+ __decorate([
1535
+ Transactional({ propagation: "mandatory" })
1536
+ ], ApprovalsService.prototype, "settleClosedIn", null);
1537
+ __decorate([
1538
+ Transactional({ propagation: "mandatory" })
1539
+ ], ApprovalsService.prototype, "settleFromResolver", null);
1540
+ __decorate([
1541
+ Transactional({ propagation: "mandatory" })
1542
+ ], ApprovalsService.prototype, "attemptIn", null);
1297
1543
  __decorate([
1298
1544
  Transactional({ propagation: "requires_new" })
1299
1545
  ], ApprovalsService.prototype, "decideIn", null);
1546
+ __decorate([
1547
+ Transactional({ propagation: "mandatory" })
1548
+ ], ApprovalsService.prototype, "decideTx", null);
1300
1549
  __decorate([
1301
1550
  Transactional({ propagation: "mandatory" })
1302
1551
  ], ApprovalsService.prototype, "keepTrialGrant", null);
@@ -77,14 +77,18 @@ const notFound = (id) => new ToolError(`No background task ${clip(id, 40)} in th
77
77
  */
78
78
  export const subagentNotTaskText = (id) => `${clip(id, 40)} is a sub-agent, not a background task. Its report arrives in this conversation as a notification when it hands back or finishes. Don't poll or sleep for it: keep working or end your turn. Use SendMessage to give it more instructions.`;
79
79
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
80
- /** {@link notFound}, or {@link subagentNotTaskText} when `id` is a sub-agent of the caller's tree. */
81
- async function missingTask(env, ctx, id) {
80
+ /** Whether `id` is the agentId of a sub-agent in the caller's tree (not a task id). */
81
+ async function isSubagentId(env, ctx, id) {
82
82
  const trimmed = id.trim();
83
- if (env.subagents !== undefined &&
83
+ return (!TASK_ID_RE.test(id) &&
84
+ env.subagents !== undefined &&
84
85
  UUID_RE.test(trimmed) &&
85
- (await env.subagents.inTree(sandboxConversationOf(ctx), trimmed.toLowerCase()))) {
86
- return new ToolError(subagentNotTaskText(trimmed));
87
- }
86
+ (await env.subagents.inTree(sandboxConversationOf(ctx), trimmed.toLowerCase())));
87
+ }
88
+ /** {@link notFound}, or {@link subagentNotTaskText} when `id` is a sub-agent of the caller's tree. */
89
+ async function missingTask(env, ctx, id) {
90
+ if (await isSubagentId(env, ctx, id))
91
+ return new ToolError(subagentNotTaskText(id.trim()));
88
92
  return notFound(id);
89
93
  }
90
94
  /**
@@ -286,6 +290,17 @@ export function taskOutputTool(deps) {
286
290
  async run(input, ctx) {
287
291
  const { task_id, block = true, timeout } = input;
288
292
  const env = await taskEnv(deps, ctx);
293
+ // A sub-agent's agentId is answered, not refused: where its report arrives is the answer
294
+ // (acceptance 2026-11-13: guidance the model is expected to follow is not a failed call).
295
+ // TaskStop keeps the error: it cannot stop a sub-agent.
296
+ if (await isSubagentId(env, ctx, task_id)) {
297
+ const id = clip(task_id.trim(), 40);
298
+ return {
299
+ content: [{ type: "text", text: subagentNotTaskText(task_id.trim()) }],
300
+ isError: false,
301
+ display: display(`查看子智能体:${id}`, `Checked sub-agent: ${id}`),
302
+ };
303
+ }
289
304
  let task = await ownedTask(env, ctx, task_id);
290
305
  await recheckDevice(env, ctx);
291
306
  if (block && task.status === "running") {
@@ -0,0 +1,40 @@
1
+ import { KernelDb } from "../infra/database/kernel-db.js";
2
+ import { TransactionHost } from "../infra/database/transaction-host.js";
3
+ import { DevicePolicyService } from "../local-devices/device-policy.service.js";
4
+ import type { DeviceOpResolveData } from "../local-devices/policy-op-jobs.js";
5
+ import { ApprovalsService } from "./approvals.service.js";
6
+ /**
7
+ * The `device-op-resolve` job (transactional outbox spec §7): records what became of a device
8
+ * policy op whose outcome its caller did not record (a crash between the call and the resolve
9
+ * transaction, an answer that never came). Owed by the op's intent for when the op can no longer
10
+ * reach the machine, and by a hello that reported the op (`device-op-reported-<opId>`).
11
+ *
12
+ * An op without an entry any more is resolved already: a no-op. Otherwise the outcome is the one
13
+ * the hello reported, else the machine's answer to a read-only `policyStatus`: `applied` →
14
+ * recorded applied; `refused`, never received (`unknown` to the machine, or `absent` from its
15
+ * hello, once the op can no longer reach it) → failed; `started` (the machine stopped between
16
+ * its log and its answer), once the op can no longer reach it → applied when the policy it
17
+ * reports holds the change, else failed (`interrupted`); no answer (`offline`) → `unknown`, left
18
+ * for the machine's next hello. It never sends the change
19
+ * again (a widening after its ask closed is not allowed). An op that answers a network ask
20
+ * decides it when applied and the ask is still pending, else leaves it pending with the reason
21
+ * (`ApprovalsService.settleFromResolver`).
22
+ */
23
+ export declare class DeviceOpResolver {
24
+ private readonly db;
25
+ readonly txHost: TransactionHost;
26
+ private readonly policies;
27
+ private readonly approvals;
28
+ constructor(db: KernelDb, txHost: TransactionHost, policies: Pick<DevicePolicyService, "outcomeOf" | "settleIn">, approvals: Pick<ApprovalsService, "settleFromResolver">);
29
+ resolve(data: DeviceOpResolveData): Promise<void>;
30
+ /**
31
+ * The outcome a hello reported; a refusal's message (a hello carries none) is the machine's
32
+ * answer to `policyStatus` when it is connected.
33
+ */
34
+ private reportedResult;
35
+ /**
36
+ * The resolve transaction: the op's outcome, and the network ask it answers decided or given
37
+ * the reason in the same transaction (`ApprovalsService.settleFromResolver`).
38
+ */
39
+ private settle;
40
+ }