@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.
- package/CHANGELOG.md +214 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +89 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- 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
|
+
}
|