@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,60 @@
1
+ /**
2
+ * The auth.json → pi-ai credential bridge (provider plugins Task 2).
3
+ *
4
+ * pi-ai's `Models` resolves auth through a `CredentialStore` (stored
5
+ * credentials own the provider; ambient env is consulted only when nothing
6
+ * is stored) plus an `AuthContext` (ambient `env`/`fileExists` access).
7
+ * Sigrun's credentials are operator-provisioned through auth.json (the
8
+ * 2026-10-13 config-surface ruling), so the bridge is READ-ONLY: reads
9
+ * project the file into `api_key` credentials, with the `"default"`
10
+ * entry scoped to the default model's provider only (one key must never
11
+ * leak across plugin providers), and every write path refuses loudly
12
+ * rather than pretending the harness owns credential lifecycle.
13
+ *
14
+ * The `CredentialStore`/`AuthContext`/credential shapes below are
15
+ * structural re-declarations of pi-ai's, narrowed to the `api_key` subset
16
+ * this bridge produces (sigrun stores no OAuth credentials). The narrowed
17
+ * shapes stay assignable to pi-ai's full interfaces at the `createModels`
18
+ * call site in `collectProviders`.
19
+ */
20
+ import { DEFAULT_AUTH_KEY } from "../auth.js";
21
+ /** The refusal every write attempt gets: auth.json is operator-owned. */
22
+ export function operatorProvisionedError() {
23
+ return new Error("sigrun credentials are operator-provisioned; edit auth.json");
24
+ }
25
+ /**
26
+ * Adapt an {@link AuthStore} into a pi-ai `CredentialStore`.
27
+ *
28
+ * `read(providerId)` returns `{type:"api_key", key}` from auth.json.
29
+ * The `"default"` entry applies ONLY when `providerId` is the resolved
30
+ * default model's provider (`defaultProviderId()`, consulted per read,
31
+ * never cached); for it the order is `"default"` → own entry. Every
32
+ * other provider reads only its own entry; `undefined` when nothing
33
+ * matches. `list()` maps the file's provider keys. `modify`/`delete`
34
+ * throw: credentials are operator-provisioned.
35
+ */
36
+ export function authStoreCredentialStore(auth, defaultProviderId) {
37
+ return {
38
+ read: async (providerId) => {
39
+ const file = auth.read();
40
+ if (providerId === defaultProviderId()) {
41
+ const fromDefault = file[DEFAULT_AUTH_KEY]?.apiKey;
42
+ if (fromDefault !== undefined) {
43
+ return { type: "api_key", key: fromDefault };
44
+ }
45
+ }
46
+ const fromProvider = file[providerId]?.apiKey;
47
+ if (fromProvider !== undefined) {
48
+ return { type: "api_key", key: fromProvider };
49
+ }
50
+ return undefined;
51
+ },
52
+ list: async () => Object.keys(auth.read()).map((providerId) => ({ providerId, type: "api_key" })),
53
+ modify: async () => {
54
+ throw operatorProvisionedError();
55
+ },
56
+ delete: async () => {
57
+ throw operatorProvisionedError();
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * The provider-contributor contract (spec §3 "Model providers").
3
+ *
4
+ * Extensions may contribute pi-ai `createProvider()` results plus a default
5
+ * model at boot; the core merges all contributions into one `Models` store
6
+ * before `Harness.open()`. Merge semantics are deterministic,
7
+ * **last-registered-wins** on provider-id/model-id collision, and every
8
+ * collision is recorded in a {@link ProviderMergeSink} — never silent — so
9
+ * `GET /v1/meta` (Task 9) can surface it.
10
+ *
11
+ * `SIGRUN_DEFAULT_MODEL` (or the file's `defaultModel`) overrides any
12
+ * contributor default. When no extension contributes a provider for the
13
+ * pinned namespace, the env-driven OpenAI-compat degenerate provider
14
+ * serves it — its credentials resolve from `auth.json` (the `"default"`
15
+ * key, or the resolved provider's own entry) with `OPENAI_API_KEY` as
16
+ * the compat fallback. Each contributor's §3 `health()` producer is
17
+ * retained on the collected result for the §5 meta `health.providers`
18
+ * aggregation (Task 9e); the degenerate provider reports `"ok"` by
19
+ * absence.
20
+ */
21
+ import { existsSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { createModels, createProvider, } from "@earendil-works/pi-ai/models";
24
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
25
+ import { parseModelSpec } from "../../config.js";
26
+ import { DEFAULT_AUTH_KEY } from "../auth.js";
27
+ import { authStoreCredentialStore, } from "./auth-bridge.js";
28
+ export function createProviderMergeSink() {
29
+ const collisions = [];
30
+ return {
31
+ record: (collision) => {
32
+ collisions.push(collision);
33
+ console.warn(`provider collision: ${collision.contributor} replaces ${collision.previous} ` +
34
+ `provider "${collision.provider}"` +
35
+ (collision.modelIds.length > 0
36
+ ? ` (models: ${collision.modelIds.join(", ")})`
37
+ : ""));
38
+ },
39
+ get collisions() {
40
+ return collisions;
41
+ },
42
+ };
43
+ }
44
+ /** Environment keys the degenerate env provider reads. */
45
+ export const OPENAI_API_KEY_ENV = "OPENAI_API_KEY";
46
+ export const OPENAI_BASE_URL_ENV = "OPENAI_BASE_URL";
47
+ /** Fallback when `OPENAI_BASE_URL` is unset: OpenAI's public API. */
48
+ export const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
49
+ /**
50
+ * Conservative defaults for an unknown OpenAI-compat model. Context
51
+ * management (Task 8) tunes its policy against the default model's window;
52
+ * these placeholders keep that deterministic before tuning.
53
+ */
54
+ export const DEFAULT_OPENAI_CONTEXT_WINDOW = 128_000;
55
+ export const DEFAULT_OPENAI_MAX_TOKENS = 16_384;
56
+ /**
57
+ * Merge contributor providers into one `Models` store, then install the
58
+ * env-driven degenerate `openai` provider when no contributor contributed
59
+ * one. Deterministic order: contributors in array order, each provider
60
+ * upserting by id (last-registered-wins), then the env default — a designed
61
+ * fallback, never a collision.
62
+ *
63
+ * @param env raw environment (`process.env`); a set `SIGRUN_DEFAULT_MODEL`
64
+ * overrides every contributor default and names the model the env provider
65
+ * serves.
66
+ * @param sink receives collisions; defaults to a fresh sink. Boot passes the
67
+ * sink it returns so `GET /v1/meta` reads the same list.
68
+ *
69
+ * @throws Error naming `SIGRUN_DEFAULT_MODEL` when no default model is
70
+ * configured and no contributor proposed one — there would be no boot
71
+ * default at all.
72
+ */
73
+ export function collectProviders(extensions, env, sink = createProviderMergeSink(), options) {
74
+ // The auth bridge: with a credential store present, auth.json owns
75
+ // stored credentials for every contributed + degenerate provider —
76
+ // except `"default"`, which applies only to the provider of the
77
+ // resolved default model (the closure reads `defaultModel` as it
78
+ // stands at each `read()`, so the post-loop resolution governs); the
79
+ // auth context keeps ambient env/file lookups on the operator-provided
80
+ // environment (the options.env seam first, then the raw env).
81
+ const authContext = {
82
+ env: async (name) => options?.env?.[name] ?? env[name],
83
+ fileExists: (path) => Promise.resolve(existsSync(path.startsWith("~") ? homedir() + path.slice(1) : path)),
84
+ };
85
+ const models = createModels({
86
+ ...(options?.auth === undefined
87
+ ? {}
88
+ : {
89
+ credentials: authStoreCredentialStore(options.auth, () => defaultModel?.provider),
90
+ }),
91
+ ...(options?.modelsStore === undefined
92
+ ? {}
93
+ : { modelsStore: options.modelsStore }),
94
+ authContext,
95
+ });
96
+ let defaultModel;
97
+ const ownership = new Map();
98
+ const readinessTrackers = new Map();
99
+ const healthProducers = [];
100
+ for (const extension of extensions) {
101
+ // §3 producers are collected from every contributor that defines one,
102
+ // independent of its provider contributions (a health-only contributor
103
+ // still reports its surface's health).
104
+ if (extension.health !== undefined) {
105
+ healthProducers.push({
106
+ name: extension.name,
107
+ health: extension.health,
108
+ });
109
+ }
110
+ for (const contribution of extension.providers ?? []) {
111
+ const provider = contribution.provider;
112
+ const previous = ownership.get(provider.id);
113
+ if (previous !== undefined) {
114
+ const replaced = modelIdsOf(models, provider.id);
115
+ sink.record({
116
+ kind: "provider",
117
+ provider: provider.id,
118
+ // Record every model the replaced provider served, not just the ids the
119
+ // incoming one re-declares — a contributor replacing `{m1,m2}` with `{m2}`
120
+ // silently drops `m1`; meta must surface the full set that changed.
121
+ modelIds: replaced,
122
+ previous,
123
+ contributor: extension.name,
124
+ });
125
+ }
126
+ ownership.set(provider.id, extension.name);
127
+ // Task 6: the OWNING contributor's tracker is authoritative — a
128
+ // replacing contribution without one clears a replaced
129
+ // contributor's tracker (last-registered-wins, like ownership).
130
+ if (contribution.readiness !== undefined) {
131
+ readinessTrackers.set(provider.id, contribution.readiness);
132
+ }
133
+ else {
134
+ readinessTrackers.delete(provider.id);
135
+ }
136
+ models.setProvider(provider);
137
+ if (contribution.defaultModel !== undefined) {
138
+ defaultModel = contribution.defaultModel;
139
+ }
140
+ }
141
+ }
142
+ // A configured default model (env `SIGRUN_DEFAULT_MODEL` / deprecated
143
+ // alias `SIGRUN_MODEL`, or the file's `defaultModel` — the caller
144
+ // restates the resolution into the env seam) overrides any contributor
145
+ // default.
146
+ const envSpec = env.SIGRUN_DEFAULT_MODEL?.trim() || env.SIGRUN_MODEL?.trim();
147
+ if (envSpec) {
148
+ defaultModel = parseModelSpec(envSpec);
149
+ }
150
+ if (defaultModel === undefined) {
151
+ throw new Error("SIGRUN_DEFAULT_MODEL or settings.json defaultModel is required when no provider contributor proposes a default model " +
152
+ "(format: provider/model-id, e.g. openai/gpt-4o)");
153
+ }
154
+ // Degenerate fallback: when no extension contributed a provider for the
155
+ // pinned default model's namespace, register the env-driven OpenAI-compat
156
+ // provider there (any namespace), so a zero-extension boot still serves its
157
+ // pin instead of leaving the catalog empty and failing loud at submit.
158
+ if (!models.getProvider(defaultModel.provider)) {
159
+ models.setProvider(envDegenerateProvider(env, defaultModel.provider, defaultModel.modelId, options));
160
+ }
161
+ return {
162
+ models,
163
+ defaultModel,
164
+ healthProducers,
165
+ providerOwnership: ownership,
166
+ readinessTrackers,
167
+ };
168
+ }
169
+ /**
170
+ * Resolve the env-driven degenerate provider's API key (ruling 6). Order:
171
+ * auth.json `"default".apiKey`, then the resolved provider's own auth.json
172
+ * entry, then the `OPENAI_API_KEY` env var (compat fallback, still
173
+ * honored). Key material is never logged — this function returns it
174
+ * opaquely.
175
+ */
176
+ function resolveDegenerateApiKey(env, providerId, auth) {
177
+ if (auth !== undefined) {
178
+ const file = auth.read();
179
+ const fromDefault = file[DEFAULT_AUTH_KEY]?.apiKey;
180
+ if (fromDefault !== undefined)
181
+ return fromDefault;
182
+ const fromProvider = file[providerId]?.apiKey;
183
+ if (fromProvider !== undefined)
184
+ return fromProvider;
185
+ }
186
+ return env[OPENAI_API_KEY_ENV]?.trim() || undefined;
187
+ }
188
+ function modelIdsOf(models, providerId) {
189
+ return models.getModels(providerId).map((model) => model.id);
190
+ }
191
+ function modelIdsOfProvider(provider) {
192
+ try {
193
+ return provider.getModels().map((model) => model.id);
194
+ }
195
+ catch {
196
+ // Best-effort collision detail (matches `Models.getModels` semantics).
197
+ return [];
198
+ }
199
+ }
200
+ /**
201
+ * The degenerate env-driven OpenAI-compat provider: exactly one model — the
202
+ * id the default-model spec selected — streamed over `openai-completions`,
203
+ * with auth resolved from `auth.json` (`"default"` key or the provider's
204
+ * own entry) falling back to `OPENAI_API_KEY`, and the endpoint from
205
+ * `OPENAI_BASE_URL` over `options.baseUrl` (the file's
206
+ * `defaultProvider.baseUrl`, projected by `openHarness`) over the OpenAI
207
+ * public default (verified shape, App C: `{ auth: { apiKey, baseUrl } }`).
208
+ * Credentials resolve to `undefined` when no key exists anywhere, so the
209
+ * provider registers unconfigured and requests fail loud instead of boot
210
+ * failing.
211
+ *
212
+ * `cost` is required by the `Model` shape but unknown for an arbitrary
213
+ * gateway model, so it is zeroed; usage tracking stays structural.
214
+ */
215
+ export function envDegenerateProvider(env, providerId, modelId, options) {
216
+ const apiKey = resolveDegenerateApiKey(env, providerId, options?.auth);
217
+ const baseUrl = env[OPENAI_BASE_URL_ENV]?.trim() ||
218
+ options?.baseUrl ||
219
+ DEFAULT_OPENAI_BASE_URL;
220
+ const model = {
221
+ id: modelId,
222
+ name: modelId,
223
+ api: "openai-completions",
224
+ provider: providerId,
225
+ baseUrl,
226
+ input: ["text", "image"],
227
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
228
+ reasoning: false,
229
+ contextWindow: DEFAULT_OPENAI_CONTEXT_WINDOW,
230
+ maxTokens: DEFAULT_OPENAI_MAX_TOKENS,
231
+ };
232
+ return createProvider({
233
+ id: providerId,
234
+ name: `OpenAI-compatible (env, ${providerId})`,
235
+ baseUrl,
236
+ auth: {
237
+ apiKey: {
238
+ name: "OpenAI-compatible API key (auth.json or OPENAI_API_KEY)",
239
+ resolve: async () => apiKey === undefined
240
+ ? undefined
241
+ : { auth: { apiKey, baseUrl }, source: OPENAI_API_KEY_ENV },
242
+ },
243
+ },
244
+ models: [model],
245
+ api: openAICompletionsApi(),
246
+ });
247
+ }
248
+ /** Error thrown when a referenced model is served by no contributed provider. */
249
+ export class UnservedModelError extends Error {
250
+ ref;
251
+ /** Every served `provider/model-id` at the time of the check. */
252
+ available;
253
+ constructor(ref, available) {
254
+ super(`Model ${ref.provider}/${ref.modelId} is not available. Available models: ` +
255
+ `${available.length > 0 ? available.join(", ") : "(none)"}.`);
256
+ this.name = "UnservedModelError";
257
+ this.ref = ref;
258
+ this.available = available;
259
+ }
260
+ }
261
+ /**
262
+ * Resolve a model reference against the merged catalog, failing loud with
263
+ * the available ids when no contributor serves it — never a silent
264
+ * fallback. Task 9 maps this to the submit-time 4xx; the harness's own
265
+ * generation path fails independently (`no_model`, run unanswered).
266
+ */
267
+ export function requireServedModel(models, ref) {
268
+ const model = models.getModel(ref.provider, ref.modelId);
269
+ if (model === undefined) {
270
+ throw new UnservedModelError(ref, listServedModelIds(models));
271
+ }
272
+ return model;
273
+ }
274
+ /** Every served chat model as `provider/model-id`, in catalog order. */
275
+ export function listServedModelIds(models) {
276
+ return models.getModels().map((model) => `${model.provider}/${model.id}`);
277
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The file-backed pi-ai `ModelsStore` (provider plugins Task 3).
3
+ *
4
+ * Dynamic providers (`refreshModels`) publish their catalogs through the
5
+ * store `createModels` was built with; pi-ai writes every publication via
6
+ * `write` and restores it via `read` at the next boot's offline init. This
7
+ * implementation persists one JSON file per provider under
8
+ * `<dir>/models/<sanitized-provider-id>.json`, so a restart serves the
9
+ * last known catalog without network access.
10
+ *
11
+ * Poison resistance: a catalog file that fails to parse (or parses without
12
+ * a `models` array) is logged — the path, never the content — deleted, and
13
+ * reported as absent. A poisoned catalog degrades to "fetch it again",
14
+ * never a boot crash.
15
+ */
16
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ /** Characters a flattened provider id keeps; everything else becomes `_`. */
19
+ const SAFE_FILENAME = /[^A-Za-z0-9-]/g;
20
+ /** Monotonic scratch-file counter: concurrent writes never share a tmp name. */
21
+ let scratchCounter = 0;
22
+ /**
23
+ * Flatten a provider id into a safe filename component. Dots are NOT in the
24
+ * safe set, so no flattened id can form `.`, `..`, or a hidden file, and a
25
+ * `/`-shaped id (e.g. `acme/gw`) stays inside the models dir as `acme_gw`.
26
+ * Flattening is many-to-one: provider ids differing only in unsafe
27
+ * characters share a file — sigrun provider ids are slugs, so this never
28
+ * happens in practice, and the collision costs one re-fetch, not data loss.
29
+ */
30
+ function flattenProviderId(providerId) {
31
+ const flat = providerId.replace(SAFE_FILENAME, "_");
32
+ return flat.length > 0 ? flat : "_";
33
+ }
34
+ /** Is a parsed catalog file shaped like a `ModelsStoreEntry`? */
35
+ function isEntryShape(parsed) {
36
+ return (typeof parsed === "object" &&
37
+ parsed !== null &&
38
+ Array.isArray(parsed.models));
39
+ }
40
+ /**
41
+ * Build a `ModelsStore` rooted at `dir`: one JSON file per provider under
42
+ * `<dir>/models/`.
43
+ *
44
+ * Writes are atomic — the serialized entry lands in a uniquely named
45
+ * `<file>.<pid>-<n>.tmp` first, then `rename` swaps it in — so a crash
46
+ * mid-write can never leave a half-written catalog, and failed writes
47
+ * unlink their scratch file. The `AbortSignal` parameters of pi-ai's store
48
+ * contract are accepted but unused: operations are tiny local-file writes
49
+ * and reads, not cancellable network work.
50
+ */
51
+ export function fileModelsStore(dir) {
52
+ const modelsDir = join(dir, "models");
53
+ const providerPath = (providerId) => join(modelsDir, `${flattenProviderId(providerId)}.json`);
54
+ /** Warn (path, never content), delete the poisoned file, report absent. */
55
+ const discardCorrupt = async (path, why) => {
56
+ console.warn(`models-store: corrupt catalog file ${path} (${why}); deleting — it will be re-fetched`);
57
+ await rm(path, { force: true });
58
+ };
59
+ return {
60
+ read: async (providerId) => {
61
+ const path = providerPath(providerId);
62
+ let raw;
63
+ try {
64
+ raw = await readFile(path, "utf8");
65
+ }
66
+ catch (error) {
67
+ if (typeof error === "object" &&
68
+ error !== null &&
69
+ error.code === "ENOENT") {
70
+ return undefined;
71
+ }
72
+ throw error;
73
+ }
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(raw);
77
+ }
78
+ catch {
79
+ await discardCorrupt(path, "not valid JSON");
80
+ return undefined;
81
+ }
82
+ if (!isEntryShape(parsed)) {
83
+ await discardCorrupt(path, "models must be an array");
84
+ return undefined;
85
+ }
86
+ return parsed;
87
+ },
88
+ write: async (providerId, entry) => {
89
+ await mkdir(modelsDir, { recursive: true });
90
+ const path = providerPath(providerId);
91
+ const scratch = `${path}.${process.pid}-${scratchCounter++}.tmp`;
92
+ try {
93
+ await writeFile(scratch, JSON.stringify(entry), "utf8");
94
+ await rename(scratch, path);
95
+ }
96
+ catch (error) {
97
+ await rm(scratch, { force: true });
98
+ throw error;
99
+ }
100
+ },
101
+ delete: async (providerId) => {
102
+ await rm(providerPath(providerId), { force: true });
103
+ },
104
+ };
105
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Core-owned refresh policy (spec §"Core: refresh policy"): WHEN pi-ai's
3
+ * refresh runs. The protocol is pi-ai's — `Models.refresh({providers,
4
+ * allowNetwork, signal})` drives every dynamic provider, per-provider
5
+ * failures land in `ModelsRefreshResult.errors` without rejecting, and a
6
+ * failed refresh retains the previous catalog by contract. This module
7
+ * owns the two policy call sites:
8
+ *
9
+ * 1. {@link bootRefresh} — one network-allowed refresh started by boot
10
+ * fire-and-tracked (never awaited on the boot path — it runs
11
+ * concurrently with `Harness.open` and listen), bounded by a deadline
12
+ * (default {@link DEFAULT_REFRESH_TIMEOUT_MS}) so a wedged upstream
13
+ * cannot stall deploy readiness. Timeout/failure only warns
14
+ * (`console.warn` — repo convention, no shared logger): the server
15
+ * starts, readiness reports the provider's state, the interval
16
+ * refresh heals.
17
+ * 2. {@link resolveServedModelRefreshing} — refresh-on-miss at the submit
18
+ * call site: ONE bounded refresh of the named provider + re-resolve
19
+ * when `requireServedModel` reports a miss, so a model added upstream
20
+ * five minutes after the last interval tick is immediately usable
21
+ * instead of a 4xx. Still absent → the ORIGINAL
22
+ * {@link UnservedModelError} rethrows (the available-ids snapshot the
23
+ * caller surfaces is the one from the first check). The auto-namer's
24
+ * model resolution deliberately does NOT go through this wrapper —
25
+ * naming is a background lane (spec ruling).
26
+ */
27
+ import { requireServedModel, UnservedModelError } from "./index.js";
28
+ /**
29
+ * The refresh deadline default (boot and refresh-on-miss share it): long
30
+ * enough for a real catalog fetch, short enough that a wedged upstream
31
+ * cannot stall deploy readiness or hang a submit.
32
+ */
33
+ export const DEFAULT_REFRESH_TIMEOUT_MS = 30_000;
34
+ /**
35
+ * The fallback readiness snapshot (no derivation wired): provider id →
36
+ * `"ready"` for every catalog provider. Boot always wires
37
+ * `snapshotReadiness`; this keeps the option structurally optional.
38
+ */
39
+ export function refreshReadinessSnapshot(models) {
40
+ const snapshot = new Map();
41
+ for (const provider of models.getProviders()) {
42
+ snapshot.set(provider.id, "ready");
43
+ }
44
+ return snapshot;
45
+ }
46
+ /**
47
+ * One network-allowed refresh of every dynamic provider, bounded by
48
+ * `opts.timeoutMs`. Never rejects and never blocks boot beyond the
49
+ * deadline: an aborted (deadline/cancelled) or failed refresh logs ONE
50
+ * `console.warn` naming what did not complete — the catalogs simply stay
51
+ * at their last-known state. Boot starts this fire-and-tracked; the
52
+ * tracked promise rides on `OpenedHarness.bootRefresh`.
53
+ */
54
+ export async function bootRefresh(models, opts) {
55
+ await refreshBounded(models, { label: "boot refresh", ...opts });
56
+ }
57
+ /**
58
+ * One bounded single-provider refresh (core hardening W3 Task 6, spec
59
+ * §7.6): the exact refresh {@link resolveServedModelRefreshing} runs on
60
+ * a miss, exposed for the provider ROUTES (`POST
61
+ * /v1/providers/:id/refresh` and the credential write paths) — never a
62
+ * second policy. Accepts the same {@link RefreshBound} fields the boot
63
+ * and interval refreshes take (boot wiring passes its readiness
64
+ * observer so a flipped provider publishes `providers.changed`); every
65
+ * field is optional with the policy defaults applying. Like every
66
+ * policy refresh: never rejects — failures/aborts warn once and the
67
+ * catalog holds its last-known state.
68
+ */
69
+ export async function refreshProvider(models, providerId, opts = {}) {
70
+ await refreshBounded(models, {
71
+ label: `provider refresh (${providerId})`,
72
+ ...opts,
73
+ timeoutMs: opts.timeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS,
74
+ providers: [providerId],
75
+ });
76
+ }
77
+ /**
78
+ * `requireServedModel` with ONE refresh+retry: on an
79
+ * {@link UnservedModelError}, refresh the named provider (bounded; its
80
+ * failures only warn — a failed refresh is not a submit error) and
81
+ * re-resolve. Still absent after the retry → the ORIGINAL miss rethrows,
82
+ * available ids attached. The submit call site wraps its resolution in
83
+ * this; the auto-namer's resolution stays on bare `requireServedModel`.
84
+ */
85
+ export async function resolveServedModelRefreshing(models, ref, opts = {}) {
86
+ let miss;
87
+ try {
88
+ return requireServedModel(models, ref);
89
+ }
90
+ catch (error) {
91
+ if (!(error instanceof UnservedModelError))
92
+ throw error;
93
+ miss = error;
94
+ }
95
+ await refreshBounded(models, {
96
+ label: `refresh-on-miss (${ref.provider})`,
97
+ timeoutMs: opts.timeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS,
98
+ ...(opts.signal === undefined ? {} : { signal: opts.signal }),
99
+ providers: [ref.provider],
100
+ });
101
+ try {
102
+ return requireServedModel(models, ref);
103
+ }
104
+ catch {
105
+ // Still absent: rethrow the ORIGINAL miss. `requireServedModel`
106
+ // only ever throws UnservedModelError, and the available-ids list
107
+ // the caller surfaces is deliberately the first check's snapshot.
108
+ throw miss;
109
+ }
110
+ }
111
+ /**
112
+ * The shared bounded refresh: `allowNetwork: true`, an abort controller
113
+ * armed at `timeoutMs` (unref'd — a bare boot never holds the process
114
+ * open) and OR-ed with any caller signal, errors/aborts collapsed into
115
+ * one `console.warn`. `refresh()` itself reports per-provider failures
116
+ * through its result rather than rejecting; the catch is the belt for the
117
+ * unexpected throw — either way this resolves, never rejects.
118
+ */
119
+ async function refreshBounded(models, opts) {
120
+ const controller = new AbortController();
121
+ const signal = opts.signal === undefined
122
+ ? controller.signal
123
+ : AbortSignal.any([opts.signal, controller.signal]);
124
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
125
+ timer.unref?.();
126
+ try {
127
+ const result = await models.refresh({
128
+ allowNetwork: true,
129
+ ...(opts.providers === undefined ? {} : { providers: opts.providers }),
130
+ signal,
131
+ });
132
+ if (result.aborted) {
133
+ console.warn(`${opts.label} aborted before completing (deadline ${opts.timeoutMs}ms) — ` +
134
+ `provider catalogs stay at their last-known state`);
135
+ return;
136
+ }
137
+ if (result.errors.size > 0) {
138
+ const failed = [...result.errors]
139
+ .map(([id, error]) => `${id} (${error instanceof Error ? error.message : String(error)})`)
140
+ .join("; ");
141
+ console.warn(`${opts.label} failed for ${result.errors.size} provider(s): ${failed}`);
142
+ }
143
+ // The readiness observation runs for EVERY completed refresh
144
+ // (success or partial failure) so a provider that flips either way
145
+ // is observed; a refresh that threw before completing holds the
146
+ // previous snapshot (the catch below skips the observer).
147
+ try {
148
+ const snapshot = (opts.snapshotReadiness ?? refreshReadinessSnapshot)(models);
149
+ opts.onReadinessChange?.(snapshot);
150
+ }
151
+ catch (error) {
152
+ console.warn(`${opts.label}: readiness observation failed: ${error instanceof Error ? error.message : String(error)}`);
153
+ }
154
+ }
155
+ catch (error) {
156
+ console.warn(`${opts.label} failed: ${error instanceof Error ? error.message : String(error)}`);
157
+ }
158
+ finally {
159
+ clearTimeout(timer);
160
+ }
161
+ }