@capybara-hq/kernel 0.1.0-rc.25 → 0.1.0-rc.27

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 (91) hide show
  1. package/dist/admin/outbox-dead.service.d.ts +7 -3
  2. package/dist/admin/outbox-dead.service.js +17 -6
  3. package/dist/admin/overview.service.d.ts +6 -6
  4. package/dist/admin/overview.service.js +7 -7
  5. package/dist/background/wake-rules.d.ts +5 -0
  6. package/dist/background/wake-rules.js +16 -5
  7. package/dist/catalog/catalog-content.d.ts +6 -1
  8. package/dist/catalog/catalog-content.js +2 -1
  9. package/dist/catalog/marketplaces.service.d.ts +1 -1
  10. package/dist/catalog/org-catalog.service.js +5 -2
  11. package/dist/catalog/plugin-install.d.ts +5 -0
  12. package/dist/catalog/plugin-install.js +37 -1
  13. package/dist/channels/channels.module.d.ts +9 -8
  14. package/dist/channels/channels.module.js +11 -10
  15. package/dist/channels/connections.service.d.ts +3 -3
  16. package/dist/channels/connections.service.js +6 -6
  17. package/dist/channels/deliver-job.d.ts +10 -3
  18. package/dist/channels/deliver-job.js +7 -3
  19. package/dist/channels/jobs.d.ts +11 -4
  20. package/dist/channels/jobs.js +18 -3
  21. package/dist/channels/outbound.d.ts +5 -2
  22. package/dist/channels/outbound.js +9 -2
  23. package/dist/conversations/conversations.controller.d.ts +4 -2
  24. package/dist/conversations/conversations.controller.js +4 -2
  25. package/dist/conversations/conversations.service.d.ts +37 -4
  26. package/dist/conversations/conversations.service.js +77 -20
  27. package/dist/conversations/events.d.ts +6 -0
  28. package/dist/conversations/events.js +6 -0
  29. package/dist/db/kernel-schema.d.ts +10 -1
  30. package/dist/devices/devices.service.d.ts +14 -6
  31. package/dist/devices/devices.service.js +20 -7
  32. package/dist/evals/evals.module.js +5 -1
  33. package/dist/evals/jobs.d.ts +14 -6
  34. package/dist/evals/jobs.js +21 -19
  35. package/dist/evals/runs.service.d.ts +5 -6
  36. package/dist/evals/runs.service.js +11 -33
  37. package/dist/infra/outbox/outbox.service.d.ts +6 -0
  38. package/dist/infra/outbox/outbox.service.js +51 -17
  39. package/dist/infra/outbox/registry.d.ts +52 -0
  40. package/dist/infra/outbox/registry.js +57 -0
  41. package/dist/local-devices/device-policy.service.d.ts +131 -26
  42. package/dist/local-devices/device-policy.service.js +341 -145
  43. package/dist/local-devices/machines.d.ts +17 -2
  44. package/dist/local-devices/machines.js +66 -5
  45. package/dist/local-devices/policy-op-jobs.d.ts +15 -0
  46. package/dist/local-devices/policy-op-jobs.js +12 -0
  47. package/dist/local-devices/policy-summary.d.ts +36 -3
  48. package/dist/local-devices/policy-summary.js +47 -1
  49. package/dist/orgs/orgs.service.js +4 -1
  50. package/dist/overrides.d.ts +4 -0
  51. package/dist/overrides.js +2 -0
  52. package/dist/platform/ops/conversations.ops.js +1 -1
  53. package/dist/runtime/maintenance.d.ts +6 -3
  54. package/dist/runtime/maintenance.js +21 -7
  55. package/dist/runtime/plugin-context.service.d.ts +9 -3
  56. package/dist/runtime/plugin-context.service.js +17 -5
  57. package/dist/runtime/run-context.service.d.ts +7 -3
  58. package/dist/runtime/run-context.service.js +12 -2
  59. package/dist/runtime/run-finish.d.ts +2 -2
  60. package/dist/runtime/run-finish.js +11 -6
  61. package/dist/runtime/run-jobs.d.ts +21 -4
  62. package/dist/runtime/run-jobs.js +29 -6
  63. package/dist/runtime/run-orphan-check.job.d.ts +14 -11
  64. package/dist/runtime/run-orphan-check.job.js +16 -11
  65. package/dist/runtime/run-queue.d.ts +23 -31
  66. package/dist/runtime/run-queue.js +62 -64
  67. package/dist/runtime/run-status.d.ts +12 -0
  68. package/dist/runtime/run-status.js +21 -0
  69. package/dist/runtime/run-worker.d.ts +10 -2
  70. package/dist/runtime/run-worker.js +44 -19
  71. package/dist/runtime/step-log.d.ts +6 -5
  72. package/dist/runtime/step-log.js +6 -5
  73. package/dist/runtime/usage-report.d.ts +3 -1
  74. package/dist/runtime/usage-report.js +4 -2
  75. package/dist/runtime/waits.d.ts +16 -6
  76. package/dist/runtime/waits.js +37 -15
  77. package/dist/schedules/scan.job.d.ts +6 -6
  78. package/dist/schedules/scan.job.js +6 -6
  79. package/dist/skills/catalog.d.ts +7 -3
  80. package/dist/skills/catalog.js +12 -2
  81. package/dist/tools/approvals.service.d.ts +96 -19
  82. package/dist/tools/approvals.service.js +300 -67
  83. package/dist/tools/builtins/sandbox/task-output.js +21 -6
  84. package/dist/tools/connectors.service.js +0 -4
  85. package/dist/tools/device-op-resolver.d.ts +40 -0
  86. package/dist/tools/device-op-resolver.js +121 -0
  87. package/dist/tools/network-asks.d.ts +1 -1
  88. package/dist/tools/network-asks.js +4 -3
  89. package/dist/tools/tools.module.js +4 -0
  90. package/package.json +2 -2
  91. package/src/migrations/0125_conversation_tree_stopped_at.sql +5 -0
