@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
@@ -9,66 +9,111 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
9
9
  };
10
10
  import { randomUUID } from "node:crypto";
11
11
  import { ToolError, validateAllowHost, } from "@capybara-hq/plugin-sdk";
12
- import { Inject, Injectable } from "@nestjs/common";
12
+ import { Inject, Injectable, Optional } from "@nestjs/common";
13
13
  import { sql } from "kysely";
14
14
  import { ApiError } from "../errors.js";
15
15
  import { audit } from "../identity/audit.js";
16
+ import { KernelConfigService } from "../infra/config/kernel-config.service.js";
16
17
  import { KernelDb } from "../infra/database/kernel-db.js";
17
18
  import { currentStore, TransactionHost } from "../infra/database/transaction-host.js";
18
19
  import { Transactional } from "../infra/database/transactional.decorator.js";
20
+ import { OutboxService } from "../infra/outbox/outbox.service.js";
19
21
  import { KERNEL_LOGGER } from "../infra/tokens.js";
22
+ import { MAINTENANCE_QUEUE_NAME } from "../runtime/maintenance-queue.js";
20
23
  import { deviceNotFound } from "../sandbox/sandbox.service.js";
21
24
  import { MachinesService } from "./machines.js";
22
- import { isolationOf } from "./policy-summary.js";
23
- import { LocalDeviceRelay, ON_MACHINE_PREFIX } from "./relay.js";
25
+ import { DEVICE_OP_RESOLVE_JOB, deviceOpResolveJobId } from "./policy-op-jobs.js";
26
+ import { isolationOf, pendingPolicyOpsOf } from "./policy-summary.js";
27
+ import { LIST_TTL_MARGIN_MS, LocalDeviceRelay, ON_MACHINE_PREFIX } from "./relay.js";
24
28
  /** How long the machine may take to apply a policy change. */
25
29
  export const POLICY_DEADLINE_MS = 30_000;
26
- /**
27
- * How long a sent op may still explain a summary the machine reports by itself: its deadline and
28
- * the reconnect grace, with room for an answer that comes only with the next hello.
29
- */
30
- const PENDING_OP_MS = POLICY_DEADLINE_MS + 5 * 60_000;
30
+ /** How long the machine may take to answer a `policyStatus` (it only reads its op log). */
31
+ const STATUS_DEADLINE_MS = 10_000;
32
+ /** How long an op left `unknown` waits for the machine's hello to report it (from its intent). */
33
+ export const UNKNOWN_OP_MS = 24 * 3_600_000;
31
34
  /** The prefix the relay puts before a machine's `policy`-kind refusal. */
32
35
  const REFUSED_PREFIX = "Refused by the machine's local policy: ";
36
+ /**
37
+ * Test seam (`KernelOverride`): called by {@link DevicePolicyService.send} right after the `policy`
38
+ * op's call returned, before its outcome is recorded (by `apply` or a network ask's decision);
39
+ * throwing stands for a crash between the call and the resolve transaction.
40
+ * The kernel never provides it.
41
+ */
42
+ export const DEVICE_POLICY_AFTER_CALL = Symbol("DEVICE_POLICY_AFTER_CALL");
33
43
  const OFFLINE_MESSAGE = "The device's machine is offline, so its policy was not changed. Start capybara-device on it.";
34
44
  const UNSUPPORTED_MESSAGE = "The device's capybara-device program cannot change its policy from the platform. Update capybara-device on the machine.";
45
+ const UNCONFIRMED_MESSAGE = "The device's machine did not confirm the policy change; the platform checks it again shortly.";
46
+ const INTERRUPTED_MESSAGE = "capybara-device stopped while applying the policy change, and the change is not in the device's policy now.";
47
+ /** The longest text of the machine's own error the caller is told (as its op log keeps it). */
48
+ const MAX_MACHINE_MESSAGE = 500;
49
+ const NOT_RECEIVED_MESSAGE = "The device's machine never received the policy change.";
35
50
  const errMsg = (e) => (e instanceof Error ? e.message : String(e));
