@north-light/api 0.3.561 → 0.3.563

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 (87) hide show
  1. package/dist/api/client.d.ts +27 -4
  2. package/dist/api/client.js +53 -0
  3. package/dist/api/contract.d.ts +1 -1
  4. package/dist/api/contract.js +1 -1
  5. package/dist/api/dto/bash-jobs.d.ts +5 -1
  6. package/dist/api/dto/broker-ops.d.ts +25 -1
  7. package/dist/api/dto/charges.d.ts +9 -2
  8. package/dist/api/dto/delivery.d.ts +26 -2
  9. package/dist/api/dto/execution-start.d.ts +57 -0
  10. package/dist/api/dto/execution-start.js +2 -0
  11. package/dist/api/dto/modelauth.d.ts +5 -0
  12. package/dist/api/dto/node-records.d.ts +16 -0
  13. package/dist/api/dto/objects.d.ts +1 -1
  14. package/dist/api/dto/reports.d.ts +0 -3
  15. package/dist/api/dto/run-events.d.ts +7 -1
  16. package/dist/api/dto/settings.d.ts +55 -0
  17. package/dist/api/dto/settings.js +4 -0
  18. package/dist/api/index.d.ts +2 -0
  19. package/dist/api/index.js +2 -0
  20. package/dist/api/routes.d.ts +11 -0
  21. package/dist/api/routes.js +12 -0
  22. package/dist/core/canvas/paths.d.ts +0 -1
  23. package/dist/core/canvas/paths.js +0 -3
  24. package/dist/core/canvas/person-state.d.ts +26 -0
  25. package/dist/core/canvas/person-state.js +41 -0
  26. package/dist/core/command-plugins/bundle.d.ts +1 -1
  27. package/dist/core/command-plugins/bundle.js +5 -5
  28. package/dist/core/config.d.ts +16 -15
  29. package/dist/core/config.js +99 -76
  30. package/dist/core/conversation-store/client.d.ts +2 -0
  31. package/dist/core/conversation-store/client.js +111 -0
  32. package/dist/core/conversation-store/index.d.ts +96 -0
  33. package/dist/core/conversation-store/index.js +62 -0
  34. package/dist/core/conversation-store/patch.d.ts +3 -0
  35. package/dist/core/conversation-store/patch.js +192 -0
  36. package/dist/core/conversation-store/repair.d.ts +45 -0
  37. package/dist/core/conversation-store/repair.js +84 -0
  38. package/dist/core/env-name.d.ts +1 -0
  39. package/dist/core/env-name.js +16 -0
  40. package/dist/core/events/emit.js +7 -5
  41. package/dist/core/git.d.ts +28 -0
  42. package/dist/core/git.js +103 -0
  43. package/dist/core/installed-plugins.js +12 -7
  44. package/dist/core/managed-providers.d.ts +72 -0
  45. package/dist/core/managed-providers.js +61 -0
  46. package/dist/core/plugin-env.js +1 -2
  47. package/dist/core/profiles/manifest.d.ts +14 -18
  48. package/dist/core/profiles/manifest.js +79 -98
  49. package/dist/core/runtime/broker/attachments-fetch.d.ts +5 -0
  50. package/dist/core/runtime/broker/attachments-fetch.js +20 -0
  51. package/dist/core/runtime/broker/daemon-ops.d.ts +10 -1
  52. package/dist/core/runtime/broker/daemon-ops.js +41 -3
  53. package/dist/core/runtime/broker/node-token.d.ts +3 -0
  54. package/dist/core/runtime/broker/node-token.js +16 -0
  55. package/dist/core/runtime/broker/signal-stream.js +2 -2
  56. package/dist/core/scoped-state/materialized-plugins.d.ts +18 -0
  57. package/dist/core/scoped-state/materialized-plugins.js +103 -0
  58. package/dist/core/scoped-state/person-store.d.ts +72 -0
  59. package/dist/core/scoped-state/person-store.js +112 -0
  60. package/dist/core/user-settings.js +1 -1
  61. package/dist/daemon/broker-signals/bus.d.ts +48 -11
  62. package/dist/daemon/broker-signals/bus.js +116 -46
  63. package/dist/shared/inbox-entry-body.js +2 -2
  64. package/dist/types.d.ts +12 -0
  65. package/package.json +1 -1
  66. package/dist/core/canvas/legacy-person.d.ts +0 -4
  67. package/dist/core/canvas/legacy-person.js +0 -4
  68. package/dist/core/locked-file.d.ts +0 -27
  69. package/dist/core/locked-file.js +0 -121
  70. package/dist/core/profiles/env-store.d.ts +0 -22
  71. package/dist/core/profiles/env-store.js +0 -166
  72. package/dist/core/scoped-state/db.d.ts +0 -17
  73. package/dist/core/scoped-state/db.js +0 -261
  74. package/dist/core/scoped-state/migrate.d.ts +0 -8
  75. package/dist/core/scoped-state/migrate.js +0 -187
  76. package/dist/core/scoped-state/profiles.d.ts +0 -27
  77. package/dist/core/scoped-state/profiles.js +0 -94
  78. package/dist/core/scoped-state/providers.d.ts +0 -24
  79. package/dist/core/scoped-state/providers.js +0 -19
  80. package/dist/core/scoped-state/schema.d.ts +0 -10
  81. package/dist/core/scoped-state/schema.js +0 -47
  82. package/dist/core/scoped-state/settings.d.ts +0 -28
  83. package/dist/core/scoped-state/settings.js +0 -104
  84. package/dist/core/sqlite-statements.d.ts +0 -4
  85. package/dist/core/sqlite-statements.js +0 -17
  86. package/dist/core/subscription-state.d.ts +0 -129
  87. package/dist/core/subscription-state.js +0 -299