@@ -16,11 +16,14 @@ import { afterCommit } from "../infra/database/after-commit.js";
16
16
  import { KernelDb } from "../infra/database/kernel-db.js";
17
17
  import { TransactionHost } from "../infra/database/transaction-host.js";
18
18
  import { Transactional } from "../infra/database/transactional.decorator.js";
19
+ import { OutboxService } from "../infra/outbox/outbox.service.js";
19
20
  import { KERNEL_BUS, KERNEL_LOGGER } from "../infra/tokens.js";
21
+ import { MAINTENANCE_QUEUE_NAME } from "../runtime/maintenance-queue.js";
20
22
  import { LOCAL_PROVIDER } from "../sandbox/providers.js";
21
23
  import { deviceNotFound } from "../sandbox/sandbox.service.js";
22
24
  import { deviceRevokedTopic } from "./keys.js";
23
- import { deviceFeaturesOf, isolationOf, localPolicyDiff, localPolicyOf, opExplains, pendingPolicyOpsOf, } from "./policy-summary.js";
25
+ import { DEVICE_OP_RESOLVE_JOB, deviceOpReportedJobId } from "./policy-op-jobs.js";
26
+ import { deviceFeaturesOf, isolationOf, localPolicyDiff, localPolicyOf, opExplains, opVisibleIn, pendingPolicyOpsOf, } from "./policy-summary.js";
24
27
  import { LocalDeviceRelay } from "./relay.js";
25
28
  /** The machine credential's prefix (`cap_dev_` plus 32 random bytes, base64url). */
26
29
  export const MACHINE_CREDENTIAL_PREFIX = "cap_dev_";