36
51
  /**
37
52
  * Changes a local device's policy on its machine (network ask-to-allow spec §5.2; sandbox dev
38
53
  * tiers spec §7.3: its hosts, its `tier`, and `isolation` back to `sandbox`): the kernel→device
39
- * `policy` op over the {@link LocalDeviceRelay}. The machine is the authority: it
40
- * refuses widening unless its owner allows remote grants, validates every host and answers with
41
- * the policy as written, which the kernel then keeps on the active machine's row (§5.4) and
42
- * audits as `device.policy_changed`.
54
+ * `policy` op over the {@link LocalDeviceRelay}. The machine is the authority: it refuses
55
+ * widening unless its owner allows remote grants, validates every host and answers with the
56
+ * policy as written, which the kernel then keeps on the active machine's row (§5.4).
43
57
  *
44
- * The op is sent outside any transaction (a decision records its outcome afterwards, in its own
45
- * transaction); only the summary write and the audit row are transactional. Callers decide who
46
- * may change a device's policy (the device owner, in a signed-in session); this service checks
47
- * only that the device is a live device of the org.
58
+ * Intent first (transactional outbox spec §7): {@link begin} writes the intent in a transaction
59
+ * before anything is sent (the entry on the machine row's `pending_policy_ops`, the audit
60
+ * `device.policy_requested` and the `device-op-resolve` job owed for when the op can no longer
61
+ * reach the machine); the call sends the op with its id (no transaction open); {@link settleIn}
62
+ * records the outcome: `device.policy_changed` (applied), `device.policy_failed` (refused, never
63
+ * received) or `device.policy_unknown` (no answer: the entry stays, `unknown`, for the machine's
64
+ * next hello or the resolver). A crash between the call and the resolve transaction leaves the
65
+ * intent and its owed job, whose resolver asks the machine what became of the op (never sends it
66
+ * again). Callers decide who may change a device's policy (the device owner, in a signed-in
67
+ * session); this service checks only that the device is a live device of the org.
48
68
  */
