@sigrun/sigrun 0.0.0-stage → 0.1.1

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 (130) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +89 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. package/webui/dist/index.html +37 -0
@@ -0,0 +1,865 @@
1
+ /**
2
+ * Harness bootstrap: open one durable Session over SQLite, ready for use.
3
+ *
4
+ * `openHarness` composes the pieces every later task needs — file-backed
5
+ * storage at `config.storagePath`, an empty registry holding the built-in
6
+ * task definitions, and the merged provider catalog (`collectProviders`) —
7
+ * and returns them together. `Harness.root(context)` (called by the host,
8
+ * not here) creates or resumes the reserved root conversation.
9
+ */
10
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
11
+ import { join } from "node:path";
12
+ import { Harness, } from "@earendil-works/pi-durable";
13
+ import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
14
+ import { wireActivity } from "./activity.js";
15
+ import { createScheduleTask } from "../jobs/task.js";
16
+ import { createJobsService, jobWireOf } from "../jobs/index.js";
17
+ import { reconcileJobs } from "../jobs/reconcile.js";
18
+ import { registerJobObservers } from "../jobs/observe.js";
19
+ import { JobDoc, JobRunDoc, listRevisions } from "../jobs/docs.js";
20
+ import { EventBus } from "./event-bus.js";
21
+ import { loadExtensions, } from "./loader.js";
22
+ import { createAutoNamer } from "./naming.js";
23
+ import { AuthStore } from "./auth.js";
24
+ import { openIdentityStores, ensureSetupCode, } from "./identity/index.js";
25
+ import { MEMORY_SECTION_KEY, noopMemoryExtension } from "./memory-default.js";
26
+ import { createSeamHost, } from "./seam-host.js";
27
+ import { contextFilesExtension } from "./sections/context-files.js";
28
+ import { modelGuidanceExtension } from "./sections/model-guidance.js";
29
+ import { createSkillsExtension } from "../skills/index.js";
30
+ import { SLASH_COMMANDS } from "../server/commands.js";
31
+ import { packagePromptRootsOf } from "../commands/templates.js";
32
+ import { codingToolsExtension, searchToolsExtension } from "./tools.js";
33
+ import { collectProviders, createProviderMergeSink, } from "./providers/index.js";
34
+ import { EventHub } from "../server/events/hub.js";
35
+ import { providerAuthStatus } from "../server/helpers.js";
36
+ import { bootRefresh, DEFAULT_REFRESH_TIMEOUT_MS, refreshProvider, } from "./providers/refresh.js";
37
+ import { startProviderRefresh } from "./providers/scheduler.js";
38
+ import { fileModelsStore } from "./providers/models-store.js";
39
+ import { packagesDirOf } from "./packages.js";
40
+ import { resolveBuiltins, } from "./builtins.js";
41
+ import { createRegistry } from "./registry.js";
42
+ import { defaultSettingsDir, SettingsStore } from "./settings.js";
43
+ import { ensureStoreRoots, resolveStoreRoots, sharedExtensionStores, } from "./stores.js";
44
+ import { createWorkspaceService, defaultWorkspacePrefs, toHarnessEnv, } from "./workspace.js";
45
+ /** The idle sweep cadence (spec §2: ONE host-owned 1s interval). */
46
+ const IDLE_SWEEP_MS = 1_000;
47
+ /**
48
+ * The prompt-section keys the core surfaces own live in
49
+ * `./reserved-sections.ts` (spec §6.5 R11): the loader reads them for the
50
+ * load-time quarantine — an extension declaring one fails its LOAD,
51
+ * never this boot — and this module re-exports the constant for existing
52
+ * importers.
53
+ */
54
+ export { RESERVED_CORE_SECTION_KEYS } from "./reserved-sections.js";
55
+ /**
56
+ * Reserved key → the one core extension that may own it. The loader's
57
+ * quarantine already rejects any loaded extension declaring a reserved
58
+ * key, so the post-install walk below (which reads THIS map) is a
59
+ * CORE-ONLY assertion — it can fire only on a core bug (a core extension
60
+ * replaced by or mis-registered under another name).
61
+ */
62
+ export const RESERVED_CORE_SECTION_OWNERS = {
63
+ identity: "sigrun.context-files",
64
+ persona: "sigrun.context-files",
65
+ "workspace-instructions": "sigrun.context-files",
66
+ skills: "sigrun.skills",
67
+ "tool-guidance": "sigrun.model-guidance",
68
+ "execution-guidance": "sigrun.model-guidance",
69
+ };
70
+ /**
71
+ * Open (or reopen) the durable harness at `config.storagePath`.
72
+ *
73
+ * Storage is Node's built-in `node:sqlite` through pi-durable's adapter —
74
+ * one process owns the file. The provider catalog merges extension
75
+ * contributions plus the env-driven degenerate OpenAI-compat provider into
76
+ * one `Models` store *before* `Harness.open()`, per the spec §3 boot order.
77
+ * Generation against a model no contributor serves fails loud upstream
78
+ * (`no_model`); there is never a silent fallback. Task scheduling is enabled
79
+ * (`resume()` is idempotent and throws only after close). Extension packages
80
+ * (spec §4.1 deploy-time list) load and install BEFORE `Harness.open` per
81
+ * the spec §3 boot order; a failing extension is quarantined (logged +
82
+ * reported in `extensionOutcomes`), never an abort. When no loaded extension
83
+ * provides a `long-term-memory` section, the no-op memory default installs
84
+ * so a bare core still renders the stable placeholder surface. §3 health
85
+ * producers are retained at boot — contributor producers on
86
+ * `providerHealth`, the memory extension's producer on `memoryHealth` — for
87
+ * the §5 meta `health` aggregation (Task 9e), a pure synchronous derivation
88
+ * over this state.
89
+ */
90
+ export async function openHarness(config, options = {}) {
91
+ // The boot env, read once through the one seam (tests inject a fake;
92
+ // the deploy passes nothing and gets `process.env`). Two consumers:
93
+ // store-root resolution immediately below, and the provider
94
+ // collector further down (`providerEnv`).
95
+ const env = options.env ?? process.env;
96
+ // Store-root lifecycle (spec §6): every durable root except cache
97
+ // exists before anything touches disk — storage, the registry's
98
+ // first install, extension packages — so a fresh deploy (or a PVC
99
+ // mount missing a subpath) fails HERE, loudly, naming the root it
100
+ // could not create, rather than deep inside sqlite or npm. The cache
101
+ // root is NOT ensured at boot (Review Focus 5: a wiped `emptyDir`
102
+ // must never break boot; cache consumers repair per call through
103
+ // `ensureCacheDir`). The ONE resolved roots object threads on from
104
+ // here: the extension-stores handle (the seam host's `stores`) is
105
+ // built from it below — never re-resolved from env.
106
+ //
107
+ // Task 6: config owns the data/cache/state resolution — it folded the
108
+ // `SIGRUN_*_DIR` envs over the file's `server.*Dir` over the resolver
109
+ // chain ONCE, and `SigrunConfig` carries the result — so boot takes
110
+ // those three from the config (resolution-once; a hand-built config
111
+ // stays authoritative over a stale injected `env`). Only the CONFIG
112
+ // root still resolves here from env: `SIGRUN_CONFIG_DIR` keeps its
113
+ // own meaning (the settings file's own location) and is deliberately
114
+ // not a `SigrunConfig` field. The stores layer stays env-param pure —
115
+ // this is the one place the two worlds meet.
116
+ const roots = {
117
+ ...resolveStoreRoots(env),
118
+ dataDir: config.dataDir,
119
+ cacheDir: config.cacheDir,
120
+ stateDir: config.stateDir,
121
+ };
122
+ await ensureStoreRoots(roots);
123
+ // The identity stores (W1 Task 2) ride the same resolved roots: no
124
+ // I/O here (each file materializes on its first write), so this is
125
+ // pure construction over paths boot already owns.
126
+ const identity = options.identity ?? openIdentityStores(roots);
127
+ // The unclaimed onboarding posture (W1 Task 4, spec §5.8): every
128
+ // unclaimed boot (re)ensures the setup code under the pod-local cache
129
+ // root and logs it ONCE — the retrieval flow is `kubectl logs … | grep
130
+ // "setup code"` or `kubectl exec … cat <cacheDir>/setup-code` (the file
131
+ // is 0600 + atomic, and the cache root is never a backup section).
132
+ // `SIGRUN_SETUP_CODE` pins the value instead (the k8s Secret shape).
133
+ // Claimed boots (every identity-seeded test, every post-onboarding
134
+ // deploy) write nothing.
135
+ if (!identity.users.isClaimed()) {
136
+ // `identity.cacheDir`, not `roots.cacheDir`: the claim route reads
137
+ // `readSetupCode(identity.cacheDir)`, and an injected identity
138
+ // bundle (the test seam) may root elsewhere than the resolved
139
+ // roots — write where the reader reads.
140
+ const code = await ensureSetupCode(identity.cacheDir, env);
141
+ console.log(`sigrun setup code: ${code}`);
142
+ }
143
+ const storage = await openNodeSqliteStorage(config.storagePath);
144
+ const registry = createRegistry();
145
+ // The skills convention's package roots (W5 Task 4, spec §9.4): a LATE
146
+ // array — the extension packages that contribute skill dirs load BELOW
147
+ // (after this install), and the section renders per REQUEST, so the
148
+ // closure below observes whatever the loader collected without the
149
+ // install order changing (core sections lead the prompt).
150
+ const collectedSkillRoots = [];
151
+ // The template roots (Task 2c): the SAME late-fill shape as the skill
152
+ // roots — the loader runs below, and both the `/v1/commands` route and
153
+ // each expansion read the collections LIVE, so the late fill is
154
+ // visible from the next read on.
155
+ const collectedTemplateRoots = [];
156
+ // The settings store the live cap reads. Declared further down (the
157
+ // file-backed store needs `options.settingsDir` only); the closure
158
+ // reads it at RENDER time, long after the assignment — the skills cap
159
+ // is a live setting (`skills.sectionMaxBytes`, default 4KB).
160
+ let skillsSettings;
161
+ // Context-file sections (SOUL.md persona, AGENTS.md instructions), the
162
+ // skills convention, and the core tool surface (plan Task 1: coding
163
+ // tools read/write/edit/bash + search tools find/grep/ls) are all
164
+ // core-owned surfaces (spec §3): every conversation gets them, so they
165
+ // install before `Harness.open` reads the registry — never through the
166
+ // extension loader, never quarantined. install() publishes at once and
167
+ // replaces any same-name extension in place. The skills extension is
168
+ // BUILT here (W5 Task 4): three discovery roots — workspace (env), the
169
+ // user dir `<configDir>/skills/`, and the package roots the loader
170
+ // collects — with workspace > user > packages precedence and the live
171
+ // per-render cap.
172
+ // The skills options the EXPANSION paths share (Task 2c): the same
173
+ // three-root shape the section renders with — `/skill:name` and the
174
+ // `/` menu must see the same skills the section does (plus the hidden
175
+ // ones, which `listSkills` includes by design).
176
+ const skillsOptions = {
177
+ userDir: join(roots.configDir, "skills"),
178
+ packageRoots: () => collectedSkillRoots,
179
+ sectionMaxBytes: () => skillsSettings?.read().skills?.sectionMaxBytes ?? 4096,
180
+ };
181
+ const templateOptions = {
182
+ userDir: join(roots.configDir, "prompts"),
183
+ packageRoots: () => collectedTemplateRoots,
184
+ };
185
+ // The command-expansion handle (Task 2c): everything `expandInput`
186
+ // needs — the two root sets plus the reserved command-name set (core
187
+ // table + every extension command; a template colliding with any of
188
+ // these never expands). Read LIVE per expansion.
189
+ const commandExpansion = {
190
+ skills: skillsOptions,
191
+ templates: templateOptions,
192
+ commandNames: () => {
193
+ const names = new Set(SLASH_COMMANDS.keys());
194
+ for (const binding of commandBindings) {
195
+ for (const contribution of binding.commands) {
196
+ names.add(contribution.name);
197
+ }
198
+ }
199
+ return names;
200
+ },
201
+ };
202
+ registry.install(contextFilesExtension);
203
+ registry.install(createSkillsExtension(skillsOptions));
204
+ registry.install(codingToolsExtension);
205
+ registry.install(searchToolsExtension);
206
+ // Extension packages load before `Harness.open` reads the registry
207
+ // (spec §3 boot order). Failures quarantine and boot continues. §3
208
+ // factory-result producers are retained alongside the outcomes (Task 9e).
209
+ // The packages dir: the deployment-installed extension root the
210
+ // loader resolves against FIRST (the settings file's `packages`
211
+ // declarations install here — see src/harness/packages.ts).
212
+ const packagesDir = options.packagesDir ?? packagesDirOf(options.settingsDir ?? defaultSettingsDir());
213
+ // Built-in extensions (Task 2, pi parity): codemode, MCP and tool
214
+ // search ship with core. Resolution needs the settings FILE (the
215
+ // `builtinExtensions` toggles live there), so the store is constructed
216
+ // HERE — before the loader — and the one instance threads on to the
217
+ // rest of boot (its `harnessSettings()` call below stays the single
218
+ // construction).
219
+ const settingsStore = new SettingsStore({ dir: options.settingsDir });
220
+ // Eager validation (the fail-loud boot posture, matching the
221
+ // `harnessSettings()` contract below): a malformed settings file
222
+ // throws HERE even when the caller seam overrides every field.
223
+ const bootSettings = settingsStore.read();
224
+ const builtinsEnabled = options.builtins !== false;
225
+ // Skipped entirely (no resolution, no log lines) when the caller
226
+ // turns built-ins off.
227
+ // The configured side, normalized to ResolvedExtensionInput (the
228
+ // loader accepts both forms, but the combined input array must be
229
+ // homogeneous): a plain string becomes `{ entry: spec }`.
230
+ const configuredInputs = (options.extensionEntries ??
231
+ options.extensionSpecifiers ??
232
+ config.extensions).map((specifier) => typeof specifier === "string" ? { entry: specifier } : specifier);
233
+ const builtins = builtinsEnabled
234
+ ? resolveBuiltins(bootSettings, configuredInputs, options.extraBuiltins ?? [])
235
+ : { load: [], skipped: [] };
236
+ // The loader input: built-ins FIRST (pi parity — the replacement rule
237
+ // already removed any built-in a configured entry claims), marked
238
+ // `builtin: true` so their outcomes carry the flag to /v1/extensions,
239
+ // then the configured entries verbatim. The hermetic test seams
240
+ // (`extensionSpecifiers`/`extensionEntries`) still load — but with
241
+ // built-ins skipped entirely when `options.builtins === false`, the
242
+ // exact-shape outcome assertions' escape hatch.
243
+ const loaderInputs = builtinsEnabled
244
+ ? [
245
+ ...builtins.load.map((entry) => ({
246
+ ...entry,
247
+ builtin: true,
248
+ // Built-ins resolve from core's own tree, NEVER the
249
+ // packages dir (ruling 2): the marker tells the loader
250
+ // to skip the packages-dir-first resolution step.
251
+ coreResolved: true,
252
+ })),
253
+ ...configuredInputs,
254
+ ]
255
+ : configuredInputs;
256
+ // The seam host's lazy harness binding: factories run before
257
+ // `Harness.open` (spec §3 boot order), so the host resolves the harness
258
+ // through a promise that boot resolves right after the open. A seam
259
+ // call from a factory body would wait for it (deadlock: factories run
260
+ // synchronously inside loadExtensions) — the honest shape is: factories
261
+ // STASH the host; tool execute/hooks use it later, post-open.
262
+ // One shared deferred: every waiter joins the SAME promise, so the
263
+ // single boot assignment releases them all. A call after the bind
264
+ // resolves immediately (the promise is already settled).
265
+ let release;
266
+ const harnessPromise = new Promise((resolve) => {
267
+ release = resolve;
268
+ });
269
+ const bindHarness = (harness) => release(harness);
270
+ const harnessOf = () => harnessPromise;
271
+ // The boot-shared store trio (Task 5): one SecretsStore + one
272
+ // ExtensionSettingsStore over the roots resolved at the top of boot;
273
+ // each seam host scopes it to its extension's slug (cheap, no I/O —
274
+ // the cache dir materializes lazily on first `paths.cache` access).
275
+ // The boot env feeds the admin surface's env-secret listing (the
276
+ // `SIGRUN_EXT_<SLUG>_` fallbacks — never written down, listed only).
277
+ const sharedStores = sharedExtensionStores(roots, env);
278
+ const seamHosts = new Map();
279
+ const seamKinds = new Map();
280
+ const { outcomes: extensionOutcomes, healthProducers: extensionHealth, runSettledBindings, lifecycleBindings, workerBindings, providerContributors, webuiBundles: extensionWebuiBundles, skillRoots: extensionSkillRoots, jobBindings, routeBindings, commandBindings, notifierBindings, } = await loadExtensions(loaderInputs, registry, {
281
+ packagesDir,
282
+ hostContextFactory: (extensionName, kinds, priorHost) => {
283
+ // Two-phase, revised for the provisional identity (provider
284
+ // plugins Task 9 P1): the loader's PRE-import call passes the
285
+ // slug derived from the load specifier (a factory-time settings
286
+ // read — sigrun-pareto's `baseUrl` override — resolves the real
287
+ // package's file), and the post-resolution call hands the SAME
288
+ // instance back as `priorHost`. The re-key below therefore always
289
+ // mutates the instance the factory captured — never a second
290
+ // instance (review M2: a rebuilt host would orphan every inbound
291
+ // handler registered on the captured one), even when the declared
292
+ // name differs from the specifier-derived provisional.
293
+ const host = priorHost ??
294
+ createSeamHost({ name: extensionName }, kinds, {
295
+ bindHarness,
296
+ harness: harnessOf,
297
+ }, sharedStores);
298
+ host.rekey({ name: extensionName }, kinds);
299
+ // File under the final name only, and only once the load
300
+ // resolved: the PRE-import call must NOT file (its identity is
301
+ // provisional; a quarantined load would leak a placeholder
302
+ // entry), so the maps gain exactly one entry per resolving
303
+ // extension.
304
+ if (priorHost !== undefined) {
305
+ seamHosts.set(extensionName, host);
306
+ seamKinds.set(extensionName, kinds);
307
+ }
308
+ return host;
309
+ },
310
+ });
311
+ // The late fill (W5 Task 4): the skills extension installed BEFORE the
312
+ // loader ran, holding an empty package-roots array by reference — the
313
+ // collected roots land HERE, and the section (which renders per request)
314
+ // observes them from its next render on. Core sections still lead the
315
+ // prompt; only the discovery content is late.
316
+ collectedSkillRoots.push(...extensionSkillRoots);
317
+ // The prompt-template package roots (Task 2c): derived from the loaded
318
+ // entries' package roots — each package that ships a `prompts/`
319
+ // directory becomes an attributed root (the loader has no fs coupling
320
+ // by contract, so the derivation lives with the templates module).
321
+ collectedTemplateRoots.push(...packagePromptRootsOf(loaderInputs
322
+ .filter((input) => input.entry !== undefined)
323
+ .map((input) => input.entry)));
324
+ // The contributed-bundle registry (build-and-webui Task 6): the loader's
325
+ // flat, slug-attributed collection folded into one entry per NAME.
326
+ // Collision semantics mirror the provider merge EXACTLY —
327
+ // last-registered-wins with a console.warn and a retained record
328
+ // (`webuiBundleCollisions`, the sink `/v1/meta`'s `collisions.webui`
329
+ // reads) — a co-installed replacement bundle is a configuration choice
330
+ // the deployer must be able to SEE, never a silent shadowing.
331
+ const webuiBundleRegistry = new Map();
332
+ const webuiBundleCollisions = [];
333
+ for (const bundle of extensionWebuiBundles) {
334
+ const previous = webuiBundleRegistry.get(bundle.name);
335
+ if (previous !== undefined) {
336
+ webuiBundleCollisions.push({
337
+ kind: "webui-bundle",
338
+ name: bundle.name,
339
+ previous: previous.extension,
340
+ contributor: bundle.extension,
341
+ });
342
+ console.warn(`webui bundle collision: extension "${bundle.extension}" replaces ` +
343
+ `"${previous.extension}" bundle "${bundle.name}" (last-registered-wins)`);
344
+ }
345
+ webuiBundleRegistry.set(bundle.name, bundle);
346
+ }
347
+ const webuiBundles = [...webuiBundleRegistry.values()];
348
+ // The memory-surface owner: the installed extension whose sections
349
+ // include the long-term-memory key. None → the no-op default installs
350
+ // (the zero-extensions guarantee); a quarantined memory extension also
351
+ // leaves the surface to the no-op default.
352
+ const memoryOwner = registry
353
+ .snapshot()
354
+ .sections()
355
+ .find((entry) => entry.section.key === MEMORY_SECTION_KEY)?.extension;
356
+ if (memoryOwner === undefined) {
357
+ registry.install(noopMemoryExtension);
358
+ }
359
+ // Per-model guidance installs LAST (system-prompt Task 3): after
360
+ // `loadExtensions` and the memory section, so its two sections appear
361
+ // and disappear with the matched model as trailing patch rows instead
362
+ // of re-emitting the whole prompt on a mid-conversation model swap.
363
+ registry.install(modelGuidanceExtension);
364
+ // The reserved-core-section assertion (Task 3; §6.5 R11 narrows it to a
365
+ // CORE-ONLY guard), walked AFTER `loadExtensions` and the guidance
366
+ // install: the loader already quarantines any loaded extension that
367
+ // DECLARES a reserved key, so a mismatch here means a core extension
368
+ // was replaced by or registered under the wrong name — a core bug,
369
+ // failing boot loud naming the extension and the key. Prompt-section
370
+ // shape is core-owned surface (spec §3); a silent takeover is not a
371
+ // valid configuration.
372
+ for (const entry of registry.snapshot().sections()) {
373
+ const expectedOwner = RESERVED_CORE_SECTION_OWNERS[entry.section.key];
374
+ if (expectedOwner !== undefined && entry.extension.name !== expectedOwner) {
375
+ throw new Error(`extension "${entry.extension.name}" declares reserved core section "${entry.section.key}"`);
376
+ }
377
+ }
378
+ // §5 memory health (Task 9e): the memory extension's retained §3 producer
379
+ // — identity-matched against the INSTALLED extension, so a same-name
380
+ // reinstall cannot mis-attribute a producer. No match → undefined →
381
+ // "ok" (no-op default, quarantined, or producer-less memory extension).
382
+ const memoryHealth = extensionHealth.find((producer) => producer.extension === memoryOwner)?.health;
383
+ const sink = createProviderMergeSink();
384
+ // `config.models` is the already-validated default-model resolution
385
+ // (loadConfig refuses to boot without env or file); restating it for
386
+ // the collector under BOTH env spellings keeps a hand-built config
387
+ // authoritative over a stale or absent runtime env var — the collector
388
+ // reads SIGRUN_DEFAULT_MODEL first, then the deprecated SIGRUN_MODEL.
389
+ const effectiveEnv = {
390
+ ...env,
391
+ SIGRUN_DEFAULT_MODEL: `${config.models.provider}/${config.models.modelId}`,
392
+ SIGRUN_MODEL: `${config.models.provider}/${config.models.modelId}`,
393
+ };
394
+ // The auth store (ruling 6) — the same config dir the settings store
395
+ // uses. Passed to the collector so the degenerate provider resolves
396
+ // credentials from `auth.json` before the OPENAI_API_KEY fallback.
397
+ const authStore = new AuthStore({ dir: options.settingsDir });
398
+ // The file-backed models store (Task 3): refreshed provider catalogs
399
+ // persist under `<dataDir>/models/` and restore at the next boot's
400
+ // offline init — the data root is ensured above, so the store never
401
+ // mkdirs outside a sanctioned root.
402
+ const modelsStore = fileModelsStore(config.dataDir);
403
+ const { models, defaultModel, healthProducers: providerHealth, providerOwnership, readinessTrackers: providerReadiness, } = collectProviders([...(options.extensions ?? []), ...providerContributors], effectiveEnv, sink, {
404
+ auth: authStore,
405
+ baseUrl: config.defaultProviderBaseUrl,
406
+ modelsStore,
407
+ });
408
+ // The jobs core extension (jobs Task 3, spec §8.1 "The executor"):
409
+ // ONE registry install carrying the `sigrun.schedule` task, BEFORE
410
+ // `Harness.open` reads the registry (the same slot as the other core
411
+ // extensions). The task is a pure definition over the runtime deps —
412
+ // the clock seam (options.now) and `config.timezone` (§8.2) — so it
413
+ // installs here, before the open, and nothing else keyed on it. The
414
+ // deps object is shared with `fireJob`'s run-now and the JobsService
415
+ // below (one clock, one tz, one models store).
416
+ const jobRuntimeDeps = {
417
+ // SAFETY: harness + storage are assigned right after `Harness.open`
418
+ // returns below (the open needs the task registered, so the deps
419
+ // object is created first with placeholders). Every read — task
420
+ // phases, fireJob, the JobsService — happens post-assignment.
421
+ harness: undefined,
422
+ storage: undefined,
423
+ registry,
424
+ models,
425
+ defaultModel,
426
+ now: () => clockNow().getTime(),
427
+ defaultTz: config.timezone,
428
+ // Task 2c: job prompts expand `/skill:name` and template commands
429
+ // through the same roots the submit path uses (live reads, no
430
+ // `typed` entry — there is no user turn to attribute).
431
+ commandExpansion,
432
+ envBuilder: undefined,
433
+ };
434
+ registry.install({
435
+ name: "sigrun.jobs",
436
+ tasks: [createScheduleTask(jobRuntimeDeps)],
437
+ });
438
+ // The host event hub (W3 Task 4, spec §7.4): ONE per opened harness.
439
+ // The event bus's commit-derived events subscribe below (after the bus
440
+ // opens); the provider refresh paths observe readiness here — each
441
+ // completed refresh re-derives the per-provider readiness through the
442
+ // shared `providerAuthStatus` derivation and publishes one
443
+ // `providers.changed` per provider whose status moved.
444
+ const events = new EventHub();
445
+ // SAFETY: `providerAuthStatus` reads ONLY the five readiness inputs
446
+ // (providerReadiness/providerOwnership/providerHealth/auth/providerEnv)
447
+ // — all boot-complete at this point; the cast supplies the unrelated
448
+ // OpenedHarness fields as `unknown` placeholders nothing here reads.
449
+ const provisionalOpened = {
450
+ providerReadiness,
451
+ providerOwnership,
452
+ providerHealth,
453
+ auth: authStore,
454
+ providerEnv: effectiveEnv,
455
+ };
456
+ const producersByName = new Map(providerHealth.map((producer) => [producer.name, producer]));
457
+ const snapshotReadiness = () => {
458
+ const snapshot = new Map();
459
+ for (const { id } of models.getProviders()) {
460
+ snapshot.set(id, providerAuthStatus(provisionalOpened, id, producersByName).status);
461
+ }
462
+ return snapshot;
463
+ };
464
+ let lastReadiness;
465
+ const observeReadiness = (snapshot) => {
466
+ const prior = lastReadiness;
467
+ lastReadiness = snapshot;
468
+ if (prior === undefined)
469
+ return; // the first snapshot only baselines
470
+ for (const [provider, status] of snapshot) {
471
+ if (prior.get(provider) === status)
472
+ continue;
473
+ events.publish({ type: "providers.changed", provider, status });
474
+ }
475
+ for (const provider of prior.keys()) {
476
+ if (snapshot.has(provider))
477
+ continue;
478
+ // A provider that vanished from the catalog degrades out.
479
+ events.publish({
480
+ type: "providers.changed",
481
+ provider,
482
+ status: "not_ready",
483
+ });
484
+ }
485
+ };
486
+ // The boot refresh (Task 4, spec refresh policy 1): one network-allowed
487
+ // refresh of every dynamic provider, started HERE and never awaited on
488
+ // the boot path — it runs concurrently with `Harness.open` and listen.
489
+ // The deadline bounds a wedged upstream; timeout/failure only warns
490
+ // (never blocks boot). The tracked promise rides on the returned handle.
491
+ const bootRefreshDone = bootRefresh(models, {
492
+ timeoutMs: DEFAULT_REFRESH_TIMEOUT_MS,
493
+ onReadinessChange: observeReadiness,
494
+ snapshotReadiness,
495
+ });
496
+ // The boot-wired single-provider refresh (W3 Task 6): the route
497
+ // surface's refresh — the same bound policy `resolveServedModelRefreshing`
498
+ // runs on a miss, with the same readiness observation so a provider a
499
+ // credential write flips publishes `providers.changed` like every
500
+ // other refresh path does.
501
+ const refreshOneProvider = (providerId) => refreshProvider(models, providerId, {
502
+ onReadinessChange: observeReadiness,
503
+ snapshotReadiness,
504
+ });
505
+ // The interval refresh scheduler (Task 5, refresh policy 2): the cadence
506
+ // keeping the catalogs current after the boot refresh. Started only
507
+ // when the resolved interval is greater than zero (0 = the designed
508
+ // disable, no timer); the timer is unref()ed inside, and `stop()` rides
509
+ // on the returned handle for the deploy shutdown path.
510
+ const stopProviderRefresh = config.providers.refreshIntervalMinutes > 0
511
+ ? startProviderRefresh(models, {
512
+ intervalMinutes: config.providers.refreshIntervalMinutes,
513
+ onReadinessChange: observeReadiness,
514
+ snapshotReadiness,
515
+ }).stop
516
+ : () => { };
517
+ // The static workspace service (spec §4.5): every tool call's ExecutionEnv
518
+ // resolves through it, so a future resolver swap changes this wiring's
519
+ // inputs and nothing downstream.
520
+ const workspaceService = createWorkspaceService();
521
+ const workspacePrefs = defaultWorkspacePrefs(config.workspace.path);
522
+ const envBuilder = toHarnessEnv(workspaceService, workspacePrefs);
523
+ // The job runtime's env-builder binding resolves here (the placeholder
524
+ // the pre-open deps object needed): job-prompt expansion reads the
525
+ // conversation workspace through the SAME seam tool calls use.
526
+ jobRuntimeDeps.envBuilder = envBuilder;
527
+ // The settings store (Task 8a): file-backed, live. `harnessSettings()` is
528
+ // the getter-backed `HarnessSettings` pi-durable re-resolves per
529
+ // submission — absent = every default. A malformed file throws HERE, at
530
+ // boot, exactly like a bad `SIGRUN_*` value (loadConfig's posture).
531
+ // The skills extension's live cap reads the store constructed above
532
+ // (the closure built at the top resolves it per render —
533
+ // `skills.sectionMaxBytes`, default 4KB).
534
+ skillsSettings = settingsStore;
535
+ const fileSettings = settingsStore.harnessSettings();
536
+ const callerSettings = options.settings;
537
+ // The ONE boot clock (options.now ?? real): the activity stamper AND
538
+ // the harness clock share it (the schedule task's `runtime.sleep`
539
+ // reads the harness clock; pinning the seam pins both — the jobs
540
+ // tests' time-injection lever).
541
+ const clockNow = options.now ?? (() => new Date());
542
+ const harness = await Harness.open(storage, {
543
+ models,
544
+ registry,
545
+ env: envBuilder,
546
+ now: () => clockNow().getTime(),
547
+ // Context-management policy (Task 8): config's `SIGRUN_COMPACTION_*`
548
+ // tuning, with any caller-set field winning over it. Upstream merges
549
+ // the result over `DEFAULT_COMPACTION_POLICY` per field. Task 8a: the
550
+ // four file-backed keys resolve PER ACCESS through getters — caller
551
+ // seam first (test override over the file), then the file's LIVE
552
+ // getter. pi-durable re-resolves `settings` at every boundary, so a
553
+ // file PATCH becomes effective on the next admission without a
554
+ // restart. NEVER spread the getters into this object: a spread
555
+ // snapshots the file's boot-time values, silently freezing the
556
+ // queue modes and clobbering the caller seam (the round-1 defects).
557
+ // `extensions`/`toolExecution` have no file backing — plain passthrough.
558
+ settings: {
559
+ get steeringMode() {
560
+ return callerSettings?.steeringMode ?? fileSettings.steeringMode;
561
+ },
562
+ get followUpMode() {
563
+ return callerSettings?.followUpMode ?? fileSettings.followUpMode;
564
+ },
565
+ get retry() {
566
+ return callerSettings?.retry ?? fileSettings.retry;
567
+ },
568
+ get stream() {
569
+ return callerSettings?.stream ?? fileSettings.stream;
570
+ },
571
+ extensions: callerSettings?.extensions,
572
+ toolExecution: callerSettings?.toolExecution,
573
+ compaction: {
574
+ ...config.compaction,
575
+ ...callerSettings?.compaction,
576
+ },
577
+ },
578
+ }, BACKGROUND_CONTEXT);
579
+ // The seam hosts' lazy binding resolves here: every seam call issued
580
+ // after boot completes finds the harness immediately; one issued from a
581
+ // post-open async factory path waits on this same promise.
582
+ bindHarness(harness);
583
+ // The job runtime deps' harness/storage binding resolves the same way
584
+ // (the placeholders the pre-open install needed): the harness exists
585
+ // from here on, and every job read — task phases, fireJob, the
586
+ // JobsService — happens after this point.
587
+ jobRuntimeDeps.harness = harness;
588
+ jobRuntimeDeps.storage = storage;
589
+ // The memory-seam event bus (Tasks 2-4): subscribe BEFORE `resume()` so
590
+ // recovery settles (faulted/orphaned submissions the scheduler drains)
591
+ // are observed like any other commit. Extension handlers register under
592
+ // their final resolved names — the loader retained the bindings paired
593
+ // with the installed extensions, and registerExtension REPLACES a
594
+ // same-name entry, so the runSettled + lifecycle bindings MERGE here into
595
+ // one object per extension. The idle sweep rides the same open (the bus
596
+ // owns the 1s interval); the bounds come from `config.memorySeam`
597
+ // (Task 6's `SIGRUN_*` knobs), with the caller's eventBus seam winning
598
+ // per-field (the hermetic-test override).
599
+ const memorySeam = config.memorySeam;
600
+ const hookDeadlineMs = memorySeam.hookDeadlineMs;
601
+ const eventBus = EventBus.open(harness, {
602
+ queueMax: memorySeam.queueMax,
603
+ hookDeadlineMs,
604
+ idleThresholdMs: options.eventBus?.idleThresholdMs ?? memorySeam.idleThresholdMs,
605
+ idleSweepMs: options.eventBus?.idleSweepMs ?? IDLE_SWEEP_MS,
606
+ workerDeadlineMs: options.eventBus?.workerDeadlineMs ?? memorySeam.workerDeadlineMs,
607
+ // W3 Task 4: the hub's subscription is installed at open —
608
+ // synchronous, no-blocking-work (the SSE route's filtering side
609
+ // lane does its own async work). Exposed so the bus's open() can
610
+ // wire it before any commit lands.
611
+ onHostEvent: (event) => events.publish(event),
612
+ });
613
+ // Activity stamping (UI refresh A1): every settled run stamps its
614
+ // conversation's `lastActivityAt` (the admission-side stamp lives in the
615
+ // submissions route). Registered directly after the bus opens — host-owned
616
+ // machinery, ahead of every extension registration (install order = run
617
+ // order). The bus's close stops delivery; the unsubscribe is unused.
618
+ const now = clockNow;
619
+ wireActivity(eventBus, harness, now);
620
+ const handlersByName = new Map();
621
+ for (const binding of runSettledBindings) {
622
+ handlersByName.set(binding.extension.name, {
623
+ ...handlersByName.get(binding.extension.name),
624
+ runSettled: binding.runSettled,
625
+ });
626
+ }
627
+ for (const binding of lifecycleBindings) {
628
+ handlersByName.set(binding.extension.name, {
629
+ ...handlersByName.get(binding.extension.name),
630
+ ...binding.handlers,
631
+ });
632
+ }
633
+ // Task 5: workers merge into the same per-extension registration (the
634
+ // bus owns their timers + wake fan-out from that point).
635
+ for (const binding of workerBindings) {
636
+ handlersByName.set(binding.extension.name, {
637
+ ...handlersByName.get(binding.extension.name),
638
+ workers: binding.workers,
639
+ });
640
+ }
641
+ for (const [name, handlers] of handlersByName) {
642
+ eventBus.registerExtension(name, handlers);
643
+ }
644
+ // Auto-naming (UI refresh A2): one background one-shot completion per
645
+ // first-settled answered run writes `SessionMeta.autoName`. Host-owned
646
+ // machinery registered AFTER every extension (install order = run order
647
+ // — naming never delays an extension's observation), never over a user
648
+ // name, never for task-owned conversations, `namingModel: false` opts
649
+ // out, and its failures never touch the run.
650
+ const autoNamer = createAutoNamer({
651
+ harness,
652
+ models,
653
+ readSettings: () => settingsStore.read(),
654
+ defaultModel,
655
+ isTaskOwned: async (id) => (await storage.conversation(id, BACKGROUND_CONTEXT))?.owner !== undefined,
656
+ });
657
+ eventBus.registerExtension("sigrun:auto-name", {
658
+ runSettled: autoNamer.runSettled,
659
+ });
660
+ // The jobs run-status observers (W4 Task 3, §8.1): the runSettled
661
+ // handler registered as `sigrun:jobs` (after every loaded extension,
662
+ // like the auto-namer — a job observation never delays an extension's)
663
+ // + the taskSettled host-event listener (task-action runs). Registered
664
+ // BEFORE `resume()` so recovery settles are observed; the unsubscribe
665
+ // rides the bus's own close (host machinery, booted with the bus).
666
+ registerJobObservers({
667
+ harness,
668
+ storage,
669
+ runtime: jobRuntimeDeps,
670
+ settings: () => settingsStore.read(),
671
+ eventBus,
672
+ });
673
+ // Delivery scheduling: the bus itself stays passive (Task 2's
674
+ // contract — `drain()` is the only pump); boot owns when drains run.
675
+ // Every commit (bus events or not) schedules one debounced drain on
676
+ // the next macrotask — unref'd so a bare boot never holds the process
677
+ // open, and a no-op once the queue is found empty. Events enqueued
678
+ // while a drain is already pumping are picked up by that same loop.
679
+ let drainTimer;
680
+ harness.subscribeCommits(() => {
681
+ if (drainTimer !== undefined)
682
+ return;
683
+ drainTimer = setTimeout(() => {
684
+ drainTimer = undefined;
685
+ void eventBus.drain(hookDeadlineMs, BACKGROUND_CONTEXT);
686
+ });
687
+ drainTimer.unref?.();
688
+ });
689
+ // A failed Session closes itself (the upstream contract): the bus's
690
+ // sweep + subscription must not outlive it. shutdownSequence (the
691
+ // ordered path src/index.ts runs) closes the bus first, making this a
692
+ // no-op on the normal path.
693
+ harness.subscribeClose(() => eventBus.close());
694
+ // The jobs service (W4 Task 3): the declared sources are read LIVE
695
+ // per reconcile — the settings store re-reads (a file edit lands on
696
+ // the next reconcile) and the loader's job bindings snapshot at boot
697
+ // (an extension's declaration is its boot-time declaration).
698
+ const fileJobs = () => settingsStore.read().jobs ?? [];
699
+ const jobsSources = () => ({ file: fileJobs(), extensions: jobBindings });
700
+ // The root conversation — the schedule tasks' owner (spec §8.1).
701
+ // `Harness.root` is idempotent (creates with `agent` on first call,
702
+ // returns the reserved root thereafter), so the server's own
703
+ // root-ensure later sees the same conversation. Reconciliation needs
704
+ // it BEFORE creating any executor.
705
+ const rootConversation = await harness.root(BACKGROUND_CONTEXT, {
706
+ agent: { model: defaultModel },
707
+ });
708
+ const rootConversationId = rootConversation.id;
709
+ // The `job.changed` publication (W4 Task 4, §7.4/§8.4): the jobs
710
+ // service's onChange hook (fired from its `sigrun.job` commit
711
+ // subscription — every record change, any writer) publishes the
712
+ // changed job's wire row through the hub WITHOUT `access` (the SSE
713
+ // stream computes accessOf per principal and adds it; a hidden job's
714
+ // event is withheld entirely). The read runs on a microtask side lane
715
+ // (the commit-listener contract is no-blocking-work); a RETIRED record
716
+ // (the delete path) is served from its newest revision so clients
717
+ // still get the nudge to refetch.
718
+ const publishJobChanged = async (jobId) => {
719
+ try {
720
+ const record = await harness.snapshot(JobDoc, jobId, BACKGROUND_CONTEXT);
721
+ if (record !== undefined) {
722
+ // The hub's copy is ACCESS-NEUTRAL by contract (the wire's
723
+ // `Omit<JobWire, "access">`): `jobWireOf`'s row carries the
724
+ // service's neutral placeholder, so it is STRIPPED here — the
725
+ // SSE stream computes `accessOf` per principal and adds it. A
726
+ // future in-process hub subscriber must never read a fabricated
727
+ // access off the hub's copy.
728
+ const { access: _dropped, ...row } = jobWireOf(jobId, record);
729
+ events.publish({
730
+ type: "job.changed",
731
+ job: row,
732
+ });
733
+ return;
734
+ }
735
+ const [newest] = await listRevisions(storage, jobId, BACKGROUND_CONTEXT);
736
+ if (newest === undefined)
737
+ return;
738
+ events.publish({
739
+ type: "job.changed",
740
+ job: {
741
+ id: jobId,
742
+ name: newest.spec.name,
743
+ source: newest.source,
744
+ ...(newest.owner === undefined ? {} : { owner: newest.owner }),
745
+ ...(newest.sharing === undefined ? {} : { sharing: newest.sharing }),
746
+ schedule: newest.spec.schedule,
747
+ action: newest.spec.action,
748
+ enabled: newest.enabled,
749
+ // The retired row's status is gone with the record; the
750
+ // nudge only needs identity + definition (clients refetch).
751
+ state: "disabled",
752
+ version: newest.version,
753
+ },
754
+ });
755
+ }
756
+ catch (error) {
757
+ console.error("jobs: job.changed publication failed:", error);
758
+ }
759
+ };
760
+ // The in-process job-run outcome counter (W4 Task 4, §6.9): counted
761
+ // from `sigrun.job.run` document commits in THIS process — reset on
762
+ // restart by design (the durable history lives in the run records;
763
+ // the counter feeds `sigrun_job_runs_total{outcome}`). A creation
764
+ // write of an already-settled run (the overlap skip, the catch-up
765
+ // skip) counts once at creation; a `running` run counts at its settle;
766
+ // prune retirements (value null) never count.
767
+ const jobRunTotals = new Map();
768
+ harness.subscribeCommits((publication) => {
769
+ for (const change of publication.changes) {
770
+ if (change.type !== "document")
771
+ continue;
772
+ if (change.record.kind !== JobRunDoc.definition.kind)
773
+ continue;
774
+ const outcome = change.value?.outcome;
775
+ if (typeof outcome !== "string" || outcome === "running")
776
+ continue;
777
+ jobRunTotals.set(outcome, (jobRunTotals.get(outcome) ?? 0) + 1);
778
+ }
779
+ });
780
+ const jobs = createJobsService({
781
+ harness,
782
+ storage,
783
+ runtime: jobRuntimeDeps,
784
+ rootId: rootConversationId,
785
+ sources: jobsSources,
786
+ settings: () => settingsStore.read(),
787
+ onChange: (jobId) => {
788
+ void publishJobChanged(jobId);
789
+ },
790
+ });
791
+ // Reconcile BEFORE resume: the scheduler is still disabled, so the
792
+ // executor creations/aborts land durably and only then start running
793
+ // (the spec §8.3 boot order — "reconcile after resume()" — holds in
794
+ // effect: resume is the very next line). Awaiting a reconcile AFTER
795
+ // resume would delay openHarness's return past the scheduler's first
796
+ // drain microtask, racing post-boot registry installs against
797
+ // recovered-task agent resolution (the battery's hook-refire driver
798
+ // installs its hook extension right after boot — the recovered
799
+ // generation must resolve its agent AFTER that install).
800
+ await reconcileJobs({
801
+ harness,
802
+ storage,
803
+ runtime: jobRuntimeDeps,
804
+ rootId: rootConversationId,
805
+ sources: jobsSources,
806
+ }, BACKGROUND_CONTEXT);
807
+ // The deleted-owner warning (§8.3): a declared job whose `owner` names
808
+ // a user no longer in the directory runs OWNERLESS until its source is
809
+ // edited (household-write by the §5.9 rule) — surfaced at boot, never
810
+ // silently. Only checked on a claimed directory (an unclaimed boot
811
+ // has no users to check against).
812
+ if (identity.users.isClaimed()) {
813
+ const known = new Set(identity.users.list().map((user) => user.user));
814
+ for (const spec of [...fileJobs(), ...jobBindings.flatMap((binding) => binding.jobs)]) {
815
+ if (spec.owner === undefined || known.has(spec.owner))
816
+ continue;
817
+ console.warn(`jobs: declared job "${spec.name}" names owner "${spec.owner}" who is not in the user directory — it runs ownerless until its source is edited`);
818
+ }
819
+ }
820
+ harness.resume();
821
+ return {
822
+ harness,
823
+ storage,
824
+ registry,
825
+ models,
826
+ jobs,
827
+ jobRunTotals: () => jobRunTotals,
828
+ defaultModel,
829
+ providerCollisions: sink.collisions,
830
+ webuiBundles,
831
+ webuiBundleCollisions,
832
+ envBuilder,
833
+ extensionOutcomes,
834
+ builtinSkipped: builtinsEnabled ? builtins.skipped : [],
835
+ providerHealth,
836
+ providerOwnership,
837
+ providerReadiness,
838
+ providerEnv: effectiveEnv,
839
+ auth: authStore,
840
+ identity,
841
+ memoryHealth,
842
+ settings: settingsStore,
843
+ extensionStores: sharedStores,
844
+ packagesDir,
845
+ seamHosts,
846
+ seamKinds,
847
+ bindHostBridge: (bridge) => {
848
+ for (const host of seamHosts.values())
849
+ host.bindBridge(bridge);
850
+ },
851
+ extensionRoutes: routeBindings,
852
+ extensionCommands: commandBindings,
853
+ notifierBindings,
854
+ skillRoots: extensionSkillRoots,
855
+ commandExpansion,
856
+ eventBus,
857
+ events,
858
+ workerStatuses: () => eventBus.workerStatuses(),
859
+ now,
860
+ autoNamer,
861
+ bootRefresh: bootRefreshDone,
862
+ refreshProvider: refreshOneProvider,
863
+ stopProviderRefresh,
864
+ };
865
+ }