@exla/rudder 0.0.0-stage → 3.4.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/LICENSE +100 -0
- package/README.md +733 -2
- package/assets/rudder.icns +0 -0
- package/assets/sounds/NOTICE.md +8 -0
- package/assets/sounds/ping.mp3 +0 -0
- package/dist/agent-attention.d.ts +5 -0
- package/dist/agent-attention.js +54 -0
- package/dist/agent-attention.js.map +1 -0
- package/dist/analytics.d.ts +36 -0
- package/dist/analytics.js +178 -0
- package/dist/analytics.js.map +1 -0
- package/dist/attach-session.d.ts +91 -0
- package/dist/attach-session.js +290 -0
- package/dist/attach-session.js.map +1 -0
- package/dist/auth.d.ts +36 -0
- package/dist/auth.js +392 -0
- package/dist/auth.js.map +1 -0
- package/dist/backends.d.ts +3 -0
- package/dist/backends.js +638 -0
- package/dist/backends.js.map +1 -0
- package/dist/board/board.css +1 -0
- package/dist/board/board.js +2 -0
- package/dist/board/daemon.d.ts +39 -0
- package/dist/board/daemon.js +1510 -0
- package/dist/board/daemon.js.map +1 -0
- package/dist/brain.d.ts +24 -0
- package/dist/brain.js +244 -0
- package/dist/brain.js.map +1 -0
- package/dist/bus.d.ts +9 -0
- package/dist/bus.js +23 -0
- package/dist/bus.js.map +1 -0
- package/dist/checkout-sync.d.ts +35 -0
- package/dist/checkout-sync.js +247 -0
- package/dist/checkout-sync.js.map +1 -0
- package/dist/cloud.d.ts +48 -0
- package/dist/cloud.js +4596 -0
- package/dist/cloud.js.map +1 -0
- package/dist/codex-binary.d.ts +12 -0
- package/dist/codex-binary.js +184 -0
- package/dist/codex-binary.js.map +1 -0
- package/dist/context-audit.d.ts +21 -0
- package/dist/context-audit.js +109 -0
- package/dist/context-audit.js.map +1 -0
- package/dist/daemon.d.ts +27 -0
- package/dist/daemon.js +203 -0
- package/dist/daemon.js.map +1 -0
- package/dist/effort.d.ts +9 -0
- package/dist/effort.js +72 -0
- package/dist/effort.js.map +1 -0
- package/dist/feedback.d.ts +46 -0
- package/dist/feedback.js +173 -0
- package/dist/feedback.js.map +1 -0
- package/dist/gc.d.ts +3 -0
- package/dist/gc.js +262 -0
- package/dist/gc.js.map +1 -0
- package/dist/git.d.ts +31 -0
- package/dist/git.js +455 -0
- package/dist/git.js.map +1 -0
- package/dist/goal.d.ts +33 -0
- package/dist/goal.js +95 -0
- package/dist/goal.js.map +1 -0
- package/dist/graph.d.ts +93 -0
- package/dist/graph.js +372 -0
- package/dist/graph.js.map +1 -0
- package/dist/handoff.d.ts +110 -0
- package/dist/handoff.js +565 -0
- package/dist/handoff.js.map +1 -0
- package/dist/improve/advisor.d.ts +10 -0
- package/dist/improve/advisor.js +115 -0
- package/dist/improve/advisor.js.map +1 -0
- package/dist/improve/collect.d.ts +49 -0
- package/dist/improve/collect.js +274 -0
- package/dist/improve/collect.js.map +1 -0
- package/dist/improve/gate.d.ts +17 -0
- package/dist/improve/gate.js +60 -0
- package/dist/improve/gate.js.map +1 -0
- package/dist/improve/index.d.ts +9 -0
- package/dist/improve/index.js +376 -0
- package/dist/improve/index.js.map +1 -0
- package/dist/improve/judge.d.ts +32 -0
- package/dist/improve/judge.js +114 -0
- package/dist/improve/judge.js.map +1 -0
- package/dist/improve/mine.d.ts +31 -0
- package/dist/improve/mine.js +192 -0
- package/dist/improve/mine.js.map +1 -0
- package/dist/improve/propose.d.ts +51 -0
- package/dist/improve/propose.js +150 -0
- package/dist/improve/propose.js.map +1 -0
- package/dist/improve/rank.d.ts +16 -0
- package/dist/improve/rank.js +49 -0
- package/dist/improve/rank.js.map +1 -0
- package/dist/improve/schedule.d.ts +1 -0
- package/dist/improve/schedule.js +95 -0
- package/dist/improve/schedule.js.map +1 -0
- package/dist/improve/ship.d.ts +25 -0
- package/dist/improve/ship.js +82 -0
- package/dist/improve/ship.js.map +1 -0
- package/dist/improve/state.d.ts +157 -0
- package/dist/improve/state.js +264 -0
- package/dist/improve/state.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/jj-binary.d.ts +36 -0
- package/dist/jj-binary.js +143 -0
- package/dist/jj-binary.js.map +1 -0
- package/dist/jj.d.ts +163 -0
- package/dist/jj.js +926 -0
- package/dist/jj.js.map +1 -0
- package/dist/logger.d.ts +22 -0
- package/dist/logger.js +110 -0
- package/dist/logger.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +1366 -0
- package/dist/main.js.map +1 -0
- package/dist/migration.d.ts +82 -0
- package/dist/migration.js +198 -0
- package/dist/migration.js.map +1 -0
- package/dist/models.d.ts +9 -0
- package/dist/models.js +623 -0
- package/dist/models.js.map +1 -0
- package/dist/move-home.d.ts +82 -0
- package/dist/move-home.js +472 -0
- package/dist/move-home.js.map +1 -0
- package/dist/native/darwin-arm64/rudder-native +0 -0
- package/dist/native/darwin-x64/rudder-native +0 -0
- package/dist/native/linux-x64/rudder-native +0 -0
- package/dist/native-agents.d.ts +8 -0
- package/dist/native-agents.js +178 -0
- package/dist/native-agents.js.map +1 -0
- package/dist/native-binary.d.ts +4 -0
- package/dist/native-binary.js +140 -0
- package/dist/native-binary.js.map +1 -0
- package/dist/paste-bridge.d.ts +36 -0
- package/dist/paste-bridge.js +279 -0
- package/dist/paste-bridge.js.map +1 -0
- package/dist/plan-mode.d.ts +2 -0
- package/dist/plan-mode.js +22 -0
- package/dist/plan-mode.js.map +1 -0
- package/dist/planner.d.ts +27 -0
- package/dist/planner.js +587 -0
- package/dist/planner.js.map +1 -0
- package/dist/predict.d.ts +67 -0
- package/dist/predict.js +369 -0
- package/dist/predict.js.map +1 -0
- package/dist/report.d.ts +74 -0
- package/dist/report.js +412 -0
- package/dist/report.js.map +1 -0
- package/dist/rudder-md.d.ts +33 -0
- package/dist/rudder-md.js +142 -0
- package/dist/rudder-md.js.map +1 -0
- package/dist/run-manager.d.ts +62 -0
- package/dist/run-manager.js +1400 -0
- package/dist/run-manager.js.map +1 -0
- package/dist/scheduler.d.ts +128 -0
- package/dist/scheduler.js +955 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/state.d.ts +127 -0
- package/dist/state.js +704 -0
- package/dist/state.js.map +1 -0
- package/dist/surfaces.d.ts +91 -0
- package/dist/surfaces.js +460 -0
- package/dist/surfaces.js.map +1 -0
- package/dist/task-summary.d.ts +30 -0
- package/dist/task-summary.js +472 -0
- package/dist/task-summary.js.map +1 -0
- package/dist/types.d.ts +492 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/usage.d.ts +198 -0
- package/dist/usage.js +830 -0
- package/dist/usage.js.map +1 -0
- package/dist/util.d.ts +73 -0
- package/dist/util.js +399 -0
- package/dist/util.js.map +1 -0
- package/dist/version-check.d.ts +16 -0
- package/dist/version-check.js +269 -0
- package/dist/version-check.js.map +1 -0
- package/package.json +68 -4
- package/scripts/copy-native.mjs +89 -0
- package/scripts/postinstall.mjs +34 -0
package/dist/state.js
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import fsp from "node:fs/promises";
|
|
4
|
+
import { DEFAULT_BOARD_PORT } from "./types.js";
|
|
5
|
+
import { ensureDir, newRunId, nowIso, readJson, rudderHome, runCommand, shortHash, slugPrefix, slugify, updateJson, writeJson, } from "./util.js";
|
|
6
|
+
import { llmSummarizeTask, summarizeTask } from "./task-summary.js";
|
|
7
|
+
const OUTPUT_TXT_MAX_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
export function globalConfigPath() {
|
|
9
|
+
return path.join(rudderHome(), "config.json");
|
|
10
|
+
}
|
|
11
|
+
export function projectsRegistryPath() {
|
|
12
|
+
return path.join(rudderHome(), "projects.json");
|
|
13
|
+
}
|
|
14
|
+
let lastUsedCodexModelCache = null;
|
|
15
|
+
// The model the user last picked for codex (`backends.codex.model`, saved by
|
|
16
|
+
// every /model choice). Synchronous so the sync arg-builders can consult it
|
|
17
|
+
// before falling back to a hardcoded model: "the default is the last used
|
|
18
|
+
// model", not whatever release was current when this file last shipped.
|
|
19
|
+
// Cached for the life of the process — the CLI is short-lived, and a mid-run
|
|
20
|
+
// /model change updates the run record itself, not these fallbacks.
|
|
21
|
+
/** Codex's default model. gpt-5.5 retired on 2026-10-14; Codex then shows a
|
|
22
|
+
* blocking "Try new model" dialog in every session launched on it. Mirrors
|
|
23
|
+
* `CODEX_DEFAULT_MODEL` in native/src/models.rs. */
|
|
24
|
+
export const CODEX_DEFAULT_MODEL = "gpt-6.1-sol";
|
|
25
|
+
const RETIRED_CODEX_MODELS = new Set(["gpt-5.5", "gpt-5.5-pro"]);
|
|
26
|
+
export function liveCodexModel(model) {
|
|
27
|
+
return model && RETIRED_CODEX_MODELS.has(model.trim()) ? CODEX_DEFAULT_MODEL : model;
|
|
28
|
+
}
|
|
29
|
+
export function lastUsedCodexModelSync() {
|
|
30
|
+
if (lastUsedCodexModelCache === null) {
|
|
31
|
+
let model;
|
|
32
|
+
try {
|
|
33
|
+
const raw = fs.readFileSync(globalConfigPath(), "utf8");
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
const value = parsed?.backends?.codex?.model;
|
|
36
|
+
model = typeof value === "string" && value.trim() ? liveCodexModel(value.trim()) : undefined;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
model = undefined;
|
|
40
|
+
}
|
|
41
|
+
lastUsedCodexModelCache = model;
|
|
42
|
+
}
|
|
43
|
+
return lastUsedCodexModelCache;
|
|
44
|
+
}
|
|
45
|
+
export function authStorePath() {
|
|
46
|
+
return path.join(rudderHome(), "auth-profiles.json");
|
|
47
|
+
}
|
|
48
|
+
export function cloudAuthPath() {
|
|
49
|
+
return path.join(rudderHome(), "cloud.json");
|
|
50
|
+
}
|
|
51
|
+
export function projectStateDir(repoRoot) {
|
|
52
|
+
return path.join(repoRoot, ".rudder");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Directory INSIDE the repo holding one jj workspace per agent. Named for what
|
|
56
|
+
* it holds: `jj workspace add` targets, not git worktrees.
|
|
57
|
+
*/
|
|
58
|
+
export const AGENT_WORKSPACES_DIR = ".rudder-workspaces";
|
|
59
|
+
/**
|
|
60
|
+
* The pre-rename name for the same directory. Still recognized everywhere a path
|
|
61
|
+
* is CLASSIFIED (ignore rules, path healing, gc scans, dashboard-root walks): a
|
|
62
|
+
* jj workspace is registered in `.jj` under the absolute path it was created at,
|
|
63
|
+
* so workspaces made before the rename must keep resolving. Only NEW workspaces
|
|
64
|
+
* are created under AGENT_WORKSPACES_DIR.
|
|
65
|
+
*/
|
|
66
|
+
export const LEGACY_AGENT_WORKSPACES_DIR = ".rudder-worktrees";
|
|
67
|
+
const PROJECT_RUNTIME_IGNORES = [
|
|
68
|
+
".rudder/",
|
|
69
|
+
`${AGENT_WORKSPACES_DIR}/`,
|
|
70
|
+
`${LEGACY_AGENT_WORKSPACES_DIR}/`,
|
|
71
|
+
"RUDDER.md",
|
|
72
|
+
"RUDDER_SHARED.md",
|
|
73
|
+
];
|
|
74
|
+
const projectRuntimeIgnoreInstalls = new Map();
|
|
75
|
+
/**
|
|
76
|
+
* Keep Rudder's live control plane out of every jj snapshot. This must run
|
|
77
|
+
* before creating scaffold changes or workspaces: adding the ignores after a
|
|
78
|
+
* workspace command is too late because jj snapshots the current checkout as
|
|
79
|
+
* part of that command.
|
|
80
|
+
*
|
|
81
|
+
* We write both .gitignore (the existing, visible project contract) and Git's
|
|
82
|
+
* local exclude file. The latter applies immediately across colocated jj
|
|
83
|
+
* workspaces, including a scaffold change created before an older Rudder
|
|
84
|
+
* version had installed the project-level rules.
|
|
85
|
+
*/
|
|
86
|
+
export function ensureProjectRuntimeIgnored(repoRoot) {
|
|
87
|
+
const key = path.resolve(repoRoot);
|
|
88
|
+
const existing = projectRuntimeIgnoreInstalls.get(key);
|
|
89
|
+
if (existing)
|
|
90
|
+
return existing;
|
|
91
|
+
const install = installProjectRuntimeIgnores(key).catch((error) => {
|
|
92
|
+
projectRuntimeIgnoreInstalls.delete(key);
|
|
93
|
+
throw error;
|
|
94
|
+
});
|
|
95
|
+
projectRuntimeIgnoreInstalls.set(key, install);
|
|
96
|
+
return install;
|
|
97
|
+
}
|
|
98
|
+
async function installProjectRuntimeIgnores(repoRoot) {
|
|
99
|
+
const targets = [path.join(repoRoot, ".gitignore")];
|
|
100
|
+
const exclude = await runCommand("git", ["rev-parse", "--git-path", "info/exclude"], {
|
|
101
|
+
cwd: repoRoot,
|
|
102
|
+
allowFailure: true,
|
|
103
|
+
}).catch(() => null);
|
|
104
|
+
const excludePath = exclude?.stdout.trim();
|
|
105
|
+
if (excludePath) {
|
|
106
|
+
targets.push(path.isAbsolute(excludePath) ? excludePath : path.resolve(repoRoot, excludePath));
|
|
107
|
+
}
|
|
108
|
+
for (const target of new Set(targets)) {
|
|
109
|
+
for (const line of PROJECT_RUNTIME_IGNORES) {
|
|
110
|
+
await ensureIgnoreLine(target, line);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function ensureIgnoreLine(filePath, line) {
|
|
115
|
+
const existing = await fsp.readFile(filePath, "utf8").catch(() => "");
|
|
116
|
+
if (existing.split(/\r?\n/).some((item) => item.trim() === line)) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
|
|
120
|
+
await ensureDir(path.dirname(filePath));
|
|
121
|
+
await fsp.appendFile(filePath, `${prefix}${line}\n`, "utf8");
|
|
122
|
+
}
|
|
123
|
+
export function runsDir(repoRoot) {
|
|
124
|
+
return path.join(projectStateDir(repoRoot), "runs");
|
|
125
|
+
}
|
|
126
|
+
export function undoStackPath(repoRoot) {
|
|
127
|
+
return path.join(projectStateDir(repoRoot), "undo-stack.json");
|
|
128
|
+
}
|
|
129
|
+
export function runDir(repoRoot, runId) {
|
|
130
|
+
return path.join(runsDir(repoRoot), runId);
|
|
131
|
+
}
|
|
132
|
+
export function runRecordPath(repoRoot, runId) {
|
|
133
|
+
return path.join(runDir(repoRoot, runId), "run.json");
|
|
134
|
+
}
|
|
135
|
+
export function eventsPath(repoRoot, runId) {
|
|
136
|
+
return path.join(runDir(repoRoot, runId), "events.ndjson");
|
|
137
|
+
}
|
|
138
|
+
export function outputPath(repoRoot, runId) {
|
|
139
|
+
return path.join(runDir(repoRoot, runId), "output.txt");
|
|
140
|
+
}
|
|
141
|
+
export function stopRequestPath(repoRoot, runId) {
|
|
142
|
+
return path.join(runDir(repoRoot, runId), "stop-request.json");
|
|
143
|
+
}
|
|
144
|
+
export function agentContextPath(repoRoot) {
|
|
145
|
+
return path.join(repoRoot, "RUDDER.md");
|
|
146
|
+
}
|
|
147
|
+
export function specPath(repoRoot, runId) {
|
|
148
|
+
return path.join(runDir(repoRoot, runId), "spec.json");
|
|
149
|
+
}
|
|
150
|
+
export function verifierPath(repoRoot, runId) {
|
|
151
|
+
return path.join(runDir(repoRoot, runId), "verifier.json");
|
|
152
|
+
}
|
|
153
|
+
export function workspacePath(repoRoot, runId, task) {
|
|
154
|
+
// Workspaces live INSIDE the project (gitignored .rudder-workspaces/), not in the parent
|
|
155
|
+
// directory. This keeps every Rudder path within the project boundary, so a planner or
|
|
156
|
+
// agent confined to the project never reads outside it — which is what triggered
|
|
157
|
+
// Claude's "allow reading outside the project?" permission prompt.
|
|
158
|
+
const repoName = `${slugify(path.basename(repoRoot), "repo")}-${shortHash(repoRoot)}`;
|
|
159
|
+
return path.join(repoRoot, AGENT_WORKSPACES_DIR, repoName, workspaceDirName(runId, task));
|
|
160
|
+
}
|
|
161
|
+
function workspaceDirName(runId, task) {
|
|
162
|
+
const slug = slugPrefix(task ?? runId, "task");
|
|
163
|
+
const suffix = shortHash(runId).slice(0, 8);
|
|
164
|
+
return `${slug}-${suffix}`;
|
|
165
|
+
}
|
|
166
|
+
export async function loadConfig() {
|
|
167
|
+
const existing = await readJson(globalConfigPath());
|
|
168
|
+
if (existing?.version === 1) {
|
|
169
|
+
return normalizeConfig(existing);
|
|
170
|
+
}
|
|
171
|
+
return defaultConfig();
|
|
172
|
+
}
|
|
173
|
+
export function defaultConfig() {
|
|
174
|
+
return {
|
|
175
|
+
version: 1,
|
|
176
|
+
defaultBackend: "claude",
|
|
177
|
+
mergeStrategy: "merge",
|
|
178
|
+
colorMode: "terminal",
|
|
179
|
+
runPolicy: {
|
|
180
|
+
sameCheckout: "single-active",
|
|
181
|
+
concurrentPromptMode: "workspace",
|
|
182
|
+
},
|
|
183
|
+
acpx: { install: "latest" },
|
|
184
|
+
backends: {
|
|
185
|
+
claude: { profileId: "anthropic:claude-code", model: "sonnet" },
|
|
186
|
+
codex: {
|
|
187
|
+
profileId: "openai-codex:default",
|
|
188
|
+
model: CODEX_DEFAULT_MODEL,
|
|
189
|
+
},
|
|
190
|
+
acpx: { model: CODEX_DEFAULT_MODEL },
|
|
191
|
+
},
|
|
192
|
+
board: { port: DEFAULT_BOARD_PORT },
|
|
193
|
+
orchestrator: { maxParallel: 1000 },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function normalizeConfig(existing) {
|
|
197
|
+
const defaults = defaultConfig();
|
|
198
|
+
const normalized = {
|
|
199
|
+
...defaults,
|
|
200
|
+
...existing,
|
|
201
|
+
mergeStrategy: parseMergeStrategy(existing.mergeStrategy),
|
|
202
|
+
colorMode: existing.colorMode === "paper" ? "paper" : "terminal",
|
|
203
|
+
runPolicy: {
|
|
204
|
+
...defaults.runPolicy,
|
|
205
|
+
...(existing.runPolicy ?? {}),
|
|
206
|
+
},
|
|
207
|
+
acpx: {
|
|
208
|
+
...defaults.acpx,
|
|
209
|
+
...(existing.acpx ?? {}),
|
|
210
|
+
},
|
|
211
|
+
backends: {
|
|
212
|
+
...defaults.backends,
|
|
213
|
+
...(existing.backends ?? {}),
|
|
214
|
+
},
|
|
215
|
+
board: {
|
|
216
|
+
...defaults.board,
|
|
217
|
+
...(existing.board ?? {}),
|
|
218
|
+
},
|
|
219
|
+
orchestrator: {
|
|
220
|
+
maxParallel: existing.orchestrator?.maxParallel ?? defaults.orchestrator?.maxParallel ?? 1000,
|
|
221
|
+
...(existing.orchestrator?.budget ? { budget: existing.orchestrator.budget } : {}),
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
for (const id of ["codex", "acpx"]) {
|
|
225
|
+
const backend = normalized.backends[id];
|
|
226
|
+
if (backend?.model)
|
|
227
|
+
backend.model = liveCodexModel(backend.model);
|
|
228
|
+
}
|
|
229
|
+
// Remove the retired merge gate instead of carrying a dead feature flag
|
|
230
|
+
// forward through the top-level config spread.
|
|
231
|
+
Reflect.deleteProperty(normalized, "autoMerge");
|
|
232
|
+
return normalized;
|
|
233
|
+
}
|
|
234
|
+
function parseMergeStrategy(value) {
|
|
235
|
+
return value === "rebase" ? "rebase" : "merge";
|
|
236
|
+
}
|
|
237
|
+
export async function saveConfig(config) {
|
|
238
|
+
await writeJson(globalConfigPath(), config, { mode: 0o600 });
|
|
239
|
+
}
|
|
240
|
+
export async function rememberBackendSelection(params) {
|
|
241
|
+
const config = await loadConfig();
|
|
242
|
+
applyBackendSelection(config, params);
|
|
243
|
+
await saveConfig(config);
|
|
244
|
+
return config;
|
|
245
|
+
}
|
|
246
|
+
function applyBackendSelection(config, params) {
|
|
247
|
+
config.lastUsedBackend = params.backend;
|
|
248
|
+
config.backends = config.backends ?? {};
|
|
249
|
+
if (!params.updateModel && !params.updateEffort) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const next = { ...(config.backends[params.backend] ?? {}) };
|
|
253
|
+
if (params.updateModel) {
|
|
254
|
+
if (params.model) {
|
|
255
|
+
next.model = params.model;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
delete next.model;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (params.updateEffort) {
|
|
262
|
+
if (params.backend === "claude") {
|
|
263
|
+
if (params.effort) {
|
|
264
|
+
next.effort = params.effort;
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
delete next.effort;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else if (params.effort) {
|
|
271
|
+
next.reasoningEffort = params.effort;
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
delete next.reasoningEffort;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
config.backends[params.backend] = next;
|
|
278
|
+
}
|
|
279
|
+
export async function loadAuthStore() {
|
|
280
|
+
const existing = await readJson(authStorePath());
|
|
281
|
+
if (existing?.version === 1 && existing.profiles && typeof existing.profiles === "object") {
|
|
282
|
+
return {
|
|
283
|
+
version: 1,
|
|
284
|
+
profiles: normalizeProfiles(existing.profiles),
|
|
285
|
+
order: existing.order,
|
|
286
|
+
lastGood: existing.lastGood,
|
|
287
|
+
usageStats: existing.usageStats,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
return { version: 1, profiles: {} };
|
|
291
|
+
}
|
|
292
|
+
function normalizeProfiles(profiles) {
|
|
293
|
+
const normalized = {};
|
|
294
|
+
for (const [profileId, raw] of Object.entries(profiles)) {
|
|
295
|
+
if (!raw || typeof raw !== "object") {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const entry = { ...raw };
|
|
299
|
+
if (!entry.type && typeof entry.mode === "string") {
|
|
300
|
+
entry.type = entry.mode;
|
|
301
|
+
}
|
|
302
|
+
if (entry.type === "api_key" && typeof entry.provider === "string") {
|
|
303
|
+
normalized[profileId] = {
|
|
304
|
+
type: "api_key",
|
|
305
|
+
provider: entry.provider,
|
|
306
|
+
...(typeof entry.key === "string" ? { key: entry.key } : {}),
|
|
307
|
+
...(typeof entry.apiKey === "string" ? { key: entry.apiKey } : {}),
|
|
308
|
+
};
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (entry.type === "token" && typeof entry.provider === "string") {
|
|
312
|
+
normalized[profileId] = {
|
|
313
|
+
type: "token",
|
|
314
|
+
provider: entry.provider,
|
|
315
|
+
...(typeof entry.token === "string" ? { token: entry.token } : {}),
|
|
316
|
+
...(typeof entry.expires === "number" ? { expires: entry.expires } : {}),
|
|
317
|
+
};
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (entry.type === "oauth" &&
|
|
321
|
+
typeof entry.provider === "string" &&
|
|
322
|
+
typeof entry.access === "string" &&
|
|
323
|
+
typeof entry.refresh === "string" &&
|
|
324
|
+
typeof entry.expires === "number") {
|
|
325
|
+
normalized[profileId] = {
|
|
326
|
+
type: "oauth",
|
|
327
|
+
provider: entry.provider,
|
|
328
|
+
access: entry.access,
|
|
329
|
+
refresh: entry.refresh,
|
|
330
|
+
expires: entry.expires,
|
|
331
|
+
...(typeof entry.email === "string" ? { email: entry.email } : {}),
|
|
332
|
+
...(typeof entry.accountId === "string" ? { accountId: entry.accountId } : {}),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return normalized;
|
|
337
|
+
}
|
|
338
|
+
export async function saveAuthStore(store) {
|
|
339
|
+
await writeJson(authStorePath(), store, { mode: 0o600 });
|
|
340
|
+
}
|
|
341
|
+
export async function createRunRecord(params) {
|
|
342
|
+
const id = params.id ?? newRunId(params.task);
|
|
343
|
+
const createdAt = nowIso();
|
|
344
|
+
const record = {
|
|
345
|
+
id,
|
|
346
|
+
status: "created",
|
|
347
|
+
vcs: params.vcs,
|
|
348
|
+
...(params.resolverFor ? { resolverFor: params.resolverFor } : {}),
|
|
349
|
+
mode: params.mode ?? "execute",
|
|
350
|
+
task: params.task,
|
|
351
|
+
taskSummary: summarizeTask(params.task),
|
|
352
|
+
backend: params.backend,
|
|
353
|
+
model: params.model,
|
|
354
|
+
effort: params.effort,
|
|
355
|
+
createdAt,
|
|
356
|
+
updatedAt: createdAt,
|
|
357
|
+
repoRoot: params.repoRoot,
|
|
358
|
+
targetBranch: params.targetBranch,
|
|
359
|
+
baseCommit: params.baseCommit,
|
|
360
|
+
workspace: {
|
|
361
|
+
enabled: params.useWorkspace,
|
|
362
|
+
path: params.workspacePath ?? params.repoRoot,
|
|
363
|
+
branch: params.workspaceBranch,
|
|
364
|
+
workspaceName: params.workspaceName,
|
|
365
|
+
...(params.workspaceChangeId ? { jjChangeId: params.workspaceChangeId } : {}),
|
|
366
|
+
},
|
|
367
|
+
currentPrompt: params.task,
|
|
368
|
+
turns: [{ ts: createdAt, prompt: params.task, source: "user" }],
|
|
369
|
+
lastUserInputAt: createdAt,
|
|
370
|
+
autoSteer: { count: 0, max: 2 },
|
|
371
|
+
};
|
|
372
|
+
await ensureDir(runDir(params.repoRoot, id));
|
|
373
|
+
await saveRunRecord(record);
|
|
374
|
+
return record;
|
|
375
|
+
}
|
|
376
|
+
export async function saveRunRecord(record, options) {
|
|
377
|
+
record.taskSummary = record.taskSummary || summarizeTask(record.task);
|
|
378
|
+
record.updatedAt = nowIso();
|
|
379
|
+
let saved = false;
|
|
380
|
+
await updateJson(runRecordPath(record.repoRoot, record.id), (prev) => {
|
|
381
|
+
// A redirected headless run starts a fresh attempt. Backend/controller
|
|
382
|
+
// callbacks from the superseded process may still arrive briefly after the
|
|
383
|
+
// signal; keep those stale snapshots from overwriting the new turn.
|
|
384
|
+
if (options?.expectedAttemptId &&
|
|
385
|
+
prev &&
|
|
386
|
+
prev.process?.attemptId !== options.expectedAttemptId) {
|
|
387
|
+
return prev;
|
|
388
|
+
}
|
|
389
|
+
// Runs created by an older Rudder have no attempt id. Their worker still
|
|
390
|
+
// carries the original startedAt, so use it as a migration-safe ownership
|
|
391
|
+
// token and reject it once a redirect installs a new attempt.
|
|
392
|
+
if (!options?.expectedAttemptId &&
|
|
393
|
+
options?.expectedStartedAt &&
|
|
394
|
+
prev &&
|
|
395
|
+
(Boolean(prev.process?.attemptId) || prev.process?.startedAt !== options.expectedStartedAt)) {
|
|
396
|
+
return prev;
|
|
397
|
+
}
|
|
398
|
+
// A long-lived in-memory `record` can be stale: the background LLM
|
|
399
|
+
// summarizer reads + rewrites run.json out of band. Preserve the title it
|
|
400
|
+
// persisted instead of clobbering it with the naive summary.
|
|
401
|
+
if (prev?.taskSummaryLlm && !record.taskSummaryLlm) {
|
|
402
|
+
record.taskSummary = prev.taskSummary;
|
|
403
|
+
record.taskSummaryLlm = true;
|
|
404
|
+
}
|
|
405
|
+
saved = true;
|
|
406
|
+
return record;
|
|
407
|
+
});
|
|
408
|
+
return saved;
|
|
409
|
+
}
|
|
410
|
+
const inflightLlmSummaries = new Set();
|
|
411
|
+
/**
|
|
412
|
+
* Records written before the worktree->workspace rename carry the isolation info
|
|
413
|
+
* under a `worktree` key. Lift it onto `workspace` on read so every consumer
|
|
414
|
+
* speaks one name; the next save persists the new key. Reading a record must
|
|
415
|
+
* never throw on the old shape, so this is a pure in-memory promotion.
|
|
416
|
+
*/
|
|
417
|
+
export function adoptLegacyWorkspaceKey(record) {
|
|
418
|
+
const raw = record;
|
|
419
|
+
if (raw.workspace === undefined && raw.worktree !== undefined) {
|
|
420
|
+
raw.workspace = raw.worktree;
|
|
421
|
+
}
|
|
422
|
+
delete raw.worktree;
|
|
423
|
+
return record;
|
|
424
|
+
}
|
|
425
|
+
export async function loadRunRecord(repoRoot, runId) {
|
|
426
|
+
const record = await readJson(runRecordPath(repoRoot, runId));
|
|
427
|
+
if (record) {
|
|
428
|
+
adoptLegacyWorkspaceKey(record);
|
|
429
|
+
healMovedRepoPaths(record, repoRoot);
|
|
430
|
+
}
|
|
431
|
+
if (record && !record.taskSummary) {
|
|
432
|
+
record.taskSummary = summarizeTask(record.task);
|
|
433
|
+
}
|
|
434
|
+
if (record) {
|
|
435
|
+
maybeBackgroundLlmSummarize(record);
|
|
436
|
+
}
|
|
437
|
+
return record;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Recorded absolute paths survive a repo RENAME or MOVE; the repo does not.
|
|
441
|
+
* Everything a run record points at (workspaces, cwds) lives INSIDE the repo
|
|
442
|
+
* and moves with it, so when the record's repoRoot disagrees with the root we
|
|
443
|
+
* are actually reading it from, every stored path under the old root has an
|
|
444
|
+
* exact counterpart under the new one. Rewrite the prefix and the record is
|
|
445
|
+
* true again — the same reason jj survives a rename: its workspace pointers
|
|
446
|
+
* are relative. In-memory only; the healed record persists whenever the
|
|
447
|
+
* record is next saved.
|
|
448
|
+
*/
|
|
449
|
+
export function healMovedRepoPaths(record, actualRepoRoot) {
|
|
450
|
+
const currentRoot = path.resolve(actualRepoRoot);
|
|
451
|
+
// Rule 1's key: the stored root, IF it disagrees. Rule 2 exists because
|
|
452
|
+
// rule 1 is not enough — a later save can rewrite repoRoot alone (observed:
|
|
453
|
+
// repoRoot said the new name while workspace.path still said the old one).
|
|
454
|
+
// Workspaces always live at <root>/<agent-workspaces-dir>/..., so whatever
|
|
455
|
+
// precedes that marker is some spelling of the root; rebase it.
|
|
456
|
+
const storedRoot = record.repoRoot && record.repoRoot !== currentRoot ? record.repoRoot : null;
|
|
457
|
+
const markers = [
|
|
458
|
+
`${path.sep}${AGENT_WORKSPACES_DIR}${path.sep}`,
|
|
459
|
+
`${path.sep}${LEGACY_AGENT_WORKSPACES_DIR}${path.sep}`,
|
|
460
|
+
];
|
|
461
|
+
const remap = (value) => {
|
|
462
|
+
if (typeof value === "string") {
|
|
463
|
+
if (storedRoot) {
|
|
464
|
+
if (value === storedRoot)
|
|
465
|
+
return currentRoot;
|
|
466
|
+
if (value.startsWith(storedRoot + path.sep)) {
|
|
467
|
+
return currentRoot + value.slice(storedRoot.length);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
for (const marker of markers) {
|
|
471
|
+
const index = value.indexOf(marker);
|
|
472
|
+
if (index >= 0 && !value.startsWith(currentRoot)) {
|
|
473
|
+
return currentRoot + value.slice(index);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return value;
|
|
477
|
+
}
|
|
478
|
+
if (Array.isArray(value)) {
|
|
479
|
+
for (let i = 0; i < value.length; i += 1)
|
|
480
|
+
value[i] = remap(value[i]);
|
|
481
|
+
return value;
|
|
482
|
+
}
|
|
483
|
+
if (value && typeof value === "object") {
|
|
484
|
+
const obj = value;
|
|
485
|
+
for (const key of Object.keys(obj))
|
|
486
|
+
obj[key] = remap(obj[key]);
|
|
487
|
+
return value;
|
|
488
|
+
}
|
|
489
|
+
return value;
|
|
490
|
+
};
|
|
491
|
+
remap(record);
|
|
492
|
+
// A record inside <root>/.rudder/runs belongs to <root> by construction.
|
|
493
|
+
record.repoRoot = currentRoot;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Scan every run record in the repo and fire a background LLM summarization
|
|
497
|
+
* for any whose task summary has never been upgraded. Used by the CLI before
|
|
498
|
+
* spawning the native dashboard so the next launch picks up nicer titles even
|
|
499
|
+
* though the native dashboard reads run.json directly and skips the TS load
|
|
500
|
+
* path. Caps the number of in-flight summaries so a big repo doesn't blast
|
|
501
|
+
* Anthropic. Never throws.
|
|
502
|
+
*/
|
|
503
|
+
export async function backfillLlmTaskSummaries(repoRoot, maxInFlight = 8) {
|
|
504
|
+
try {
|
|
505
|
+
const dir = runsDir(repoRoot);
|
|
506
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
507
|
+
const ids = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
508
|
+
const slice = ids.slice(0, maxInFlight);
|
|
509
|
+
for (const id of slice) {
|
|
510
|
+
const record = await readJson(runRecordPath(repoRoot, id));
|
|
511
|
+
if (record) {
|
|
512
|
+
maybeBackgroundLlmSummarize(record);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
// ignore — best-effort
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function maybeBackgroundLlmSummarize(record) {
|
|
521
|
+
if (record.taskSummaryLlm) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const task = (record.task ?? "").trim();
|
|
525
|
+
if (!task) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const naive = summarizeTask(record.task);
|
|
529
|
+
const current = (record.taskSummary ?? "").trim();
|
|
530
|
+
if (current && current !== naive) {
|
|
531
|
+
// user (or some other path) already set a non-naive title; skip
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const key = `${record.repoRoot}::${record.id}`;
|
|
535
|
+
if (inflightLlmSummaries.has(key)) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
inflightLlmSummaries.add(key);
|
|
539
|
+
(async () => {
|
|
540
|
+
try {
|
|
541
|
+
const title = await llmSummarizeTask(record.task);
|
|
542
|
+
if (!title) {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const file = runRecordPath(record.repoRoot, record.id);
|
|
546
|
+
await updateJson(file, (fresh) => {
|
|
547
|
+
if (!fresh)
|
|
548
|
+
return undefined;
|
|
549
|
+
if (fresh.taskSummaryLlm)
|
|
550
|
+
return fresh;
|
|
551
|
+
const currentNaive = summarizeTask(fresh.task);
|
|
552
|
+
const currentTitle = (fresh.taskSummary ?? "").trim();
|
|
553
|
+
if (currentTitle && currentTitle !== currentNaive) {
|
|
554
|
+
return fresh;
|
|
555
|
+
}
|
|
556
|
+
// Merge only the summary fields into the record read under the
|
|
557
|
+
// cross-process lock. Never rewrite status/turn/process ownership from
|
|
558
|
+
// the stale snapshot that initiated the LLM request.
|
|
559
|
+
fresh.taskSummary = title;
|
|
560
|
+
fresh.taskSummaryLlm = true;
|
|
561
|
+
fresh.updatedAt = nowIso();
|
|
562
|
+
return fresh;
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
// swallow — background best-effort
|
|
567
|
+
}
|
|
568
|
+
finally {
|
|
569
|
+
inflightLlmSummaries.delete(key);
|
|
570
|
+
}
|
|
571
|
+
})();
|
|
572
|
+
}
|
|
573
|
+
export async function appendEvent(repoRoot, event) {
|
|
574
|
+
await ensureDir(runDir(repoRoot, event.runId));
|
|
575
|
+
await fsp.appendFile(eventsPath(repoRoot, event.runId), `${JSON.stringify(event)}\n`, "utf8");
|
|
576
|
+
const text = event.type === "backend.output" || event.type === "backend.error"
|
|
577
|
+
? event.message ?? (typeof event.data === "string" ? event.data : undefined)
|
|
578
|
+
: undefined;
|
|
579
|
+
if (text) {
|
|
580
|
+
const file = outputPath(repoRoot, event.runId);
|
|
581
|
+
await fsp.appendFile(file, text, "utf8");
|
|
582
|
+
await trimFileTail(file, OUTPUT_TXT_MAX_BYTES);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
async function trimFileTail(file, maxBytes) {
|
|
586
|
+
const stat = await fsp.stat(file).catch(() => null);
|
|
587
|
+
if (!stat || stat.size <= maxBytes) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const keep = Math.max(1, maxBytes);
|
|
591
|
+
const handle = await fsp.open(file, "r").catch(() => null);
|
|
592
|
+
if (!handle) {
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
try {
|
|
596
|
+
const buffer = Buffer.alloc(keep);
|
|
597
|
+
await handle.read(buffer, 0, keep, Math.max(0, stat.size - keep));
|
|
598
|
+
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
599
|
+
await fsp.writeFile(temp, buffer);
|
|
600
|
+
await fsp.rename(temp, file);
|
|
601
|
+
}
|
|
602
|
+
finally {
|
|
603
|
+
await handle.close().catch(() => undefined);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
export async function listRuns(repoRoot) {
|
|
607
|
+
await ensureDir(runsDir(repoRoot));
|
|
608
|
+
const entries = await fsp.readdir(runsDir(repoRoot), { withFileTypes: true });
|
|
609
|
+
const runs = await Promise.all(entries
|
|
610
|
+
.filter((entry) => entry.isDirectory())
|
|
611
|
+
.map(async (entry) => await loadRunRecord(repoRoot, entry.name)));
|
|
612
|
+
return runs
|
|
613
|
+
.filter((run) => Boolean(run))
|
|
614
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
615
|
+
}
|
|
616
|
+
export async function resolveRun(repoRoot, runId) {
|
|
617
|
+
if (runId) {
|
|
618
|
+
return await loadRunRecord(repoRoot, runId);
|
|
619
|
+
}
|
|
620
|
+
const runs = await listRuns(repoRoot);
|
|
621
|
+
return runs[0] ?? null;
|
|
622
|
+
}
|
|
623
|
+
export async function loadUndoStack(repoRoot) {
|
|
624
|
+
const stack = await readJson(undoStackPath(repoRoot));
|
|
625
|
+
return Array.isArray(stack) ? stack : [];
|
|
626
|
+
}
|
|
627
|
+
export async function pushUndoEntry(repoRoot, entry) {
|
|
628
|
+
await updateJson(undoStackPath(repoRoot), (current) => {
|
|
629
|
+
const stack = Array.isArray(current) ? current : [];
|
|
630
|
+
return [...stack, entry];
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
export async function popUndoEntry(repoRoot) {
|
|
634
|
+
let popped = null;
|
|
635
|
+
await updateJson(undoStackPath(repoRoot), (current) => {
|
|
636
|
+
const stack = Array.isArray(current) ? [...current] : [];
|
|
637
|
+
popped = stack.pop() ?? null;
|
|
638
|
+
return stack;
|
|
639
|
+
});
|
|
640
|
+
return popped;
|
|
641
|
+
}
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
// Multi-project registry (~/.rudder/projects.json). The localhost board serves
|
|
644
|
+
// every registered repo; runs auto-register their repo so projects appear.
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
export async function loadProjects() {
|
|
647
|
+
const registry = await readJson(projectsRegistryPath());
|
|
648
|
+
if (registry?.version === 1 && Array.isArray(registry.projects)) {
|
|
649
|
+
return registry.projects.filter((entry) => Boolean(entry) && typeof entry.slug === "string" && typeof entry.repoRoot === "string");
|
|
650
|
+
}
|
|
651
|
+
return [];
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Compute a stable slug for a repo: slugify(basename) with a -<shortHash> suffix
|
|
655
|
+
* appended only when another already-registered repo claimed the bare slug.
|
|
656
|
+
*/
|
|
657
|
+
function computeProjectSlug(repoRoot, existing) {
|
|
658
|
+
const resolved = path.resolve(repoRoot);
|
|
659
|
+
const base = slugify(path.basename(resolved), "project");
|
|
660
|
+
const collision = existing.some((entry) => entry.slug === base && path.resolve(entry.repoRoot) !== resolved);
|
|
661
|
+
return collision ? `${base}-${shortHash(resolved)}` : base;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Register the given repo in the global registry. Idempotent: an existing entry
|
|
665
|
+
* for the same repoRoot is returned unchanged (its slug is stable). Never throws
|
|
666
|
+
* on a malformed registry (it is rebuilt from the known-good entries).
|
|
667
|
+
*/
|
|
668
|
+
export async function registerProject(repoRoot) {
|
|
669
|
+
const resolved = path.resolve(repoRoot);
|
|
670
|
+
let result = null;
|
|
671
|
+
await updateJson(projectsRegistryPath(), (current) => {
|
|
672
|
+
const projects = current?.version === 1 && Array.isArray(current.projects)
|
|
673
|
+
? current.projects.filter((entry) => Boolean(entry) && typeof entry.slug === "string" && typeof entry.repoRoot === "string")
|
|
674
|
+
: [];
|
|
675
|
+
const found = projects.find((entry) => path.resolve(entry.repoRoot) === resolved);
|
|
676
|
+
if (found) {
|
|
677
|
+
result = found;
|
|
678
|
+
return { version: 1, projects };
|
|
679
|
+
}
|
|
680
|
+
const entry = {
|
|
681
|
+
slug: computeProjectSlug(resolved, projects),
|
|
682
|
+
repoRoot: resolved,
|
|
683
|
+
name: path.basename(resolved),
|
|
684
|
+
addedAt: nowIso(),
|
|
685
|
+
};
|
|
686
|
+
result = entry;
|
|
687
|
+
return { version: 1, projects: [...projects, entry] };
|
|
688
|
+
});
|
|
689
|
+
if (!result) {
|
|
690
|
+
// Should be unreachable; the transform always assigns result.
|
|
691
|
+
result = {
|
|
692
|
+
slug: computeProjectSlug(resolved, []),
|
|
693
|
+
repoRoot: resolved,
|
|
694
|
+
name: path.basename(resolved),
|
|
695
|
+
addedAt: nowIso(),
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
return result;
|
|
699
|
+
}
|
|
700
|
+
export async function findProjectBySlug(slug) {
|
|
701
|
+
const projects = await loadProjects();
|
|
702
|
+
return projects.find((entry) => entry.slug === slug) ?? null;
|
|
703
|
+
}
|
|
704
|
+
//# sourceMappingURL=state.js.map
|