49
69
  let DevicePolicyService = class DevicePolicyService {
50
70
  db;
51
71
  txHost;
52
72
  relay;
53
73
  machines;
74
+ outbox;
75
+ config;
54
76
  log;
55
- constructor(db, txHost, relay, machines, log) {
77
+ afterCall;
78
+ constructor(db, txHost, relay, machines, outbox, config, log, afterCall = () => { }) {
56
79
  this.db = db;
57
80
  this.txHost = txHost;
58
81
  this.relay = relay;
59
82
  this.machines = machines;
83
+ this.outbox = outbox;
84
+ this.config = config;
60
85
  this.log = log;
86
+ this.afterCall = afterCall;
61
87
  }
62
88
  /**
63
- * Sends the `policy` op (no transaction may be open), stores the returned summary and audits
64
- * `device.policy_changed`. Never throws for an offline machine, a refusal or a program without
65
- * the `policyOp` feature (nothing is sent to it); a device that is not a live device of `orgId`
66
- * is `device.not_found`.
89
+ * Changes the policy: {@link begin}, the call, then the outcome recorded in its own transaction
90
+ * (no transaction may be open). Never throws for an offline machine, a refusal or a program
91
+ * without the `policyOpId` feature (nothing is sent to it); a device that is not a live device
92
+ * of `orgId` is `device.not_found`.
67
93
  */
68
94
  async apply(input) {
95
+ const begun = await this.begin(input);
96
+ if ("outcome" in begun)
97
+ return begun.outcome;
98
+ const { intent } = begun;
99
+ const sent = await this.send(intent);
100
+ const out = await this.settle(intent, sent.result);
101
+ if (sent.notFound)
102
+ throw deviceNotFound();
103
+ // unknown: the caller is told what the call saw (offline, or an answer the machine did not
104
+ // confirm), not the recorded state's generic text
105
+ return sent.result.outcome === "unknown" ? sent.outcome : (out ?? sent.outcome);
106
+ }
107
+ /**
108
+ * Checks the device and its machine, then records the intent in its own transaction: the entry
109
+ * (`sent`) on the machine's row, the audit `device.policy_requested` and the `device-op-resolve`
110
+ * job owed for when the op can no longer reach the machine. No transaction may be open (the
111
+ * call follows). Returns the intent, or why nothing will be sent (offline, unsupported).
112
+ */
113
+ async begin(input) {
69
114
  if (currentStore()) {
70
- throw new Error("DevicePolicyService.apply() must not run inside a transaction: send the policy op " +
71
- "first, then record the decision in a transaction.");
115
+ throw new Error("DevicePolicyService.begin() must not run inside a transaction: the intent is recorded " +
116
+ "first, the policy op sent, then the outcome recorded in a transaction.");
72
117
  }
73
118
  assertHosts(input.change, input.source.approvalId !== undefined);
74
119
  const device = await this.db
@@ -82,176 +127,327 @@ let DevicePolicyService = class DevicePolicyService {
82
127
  .executeTakeFirst();
83
128
  if (!device)
84
129
  throw deviceNotFound();
85
- if (device.source !== "local") {
86
- return { applied: false, reason: "unsupported", message: UNSUPPORTED_MESSAGE };
87
- }
130
+ if (device.source !== "local")
131
+ return { outcome: unsupported() };
88
132
  const machine = await this.machines.activeFor(input.deviceId);
89
133
  if (machine === null)
90
- return { applied: false, reason: "offline", message: OFFLINE_MESSAGE };
91
- if (!machine.features.includes("policyOp")) {
92
- return { applied: false, reason: "unsupported", message: UNSUPPORTED_MESSAGE };
93
- }
134
+ return { outcome: offline() };
135
+ // only a program that logs op outcomes under their ids can tell what became of a lost answer
136
+ if (!machine.features.includes("policyOpId"))
137
+ return { outcome: unsupported() };
94
138
  // not connected: nothing is sent (a queued op would wait for a reconnect nobody watches)
95
- if (!(await this.relay.holderLive(input.deviceId))) {
96
- return { applied: false, reason: "offline", message: OFFLINE_MESSAGE };
97
- }
98
- const args = { ...input.change, source: input.source };
99
- // noted before it is sent: the machine may report the change by itself (a `policy` frame)
100
- // before its answer is recorded, and the device page lists it once, as the platform's
139
+ if (!(await this.relay.holderLive(input.deviceId)))
140
+ return { outcome: offline() };
101
141
  const opId = randomUUID();
102
- await this.notePending(machine.id, {
103
- id: opId,
104
- add: input.change.add ?? [],
105
- remove: input.change.remove ?? [],
106
- ...(input.change.tier !== undefined && { tier: input.change.tier }),
107
- ...(input.change.isolation !== undefined && { isolation: input.change.isolation }),
108
- until: Date.now() + PENDING_OP_MS,
109
- });
110
- let policy;
142
+ const args = { ...input.change, opId, source: input.source };
143
+ const recorded = await this.recordIntent({ ...input, machineId: machine.id, opId });
144
+ if (!recorded)
145
+ return { outcome: offline() };
146
+ return {
147
+ intent: { orgId: input.orgId, deviceId: input.deviceId, machineId: machine.id, opId, args },
148
+ };
149
+ }
150
+ /**
151
+ * Sends the intent's `policy` op (no transaction may be open) and classifies the answer:
152
+ * applied; refused (the machine's local policy refused it: its message); anything else is
153
+ * `unknown`, left to the resolver's `policyStatus`: no answer, and an answer the kernel cannot
154
+ * trust to mean "not applied" (a result it cannot read, the machine's own error). `notFound`:
155
+ * the device was deleted meanwhile (recorded as unknown; the caller throws `device.not_found`).
156
+ */
157
+ async send(intent) {
158
+ if (currentStore()) {
159
+ throw new Error("DevicePolicyService.send() must not run inside a transaction: the outcome is recorded " +
160
+ "afterwards, in its own transaction.");
161
+ }
162
+ const sent = await this.call(intent);
163
+ this.afterCall(intent.opId);
164
+ return sent;
165
+ }
166
+ /** The `policy` op's call and its classification ({@link send}). */
167
+ async call(intent) {
111
168
  try {
112
- ({ policy } = await this.relay.call(input.deviceId, "policy", args, {
169
+ const { policy } = await this.relay.call(intent.deviceId, "policy", intent.args, {
113
170
  deadlineMs: POLICY_DEADLINE_MS,
114
171
  // the relay gives up by itself (deadline plus the reconnect grace)
115
172
  signal: new AbortController().signal,
116
- }));
173
+ });
174
+ return { result: { outcome: "applied", policy }, outcome: { applied: true, policy } };
117
175
  }
118
176
  catch (e) {
119
- if (e instanceof ApiError && e.code === "device.offline") {
120
- // the op was sent and the wait ran out: the machine may still apply it, so the attempt is
121
- // audited unconfirmed; its next hello reports the policy as it is
122
- await this.record({ ...input, machineId: machine.id, opId, policy: null });
177
+ if (e instanceof ApiError) {
178
+ // device.offline (the wait ran out: the machine may still apply it), device.forbidden
179
+ // (the machine was disconnected meanwhile), device.not_found (the device was deleted)
180
+ return {
181
+ result: { outcome: "unknown" },
182
+ outcome: offline(),
183
+ ...(e.code === "device.not_found" && { notFound: true }),
184
+ };
123
185
  }
124
- else {
125
- // refused or failed: nothing changed, so it explains no later change
126
- await this.dropPending(machine.id, opId);
186
+ if (e instanceof ToolError && e.message.startsWith(REFUSED_PREFIX)) {
187
+ const message = e.message.slice(REFUSED_PREFIX.length);
188
+ return {
189
+ result: { outcome: "refused", message },
190
+ outcome: { applied: false, reason: "refused", message },
191
+ };
127
192
  }
128
- return this.notApplied(input.deviceId, e);
193
+ // an unreadable result (the machine may have applied it) or the machine's own error (it may
194
+ // have failed after the write): what the machine logged under the op id tells
195
+ this.log.warn("a device policy change was not confirmed by the machine", {
196
+ deviceId: intent.deviceId,
197
+ opId: intent.opId,
198
+ error: errMsg(e),
199
+ });
200
+ const raw = errMsg(e);
201
+ const message = (raw.startsWith(ON_MACHINE_PREFIX) ? raw.slice(ON_MACHINE_PREFIX.length) : raw).slice(0, MAX_MACHINE_MESSAGE);
202
+ return {
203
+ result: { outcome: "unknown" },
204
+ outcome: {
205
+ applied: false,
206
+ reason: "offline",
207
+ message: `${UNCONFIRMED_MESSAGE} ${message}`,
208
+ },
209
+ };
129
210
  }
130
- await this.record({ ...input, machineId: machine.id, opId, policy });
131
- return { applied: true, policy };
211
+ }
212
+ /**
213
+ * What the machine says became of the op `opId` (a read-only `policyStatus`), or `offline` when
214
+ * no replica holds its connection or it does not answer. Never sends the change.
215
+ */
216
+ async outcomeOf(deviceId, opId) {
217
+ if (!(await this.relay.holderLive(deviceId)))
218
+ return { outcome: "offline" };
219
+ try {
220
+ return await this.relay.call(deviceId, "policyStatus", { opId }, { deadlineMs: STATUS_DEADLINE_MS, signal: new AbortController().signal });
221
+ }
222
+ catch (e) {
223
+ this.log.warn("could not ask a machine what became of a policy op", {
224
+ deviceId,
225
+ opId,
226
+ error: errMsg(e),
227
+ });
228
+ return { outcome: "offline" };
229
+ }
230
+ }
231
+ /**
232
+ * Records what became of the op `opId` of machine `machineId`, in the caller's transaction (the
233
+ * machine's row locked): `applied` → the policy the machine wrote (when given) kept on the row,
234
+ * the entry removed, `device.policy_changed` (`late` when the op was left `unknown`); `refused`,
235
+ * `not_received` or `interrupted` → the entry removed, `device.policy_failed` (`reason` the
236
+ * outcome); `unknown` → the entry kept, `unknown` until a day after its intent,
237
+ * `device.policy_unknown` (once; ignored once a hello reported the op). Returns the outcome for
238
+ * the caller, or null when the op has no live entry (resolved already, or expired: a no-op).
239
+ */
240
+ async settleIn(ref, result) {
241
+ const row = await this.db
242
+ .selectFrom("device_machines")
243
+ .select(["org_id", "device_id", "local_policy", "pending_policy_ops", "revoked_at"])
244
+ .where("id", "=", ref.machineId)
245
+ .forUpdate()
246
+ .executeTakeFirst();
247
+ const op = pendingPolicyOpsOf(row?.pending_policy_ops).find((o) => o.id === ref.opId);
248
+ // an expired entry no longer explains the machine's own reports (they are audited as the
249
+ // owner's): recording the op too would list the change twice
250
+ if (row === undefined || op === undefined || op.until <= Date.now())
251
+ return null;
252
+ const auditOp = (action, extra) => audit(this.db.db, {
253
+ actorAccountId: op.source.accountId,
254
+ ...(op.actorTokenId !== undefined && { actorTokenId: op.actorTokenId }),
255
+ orgId: row.org_id,
256
+ action,
257
+ target: row.device_id,
258
+ detail: { ...changeDetail(op), ...extra },
259
+ });
260
+ if (result.outcome === "unknown") {
261
+ // a hello reported the op meanwhile (its job records it), or it is unknown already
262
+ if (op.reported !== undefined || op.status === "unknown")
263
+ return { applied: false, reason: "offline", message: OFFLINE_MESSAGE };
264
+ await this.db
265
+ .updateTable("device_machines")
266
+ .set({
267
+ pending_policy_ops: withOp(op.id, {
268
+ status: "unknown",
269
+ until: op.sentAt + UNKNOWN_OP_MS,
270
+ }),
271
+ })
272
+ .where("id", "=", ref.machineId)
273
+ .execute();
274
+ await auditOp("device.policy_unknown", {});
275
+ return offline();
276
+ }
277
+ const live = row.revoked_at === null;
278
+ let isolationNow;
279
+ const policy = result.outcome === "applied" ? result.policy : undefined;
280
+ if (policy !== undefined && live) {
281
+ // the isolation the machine reports now, when it differs from the stored one: a tier change
282
+ // turns an unsandboxed device's sandbox back on without asking for it (spec §5.4, §10.4)
283
+ const before = isolationOf(row.local_policy) ?? "sandbox";
284
+ if (policy.isolation !== before)
285
+ isolationNow = policy.isolation;
286
+ }
287
+ await this.db
288
+ .updateTable("device_machines")
289
+ .set({
290
+ // resolved: the op no longer explains a summary the machine reports
291
+ pending_policy_ops: withoutOp(op.id),
292
+ ...(policy !== undefined && live && { local_policy: JSON.stringify(policy) }),
293
+ })
294
+ .where("id", "=", ref.machineId)
295
+ .execute();
296
+ if (result.outcome === "applied") {
297
+ await auditOp("device.policy_changed", {
298
+ ...(isolationNow !== undefined && { isolation: isolationNow }),
299
+ ...(op.status === "unknown" && { late: true }),
300
+ });
301
+ const stored = policy ?? (await this.machines.activeFor(row.device_id))?.localPolicy;
302
+ return stored
303
+ ? { applied: true, policy: stored }
304
+ : { applied: false, reason: "offline", message: OFFLINE_MESSAGE };
305
+ }
306
+ const message = result.outcome === "refused"
307
+ ? result.message
308
+ : result.outcome === "interrupted"
309
+ ? INTERRUPTED_MESSAGE
310
+ : NOT_RECEIVED_MESSAGE;
311
+ await auditOp("device.policy_failed", { reason: result.outcome, message });
312
+ return result.outcome === "refused"
313
+ ? { applied: false, reason: "refused", message }
314
+ : offline();
132
315
  }
133
316
  /** The active machine's features and policy summary (null when no machine is connected). */
134
317
  async summary(deviceId) {
135
318
  const m = await this.machines.activeFor(deviceId);
136
319
  return m === null ? null : { features: m.features, policy: m.localPolicy };
137
320
  }
138
- /** Adds `op` to the machine's pending ops, dropping the expired ones. */
139
- async notePending(machineId, op) {
321
+ /**
322
+ * How long after its intent an op can no longer reach the machine: the call's deadline, the
323
+ * reconnect grace and the request list's margin (the relay's list TTL), plus 5 s. A machine
324
+ * that does not know the op id by then never will.
325
+ */
326
+ resolveDelayMs() {
327
+ const graceMs = this.config.get().devices.local.reconnectGraceSeconds * 1000;
328
+ return POLICY_DEADLINE_MS + graceMs + LIST_TTL_MARGIN_MS + 5_000;
329
+ }
330
+ /** {@link settleIn} in its own transaction (the {@link apply} path). */
331
+ settle(intent, result) {
332
+ return this.settleIn(intent, result);
333
+ }
334
+ /**
335
+ * The intent transaction: locks the machine's row (still the device's active machine), appends
336
+ * the entry (dropping the expired ones), audits `device.policy_requested` and owes the
337
+ * `device-op-resolve` job. False when the machine was revoked meanwhile (nothing written).
338
+ */
339
+ async recordIntent(input) {
340
+ const locked = await this.db
341
+ .selectFrom("device_machines")
342
+ .select("id")
343
+ .where("id", "=", input.machineId)
344
+ .where("revoked_at", "is", null)
345
+ .forUpdate()
346
+ .executeTakeFirst();
347
+ if (locked === undefined)
348
+ return false;
349
+ const now = Date.now();
350
+ const resolveAt = now + this.resolveDelayMs();
351
+ const { change, source } = input;
352
+ const op = {
353
+ id: input.opId,
354
+ add: change.add ?? [],
355
+ remove: change.remove ?? [],
356
+ ...(change.tier !== undefined && { tier: change.tier }),
357
+ ...(change.isolation !== undefined && { isolation: change.isolation }),
358
+ source: {
359
+ accountId: source.accountId,
360
+ via: source.via,
361
+ approvalId: source.approvalId ?? null,
362
+ },
363
+ ...(input.actorTokenId !== undefined && { actorTokenId: input.actorTokenId }),
364
+ status: "sent",
365
+ sentAt: now,
366
+ resolveAt,
367
+ // a summary the machine reports by itself may be this op's until it is resolved
368
+ until: now + UNKNOWN_OP_MS,
369
+ };
140
370
  await this.db
141
371
  .updateTable("device_machines")
142
372
  .set({
143
373
  pending_policy_ops: sql `(
144
374
  SELECT coalesce(jsonb_agg(e), '[]'::jsonb) FROM jsonb_array_elements(pending_policy_ops) e
145
- WHERE (e->>'until')::bigint > ${Date.now()}
375
+ WHERE (e->>'until')::bigint > ${now}
146
376
  ) || jsonb_build_array(${JSON.stringify(op)}::jsonb)`,
147
377
  })
148
- .where("id", "=", machineId)
378
+ .where("id", "=", input.machineId)
149
379
  .execute();
150
- }
151
- /** Removes the pending op `opId` from the machine's row. */
152
- async dropPending(machineId, opId) {
153
- await this.db
154
- .updateTable("device_machines")
155
- .set({ pending_policy_ops: withoutOp(opId) })
156
- .where("id", "=", machineId)
157
- .execute();
158
- }
159
- /**
160
- * Keeps the policy the machine wrote on its row and audits the change (`confirmed`); with no
161
- * policy (the answer never came) only audits the attempt, unconfirmed. The detail's `isolation`
162
- * is the one asked for, or the one the machine now reports when it changed (a tier change turns
163
- * the sandbox back on).
164
- */
165
- async record(input) {
166
- // the isolation the machine reports now, when it differs from the stored one: a tier change
167
- // turns an unsandboxed device's sandbox back on without asking for it (spec §5.4, §10.4)
168
- let isolationNow;
169
- if (input.policy !== null) {
170
- const prev = await this.db
171
- .selectFrom("device_machines")
172
- .select("local_policy")
173
- .where("id", "=", input.machineId)
174
- .where("revoked_at", "is", null)
175
- .forUpdate()
176
- .executeTakeFirst();
177
- const before = isolationOf(prev?.local_policy) ?? "sandbox";
178
- if (input.policy.isolation !== before)
179
- isolationNow = input.policy.isolation;
180
- await this.db
181
- .updateTable("device_machines")
182
- // answered: the op no longer explains a summary the machine reports
183
- .set({
184
- local_policy: JSON.stringify(input.policy),
185
- pending_policy_ops: withoutOp(input.opId),
186
- })
187
- .where("id", "=", input.machineId)
188
- .where("revoked_at", "is", null)
189
- .execute();
190
- }
191
- const { change, source } = input;
192
380
  await audit(this.db.db, {
193
381
  actorAccountId: source.accountId,
194
382
  ...(input.actorTokenId !== undefined && { actorTokenId: input.actorTokenId }),
195
383
  orgId: input.orgId,
196
- action: "device.policy_changed",
384
+ action: "device.policy_requested",
197
385
  target: input.deviceId,
198
- detail: {
199
- add: change.add ?? [],
200
- remove: change.remove ?? [],
201
- // only when set (null: follow the org's default); the device page lists the change
202
- ...(change.tier !== undefined && { tier: change.tier }),
203
- ...((isolationNow ?? change.isolation) !== undefined && {
204
- isolation: isolationNow ?? change.isolation,
205
- }),
206
- by: source.accountId,
207
- via: source.via,
208
- approvalId: source.approvalId ?? null,
209
- confirmed: input.policy !== null,
210
- },
386
+ detail: changeDetail(op),
211
387
  });
212
- }
213
- /** The outcome of a failed op: offline, or the machine's refusal; a deleted device is thrown. */
214
- notApplied(deviceId, e) {
215
- if (e instanceof ApiError) {
216
- if (e.code === "device.not_found")
217
- throw e;
218
- // device.offline, or device.forbidden (the machine was disconnected meanwhile)
219
- return { applied: false, reason: "offline", message: OFFLINE_MESSAGE };
220
- }
221
- if (e instanceof ToolError && e.message.startsWith(REFUSED_PREFIX)) {
222
- return { applied: false, reason: "refused", message: e.message.slice(REFUSED_PREFIX.length) };
223
- }
224
- // anything else the machine answered (its own error, an invalid result): not applied
225
- this.log.warn("a device policy change failed on the machine", { deviceId, error: errMsg(e) });
226
- const message = errMsg(e);
227
- return {
228
- applied: false,
229
- reason: "refused",
230
- message: message.startsWith(ON_MACHINE_PREFIX)
231
- ? message.slice(ON_MACHINE_PREFIX.length)
232
- : message,
233
- };
388
+ await this.outbox.add({
389
+ queue: MAINTENANCE_QUEUE_NAME,
390
+ name: DEVICE_OP_RESOLVE_JOB,
391
+ jobId: deviceOpResolveJobId(input.opId),
392
+ data: { machineId: input.machineId, opId: input.opId },
393
+ runAt: new Date(resolveAt),
394
+ orgId: input.orgId,
395
+ });
396
+ return true;
234
397
  }
235
398
  };
236
399
  __decorate([
237
- Transactional()
238
- ], DevicePolicyService.prototype, "notePending", null);
400
+ Transactional({ propagation: "mandatory" })
401
+ ], DevicePolicyService.prototype, "settleIn", null);
239
402
  __decorate([
240
403
  Transactional()
241
- ], DevicePolicyService.prototype, "dropPending", null);
404
+ ], DevicePolicyService.prototype, "settle", null);
242
405
  __decorate([
243
406
  Transactional()
244
- ], DevicePolicyService.prototype, "record", null);
407
+ ], DevicePolicyService.prototype, "recordIntent", null);
245
408
  DevicePolicyService = __decorate([
246
409
  Injectable(),
247
410
  __param(0, Inject(KernelDb)),
248
411
  __param(1, Inject(TransactionHost)),
249
412
  __param(2, Inject(LocalDeviceRelay)),
250
413
  __param(3, Inject(MachinesService)),
251
- __param(4, Inject(KERNEL_LOGGER))
414
+ __param(4, Inject(OutboxService)),
415
+ __param(5, Inject(KernelConfigService)),
416
+ __param(6, Inject(KERNEL_LOGGER)),
417
+ __param(7, Optional()),
418
+ __param(7, Inject(DEVICE_POLICY_AFTER_CALL))
252
419
  ], DevicePolicyService);
