@gotcos/glasses-server 6.79.2 → 6.80.0
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/.env.example +9 -0
- package/CHANGELOG.md +19 -0
- package/README.md +10 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +9 -1
- package/server/index.ts +11 -0
- package/server/lib/agent-session-search.ts +8 -5
- package/server/lib/agent-session-store.ts +45 -4
- package/server/lib/archive.ts +2 -1
- package/server/lib/attached-provider-adapter.ts +11 -0
- package/server/lib/claude-bridge.ts +112 -8
- package/server/lib/claude-settings-guard.ts +94 -0
- package/server/lib/claude-steer.ts +27 -0
- package/server/lib/codex-bridge.ts +205 -32
- package/server/lib/codex-engine-sessions.ts +27 -6
- package/server/lib/codex-homes.ts +134 -0
- package/server/lib/codex-live-queue.ts +14 -0
- package/server/lib/codex-profile-home.ts +452 -0
- package/server/lib/codex-queue-control.ts +17 -1
- package/server/lib/codex-subagent-activity.ts +22 -0
- package/server/lib/conversation.ts +92 -15
- package/server/lib/direct-bridge.ts +853 -0
- package/server/lib/direct-sse.ts +262 -0
- package/server/lib/fork-thread.ts +11 -0
- package/server/lib/key-answer-fields.ts +49 -0
- package/server/lib/key-catalog.ts +301 -0
- package/server/lib/key-lanes-pricing.ts +12 -0
- package/server/lib/key-lanes-wiring.ts +346 -0
- package/server/lib/key-lanes.ts +145 -0
- package/server/lib/key-ledger.ts +123 -0
- package/server/lib/key-notices.ts +486 -0
- package/server/lib/key-profile-rejects.ts +25 -0
- package/server/lib/key-profile-routing.ts +173 -0
- package/server/lib/key-profiles.ts +182 -0
- package/server/lib/key-relay-contract.ts +262 -0
- package/server/lib/key-relay-errors.ts +211 -0
- package/server/lib/key-relay-meter.ts +303 -0
- package/server/lib/key-relay.ts +1018 -0
- package/server/lib/key-secrets-guard.ts +52 -0
- package/server/lib/key-store.ts +228 -0
- package/server/lib/keyed-sessions.ts +154 -0
- package/server/lib/lane-b-env.ts +169 -0
- package/server/lib/model-router.ts +96 -7
- package/server/lib/morning-brief-config.ts +3 -0
- package/server/lib/native-head.ts +13 -1
- package/server/lib/occupancy-probes.ts +4 -0
- package/server/lib/ollama-run-ledger.ts +96 -13
- package/server/lib/prompt-edit.ts +2 -0
- package/server/lib/query-job-coordinator.ts +2 -1
- package/server/lib/query-job-runtime.ts +67 -15
- package/server/lib/query-job-store.ts +7 -1
- package/server/lib/query-job-types.ts +15 -1
- package/server/lib/session-hook-events.ts +28 -0
- package/server/lib/session-hook-ledger.ts +10 -0
- package/server/lib/thread-occupancy.ts +22 -1
- package/server/lib/token-audit.ts +18 -4
- package/server/lib/work-handoff-requests.ts +3 -0
- package/server/routes/agent-session-bindings.ts +15 -0
- package/server/routes/agent-sessions.ts +17 -5
- package/server/routes/handoffs.ts +22 -1
- package/server/routes/health.ts +5 -0
- package/server/routes/key-lanes.ts +442 -0
- package/server/routes/openai-compat.ts +28 -3
- package/server/routes/query.ts +20 -6
- package/shared/key-preference.ts +57 -0
- package/shared/model-preference.ts +20 -2
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Key lanes K1 P4 (plan v4 8.2): every Codex home a reader must look in.
|
|
2
|
+
//
|
|
3
|
+
// A keyed (Lane B) Codex turn runs in its own home, `data/codex-homes/<profileId>/`, so its
|
|
4
|
+
// rollouts, writer locks and queue database never land in the user's `~/.codex`. Readers that
|
|
5
|
+
// FIND threads (the session list, the native head, the queue counts, occupancy, hook events)
|
|
6
|
+
// look in every home; readers of config, catalog or models stay on the server home.
|
|
7
|
+
//
|
|
8
|
+
// codexHomes(serverHome) the server home FIRST, then each profile home. With key lanes off
|
|
9
|
+
// or no profile home on disk it is exactly `[serverHome]`, so every
|
|
10
|
+
// reader built on it is identical to 963fe25.
|
|
11
|
+
//
|
|
12
|
+
// HARD RULE (zero impact without a key): nothing here creates a file or a directory. A profile
|
|
13
|
+
// home is listed only while key lanes are enabled AND `data/codex-homes/` exists. Importing this
|
|
14
|
+
// module reads nothing.
|
|
15
|
+
import { lstatSync, readdirSync } from 'node:fs'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { join, resolve, sep } from 'node:path'
|
|
18
|
+
import { dataPath } from './data-dir.js'
|
|
19
|
+
import { keyLanesEnabled } from './key-lanes.js'
|
|
20
|
+
import { isKeyProfileId } from '../../shared/key-preference.js'
|
|
21
|
+
|
|
22
|
+
/** The directory under the data dir. P1's Remove all keys deletes it (`routes/key-lanes.ts`). */
|
|
23
|
+
export const CODEX_PROFILE_HOMES_DIR = 'codex-homes'
|
|
24
|
+
/** A bound on how many profile homes one read lists (the profile store caps at 20). */
|
|
25
|
+
export const MAX_CODEX_PROFILE_HOMES = 64
|
|
26
|
+
|
|
27
|
+
/** The server's own Codex home: `CODEX_HOME`, else `~/.codex` (as the hooks installer reads it). */
|
|
28
|
+
export function serverCodexHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
29
|
+
const raw = env.CODEX_HOME?.trim()
|
|
30
|
+
return raw ? resolve(raw) : join(homedir(), '.codex')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function codexProfileHomesRoot(): string {
|
|
34
|
+
return dataPath(CODEX_PROFILE_HOMES_DIR)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `data/codex-homes/<id>`. Throws on a malformed id (a caller bug, never user input). */
|
|
38
|
+
export function codexProfileHome(profileId: string): string {
|
|
39
|
+
if (!isKeyProfileId(profileId)) throw new Error('invalid key profile id')
|
|
40
|
+
return join(codexProfileHomesRoot(), profileId)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CodexProfileHomeEntry {
|
|
44
|
+
profileId: string
|
|
45
|
+
home: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Profile homes on disk, sorted by id. Empty unless key lanes are enabled and the root exists.
|
|
50
|
+
* A symlink, a file or a badly named entry is skipped, never followed.
|
|
51
|
+
*/
|
|
52
|
+
export function profileCodexHomes(env: NodeJS.ProcessEnv = process.env): CodexProfileHomeEntry[] {
|
|
53
|
+
if (!keyLanesEnabled(env)) return []
|
|
54
|
+
const root = codexProfileHomesRoot()
|
|
55
|
+
let names: string[]
|
|
56
|
+
try {
|
|
57
|
+
if (!lstatSync(root).isDirectory()) return []
|
|
58
|
+
names = readdirSync(root)
|
|
59
|
+
} catch {
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
62
|
+
const out: CodexProfileHomeEntry[] = []
|
|
63
|
+
for (const name of names.sort()) {
|
|
64
|
+
if (!isKeyProfileId(name)) continue
|
|
65
|
+
const home = join(root, name)
|
|
66
|
+
try { if (!lstatSync(home).isDirectory()) continue } catch { continue }
|
|
67
|
+
out.push({ profileId: name, home })
|
|
68
|
+
if (out.length >= MAX_CODEX_PROFILE_HOMES) break
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every Codex home, the server home first. A reader passes its own server home (some honour
|
|
75
|
+
* `COS_AGENT_SESSIONS_HOME`, some `CODEX_HOME`) so the first entry is exactly what it read before.
|
|
76
|
+
*/
|
|
77
|
+
export function codexHomes(serverHome: string = serverCodexHome(), env: NodeJS.ProcessEnv = process.env): string[] {
|
|
78
|
+
return [serverHome, ...profileCodexHomes(env).map(entry => entry.home)]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The profile homes only, joined with a subpath (`sessions`, `thread-writer-locks`). */
|
|
82
|
+
export function profileCodexHomeSubdirs(sub: string, env: NodeJS.ProcessEnv = process.env): string[] {
|
|
83
|
+
return profileCodexHomes(env).map(entry => join(entry.home, sub))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The profile id a path lives under (`data/codex-homes/<id>/...`), or undefined. Prefix-exact on
|
|
88
|
+
* the real root, so a user folder that happens to be called `codex-homes` never matches.
|
|
89
|
+
*/
|
|
90
|
+
export function profileIdForCodexPath(path: unknown): string | undefined {
|
|
91
|
+
if (typeof path !== 'string' || !path) return undefined
|
|
92
|
+
const root = codexProfileHomesRoot() + sep
|
|
93
|
+
if (!path.startsWith(root)) return undefined
|
|
94
|
+
const id = path.slice(root.length).split(sep)[0]
|
|
95
|
+
return isKeyProfileId(id) ? id : undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ROLLOUT_ID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i
|
|
99
|
+
const MAX_DIR_ENTRIES = 512
|
|
100
|
+
const MAX_FILES = 20_000
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The profile whose home holds this Codex thread's rollout, or undefined. Walks
|
|
104
|
+
* `sessions/<yyyy>/<mm>/<dd>/rollout-*-<id>.jsonl` in each profile home, bounded like
|
|
105
|
+
* native-head's walk. With no profile home it returns at once without touching the disk.
|
|
106
|
+
*/
|
|
107
|
+
export function profileForCodexThread(threadId: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
108
|
+
const homes = profileCodexHomes(env)
|
|
109
|
+
if (homes.length === 0) return undefined
|
|
110
|
+
const needle = typeof threadId === 'string' ? threadId.trim().toLowerCase() : ''
|
|
111
|
+
if (!/^[0-9a-f-]{36}$/.test(needle)) return undefined
|
|
112
|
+
const list = (dir: string): string[] => {
|
|
113
|
+
try {
|
|
114
|
+
const names = readdirSync(dir)
|
|
115
|
+
return names.length > MAX_DIR_ENTRIES ? [] : names
|
|
116
|
+
} catch { return [] }
|
|
117
|
+
}
|
|
118
|
+
for (const { profileId, home } of homes) {
|
|
119
|
+
let scanned = 0
|
|
120
|
+
const root = join(home, 'sessions')
|
|
121
|
+
for (const y of list(root)) for (const m of list(join(root, y))) for (const d of list(join(root, y, m))) {
|
|
122
|
+
for (const name of list(join(root, y, m, d))) {
|
|
123
|
+
if (++scanned > MAX_FILES) break
|
|
124
|
+
if (!name.startsWith('rollout-')) continue
|
|
125
|
+
if (ROLLOUT_ID_RE.exec(name)?.[1]?.toLowerCase() === needle) return profileId
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return undefined
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function isProfileCodexThread(threadId: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
133
|
+
return profileForCodexThread(threadId, env) !== undefined
|
|
134
|
+
}
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
// sentence into the thread twice. A clap usage error (exit 2) queued nothing.
|
|
55
55
|
|
|
56
56
|
import { isValidNativeThreadId } from './native-thread-id.js'
|
|
57
|
+
import { isProfileCodexThread } from './codex-homes.js'
|
|
57
58
|
|
|
58
59
|
export type CodexLiveReason =
|
|
59
60
|
/** The row is in the holder's queue. */
|
|
@@ -71,6 +72,11 @@ export type CodexLiveReason =
|
|
|
71
72
|
| 'refused'
|
|
72
73
|
/** Something may be queued. Hold; never retry, never spawn over it. */
|
|
73
74
|
| 'unverified'
|
|
75
|
+
/**
|
|
76
|
+
* Key lanes K1 (plan v4 8.1): the thread lives in a keyed profile home, never the Codex app's
|
|
77
|
+
* queue. Nothing sent; the steer lands after the running turn (CODEX_KEYED_STEER_NOTICE).
|
|
78
|
+
*/
|
|
79
|
+
| 'keyed_session'
|
|
74
80
|
|
|
75
81
|
export interface CodexLiveResult {
|
|
76
82
|
ok: boolean
|
|
@@ -100,8 +106,13 @@ export interface CodexLiveDeps {
|
|
|
100
106
|
run: (binary: string, args: readonly string[], timeoutMs: number) => Promise<CodexQueueRun>
|
|
101
107
|
now: () => number
|
|
102
108
|
log?: (level: 'info' | 'warn', event: string, detail: Record<string, unknown>) => void
|
|
109
|
+
/** Key lanes: is this thread a keyed profile thread? Defaults to a look in the profile homes. */
|
|
110
|
+
isKeyedThread?: (threadId: string) => boolean
|
|
103
111
|
}
|
|
104
112
|
|
|
113
|
+
/** Plan 8.1 lens line for a steer into a keyed Codex session. */
|
|
114
|
+
export const CODEX_KEYED_STEER_NOTICE = 'Steer lands after this turn.'
|
|
115
|
+
|
|
105
116
|
export interface CodexLiveRequest {
|
|
106
117
|
provider: string
|
|
107
118
|
sessionId: string
|
|
@@ -182,6 +193,9 @@ export async function deliverOverCodexQueue(request: CodexLiveRequest, deps: Cod
|
|
|
182
193
|
})
|
|
183
194
|
if (!request.enabled) return done('disabled')
|
|
184
195
|
if (request.provider !== 'codex' || request.foreignHolder !== true) return done('no_holder')
|
|
196
|
+
// Key lanes: a keyed profile thread is never the Codex app's, so it never joins its queue.
|
|
197
|
+
// With no profile home this is one predicate call that touches nothing.
|
|
198
|
+
if ((deps.isKeyedThread ?? isProfileCodexThread)(request.sessionId)) return done('keyed_session')
|
|
185
199
|
let args: string[]
|
|
186
200
|
try {
|
|
187
201
|
args = buildCodexQueueArgs(request.sessionId, request.prompt)
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
// Key lanes K1 P4 (plan v4 8.1, D9): the generated Codex home a keyed (Lane B) Codex turn runs in.
|
|
2
|
+
//
|
|
3
|
+
// A keyed Codex turn never runs in the user's `~/.codex`. Canary C2d (2026-10-19): even with
|
|
4
|
+
// `-c` overrides on the real home, Codex rewrote `auth.json` (a ChatGPT token refresh) during a
|
|
5
|
+
// key run. So each profile gets `data/codex-homes/<id>/` (0700), regenerated before every turn:
|
|
6
|
+
//
|
|
7
|
+
// config.toml the loopback relay as the ONLY provider (`wire_api = "responses"`, C2a/C2b; the
|
|
8
|
+
// chat wire is refused at config load), retries 2, plus these tables copied
|
|
9
|
+
// read-only from the server home's config: `[mcp_servers.*]` (D9), `[projects.*]`
|
|
10
|
+
// (folder trust, so project hooks and config load as they do on Lane A),
|
|
11
|
+
// `[shell_environment_policy*]`, `[sandbox_workspace_write]`, and the hook trust
|
|
12
|
+
// entries that still describe the hooks written here.
|
|
13
|
+
// hooks.json the COS hook blocks, from the installer's own writer, for exactly the events the
|
|
14
|
+
// user's `~/.codex/hooks.json` carries ours for (mirror, never more).
|
|
15
|
+
// auth.json NEVER. One found there is removed before the turn: the key reaches Codex only as
|
|
16
|
+
// a turn nonce in `COS_RELAY_TOKEN`, and the real key only ever lives in the relay.
|
|
17
|
+
//
|
|
18
|
+
// The user's `~/.codex` is read, never written. HARD RULE: nothing here runs unless a caller has
|
|
19
|
+
// a key profile in hand; importing this module reads no file, opens no socket and starts no timer.
|
|
20
|
+
import { createHash } from 'node:crypto'
|
|
21
|
+
import { chmodSync, lstatSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs'
|
|
22
|
+
import { dirname, join, resolve as resolvePath } from 'node:path'
|
|
23
|
+
import { homedir } from 'node:os'
|
|
24
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
25
|
+
import { currentHookPaths, type HookPaths } from './claude-hooks-installer.js'
|
|
26
|
+
import { codexSubscribedEvents, mergeCodexHooks, stableCodexHookScriptPath } from './codex-hooks-installer.js'
|
|
27
|
+
import { codexProfileHome, codexProfileHomesRoot, serverCodexHome } from './codex-homes.js'
|
|
28
|
+
import { CODEX_KEYED_STEER_NOTICE } from './codex-live-queue.js'
|
|
29
|
+
import { keyLanesActive } from './key-lanes.js'
|
|
30
|
+
import { KEY_LANE_REFUSAL_COPY } from './key-notices.js'
|
|
31
|
+
import { isLaneBStrippedName, LANE_B_STRIPPED_NAMES } from './lane-b-env.js'
|
|
32
|
+
import { engineModelProblem } from './key-profiles.js'
|
|
33
|
+
import { KEY_LANE_CHILD_ENV, RELAY_BASE_PATHS } from './key-relay-contract.js'
|
|
34
|
+
import type { KeyKind, KeyProfile, KeyRelay } from './key-relay-contract.js'
|
|
35
|
+
import { getPublicKey } from './key-store.js'
|
|
36
|
+
|
|
37
|
+
/** The provider id the generated config defines and argv selects. */
|
|
38
|
+
export const CODEX_RELAY_PROVIDER_ID = 'cos-relay'
|
|
39
|
+
/** Plan 4.6: the child retries a 429 or 5xx twice; the relay never retries itself. R15 checks the names. */
|
|
40
|
+
export const CODEX_RELAY_RETRIES = 2
|
|
41
|
+
/**
|
|
42
|
+
* Canary R14 / R15: Codex multiplies the two (each stream reconnect runs its own request retries:
|
|
43
|
+
* 7 to 9 upstream calls per failure). Stream reconnects are off, so one failure costs at most
|
|
44
|
+
* 1 + CODEX_RELAY_RETRIES = 3 attempts, the plan 4.6 budget.
|
|
45
|
+
*/
|
|
46
|
+
export const CODEX_RELAY_STREAM_RETRIES = 0
|
|
47
|
+
/** Top-level config tables copied from the server home (first key segment). */
|
|
48
|
+
export const CODEX_COPIED_TABLES: readonly string[] = ['mcp_servers', 'projects', 'shell_environment_policy', 'sandbox_workspace_write']
|
|
49
|
+
|
|
50
|
+
/** Call options a keyed Codex turn adds. Same field name and type P3 adds to CallOptions. */
|
|
51
|
+
export interface CodexKeyLaneCallOptions {
|
|
52
|
+
keyProfile?: KeyProfile
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Child environment (plan 7.2 base, 8.1 additions) ────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Provider-credential names a Lane B child never inherits: P3's one list (lane-b-env.ts, deduped by
|
|
59
|
+
* P11), plus `CODEX_HOME`, which the profile home replaces. Every other name is kept.
|
|
60
|
+
*/
|
|
61
|
+
export const CODEX_LANE_B_STRIPPED_NAMES: readonly string[] = [...LANE_B_STRIPPED_NAMES, 'CODEX_HOME']
|
|
62
|
+
|
|
63
|
+
export function isCodexLaneBStrippedName(name: string): boolean {
|
|
64
|
+
return name === 'CODEX_HOME' || isLaneBStrippedName(name)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The names applyCodexLaneBEnv sets. With the stripped names this is the whole A-vs-B difference. */
|
|
68
|
+
export const CODEX_LANE_B_ADDED_NAMES: readonly string[] = [
|
|
69
|
+
'CODEX_HOME',
|
|
70
|
+
KEY_LANE_CHILD_ENV.codexToken,
|
|
71
|
+
KEY_LANE_CHILD_ENV.profileMarker,
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
/** Point a child env copy (never process.env) at the profile home and the relay nonce. */
|
|
75
|
+
export function applyCodexLaneBEnv(env: NodeJS.ProcessEnv, spec: { home: string; nonce: string; profileId: string }): void {
|
|
76
|
+
if (env === process.env) throw new Error('codex-profile-home: refusing to write process.env')
|
|
77
|
+
if (!spec.home || !spec.nonce || !spec.profileId) throw new Error('codex-profile-home: incomplete spec')
|
|
78
|
+
for (const name of Object.keys(env)) {
|
|
79
|
+
if (isCodexLaneBStrippedName(name)) delete env[name]
|
|
80
|
+
}
|
|
81
|
+
env.CODEX_HOME = spec.home
|
|
82
|
+
env[KEY_LANE_CHILD_ENV.codexToken] = spec.nonce
|
|
83
|
+
env[KEY_LANE_CHILD_ENV.profileMarker] = spec.profileId
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Seams (P2 relay, P3 keyed-session record). P11 wires both. ───────────────
|
|
87
|
+
|
|
88
|
+
type RelayResolver = () => KeyRelay | Promise<KeyRelay>
|
|
89
|
+
let relayResolver: RelayResolver | null = null
|
|
90
|
+
|
|
91
|
+
export function registerCodexKeyRelay(resolver: RelayResolver | null): void {
|
|
92
|
+
relayResolver = resolver
|
|
93
|
+
}
|
|
94
|
+
export function codexKeyRelayRegistered(): boolean {
|
|
95
|
+
return relayResolver !== null
|
|
96
|
+
}
|
|
97
|
+
export async function codexKeyRelay(): Promise<KeyRelay> {
|
|
98
|
+
if (!relayResolver) throw new Error('codex-profile-home: no key relay registered')
|
|
99
|
+
return await relayResolver()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface KeyedCodexThread {
|
|
103
|
+
id: string
|
|
104
|
+
engine: 'codex'
|
|
105
|
+
profileId: string
|
|
106
|
+
cosSessionId: string
|
|
107
|
+
}
|
|
108
|
+
type KeyedThreadRecorder = (thread: KeyedCodexThread) => void
|
|
109
|
+
let keyedThreadRecorder: KeyedThreadRecorder | null = null
|
|
110
|
+
|
|
111
|
+
/** Plan 7.3: P11 points this at P3's `recordKeyedSession`. */
|
|
112
|
+
export function registerCodexKeyedThreadRecorder(recorder: KeyedThreadRecorder | null): void {
|
|
113
|
+
keyedThreadRecorder = recorder
|
|
114
|
+
}
|
|
115
|
+
export function recordKeyedCodexThread(thread: KeyedCodexThread): void {
|
|
116
|
+
try { keyedThreadRecorder?.(thread) } catch (err) {
|
|
117
|
+
console.error(`[codex-profile-home] keyed thread record failed: ${err instanceof Error ? err.message.slice(0, 120) : 'unknown'}`)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Tests only. */
|
|
122
|
+
export function __resetCodexProfileSeamsForTests(): void {
|
|
123
|
+
relayResolver = null
|
|
124
|
+
keyedThreadRecorder = null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Lens copy (plan 11.3). P8's key-notices.ts is the single source once merged. ─────────────
|
|
128
|
+
|
|
129
|
+
export const CODEX_LANE_B_COPY = {
|
|
130
|
+
lanesOff: KEY_LANE_REFUSAL_COPY.lanesOff,
|
|
131
|
+
profileOff: KEY_LANE_REFUSAL_COPY.profileOff,
|
|
132
|
+
wrongEngine: KEY_LANE_REFUSAL_COPY.wrongEngineCodex,
|
|
133
|
+
keyMissing: KEY_LANE_REFUSAL_COPY.keyMissing,
|
|
134
|
+
modelNotAllowed: KEY_LANE_REFUSAL_COPY.codexModelNotAllowed,
|
|
135
|
+
scheduled: KEY_LANE_REFUSAL_COPY.scheduled,
|
|
136
|
+
relayUnavailable: KEY_LANE_REFUSAL_COPY.relayUnavailable,
|
|
137
|
+
/** Plan 8.1: a keyed Codex session never joins the Codex app queue. */
|
|
138
|
+
steerAfterTurn: CODEX_KEYED_STEER_NOTICE,
|
|
139
|
+
} as const
|
|
140
|
+
|
|
141
|
+
export type CodexProfilePreflight = { ok: true; keyKind: KeyKind } | { ok: false; message: string }
|
|
142
|
+
|
|
143
|
+
/** Project-config names that could point a keyed Codex turn at a provider other than the relay. */
|
|
144
|
+
const CODEX_PROJECT_PROVIDER_RE = /^\s*(?:\[\s*model_providers\b|(?:model_provider|model_providers|openai_base_url|chatgpt_base_url)\s*[=.])/m
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* QA W4: a trusted project's `.codex/config.toml` is merged over the profile home's config, so
|
|
148
|
+
* one that sets a provider (or redefines `cos-relay`) could send a keyed turn around the meter.
|
|
149
|
+
* Walks from the working directory up to the repository root (a `.git` entry) or the filesystem
|
|
150
|
+
* root, never into the user's home itself (that `.codex` is the global config the profile home
|
|
151
|
+
* replaces). Returns the file and the name it sets, or null. Read only on a keyed turn.
|
|
152
|
+
*/
|
|
153
|
+
export function codexProjectConfigRefusal(cwd: string, home: string = homedir()): { file: string; name: string } | null {
|
|
154
|
+
let dir = resolvePath(cwd)
|
|
155
|
+
for (let i = 0; i < 32; i++) {
|
|
156
|
+
if (dir !== home) {
|
|
157
|
+
const file = join(dir, '.codex', 'config.toml')
|
|
158
|
+
try {
|
|
159
|
+
if (lstatSync(file).isFile()) {
|
|
160
|
+
const m = CODEX_PROJECT_PROVIDER_RE.exec(readFileSync(file, 'utf8'))
|
|
161
|
+
if (m) return { file, name: m[0].replace(/[\s\[=.]/g, '') || 'model_provider' }
|
|
162
|
+
}
|
|
163
|
+
} catch { /* none here */ }
|
|
164
|
+
try { if (lstatSync(join(dir, '.git'))) break } catch { /* not the repo root */ }
|
|
165
|
+
}
|
|
166
|
+
const up = dirname(dir)
|
|
167
|
+
if (up === dir) break
|
|
168
|
+
dir = up
|
|
169
|
+
}
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Everything a keyed Codex turn passes before history, the run ledger or a process. Fails closed. */
|
|
174
|
+
export function codexProfilePreflight(input: { profile: KeyProfile; dispatch: boolean; cwd?: string }): CodexProfilePreflight {
|
|
175
|
+
const { profile } = input
|
|
176
|
+
if (!keyLanesActive()) return { ok: false, message: CODEX_LANE_B_COPY.lanesOff }
|
|
177
|
+
if (profile.engine !== 'codex') return { ok: false, message: CODEX_LANE_B_COPY.wrongEngine(profile.label) }
|
|
178
|
+
if (!profile.enabled) return { ok: false, message: CODEX_LANE_B_COPY.profileOff(profile.label) }
|
|
179
|
+
if (input.dispatch) return { ok: false, message: CODEX_LANE_B_COPY.scheduled }
|
|
180
|
+
const key = getPublicKey(profile.keyRef)
|
|
181
|
+
if (!key) return { ok: false, message: CODEX_LANE_B_COPY.keyMissing(profile.label) }
|
|
182
|
+
if (engineModelProblem('codex', profile.model, key.kind)) return { ok: false, message: CODEX_LANE_B_COPY.modelNotAllowed(profile.label) }
|
|
183
|
+
const project = input.cwd ? codexProjectConfigRefusal(input.cwd) : null
|
|
184
|
+
if (project) return { ok: false, message: KEY_LANE_REFUSAL_COPY.codexProjectConfig(project.name) }
|
|
185
|
+
if (!codexKeyRelayRegistered()) return { ok: false, message: CODEX_LANE_B_COPY.relayUnavailable }
|
|
186
|
+
return { ok: true, keyKind: key.kind }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── TOML, just enough (the server has no TOML dependency) ───────────────────
|
|
190
|
+
|
|
191
|
+
const BARE_KEY_RE = /^[A-Za-z0-9_-]+$/
|
|
192
|
+
|
|
193
|
+
/** A dotted table key (`a."b c".d`) to its segments, or null when it is not a key. */
|
|
194
|
+
export function parseTomlKeyPath(inner: string): string[] | null {
|
|
195
|
+
const segs: string[] = []
|
|
196
|
+
let i = 0
|
|
197
|
+
const s = inner.trim()
|
|
198
|
+
const skipWs = () => { while (i < s.length && (s[i] === ' ' || s[i] === '\t')) i++ }
|
|
199
|
+
while (i < s.length) {
|
|
200
|
+
skipWs()
|
|
201
|
+
if (s[i] === '"') {
|
|
202
|
+
let out = ''
|
|
203
|
+
i++
|
|
204
|
+
for (;;) {
|
|
205
|
+
if (i >= s.length) return null
|
|
206
|
+
const ch = s[i]
|
|
207
|
+
if (ch === '"') { i++; break }
|
|
208
|
+
if (ch === '\\') {
|
|
209
|
+
const nx = s[i + 1]
|
|
210
|
+
const map: Record<string, string> = { '"': '"', '\\': '\\', n: '\n', t: '\t', r: '\r', b: '\b', f: '\f' }
|
|
211
|
+
if (nx && nx in map) { out += map[nx]; i += 2; continue }
|
|
212
|
+
if (nx === 'u' || nx === 'U') {
|
|
213
|
+
const len = nx === 'u' ? 4 : 8
|
|
214
|
+
const hex = s.slice(i + 2, i + 2 + len)
|
|
215
|
+
if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length !== len) return null
|
|
216
|
+
out += String.fromCodePoint(parseInt(hex, 16))
|
|
217
|
+
i += 2 + len
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
return null
|
|
221
|
+
}
|
|
222
|
+
out += ch
|
|
223
|
+
i++
|
|
224
|
+
}
|
|
225
|
+
segs.push(out)
|
|
226
|
+
} else if (s[i] === "'") {
|
|
227
|
+
const end = s.indexOf("'", i + 1)
|
|
228
|
+
if (end < 0) return null
|
|
229
|
+
segs.push(s.slice(i + 1, end))
|
|
230
|
+
i = end + 1
|
|
231
|
+
} else {
|
|
232
|
+
const m = /^[A-Za-z0-9_-]+/.exec(s.slice(i))
|
|
233
|
+
if (!m) return null
|
|
234
|
+
segs.push(m[0])
|
|
235
|
+
i += m[0].length
|
|
236
|
+
}
|
|
237
|
+
skipWs()
|
|
238
|
+
if (i >= s.length) break
|
|
239
|
+
if (s[i] !== '.') return null
|
|
240
|
+
i++
|
|
241
|
+
}
|
|
242
|
+
return segs.length ? segs : null
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function encodeTomlKeySegment(seg: string): string {
|
|
246
|
+
return BARE_KEY_RE.test(seg) ? seg : tomlBasicString(seg)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** A TOML basic string, always quoted. */
|
|
250
|
+
export function tomlBasicString(seg: string): string {
|
|
251
|
+
let out = '"'
|
|
252
|
+
for (const ch of seg) {
|
|
253
|
+
const cp = ch.codePointAt(0)!
|
|
254
|
+
if (ch === '"') out += '\\"'
|
|
255
|
+
else if (ch === '\\') out += '\\\\'
|
|
256
|
+
else if (cp < 0x20 || cp === 0x7f) out += `\\u${cp.toString(16).padStart(4, '0')}`
|
|
257
|
+
else out += ch
|
|
258
|
+
}
|
|
259
|
+
return out + '"'
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface TomlSection {
|
|
263
|
+
/** Null for the leading top-level keys, before any header. */
|
|
264
|
+
path: string[] | null
|
|
265
|
+
arrayTable: boolean
|
|
266
|
+
header: string
|
|
267
|
+
body: string[]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const HEADER_RE = /^\s*(\[\[?)\s*(.+?)\s*(\]\]?)\s*(#.*)?$/
|
|
271
|
+
|
|
272
|
+
/** Split TOML text into sections at table headers. Multi-line strings never split a section. */
|
|
273
|
+
export function splitTomlSections(text: string): TomlSection[] {
|
|
274
|
+
const sections: TomlSection[] = [{ path: null, arrayTable: false, header: '', body: [] }]
|
|
275
|
+
let inMulti: '"""' | "'''" | null = null
|
|
276
|
+
for (const line of text.split(/\r?\n/)) {
|
|
277
|
+
if (inMulti) {
|
|
278
|
+
sections[sections.length - 1]!.body.push(line)
|
|
279
|
+
if ((line.split(inMulti).length - 1) % 2 === 1) inMulti = null
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
const m = HEADER_RE.exec(line)
|
|
283
|
+
const open = m?.[1], close = m?.[3]
|
|
284
|
+
const path = m && ((open === '[' && close === ']') || (open === '[[' && close === ']]')) ? parseTomlKeyPath(m[2]!) : null
|
|
285
|
+
if (m && path) {
|
|
286
|
+
sections.push({ path, arrayTable: open === '[[', header: line, body: [] })
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
sections[sections.length - 1]!.body.push(line)
|
|
290
|
+
for (const q of ['"""', "'''"] as const) {
|
|
291
|
+
if ((line.split(q).length - 1) % 2 === 1) { inMulti = q; break }
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return sections
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function headerFor(path: string[], arrayTable: boolean): string {
|
|
298
|
+
const name = path.map(encodeTomlKeySegment).join('.')
|
|
299
|
+
return arrayTable ? `[[${name}]]` : `[${name}]`
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function camelToSnake(event: string): string {
|
|
303
|
+
return event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const HOOK_TRUST_KEY_RE = /^(.*):([a-z_]+):(\d+):(\d+)$/
|
|
307
|
+
|
|
308
|
+
// ── The generated home ───────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
export interface CodexProfileHomeSources {
|
|
311
|
+
/** The server's Codex home, read only. Default `CODEX_HOME` or `~/.codex`. */
|
|
312
|
+
serverHome?: string
|
|
313
|
+
scriptPath?: string
|
|
314
|
+
hookPaths?: HookPaths
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface RenderedCodexProfileHome {
|
|
318
|
+
profileId: string
|
|
319
|
+
home: string
|
|
320
|
+
/** config.toml with the relay origin still a placeholder. */
|
|
321
|
+
configTemplate: string
|
|
322
|
+
hooksText: string
|
|
323
|
+
/** Plan 8.1 resume binding: `profile:<id>:<sha>` over the template and hooks (never the port). */
|
|
324
|
+
engineFingerprint: string
|
|
325
|
+
copiedTables: string[]
|
|
326
|
+
mirroredHookEvents: string[]
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const ORIGIN_PLACEHOLDER = '__COS_RELAY_ORIGIN__'
|
|
330
|
+
|
|
331
|
+
function readTextOrNull(path: string): string | null {
|
|
332
|
+
try {
|
|
333
|
+
if (lstatSync(path).isSymbolicLink()) return null
|
|
334
|
+
return readFileSync(path, 'utf8')
|
|
335
|
+
} catch { return null }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Pure apart from READING the server home's config.toml and hooks.json. Writes nothing. */
|
|
339
|
+
export function renderCodexProfileHome(profile: KeyProfile, sources: CodexProfileHomeSources = {}): RenderedCodexProfileHome {
|
|
340
|
+
const serverHome = sources.serverHome ?? serverCodexHome()
|
|
341
|
+
const home = codexProfileHome(profile.id)
|
|
342
|
+
const scriptPath = sources.scriptPath ?? stableCodexHookScriptPath()
|
|
343
|
+
const hookPaths = sources.hookPaths ?? currentHookPaths()
|
|
344
|
+
const serverHooksPath = join(serverHome, 'hooks.json')
|
|
345
|
+
const profileHooksPath = join(home, 'hooks.json')
|
|
346
|
+
|
|
347
|
+
// hooks.json: our canonical blocks, for exactly the events the user's file has ours for.
|
|
348
|
+
let userHooks: unknown = {}
|
|
349
|
+
const userHooksText = readTextOrNull(serverHooksPath)
|
|
350
|
+
if (userHooksText !== null) { try { userHooks = JSON.parse(userHooksText) } catch { userHooks = {} } }
|
|
351
|
+
const subscribed = codexSubscribedEvents(userHooks, scriptPath, hookPaths).subscribed
|
|
352
|
+
const merged = mergeCodexHooks({}, scriptPath, hookPaths)
|
|
353
|
+
const allBlocks = merged.ok ? (merged.settings.hooks as Record<string, unknown>) : {}
|
|
354
|
+
const hooks: Record<string, unknown> = {}
|
|
355
|
+
for (const event of subscribed) if (allBlocks[event] !== undefined) hooks[event] = allBlocks[event]
|
|
356
|
+
const hooksText = JSON.stringify({ hooks }, null, 2) + '\n'
|
|
357
|
+
|
|
358
|
+
// Where our block sits in the user's file, per event (snake name), for the trust remap.
|
|
359
|
+
const userTable = userHooks && typeof userHooks === 'object' ? (userHooks as { hooks?: Record<string, unknown> }).hooks ?? {} : {}
|
|
360
|
+
const ourIndex = new Map<string, number>()
|
|
361
|
+
for (const event of subscribed) {
|
|
362
|
+
const blocks = Array.isArray(userTable[event]) ? userTable[event] as unknown[] : []
|
|
363
|
+
const canonical = JSON.stringify((allBlocks[event] as unknown[] | undefined)?.[0])
|
|
364
|
+
const at = blocks.findIndex(b => JSON.stringify(b) === canonical)
|
|
365
|
+
if (at >= 0) ourIndex.set(camelToSnake(event), at)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const copied: string[] = []
|
|
369
|
+
const out: string[] = []
|
|
370
|
+
const configText = readTextOrNull(join(serverHome, 'config.toml'))
|
|
371
|
+
if (configText !== null) {
|
|
372
|
+
for (const section of splitTomlSections(configText)) {
|
|
373
|
+
const path = section.path
|
|
374
|
+
if (!path) continue
|
|
375
|
+
if (CODEX_COPIED_TABLES.includes(path[0]!)) {
|
|
376
|
+
out.push(section.header, ...section.body)
|
|
377
|
+
copied.push(path.join('.'))
|
|
378
|
+
continue
|
|
379
|
+
}
|
|
380
|
+
// Hook trust: Codex keys each hook by `<file>:<event>:<block>:<hook>`. A project file's key
|
|
381
|
+
// names the same file on both lanes and is copied as it stands. A key on the user's own
|
|
382
|
+
// hooks.json is moved to this home's hooks.json, only where our block is the one trusted.
|
|
383
|
+
if (path[0] === 'hooks' && path[1] === 'state' && path.length === 3) {
|
|
384
|
+
const key = path[2]!
|
|
385
|
+
const parsed = HOOK_TRUST_KEY_RE.exec(key)
|
|
386
|
+
if (parsed && parsed[1] === serverHooksPath) {
|
|
387
|
+
const at = ourIndex.get(parsed[2]!)
|
|
388
|
+
if (at === undefined || Number(parsed[3]) !== at) continue
|
|
389
|
+
const remapped = `${profileHooksPath}:${parsed[2]}:0:${parsed[4]}`
|
|
390
|
+
out.push(headerFor(['hooks', 'state', remapped], false), ...section.body)
|
|
391
|
+
copied.push(`hooks.state.${parsed[2]}`)
|
|
392
|
+
continue
|
|
393
|
+
}
|
|
394
|
+
if (parsed && parsed[1]!.startsWith(home)) continue
|
|
395
|
+
out.push(section.header, ...section.body)
|
|
396
|
+
copied.push('hooks.state')
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const head = [
|
|
402
|
+
`# Generated by COS for key profile ${profile.id} before every keyed turn. COS rewrites it; do not edit.`,
|
|
403
|
+
`model = ${tomlBasicString(profile.model)}`,
|
|
404
|
+
`model_provider = "${CODEX_RELAY_PROVIDER_ID}"`,
|
|
405
|
+
'',
|
|
406
|
+
`[model_providers.${CODEX_RELAY_PROVIDER_ID}]`,
|
|
407
|
+
'name = "COS key relay"',
|
|
408
|
+
`base_url = "${ORIGIN_PLACEHOLDER}${RELAY_BASE_PATHS.codex}"`,
|
|
409
|
+
`env_key = "${KEY_LANE_CHILD_ENV.codexToken}"`,
|
|
410
|
+
'wire_api = "responses"',
|
|
411
|
+
`request_max_retries = ${CODEX_RELAY_RETRIES}`,
|
|
412
|
+
`stream_max_retries = ${CODEX_RELAY_STREAM_RETRIES}`,
|
|
413
|
+
'',
|
|
414
|
+
]
|
|
415
|
+
const tail = out.length ? [`# Copied read-only from ${join(serverHome, 'config.toml')} (plan D9).`, ...out] : []
|
|
416
|
+
const configTemplate = [...head, ...tail].join('\n').replace(/\n*$/, '\n')
|
|
417
|
+
const sha = createHash('sha256').update(configTemplate).update('\0').update(hooksText).digest('hex').slice(0, 16)
|
|
418
|
+
return {
|
|
419
|
+
profileId: profile.id,
|
|
420
|
+
home,
|
|
421
|
+
configTemplate,
|
|
422
|
+
hooksText,
|
|
423
|
+
engineFingerprint: `profile:${profile.id}:${sha}`,
|
|
424
|
+
copiedTables: [...new Set(copied)],
|
|
425
|
+
mirroredHookEvents: Object.keys(hooks),
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const LOOPBACK_ORIGIN_RE = /^http:\/\/127\.0\.0\.1:\d{1,5}$/
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Write the rendered home with the relay origin filled in: dirs 0700, files 0600, atomic. Refuses
|
|
433
|
+
* a symlinked root or home. Any `auth.json` in the home is removed: a keyed home never holds one.
|
|
434
|
+
*/
|
|
435
|
+
export function writeCodexProfileHome(rendered: RenderedCodexProfileHome, relayOrigin: string): { configPath: string; hooksPath: string } {
|
|
436
|
+
if (!LOOPBACK_ORIGIN_RE.test(relayOrigin)) throw new Error('codex-profile-home: the relay origin is not loopback')
|
|
437
|
+
const root = codexProfileHomesRoot()
|
|
438
|
+
for (const dir of [root, rendered.home]) {
|
|
439
|
+
let st: ReturnType<typeof lstatSync> | null = null
|
|
440
|
+
try { st = lstatSync(dir) } catch { /* created below */ }
|
|
441
|
+
if (st && (st.isSymbolicLink() || !st.isDirectory())) throw new Error('codex-profile-home: refusing a symlinked or non-directory home')
|
|
442
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
443
|
+
chmodSync(dir, 0o700)
|
|
444
|
+
}
|
|
445
|
+
const auth = join(rendered.home, 'auth.json')
|
|
446
|
+
try { lstatSync(auth); unlinkSync(auth) } catch { /* none, as it should be */ }
|
|
447
|
+
const configPath = join(rendered.home, 'config.toml')
|
|
448
|
+
const hooksPath = join(rendered.home, 'hooks.json')
|
|
449
|
+
durableAtomicWriteFileSync(configPath, rendered.configTemplate.split(ORIGIN_PLACEHOLDER).join(relayOrigin), { mode: 0o600 })
|
|
450
|
+
durableAtomicWriteFileSync(hooksPath, rendered.hooksText, { mode: 0o600 })
|
|
451
|
+
return { configPath, hooksPath }
|
|
452
|
+
}
|
|
@@ -6,6 +6,7 @@ import { existsSync } from 'node:fs'
|
|
|
6
6
|
import { homedir } from 'node:os'
|
|
7
7
|
import { join } from 'node:path'
|
|
8
8
|
import { resolveProviderBinary } from './provider-binary.js'
|
|
9
|
+
import { codexHomes } from './codex-homes.js'
|
|
9
10
|
|
|
10
11
|
export interface NativeQueuedSubmission { id: string; input: Array<{ type: string; text?: string; text_elements?: unknown[] }>; clientUserMessageId: string }
|
|
11
12
|
export interface NativeQueueControl {
|
|
@@ -89,7 +90,22 @@ export const nativeQueueControl: NativeQueueControl = {
|
|
|
89
90
|
// One read-only aggregate per session-list request, not 80 protocol processes.
|
|
90
91
|
// No queue file is ever edited via SQL; mutations use Codex's own protocol above.
|
|
91
92
|
export async function nativeQueuedCounts(): Promise<Map<string, number> | null> {
|
|
92
|
-
|
|
93
|
+
// Key lanes K1 (plan v4 8.2): the server home first, then each keyed profile home. With no
|
|
94
|
+
// profile home this is exactly the one read it always was.
|
|
95
|
+
const homes = codexHomes(process.env.CODEX_HOME || join(homedir(), '.codex'))
|
|
96
|
+
if (homes.length === 1) return queuedCountsIn(homes[0]!)
|
|
97
|
+
const total = new Map<string, number>()
|
|
98
|
+
for (const home of homes) {
|
|
99
|
+
const counts = await queuedCountsIn(home)
|
|
100
|
+
// An unreadable keyed home never degrades the server home's answer.
|
|
101
|
+
if (counts === null) { if (home === homes[0]) return null; continue }
|
|
102
|
+
for (const [thread, n] of counts) total.set(thread, (total.get(thread) ?? 0) + n)
|
|
103
|
+
}
|
|
104
|
+
return total
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function queuedCountsIn(home: string): Promise<Map<string, number> | null> {
|
|
108
|
+
const db = join(home, 'queue_1.sqlite')
|
|
93
109
|
if (!existsSync(db)) return new Map()
|
|
94
110
|
try {
|
|
95
111
|
const { stdout } = await promisify(execFile)('/usr/bin/sqlite3', ['-readonly', '-json', db, 'SELECT thread_id, count(*) AS n FROM queued_items GROUP BY thread_id'], { timeout: 2000, maxBuffer: 1024 * 1024 })
|