@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
@@ -154,7 +154,7 @@ const resultsViewedKey = (runId, accountId) => `capk:eval-results-viewed:${runId
154
154
  let EvalRunsService = EvalRunsService_1 = class EvalRunsService {
155
155
  txHost;
156
156
  d;
157
- constructor(kdb, orgLock, locks, config, registry, prices, settings, queue, budget, purposes, txHost) {
157
+ constructor(kdb, orgLock, locks, config, registry, prices, settings, jobs, budget, purposes, txHost) {
158
158
  this.txHost = txHost;
159
159
  this.d = {
160
160
  orgLock,
@@ -166,7 +166,7 @@ let EvalRunsService = EvalRunsService_1 = class EvalRunsService {
166
166
  registry,
167
167
  prices,
168
168
  settings,
169
- queue,
169
+ jobs,
170
170
  budget,
171
171
  purposes,
172
172
  };
@@ -415,8 +415,9 @@ let EvalRunsService = EvalRunsService_1 = class EvalRunsService {
415
415
  }
416
416
  /**
417
417
  * 启动:校验 token 上限([1, `evals.maxTokenCap`],缺省 `evals.defaultTokenCap`)、候选、评委用途;
418
- * 没有可回放的 → `eval.nothing_to_replay`。事务内建运行行、预建 pending 结果,提交后每个用例入队一个任务;
419
- * 入队失败 → 运行标 failed、结果 skipped,错误照常抛出。
418
+ * 没有可回放的 → `eval.nothing_to_replay`。One transaction writes the run row and its pending
419
+ * results and owes one case job per valid case (transactional outbox spec §3): the outbox adds
420
+ * them after the commit, so a run never waits for a job that was never added.
420
421
  */
421
422
  async start(orgId, actor, setId, input, actorTokenId, auditDetail) {
422
423
  const set = await this.liveSet(orgId, setId);
@@ -435,17 +436,10 @@ let EvalRunsService = EvalRunsService_1 = class EvalRunsService {
435
436
  if (firstProblem) {
436
437
  throw candidateInvalid(firstProblem.message, firstProblem.details ?? []);
437
438
  }
438
- const { runId, plans } = await this.startIn(orgId, actor, setId, set.agent_id, input, tokenCap, planned, judge, actorTokenId, auditDetail);
439
- try {
440
- await this.d.queue.enqueue(plans.map((p) => ({ runId, caseId: p.caseId, orgId })));
441
- }
442
- catch (e) {
443
- await this.failStartIn(runId);
444
- throw e;
445
- }
439
+ const { runId } = await this.startIn(orgId, actor, setId, set.agent_id, input, tokenCap, planned, judge, actorTokenId, auditDetail);
446
440
  return this.get(orgId, runId);
447
441
  }
448
- /** The run row and its pending results, in a transaction of their own (enqueued after the commit). */
442
+ /** The run row, its pending results and its owed case jobs, in a transaction of their own. */
449
443
  async startIn(orgId, actor, setId, agentId, input, tokenCap, planned, judge, actorTokenId, auditDetail) {
450
444
  const db = this.d.db;
451
445
  await this.d.orgLock.lockAndAuthorize(orgId, actor, "evals.manage");
@@ -521,23 +515,10 @@ let EvalRunsService = EvalRunsService_1 = class EvalRunsService {
521
515
  },
522
516
  });
523
517
  await this.d.budget.recountCases(run.id);
524
- return { runId: run.id, plans: plans.filter((p) => p.problem === null) };
525
- }
526
- /** The enqueue failed: the run is `failed`, its pending results `skipped`. */
527
- async failStartIn(runId) {
528
- const db = this.d.db;
529
- await db
530
- .updateTable("eval_runs")
531
- .set({ status: "failed", finished_at: sql `now()` })
532
- .where("id", "=", runId)
533
- .execute();
534
- await db
535
- .updateTable("eval_results")
536
- .set({ status: "skipped", updated_at: sql `now()` })
537
- .where("run_id", "=", runId)
538
- .where("status", "=", "pending")
539
- .execute();
540
- await this.d.budget.recountCases(runId);
518
+ await this.d.jobs.owe(plans
519
+ .filter((p) => p.problem === null)
520
+ .map((p) => ({ runId: run.id, caseId: p.caseId, orgId })));
521
+ return { runId: run.id };
541
522
  }
542
523
  /** 各运行的汇总(只算已结束的)。 */
543
524
  async summaries(rows) {
@@ -836,9 +817,6 @@ let EvalRunsService = EvalRunsService_1 = class EvalRunsService {
836
817
  __decorate([
837
818
  Transactional({ propagation: "requires_new" })
838
819
  ], EvalRunsService.prototype, "startIn", null);
839
- __decorate([
840
- Transactional({ propagation: "requires_new" })
841
- ], EvalRunsService.prototype, "failStartIn", null);
842
820
  __decorate([
843
821
  Transactional({ propagation: "requires_new" })
844
822
  ], EvalRunsService.prototype, "cancelIn", null);
@@ -46,6 +46,12 @@ export declare class OutboxService {
46
46
  * conflicts, else logs an error. Undefined data is stored as JSON null.
47
47
  */
48
48
  add<T>(job: OutboxJob<T>): Promise<boolean>;
49
+ /**
50
+ * {@link add} for many jobs in one multi-row insert per {@link ADD_MANY_CHUNK} jobs (an eval
51
+ * run's cases). A job whose (queue, jobId) is already owed is skipped without comparing its data.
52
+ * Returns how many rows it inserted.
53
+ */
54
+ addMany<T>(jobs: readonly OutboxJob<T>[]): Promise<number>;
49
55
  /** Collects `id` for its transaction; the first id registers the transaction's one hint. */
50
56
  private deliverAfterCommit;
51
57
  }
@@ -21,6 +21,29 @@ export const OUTBOX_FAST_PATH = Symbol("OUTBOX_FAST_PATH");
21
21
  * logging an error (spec §4, designer's call: "throws in tests, logs in production").
22
22
  */
23
23
  export const OUTBOX_STRICT_CONFLICTS = Symbol("OUTBOX_STRICT_CONFLICTS");
24
+ /** Jobs per insert statement of {@link OutboxService.addMany} (6 parameters each). */
25
+ const ADD_MANY_CHUNK = 1000;
26
+ /** The row of `job` (throws on a job id with `:`). */
27
+ function rowOf(job) {
28
+ if (job.jobId.includes(":")) {
29
+ throw new Error(`outbox job id "${job.jobId}" must not contain ":" (BullMQ uses it as its key separator); ` +
30
+ 'join the id\'s parts with "-" instead');
31
+ }
32
+ return {
33
+ queue: job.queue,
34
+ job_name: job.name,
35
+ job_id: job.jobId,
36
+ data: JSON.stringify(job.data ?? null),
37
+ run_at: job.runAt ?? null,
38
+ org_id: job.orgId ?? null,
39
+ };
40
+ }
41
+ /**
42
+ * Only a job due now goes through the fast path (ruling R20): a delayed one (a backstop, a
43
+ * timeout) is claimed by the relay within its next tick and added with its remaining delay, so the
44
+ * commit's caller spends no claim transaction on work due in seconds or hours.
45
+ */
46
+ const dueNow = (job) => job.runAt === undefined || job.runAt.getTime() <= Date.now();
24
47
  /**
25
48
  * The ids each open transaction inserted, keyed by its transaction store: lives only as long as
26
49
  * that transaction (a WeakMap entry, no state shared across requests).
@@ -51,31 +74,16 @@ let OutboxService = class OutboxService {
51
74
  * conflicts, else logs an error. Undefined data is stored as JSON null.
52
75
  */
53
76
  async add(job) {
54
- if (job.jobId.includes(":")) {
55
- throw new Error(`outbox job id "${job.jobId}" must not contain ":" (BullMQ uses it as its key separator); ` +
56
- 'join the id\'s parts with "-" instead');
57
- }
58
77
  const json = JSON.stringify(job.data ?? null);
59
78
  const row = await this.db
60
79
  .insertInto("outbox")
61
- .values({
62
- queue: job.queue,
63
- job_name: job.name,
64
- job_id: job.jobId,
65
- data: json,
66
- run_at: job.runAt ?? null,
67
- org_id: job.orgId ?? null,
68
- })
80
+ .values(rowOf(job))
69
81
  .onConflict((oc) => oc.columns(["queue", "job_id"]).where("dead_at", "is", null).doNothing())
70
82
  .returning("id")
71
83
  .executeTakeFirst();
72
84
  if (row) {
73
- // only a job due now goes through the fast path (ruling R20): a delayed one (a backstop, a
74
- // timeout) is claimed by the relay within its next tick and added with its remaining delay,
75
- // so the commit's caller spends no claim transaction on work due in seconds or hours
76
- if (job.runAt === undefined || job.runAt.getTime() <= Date.now()) {
85
+ if (dueNow(job))
77
86
  this.deliverAfterCommit(row.id);
78
- }
79
87
  return true;
80
88
  }
81
89
  const stored = await this.db
@@ -100,6 +108,29 @@ let OutboxService = class OutboxService {
100
108
  }
101
109
  return false;
102
110
  }
111
+ /**
112
+ * {@link add} for many jobs in one multi-row insert per {@link ADD_MANY_CHUNK} jobs (an eval
113
+ * run's cases). A job whose (queue, jobId) is already owed is skipped without comparing its data.
114
+ * Returns how many rows it inserted.
115
+ */
116
+ async addMany(jobs) {
117
+ let inserted = 0;
118
+ for (let i = 0; i < jobs.length; i += ADD_MANY_CHUNK) {
119
+ const chunk = jobs.slice(i, i + ADD_MANY_CHUNK);
120
+ const rows = await this.db
121
+ .insertInto("outbox")
122
+ .values(chunk.map(rowOf))
123
+ .onConflict((oc) => oc.columns(["queue", "job_id"]).where("dead_at", "is", null).doNothing())
124
+ .returning(["id", "run_at"])
125
+ .execute();
126
+ for (const r of rows) {
127
+ if (dueNow({ runAt: r.run_at ?? undefined }))
128
+ this.deliverAfterCommit(r.id);
129
+ }
130
+ inserted += rows.length;
131
+ }
132
+ return inserted;
133
+ }
103
134
  /** Collects `id` for its transaction; the first id registers the transaction's one hint. */
104
135
  deliverAfterCommit(id) {
105
136
  // `mandatory` guarantees a live store here
@@ -119,6 +150,9 @@ let OutboxService = class OutboxService {
119
150
  __decorate([
120
151
  Transactional({ propagation: "mandatory" })
121
152
  ], OutboxService.prototype, "add", null);
153
+ __decorate([
154
+ Transactional({ propagation: "mandatory" })
155
+ ], OutboxService.prototype, "addMany", null);
122
156
  OutboxService = __decorate([
123
157
  Injectable(),
124
158
  __param(0, Inject(TransactionHost)),
@@ -145,6 +145,21 @@ export declare const OUTBOX_JOBS: {
145
145
  readonly removeOnFail: true;
146
146
  };
147
147
  };
148
+ readonly "channel-resume": {
149
+ readonly queue: "cap-kernel-channels";
150
+ readonly idempotent: "the catch-up re-reads which of the connection's conversations lag their log and adds their delivery jobs, and a delivery resumes from the delivered seq under a per-conversation lock, so a second catch-up sends nothing twice";
151
+ readonly processedIn: "packages/kernel/src/channels/channels.module.ts";
152
+ readonly opts: {
153
+ readonly attempts: 5;
154
+ readonly backoff: {
155
+ readonly type: "exponential";
156
+ readonly delay: 5000;
157
+ };
158
+ readonly priority: 10;
159
+ readonly removeOnComplete: true;
160
+ readonly removeOnFail: true;
161
+ };
162
+ };
148
163
  readonly "scope-item": {
149
164
  readonly queue: "cap-kernel-plugin-scope";
150
165
  readonly idempotent: "the sync converges each (item, org) from the scope, switches and installs it reads under the org's lock, and the installer re-checks under the org row lock, so a second run changes nothing";
@@ -221,6 +236,43 @@ export declare const OUTBOX_JOBS: {
221
236
  readonly removeOnFail: true;
222
237
  };
223
238
  };
239
+ readonly "device-op-resolve": {
240
+ readonly queue: "cap-kernel-maintenance";
241
+ readonly idempotent: "resolving an op whose entry is gone is a no-op (the entry is read under the machine row's lock, and under the network ask's lock for an op that answers one, so the ask is decided at most once); the resolver only reads the device, never sends a change";
242
+ readonly processedIn: "packages/kernel/src/runtime/maintenance.ts";
243
+ readonly opts: {
244
+ readonly attempts: 5;
245
+ readonly backoff: {
246
+ readonly type: "exponential";
247
+ readonly delay: 5000;
248
+ };
249
+ readonly removeOnComplete: true;
250
+ readonly removeOnFail: true;
251
+ };
252
+ };
253
+ readonly "bg-wake": {
254
+ readonly queue: "cap-kernel-maintenance";
255
+ readonly idempotent: "the wake only calls ensureRun, which decides from the conversation's state under its row lock, so a wake with nothing unconsumed or an active run starts nothing";
256
+ readonly processedIn: "packages/kernel/src/runtime/maintenance.ts";
257
+ readonly opts: {
258
+ readonly removeOnComplete: true;
259
+ readonly removeOnFail: true;
260
+ };
261
+ };
262
+ readonly "eval-case": {
263
+ readonly queue: "cap-kernel-evals";
264
+ readonly idempotent: "a case job replays only the case's unfinished results (and pending judgements) of a still-active run, each under a fresh claim, so a second job writes no result twice";
265
+ readonly processedIn: "packages/kernel/src/evals/evals.module.ts";
266
+ readonly opts: {
267
+ readonly attempts: 3;
268
+ readonly backoff: {
269
+ readonly type: "exponential";
270
+ readonly delay: 1000;
271
+ };
272
+ readonly removeOnComplete: true;
273
+ readonly removeOnFail: 1000;
274
+ };
275
+ };
224
276
  readonly lifecycle: {
225
277
  readonly queue: "cap-kernel-lifecycle";
226
278
  readonly idempotent: "lifecycle handlers must be idempotent (the SDK contract): they delete what the plugin holds for the removed member, agent or org, and deleting it again is a no-op";
@@ -130,6 +130,22 @@ export const OUTBOX_JOBS = {
130
130
  removeOnFail: true,
131
131
  },
132
132
  },
133
+ // `channels/deliver-job.ts` RESUME_JOB: owed by the update that lets a connection deliver again
134
+ // (re-enabled, or credentials rotated while active), under `resumeJobId` (one per update)
135
+ "channel-resume": {
136
+ queue: "cap-kernel-channels",
137
+ idempotent: "the catch-up re-reads which of the connection's conversations lag their log and adds their delivery jobs, and a delivery resumes from the delivered seq under a per-conversation lock, so a second catch-up sends nothing twice",
138
+ processedIn: "packages/kernel/src/channels/channels.module.ts",
139
+ // retried while PG or Redis is briefly unavailable; behind inbound jobs like a delivery;
140
+ // removed once done
141
+ opts: {
142
+ attempts: 5,
143
+ backoff: { type: "exponential", delay: 5_000 },
144
+ priority: 10,
145
+ removeOnComplete: true,
146
+ removeOnFail: true,
147
+ },
148
+ },
133
149
  // `catalog/scope-sync.ts` SCOPE_ITEM_JOB: owed by every transaction that changes an item's
134
150
  // scope, disabled flag, list, owner or platform version, under `scopeItemJobId` (one per scope
135
151
  // version), and by a sync deferred on a replica that had not loaded the extension yet, under
@@ -210,6 +226,47 @@ export const OUTBOX_JOBS = {
210
226
  removeOnFail: true,
211
227
  },
212
228
  },
229
+ // `local-devices/policy-op-jobs.ts` DEVICE_OP_RESOLVE_JOB: owed by a device policy op's intent
230
+ // transaction under `deviceOpResolveJobId` with `runAt` = when the op can no longer reach the
231
+ // machine, and by a hello that reported the op's outcome under `deviceOpReportedJobId` (spec §7)
232
+ "device-op-resolve": {
233
+ queue: "cap-kernel-maintenance",
234
+ idempotent: "resolving an op whose entry is gone is a no-op (the entry is read under the machine row's lock, and under the network ask's lock for an op that answers one, so the ask is decided at most once); the resolver only reads the device, never sends a change",
235
+ processedIn: "packages/kernel/src/runtime/maintenance.ts",
236
+ // retried while PG or the device is briefly unreachable; removed once done
237
+ opts: {
238
+ attempts: 5,
239
+ backoff: { type: "exponential", delay: 5_000 },
240
+ removeOnComplete: true,
241
+ removeOnFail: true,
242
+ },
243
+ },
244
+ // `background/wake-rules.ts` BG_WAKE_JOB: owed by the `ensureRun` transaction that finds a
245
+ // conversation's wake inside its wake window, under `bgWakeJobId`, with `runAt` = the window's
246
+ // end (background Bash spec §3)
247
+ "bg-wake": {
248
+ queue: "cap-kernel-maintenance",
249
+ idempotent: "the wake only calls ensureRun, which decides from the conversation's state under its row lock, so a wake with nothing unconsumed or an active run starts nothing",
250
+ processedIn: "packages/kernel/src/runtime/maintenance.ts",
251
+ // the options the direct enqueue had (the queue's default retries): removed once done, so a
252
+ // later window's wake is never deduped away (a job that fired early owes its next retry id)
253
+ opts: { removeOnComplete: true, removeOnFail: true },
254
+ },
255
+ // `evals/jobs.ts` EVAL_CASE_JOB: owed by an eval run's start transaction (the run row and its
256
+ // pending results), one per case, under `evalJobId`
257
+ "eval-case": {
258
+ queue: "cap-kernel-evals",
259
+ idempotent: "a case job replays only the case's unfinished results (and pending judgements) of a still-active run, each under a fresh claim, so a second job writes no result twice",
260
+ processedIn: "packages/kernel/src/evals/evals.module.ts",
261
+ // the options the direct enqueue had: transient failures outside the handler (PG, Redis)
262
+ // retried with backoff; removed once done, failed ones kept (by count)
263
+ opts: {
264
+ attempts: 3,
265
+ backoff: { type: "exponential", delay: 1000 },
266
+ removeOnComplete: true,
267
+ removeOnFail: 1000,
268
+ },
269
+ },
213
270
  // `extensions/lifecycle-hooks.ts` LIFECYCLE_JOB: owed by the transaction that removes a member,
214
271
  // deletes an agent or deletes an org, once per subscribing plugin, under `lifecycleJobId`
215
272
  // (memory plugin spec §6.3)
@@ -1,11 +1,24 @@
1
- import { type DeviceFeature, type DeviceLocalPolicySummary, type DevicePolicyArgs } from "@capybara-hq/plugin-sdk";
1
+ import { type DeviceFeature, type DeviceLocalPolicySummary, type DevicePolicyArgs, type DevicePolicyStatus } from "@capybara-hq/plugin-sdk";
2
+ import { KernelConfigService } from "../infra/config/kernel-config.service.js";
2
3
  import { KernelDb } from "../infra/database/kernel-db.js";
3
4
  import { TransactionHost } from "../infra/database/transaction-host.js";
5
+ import { OutboxService } from "../infra/outbox/outbox.service.js";
4
6
  import type { KernelLogger } from "../log.js";
5
7
  import { MachinesService } from "./machines.js";
6
8
  import { LocalDeviceRelay } from "./relay.js";
7
9
  /** How long the machine may take to apply a policy change. */
8
10
  export declare const POLICY_DEADLINE_MS = 30000;
11
+ /** How long an op left `unknown` waits for the machine's hello to report it (from its intent). */
12
+ export declare const UNKNOWN_OP_MS: number;
13
+ /** A policy change as callers ask for it: the op id and the source are added here. */
14
+ type PolicyChange = Omit<DevicePolicyArgs, "source" | "opId">;
15
+ /**
16
+ * Test seam (`KernelOverride`): called by {@link DevicePolicyService.send} right after the `policy`
17
+ * op's call returned, before its outcome is recorded (by `apply` or a network ask's decision);
18
+ * throwing stands for a crash between the call and the resolve transaction.
19
+ * The kernel never provides it.
20
+ */
21
+ export declare const DEVICE_POLICY_AFTER_CALL: unique symbol;
9
22
  /** What became of a policy change. */
10
23
  export type PolicyChangeOutcome = {
11
24
  applied: true;
@@ -15,55 +28,147 @@ export type PolicyChangeOutcome = {
15
28
  reason: "offline" | "refused" | "unsupported";
16
29
  message: string;
17
30
  };
31
+ /**
32
+ * An op whose intent is recorded ({@link DevicePolicyService.begin}): the call sends `args` to
33
+ * the device's machine.
34
+ */
35
+ export interface PolicyIntent {
36
+ orgId: string;
37
+ deviceId: string;
38
+ machineId: string;
39
+ opId: string;
40
+ args: DevicePolicyArgs;
41
+ }
42
+ /**
43
+ * What became of a sent op, as recorded by {@link DevicePolicyService.settleIn}: `applied` (with
44
+ * the policy the machine wrote, when it said), `refused` (the machine's message), `not_received`
45
+ * (the machine never got it and never will), `unknown` (no answer).
46
+ */
47
+ export type PolicyOpResult = {
48
+ outcome: "applied";
49
+ policy?: DeviceLocalPolicySummary;
50
+ } | {
51
+ outcome: "refused";
52
+ message: string;
53
+ } | {
54
+ outcome: "not_received";
55
+ } | {
56
+ outcome: "interrupted";
57
+ } | {
58
+ outcome: "unknown";
59
+ };
60
+ /** What a machine says of an op id ({@link DevicePolicyService.outcomeOf}). */
61
+ export type PolicyOpStatus = DevicePolicyStatus | {
62
+ outcome: "offline";
63
+ };
18
64
  /**
19
65
  * Changes a local device's policy on its machine (network ask-to-allow spec §5.2; sandbox dev
20
66
  * tiers spec §7.3: its hosts, its `tier`, and `isolation` back to `sandbox`): the kernel→device
21
- * `policy` op over the {@link LocalDeviceRelay}. The machine is the authority: it
22
- * refuses widening unless its owner allows remote grants, validates every host and answers with
23
- * the policy as written, which the kernel then keeps on the active machine's row (§5.4) and
24
- * audits as `device.policy_changed`.
67
+ * `policy` op over the {@link LocalDeviceRelay}. The machine is the authority: it refuses
68
+ * widening unless its owner allows remote grants, validates every host and answers with the
69
+ * policy as written, which the kernel then keeps on the active machine's row (§5.4).
25
70
  *
26
- * The op is sent outside any transaction (a decision records its outcome afterwards, in its own
27
- * transaction); only the summary write and the audit row are transactional. Callers decide who
28
- * may change a device's policy (the device owner, in a signed-in session); this service checks
29
- * only that the device is a live device of the org.
71
+ * Intent first (transactional outbox spec §7): {@link begin} writes the intent in a transaction
72
+ * before anything is sent (the entry on the machine row's `pending_policy_ops`, the audit
73
+ * `device.policy_requested` and the `device-op-resolve` job owed for when the op can no longer
74
+ * reach the machine); the call sends the op with its id (no transaction open); {@link settleIn}
75
+ * records the outcome: `device.policy_changed` (applied), `device.policy_failed` (refused, never
76
+ * received) or `device.policy_unknown` (no answer: the entry stays, `unknown`, for the machine's
77
+ * next hello or the resolver). A crash between the call and the resolve transaction leaves the
78
+ * intent and its owed job, whose resolver asks the machine what became of the op (never sends it
79
+ * again). Callers decide who may change a device's policy (the device owner, in a signed-in
80
+ * session); this service checks only that the device is a live device of the org.
30
81
  */
31
82
  export declare class DevicePolicyService {
32
83
  private readonly db;
33
84
  readonly txHost: TransactionHost;
34
85
  private readonly relay;
35
86
  private readonly machines;
87
+ private readonly outbox;
88
+ private readonly config;
36
89
  private readonly log;
37
- constructor(db: KernelDb, txHost: TransactionHost, relay: Pick<LocalDeviceRelay, "call" | "holderLive">, machines: Pick<MachinesService, "activeFor">, log: Pick<KernelLogger, "warn">);
90
+ private readonly afterCall;
91
+ constructor(db: KernelDb, txHost: TransactionHost, relay: Pick<LocalDeviceRelay, "call" | "holderLive">, machines: Pick<MachinesService, "activeFor">, outbox: Pick<OutboxService, "add">, config: Pick<KernelConfigService, "get">, log: Pick<KernelLogger, "warn">, afterCall?: (opId: string) => void);
38
92
  /**
39
- * Sends the `policy` op (no transaction may be open), stores the returned summary and audits
40
- * `device.policy_changed`. Never throws for an offline machine, a refusal or a program without
41
- * the `policyOp` feature (nothing is sent to it); a device that is not a live device of `orgId`
42
- * is `device.not_found`.
93
+ * Changes the policy: {@link begin}, the call, then the outcome recorded in its own transaction
94
+ * (no transaction may be open). Never throws for an offline machine, a refusal or a program
95
+ * without the `policyOpId` feature (nothing is sent to it); a device that is not a live device
96
+ * of `orgId` is `device.not_found`.
43
97
  */
44
98
  apply(input: {
45
99
  orgId: string;
46
100
  deviceId: string;
47
- change: Omit<DevicePolicyArgs, "source">;
101
+ change: PolicyChange;
48
102
  source: DevicePolicyArgs["source"];
49
103
  actorTokenId?: string;
50
104
  }): Promise<PolicyChangeOutcome>;
105
+ /**
106
+ * Checks the device and its machine, then records the intent in its own transaction: the entry
107
+ * (`sent`) on the machine's row, the audit `device.policy_requested` and the `device-op-resolve`
108
+ * job owed for when the op can no longer reach the machine. No transaction may be open (the
109
+ * call follows). Returns the intent, or why nothing will be sent (offline, unsupported).
110
+ */
111
+ begin(input: {
112
+ orgId: string;
113
+ deviceId: string;
114
+ change: PolicyChange;
115
+ source: DevicePolicyArgs["source"];
116
+ actorTokenId?: string;
117
+ }): Promise<{
118
+ intent: PolicyIntent;
119
+ } | {
120
+ outcome: PolicyChangeOutcome;
121
+ }>;
122
+ /**
123
+ * Sends the intent's `policy` op (no transaction may be open) and classifies the answer:
124
+ * applied; refused (the machine's local policy refused it: its message); anything else is
125
+ * `unknown`, left to the resolver's `policyStatus`: no answer, and an answer the kernel cannot
126
+ * trust to mean "not applied" (a result it cannot read, the machine's own error). `notFound`:
127
+ * the device was deleted meanwhile (recorded as unknown; the caller throws `device.not_found`).
128
+ */
129
+ send(intent: PolicyIntent): Promise<{
130
+ result: PolicyOpResult;
131
+ outcome: PolicyChangeOutcome;
132
+ notFound?: true;
133
+ }>;
134
+ /** The `policy` op's call and its classification ({@link send}). */
135
+ private call;
136
+ /**
137
+ * What the machine says became of the op `opId` (a read-only `policyStatus`), or `offline` when
138
+ * no replica holds its connection or it does not answer. Never sends the change.
139
+ */
140
+ outcomeOf(deviceId: string, opId: string): Promise<PolicyOpStatus>;
141
+ /**
142
+ * Records what became of the op `opId` of machine `machineId`, in the caller's transaction (the
143
+ * machine's row locked): `applied` → the policy the machine wrote (when given) kept on the row,
144
+ * the entry removed, `device.policy_changed` (`late` when the op was left `unknown`); `refused`,
145
+ * `not_received` or `interrupted` → the entry removed, `device.policy_failed` (`reason` the
146
+ * outcome); `unknown` → the entry kept, `unknown` until a day after its intent,
147
+ * `device.policy_unknown` (once; ignored once a hello reported the op). Returns the outcome for
148
+ * the caller, or null when the op has no live entry (resolved already, or expired: a no-op).
149
+ */
150
+ settleIn(ref: {
151
+ machineId: string;
152
+ opId: string;
153
+ }, result: PolicyOpResult): Promise<PolicyChangeOutcome | null>;
51
154
  /** The active machine's features and policy summary (null when no machine is connected). */
52
155
  summary(deviceId: string): Promise<{
53
156
  features: DeviceFeature[];
54
157
  policy: DeviceLocalPolicySummary | null;
55
158
  } | null>;
56
- /** Adds `op` to the machine's pending ops, dropping the expired ones. */
57
- private notePending;
58
- /** Removes the pending op `opId` from the machine's row. */
59
- private dropPending;
60
159
  /**
61
- * Keeps the policy the machine wrote on its row and audits the change (`confirmed`); with no
62
- * policy (the answer never came) only audits the attempt, unconfirmed. The detail's `isolation`
63
- * is the one asked for, or the one the machine now reports when it changed (a tier change turns
64
- * the sandbox back on).
160
+ * How long after its intent an op can no longer reach the machine: the call's deadline, the
161
+ * reconnect grace and the request list's margin (the relay's list TTL), plus 5 s. A machine
162
+ * that does not know the op id by then never will.
163
+ */
164
+ resolveDelayMs(): number;
165
+ /** {@link settleIn} in its own transaction (the {@link apply} path). */
166
+ private settle;
167
+ /**
168
+ * The intent transaction: locks the machine's row (still the device's active machine), appends
169
+ * the entry (dropping the expired ones), audits `device.policy_requested` and owes the
170
+ * `device-op-resolve` job. False when the machine was revoked meanwhile (nothing written).
65
171
  */
66
- private record;
67
- /** The outcome of a failed op: offline, or the machine's refusal; a deleted device is thrown. */
68
- private notApplied;
172
+ private recordIntent;
69
173
  }
174
+ export {};