253
420
  export { DevicePolicyService };
254
- /** The machine's pending ops without `opId` (an expired one left for the next note to drop). */
421
+ const offline = () => ({
422
+ applied: false,
423
+ reason: "offline",
424
+ message: OFFLINE_MESSAGE,
425
+ });
426
+ const unsupported = () => ({
427
+ applied: false,
428
+ reason: "unsupported",
429
+ message: UNSUPPORTED_MESSAGE,
430
+ });
431
+ /** The audit detail of an op's change and source (every `device.policy_*` row of an op). */
432
+ function changeDetail(op) {
433
+ return {
434
+ opId: op.id,
435
+ add: op.add,
436
+ remove: op.remove,
437
+ // only when set (null: follow the org's default); the device page lists the change
438
+ ...(op.tier !== undefined && { tier: op.tier }),
439
+ ...(op.isolation !== undefined && { isolation: op.isolation }),
440
+ by: op.source.accountId,
441
+ via: op.source.via,
442
+ approvalId: op.source.approvalId,
443
+ };
444
+ }
445
+ /** The machine's pending ops with the fields `patch` merged into the op `opId`. */
446
+ const withOp = (opId, patch) => sql `(
447
+ SELECT coalesce(jsonb_agg(CASE WHEN e->>'id' = ${opId} THEN e || ${JSON.stringify(patch)}::jsonb ELSE e END), '[]'::jsonb)
448
+ FROM jsonb_array_elements(pending_policy_ops) e
449
+ )`;
450
+ /** The machine's pending ops without `opId` (an expired one left for the next intent to drop). */
255
451
  const withoutOp = (opId) => sql `(
256
452
  SELECT coalesce(jsonb_agg(e), '[]'::jsonb) FROM jsonb_array_elements(pending_policy_ops) e
257
453
  WHERE e->>'id' <> ${opId}
@@ -1,6 +1,7 @@
1
1
  import type { DeviceFeature, DeviceHello, DeviceLocalPolicySummary, DeviceStopArgs, ExtensionContext } from "@capybara-hq/plugin-sdk";
2
2
  import { KernelDb } from "../infra/database/kernel-db.js";
3
3
  import { TransactionHost } from "../infra/database/transaction-host.js";
4
+ import { OutboxService } from "../infra/outbox/outbox.service.js";
4
5
  import type { KernelLogger } from "../log.js";
5
6
  import { LocalDeviceRelay } from "./relay.js";
6
7
  /** The machine credential's prefix (`cap_dev_` plus 32 random bytes, base64url). */
@@ -43,8 +44,9 @@ export declare class MachinesService {
43
44
  readonly txHost: TransactionHost;
44
45
  private readonly relay;
45
46
  private readonly bus;
47
+ private readonly outbox;
46
48
  private readonly log;
47
- constructor(db: KernelDb, txHost: TransactionHost, relay: Pick<LocalDeviceRelay, "holderLive" | "send">, bus: ExtensionContext["bus"], log: Pick<KernelLogger, "warn">);
49
+ constructor(db: KernelDb, txHost: TransactionHost, relay: Pick<LocalDeviceRelay, "holderLive" | "send">, bus: ExtensionContext["bus"], outbox: Pick<OutboxService, "add">, log: Pick<KernelLogger, "warn">);
48
50
  /**
49
51
  * A new machine for the device and its credential (returned once). Revokes the
50
52
  * device's active machine first. Joins the caller's transaction (the device confirmation),
@@ -96,9 +98,22 @@ export declare class MachinesService {
96
98
  * isolation that differs from the stored one (none stored counts as `sandbox`) is audited
97
99
  * `device.isolation_reported` (sandbox dev tiers spec §10.4), once per change; a change of what
98
100
  * the owner sets, made on the machine while it was away, is audited `device.policy_changed`
99
- * ({@link reportedPolicy}).
101
+ * ({@link reportedPolicy}). The outcomes of its recent policy ops (`recentOps`) backfill the
102
+ * platform's ops still pending on the row ({@link backfillOps}).
100
103
  */
101
104
  recordHello(machineId: string, hello: DeviceHello, rawPolicy?: unknown): Promise<void>;
105
+ /**
106
+ * Hello backfill (transactional outbox spec §7): a pending op of the platform's whose outcome
107
+ * the hello reports (`applied`, `refused`), or that it does not list although the op can no
108
+ * longer reach the machine (`absent`: past its `resolveAt`), is marked `reported` and owes a
109
+ * `device-op-resolve` job (`device-op-reported-<opId>`) that records the outcome. An op the
110
+ * machine logged only as `started` (it stopped between the log and the answer) is judged once
111
+ * past its `resolveAt` by the policy the hello reports: its change there → `applied`, else
112
+ * `interrupted` (within the window, or with no readable policy, it is left). An expired entry
113
+ * (past `until`) is skipped: the machine's reports are no longer the op's. Runs in the caller's
114
+ * transaction, with the machine's row locked; an op already reported is not owed again.
115
+ */
116
+ private backfillOps;
102
117
  /**
103
118
  * Stores the local policy summary a connected program pushed after its hello (a `policy` frame:
104
119
  * its policy file changed on the machine, its localhost proxy guard flipped, a new tier