package/dist/types.d.ts CHANGED
@@ -405,6 +405,18 @@ export interface ScopeConfig {
405
405
  /** The money ledger. `take_pct` (0-100, default 0) is Northlight's take on each priced model or tool charge; at 0 no take rows are written. */
406
406
  ledger?: {
407
407
  take_pct?: number;
408
+ /** USD per GB-hour of Blaxel sandbox awake time; the `ledger-sandbox` lane writes no rows while it is unset. */
409
+ sandbox_usd_per_gb_hour?: number;
410
+ /** Seconds a sandbox stays awake after its broker goes idle or exits (default 30). */
411
+ sandbox_standby_s?: number;
412
+ /** USD per GB-month of R2 storage; the `ledger-storage` lane writes no `r2` rows while it is unset. */
413
+ storage_usd_per_gb_month?: number;
414
+ /** USD per GB-month of Blaxel sandbox disk; the `ledger-storage` lane writes no `blaxel` rows while it is unset. */
415
+ sandbox_disk_usd_per_gb_month?: number;
416
+ /** USD of sandbox and storage a billing account uses free each UTC month (default 50); `billed_usd` on those rows is the part above it. */
417
+ infra_free_usd_per_month?: number;
418
+ /** The `ledger-r2` lane moves closed months into the blob store only while this is true (off until 5b is accepted). */
419
+ r2_move?: boolean;
408
420
  };
409
421
  /** Conversation store configuration for the daemon. */
410
422
  store?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/api",
3
- "version": "0.3.561",
3
+ "version": "0.3.563",
4
4
  "description": "Typed crtrd /v1 API contract — DTOs, route builders, the error contract, the CrtrClient, and the command-plugin manifest format. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "./dist/api/index.js",
@@ -1,4 +0,0 @@
1
- /** The person principal every install carried before it minted its own: read only by the one-shot that retires it and by the legacy SQLite/layout importers. Pure so CLI-reachable modules can import it. */
2
- export declare const LEGACY_LOCAL_PERSON = "user:local";
3
- /** The id of the local terminal grant an install carried before its grant became `terminal:<install id>`; nodes, tokens, crons and queued births may still reference it. */
4
- export declare const LEGACY_LOCAL_TERMINAL_GRANT = "local:terminal";
@@ -1,4 +0,0 @@
1
- /** The person principal every install carried before it minted its own: read only by the one-shot that retires it and by the legacy SQLite/layout importers. Pure so CLI-reachable modules can import it. */
2
- export const LEGACY_LOCAL_PERSON = 'user:local';
3
- /** The id of the local terminal grant an install carried before its grant became `terminal:<install id>`; nodes, tokens, crons and queued births may still reference it. */
4
- export const LEGACY_LOCAL_TERMINAL_GRANT = 'local:terminal';
@@ -1,27 +0,0 @@
1
- export type LockResult<T> = {
2
- result: T;
3
- next?: string;
4
- };
5
- /** Throws if this lock was compromised (stolen/expired) since acquisition. Callers that write
6
- * from INSIDE the critical section must call it immediately before that write, because the
7
- * post-`fn` check cannot un-write a file the callback already replaced. */
8
- export type LockHeldGuard = () => void;
9
- export type LockedFilePublish = (next: string) => void;
10
- export interface LockedFileBackend {
11
- withLockAsync<T>(fn: (current: string | undefined, assertLockHeld: LockHeldGuard, publish: LockedFilePublish) => Promise<LockResult<T>>): Promise<T>;
12
- /** The current whole-file contents, or undefined when the file does not exist yet.
13
- * Lock-free: every writer replaces the file by atomic rename. */
14
- readUnlocked(): string | undefined;
15
- }
16
- /** A 0600 JSON file guarded by a cross-process `proper-lockfile` lock on its own path. */
17
- export declare class LockedJsonFile implements LockedFileBackend {
18
- private readonly path;
19
- private readonly durable;
20
- private readonly initialContent;
21
- constructor(path: string, durable?: boolean, initialContent?: string);
22
- readUnlocked(): string | undefined;
23
- private ensureFile;
24
- /** Acquire the lock without blocking the event loop, keeping every read-modify-write
25
- * callback on the same asynchronous path. */
26
- withLockAsync<T>(fn: (current: string | undefined, assertLockHeld: LockHeldGuard, publish: LockedFilePublish) => Promise<LockResult<T>>): Promise<T>;
27
- }
@@ -1,121 +0,0 @@
1
- // locked-file.ts — crouter's cross-process locked read/modify/write primitive for the
2
- // small JSON state files the runtime co-owns with pi: pi's own `auth.json` (api keys and
3
- // non-managed OAuth credentials) and crouter's per-provider subscription pool files, which
4
- // hold every managed account's own credential.
5
- //
6
- // The lock is `proper-lockfile` on the target path, so a crouter process and a pi process
7
- // contending for `auth.json` still exclude each other: same library, same lock path, and
8
- // same `realpath: false` option. Every callback runs asynchronously and returns
9
- // `{ result, next }`; `next === undefined` means "no write". Every write lands by atomic
10
- // tmp+rename (pool callers do their own, inside the callback), so a whole snapshot is the
11
- // only thing on disk and READS need no lock at all -- `readUnlocked` serves them. That
12
- // matters: pi asks its CredentialStore for every registered provider (~75) whenever it
13
- // builds a model runtime, and routing those through the lock made each boot serialize
14
- // behind seconds of lock acquisition backoff.
15
- import { chmodSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, closeSync } from 'node:fs';
16
- import { dirname } from 'node:path';
17
- import lockfile from 'proper-lockfile';
18
- import { publishFileExclusive } from './fs-utils.js';
19
- const WRITE_OPTIONS = { encoding: 'utf-8', mode: 0o600 };
20
- // proper-lockfile decides a held lock is abandoned when its lockdir mtime is older than
21
- // the acquirer's `stale`, and refreshes the mtime on the holder's `update` timer. The holder
22
- // refreshes well inside the staleness threshold so long-running callbacks remain protected.
23
- const LOCK_STALE_MS = 30_000;
24
- const LOCK_UPDATE_MS = 5_000;
25
- /** A 0600 JSON file guarded by a cross-process `proper-lockfile` lock on its own path. */
26
- export class LockedJsonFile {
27
- path;
28
- durable;
29
- initialContent;
30
- constructor(path, durable = false, initialContent = '{}') {
31
- this.path = path;
32
- this.durable = durable;
33
- this.initialContent = initialContent;
34
- }
35
- readUnlocked() {
36
- return existsSync(this.path) ? readFileSync(this.path, 'utf-8') : undefined;
37
- }
38
- ensureFile() {
39
- const dir = dirname(this.path);
40
- if (!existsSync(dir))
41
- mkdirSync(dir, { recursive: true, mode: 0o700 });
42
- if (!existsSync(this.path)) {
43
- // Published whole and exclusively: another process may be creating it too, and a
44
- // reader (or a lost write) must never see the empty file a plain create leaves.
45
- publishFileExclusive(this.path, this.initialContent, 0o600);
46
- chmodSync(this.path, 0o600);
47
- }
48
- }
49
- /** Acquire the lock without blocking the event loop, keeping every read-modify-write
50
- * callback on the same asynchronous path. */
51
- async withLockAsync(fn) {
52
- this.ensureFile();
53
- let release;
54
- let compromised;
55
- const throwIfCompromised = () => {
56
- if (compromised)
57
- throw compromised;
58
- };
59
- try {
60
- release = await lockfile.lock(this.path, {
61
- // A different realpath setting would derive a DIFFERENT lock path for a symlinked
62
- // auth.json and silently stop excluding other writers.
63
- realpath: false,
64
- retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },
65
- stale: LOCK_STALE_MS,
66
- // Refresh the lockdir mtime well inside `stale` so long-running callbacks remain protected.
67
- update: LOCK_UPDATE_MS,
68
- onCompromised: (err) => {
69
- compromised = err;
70
- },
71
- });
72
- throwIfCompromised();
73
- const current = existsSync(this.path) ? readFileSync(this.path, 'utf-8') : undefined;
74
- let published = false;
75
- const publish = (next) => {
76
- if (published)
77
- throw new Error(`locked file was published twice: ${this.path}`);
78
- throwIfCompromised();
79
- const tmp = `${this.path}.${process.pid}.${Date.now()}.tmp`;
80
- writeFileSync(tmp, next, WRITE_OPTIONS);
81
- chmodSync(tmp, 0o600);
82
- if (this.durable) {
83
- const fd = openSync(tmp, 'r');
84
- try {
85
- fsyncSync(fd);
86
- }
87
- finally {
88
- closeSync(fd);
89
- }
90
- }
91
- renameSync(tmp, this.path);
92
- if (this.durable) {
93
- const fd = openSync(dirname(this.path), 'r');
94
- try {
95
- fsyncSync(fd);
96
- }
97
- finally {
98
- closeSync(fd);
99
- }
100
- }
101
- published = true;
102
- };
103
- const { result, next } = await fn(current, throwIfCompromised, publish);
104
- throwIfCompromised();
105
- if (next !== undefined)
106
- publish(next);
107
- throwIfCompromised();
108
- return result;
109
- }
110
- finally {
111
- if (release) {
112
- try {
113
- await release();
114
- }
115
- catch {
116
- // A compromised lock's release throws; the compromise itself already surfaced.
117
- }
118
- }
119
- }
120
- }
121
- }
@@ -1,22 +0,0 @@
1
- export declare function isReservedEnvName(name: string): boolean;
2
- /** Every stored variable NAME for a profile, sorted. Never returns values. */
3
- export declare function listProfileEnvNames(profileId: string): string[];
4
- /** Set one variable, replacing any prior value under the same name. Holds the
5
- * same per-profile lock manifest mutations use (`withProfileManifestLock`),
6
- * so a concurrent `profile delete` can never race a write into an
7
- * about-to-vanish profile dir — whichever runs first wins the lock, and the
8
- * loser either sees `not_found` (delete-then-set) or removes the file right
9
- * back out from under a just-written value (set-then-delete, via
10
- * `deleteProfile`'s recursive rm of the whole profile root). Returns whether
11
- * the name was newly added (`true`) or replaced an existing value (`false`). */
12
- export declare function setProfileEnvVar(profileId: string, name: string, value: string): boolean;
13
- /** Remove one variable. Returns whether it was present. */
14
- export declare function removeProfileEnvVar(profileId: string, name: string): boolean;
15
- /** Every stored `{name: value}` for a profile — the ONLY function that
16
- * returns raw values, called exclusively by `buildBrokerEnv`
17
- * (`core/runtime/spawn-env.ts`) to inject them directly into a launched
18
- * broker's env. Never throws: a stale/invalid/deleted profile id is a
19
- * hot-path no-op (mirrors `readRawProfileConfig` in `core/config.ts`),
20
- * because broker-env resolution must never fail a launch over a bad profile
21
- * id. */
22
- export declare function readProfileEnvVars(profileId: string | null): Record<string, string>;
@@ -1,166 +0,0 @@
1
- // The profile-owned env store: `<runtime>/secrets/env/<app>/<profileId>` — a 0600
2
- // map of env-var name -> value a profile owns explicitly. `buildBrokerEnv`
3
- // (`core/runtime/spawn-env.ts`) reads it straight off disk and injects the
4
- // values directly into every broker launched under that profile, independent
5
- // of the daemon's own ambient `process.env` or any `.envrc`/`.env` a node's
6
- // cwd might carry. Setting a value here IS the consent to cross the spawn
7
- // boundary — no matching `spawnEnv.allow` entry is needed, because these
8
- // values never pass through the host-env allowlist in the first place; they
9
- // are a separate, always-injected source. Never surface a stored VALUE
10
- // outside this file's own readers — `listProfileEnvNames` returns names only,
11
- // and no caller should log or echo what `readProfileEnvVars` returns.
12
- import { existsSync, readFileSync, renameSync, writeFileSync, chmodSync } from 'node:fs';
13
- import { dirname } from 'node:path';
14
- import { profileSpace } from '../layout.js';
15
- import { loadExactProfileManifest, withProfileManifestLock } from './manifest.js';
16
- import { ensureDir } from '../fs-utils.js';
17
- import { notFound, usage } from '../errors.js';
18
- /** crtr's own identity/routing env names — reserved so a stored profile value
19
- * can never redirect a broker to a different canvas home/db/socket dir or
20
- * masquerade as its own process identity. `HOME` is reserved alongside the
21
- * `CRTR_*` family because crtr has no independent authoritative scope root
22
- * yet; every path in `core/canvas/paths.ts` ultimately derives from it.
23
- * Enforced at write (`setProfileEnvVar` throws below) AND defensively at
24
- * read (`readStoreUnlocked`, which every reader — list/set/remove/inject —
25
- * goes through), so even a reserved name that reached disk some other way
26
- * (hand-edited file, a pre-reservation write) is uniformly invisible rather
27
- * than trusted.
28
- *
29
- * The deployment-wide feedback names are the only `CRTR_*` values stored
30
- * anywhere; they live in their own store (`profiles/app-env-store.ts`). */
31
- const RESERVED_ENV_PREFIX = 'CRTR_';
32
- const RESERVED_ENV_EXACT_NAMES = new Set(['HOME']);
33
- export function isReservedEnvName(name) {
34
- return name.startsWith(RESERVED_ENV_PREFIX) || RESERVED_ENV_EXACT_NAMES.has(name);
35
- }
36
- /** NUL is the one byte no OS accepts in an env value — Node's own `spawn`
37
- * rejects it synchronously and echoes the full value in the thrown error, so
38
- * it must never reach storage or a spawn call. */
39
- function hasNulByte(value) {
40
- return value.includes('\0');
41
- }
42
- function envPath(profileId) {
43
- const profile = loadExactProfileManifest(profileId);
44
- return profileSpace(profile.manifest.grantee ?? 'app:terminal', profileId).env;
45
- }
46
- /** Reads env.json into a null-prototype, sanitized own-property map.
47
- * Null-prototype so a stored name like `__proto__` is a plain own data
48
- * property instead of silently colliding with the inherited accessor on an
49
- * ordinary object (which would make `in`/assignment lie about what's
50
- * actually stored — `created`/`removed` would misreport, and the value
51
- * would never actually persist). Sanitized — dropping any non-string,
52
- * NUL-containing, or reserved-name entry — because this file may have been
53
- * hand-edited or written by a version that predates these guards, and every
54
- * reader (list/set/remove/inject) shares this one function, so a bad
55
- * on-disk entry is uniformly invisible instead of leaking into one path but
56
- * not another. Never throws: a corrupt file degrades to empty, matching the
57
- * prior behavior. */
58
- function readStoreUnlocked(profileId) {
59
- const p = envPath(profileId);
60
- const vars = Object.create(null);
61
- if (!existsSync(p))
62
- return { vars };
63
- try {
64
- const parsed = JSON.parse(readFileSync(p, 'utf8'));
65
- for (const [name, value] of Object.entries(parsed.vars ?? {})) {
66
- if (typeof value !== 'string')
67
- continue;
68
- if (hasNulByte(value))
69
- continue;
70
- if (isReservedEnvName(name))
71
- continue;
72
- vars[name] = value;
73
- }
74
- }
75
- catch {
76
- // Corrupt/unparseable file: degrade to whatever was sanitized so far
77
- // (empty, since the loop above never ran).
78
- }
79
- return { vars };
80
- }
81
- /** Writes env.json directly (never via `fs-utils`' generic writers, which
82
- * have no mode control) so the file is created 0600 from the first byte,
83
- * then `chmodSync`s belt-and-suspenders in case it pre-existed with looser
84
- * permissions — the same shape as `core/secrets.ts`. Atomic via tmp+rename. */
85
- function writeStoreAtomic(profileId, store) {
86
- const p = envPath(profileId);
87
- ensureDir(dirname(p));
88
- const tmp = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
89
- writeFileSync(tmp, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 });
90
- chmodSync(tmp, 0o600);
91
- renameSync(tmp, p);
92
- }
93
- function requireProfileExists(profileId) {
94
- try {
95
- loadExactProfileManifest(profileId);
96
- }
97
- catch (error) {
98
- if (error instanceof Error && error.message.startsWith('profile not found:'))
99
- throw notFound(`profile not found: ${profileId}`, { received: profileId });
100
- throw error;
101
- }
102
- }
103
- /** Every stored variable NAME for a profile, sorted. Never returns values. */
104
- export function listProfileEnvNames(profileId) {
105
- return Object.keys(readStoreUnlocked(profileId).vars).sort();
106
- }
107
- /** Set one variable, replacing any prior value under the same name. Holds the
108
- * same per-profile lock manifest mutations use (`withProfileManifestLock`),
109
- * so a concurrent `profile delete` can never race a write into an
110
- * about-to-vanish profile dir — whichever runs first wins the lock, and the
111
- * loser either sees `not_found` (delete-then-set) or removes the file right
112
- * back out from under a just-written value (set-then-delete, via
113
- * `deleteProfile`'s recursive rm of the whole profile root). Returns whether
114
- * the name was newly added (`true`) or replaced an existing value (`false`). */
115
- export function setProfileEnvVar(profileId, name, value) {
116
- if (isReservedEnvName(name)) {
117
- throw usage(`env var name is reserved by crtr: ${name}`, {
118
- received: name,
119
- field: 'name',
120
- next: 'crtr identity/routing names (CRTR_* and HOME) cannot be stored in a profile env store — a stored value under one of these names could redirect a broker to a different canvas home. Choose a different name.',
121
- });
122
- }
123
- if (hasNulByte(value)) {
124
- throw usage('env var value contains a NUL byte, which no OS accepts in an environment variable', {
125
- field: 'value',
126
- next: 'Remove NUL bytes from the piped value and retry.',
127
- });
128
- }
129
- return withProfileManifestLock(profileId, () => {
130
- requireProfileExists(profileId);
131
- const store = readStoreUnlocked(profileId);
132
- const created = !Object.hasOwn(store.vars, name);
133
- store.vars[name] = value;
134
- writeStoreAtomic(profileId, store);
135
- return created;
136
- });
137
- }
138
- /** Remove one variable. Returns whether it was present. */
139
- export function removeProfileEnvVar(profileId, name) {
140
- return withProfileManifestLock(profileId, () => {
141
- requireProfileExists(profileId);
142
- const store = readStoreUnlocked(profileId);
143
- if (!Object.hasOwn(store.vars, name))
144
- return false;
145
- delete store.vars[name];
146
- writeStoreAtomic(profileId, store);
147
- return true;
148
- });
149
- }
150
- /** Every stored `{name: value}` for a profile — the ONLY function that
151
- * returns raw values, called exclusively by `buildBrokerEnv`
152
- * (`core/runtime/spawn-env.ts`) to inject them directly into a launched
153
- * broker's env. Never throws: a stale/invalid/deleted profile id is a
154
- * hot-path no-op (mirrors `readRawProfileConfig` in `core/config.ts`),
155
- * because broker-env resolution must never fail a launch over a bad profile
156
- * id. */
157
- export function readProfileEnvVars(profileId) {
158
- if (profileId === null || profileId === '')
159
- return {};
160
- try {
161
- return { ...readStoreUnlocked(profileId).vars };
162
- }
163
- catch {
164
- return {};
165
- }
166
- }
@@ -1,17 +0,0 @@
1
- import { DatabaseSync } from 'node:sqlite';
2
- import { type ScopedStore } from './paths.js';
3
- export declare const PROFILE_ID_SHAPE: RegExp;
4
- export declare function writeOwnerRoot(db: DatabaseSync, key: string, root: string): void;
5
- export declare function assertScopedStoreReady(store: ScopedStore): void;
6
- export declare function discardIncompleteScopedStore(store: ScopedStore): void;
7
- export declare function withProjectStateLock<T>(root: string, run: () => T): T;
8
- export declare function scopedStoreExists(store: ScopedStore): boolean;
9
- export declare function legacyScopedStorePaths(store: ScopedStore): string[];
10
- export declare function initializeFreshScopedStore(input: {
11
- kind: 'user' | 'project' | 'provider';
12
- root: string;
13
- }): void;
14
- export declare function openScopedStore(store: ScopedStore): DatabaseSync;
15
- export declare function createScopedStore(store: ScopedStore, create: (db: DatabaseSync) => void): void;
16
- export declare function withScopedWrite<T>(store: ScopedStore, run: (db: DatabaseSync) => T): T;
17
- export declare function closeScopedStores(): void;
@@ -1,261 +0,0 @@
1
- import { DatabaseSync } from 'node:sqlite';
2
- import { forgetPrepared } from '../sqlite-statements.js';
3
- import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
- import { join } from 'node:path';
5
- import lockfile from 'proper-lockfile';
6
- import { CONFIG_FILE, PROFILE_DIR, STATE_FILE } from '../../types.js';
7
- import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
8
- import { syncDirectory, syncFile } from '../fs-utils.js';
9
- import { layoutNeedsMigration } from '../layout.js';
10
- import { providerStore, projectStore, userStore } from './paths.js';
11
- import { createScopedSchema, ensureProviderModelCooldowns, PROFILE_ENVIRONMENT_COLUMN, PROVIDER_SCHEMA_VERSION, SCOPED_SCHEMA_VERSION } from './schema.js';
12
- const handles = new Map();
13
- export const PROFILE_ID_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{8}$/;
14
- export function writeOwnerRoot(db, key, root) {
15
- db.prepare('UPDATE scope_owners SET root=? WHERE owner_key=?').run(root, key);
16
- }
17
- function version(db) {
18
- return Number(db.prepare('PRAGMA user_version').get().user_version);
19
- }
20
- function assertVersion(db, store) {
21
- const found = version(db);
22
- if (found === 0)
23
- throw new Error(`scoped SQL store is uninitialized: ${store.path}; crtr was updated and \`crtr sys daemon restart\` converts local state`);
24
- const expected = store.kind === 'provider' ? PROVIDER_SCHEMA_VERSION : SCOPED_SCHEMA_VERSION;
25
- if (found > expected)
26
- throw new Error(`scoped SQL store ${store.path} uses unsupported schema version ${found}`);
27
- if (found !== expected)
28
- throw new Error(`scoped SQL store ${store.path} requires an explicit conversion`);
29
- }
30
- function profilesHaveEnvironmentColumn(db) {
31
- return db.prepare('PRAGMA table_info(profiles)').all().some(({ name }) => name === PROFILE_ENVIRONMENT_COLUMN);
32
- }
33
- function upgradeScopedStore(db, store) {
34
- if (store.kind === 'provider') {
35
- if (version(db) === PROVIDER_SCHEMA_VERSION) {
36
- db.exec('PRAGMA busy_timeout = 5000');
37
- ensureProviderModelCooldowns(db);
38
- }
39
- return;
40
- }
41
- if (version(db) > SCOPED_SCHEMA_VERSION)
42
- return;
43
- db.exec('PRAGMA busy_timeout = 5000');
44
- if (version(db) === SCOPED_SCHEMA_VERSION && profilesHaveEnvironmentColumn(db))
45
- return;
46
- db.exec('BEGIN IMMEDIATE');
47
- try {
48
- if (version(db) === 1) {
49
- const columns = db.prepare('PRAGMA table_info(profiles)').all();
50
- if (!columns.some(({ name }) => name === 'grantee'))
51
- db.exec('ALTER TABLE profiles ADD COLUMN grantee TEXT');
52
- db.exec('PRAGMA user_version = 2');
53
- }
54
- if (version(db) === 2) {
55
- const columns = db.prepare('PRAGMA table_info(profiles)').all();
56
- if (columns.some(({ name }) => name === 'home'))
57
- db.exec('ALTER TABLE profiles DROP COLUMN home');
58
- db.exec('PRAGMA user_version = 3');
59
- }
60
- if (version(db) === 3) {
61
- db.exec("ALTER TABLE profiles ADD COLUMN delivery_limits_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(delivery_limits_json))");
62
- db.exec('PRAGMA user_version = 4');
63
- }
64
- if (version(db) === 4) {
65
- db.exec(`CREATE TABLE IF NOT EXISTS legacy_project_caps (profile_id TEXT NOT NULL, path TEXT NOT NULL, memory TEXT NOT NULL CHECK(memory IN ('none','name','preview')), PRIMARY KEY(profile_id,path))`);
66
- const columns = db.prepare('PRAGMA table_info(profile_projects)').all();
67
- if (columns.some(({ name }) => name === 'memory')) {
68
- db.exec(`INSERT INTO legacy_project_caps SELECT profile_id, path, memory FROM profile_projects WHERE memory != 'content';
69
- ALTER TABLE profile_projects DROP COLUMN memory;`);
70
- }
71
- db.exec(`PRAGMA user_version = ${SCOPED_SCHEMA_VERSION}`);
72
- }
73
- if (version(db) === SCOPED_SCHEMA_VERSION && !profilesHaveEnvironmentColumn(db))
74
- db.exec(`ALTER TABLE profiles ADD COLUMN ${PROFILE_ENVIRONMENT_COLUMN} TEXT`);
75
- db.exec('COMMIT');
76
- }
77
- catch (error) {
78
- db.exec('ROLLBACK');
79
- throw error;
80
- }
81
- }
82
- export function assertScopedStoreReady(store) {
83
- const db = new DatabaseSync(store.path);
84
- try {
85
- upgradeScopedStore(db, store);
86
- assertVersion(db, store);
87
- }
88
- finally {
89
- db.close();
90
- }
91
- }
92
- export function discardIncompleteScopedStore(store) {
93
- if (!scopedStoreExists(store))
94
- return;
95
- const db = new DatabaseSync(store.path, { readOnly: true });
96
- let found;
97
- try {
98
- found = version(db);
99
- }
100
- finally {
101
- db.close();
102
- }
103
- if (found === 0)
104
- for (const path of [store.path, `${store.path}-wal`, `${store.path}-shm`])
105
- rmSync(path, { force: true });
106
- }
107
- export function withProjectStateLock(root, run) {
108
- return withExclusiveDirectoryLock(join(root, '.init.lock'), () => {
109
- const path = join(root, STATE_FILE);
110
- const deadline = Date.now() + 5_000;
111
- let pollMs = 10;
112
- for (;;) {
113
- let release;
114
- try {
115
- release = lockfile.lockSync(path, { realpath: false, retries: 0, stale: 30_000, update: 10_000 });
116
- }
117
- catch (error) {
118
- if (error.code !== 'ELOCKED')
119
- throw error;
120
- const remaining = deadline - Date.now();
121
- if (remaining <= 0)
122
- throw new Error(`timed out waiting for project state conversion lock: ${path}`, { cause: error });
123
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(pollMs, remaining));
124
- pollMs = Math.min(pollMs * 2, 250);
125
- continue;
126
- }
127
- try {
128
- return run();
129
- }
130
- finally {
131
- release();
132
- }
133
- }
134
- });
135
- }
136
- function secure(path) {
137
- if (existsSync(path))
138
- chmodSync(path, 0o600);
139
- for (const suffix of ['-wal', '-shm'])
140
- if (existsSync(`${path}${suffix}`))
141
- chmodSync(`${path}${suffix}`, 0o600);
142
- }
143
- export function scopedStoreExists(store) {
144
- return existsSync(store.path);
145
- }
146
- export function legacyScopedStorePaths(store) {
147
- if (store.kind === 'project')
148
- return [join(store.root, STATE_FILE)];
149
- if (store.kind === 'provider')
150
- return [join(store.root, 'anthropic-subscriptions.json'), join(store.root, 'openai-codex-subscriptions.json'), join(store.root, 'provider-rotation.json')];
151
- const paths = [join(store.root, CONFIG_FILE), join(store.root, STATE_FILE), join(store.root, 'profile-defaults.json')];
152
- const profiles = join(store.root, PROFILE_DIR);
153
- if (existsSync(profiles))
154
- for (const entry of readdirSync(profiles, { withFileTypes: true }))
155
- if (entry.isDirectory() && PROFILE_ID_SHAPE.test(entry.name))
156
- paths.push(join(profiles, entry.name, 'profile.json'), join(profiles, entry.name, CONFIG_FILE), join(profiles, entry.name, STATE_FILE));
157
- return paths;
158
- }
159
- export function initializeFreshScopedStore(input) {
160
- const store = input.kind === 'user' ? userStore(input.root) : input.kind === 'project' ? projectStore(input.root) : providerStore(input.root);
161
- if (store.kind !== 'project')
162
- mkdirSync(store.root, { recursive: true, mode: 0o700 });
163
- const initialize = () => {
164
- if (store.kind === 'project')
165
- discardIncompleteScopedStore(store);
166
- if (scopedStoreExists(store))
167
- return;
168
- const legacyPath = legacyScopedStorePaths(store).find(existsSync);
169
- if (legacyPath !== undefined)
170
- throw new Error(`legacy scoped state exists at ${legacyPath}; crtr was updated and \`crtr sys daemon restart\` converts local state`);
171
- createScopedStore(store, (db) => {
172
- createScopedSchema(db, store.kind);
173
- if (store.kind !== 'provider')
174
- writeOwnerRoot(db, store.kind, store.root);
175
- });
176
- };
177
- if (store.kind === 'project')
178
- withProjectStateLock(store.root, initialize);
179
- else
180
- withExclusiveDirectoryLock(join(store.root, '.init.lock'), initialize);
181
- }
182
- export function openScopedStore(store) {
183
- if (layoutNeedsMigration())
184
- throw new Error('layout_migration_required');
185
- const cached = handles.get(store.path);
186
- if (cached !== undefined)
187
- return cached;
188
- initializeFreshScopedStore(store);
189
- const db = new DatabaseSync(store.path);
190
- try {
191
- db.exec('PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
192
- upgradeScopedStore(db, store);
193
- assertVersion(db, store);
194
- secure(store.path);
195
- handles.set(store.path, db);
196
- return db;
197
- }
198
- catch (error) {
199
- db.close();
200
- throw error;
201
- }
202
- }
203
- export function createScopedStore(store, create) {
204
- if (existsSync(store.path))
205
- throw new Error(`scoped SQL store already exists: ${store.path}`);
206
- if (store.kind !== 'project')
207
- mkdirSync(store.root, { recursive: true, mode: 0o700 });
208
- if (!statSync(store.root).isDirectory())
209
- throw new Error(`scoped SQL root is not a directory: ${store.root}`);
210
- if (store.kind !== 'project' || process.platform !== 'linux')
211
- chmodSync(store.root, 0o700);
212
- writeFileSync(store.path, '', { mode: 0o600 });
213
- const db = new DatabaseSync(store.path);
214
- try {
215
- db.exec('PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; BEGIN IMMEDIATE;');
216
- create(db);
217
- db.exec('COMMIT');
218
- }
219
- catch (error) {
220
- try {
221
- db.exec('ROLLBACK');
222
- }
223
- catch { }
224
- throw error;
225
- }
226
- finally {
227
- secure(store.path);
228
- db.close();
229
- }
230
- // Closing checkpoints the WAL into the database file. Force the finished file and its
231
- // directory entry to stable storage so a volume handed off right after first boot never
232
- // comes back with the empty, schema-less file that `scoped SQL store is uninitialized` names.
233
- syncFile(store.path);
234
- syncDirectory(store.root);
235
- }
236
- export function withScopedWrite(store, run) {
237
- const db = openScopedStore(store);
238
- if (db.isTransaction)
239
- return run(db);
240
- db.exec('BEGIN IMMEDIATE');
241
- try {
242
- const value = run(db);
243
- db.exec('COMMIT');
244
- secure(store.path);
245
- return value;
246
- }
247
- catch (error) {
248
- try {
249
- db.exec('ROLLBACK');
250
- }
251
- catch { }
252
- throw error;
253
- }
254
- }
255
- export function closeScopedStores() {
256
- for (const db of handles.values()) {
257
- forgetPrepared(db);
258
- db.close();
259
- }
260
- handles.clear();
261
- }
@@ -1,8 +0,0 @@
1
- export { initializeFreshScopedStore } from './db.js';
2
- /** Conversion is intentionally explicit. Deployment coordination must prove all legacy writers at these shared roots are stopped before calling it. */
3
- export declare function convertLegacyScopedStore(input: {
4
- kind: 'user' | 'project' | 'provider';
5
- root: string;
6
- }): void;
7
- /** Converts legacy provider pools only after the activation owner has stopped every writer sharing this agent directory. */
8
- export declare function convertLegacyProviderStore(agentDir: string): Promise<void>;