@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
@@ -50,8 +50,9 @@ export declare class OutboxDeadService {
50
50
  * the fast path after the commit; the relay delivers it otherwise. When the same (queue, job id)
51
51
  * is owed by a pending row already, the dead row is deleted instead (`already_owed`). A pending
52
52
  * row added between the check and the update fails the update on the unique index: the retry
53
- * then runs once more and takes the `already_owed` branch. `outbox.not_found` (404) when no dead
54
- * row has the id.
53
+ * then runs once more and takes the `already_owed` branch; a second conflict on that run (the
54
+ * job owed, delivered and owed again meanwhile) is `conflict` (409), the dead row left as it was.
55
+ * `outbox.not_found` (404) when no dead row has the id.
55
56
  */
56
57
  retry(id: string, actor: WriteActor, detail?: Record<string, unknown>): Promise<{
57
58
  outcome: OutboxRetryOutcome;
@@ -61,6 +62,9 @@ export declare class OutboxDeadService {
61
62
  discard(id: string, actor: WriteActor, detail?: Record<string, unknown>): Promise<void>;
62
63
  /** The dead row `id`, locked for the transaction; `outbox.not_found` when it is not dead. */
63
64
  private lockDead;
64
- /** A platform-level audit event (no org: the outbox is the deployment's), naming the job. */
65
+ /**
66
+ * A platform-level audit event (no org: the outbox is the deployment's), naming the job. The
67
+ * caller's detail (`via`, `operation`) comes first, so it cannot override the row's fields.
68
+ */
65
69
  private audit;
66
70
  }
@@ -89,8 +89,9 @@ let OutboxDeadService = class OutboxDeadService {
89
89
  * the fast path after the commit; the relay delivers it otherwise. When the same (queue, job id)
90
90
  * is owed by a pending row already, the dead row is deleted instead (`already_owed`). A pending
91
91
  * row added between the check and the update fails the update on the unique index: the retry
92
- * then runs once more and takes the `already_owed` branch. `outbox.not_found` (404) when no dead
93
- * row has the id.
92
+ * then runs once more and takes the `already_owed` branch; a second conflict on that run (the
93
+ * job owed, delivered and owed again meanwhile) is `conflict` (409), the dead row left as it was.
94
+ * `outbox.not_found` (404) when no dead row has the id.
94
95
  */
95
96
  async retry(id, actor, detail = {}) {
96
97
  try {
@@ -99,7 +100,14 @@ let OutboxDeadService = class OutboxDeadService {
99
100
  catch (e) {
100
101
  if (!isPendingConflict(e))
101
102
  throw e;
102
- return this.retryOnce(id, actor, detail);
103
+ }
104
+ try {
105
+ return await this.retryOnce(id, actor, detail);
106
+ }
107
+ catch (e) {
108
+ if (!isPendingConflict(e))
109
+ throw e;
110
+ throw new ApiError("conflict", `The job of dead outbox row ${id} was owed again while it was retried; list the dead rows with GET /api/v1/admin/outbox/dead and retry or discard it again`);
103
111
  }
104
112
  }
105
113
  async retryOnce(id, actor, detail) {
@@ -125,7 +133,7 @@ let OutboxDeadService = class OutboxDeadService {
125
133
  outcome = "retried";
126
134
  afterCommit(() => this.fastPath([id]));
127
135
  }
128
- await this.audit("outbox.retried", id, row, actor, { outcome, ...detail });
136
+ await this.audit("outbox.retried", id, row, actor, { ...detail, outcome });
129
137
  return { outcome };
130
138
  }
131
139
  /** Deletes a dead row: its job is never delivered. `outbox.not_found` (404) when none. */
@@ -147,7 +155,10 @@ let OutboxDeadService = class OutboxDeadService {
147
155
  throw notFound(id);
148
156
  return row;
149
157
  }
150
- /** A platform-level audit event (no org: the outbox is the deployment's), naming the job. */
158
+ /**
159
+ * A platform-level audit event (no org: the outbox is the deployment's), naming the job. The
160
+ * caller's detail (`via`, `operation`) comes first, so it cannot override the row's fields.
161
+ */
151
162
  audit(action, id, row, actor, detail) {
152
163
  return audit(this.db.db, {
153
164
  actorAccountId: actor.accountId,
@@ -155,11 +166,11 @@ let OutboxDeadService = class OutboxDeadService {
155
166
  action,
156
167
  target: id,
157
168
  detail: {
169
+ ...detail,
158
170
  queue: row.queue,
159
171
  jobName: row.job_name,
160
172
  jobId: row.job_id,
161
173
  ...(row.org_id !== null && { orgId: row.org_id }),
162
- ...detail,
163
174
  },
164
175
  });
165
176
  }
@@ -107,11 +107,11 @@ export declare function extensionReason(e: Pick<AdminExtensionView, "status" | "
107
107
  /** The problem of a provider in the platform view, or null (`org_configured` is not one). */
108
108
  export declare function providerProblem(p: ProviderStatus): AttentionProviderProblem | null;
109
109
  /**
110
- * The admin overview (spec §4.1, §5.2): its numbers and the 待处理 lists, read live from the
111
- * sources the admin pages read (`ExtensionAdmin`, the catalog, the provider registry's cached
112
- * lists, the dead outbox rows, the run queue's lost-jobs canary). Each source is read on its own: a failing one is reported in
113
- * `unavailable`. Reads are not audited (decision D5) and not cached further (every source is PG
114
- * or already Redis-cached).
110
+ * The admin overview (spec §4.1, §5.2): its numbers and the 待处理 lists, read live from the sources
111
+ * the admin pages read (`ExtensionAdmin`, the catalog, the provider registry's cached lists, the
112
+ * dead outbox rows, the run queue's lost-jobs canary). Each source is read on its own: a failing
113
+ * one is reported in `unavailable`. Reads are not audited (decision D5) and not cached further
114
+ * (every source is PG or already Redis-cached).
115
115
  */
116
116
  export declare class AdminOverviewService {
117
117
  private readonly extensions;
@@ -136,7 +136,7 @@ export declare class AdminOverviewService {
136
136
  /**
137
137
  * The extensions needing a look, but not one pending approval whose plugin waits in the platform
138
138
  * inbox (a permission confirmation or an org upload: the inbox already lists and counts it). A
139
- * config-declared extension has no catalog item and stays. Without the catalog nothing is left out.
139
+ * config-declared extension has no catalog item and stays. Without the catalog none is left out.
140
140
  */
141
141
  private extensionRows;
142
142
  /** Every live catalog item, page by page (the catalog lists by name). */
@@ -53,11 +53,11 @@ export function providerProblem(p) {
53
53
  return p.models.some((m) => m.kind === "chat" && m.disabled !== true) ? null : "no_chat_model";
54
54
  }
55
55
  /**
56
- * The admin overview (spec §4.1, §5.2): its numbers and the 待处理 lists, read live from the
57
- * sources the admin pages read (`ExtensionAdmin`, the catalog, the provider registry's cached
58
- * lists, the dead outbox rows, the run queue's lost-jobs canary). Each source is read on its own: a failing one is reported in
59
- * `unavailable`. Reads are not audited (decision D5) and not cached further (every source is PG
60
- * or already Redis-cached).
56
+ * The admin overview (spec §4.1, §5.2): its numbers and the 待处理 lists, read live from the sources
57
+ * the admin pages read (`ExtensionAdmin`, the catalog, the provider registry's cached lists, the
58
+ * dead outbox rows, the run queue's lost-jobs canary). Each source is read on its own: a failing
59
+ * one is reported in `unavailable`. Reads are not audited (decision D5) and not cached further
60
+ * (every source is PG or already Redis-cached).
61
61
  */
62
62
  let AdminOverviewService = class AdminOverviewService {
63
63
  extensions;
@@ -164,13 +164,13 @@ let AdminOverviewService = class AdminOverviewService {
164
164
  /**
165
165
  * The extensions needing a look, but not one pending approval whose plugin waits in the platform
166
166
  * inbox (a permission confirmation or an org upload: the inbox already lists and counts it). A
167
- * config-declared extension has no catalog item and stays. Without the catalog nothing is left out.
167
+ * config-declared extension has no catalog item and stays. Without the catalog none is left out.
168
168
  */
169
169
  async extensionRows(items) {
170
170
  const [views, catalog] = await Promise.all([this.extensions.list(), items.catch(() => [])]);
171
171
  const inInbox = new Set(catalog.filter((i) => i.pending).map((i) => i.name));
172
172
  return views.flatMap((v) => {
173
- // every status of extensionNeedsAction has a reason, and so has enabled with an invalid config
173
+ // every status of extensionNeedsAction has a reason, as has enabled with an invalid config
174
174
  const reason = extensionReason(v);
175
175
  if (reason === null)
176
176
  return [];
@@ -54,6 +54,11 @@ export type WakeDecision = {
54
54
  * - the wake setting is on (the org's override, else `deploymentOn`);
55
55
  * - the root's queue is not paused and the root is not deleted (a paused root holds the wakes of
56
56
  * the whole tree, agent map spec §5.3);
57
+ * - for a `subagent_delivery` to the root: the root's tree is not stopped (`tree_stopped_at`, set by
58
+ * a person's stop of the root and cleared by the root's next run; agent map spec §6, changed in
59
+ * acceptance 2026-11-13): no sub-agent's finish or report, whenever its child finished, wakes a
60
+ * root a person stopped, the delivery waits for the next run. A sub-agent of a stopped tree is
61
+ * never woken here (`RunQueue.childWakeIn` refuses it first);
57
62
  * - the event's trigger account can still send to the root (`canSend`, which the caller binds to
58
63
  * the root);
59
64
  * - no other wake of this conversation started in the last {@link WAKE_WINDOW_MS} (else `later`).
@@ -38,6 +38,11 @@ const WAKE_EVENT_TYPES = ["background_task_finished", "subagent_delivery"];
38
38
  * - the wake setting is on (the org's override, else `deploymentOn`);
39
39
  * - the root's queue is not paused and the root is not deleted (a paused root holds the wakes of
40
40
  * the whole tree, agent map spec §5.3);
41
+ * - for a `subagent_delivery` to the root: the root's tree is not stopped (`tree_stopped_at`, set by
42
+ * a person's stop of the root and cleared by the root's next run; agent map spec §6, changed in
43
+ * acceptance 2026-11-13): no sub-agent's finish or report, whenever its child finished, wakes a
44
+ * root a person stopped, the delivery waits for the next run. A sub-agent of a stopped tree is
45
+ * never woken here (`RunQueue.childWakeIn` refuses it first);
41
46
  * - the event's trigger account can still send to the root (`canSend`, which the caller binds to
42
47
  * the root);
43
48
  * - no other wake of this conversation started in the last {@link WAKE_WINDOW_MS} (else `later`).
@@ -68,21 +73,27 @@ export async function wakeDecisionIn(t, conv, consumed, deploymentOn, canSend) {
68
73
  .orderBy("e.seq", "desc")
69
74
  .limit(CANDIDATES)
70
75
  .execute();
71
- const candidates = [...finishes, ...deliveries]
72
- .sort((a, b) => Number(b.seq) - Number(a.seq))
73
- .slice(0, CANDIDATES);
74
- if (candidates.length === 0)
76
+ if (finishes.length === 0 && deliveries.length === 0)
75
77
  return null;
76
78
  const org = (await readOrgSettings(t, conv.orgId)).wakeOnBackgroundTask;
77
79
  if (!effectiveWakeOnBackgroundTask(deploymentOn, org))
78
80
  return null;
81
+ // read under the root's row lock when `conv` is the root (`RunQueue.ensureRunIn` holds it), so a
82
+ // person's stop of the tree (which writes `tree_stopped_at` on that row) is seen by every wake
83
+ // decided after its commit
79
84
  const root = await t
80
85
  .selectFrom("conversations")
81
- .select(["queue_paused", "deleted_at"])
86
+ .select(["queue_paused", "deleted_at", "tree_stopped_at"])
82
87
  .where("id", "=", conv.rootId)
83
88
  .executeTakeFirst();
84
89
  if (!root || root.queue_paused || root.deleted_at !== null)
85
90
  return null;
91
+ const treeStopped = root.tree_stopped_at !== null;
92
+ const candidates = [...finishes, ...(treeStopped ? [] : deliveries)]
93
+ .sort((a, b) => Number(b.seq) - Number(a.seq))
94
+ .slice(0, CANDIDATES);
95
+ if (candidates.length === 0)
96
+ return null;
86
97
  let boundary;
87
98
  for (const c of candidates) {
88
99
  if (await canSend(c.account)) {
@@ -77,6 +77,11 @@ export interface OrgCatalogTemplate {
77
77
  * install wizard does not ask for it (an org admin sets it up when switching it on).
78
78
  */
79
79
  defaultOff: boolean;
80
+ /**
81
+ * The template is on in this org right now (a default-off one an admin switched on): the install
82
+ * asks for its input too, since the install is what creates its connector.
83
+ */
84
+ on: boolean;
80
85
  /**
81
86
  * The template's setup guide in the item's latest version (`GET …/catalog/:itemId/guides/
82
87
  * :guideId`), null without one (spec 2026-11-09-setup-guides-v2-design.md §9.3).
@@ -118,7 +123,7 @@ export interface OrgCatalogDetail {
118
123
  * A connector template as an org sees it (the install wizard and the upgrade preview's inputs):
119
124
  * presets and editable paths, no credentials.
120
125
  */
121
- export declare function orgTemplateView(id: string, file: ConnectorTemplateFile, guide?: OrgCatalogTemplate["guide"]): OrgCatalogTemplate;
126
+ export declare function orgTemplateView(id: string, file: ConnectorTemplateFile, guide?: OrgCatalogTemplate["guide"], on?: boolean): OrgCatalogTemplate;
122
127
  /** An item's icon (the latest version's plugin icon). */
123
128
  export interface CatalogIcon {
124
129
  data: Uint8Array;
@@ -16,7 +16,7 @@ import { satisfiesRange } from "./versions.js";
16
16
  * A connector template as an org sees it (the install wizard and the upgrade preview's inputs):
17
17
  * presets and editable paths, no credentials.
18
18
  */
19
- export function orgTemplateView(id, file, guide = null) {
19
+ export function orgTemplateView(id, file, guide = null, on = true) {
20
20
  const t = file.template;
21
21
  return {
22
22
  id,
@@ -30,6 +30,7 @@ export function orgTemplateView(id, file, guide = null) {
30
30
  ownership: t.ownership ??
31
31
  (typeof t.auth.ownership === "string" ? t.auth.ownership : null),
32
32
  defaultOff: file.defaultEnabled === false,
33
+ on,
33
34
  guide,
34
35
  };
35
36
  }
@@ -188,7 +188,7 @@ export declare function headsToResolve(entries: readonly MarketplaceEntry[], imp
188
188
  /** The host versions (an entry's `minKernel` is judged against them). */
189
189
  export declare const HOST_VERSIONS: {
190
190
  readonly kernel: string;
191
- readonly sdk: "0.1.0-rc.19";
191
+ readonly sdk: "0.1.0-rc.20";
192
192
  };
193
193
  /** Whether this deployment satisfies the entry's `minKernel`. */
194
194
  export declare const entryCompatible: (e: Pick<MarketplaceEntry, "minKernel">) => boolean;
@@ -273,6 +273,9 @@ let OrgCatalogService = class OrgCatalogService {
273
273
  throw itemNotFound();
274
274
  const m = v.manifest;
275
275
  const templates = await readCatalogTemplates(this.storage, v);
276
+ // Which templates are on in this org now: the install asks for the input of those, a
277
+ // default-off one an admin switched on included
278
+ const on = await connectorTemplatesOn(this.db, orgId, itemId, templates.map((t) => t.id));
276
279
  // where each template's page finds its setup guide (spec 2026-11-09-setup-guides-v2 §9.3)
277
280
  const guideOf = (templateId) => {
278
281
  const g = templateGuideOf(m, templateId);
@@ -290,7 +293,7 @@ let OrgCatalogService = class OrgCatalogService {
290
293
  item,
291
294
  hasIcon: m.icon !== undefined,
292
295
  extension: m.extension ? m.name : null,
293
- templates: templates.map((t) => orgTemplateView(t.id, t.file, guideOf(t.id))),
296
+ templates: templates.map((t) => orgTemplateView(t.id, t.file, guideOf(t.id), on.has(t.id))),
294
297
  dependencies: [
295
298
  ...(m.dependencies ?? []).map((d) => ({ name: d.name, range: d.version })),
296
299
  ...implied.map((d) => ({ name: d.name, range: d.range, implied: true })),
@@ -298,7 +301,7 @@ let OrgCatalogService = class OrgCatalogService {
298
301
  skills: (m.skills ?? []).map((s) => packagePath(s.path)),
299
302
  agents: (m.agents ?? []).map((a) => packagePath(a.path)),
300
303
  missingTemplates: item.installed !== null && item.state === "needs_config"
301
- ? ((await this.pluginInstaller.missingConnectors(orgId, itemId))?.templates ?? []).map((t) => orgTemplateView(t.id, t.file, guideOf(t.id)))
304
+ ? ((await this.pluginInstaller.missingConnectors(orgId, itemId))?.templates ?? []).map((t) => orgTemplateView(t.id, t.file, guideOf(t.id), true))
302
305
  : [],
303
306
  };
304
307
  }
@@ -540,6 +540,11 @@ export declare class PluginInstaller {
540
540
  file: ConnectorTemplateFile;
541
541
  }[];
542
542
  } | undefined>;
543
+ /**
544
+ * The org's live connector made from one of the plugin's connector templates, if any: an
545
+ * uninstall keeps a connector its agents use, and the plugin's next switch-on reuses it.
546
+ */
547
+ private liveTemplateConnector;
543
548
  /**
544
549
  * Creates the connectors an installed plugin is missing (ruling P2-10, `POST …/catalog/:itemId/
545
550
  * connectors`): `input.connectors` names some of {@link missingConnectors}' templates with the
@@ -484,6 +484,10 @@ let PluginInstaller = class PluginInstaller {
484
484
  // The kernel installs only what needs no org input (the scope sync's `needs_config` rule)
485
485
  if (actor === null)
486
486
  throw systemNeedsInput(manifest.name);
487
+ // An earlier switch-off kept this template's connector: the install takes it back, so its
488
+ // input is not asked for here (the connector keeps its endpoint and credentials)
489
+ if ((await this.liveTemplateConnector(orgId, itemId, ct.id)) !== undefined)
490
+ continue;
487
491
  connectors.set(ct.id, this.planConnector(actor, { itemId, version, name: manifest.name }, ct, input, at, approvedBy));
488
492
  }
489
493
  }
@@ -743,9 +747,24 @@ let PluginInstaller = class PluginInstaller {
743
747
  }
744
748
  step = "connectors";
745
749
  for (const ct of plan.templates) {
750
+ // An earlier switch-off kept this template's connector (its agents use it): the plugin
751
+ // takes it back instead of creating a second one, whose default slug the first holds
752
+ const existing = await this.liveTemplateConnector(orgId, itemId, ct.id);
753
+ if (existing !== undefined) {
754
+ await record({
755
+ kind: "connector",
756
+ ref: existing,
757
+ created: false,
758
+ skillVersion: null,
759
+ templateId: ct.id,
760
+ });
761
+ continue;
762
+ }
746
763
  const ci = plan.connectors.get(ct.id);
764
+ // No input was needed (a live connector was taken back above, or the template was skipped
765
+ // at plan time): nothing to create
747
766
  if (!ci)
748
- throw new ApiError("internal", `Connector template ${ct.id} was not planned`);
767
+ continue;
749
768
  if (actor === null)
750
769
  throw systemNeedsInput(manifest.name);
751
770
  const r = await this.connectors.create(orgId, actor, ci, opts.actorTokenId);
@@ -1657,6 +1676,23 @@ let PluginInstaller = class PluginInstaller {
1657
1676
  templates: all.filter((t) => on.has(t.id) && !made.has(t.id)),
1658
1677
  };
1659
1678
  }
1679
+ /**
1680
+ * The org's live connector made from one of the plugin's connector templates, if any: an
1681
+ * uninstall keeps a connector its agents use, and the plugin's next switch-on reuses it.
1682
+ */
1683
+ async liveTemplateConnector(orgId, itemId, templateId) {
1684
+ const row = await this.db
1685
+ .selectFrom("connectors as c")
1686
+ .innerJoin("platform_connector_templates as p", "p.id", "c.platform_template_id")
1687
+ .select("c.id")
1688
+ .where("c.org_id", "=", orgId)
1689
+ .where("c.deleted_at", "is", null)
1690
+ .where("p.catalog_item_id", "=", itemId)
1691
+ .where("p.template_id", "=", templateId)
1692
+ .orderBy("c.created_at")
1693
+ .executeTakeFirst();
1694
+ return row?.id;
1695
+ }
1660
1696
  /**
1661
1697
  * Creates the connectors an installed plugin is missing (ruling P2-10, `POST …/catalog/:itemId/
1662
1698
  * connectors`): `input.connectors` names some of {@link missingConnectors}' templates with the
@@ -9,14 +9,15 @@ import { ChannelOutbound } from "./outbound.js";
9
9
  * Channels on every role: the org's channel connections, inbound (dedupe and enqueue; the inbound
10
10
  * jobs), the outbound delivery, the producer of the channel queue `cap-kernel-channels`, the
11
11
  * anonymous guests. The roles' parts are the role modules': the queue's consumer
12
- * ({@link ChannelWorkersModule}) and the gateway check job are the worker role's, the long-connection
13
- * supervisor and the webhook inbound the gateway role's (`GatewayModule`).
12
+ * ({@link ChannelWorkersModule}) and the gateway check job are the worker role's, the
13
+ * long-connection supervisor and the webhook inbound the gateway role's (`GatewayModule`).
14
14
  */
15
15
  export declare class ChannelsModule {
16
16
  }
17
17
  /**
18
18
  * The consumer of `cap-kernel-channels`: deliveries (`DELIVER_JOB`, owed through the outbox or
19
- * hinted) and inbound jobs (`INBOUND_JOB`), both handled by {@link ChannelOutbound}.
19
+ * hinted), a connection's catch-up (`RESUME_JOB`, owed) and inbound jobs (`INBOUND_JOB`), all
20
+ * handled by {@link ChannelOutbound}.
20
21
  */
21
22
  export declare class ChannelsProcessor extends WorkerHost<Worker<ChannelJobData>> {
22
23
  private readonly outbound;
@@ -32,11 +33,11 @@ export declare class ChannelsProcessor extends WorkerHost<Worker<ChannelJobData>
32
33
  */
33
34
  export declare function channelsProcessorOptions(config: Pick<KernelConfig, "runtime" | "channels">): ProcessorWorkerOptions;
34
35
  /**
35
- * The consumer of the channel queue (concurrency `channels.deliveryConcurrency`, the run queue's job
36
- * lock and stalled detection, the delivery backoff). Imported only by the worker role
37
- * (`WorkerModule`), so other roles get no processor and no BullMQ worker. It never starts by itself:
38
- * the worker role (`WorkerRole`) starts it after the extension load; `ChannelOutbound.close` closes it
39
- * on stop.
36
+ * The consumer of the channel queue (concurrency `channels.deliveryConcurrency`, the run queue's
37
+ * job lock and stalled detection, the delivery backoff). Imported only by the worker role
38
+ * (`WorkerModule`), so other roles get no processor and no BullMQ worker. It never starts by
39
+ * itself: the worker role (`WorkerRole`) starts it after the extension load;
40
+ * `ChannelOutbound.close` closes it on stop.
40
41
  */
41
42
  export declare class ChannelWorkersModule {
42
43
  static forRoot(config: KernelConfig): DynamicModule;
@@ -26,7 +26,7 @@ import { SchedulesModule } from "../schedules/schedules.module.js";
26
26
  import { ToolsModule } from "../tools/tools.module.js";
27
27
  import { ChannelConnections } from "./connections.service.js";
28
28
  import { ChannelInbound } from "./inbound.js";
29
- import { CHANNEL_QUEUE_NAME, ChannelJobs, DELIVER_JOB, INBOUND_JOB, } from "./jobs.js";
29
+ import { CHANNEL_QUEUE_NAME, ChannelJobs, DELIVER_JOB, INBOUND_JOB, RESUME_JOB, } from "./jobs.js";
30
30
  import { ChannelOutbound, deliverBackoff } from "./outbound.js";
31
31
  import { AnonymousGuestLock, AnonymousGuests } from "./threads.js";
32
32
  /** The channel queue's registration (its producer here; the admin health reads its counts). */
@@ -35,8 +35,8 @@ const CHANNEL_QUEUE_MODULE = QueueModule.registerQueue(CHANNEL_QUEUE_NAME);
35
35
  * Channels on every role: the org's channel connections, inbound (dedupe and enqueue; the inbound
36
36
  * jobs), the outbound delivery, the producer of the channel queue `cap-kernel-channels`, the
37
37
  * anonymous guests. The roles' parts are the role modules': the queue's consumer
38
- * ({@link ChannelWorkersModule}) and the gateway check job are the worker role's, the long-connection
39
- * supervisor and the webhook inbound the gateway role's (`GatewayModule`).
38
+ * ({@link ChannelWorkersModule}) and the gateway check job are the worker role's, the
39
+ * long-connection supervisor and the webhook inbound the gateway role's (`GatewayModule`).
40
40
  */
41
41
  let ChannelsModule = class ChannelsModule {
42
42
  };
@@ -77,7 +77,8 @@ ChannelsModule = __decorate([
77
77
  export { ChannelsModule };
78
78
  /**
79
79
  * The consumer of `cap-kernel-channels`: deliveries (`DELIVER_JOB`, owed through the outbox or
80
- * hinted) and inbound jobs (`INBOUND_JOB`), both handled by {@link ChannelOutbound}.
80
+ * hinted), a connection's catch-up (`RESUME_JOB`, owed) and inbound jobs (`INBOUND_JOB`), all
81
+ * handled by {@link ChannelOutbound}.
81
82
  */
82
83
  let ChannelsProcessor = class ChannelsProcessor extends WorkerHost {
83
84
  outbound;
@@ -86,7 +87,7 @@ let ChannelsProcessor = class ChannelsProcessor extends WorkerHost {
86
87
  this.outbound = outbound;
87
88
  }
88
89
  process(job, token) {
89
- if (job.name !== DELIVER_JOB && job.name !== INBOUND_JOB) {
90
+ if (job.name !== DELIVER_JOB && job.name !== INBOUND_JOB && job.name !== RESUME_JOB) {
90
91
  return Promise.reject(new UnrecoverableError(`unknown channel job "${job.name}"`));
91
92
  }
92
93
  return runJob(CHANNEL_QUEUE_NAME, job, () => this.outbound.process(job, token));
@@ -124,11 +125,11 @@ export function channelsProcessorOptions(config) {
124
125
  };
125
126
  }
126
127
  /**
127
- * The consumer of the channel queue (concurrency `channels.deliveryConcurrency`, the run queue's job
128
- * lock and stalled detection, the delivery backoff). Imported only by the worker role
129
- * (`WorkerModule`), so other roles get no processor and no BullMQ worker. It never starts by itself:
130
- * the worker role (`WorkerRole`) starts it after the extension load; `ChannelOutbound.close` closes it
131
- * on stop.
128
+ * The consumer of the channel queue (concurrency `channels.deliveryConcurrency`, the run queue's
129
+ * job lock and stalled detection, the delivery backoff). Imported only by the worker role
130
+ * (`WorkerModule`), so other roles get no processor and no BullMQ worker. It never starts by
131
+ * itself: the worker role (`WorkerRole`) starts it after the extension load;
132
+ * `ChannelOutbound.close` closes it on stop.
132
133
  */
133
134
  let ChannelWorkersModule = ChannelWorkersModule_1 = class ChannelWorkersModule {
134
135
  static forRoot(config) {
@@ -123,7 +123,7 @@ export declare class ChannelConnections implements OnApplicationShutdown {
123
123
  private readonly registry;
124
124
  private readonly bus;
125
125
  private readonly redis;
126
- /** A connection that can deliver again catches up its conversations (`resumeConnection`). */
126
+ /** A connection that can deliver again owes its conversations' catch-up (`oweResume`). */
127
127
  private readonly jobs;
128
128
  /** `channels.webhookBaseUrl`: without it webhook mode is unavailable. */
129
129
  private readonly config;
@@ -138,8 +138,8 @@ export declare class ChannelConnections implements OnApplicationShutdown {
138
138
  constructor(db: KernelDb,
139
139
  /** The org row lock (orgs/org-lock.ts): joins the transaction of the write. */
140
140
  orgLock: Pick<OrgLock, "lockAndAuthorize">, keyring: Keyring, registry: Pick<ExtensionRegistry, "list">, bus: ExtensionContext["bus"], redis: Pick<Redis, "del">,
141
- /** A connection that can deliver again catches up its conversations (`resumeConnection`). */
142
- jobs: Pick<ChannelJobs, "resumeConnection">,
141
+ /** A connection that can deliver again owes its conversations' catch-up (`oweResume`). */
142
+ jobs: Pick<ChannelJobs, "oweResume">,
143
143
  /** `channels.webhookBaseUrl`: without it webhook mode is unavailable. */
144
144
  config: Pick<KernelConfigService, "get">, events: Pick<KernelEvents, "on">,
145
145
  /** Secret values handled here (written or decrypted) are redacted from the logs (`addOrgSecrets`). */
@@ -154,7 +154,7 @@ let ChannelConnections = class ChannelConnections {
154
154
  constructor(db,
155
155
  /** The org row lock (orgs/org-lock.ts): joins the transaction of the write. */
156
156
  orgLock, keyring, registry, bus, redis,
157
- /** A connection that can deliver again catches up its conversations (`resumeConnection`). */
157
+ /** A connection that can deliver again owes its conversations' catch-up (`oweResume`). */
158
158
  jobs,
159
159
  /** `channels.webhookBaseUrl`: without it webhook mode is unavailable. */
160
160
  config, events,
@@ -546,11 +546,6 @@ let ChannelConnections = class ChannelConnections {
546
546
  }
547
547
  // A mode switch also reconnects: the holder drops the long connection when going to webhook (F9)
548
548
  await this.changed(id, rotated !== undefined || patch.status !== undefined || modeChanged);
549
- if ((rotated || patch.status === "active") && row.status === "active") {
550
- await this.jobs
551
- .resumeConnection(id)
552
- .catch((err) => this.log.warn("cannot resume channel deliveries", { err }));
553
- }
554
549
  return { channel: await this.viewOf(row), verifySkipped };
555
550
  }
556
551
  /** The rows of {@link update}, in their own transaction (the caller publishes after the commit). */
@@ -592,6 +587,11 @@ let ChannelConnections = class ChannelConnections {
592
587
  .executeTakeFirst();
593
588
  if (!updated)
594
589
  throw notFound();
590
+ // can deliver again (re-enabled, or new credentials while active): the conversations that fell
591
+ // behind meanwhile are caught up by a job owed here (transactional outbox spec §3.1)
592
+ if ((rotated || patch.status === "active") && updated.status === "active") {
593
+ await this.jobs.oweResume(id, orgId, updated.updated_at);
594
+ }
595
595
  await audit(this.db.db, {
596
596
  actorAccountId,
597
597
  ...(actorTokenId !== undefined && { actorTokenId }),
@@ -4,15 +4,22 @@ import type { OutboxService } from "../infra/outbox/outbox.service.js";
4
4
  * with no BullMQ and no Nest class, so the event log can owe it without importing the channels
5
5
  * domain. `jobs.ts` (the producer) re-exports these names.
6
6
  */
7
- /** 渠道队列(出站投递与入站任务,不含 `:`),只在 worker 角色上消费。 */
7
+ /** The channel queue (outbound deliveries and inbound jobs; no `:`), consumed only by workers. */
8
8
  export declare const CHANNEL_QUEUE_NAME = "cap-kernel-channels";
9
- /** 投递任务名。 */
9
+ /** The delivery job's name. */
10
10
  export declare const DELIVER_JOB = "deliver";
11
- /** 投递任务 id(不含 `:`):同一会话、同一日志位置只排一个任务。 */
11
+ /** The delivery job id (no `:`): one job per conversation and log position. */
12
12
  export declare const deliverJobId: (conversationId: string, seq: number) => string;
13
13
  export interface DeliverJobData {
14
14
  conversationId: string;
15
15
  }
16
+ /** The catch-up job of a connection that can deliver again (re-enabled, credentials rotated). */
17
+ export declare const RESUME_JOB = "channel-resume";
18
+ /** Its job id: one per connection and update (`updated_at` in ms; no `:`). */
19
+ export declare const resumeJobId: (connectionId: string, at: Date) => string;
20
+ export interface ResumeJobData {
21
+ connectionId: string;
22
+ }
16
23
  /**
17
24
  * Owes the conversation's delivery job for the log position `seq` in the transaction in context:
18
25
  * the caller knows the conversation has a channel target. Shares the job id of
@@ -3,12 +3,16 @@
3
3
  * with no BullMQ and no Nest class, so the event log can owe it without importing the channels
4
4
  * domain. `jobs.ts` (the producer) re-exports these names.
5
5
  */
6
- /** 渠道队列(出站投递与入站任务,不含 `:`),只在 worker 角色上消费。 */
6
+ /** The channel queue (outbound deliveries and inbound jobs; no `:`), consumed only by workers. */
7
7
  export const CHANNEL_QUEUE_NAME = "cap-kernel-channels";
8
- /** 投递任务名。 */
8
+ /** The delivery job's name. */
9
9
  export const DELIVER_JOB = "deliver";
10
- /** 投递任务 id(不含 `:`):同一会话、同一日志位置只排一个任务。 */
10
+ /** The delivery job id (no `:`): one job per conversation and log position. */
11
11
  export const deliverJobId = (conversationId, seq) => `deliver-${conversationId}-${seq}`;
12
+ /** The catch-up job of a connection that can deliver again (re-enabled, credentials rotated). */
13
+ export const RESUME_JOB = "channel-resume";
14
+ /** Its job id: one per connection and update (`updated_at` in ms; no `:`). */
15
+ export const resumeJobId = (connectionId, at) => `channel-resume-${connectionId}-${at.getTime()}`;
12
16
  /**
13
17
  * Owes the conversation's delivery job for the log position `seq` in the transaction in context:
14
18
  * the caller knows the conversation has a channel target. Shares the job id of
@@ -5,8 +5,8 @@ import { KernelDb } from "../infra/database/kernel-db.js";
5
5
  import { TransactionHost } from "../infra/database/transaction-host.js";
6
6
  import { KernelEvents } from "../infra/events/kernel-events.js";
7
7
  import { OutboxService } from "../infra/outbox/outbox.service.js";
8
- import { type DeliverJobData } from "./deliver-job.js";
9
- export { CHANNEL_QUEUE_NAME, DELIVER_JOB, type DeliverJobData, deliverJobId, oweDelivery, } from "./deliver-job.js";
8
+ import { type DeliverJobData, type ResumeJobData } from "./deliver-job.js";
9
+ export { CHANNEL_QUEUE_NAME, DELIVER_JOB, type DeliverJobData, deliverJobId, oweDelivery, RESUME_JOB, type ResumeJobData, resumeJobId, } from "./deliver-job.js";
10
10
  /**
11
11
  * 结束后复核排的任务 id:每次唯一(带随机后缀,不含 `:`)。不能复用 `deliver-<会话>-<seq>`——同 id 的任务
12
12
  * 可能正因锁被占而即将直接结束,BullMQ 会把新任务去重进它,于是没人投递。
@@ -24,7 +24,7 @@ export interface InboundJobData {
24
24
  connectionId: string;
25
25
  event: InboundEvent;
26
26
  }
27
- export type ChannelJobData = DeliverJobData | InboundJobData;
27
+ export type ChannelJobData = DeliverJobData | InboundJobData | ResumeJobData;
28
28
  /** 入站任务名。 */
29
29
  export declare const INBOUND_JOB = "inbound";
30
30
  /**
@@ -86,7 +86,14 @@ export declare class ChannelJobs implements OnApplicationShutdown {
86
86
  */
87
87
  enqueueInbound(connectionId: string, event: InboundEvent, jobId: string): Promise<void>;
88
88
  /**
89
- * 连接重新可投递(轮换了凭据 / 重新启用):给该连接投递进度落后于会话日志的会话各排一个出站任务,
89
+ * Owes the catch-up of a connection that can deliver again in the caller's transaction (the
90
+ * update that re-enabled it or rotated its credentials while active): the deliveries that ran
91
+ * while it could not deliver have ended, and nothing else finds the conversations lagging the log.
92
+ * `at` is the connection row's new `updated_at`.
93
+ */
94
+ oweResume(connectionId: string, orgId: string, at: Date): Promise<boolean>;
95
+ /**
96
+ * The {@link RESUME_JOB} job: 连接重新可投递(轮换了凭据 / 重新启用):给该连接投递进度落后于会话日志的会话各排一个出站任务,
90
97
  * 补投停用或凭据失效期间落下的内容(不必等下一条消息)。
91
98
  */
92
99
  resumeConnection(connectionId: string): Promise<void>;
@@ -17,8 +17,8 @@ import { KernelEvents } from "../infra/events/kernel-events.js";
17
17
  import { OutboxService } from "../infra/outbox/outbox.service.js";
18
18
  import { OUTBOX_JOBS } from "../infra/outbox/registry.js";
19
19
  import { KERNEL_LOGGER } from "../infra/tokens.js";
20
- import { CHANNEL_QUEUE_NAME, DELIVER_JOB, deliverJobId, oweDelivery, } from "./deliver-job.js";
21
- export { CHANNEL_QUEUE_NAME, DELIVER_JOB, deliverJobId, oweDelivery, } from "./deliver-job.js";
20
+ import { CHANNEL_QUEUE_NAME, DELIVER_JOB, deliverJobId, oweDelivery, RESUME_JOB, resumeJobId, } from "./deliver-job.js";
21
+ export { CHANNEL_QUEUE_NAME, DELIVER_JOB, deliverJobId, oweDelivery, RESUME_JOB, resumeJobId, } from "./deliver-job.js";
22
22
  /**
23
23
  * 结束后复核排的任务 id:每次唯一(带随机后缀,不含 `:`)。不能复用 `deliver-<会话>-<seq>`——同 id 的任务
24
24
  * 可能正因锁被占而即将直接结束,BullMQ 会把新任务去重进它,于是没人投递。
@@ -160,7 +160,22 @@ let ChannelJobs = class ChannelJobs {
160
160
  });
161
161
  }
162
162
  /**
163
- * 连接重新可投递(轮换了凭据 / 重新启用):给该连接投递进度落后于会话日志的会话各排一个出站任务,
163
+ * Owes the catch-up of a connection that can deliver again in the caller's transaction (the
164
+ * update that re-enabled it or rotated its credentials while active): the deliveries that ran
165
+ * while it could not deliver have ended, and nothing else finds the conversations lagging the log.
166
+ * `at` is the connection row's new `updated_at`.
167
+ */
168
+ oweResume(connectionId, orgId, at) {
169
+ return this.outbox.add({
170
+ queue: CHANNEL_QUEUE_NAME,
171
+ name: RESUME_JOB,
172
+ jobId: resumeJobId(connectionId, at),
173
+ data: { connectionId },
174
+ orgId,
175
+ });
176
+ }
177
+ /**
178
+ * The {@link RESUME_JOB} job: 连接重新可投递(轮换了凭据 / 重新启用):给该连接投递进度落后于会话日志的会话各排一个出站任务,
164
179
  * 补投停用或凭据失效期间落下的内容(不必等下一条消息)。
165
180
  */
166
181
  async resumeConnection(connectionId) {