@@ -42,12 +45,14 @@ let MachinesService = class MachinesService {
42
45
  txHost;
43
46
  relay;
44
47
  bus;
48
+ outbox;
45
49
  log;
46
- constructor(db, txHost, relay, bus, log) {
50
+ constructor(db, txHost, relay, bus, outbox, log) {
47
51
  this.db = db;
48
52
  this.txHost = txHost;
49
53
  this.relay = relay;
50
54
  this.bus = bus;
55
+ this.outbox = outbox;
51
56
  this.log = log;
52
57
  }
53
58
  /**
@@ -164,7 +169,8 @@ let MachinesService = class MachinesService {
164
169
  * isolation that differs from the stored one (none stored counts as `sandbox`) is audited
165
170
  * `device.isolation_reported` (sandbox dev tiers spec §10.4), once per change; a change of what
166
171
  * the owner sets, made on the machine while it was away, is audited `device.policy_changed`
167
- * ({@link reportedPolicy}).
172
+ * ({@link reportedPolicy}). The outcomes of its recent policy ops (`recentOps`) backfill the
173
+ * platform's ops still pending on the row ({@link backfillOps}).
168
174
  */
169
175
  async recordHello(machineId, hello, rawPolicy = hello.policy) {
170
176
  const self = await this.db
@@ -227,6 +233,61 @@ let MachinesService = class MachinesService {
227
233
  .where("id", "=", machineId)
228
234
  .where("revoked_at", "is", null)
229
235
  .execute();
236
+ if (hello.recentOps !== undefined) {
237
+ await this.backfillOps(machineId, self, hello.recentOps, localPolicyOf(hello.policy));
238
+ }
239
+ }
240
+ /**
241
+ * Hello backfill (transactional outbox spec §7): a pending op of the platform's whose outcome
242
+ * the hello reports (`applied`, `refused`), or that it does not list although the op can no
243
+ * longer reach the machine (`absent`: past its `resolveAt`), is marked `reported` and owes a
244
+ * `device-op-resolve` job (`device-op-reported-<opId>`) that records the outcome. An op the
245
+ * machine logged only as `started` (it stopped between the log and the answer) is judged once
246
+ * past its `resolveAt` by the policy the hello reports: its change there → `applied`, else
247
+ * `interrupted` (within the window, or with no readable policy, it is left). An expired entry
248
+ * (past `until`) is skipped: the machine's reports are no longer the op's. Runs in the caller's
249
+ * transaction, with the machine's row locked; an op already reported is not owed again.
250
+ */
251
+ async backfillOps(machineId, self, recentOps, policy) {
252
+ const outcomes = new Map(recentOps.map((r) => [r.opId, r.outcome]));
253
+ const now = Date.now();
254
+ const reported = new Map();
255
+ for (const op of pendingPolicyOpsOf(self.pending_policy_ops)) {
256
+ if (op.reported !== undefined || op.until <= now)
257
+ continue;
258
+ const outcome = outcomes.get(op.id);
259
+ if (outcome === "applied" || outcome === "refused")
260
+ reported.set(op.id, { outcome });
261
+ else if (now < op.resolveAt)
262
+ continue;
263
+ else if (outcome === undefined)
264
+ reported.set(op.id, { outcome: "absent" });
265
+ else if (policy !== null) {
266
+ reported.set(op.id, { outcome: opVisibleIn(op, policy) ? "applied" : "interrupted" });
267
+ }
268
+ }
269
+ if (reported.size === 0)
270
+ return;
271
+ const ops = Array.isArray(self.pending_policy_ops) ? self.pending_policy_ops : [];
272
+ const next = ops.map((e) => {
273
+ const id = e?.id;
274
+ const r = typeof id === "string" ? reported.get(id) : undefined;
275
+ return r === undefined ? e : { ...e, reported: r };
276
+ });
277
+ await this.db
278
+ .updateTable("device_machines")
279
+ .set({ pending_policy_ops: JSON.stringify(next) })
280
+ .where("id", "=", machineId)
281
+ .execute();
282
+ for (const opId of reported.keys()) {
283
+ await this.outbox.add({
284
+ queue: MAINTENANCE_QUEUE_NAME,
285
+ name: DEVICE_OP_RESOLVE_JOB,
286
+ jobId: deviceOpReportedJobId(opId),
287
+ data: { machineId, opId },
288
+ orgId: self.org_id,
289
+ });
290
+ }
230
291
  }
231
292
  /**
232
293
  * Stores the local policy summary a connected program pushed after its hello (a `policy` frame:
@@ -292,7 +353,6 @@ let MachinesService = class MachinesService {
292
353
  via: "device",
293
354
  machineId,
294
355
  approvalId: null,
295
- confirmed: true,
296
356
  },
297
357
  });
298
358
  return summary;
@@ -470,7 +530,8 @@ MachinesService = __decorate([
470
530
  __param(1, Inject(TransactionHost)),
471
531
  __param(2, Inject(LocalDeviceRelay)),
472
532
  __param(3, Inject(KERNEL_BUS)),
473
- __param(4, Inject(KERNEL_LOGGER))
533
+ __param(4, Inject(OutboxService)),
534
+ __param(5, Inject(KERNEL_LOGGER))
474
535
  ], MachinesService);
475
536
  export { MachinesService };
476
537
  const jsonOrNull = (v) => (v === null ? null : JSON.stringify(v));
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Settles a device policy op whose outcome was not recorded (transactional outbox spec §7): owed
3
+ * by the op's intent transaction for when the op can no longer reach the machine, and by a hello
4
+ * that reported its outcome. Consumed by `DeviceOpResolver` on the maintenance queue.
5
+ */
6
+ export declare const DEVICE_OP_RESOLVE_JOB = "device-op-resolve";
7
+ /** The `device-op-resolve` job's data. */
8
+ export interface DeviceOpResolveData {
9
+ machineId: string;
10
+ opId: string;
11
+ }
12
+ /** The job id of the resolve job owed with an op's intent. */
13
+ export declare const deviceOpResolveJobId: (opId: string) => string;
14
+ /** The job id of the resolve job owed by a hello that reported the op's outcome. */
15
+ export declare const deviceOpReportedJobId: (opId: string) => string;
@@ -0,0 +1,12 @@
1
+ // The device policy op jobs' names and ids: a leaf module (no service imports), read by the
2
+ // producers (DevicePolicyService, MachinesService) and the maintenance processor.
3
+ /**
4
+ * Settles a device policy op whose outcome was not recorded (transactional outbox spec §7): owed
5
+ * by the op's intent transaction for when the op can no longer reach the machine, and by a hello
6
+ * that reported its outcome. Consumed by `DeviceOpResolver` on the maintenance queue.
7
+ */
8
+ export const DEVICE_OP_RESOLVE_JOB = "device-op-resolve";
9
+ /** The job id of the resolve job owed with an op's intent. */
10
+ export const deviceOpResolveJobId = (opId) => `device-op-resolve-${opId}`;
11
+ /** The job id of the resolve job owed by a hello that reported the op's outcome. */
12
+ export const deviceOpReportedJobId = (opId) => `device-op-reported-${opId}`;
@@ -38,10 +38,28 @@ export interface LocalPolicyDiff {
38
38
  }
39
39
  /** The {@link LocalPolicyDiff} from `prev` to `next`; null when nothing the owner sets changed. */
40
40
  export declare function localPolicyDiff(prev: DeviceLocalPolicySummary, next: DeviceLocalPolicySummary): LocalPolicyDiff | null;
41
+ /** Who asked for a `policy` op: the deciding account, where, and the network ask it answers. */
42
+ export interface PolicyOpSource {
43
+ accountId: string;
44
+ via: "web" | "im";
45
+ approvalId: string | null;
46
+ }
47
+ /** What the machine reported of an op after its answer was lost (its hello's `recentOps`). */
48
+ export interface ReportedPolicyOp {
49
+ /**
50
+ * `absent`: not in the hello's outcomes although the op can no longer reach the machine;
51
+ * `interrupted`: logged `started` only, and its change is not in the policy the hello reports
52
+ * (one whose change is there is `applied`).
53
+ */
54
+ outcome: "applied" | "refused" | "absent" | "interrupted";
55
+ }
41
56
  /**
42
- * A `policy` op the platform sent to a machine whose answer may not be recorded yet (kept on the
43
- * machine's row, `pending_policy_ops`): the change asked for, and until when (epoch ms) a summary
44
- * the machine reports by itself may still be that op's.
57
+ * The intent of a `policy` op the platform sends to a machine (transactional outbox spec §7), kept
58
+ * on the machine's row (`pending_policy_ops`) from before the op is sent until its outcome is
59
+ * recorded: the change asked for, who asked, and until when (epoch ms) a summary the machine
60
+ * reports by itself may still be that op's. `sent`: on its way or in flight, the
61
+ * `device-op-resolve` job owed for `resolveAt`; `unknown`: no answer came, and only the machine's
62
+ * next hello (`recentOps`) or a later `policyStatus` can tell what became of it.
45
63
  */
46
64
  export interface PendingPolicyOp {
47
65
  id: string;
@@ -50,10 +68,25 @@ export interface PendingPolicyOp {
50
68
  /** Present when the op set the tier (null: follow the org's default). */
51
69
  tier?: SandboxTier | null;
52
70
  isolation?: "sandbox";
71
+ source: PolicyOpSource;
72
+ actorTokenId?: string;
73
+ status: "sent" | "unknown";
74
+ /** When the intent was written (epoch ms). */
75
+ sentAt: number;
76
+ /** From when the op can no longer reach the machine (epoch ms): the resolve job's `runAt`. */
77
+ resolveAt: number;
53
78
  until: number;
79
+ /** Set by a hello that reported the op's outcome (or its absence) before the op was resolved. */
80
+ reported?: ReportedPolicyOp;
54
81
  }
55
82
  /** The pending ops stored on a machine row (malformed entries dropped). */
56
83
  export declare function pendingPolicyOpsOf(v: unknown): PendingPolicyOp[];
84
+ /**
85
+ * Whether the change of `op` is in `policy`: its hosts added and removed, its tier and isolation
86
+ * set. Tells an op the machine logged only as `started` (it stopped between the log and the
87
+ * answer) that was written from one that was not.
88
+ */
89
+ export declare function opVisibleIn(op: PendingPolicyOp, policy: DeviceLocalPolicySummary): boolean;
57
90
  /**
58
91
  * Whether `diff` is what the platform's `op` did (its answer, or the machine's own report of it
59
92
  * before the answer was recorded): hosts within the op's, the tier the op set, and only what a
@@ -1,5 +1,10 @@
1
1
  import { isSandboxTier, } from "@capybara-hq/plugin-sdk";
2
- const KNOWN_FEATURES = ["networkReport", "policyOp", "tiers"];
2
+ const KNOWN_FEATURES = [
3
+ "networkReport",
4
+ "policyOp",
5
+ "policyOpId",
6
+ "tiers",
7
+ ];
3
8
  /**
4
9
  * The known features in `v` (a hello's `features` or the stored column), in a fixed order and
5
10
  * deduped; anything else (unknown names, a non-array) is dropped.
@@ -99,17 +104,58 @@ export function pendingPolicyOpsOf(v) {
99
104
  const o = e;
100
105
  if (typeof o.id !== "string" || typeof o.until !== "number")
101
106
  continue;
107
+ if (typeof o.sentAt !== "number" || typeof o.resolveAt !== "number")
108
+ continue;
109
+ if (o.status !== "sent" && o.status !== "unknown")
110
+ continue;
111
+ const src = o.source;
112
+ if (typeof src !== "object" || src === null || typeof src.accountId !== "string")
113
+ continue;
114
+ if (src.via !== "web" && src.via !== "im")
115
+ continue;
116
+ const rep = o.reported;
117
+ const outcome = typeof rep === "object" && rep !== null ? rep.outcome : undefined;
118
+ const reported = outcome === "applied" ||
119
+ outcome === "refused" ||
120
+ outcome === "absent" ||
121
+ outcome === "interrupted"
122
+ ? { outcome }
123
+ : undefined;
102
124
  out.push({
103
125
  id: o.id,
104
126
  add: hosts(o.add),
105
127
  remove: hosts(o.remove),
106
128
  ...("tier" in o && { tier: isSandboxTier(o.tier) ? o.tier : null }),
107
129
  ...(o.isolation === "sandbox" && { isolation: "sandbox" }),
130
+ source: {
131
+ accountId: src.accountId,
132
+ via: src.via,
133
+ approvalId: typeof src.approvalId === "string" ? src.approvalId : null,
134
+ },
135
+ ...(typeof o.actorTokenId === "string" && { actorTokenId: o.actorTokenId }),
136
+ status: o.status,
137
+ sentAt: o.sentAt,
138
+ resolveAt: o.resolveAt,
108
139
  until: o.until,
140
+ ...(reported !== undefined && { reported }),
109
141
  });
110
142
  }
111
143
  return out;
112
144
  }
145
+ /**
146
+ * Whether the change of `op` is in `policy`: its hosts added and removed, its tier and isolation
147
+ * set. Tells an op the machine logged only as `started` (it stopped between the log and the
148
+ * answer) that was written from one that was not.
149
+ */
150
+ export function opVisibleIn(op, policy) {
151
+ if (!op.add.every((h) => policy.allowedDomains.includes(h)))
152
+ return false;
153
+ if (op.remove.some((h) => policy.allowedDomains.includes(h)))
154
+ return false;
155
+ if (op.tier !== undefined && (policy.tier ?? null) !== op.tier)
156
+ return false;
157
+ return op.isolation === undefined || policy.isolation === op.isolation;
158
+ }
113
159
  /**
114
160
  * Whether `diff` is what the platform's `op` did (its answer, or the machine's own report of it
115
161
  * before the answer was recorded): hosts within the op's, the tier the op set, and only what a
@@ -24,6 +24,7 @@ import { KernelDb } from "../infra/database/kernel-db.js";
24
24
  import { TransactionHost } from "../infra/database/transaction-host.js";
25
25
  import { Transactional } from "../infra/database/transactional.decorator.js";
26
26
  import { KernelEvents } from "../infra/events/kernel-events.js";
27
+ import { USAGE_REPORT_QUEUE } from "../runtime/usage-report-jobs.js";
27
28
  import { OrgLock } from "./org-lock.js";
28
29
  /** 组织 slug:小写字母数字与 `-`,首尾不能是 `-`,1–40 个字符。 */
29
30
  export const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
@@ -242,11 +243,13 @@ let OrgsService = class OrgsService {
242
243
  .where("id", "=", orgId)
243
244
  .execute();
244
245
  // the org's owed jobs go with it; dead letters stay for the overview, and the deletion's own
245
- // cleanup work carries no org_id (transactional outbox spec §9)
246
+ // cleanup work carries no org_id (transactional outbox spec §9). Usage reports stay owed: the
247
+ // usage happened, and billing plugins are still told about a deleted org's usage
246
248
  await this.db
247
249
  .deleteFrom("outbox")
248
250
  .where("org_id", "=", orgId)
249
251
  .where("dead_at", "is", null)
252
+ .where("queue", "<>", USAGE_REPORT_QUEUE)
250
253
  .execute();
251
254
  // after the org is marked deleted, so it counts as no user of what it shared (P3-18)
252
255
  await this.ownedItems.onOrgDeleted(orgId, actor.id, {
@@ -13,6 +13,7 @@ import { OUTBOX_FAST_PATH_SETTLED } from "./infra/outbox/outbox.module.js";
13
13
  import { OUTBOX_STRICT_CONFLICTS } from "./infra/outbox/outbox.service.js";
14
14
  import { PRESENCE_INTERVAL_MS } from "./infra/presence/replica-presence.js";
15
15
  import type { KernelQueueName } from "./infra/queue/queue-names.js";
16
+ import { DEVICE_POLICY_AFTER_CALL } from "./local-devices/device-policy.service.js";
16
17
  import { LIST_MODELS_TIMEOUT } from "./runtime/providers.js";
17
18
  import { RUN_CONTEXT_CLOCK } from "./runtime/run-context.service.js";
18
19
  import { ENSURE_RUN_FAILS } from "./runtime/run-queue.js";
@@ -47,6 +48,8 @@ import { EMBED_TOKEN_CLOCK } from "./ui/embed-token.js";
47
48
  * - `OUTBOX_FAST_PATH_SETTLED`: receives each detached outbox fast-path pass (a test awaits them);
48
49
  * - `ENSURE_RUN_FAILS`: returns true to make `RunQueue.ensureRun` throw (Redis unreachable, or the
49
50
  * direct call after a commit skipped by a crash);
51
+ * - `DEVICE_POLICY_AFTER_CALL`: called after a device policy op's call, before its outcome is
52
+ * recorded (throwing stands for a crash between the call and the resolve transaction);
50
53
  * - `SCOPE_BACKSTOP_DELAY`: the delay of a confirmation's scope sync backstop (ruling R24);
51
54
  * - `APP_INTERCEPTOR`: one more global interceptor (a probe of the matched routes).
52
55
  */
@@ -72,6 +75,7 @@ interface KernelSeams {
72
75
  [OUTBOX_FAST_PATH_SETTLED]: (pass: Promise<void>) => void;
73
76
  [ENSURE_RUN_FAILS]: () => boolean;
74
77
  [SCOPE_BACKSTOP_DELAY]: number;
78
+ [DEVICE_POLICY_AFTER_CALL]: (opId: string) => void;
75
79
  [APP_INTERCEPTOR]: NestInterceptor;
76
80
  }
77
81
  /**
package/dist/overrides.js CHANGED
@@ -10,6 +10,7 @@ import { OUTBOX_DISPATCH_FAILS } from "./infra/outbox/outbox.dispatcher.js";
10
10
  import { OUTBOX_FAST_PATH_SETTLED } from "./infra/outbox/outbox.module.js";
11
11
  import { OUTBOX_STRICT_CONFLICTS } from "./infra/outbox/outbox.service.js";
12
12
  import { PRESENCE_INTERVAL_MS } from "./infra/presence/replica-presence.js";
13
+ import { DEVICE_POLICY_AFTER_CALL } from "./local-devices/device-policy.service.js";
13
14
  import { LIST_MODELS_TIMEOUT } from "./runtime/providers.js";
14
15
  import { RUN_CONTEXT_CLOCK } from "./runtime/run-context.service.js";
15
16
  import { ENSURE_RUN_FAILS } from "./runtime/run-queue.js";
@@ -42,6 +43,7 @@ const SEAM_TOKENS = [
42
43
  OUTBOX_FAST_PATH_SETTLED,
43
44
  ENSURE_RUN_FAILS,
44
45
  SCOPE_BACKSTOP_DELAY,
46
+ DEVICE_POLICY_AFTER_CALL,
45
47
  APP_INTERCEPTOR,
46
48
  ];
47
49
  const allSeamsListed = true;
@@ -368,7 +368,7 @@ let ConversationsOperations = class ConversationsOperations {
368
368
  }),
369
369
  defineOperation({
370
370
  id: "conversations.abort",
371
- description: "Stop the run that is active in one of your other conversations (its sub-agents keep working; stopping a sub-agent's conversation stops it and those below it). Nothing happens if no run is active. The person confirms every stop; it is never remembered. The conversation you are working in cannot be stopped.",
371
+ description: "Stop the run that is active in one of your other conversations (its sub-agents stop with it; stopping a sub-agent's conversation stops it and those below it). Nothing happens if no run is active. The person confirms every stop; it is never remembered. The conversation you are working in cannot be stopped.",
372
372
  input: Id,
373
373
  example: { conversationId: "3f2b8a40-6c1d-4e0b-9a77-5d1e2c8b9f10" },
374
374
  authz: { orgAction: "agents.use" },
@@ -7,6 +7,7 @@ import { DirectorySync } from "../orgs/directory-sync.js";
7
7
  import { Offboarding } from "../orgs/offboarding.js";
8
8
  import { SandboxService } from "../sandbox/sandbox.service.js";
9
9
  import { ScheduledTaskFirer } from "../schedules/fire.js";
10
+ import { DeviceOpResolver } from "../tools/device-op-resolver.js";
10
11
  import { ToolDrift } from "../tools/drift.js";
11
12
  import { RunQueue } from "./run-queue.js";
12
13
  import { Waits } from "./waits.js";
@@ -20,11 +21,12 @@ export declare const MAINTENANCE_CONCURRENCY = 5;
20
21
  * {@link DirectorySync}, `run-stop` → {@link Offboarding.stopRun}, `bg-wake` and `ensure-run` → {@link RunQueue.ensureRun}, `stop-tree` →
21
22
  * {@link processStopTree} and `deliver-to-parent` → {@link processDeliverToParent} (both in
22
23
  * runtime/subagents/tree-jobs.ts, over {@link Waits}), `owned-plugin-cleanup` →
23
- * {@link OwnedPluginCleanup}, `sched-fire` and `sched-settle` → {@link ScheduledTaskFirer}). The name of a periodic job (a `@LockedCron` / `@LockedInterval` job,
24
+ * {@link OwnedPluginCleanup}, `sched-fire` and `sched-settle` → {@link ScheduledTaskFirer}, `device-op-resolve` →
25
+ * {@link DeviceOpResolver}). The name of a periodic job (a `@LockedCron` / `@LockedInterval` job,
24
26
  * such as `sandbox-suspend-idle`) runs that job's body once ({@link ScheduledRuns.runNow}), as the
25
27
  * maintenance handlers did when those jobs were schedulers on this queue: an operator's or a
26
28
  * test's one-off run, not a second schedule (decision R23 of the Nest kernel spec). Any other
27
- * name is warned and completes. The producers are the domains' own (same names, ids and options).
29
+ * name fails the job (BullMQ retries it; in a rolling deploy a newer replica knows it). The producers are the domains' own (same names, ids and options).
28
30
  */
29
31
  export declare class MaintenanceProcessor extends WorkerHost<Worker> {
30
32
  private readonly waits;
@@ -35,10 +37,11 @@ export declare class MaintenanceProcessor extends WorkerHost<Worker> {
35
37
  private readonly runs;
36
38
  private readonly ownedCleanup;
37
39
  private readonly scheduledTasks;
40
+ private readonly deviceOps;
38
41
  private readonly scheduled;
39
42
  private readonly log;
40
43
  private readonly queue;
41
- constructor(waits: Pick<Waits, "processTimeout" | "childFinished" | "stopChildren" | "stopDescendantsOfStopped" | "stopTree">, sandbox: Pick<SandboxService, "destroyDevice" | "resetFileSystem">, drift: Pick<ToolDrift, "check">, directory: Pick<DirectorySync, "runSyncJob">, offboarding: Pick<Offboarding, "stopRun">, runs: Pick<RunQueue, "ensureRun">, ownedCleanup: Pick<OwnedPluginCleanup, "run">, scheduledTasks: Pick<ScheduledTaskFirer, "fire" | "settle">, scheduled: Pick<ScheduledRuns, "runNow">, log: Pick<KernelLogger, "error" | "warn">, queue: Pick<Queue, "removeJobScheduler">);
44
+ constructor(waits: Pick<Waits, "processTimeout" | "childFinished" | "stopChildren" | "stopDescendantsOfStopped" | "stopTree">, sandbox: Pick<SandboxService, "destroyDevice" | "resetFileSystem">, drift: Pick<ToolDrift, "check">, directory: Pick<DirectorySync, "runSyncJob">, offboarding: Pick<Offboarding, "stopRun">, runs: Pick<RunQueue, "ensureRun">, ownedCleanup: Pick<OwnedPluginCleanup, "run">, scheduledTasks: Pick<ScheduledTaskFirer, "fire" | "settle">, deviceOps: Pick<DeviceOpResolver, "resolve">, scheduled: Pick<ScheduledRuns, "runNow">, log: Pick<KernelLogger, "error" | "warn">, queue: Pick<Queue, "removeJobScheduler">);
42
45
  /**
43
46
  * Removes the retired job schedulers (`RETIRED_SCHEDULER_IDS`, runtime/retired-schedulers.ts): every fixed-period
44
47
  * maintenance job is a `@LockedCron` / `@LockedInterval` now. Idempotent; called once when a
@@ -19,6 +19,7 @@ import { runJob } from "../infra/queue/run-job.js";
19
19
  import { KernelScheduleModule } from "../infra/schedule/schedule.module.js";
20
20
  import { ScheduledRuns } from "../infra/schedule/scheduled-runs.js";
21
21
  import { KERNEL_LOGGER } from "../infra/tokens.js";
22
+ import { DEVICE_OP_RESOLVE_JOB, } from "../local-devices/policy-op-jobs.js";
22
23
  import { DIRECTORY_SYNC_JOB, DirectorySync } from "../orgs/directory-sync.js";
23
24
  import { Offboarding, RUN_STOP_JOB } from "../orgs/offboarding.js";
24
25
  import { OrgsModule } from "../orgs/orgs.module.js";
@@ -27,6 +28,7 @@ import { SandboxModule } from "../sandbox/sandbox.module.js";
27
28
  import { SandboxService } from "../sandbox/sandbox.service.js";
28
29
  import { ScheduledTaskFireModule, ScheduledTaskFirer } from "../schedules/fire.js";
29
30
  import { SCHED_FIRE_JOB, SCHED_SETTLE_JOB, } from "../schedules/job-ids.js";
31
+ import { DeviceOpResolver } from "../tools/device-op-resolver.js";
30
32
  import { ToolDrift } from "../tools/drift.js";
31
33
  import { DRIFT_CONNECTOR_JOB } from "../tools/job-ids.js";
32
34
  import { ToolsModule } from "../tools/tools.module.js";
@@ -46,11 +48,12 @@ export const MAINTENANCE_CONCURRENCY = 5;
46
48
  * {@link DirectorySync}, `run-stop` → {@link Offboarding.stopRun}, `bg-wake` and `ensure-run` → {@link RunQueue.ensureRun}, `stop-tree` →
47
49
  * {@link processStopTree} and `deliver-to-parent` → {@link processDeliverToParent} (both in
48
50
  * runtime/subagents/tree-jobs.ts, over {@link Waits}), `owned-plugin-cleanup` →
49
- * {@link OwnedPluginCleanup}, `sched-fire` and `sched-settle` → {@link ScheduledTaskFirer}). The name of a periodic job (a `@LockedCron` / `@LockedInterval` job,
51
+ * {@link OwnedPluginCleanup}, `sched-fire` and `sched-settle` → {@link ScheduledTaskFirer}, `device-op-resolve` →
52
+ * {@link DeviceOpResolver}). The name of a periodic job (a `@LockedCron` / `@LockedInterval` job,
50
53
  * such as `sandbox-suspend-idle`) runs that job's body once ({@link ScheduledRuns.runNow}), as the
51
54
  * maintenance handlers did when those jobs were schedulers on this queue: an operator's or a
52
55
  * test's one-off run, not a second schedule (decision R23 of the Nest kernel spec). Any other
53
- * name is warned and completes. The producers are the domains' own (same names, ids and options).
56
+ * name fails the job (BullMQ retries it; in a rolling deploy a newer replica knows it). The producers are the domains' own (same names, ids and options).
54
57
  */
55
58
  let MaintenanceProcessor = class MaintenanceProcessor extends WorkerHost {
56
59
  waits;
@@ -61,10 +64,11 @@ let MaintenanceProcessor = class MaintenanceProcessor extends WorkerHost {
61
64
  runs;
62
65
  ownedCleanup;
63
66
  scheduledTasks;
67
+ deviceOps;
64
68
  scheduled;
65
69
  log;
66
70
  queue;
67
- constructor(waits, sandbox, drift, directory, offboarding, runs, ownedCleanup, scheduledTasks, scheduled, log, queue) {
71
+ constructor(waits, sandbox, drift, directory, offboarding, runs, ownedCleanup, scheduledTasks, deviceOps, scheduled, log, queue) {
68
72
  super();
69
73
  this.waits = waits;
70
74
  this.sandbox = sandbox;
@@ -74,6 +78,7 @@ let MaintenanceProcessor = class MaintenanceProcessor extends WorkerHost {
74
78
  this.runs = runs;
75
79
  this.ownedCleanup = ownedCleanup;
76
80
  this.scheduledTasks = scheduledTasks;
81
+ this.deviceOps = deviceOps;
77
82
  this.scheduled = scheduled;
78
83
  this.log = log;
79
84
  this.queue = queue;
@@ -160,10 +165,18 @@ let MaintenanceProcessor = class MaintenanceProcessor extends WorkerHost {
160
165
  case SCHED_SETTLE_JOB:
161
166
  await this.scheduledTasks.settle(job.data);
162
167
  return;
168
+ // a device policy op whose outcome was not recorded (transactional outbox spec §7)
169
+ case DEVICE_OP_RESOLVE_JOB:
170
+ await this.deviceOps.resolve(job.data);
171
+ return;
163
172
  default:
164
173
  if (await this.scheduled.runNow(job.name))
165
174
  return;
166
- this.log.warn("unknown maintenance job", { name: job.name, jobId: job.id });
175
+ // never completed unrun: in a rolling deploy an old worker meets a job a newer replica
176
+ // owed (its outbox row is gone once added); failing it leaves it to BullMQ's retries, so a
177
+ // newer replica can take it within the job's attempts (old workers still go first, see
178
+ // docs/deploy/upgrade.md)
179
+ throw new Error(`unknown maintenance job "${job.name}"`);
167
180
  }
168
181
  }
169
182
  failed(job, err) {
@@ -189,9 +202,10 @@ MaintenanceProcessor = __decorate([
189
202
  __param(5, Inject(RunQueue)),
190
203
  __param(6, Inject(OwnedPluginCleanup)),
191
204
  __param(7, Inject(ScheduledTaskFirer)),
192
- __param(8, Inject(ScheduledRuns)),
193
- __param(9, Inject(KERNEL_LOGGER)),
194
- __param(10, Inject(getQueueToken(MAINTENANCE_QUEUE_NAME)))
205
+ __param(8, Inject(DeviceOpResolver)),
206
+ __param(9, Inject(ScheduledRuns)),
207
+ __param(10, Inject(KERNEL_LOGGER)),
208
+ __param(11, Inject(getQueueToken(MAINTENANCE_QUEUE_NAME)))
195
209
  ], MaintenanceProcessor);
196
210
  export { MaintenanceProcessor };
197
211
  /**
@@ -54,8 +54,12 @@ export declare class PluginContextService {
54
54
  private readonly registry;
55
55
  private readonly log;
56
56
  constructor(db: KernelDb, eventLog: Pick<EventLog, "appendIn" | "notify">, txHost: TransactionHost, points: Pick<ExtensionPointDefaults, "providersFor">, registry: Pick<ExtensionRegistry, "list">, log: KernelLogger);
57
- /** Asks the providers and records what changed (see the class); notifies after the commit. */
58
- record(input: PluginContextInput): Promise<void>;
57
+ /**
58
+ * Asks the providers and records what changed (see the class); notifies after the commit. False:
59
+ * the run had ended under the locks (a stop landed while the providers were asked), so nothing
60
+ * was appended and the run must not go on (as `RunContextService.record`).
61
+ */
62
+ record(input: PluginContextInput): Promise<boolean>;
59
63
  /** One provider's call, bounded by the run's signal and the time limit. */
60
64
  private ask;
61
65
  /**
@@ -63,7 +67,9 @@ export declare class PluginContextService {
63
67
  * answer's hash with the plugin's latest `plugin_context` and appends the ones that differ (a
64
68
  * plugin with no earlier event and no text writes nothing), plus an empty event for each plugin
65
69
  * in `off` whose latest text is not empty. Idem key per run and plugin, so a retried run
66
- * start finds its events or nothing changed. Returns the last seq written.
70
+ * start finds its events or nothing changed. Returns the last seq written; `ended` when the run
71
+ * is terminal under the locks (the providers are asked outside the transaction, so a stop may
72
+ * have ended it meanwhile: nothing is appended after its end).
67
73
  */
68
74
  private append;
69
75
  }
@@ -18,6 +18,7 @@ import { TransactionHost } from "../infra/database/transaction-host.js";
18
18
  import { Transactional } from "../infra/database/transactional.decorator.js";
19
19
  import { KERNEL_LOGGER } from "../infra/tokens.js";
20
20
  import { latestPluginContexts, pluginContextIdemKey } from "./context/plugin-context.js";
21
+ import { runEndedIn } from "./run-status.js";
21
22
  const sha256 = (text) => createHash("sha256").update(text).digest("hex");
22
23
  /**
23
24
  * Run start's plugin context (memory plugin spec §6.1): asks every `context@1` provider enabled in
@@ -48,10 +49,14 @@ let PluginContextService = class PluginContextService {
48
49
  this.registry = registry;
49
50
  this.log = log;
50
51
  }
51
- /** Asks the providers and records what changed (see the class); notifies after the commit. */
52
+ /**
53
+ * Asks the providers and records what changed (see the class); notifies after the commit. False:
54
+ * the run had ended under the locks (a stop landed while the providers were asked), so nothing
55
+ * was appended and the run must not go on (as `RunContextService.record`).
56
+ */
52
57
  async record(input) {
53
58
  if (input.kind !== "chat")
54
- return;
59
+ return true;
55
60
  let found;
56
61
  try {
57
62
  found = await this.points.providersFor(input.orgId, "context");
@@ -64,7 +69,7 @@ let PluginContextService = class PluginContextService {
64
69
  runId: input.runId,
65
70
  err,
66
71
  });
67
- return;
72
+ return true;
68
73
  }
69
74
  const { providers } = found;
70
75
  const tools = providers.length > 0 ? await input.toolKeysByPlugin() : new Map();
@@ -93,10 +98,13 @@ let PluginContextService = class PluginContextService {
93
98
  .filter((plugin) => !serving.has(plugin) && !found.failed.has(plugin)));
94
99
  // nothing answered and nothing to switch off: no transaction, no row lock
95
100
  if (texts.size === 0 && off.size === 0)
96
- return;
101
+ return true;
97
102
  const seq = await this.append(input, texts, off);
103
+ if (seq === "ended")
104
+ return false;
98
105
  if (seq !== null)
99
106
  await this.eventLog.notify(input.conversationId, seq);
107
+ return true;
100
108
  }
101
109
  /** One provider's call, bounded by the run's signal and the time limit. */
102
110
  async ask(p, input, tools) {
@@ -141,7 +149,9 @@ let PluginContextService = class PluginContextService {
141
149
  * answer's hash with the plugin's latest `plugin_context` and appends the ones that differ (a
142
150
  * plugin with no earlier event and no text writes nothing), plus an empty event for each plugin
143
151
  * in `off` whose latest text is not empty. Idem key per run and plugin, so a retried run
144
- * start finds its events or nothing changed. Returns the last seq written.
152
+ * start finds its events or nothing changed. Returns the last seq written; `ended` when the run
153
+ * is terminal under the locks (the providers are asked outside the transaction, so a stop may
154
+ * have ended it meanwhile: nothing is appended after its end).
145
155
  */
146
156
  async append(input, texts, off) {
147
157
  const t = this.db.db;
@@ -151,6 +161,8 @@ let PluginContextService = class PluginContextService {
151
161
  .where("id", "=", input.conversationId)
152
162
  .forUpdate()
153
163
  .executeTakeFirst();
164
+ if (await runEndedIn(t, input.runId))
165
+ return "ended";
154
166
  const rows = await t
155
167
  .selectFrom("conversation_events")
156
168
  .select(["seq", "payload"])
@@ -41,12 +41,16 @@ export declare class RunContextService {
41
41
  private readonly now;
42
42
  constructor(db: KernelDb, eventLog: Pick<EventLog, "appendIn" | "notify">, txHost: TransactionHost, now?: () => number);
43
43
  private clock;
44
- /** Records the run's context (see the class) and notifies after the commit. */
45
- record(input: RunContextInput): Promise<void>;
44
+ /**
45
+ * Records the run's context (see the class) and notifies after the commit. False: the run had
46
+ * ended (a stop got there first), so nothing was appended and the run must not go on.
47
+ */
48
+ record(input: RunContextInput): Promise<boolean>;
46
49
  /**
47
50
  * One transaction under the conversation row lock (as `lockLatestCatalog`): fold, read the
48
51
  * current values, append when something differs, with the idem key `run_context:<runId>` (a
49
- * retried run start finds its event, or finds nothing changed). Returns the seq written.
52
+ * retried run start finds its event, or finds nothing changed). Returns the seq written;
53
+ * `ended` when the run is terminal under the locks (nothing is appended after its end).
50
54
  */
51
55
  private append;
52
56
  /** A root run: agent, org, and the date, zone and language of the run's person (§3.4). */
@@ -17,6 +17,7 @@ import { Transactional } from "../infra/database/transactional.decorator.js";
17
17
  import { readOrgSettings } from "../orgs/settings.service.js";
18
18
  import { isTimeZone } from "../time-zone.js";
19
19
  import { currentRunContext, dateIn, runContextChange, sanitizeSenderName, snapshotDescription, } from "./context/run-context.js";
20
+ import { runEndedIn } from "./run-status.js";
20
21
  /**
21
22
  * Optional provider: the clock run start reads the date from and the `CurrentTime` builtin reads
22
23
  * the time from (default `Date.now`; tests).
@@ -46,16 +47,23 @@ let RunContextService = class RunContextService {
46
47
  clock() {
47
48
  return new Date(this.now());
48
49
  }
49
- /** Records the run's context (see the class) and notifies after the commit. */
50
+ /**
51
+ * Records the run's context (see the class) and notifies after the commit. False: the run had
52
+ * ended (a stop got there first), so nothing was appended and the run must not go on.
53
+ */
50
54
  async record(input) {
51
55
  const seq = await this.append(input);
56
+ if (seq === "ended")
57
+ return false;
52
58
  if (seq !== null)
53
59
  await this.eventLog.notify(input.conversationId, seq);
60
+ return true;
54
61
  }
55
62
  /**
56
63
  * One transaction under the conversation row lock (as `lockLatestCatalog`): fold, read the
57
64
  * current values, append when something differs, with the idem key `run_context:<runId>` (a
58
- * retried run start finds its event, or finds nothing changed). Returns the seq written.
65
+ * retried run start finds its event, or finds nothing changed). Returns the seq written;
66
+ * `ended` when the run is terminal under the locks (nothing is appended after its end).
59
67
  */
60
68
  async append(input) {
61
69
  const t = this.db.db;
@@ -65,6 +73,8 @@ let RunContextService = class RunContextService {
65
73
  .where("id", "=", input.conversationId)
66
74
  .forUpdate()
67
75
  .executeTakeFirst();
76
+ if (await runEndedIn(t, input.runId))
77
+ return "ended";
68
78
  const prev = await foldOf(t, input.conversationId);
69
79
  const next = input.subagent === undefined
70
80
  ? await this.rootContext(t, input)