runcastle 1.0.23 → 1.0.25
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/bin/runcastle.js +2167 -923
- package/drizzle/0010_misty_ken_ellis.sql +24 -0
- package/drizzle/meta/0010_snapshot.json +757 -0
- package/drizzle/meta/_journal.json +7 -0
- package/index.js +2161 -917
- package/package.json +1 -1
- package/pty-host.cjs +5 -1
- package/skills/burner/implement-ticket.md +32 -3
- package/skills/burner/prepare-project.md +92 -0
- package/skills/burner/resolve-conflict.md +10 -1
- package/skills/packs/README.md +1 -1
- package/skills/packs/runcastle/skills/tickets/SKILL.md +22 -6
- package/web/assets/index-B4LHcg8h.css +1 -0
- package/web/assets/{index-BcWKL2hj.js → index-DrjQZmZ4.js} +21 -21
- package/web/index.html +2 -2
- package/web/assets/index-fWdYT4eC.css +0 -1
package/index.js
CHANGED
|
@@ -68,6 +68,20 @@ import * as z from "zod";
|
|
|
68
68
|
function resolveSandboxImage(config) {
|
|
69
69
|
return config.sandboxImage ?? DEFAULT_SANDBOX_IMAGE;
|
|
70
70
|
}
|
|
71
|
+
function resolvePreparedSettings(config, project) {
|
|
72
|
+
const pick = (a, b) => {
|
|
73
|
+
const own = a?.trim();
|
|
74
|
+
if (own)
|
|
75
|
+
return own;
|
|
76
|
+
const inherited = b?.trim();
|
|
77
|
+
return inherited || undefined;
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
setupCommand: pick(project?.setupCommand, config.setupCommand),
|
|
81
|
+
verifyCommands: pick(project?.verifyCommands, config.verifyCommands),
|
|
82
|
+
knownFailures: pick(project?.knownFailures, config.knownFailures)
|
|
83
|
+
};
|
|
84
|
+
}
|
|
71
85
|
function resolveModel(step, config, project, runOverride) {
|
|
72
86
|
return runOverride ?? config.stepModels[step] ?? project?.model ?? config.model;
|
|
73
87
|
}
|
|
@@ -93,6 +107,7 @@ var init_config = __esm(() => {
|
|
|
93
107
|
"revisit",
|
|
94
108
|
"research",
|
|
95
109
|
"implement",
|
|
110
|
+
"prepare",
|
|
96
111
|
"smoke"
|
|
97
112
|
];
|
|
98
113
|
ModelStep = z.enum(MODEL_STEPS);
|
|
@@ -104,10 +119,15 @@ var init_config = __esm(() => {
|
|
|
104
119
|
mainBranch: z.string().default("main"),
|
|
105
120
|
sandboxImage: z.string().optional(),
|
|
106
121
|
burnConcurrency: z.number().int().min(1).max(8).default(3),
|
|
122
|
+
burnCpus: z.number().positive().max(256).optional(),
|
|
123
|
+
burnGuard: z.boolean().default(true),
|
|
107
124
|
burnMaxIterations: z.number().int().min(1).max(10).default(3),
|
|
108
125
|
burnAttempts: z.number().int().min(1).max(5).default(3),
|
|
109
126
|
burnConflictAttempts: z.number().int().min(0).max(3).default(2),
|
|
127
|
+
autoPrepare: z.boolean().default(true),
|
|
110
128
|
setupCommand: z.string().optional(),
|
|
129
|
+
verifyCommands: z.string().optional(),
|
|
130
|
+
knownFailures: z.string().optional(),
|
|
111
131
|
burnWorkspace: z.enum(["auto", "mounted", "isolated"]).default("auto")
|
|
112
132
|
}));
|
|
113
133
|
});
|
|
@@ -153,8 +173,17 @@ function loadConfig(env = process.env) {
|
|
|
153
173
|
if (env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS !== undefined) {
|
|
154
174
|
overrides.burnConflictAttempts = Number(env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS);
|
|
155
175
|
}
|
|
176
|
+
if (env.RUNCASTLE_BURN_CPUS)
|
|
177
|
+
overrides.burnCpus = Number(env.RUNCASTLE_BURN_CPUS);
|
|
178
|
+
if (env.RUNCASTLE_BURN_GUARD !== undefined) {
|
|
179
|
+
overrides.burnGuard = env.RUNCASTLE_BURN_GUARD !== "0" && env.RUNCASTLE_BURN_GUARD !== "false";
|
|
180
|
+
}
|
|
156
181
|
if (env.RUNCASTLE_SETUP_COMMAND)
|
|
157
182
|
overrides.setupCommand = env.RUNCASTLE_SETUP_COMMAND;
|
|
183
|
+
if (env.RUNCASTLE_VERIFY_COMMANDS)
|
|
184
|
+
overrides.verifyCommands = env.RUNCASTLE_VERIFY_COMMANDS;
|
|
185
|
+
if (env.RUNCASTLE_KNOWN_FAILURES)
|
|
186
|
+
overrides.knownFailures = env.RUNCASTLE_KNOWN_FAILURES;
|
|
158
187
|
if (env.RUNCASTLE_BURN_WORKSPACE)
|
|
159
188
|
overrides.burnWorkspace = env.RUNCASTLE_BURN_WORKSPACE;
|
|
160
189
|
const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
|
|
@@ -189,7 +218,7 @@ var init_ids = () => {};
|
|
|
189
218
|
|
|
190
219
|
// ../core/src/schemas.ts
|
|
191
220
|
import * as z2 from "zod";
|
|
192
|
-
var Phase, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, Project, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
|
|
221
|
+
var Phase, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, PREPARED_KEYS, PreparedKey, FindingSource, Project, ProjectFinding, PrepStatus, PrepRun, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
|
|
193
222
|
var init_schemas = __esm(() => {
|
|
194
223
|
Phase = z2.enum([
|
|
195
224
|
"ideation",
|
|
@@ -242,13 +271,44 @@ var init_schemas = __esm(() => {
|
|
|
242
271
|
lastSessionId: z2.string().optional(),
|
|
243
272
|
summary: z2.string().optional()
|
|
244
273
|
});
|
|
274
|
+
PREPARED_KEYS = [
|
|
275
|
+
"setupCommand",
|
|
276
|
+
"verifyCommands",
|
|
277
|
+
"knownFailures",
|
|
278
|
+
"devCommand",
|
|
279
|
+
"dbResetCommand"
|
|
280
|
+
];
|
|
281
|
+
PreparedKey = z2.enum(PREPARED_KEYS);
|
|
282
|
+
FindingSource = z2.enum(["prep", "human"]);
|
|
245
283
|
Project = z2.object({
|
|
246
284
|
id: z2.string(),
|
|
247
285
|
name: z2.string(),
|
|
248
286
|
repoPath: z2.string(),
|
|
249
287
|
mainBranch: z2.string(),
|
|
250
288
|
devCommand: z2.string().optional(),
|
|
251
|
-
model: z2.string().optional()
|
|
289
|
+
model: z2.string().optional(),
|
|
290
|
+
setupCommand: z2.string().optional(),
|
|
291
|
+
verifyCommands: z2.string().optional(),
|
|
292
|
+
knownFailures: z2.string().optional(),
|
|
293
|
+
dbResetCommand: z2.string().optional()
|
|
294
|
+
});
|
|
295
|
+
ProjectFinding = z2.object({
|
|
296
|
+
key: PreparedKey,
|
|
297
|
+
source: FindingSource,
|
|
298
|
+
evidence: z2.string().optional(),
|
|
299
|
+
establishedAt: z2.number(),
|
|
300
|
+
establishedSha: z2.string().optional(),
|
|
301
|
+
staleCommits: z2.number().optional()
|
|
302
|
+
});
|
|
303
|
+
PrepStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
|
|
304
|
+
PrepRun = z2.object({
|
|
305
|
+
id: z2.string(),
|
|
306
|
+
projectId: z2.string(),
|
|
307
|
+
status: PrepStatus,
|
|
308
|
+
startedAt: z2.number(),
|
|
309
|
+
endedAt: z2.number().optional(),
|
|
310
|
+
summary: z2.string().optional(),
|
|
311
|
+
headSha: z2.string().optional()
|
|
252
312
|
});
|
|
253
313
|
Feature = z2.object({
|
|
254
314
|
id: z2.string(),
|
|
@@ -447,8 +507,8 @@ var init_pipeline = __esm(() => {
|
|
|
447
507
|
});
|
|
448
508
|
|
|
449
509
|
// ../core/src/db-schema.ts
|
|
450
|
-
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
451
|
-
var projects, features, sessions, tickets, waypoints, runs, events, gateOverrides;
|
|
510
|
+
import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
511
|
+
var projects, projectFindings, projectPreps, features, sessions, tickets, waypoints, runs, events, gateOverrides;
|
|
452
512
|
var init_db_schema = __esm(() => {
|
|
453
513
|
projects = sqliteTable("projects", {
|
|
454
514
|
id: text("id").primaryKey(),
|
|
@@ -458,7 +518,28 @@ var init_db_schema = __esm(() => {
|
|
|
458
518
|
devCommand: text("dev_command"),
|
|
459
519
|
model: text("model"),
|
|
460
520
|
sandbox: text("sandbox"),
|
|
461
|
-
closedAt: integer("closed_at")
|
|
521
|
+
closedAt: integer("closed_at"),
|
|
522
|
+
setupCommand: text("setup_command"),
|
|
523
|
+
verifyCommands: text("verify_commands"),
|
|
524
|
+
knownFailures: text("known_failures"),
|
|
525
|
+
dbResetCommand: text("db_reset_command")
|
|
526
|
+
});
|
|
527
|
+
projectFindings = sqliteTable("project_findings", {
|
|
528
|
+
projectId: text("project_id").notNull(),
|
|
529
|
+
key: text("key").notNull(),
|
|
530
|
+
source: text("source").notNull().$type(),
|
|
531
|
+
evidence: text("evidence"),
|
|
532
|
+
establishedAt: integer("established_at").notNull(),
|
|
533
|
+
establishedSha: text("established_sha")
|
|
534
|
+
}, (t) => [primaryKey({ columns: [t.projectId, t.key] })]);
|
|
535
|
+
projectPreps = sqliteTable("project_preps", {
|
|
536
|
+
id: text("id").primaryKey(),
|
|
537
|
+
projectId: text("project_id").notNull(),
|
|
538
|
+
status: text("status").notNull().$type(),
|
|
539
|
+
startedAt: integer("started_at").notNull(),
|
|
540
|
+
endedAt: integer("ended_at"),
|
|
541
|
+
summary: text("summary"),
|
|
542
|
+
headSha: text("head_sha")
|
|
462
543
|
});
|
|
463
544
|
features = sqliteTable("features", {
|
|
464
545
|
id: text("id").primaryKey(),
|
|
@@ -562,7 +643,9 @@ var init_schema = __esm(() => {
|
|
|
562
643
|
waypoints,
|
|
563
644
|
runs,
|
|
564
645
|
events,
|
|
565
|
-
gateOverrides
|
|
646
|
+
gateOverrides,
|
|
647
|
+
projectFindings,
|
|
648
|
+
projectPreps
|
|
566
649
|
};
|
|
567
650
|
});
|
|
568
651
|
|
|
@@ -659,6 +742,72 @@ var init_migrate = __esm(() => {
|
|
|
659
742
|
// src/util/resolve-executable.ts
|
|
660
743
|
import { existsSync as existsSync3 } from "fs";
|
|
661
744
|
import { win32, posix } from "path";
|
|
745
|
+
function wellKnownBinDirs(opts = {}) {
|
|
746
|
+
const env = opts.env ?? process.env;
|
|
747
|
+
const isWin = (opts.platform ?? process.platform) === "win32";
|
|
748
|
+
const dirs = [];
|
|
749
|
+
if (isWin) {
|
|
750
|
+
const home2 = env.USERPROFILE;
|
|
751
|
+
if (home2) {
|
|
752
|
+
dirs.push(win32.join(home2, ".local", "bin"), win32.join(home2, ".bun", "bin"));
|
|
753
|
+
}
|
|
754
|
+
if (env.APPDATA)
|
|
755
|
+
dirs.push(win32.join(env.APPDATA, "npm"));
|
|
756
|
+
if (env.LOCALAPPDATA)
|
|
757
|
+
dirs.push(win32.join(env.LOCALAPPDATA, "Programs", "claude"));
|
|
758
|
+
return dirs;
|
|
759
|
+
}
|
|
760
|
+
const home = env.HOME;
|
|
761
|
+
if (home) {
|
|
762
|
+
dirs.push(posix.join(home, ".local", "bin"), posix.join(home, ".bun", "bin"), posix.join(home, ".npm-global", "bin"));
|
|
763
|
+
}
|
|
764
|
+
dirs.push("/usr/local/bin", "/opt/homebrew/bin");
|
|
765
|
+
return dirs;
|
|
766
|
+
}
|
|
767
|
+
function resolveTool(name, opts = {}) {
|
|
768
|
+
const env = opts.env ?? process.env;
|
|
769
|
+
const overrideKey = BIN_OVERRIDE_ENV[name];
|
|
770
|
+
const override = opts.override ?? (overrideKey ? env[overrideKey] : undefined);
|
|
771
|
+
const extraDirs = opts.extraDirs ?? wellKnownBinDirs({ ...opts.platform ? { platform: opts.platform } : {}, env });
|
|
772
|
+
return resolveExecutable(name, { ...opts, extraDirs, ...override ? { override } : {} });
|
|
773
|
+
}
|
|
774
|
+
function spawnTargetFor(resolved, args) {
|
|
775
|
+
if (/\.(cmd|bat)$/i.test(resolved)) {
|
|
776
|
+
return { file: process.env.ComSpec ?? "cmd.exe", args: ["/c", resolved, ...args] };
|
|
777
|
+
}
|
|
778
|
+
if (/\.ps1$/i.test(resolved)) {
|
|
779
|
+
return {
|
|
780
|
+
file: "powershell.exe",
|
|
781
|
+
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolved, ...args]
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
return { file: resolved, args };
|
|
785
|
+
}
|
|
786
|
+
function resolveSpawnTarget(name, args) {
|
|
787
|
+
return spawnTargetFor(resolveTool(name), args);
|
|
788
|
+
}
|
|
789
|
+
function isBareName(cmd) {
|
|
790
|
+
return !cmd.includes("/") && !cmd.includes("\\");
|
|
791
|
+
}
|
|
792
|
+
function explainSpawnFailure(cmd, raw) {
|
|
793
|
+
const detail = raw.trim();
|
|
794
|
+
if (!isBareName(cmd)) {
|
|
795
|
+
return `Could not launch ${cmd}${detail ? ` \u2014 ${detail}` : ""}`;
|
|
796
|
+
}
|
|
797
|
+
const overrideKey = BIN_OVERRIDE_ENV[cmd];
|
|
798
|
+
const lines = [
|
|
799
|
+
`Could not launch \`${cmd}\`: not on the PATH this runcastle server inherited, nor in the usual install locations (~/.local/bin, npm global, ~/.bun/bin).`,
|
|
800
|
+
`A terminal where \`${cmd}\` works does not prove the server can see it: the server's PATH was captured when it started.`,
|
|
801
|
+
`Fix: quit runcastle and start it again from a terminal where \`${cmd} --version\` works.`
|
|
802
|
+
];
|
|
803
|
+
if (overrideKey) {
|
|
804
|
+
lines.push(`If that fails, set ${overrideKey} to the full path (\`where.exe ${cmd}\` / \`which ${cmd}\`) and restart.`);
|
|
805
|
+
}
|
|
806
|
+
if (detail)
|
|
807
|
+
lines.push(`(underlying error: ${detail})`);
|
|
808
|
+
return lines.join(`
|
|
809
|
+
`);
|
|
810
|
+
}
|
|
662
811
|
function resolveExecutable(name, opts = {}) {
|
|
663
812
|
const exists = opts.exists ?? existsSync3;
|
|
664
813
|
if (opts.override && exists(opts.override))
|
|
@@ -666,22 +815,28 @@ function resolveExecutable(name, opts = {}) {
|
|
|
666
815
|
const isWin = (opts.platform ?? process.platform) === "win32";
|
|
667
816
|
const exts = opts.exts ?? (isWin ? WIN_EXTS : [""]);
|
|
668
817
|
const pathEnv = opts.pathEnv ?? process.env.PATH ?? "";
|
|
669
|
-
const dirs = pathEnv.split(isWin ? ";" : ":");
|
|
670
818
|
const join5 = isWin ? win32.join : posix.join;
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
|
|
819
|
+
const scan = (dirs) => {
|
|
820
|
+
for (const dir of dirs) {
|
|
821
|
+
if (!dir)
|
|
822
|
+
continue;
|
|
823
|
+
for (const ext of exts) {
|
|
824
|
+
const candidate = join5(dir, `${name}${ext}`);
|
|
825
|
+
if (exists(candidate))
|
|
826
|
+
return candidate;
|
|
827
|
+
}
|
|
678
828
|
}
|
|
679
|
-
|
|
680
|
-
|
|
829
|
+
return null;
|
|
830
|
+
};
|
|
831
|
+
return scan(pathEnv.split(isWin ? ";" : ":")) ?? scan(opts.extraDirs ?? []) ?? name;
|
|
681
832
|
}
|
|
682
|
-
var WIN_EXTS;
|
|
833
|
+
var WIN_EXTS, BIN_OVERRIDE_ENV;
|
|
683
834
|
var init_resolve_executable = __esm(() => {
|
|
684
|
-
WIN_EXTS = [".exe", ".cmd", ".bat", ""];
|
|
835
|
+
WIN_EXTS = [".exe", ".cmd", ".bat", ".ps1", ""];
|
|
836
|
+
BIN_OVERRIDE_ENV = {
|
|
837
|
+
claude: "RUNCASTLE_CLAUDE_BIN",
|
|
838
|
+
node: "RUNCASTLE_NODE_BIN"
|
|
839
|
+
};
|
|
685
840
|
});
|
|
686
841
|
|
|
687
842
|
// src/pty/pty-sidecar.ts
|
|
@@ -698,7 +853,7 @@ function resolveNodeExecutable() {
|
|
|
698
853
|
return override;
|
|
699
854
|
if (!isBun())
|
|
700
855
|
return process.execPath;
|
|
701
|
-
return
|
|
856
|
+
return resolveTool("node", { exts: process.platform === "win32" ? [".exe", ".cmd", ""] : [""] });
|
|
702
857
|
}
|
|
703
858
|
function resolveNodePtyEntry() {
|
|
704
859
|
try {
|
|
@@ -766,11 +921,17 @@ function createSidecarPtySession(cmd, args, opts) {
|
|
|
766
921
|
case "exit":
|
|
767
922
|
fireExit(typeof msg.code === "number" ? msg.code : 0, msg.signal ?? undefined);
|
|
768
923
|
break;
|
|
769
|
-
case "error":
|
|
770
|
-
|
|
924
|
+
case "error": {
|
|
925
|
+
const why = explainSpawnFailure(cmd, msg.message ?? "unknown");
|
|
926
|
+
process.stderr.write(`[pty-sidecar] host error: ${why}
|
|
771
927
|
`);
|
|
928
|
+
emitData(Buffer.from(`\r
|
|
929
|
+
${why.replace(/\n/g, `\r
|
|
930
|
+
`)}\r
|
|
931
|
+
`, "utf8"));
|
|
772
932
|
fireExit(1);
|
|
773
933
|
break;
|
|
934
|
+
}
|
|
774
935
|
}
|
|
775
936
|
}
|
|
776
937
|
});
|
|
@@ -779,8 +940,13 @@ function createSidecarPtySession(cmd, args, opts) {
|
|
|
779
940
|
process.stderr.write(chunk);
|
|
780
941
|
});
|
|
781
942
|
child.on("error", (err) => {
|
|
782
|
-
|
|
943
|
+
const why = explainSpawnFailure(nodeExe, err.message);
|
|
944
|
+
process.stderr.write(`[pty-sidecar] failed to spawn node host: ${why}
|
|
783
945
|
`);
|
|
946
|
+
emitData(Buffer.from(`\r
|
|
947
|
+
${why.replace(/\n/g, `\r
|
|
948
|
+
`)}\r
|
|
949
|
+
`, "utf8"));
|
|
784
950
|
fireExit(1);
|
|
785
951
|
});
|
|
786
952
|
child.on("exit", (code, signal) => {
|
|
@@ -915,14 +1081,19 @@ function createPtySession(cmd, args, opts) {
|
|
|
915
1081
|
}
|
|
916
1082
|
function createNativePtySession(cmd, args, opts) {
|
|
917
1083
|
const pty = loadNodePty();
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1084
|
+
let proc;
|
|
1085
|
+
try {
|
|
1086
|
+
proc = pty.spawn(cmd, args, {
|
|
1087
|
+
name: "xterm-256color",
|
|
1088
|
+
cols: opts.cols ?? 80,
|
|
1089
|
+
rows: opts.rows ?? 24,
|
|
1090
|
+
cwd: opts.cwd,
|
|
1091
|
+
env: cleanEnv(opts.env),
|
|
1092
|
+
useConpty: opts.useConpty ?? true
|
|
1093
|
+
});
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
throw new Error(explainSpawnFailure(cmd, err instanceof Error ? err.message : String(err)));
|
|
1096
|
+
}
|
|
926
1097
|
let killed = false;
|
|
927
1098
|
proc.onExit(() => {
|
|
928
1099
|
killed = true;
|
|
@@ -957,6 +1128,7 @@ function createNativePtySession(cmd, args, opts) {
|
|
|
957
1128
|
}
|
|
958
1129
|
var cached = null, BACKEND_LOGGED;
|
|
959
1130
|
var init_pty = __esm(() => {
|
|
1131
|
+
init_resolve_executable();
|
|
960
1132
|
init_pty_sidecar();
|
|
961
1133
|
BACKEND_LOGGED = Symbol.for("runcastle.pty.backend.logged");
|
|
962
1134
|
});
|
|
@@ -1238,7 +1410,11 @@ function rowToProject(row) {
|
|
|
1238
1410
|
repoPath: row.repoPath,
|
|
1239
1411
|
mainBranch: row.mainBranch,
|
|
1240
1412
|
devCommand: row.devCommand ?? undefined,
|
|
1241
|
-
model: row.model ?? undefined
|
|
1413
|
+
model: row.model ?? undefined,
|
|
1414
|
+
setupCommand: row.setupCommand ?? undefined,
|
|
1415
|
+
verifyCommands: row.verifyCommands ?? undefined,
|
|
1416
|
+
knownFailures: row.knownFailures ?? undefined,
|
|
1417
|
+
dbResetCommand: row.dbResetCommand ?? undefined
|
|
1242
1418
|
};
|
|
1243
1419
|
}
|
|
1244
1420
|
function rowToRun(row) {
|
|
@@ -1570,34 +1746,159 @@ function writeKickoffSequence(line, io, submitDelayMs = KICKOFF_SUBMIT_DELAY_MS)
|
|
|
1570
1746
|
}, submitDelayMs);
|
|
1571
1747
|
submit.unref?.();
|
|
1572
1748
|
}
|
|
1573
|
-
function
|
|
1574
|
-
const
|
|
1575
|
-
|
|
1576
|
-
|
|
1749
|
+
function stopTimers(d) {
|
|
1750
|
+
for (const t of d.timers)
|
|
1751
|
+
clearTimeout(t);
|
|
1752
|
+
d.timers.clear();
|
|
1753
|
+
}
|
|
1754
|
+
function forgetKickoff(sessionId) {
|
|
1755
|
+
const d = deliveries.get(sessionId);
|
|
1756
|
+
if (d)
|
|
1757
|
+
stopTimers(d);
|
|
1758
|
+
deliveries.delete(sessionId);
|
|
1759
|
+
pendingKickoffOverrides.delete(sessionId);
|
|
1760
|
+
}
|
|
1761
|
+
function ptyIo(sessionId) {
|
|
1762
|
+
return {
|
|
1763
|
+
write: (data) => ptyRegistry().get(sessionId)?.pty.write(data),
|
|
1764
|
+
alive: () => {
|
|
1765
|
+
const entry = ptyRegistry().get(sessionId);
|
|
1766
|
+
return !!entry && !entry.exited;
|
|
1767
|
+
}
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
function track(d, timer) {
|
|
1771
|
+
timer.unref?.();
|
|
1772
|
+
d.timers.add(timer);
|
|
1773
|
+
}
|
|
1774
|
+
function attemptKickoff(ctx, session, d, delayMs, clearFirst = false) {
|
|
1775
|
+
track(d, setTimeout(() => {
|
|
1776
|
+
if (d.settled)
|
|
1777
|
+
return;
|
|
1778
|
+
const io = ptyIo(session.id);
|
|
1779
|
+
if (!io.alive()) {
|
|
1780
|
+
d.settled = true;
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
d.attempts += 1;
|
|
1784
|
+
const attempt = d.attempts;
|
|
1577
1785
|
try {
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
data: { sessionId: session.id, kind: session.kind }
|
|
1589
|
-
});
|
|
1590
|
-
}
|
|
1786
|
+
if (clearFirst)
|
|
1787
|
+
io.write(CLEAR_INPUT);
|
|
1788
|
+
writeKickoffSequence(d.line, {
|
|
1789
|
+
write: io.write,
|
|
1790
|
+
alive: io.alive,
|
|
1791
|
+
onSubmitted: () => emit(ctx, session.featureId, {
|
|
1792
|
+
type: "session.kickoff",
|
|
1793
|
+
message: attempt === 1 ? `${session.kind} session kicked off automatically` : `${session.kind} kickoff re-sent (attempt ${attempt}) \u2014 the first was never acknowledged`,
|
|
1794
|
+
data: { sessionId: session.id, kind: session.kind, attempt }
|
|
1795
|
+
})
|
|
1591
1796
|
});
|
|
1592
1797
|
} catch {}
|
|
1593
|
-
|
|
1798
|
+
armConfirmation(ctx, session, d);
|
|
1799
|
+
}, delayMs));
|
|
1800
|
+
}
|
|
1801
|
+
function armConfirmation(ctx, session, d) {
|
|
1802
|
+
track(d, setTimeout(() => {
|
|
1803
|
+
if (d.settled || d.confirmed)
|
|
1804
|
+
return;
|
|
1805
|
+
if (d.attempts < KICKOFF_MAX_ATTEMPTS && ptyIo(session.id).alive()) {
|
|
1806
|
+
attemptKickoff(ctx, session, d, 0, true);
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
settleUndelivered(ctx, session, d, "unacknowledged");
|
|
1810
|
+
}, KICKOFF_CONFIRM_MS));
|
|
1811
|
+
}
|
|
1812
|
+
function settleUndelivered(ctx, session, d, reason) {
|
|
1813
|
+
d.settled = true;
|
|
1814
|
+
stopTimers(d);
|
|
1815
|
+
emit(ctx, session.featureId, {
|
|
1816
|
+
type: "session.kickoff_undelivered",
|
|
1817
|
+
message: reason === "superseded" ? `the ${session.kind} briefing was never delivered \u2014 you typed first, so runcastle stopped injecting it` : `the ${session.kind} briefing was typed ${d.attempts}\xD7 but Claude Code never acknowledged it \u2014 send it again from the session strip`,
|
|
1818
|
+
data: { sessionId: session.id, kind: session.kind, reason, attempts: d.attempts, line: d.line }
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
function noteKickoffPrompt(ctx, sessionId, prompt) {
|
|
1822
|
+
const d = deliveries.get(sessionId);
|
|
1823
|
+
if (!d || d.confirmed)
|
|
1824
|
+
return;
|
|
1825
|
+
const session = getSessionRow(ctx, sessionId);
|
|
1826
|
+
if (!session)
|
|
1827
|
+
return;
|
|
1828
|
+
if (promptMatchesKickoff(d.line, prompt)) {
|
|
1829
|
+
d.confirmed = true;
|
|
1830
|
+
d.settled = true;
|
|
1831
|
+
stopTimers(d);
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
if (d.settled)
|
|
1835
|
+
return;
|
|
1836
|
+
settleUndelivered(ctx, session, d, "superseded");
|
|
1837
|
+
}
|
|
1838
|
+
function promptMatchesKickoff(line, prompt) {
|
|
1839
|
+
if (!prompt)
|
|
1840
|
+
return false;
|
|
1841
|
+
const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
|
|
1842
|
+
const want = norm(line);
|
|
1843
|
+
const got = norm(prompt);
|
|
1844
|
+
if (!want || !got)
|
|
1845
|
+
return false;
|
|
1846
|
+
return got.includes(want.slice(0, MATCH_PREFIX));
|
|
1847
|
+
}
|
|
1848
|
+
function resendKickoff(ctx, sessionId) {
|
|
1849
|
+
const session = getSessionRow(ctx, sessionId);
|
|
1850
|
+
if (!session)
|
|
1851
|
+
throw new Error(`unknown session ${sessionId}`);
|
|
1852
|
+
if (session.status === "ended")
|
|
1853
|
+
throw new Error("that session has ended \u2014 open a new terminal");
|
|
1854
|
+
if (!ptyIo(sessionId).alive())
|
|
1855
|
+
throw new Error("the terminal for that session is no longer running");
|
|
1856
|
+
const existing = deliveries.get(sessionId);
|
|
1857
|
+
const d = existing ?? {
|
|
1858
|
+
line: kickoffLineFor(session.kind, pendingKickoffOverrides.get(sessionId)),
|
|
1859
|
+
attempts: 0,
|
|
1860
|
+
confirmed: false,
|
|
1861
|
+
settled: false,
|
|
1862
|
+
timers: new Set
|
|
1863
|
+
};
|
|
1864
|
+
stopTimers(d);
|
|
1865
|
+
d.attempts = 0;
|
|
1866
|
+
d.confirmed = false;
|
|
1867
|
+
d.settled = false;
|
|
1868
|
+
deliveries.set(sessionId, d);
|
|
1869
|
+
attemptKickoff(ctx, session, d, 0, true);
|
|
1870
|
+
return { line: d.line };
|
|
1871
|
+
}
|
|
1872
|
+
function armSessionReadyWatchdog(ctx, session) {
|
|
1873
|
+
const timer = setTimeout(() => {
|
|
1874
|
+
const row = getSessionRow(ctx, session.id);
|
|
1875
|
+
if (!row || row.status !== "launching")
|
|
1876
|
+
return;
|
|
1877
|
+
if (!ptyIo(session.id).alive())
|
|
1878
|
+
return;
|
|
1879
|
+
emit(ctx, session.featureId, {
|
|
1880
|
+
type: "session.not_ready",
|
|
1881
|
+
message: `the ${session.kind} terminal is open but Claude Code has not reported ready \u2014 check it for a prompt or dialog waiting on you, then send the briefing`,
|
|
1882
|
+
data: { sessionId: session.id, kind: session.kind }
|
|
1883
|
+
});
|
|
1884
|
+
}, SESSION_READY_TIMEOUT_MS);
|
|
1594
1885
|
timer.unref?.();
|
|
1595
1886
|
}
|
|
1887
|
+
function scheduleKickoff(ctx, session) {
|
|
1888
|
+
const line = kickoffLineFor(session.kind, pendingKickoffOverrides.get(session.id));
|
|
1889
|
+
pendingKickoffOverrides.delete(session.id);
|
|
1890
|
+
const existing = deliveries.get(session.id);
|
|
1891
|
+
if (existing)
|
|
1892
|
+
stopTimers(existing);
|
|
1893
|
+
const d = { line, attempts: 0, confirmed: false, settled: false, timers: new Set };
|
|
1894
|
+
deliveries.set(session.id, d);
|
|
1895
|
+
attemptKickoff(ctx, session, d, KICKOFF_DELAY_MS);
|
|
1896
|
+
}
|
|
1596
1897
|
function markSessionEnded(ctx, id) {
|
|
1597
1898
|
const existing = getSessionRow(ctx, id);
|
|
1598
1899
|
if (!existing)
|
|
1599
1900
|
return null;
|
|
1600
|
-
|
|
1901
|
+
forgetKickoff(id);
|
|
1601
1902
|
ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
|
|
1602
1903
|
return getSessionRow(ctx, id);
|
|
1603
1904
|
}
|
|
@@ -1609,7 +1910,7 @@ function mostRecentLiveSession(ctx) {
|
|
|
1609
1910
|
const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
|
|
1610
1911
|
return row ? rowToSession(row) : null;
|
|
1611
1912
|
}
|
|
1612
|
-
var KICKOFF_DELAY_MS = 1500, KICKOFF_SUBMIT_DELAY_MS = 350, CONVERGE_KICKOFF_LINE, KICKOFF_LINES, RESUME_KICKOFF_PREFIX, pendingKickoffOverrides;
|
|
1913
|
+
var KICKOFF_DELAY_MS = 1500, KICKOFF_SUBMIT_DELAY_MS = 350, KICKOFF_CONFIRM_MS = 12000, KICKOFF_MAX_ATTEMPTS = 3, CLEAR_INPUT = "\x15", SESSION_READY_TIMEOUT_MS = 25000, CONVERGE_KICKOFF_LINE, KICKOFF_LINES, RESUME_KICKOFF_PREFIX, pendingKickoffOverrides, deliveries, MATCH_PREFIX = 40;
|
|
1613
1914
|
var init_sessions = __esm(() => {
|
|
1614
1915
|
init_src();
|
|
1615
1916
|
init_schema();
|
|
@@ -1627,6 +1928,7 @@ var init_sessions = __esm(() => {
|
|
|
1627
1928
|
};
|
|
1628
1929
|
RESUME_KICKOFF_PREFIX = "We are picking this conversation back up \u2014 runcastle restarted and closed the terminal, " + "but this conversation is intact. Do NOT start over: tell me in one or two lines where we " + "left off and what is next, then carry on. Your original instruction was: ";
|
|
1629
1930
|
pendingKickoffOverrides = new Map;
|
|
1931
|
+
deliveries = new Map;
|
|
1630
1932
|
});
|
|
1631
1933
|
|
|
1632
1934
|
// src/launcher/reconcile.ts
|
|
@@ -1662,315 +1964,93 @@ var init_reconcile = __esm(() => {
|
|
|
1662
1964
|
init_sessions();
|
|
1663
1965
|
});
|
|
1664
1966
|
|
|
1665
|
-
// src/launcher/
|
|
1666
|
-
function setRuntimeCtx(ctx) {
|
|
1667
|
-
current = ctx;
|
|
1668
|
-
}
|
|
1669
|
-
async function getRuntimeCtx() {
|
|
1670
|
-
if (current)
|
|
1671
|
-
return current;
|
|
1672
|
-
const [{ createDb: createDb2 }, { dbPath: dbPath2 }, { loadConfig: loadConfig2 }] = await Promise.all([
|
|
1673
|
-
Promise.resolve().then(() => (init_client(), exports_client)),
|
|
1674
|
-
Promise.resolve().then(() => (init_paths(), exports_paths)),
|
|
1675
|
-
Promise.resolve().then(() => (init_config_load(), exports_config_load))
|
|
1676
|
-
]);
|
|
1677
|
-
const db = createDb2(dbPath2());
|
|
1678
|
-
current = { db, config: loadConfig2() };
|
|
1679
|
-
return current;
|
|
1680
|
-
}
|
|
1681
|
-
var current = null;
|
|
1682
|
-
|
|
1683
|
-
// src/services/feature-docs.ts
|
|
1967
|
+
// src/launcher/skills-root.ts
|
|
1684
1968
|
import { existsSync as existsSync5 } from "fs";
|
|
1685
|
-
import { join as join5 } from "path";
|
|
1686
|
-
function
|
|
1687
|
-
const
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1969
|
+
import { dirname, join as join5, resolve as resolve3 } from "path";
|
|
1970
|
+
function resolveSkillsRoot(fromDir) {
|
|
1971
|
+
const override = process.env[SKILLS_DIR_ENV];
|
|
1972
|
+
if (override) {
|
|
1973
|
+
const root = resolve3(override);
|
|
1974
|
+
if (existsSync5(join5(root, SKILLS_MARKER)))
|
|
1975
|
+
return root;
|
|
1976
|
+
throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
|
|
1977
|
+
}
|
|
1978
|
+
const searched = [];
|
|
1979
|
+
let dir = fromDir;
|
|
1980
|
+
for (let i = 0;i < 8; i += 1) {
|
|
1981
|
+
const candidate = join5(dir, "packages", "skills");
|
|
1982
|
+
searched.push(candidate);
|
|
1983
|
+
if (existsSync5(join5(candidate, SKILLS_MARKER)))
|
|
1984
|
+
return candidate;
|
|
1985
|
+
const parent = dirname(dir);
|
|
1986
|
+
if (parent === dir)
|
|
1987
|
+
break;
|
|
1988
|
+
dir = parent;
|
|
1989
|
+
}
|
|
1990
|
+
throw new Error(`runcastle skills root not found; searched:
|
|
1991
|
+
${searched.join(`
|
|
1992
|
+
`)}`);
|
|
1694
1993
|
}
|
|
1695
|
-
var
|
|
1696
|
-
|
|
1994
|
+
var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
|
|
1995
|
+
var init_skills_root = __esm(() => {
|
|
1996
|
+
SKILLS_MARKER = join5("packs", "runcastle");
|
|
1697
1997
|
});
|
|
1698
1998
|
|
|
1699
|
-
// src/
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
return {
|
|
1703
|
-
id: row.id,
|
|
1704
|
-
featureId: row.featureId,
|
|
1705
|
-
seq: row.seq,
|
|
1706
|
-
title: row.title,
|
|
1707
|
-
goal: row.goal,
|
|
1708
|
-
context: row.context,
|
|
1709
|
-
acceptanceCriteria: row.acceptanceCriteria,
|
|
1710
|
-
seams: row.seams,
|
|
1711
|
-
blockedBy: row.blockedBy,
|
|
1712
|
-
status: row.status,
|
|
1713
|
-
commits: row.commits,
|
|
1714
|
-
error: row.error ?? undefined,
|
|
1715
|
-
attemptBranch: row.attemptBranch ?? undefined,
|
|
1716
|
-
conflictFiles: row.conflictFiles ?? undefined
|
|
1717
|
-
};
|
|
1999
|
+
// src/pty/dev-pane.ts
|
|
2000
|
+
function drivePaneId(featureId) {
|
|
2001
|
+
return `${DRIVE_PREFIX}${featureId}`;
|
|
1718
2002
|
}
|
|
1719
|
-
function
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
} catch (e) {
|
|
1723
|
-
if (e instanceof BlockingEdgeError)
|
|
1724
|
-
throw new InvalidInputError(e.message);
|
|
1725
|
-
throw e;
|
|
1726
|
-
}
|
|
2003
|
+
function sniffDevUrl(text2) {
|
|
2004
|
+
const m = text2.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s"'<>`\x1b]*)?/i);
|
|
2005
|
+
return m ? m[0] : undefined;
|
|
1727
2006
|
}
|
|
1728
|
-
function
|
|
1729
|
-
|
|
2007
|
+
function devSpawnTarget(devCommand) {
|
|
2008
|
+
if (process.platform === "win32") {
|
|
2009
|
+
return { file: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", devCommand] };
|
|
2010
|
+
}
|
|
2011
|
+
return { file: "/bin/sh", args: ["-c", devCommand] };
|
|
1730
2012
|
}
|
|
1731
|
-
function
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
const
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
2013
|
+
function startDevPane(input) {
|
|
2014
|
+
const { ctx, featureId, repoPath, devCommand, onUrl } = input;
|
|
2015
|
+
const paneId = drivePaneId(featureId);
|
|
2016
|
+
const { file, args } = devSpawnTarget(devCommand);
|
|
2017
|
+
try {
|
|
2018
|
+
ptyRegistry().create({
|
|
2019
|
+
sessionId: paneId,
|
|
2020
|
+
cmd: file,
|
|
2021
|
+
args,
|
|
2022
|
+
opts: { cwd: repoPath, env: process.env, cols: 80, rows: 24, useConpty: true }
|
|
2023
|
+
});
|
|
2024
|
+
} catch (err) {
|
|
2025
|
+
emit(ctx, featureId, {
|
|
2026
|
+
type: "testdrive.dev_failed",
|
|
2027
|
+
message: `dev server failed to start: ${err instanceof Error ? err.message : String(err)}`,
|
|
2028
|
+
data: { paneId }
|
|
2029
|
+
});
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
2032
|
+
let scanned = "";
|
|
2033
|
+
let found = false;
|
|
2034
|
+
const sniffer = {
|
|
2035
|
+
sendData(chunk) {
|
|
2036
|
+
if (found)
|
|
2037
|
+
return;
|
|
2038
|
+
scanned = (scanned + chunk.toString("utf8")).slice(-SNIFF_WINDOW);
|
|
2039
|
+
const url = sniffDevUrl(scanned);
|
|
2040
|
+
if (url) {
|
|
2041
|
+
found = true;
|
|
2042
|
+
onUrl(url);
|
|
2043
|
+
}
|
|
2044
|
+
},
|
|
2045
|
+
sendControl() {}
|
|
2046
|
+
};
|
|
2047
|
+
ptyRegistry().attach(paneId, sniffer);
|
|
1754
2048
|
emit(ctx, featureId, {
|
|
1755
|
-
type: "
|
|
1756
|
-
message:
|
|
1757
|
-
data: {
|
|
2049
|
+
type: "testdrive.dev_started",
|
|
2050
|
+
message: `dev server spawned: ${devCommand}`,
|
|
2051
|
+
data: { paneId }
|
|
1758
2052
|
});
|
|
1759
|
-
return
|
|
1760
|
-
}
|
|
1761
|
-
function getTicket(ctx, id) {
|
|
1762
|
-
const row = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
|
|
1763
|
-
if (!row)
|
|
1764
|
-
throw new NotFoundError(`ticket ${id} not found`);
|
|
1765
|
-
return rowToTicket(row);
|
|
1766
|
-
}
|
|
1767
|
-
function assertMutable(t, verb) {
|
|
1768
|
-
if (!MUTABLE_STATUSES.has(t.status)) {
|
|
1769
|
-
throw new InvalidInputError(`cannot ${verb} ticket ${t.seq} \u2014 it is ${t.status}; only pending or failed tickets can be ${verb}${verb.endsWith("l") ? "led" : "ed"}`);
|
|
1770
|
-
}
|
|
1771
|
-
}
|
|
1772
|
-
function editTicket(ctx, id, patch) {
|
|
1773
|
-
const current2 = getTicket(ctx, id);
|
|
1774
|
-
assertMutable(current2, "edit");
|
|
1775
|
-
const set = {};
|
|
1776
|
-
if (patch.title !== undefined)
|
|
1777
|
-
set.title = patch.title;
|
|
1778
|
-
if (patch.goal !== undefined)
|
|
1779
|
-
set.goal = patch.goal;
|
|
1780
|
-
if (patch.context !== undefined)
|
|
1781
|
-
set.context = patch.context;
|
|
1782
|
-
if (patch.acceptanceCriteria !== undefined)
|
|
1783
|
-
set.acceptanceCriteria = patch.acceptanceCriteria;
|
|
1784
|
-
if (patch.seams !== undefined)
|
|
1785
|
-
set.seams = patch.seams;
|
|
1786
|
-
if (Object.keys(set).length === 0) {
|
|
1787
|
-
throw new InvalidInputError("nothing to edit \u2014 pass at least one content field");
|
|
1788
|
-
}
|
|
1789
|
-
ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
|
|
1790
|
-
emit(ctx, current2.featureId, {
|
|
1791
|
-
type: "ticket.edited",
|
|
1792
|
-
message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
|
|
1793
|
-
ticketId: id,
|
|
1794
|
-
data: { fields: Object.keys(set) }
|
|
1795
|
-
});
|
|
1796
|
-
return getTicket(ctx, id);
|
|
1797
|
-
}
|
|
1798
|
-
function cancelTicket(ctx, id, reason) {
|
|
1799
|
-
const current2 = getTicket(ctx, id);
|
|
1800
|
-
assertMutable(current2, "cancel");
|
|
1801
|
-
ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq5(tickets.id, id)).run();
|
|
1802
|
-
emit(ctx, current2.featureId, {
|
|
1803
|
-
type: "ticket.cancelled",
|
|
1804
|
-
message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
|
|
1805
|
-
ticketId: id,
|
|
1806
|
-
data: { reason: reason ?? null }
|
|
1807
|
-
});
|
|
1808
|
-
return getTicket(ctx, id);
|
|
1809
|
-
}
|
|
1810
|
-
function updateTicket(ctx, id, patch) {
|
|
1811
|
-
const current2 = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
|
|
1812
|
-
if (!current2)
|
|
1813
|
-
throw new NotFoundError(`ticket ${id} not found`);
|
|
1814
|
-
const set = {};
|
|
1815
|
-
if (patch.status !== undefined)
|
|
1816
|
-
set.status = patch.status;
|
|
1817
|
-
if (patch.commits !== undefined)
|
|
1818
|
-
set.commits = patch.commits;
|
|
1819
|
-
if (patch.error !== undefined)
|
|
1820
|
-
set.error = patch.error;
|
|
1821
|
-
if (patch.attemptBranch !== undefined)
|
|
1822
|
-
set.attemptBranch = patch.attemptBranch;
|
|
1823
|
-
if (patch.conflictFiles !== undefined)
|
|
1824
|
-
set.conflictFiles = patch.conflictFiles;
|
|
1825
|
-
ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
|
|
1826
|
-
emit(ctx, current2.featureId, {
|
|
1827
|
-
type: "ticket.updated",
|
|
1828
|
-
message: `ticket ${current2.seq} \u2192 ${patch.status ?? current2.status}`,
|
|
1829
|
-
ticketId: id,
|
|
1830
|
-
data: patch
|
|
1831
|
-
});
|
|
1832
|
-
const updated = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
|
|
1833
|
-
return rowToTicket(updated);
|
|
1834
|
-
}
|
|
1835
|
-
var MUTABLE_STATUSES;
|
|
1836
|
-
var init_tickets = __esm(() => {
|
|
1837
|
-
init_src();
|
|
1838
|
-
init_schema();
|
|
1839
|
-
init_errors();
|
|
1840
|
-
init_events();
|
|
1841
|
-
MUTABLE_STATUSES = new Set(["pending", "failed"]);
|
|
1842
|
-
});
|
|
1843
|
-
|
|
1844
|
-
// src/services/gates.ts
|
|
1845
|
-
import { existsSync as existsSync6 } from "fs";
|
|
1846
|
-
function checkGate(ctx, check, feature) {
|
|
1847
|
-
switch (check) {
|
|
1848
|
-
case "decisions-file-exists":
|
|
1849
|
-
return fileGate(ctx, feature, "decisions.md", "run the ideation session to capture decisions first");
|
|
1850
|
-
case "all-waypoints-terminal": {
|
|
1851
|
-
const wps = listByFeature(ctx, feature.id);
|
|
1852
|
-
if (wps.length === 0) {
|
|
1853
|
-
return { satisfied: false, reason: "no waypoints charted yet \u2014 chart the map before converging" };
|
|
1854
|
-
}
|
|
1855
|
-
const open = wps.filter((w) => w.status !== "resolved" && w.status !== "dropped");
|
|
1856
|
-
return open.length === 0 ? { satisfied: true } : {
|
|
1857
|
-
satisfied: false,
|
|
1858
|
-
reason: notYetTerminal("waypoint", open.map((w) => w.status), ["open", "claimed"])
|
|
1859
|
-
};
|
|
1860
|
-
}
|
|
1861
|
-
case "spec-file-exists":
|
|
1862
|
-
return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
|
|
1863
|
-
case "tickets-approved": {
|
|
1864
|
-
const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
|
|
1865
|
-
return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
|
|
1866
|
-
}
|
|
1867
|
-
case "all-tickets-terminal": {
|
|
1868
|
-
const tickets2 = listByFeature2(ctx, feature.id);
|
|
1869
|
-
if (tickets2.length === 0)
|
|
1870
|
-
return { satisfied: false, reason: "no tickets have run yet" };
|
|
1871
|
-
const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
|
|
1872
|
-
return open.length === 0 ? { satisfied: true } : {
|
|
1873
|
-
satisfied: false,
|
|
1874
|
-
reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
|
|
1875
|
-
};
|
|
1876
|
-
}
|
|
1877
|
-
case "human-merge":
|
|
1878
|
-
return { satisfied: false, reason: "use the Merge button to ship" };
|
|
1879
|
-
}
|
|
1880
|
-
}
|
|
1881
|
-
function notYetTerminal(noun, statuses, order) {
|
|
1882
|
-
const counts = new Map;
|
|
1883
|
-
for (const s of statuses)
|
|
1884
|
-
counts.set(s, (counts.get(s) ?? 0) + 1);
|
|
1885
|
-
const ordered = [
|
|
1886
|
-
...order.filter((s) => counts.has(s)),
|
|
1887
|
-
...[...counts.keys()].filter((s) => !order.includes(s))
|
|
1888
|
-
];
|
|
1889
|
-
const breakdown = ordered.map((s) => `${counts.get(s)} ${s}`).join(", ");
|
|
1890
|
-
return `${statuses.length} ${statuses.length === 1 ? noun : `${noun}s`} not yet terminal (${breakdown})`;
|
|
1891
|
-
}
|
|
1892
|
-
function fileGate(ctx, feature, fileName, reason) {
|
|
1893
|
-
const project = projectForFeature(ctx, feature);
|
|
1894
|
-
return existsSync6(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
|
|
1895
|
-
}
|
|
1896
|
-
function overrideGate(ctx, featureId, gate, reason) {
|
|
1897
|
-
const feature = getFeatureRow(ctx, featureId);
|
|
1898
|
-
ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
|
|
1899
|
-
emit(ctx, featureId, {
|
|
1900
|
-
type: "gate.overridden",
|
|
1901
|
-
message: `gate ${gate} overridden: ${reason}`,
|
|
1902
|
-
data: { gate, reason }
|
|
1903
|
-
});
|
|
1904
|
-
const next = nextPhase(feature);
|
|
1905
|
-
if (next)
|
|
1906
|
-
return setPhase(ctx, featureId, next, "phase.advanced", `advanced to ${next} (override)`);
|
|
1907
|
-
return feature;
|
|
1908
|
-
}
|
|
1909
|
-
var init_gates = __esm(() => {
|
|
1910
|
-
init_src();
|
|
1911
|
-
init_schema();
|
|
1912
|
-
init_events();
|
|
1913
|
-
init_feature_docs();
|
|
1914
|
-
init_repo();
|
|
1915
|
-
init_tickets();
|
|
1916
|
-
init_waypoints();
|
|
1917
|
-
});
|
|
1918
|
-
|
|
1919
|
-
// src/pty/dev-pane.ts
|
|
1920
|
-
function drivePaneId(featureId) {
|
|
1921
|
-
return `${DRIVE_PREFIX}${featureId}`;
|
|
1922
|
-
}
|
|
1923
|
-
function sniffDevUrl(text2) {
|
|
1924
|
-
const m = text2.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s"'<>`\x1b]*)?/i);
|
|
1925
|
-
return m ? m[0] : undefined;
|
|
1926
|
-
}
|
|
1927
|
-
function devSpawnTarget(devCommand) {
|
|
1928
|
-
if (process.platform === "win32") {
|
|
1929
|
-
return { file: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", devCommand] };
|
|
1930
|
-
}
|
|
1931
|
-
return { file: "/bin/sh", args: ["-c", devCommand] };
|
|
1932
|
-
}
|
|
1933
|
-
function startDevPane(input) {
|
|
1934
|
-
const { ctx, featureId, repoPath, devCommand, onUrl } = input;
|
|
1935
|
-
const paneId = drivePaneId(featureId);
|
|
1936
|
-
const { file, args } = devSpawnTarget(devCommand);
|
|
1937
|
-
try {
|
|
1938
|
-
ptyRegistry().create({
|
|
1939
|
-
sessionId: paneId,
|
|
1940
|
-
cmd: file,
|
|
1941
|
-
args,
|
|
1942
|
-
opts: { cwd: repoPath, env: process.env, cols: 80, rows: 24, useConpty: true }
|
|
1943
|
-
});
|
|
1944
|
-
} catch (err) {
|
|
1945
|
-
emit(ctx, featureId, {
|
|
1946
|
-
type: "testdrive.dev_failed",
|
|
1947
|
-
message: `dev server failed to start: ${err instanceof Error ? err.message : String(err)}`,
|
|
1948
|
-
data: { paneId }
|
|
1949
|
-
});
|
|
1950
|
-
return;
|
|
1951
|
-
}
|
|
1952
|
-
let scanned = "";
|
|
1953
|
-
let found = false;
|
|
1954
|
-
const sniffer = {
|
|
1955
|
-
sendData(chunk) {
|
|
1956
|
-
if (found)
|
|
1957
|
-
return;
|
|
1958
|
-
scanned = (scanned + chunk.toString("utf8")).slice(-SNIFF_WINDOW);
|
|
1959
|
-
const url = sniffDevUrl(scanned);
|
|
1960
|
-
if (url) {
|
|
1961
|
-
found = true;
|
|
1962
|
-
onUrl(url);
|
|
1963
|
-
}
|
|
1964
|
-
},
|
|
1965
|
-
sendControl() {}
|
|
1966
|
-
};
|
|
1967
|
-
ptyRegistry().attach(paneId, sniffer);
|
|
1968
|
-
emit(ctx, featureId, {
|
|
1969
|
-
type: "testdrive.dev_started",
|
|
1970
|
-
message: `dev server spawned: ${devCommand}`,
|
|
1971
|
-
data: { paneId }
|
|
1972
|
-
});
|
|
1973
|
-
return paneId;
|
|
2053
|
+
return paneId;
|
|
1974
2054
|
}
|
|
1975
2055
|
function stopDevPane(paneId) {
|
|
1976
2056
|
const reg = ptyRegistry();
|
|
@@ -1990,10 +2070,13 @@ var init_dev_pane = __esm(() => {
|
|
|
1990
2070
|
});
|
|
1991
2071
|
|
|
1992
2072
|
// src/services/git.ts
|
|
1993
|
-
import { existsSync as
|
|
2073
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
|
|
1994
2074
|
import { tmpdir } from "os";
|
|
1995
|
-
import { dirname, join as join6, resolve as
|
|
2075
|
+
import { dirname as dirname2, join as join6, resolve as resolve4 } from "path";
|
|
1996
2076
|
import { simpleGit } from "simple-git";
|
|
2077
|
+
function migrationPaths(paths) {
|
|
2078
|
+
return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
|
|
2079
|
+
}
|
|
1997
2080
|
function featureBranch(slug) {
|
|
1998
2081
|
return `feature/${slug}`;
|
|
1999
2082
|
}
|
|
@@ -2004,14 +2087,14 @@ function errMsg(e) {
|
|
|
2004
2087
|
return e instanceof Error ? e.message : String(e);
|
|
2005
2088
|
}
|
|
2006
2089
|
function canon(p) {
|
|
2007
|
-
let abs =
|
|
2090
|
+
let abs = resolve4(p);
|
|
2008
2091
|
try {
|
|
2009
2092
|
abs = realpathSync.native(abs);
|
|
2010
2093
|
} catch {}
|
|
2011
2094
|
return process.platform === "win32" ? abs.replace(/\\/g, "/").toLowerCase() : abs;
|
|
2012
2095
|
}
|
|
2013
2096
|
async function assertRepo(repoPath) {
|
|
2014
|
-
if (!
|
|
2097
|
+
if (!existsSync6(repoPath)) {
|
|
2015
2098
|
throw new InvalidInputError(`path does not exist: ${repoPath}`);
|
|
2016
2099
|
}
|
|
2017
2100
|
let isRepo = false;
|
|
@@ -2100,7 +2183,7 @@ async function ensureTalkWorktree(project, feature) {
|
|
|
2100
2183
|
await ensureBranchExists(g, branch, feature.baseBranch ?? project.mainBranch);
|
|
2101
2184
|
if (await worktreeIsValid(g, worktreePath, branch))
|
|
2102
2185
|
return worktreePath;
|
|
2103
|
-
mkdirSync2(
|
|
2186
|
+
mkdirSync2(dirname2(worktreePath), { recursive: true });
|
|
2104
2187
|
try {
|
|
2105
2188
|
await g.raw(["worktree", "add", worktreePath, branch]);
|
|
2106
2189
|
return worktreePath;
|
|
@@ -2123,7 +2206,7 @@ async function ensureBranchExists(g, branch, from) {
|
|
|
2123
2206
|
}
|
|
2124
2207
|
}
|
|
2125
2208
|
async function worktreeIsValid(g, worktreePath, branch) {
|
|
2126
|
-
if (!
|
|
2209
|
+
if (!existsSync6(worktreePath))
|
|
2127
2210
|
return false;
|
|
2128
2211
|
const registered = await registeredWorktrees(g);
|
|
2129
2212
|
if (!registered.has(canon(worktreePath)))
|
|
@@ -2169,7 +2252,7 @@ async function worktreesOnBranch(g, branch, mainRepoPath) {
|
|
|
2169
2252
|
return (await listWorktrees(g)).filter((e) => e.branch === branch && canon(e.path) !== main).map((e) => e.path);
|
|
2170
2253
|
}
|
|
2171
2254
|
async function detachWorktree(path) {
|
|
2172
|
-
if (!
|
|
2255
|
+
if (!existsSync6(path))
|
|
2173
2256
|
return false;
|
|
2174
2257
|
const g = git(path);
|
|
2175
2258
|
let head;
|
|
@@ -2184,12 +2267,27 @@ async function detachWorktree(path) {
|
|
|
2184
2267
|
return true;
|
|
2185
2268
|
}
|
|
2186
2269
|
async function reattachWorktree(path, branch) {
|
|
2187
|
-
if (!
|
|
2270
|
+
if (!existsSync6(path))
|
|
2188
2271
|
return;
|
|
2189
2272
|
try {
|
|
2190
2273
|
await git(path).checkout(branch);
|
|
2191
2274
|
} catch {}
|
|
2192
2275
|
}
|
|
2276
|
+
function prepBranchName(unique) {
|
|
2277
|
+
return `${PREP_BRANCH_PREFIX}${unique}`;
|
|
2278
|
+
}
|
|
2279
|
+
async function readFileAtRef(repoPath, ref, path) {
|
|
2280
|
+
try {
|
|
2281
|
+
return await git(repoPath).raw(["show", `${ref}:${path}`]);
|
|
2282
|
+
} catch {
|
|
2283
|
+
return;
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
async function deletePrepBranch(repoPath, branch) {
|
|
2287
|
+
try {
|
|
2288
|
+
await deleteBranchDetachingWorktrees(git(repoPath), repoPath, branch);
|
|
2289
|
+
} catch {}
|
|
2290
|
+
}
|
|
2193
2291
|
function tempBranchSlugSegment(slug) {
|
|
2194
2292
|
return slug.slice(0, TEMP_BRANCH_SLUG_MAX).replace(/-+$/, "");
|
|
2195
2293
|
}
|
|
@@ -2207,14 +2305,14 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
|
|
|
2207
2305
|
const attempts = Math.max(1, opts.attempts ?? 3);
|
|
2208
2306
|
const delayMs = opts.delayMs ?? 750;
|
|
2209
2307
|
const g = git(repoPath);
|
|
2210
|
-
for (let attempt = 1;attempt <= attempts &&
|
|
2308
|
+
for (let attempt = 1;attempt <= attempts && existsSync6(path); attempt++) {
|
|
2211
2309
|
try {
|
|
2212
2310
|
await g.raw(["worktree", "remove", "--force", path]);
|
|
2213
2311
|
} catch {}
|
|
2214
|
-
if (
|
|
2312
|
+
if (existsSync6(path) && attempt < attempts)
|
|
2215
2313
|
await sleep(delayMs);
|
|
2216
2314
|
}
|
|
2217
|
-
if (
|
|
2315
|
+
if (existsSync6(path)) {
|
|
2218
2316
|
try {
|
|
2219
2317
|
rmSync(path, { recursive: true, force: true });
|
|
2220
2318
|
} catch {}
|
|
@@ -2222,7 +2320,7 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
|
|
|
2222
2320
|
try {
|
|
2223
2321
|
await g.raw(["worktree", "prune"]);
|
|
2224
2322
|
} catch {}
|
|
2225
|
-
return !
|
|
2323
|
+
return !existsSync6(path);
|
|
2226
2324
|
}
|
|
2227
2325
|
function sleep(ms) {
|
|
2228
2326
|
return new Promise((done) => setTimeout(done, ms));
|
|
@@ -2236,6 +2334,32 @@ async function branchCommitsAhead(repoPath, base, branch) {
|
|
|
2236
2334
|
return [];
|
|
2237
2335
|
}
|
|
2238
2336
|
}
|
|
2337
|
+
async function headSha(repoPath, ref) {
|
|
2338
|
+
try {
|
|
2339
|
+
const out = (await git(repoPath).revparse([ref])).trim();
|
|
2340
|
+
return out.length > 0 ? out : undefined;
|
|
2341
|
+
} catch {
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
async function commitsSince(repoPath, sha, branch) {
|
|
2346
|
+
try {
|
|
2347
|
+
const out = (await git(repoPath).raw(["rev-list", "--count", `${sha}..${branch}`])).trim();
|
|
2348
|
+
const n = Number(out);
|
|
2349
|
+
return Number.isFinite(n) ? n : undefined;
|
|
2350
|
+
} catch {
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
async function diffPaths(repoPath, from, to) {
|
|
2355
|
+
try {
|
|
2356
|
+
const out = (await git(repoPath).raw(["diff", "--name-only", from, to])).trim();
|
|
2357
|
+
return out ? out.split(`
|
|
2358
|
+
`).map((s) => s.trim()).filter(Boolean) : [];
|
|
2359
|
+
} catch {
|
|
2360
|
+
return [];
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2239
2363
|
async function commitSummaries(repoPath, base, branch, limit = 20) {
|
|
2240
2364
|
try {
|
|
2241
2365
|
const out = await git(repoPath).raw([
|
|
@@ -2431,7 +2555,7 @@ async function removeTalkWorktree(repoPath, worktreePath) {
|
|
|
2431
2555
|
try {
|
|
2432
2556
|
await g.raw(["worktree", "prune"]);
|
|
2433
2557
|
} catch {}
|
|
2434
|
-
if (
|
|
2558
|
+
if (existsSync6(worktreePath)) {
|
|
2435
2559
|
throw new InvalidInputError(`could not remove talk worktree at ${worktreePath} \u2014 a file may be locked; ` + "close anything using it and retry the delete");
|
|
2436
2560
|
}
|
|
2437
2561
|
}
|
|
@@ -2458,7 +2582,7 @@ async function deleteFeatureBranches(repoPath, slug) {
|
|
|
2458
2582
|
return { deleted, kept };
|
|
2459
2583
|
}
|
|
2460
2584
|
async function commitDocs(worktreePath, message) {
|
|
2461
|
-
if (!
|
|
2585
|
+
if (!existsSync6(resolve4(worktreePath, DOCS_PATHSPEC)))
|
|
2462
2586
|
return;
|
|
2463
2587
|
const g = git(worktreePath);
|
|
2464
2588
|
await g.add([DOCS_PATHSPEC]);
|
|
@@ -2503,6 +2627,7 @@ async function testDrive(ctx, project, feature, action) {
|
|
|
2503
2627
|
const devPaneId = testDriveState.devPaneId;
|
|
2504
2628
|
if (devPaneId)
|
|
2505
2629
|
stopDevPane(devPaneId);
|
|
2630
|
+
const carriedChanges = await dirtyPaths(g);
|
|
2506
2631
|
await g.checkout(previousBranch2);
|
|
2507
2632
|
if (detachedWorktree2)
|
|
2508
2633
|
await reattachWorktree(detachedWorktree2, branch);
|
|
@@ -2512,7 +2637,20 @@ async function testDrive(ctx, project, feature, action) {
|
|
|
2512
2637
|
message: `test drive stopped \u2014 back on ${previousBranch2}`,
|
|
2513
2638
|
data: { branch: previousBranch2 }
|
|
2514
2639
|
});
|
|
2515
|
-
|
|
2640
|
+
if (carriedChanges.length > 0) {
|
|
2641
|
+
emit(ctx, feature.id, {
|
|
2642
|
+
type: "testdrive.carried_changes",
|
|
2643
|
+
message: `${carriedChanges.length} uncommitted file(s) came back with you onto ${previousBranch2}: ${carriedChanges.slice(0, 5).join(", ")}${carriedChanges.length > 5 ? ", \u2026" : ""}`,
|
|
2644
|
+
data: { branch: previousBranch2, files: carriedChanges }
|
|
2645
|
+
});
|
|
2646
|
+
}
|
|
2647
|
+
const dbDrift = await detectDbDrift(ctx, project, feature, previousBranch2, branch);
|
|
2648
|
+
return {
|
|
2649
|
+
ok: true,
|
|
2650
|
+
branch: previousBranch2,
|
|
2651
|
+
...carriedChanges.length > 0 ? { carriedChanges } : {},
|
|
2652
|
+
...dbDrift ? { dbDrift } : {}
|
|
2653
|
+
};
|
|
2516
2654
|
}
|
|
2517
2655
|
const porcelain = (await g.raw(["status", "--porcelain"])).trim();
|
|
2518
2656
|
if (porcelain !== "")
|
|
@@ -2549,6 +2687,35 @@ async function testDrive(ctx, project, feature, action) {
|
|
|
2549
2687
|
}
|
|
2550
2688
|
return { ok: true, branch };
|
|
2551
2689
|
}
|
|
2690
|
+
async function dirtyPaths(g) {
|
|
2691
|
+
try {
|
|
2692
|
+
const out = (await g.raw(["status", "--porcelain"])).trim();
|
|
2693
|
+
if (!out)
|
|
2694
|
+
return [];
|
|
2695
|
+
return out.split(`
|
|
2696
|
+
`).map((line) => line.slice(3).trim()).map((p) => p.includes(" -> ") ? p.split(" -> ").at(-1) ?? p : p).map((p) => p.replace(/^"|"$/g, "")).filter(Boolean);
|
|
2697
|
+
} catch {
|
|
2698
|
+
return [];
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
async function detectDbDrift(ctx, project, feature, previousBranch, driveBranch) {
|
|
2702
|
+
let files;
|
|
2703
|
+
try {
|
|
2704
|
+
files = migrationPaths(await diffPaths(project.repoPath, previousBranch, driveBranch));
|
|
2705
|
+
} catch {
|
|
2706
|
+
return;
|
|
2707
|
+
}
|
|
2708
|
+
if (files.length === 0)
|
|
2709
|
+
return;
|
|
2710
|
+
const resetCommand = project.dbResetCommand?.trim() || undefined;
|
|
2711
|
+
const drift = { files, ...resetCommand ? { resetCommand } : {} };
|
|
2712
|
+
emit(ctx, feature.id, {
|
|
2713
|
+
type: "testdrive.db_drift",
|
|
2714
|
+
message: `${driveBranch} and ${previousBranch} differ by ${files.length} migration file(s) \u2014 ` + "anything you migrated during the drive is still applied to your dev database, so the next " + `migrate on ${previousBranch} may report drift. ` + (resetCommand ? `Rebuild it with: ${resetCommand}` : 'Set a "Database reset command" in project settings to get a one-click fix here.'),
|
|
2715
|
+
data: { files, previousBranch, driveBranch, resetCommand: resetCommand ?? null }
|
|
2716
|
+
});
|
|
2717
|
+
return drift;
|
|
2718
|
+
}
|
|
2552
2719
|
async function mergeFeature(project, feature) {
|
|
2553
2720
|
const target = feature.baseBranch ?? project.mainBranch;
|
|
2554
2721
|
if (testDriveState) {
|
|
@@ -2604,171 +2771,18 @@ async function mergeInProgress(g) {
|
|
|
2604
2771
|
return false;
|
|
2605
2772
|
}
|
|
2606
2773
|
}
|
|
2607
|
-
var DOCS_PATHSPEC = "docs/features", DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", RESEARCH_BRANCH_PREFIX = "runcastle/research/", TICKET_BRANCH_PREFIX = "runcastle/ticket/", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState;
|
|
2774
|
+
var DOCS_PATHSPEC = "docs/features", MIGRATION_DIR_RE, DRIZZLE_SQL_RE, DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", RESEARCH_BRANCH_PREFIX = "runcastle/research/", TICKET_BRANCH_PREFIX = "runcastle/ticket/", PREP_BRANCH_PREFIX = "runcastle/prep/", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState;
|
|
2608
2775
|
var init_git = __esm(() => {
|
|
2609
2776
|
init_paths();
|
|
2610
2777
|
init_errors();
|
|
2611
2778
|
init_dev_pane();
|
|
2612
2779
|
init_events();
|
|
2613
2780
|
init_repo();
|
|
2781
|
+
MIGRATION_DIR_RE = /(^|\/)(migrations|migrate)\//i;
|
|
2782
|
+
DRIZZLE_SQL_RE = /(^|\/)drizzle\/.*\.sql$/i;
|
|
2614
2783
|
TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
|
|
2615
2784
|
});
|
|
2616
2785
|
|
|
2617
|
-
// src/services/knowledge.ts
|
|
2618
|
-
import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync } from "fs";
|
|
2619
|
-
import { join as join7, relative, resolve as resolve4, sep } from "path";
|
|
2620
|
-
function scaffoldDocs(ctx, feature) {
|
|
2621
|
-
const project = projectForFeature(ctx, feature);
|
|
2622
|
-
const dir = featureDocsDir(project, feature);
|
|
2623
|
-
mkdirSync3(dir, { recursive: true });
|
|
2624
|
-
const briefPath = featureDocPath(project, feature, "brief.md");
|
|
2625
|
-
if (!existsSync8(briefPath)) {
|
|
2626
|
-
const created = new Date().toISOString();
|
|
2627
|
-
const brief = [
|
|
2628
|
-
`# ${feature.title}`,
|
|
2629
|
-
"",
|
|
2630
|
-
feature.oneLiner,
|
|
2631
|
-
"",
|
|
2632
|
-
`- Slug: ${feature.slug}`,
|
|
2633
|
-
`- Created: ${created}`,
|
|
2634
|
-
""
|
|
2635
|
-
].join(`
|
|
2636
|
-
`);
|
|
2637
|
-
writeFileSync(briefPath, brief, "utf8");
|
|
2638
|
-
}
|
|
2639
|
-
if (feature.mapped) {
|
|
2640
|
-
scaffoldMapDoc(project, feature);
|
|
2641
|
-
}
|
|
2642
|
-
emit(ctx, feature.id, {
|
|
2643
|
-
type: "docs.scaffolded",
|
|
2644
|
-
message: feature.mapped ? `scaffolded docs/features/${feature.slug}/{brief,map}.md` : `scaffolded docs/features/${feature.slug}/brief.md`
|
|
2645
|
-
});
|
|
2646
|
-
}
|
|
2647
|
-
function scaffoldMapDoc(project, feature, seed) {
|
|
2648
|
-
mkdirSync3(featureDocsDir(project, feature), { recursive: true });
|
|
2649
|
-
const mapPath = featureDocPath(project, feature, "map.md");
|
|
2650
|
-
if (existsSync8(mapPath))
|
|
2651
|
-
return;
|
|
2652
|
-
writeFileSync(mapPath, mapDocBody(feature, seed), "utf8");
|
|
2653
|
-
}
|
|
2654
|
-
function mapDocBody(feature, seed) {
|
|
2655
|
-
const seeded = {
|
|
2656
|
-
Destination: seed?.destination?.trim(),
|
|
2657
|
-
Notes: seed?.notes?.trim()
|
|
2658
|
-
};
|
|
2659
|
-
const lines = [`# ${feature.title} \u2014 map`, ""];
|
|
2660
|
-
for (const section of MAP_SECTIONS) {
|
|
2661
|
-
lines.push(`## ${section}`, "");
|
|
2662
|
-
const body = seeded[section];
|
|
2663
|
-
if (body)
|
|
2664
|
-
lines.push(body, "");
|
|
2665
|
-
}
|
|
2666
|
-
return lines.join(`
|
|
2667
|
-
`);
|
|
2668
|
-
}
|
|
2669
|
-
function listDocs(ctx, feature) {
|
|
2670
|
-
const project = projectForFeature(ctx, feature);
|
|
2671
|
-
const dir = featureDocsDir(project, feature);
|
|
2672
|
-
if (!existsSync8(dir))
|
|
2673
|
-
return [];
|
|
2674
|
-
return readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => ({ relPath: e.name, title: readTitle(join7(dir, e.name), e.name) })).sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
2675
|
-
}
|
|
2676
|
-
function readDoc(ctx, feature, relPath) {
|
|
2677
|
-
const project = projectForFeature(ctx, feature);
|
|
2678
|
-
const dir = featureDocsDir(project, feature);
|
|
2679
|
-
const root = resolve4(dir);
|
|
2680
|
-
const target = resolve4(dir, relPath);
|
|
2681
|
-
const rel = relative(root, target);
|
|
2682
|
-
if (rel === "" || rel.startsWith("..") || rel.split(sep).includes("..")) {
|
|
2683
|
-
throw new InvalidInputError(`doc path escapes the feature docs dir: ${relPath}`);
|
|
2684
|
-
}
|
|
2685
|
-
if (!existsSync8(target))
|
|
2686
|
-
throw new NotFoundError(`doc not found: ${relPath}`);
|
|
2687
|
-
return { content: readFileSync3(target, "utf8") };
|
|
2688
|
-
}
|
|
2689
|
-
function readTitle(path, fallback) {
|
|
2690
|
-
try {
|
|
2691
|
-
const firstLine = readFileSync3(path, "utf8").split(`
|
|
2692
|
-
`, 1)[0] ?? "";
|
|
2693
|
-
const heading = firstLine.match(/^#\s+(.+)$/);
|
|
2694
|
-
return heading ? heading[1].trim() : fallback;
|
|
2695
|
-
} catch {
|
|
2696
|
-
return fallback;
|
|
2697
|
-
}
|
|
2698
|
-
}
|
|
2699
|
-
var MAP_SECTIONS;
|
|
2700
|
-
var init_knowledge = __esm(() => {
|
|
2701
|
-
init_errors();
|
|
2702
|
-
init_events();
|
|
2703
|
-
init_feature_docs();
|
|
2704
|
-
init_repo();
|
|
2705
|
-
MAP_SECTIONS = [
|
|
2706
|
-
"Destination",
|
|
2707
|
-
"Notes",
|
|
2708
|
-
"Not yet specified",
|
|
2709
|
-
"Out of scope"
|
|
2710
|
-
];
|
|
2711
|
-
});
|
|
2712
|
-
|
|
2713
|
-
// src/pty/end-session.ts
|
|
2714
|
-
function endSession(ctx, sessionId) {
|
|
2715
|
-
const registry = ptyRegistry();
|
|
2716
|
-
const killed = registry.kill(sessionId);
|
|
2717
|
-
registry.remove(sessionId);
|
|
2718
|
-
const session = markSessionEnded(ctx, sessionId);
|
|
2719
|
-
if (session) {
|
|
2720
|
-
releaseForSession(ctx, sessionId);
|
|
2721
|
-
emit(ctx, session.featureId, {
|
|
2722
|
-
type: "session.ended",
|
|
2723
|
-
message: "session ended by user",
|
|
2724
|
-
data: { sessionId, killed }
|
|
2725
|
-
});
|
|
2726
|
-
} else {
|
|
2727
|
-
const existing = getSessionRow(ctx, sessionId);
|
|
2728
|
-
if (!existing)
|
|
2729
|
-
return { sessionId, ok: false, killed };
|
|
2730
|
-
}
|
|
2731
|
-
return { sessionId, ok: true, killed };
|
|
2732
|
-
}
|
|
2733
|
-
var init_end_session = __esm(() => {
|
|
2734
|
-
init_sessions();
|
|
2735
|
-
init_events();
|
|
2736
|
-
init_waypoints();
|
|
2737
|
-
init_registry();
|
|
2738
|
-
});
|
|
2739
|
-
|
|
2740
|
-
// src/launcher/skills-root.ts
|
|
2741
|
-
import { existsSync as existsSync9 } from "fs";
|
|
2742
|
-
import { dirname as dirname2, join as join8, resolve as resolve5 } from "path";
|
|
2743
|
-
function resolveSkillsRoot(fromDir) {
|
|
2744
|
-
const override = process.env[SKILLS_DIR_ENV];
|
|
2745
|
-
if (override) {
|
|
2746
|
-
const root = resolve5(override);
|
|
2747
|
-
if (existsSync9(join8(root, SKILLS_MARKER)))
|
|
2748
|
-
return root;
|
|
2749
|
-
throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
|
|
2750
|
-
}
|
|
2751
|
-
const searched = [];
|
|
2752
|
-
let dir = fromDir;
|
|
2753
|
-
for (let i = 0;i < 8; i += 1) {
|
|
2754
|
-
const candidate = join8(dir, "packages", "skills");
|
|
2755
|
-
searched.push(candidate);
|
|
2756
|
-
if (existsSync9(join8(candidate, SKILLS_MARKER)))
|
|
2757
|
-
return candidate;
|
|
2758
|
-
const parent = dirname2(dir);
|
|
2759
|
-
if (parent === dir)
|
|
2760
|
-
break;
|
|
2761
|
-
dir = parent;
|
|
2762
|
-
}
|
|
2763
|
-
throw new Error(`runcastle skills root not found; searched:
|
|
2764
|
-
${searched.join(`
|
|
2765
|
-
`)}`);
|
|
2766
|
-
}
|
|
2767
|
-
var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
|
|
2768
|
-
var init_skills_root = __esm(() => {
|
|
2769
|
-
SKILLS_MARKER = join8("packs", "runcastle");
|
|
2770
|
-
});
|
|
2771
|
-
|
|
2772
2786
|
// src/services/agent-stream.ts
|
|
2773
2787
|
function beginTranscript(ticketId) {
|
|
2774
2788
|
transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
|
|
@@ -2813,9 +2827,92 @@ var init_agent_stream = __esm(() => {
|
|
|
2813
2827
|
transcripts = new Map;
|
|
2814
2828
|
});
|
|
2815
2829
|
|
|
2830
|
+
// src/workflows/burn-guard.ts
|
|
2831
|
+
function renderGuardScript(rules = GUARD_RULES) {
|
|
2832
|
+
const lines = [
|
|
2833
|
+
"#!/bin/sh",
|
|
2834
|
+
"# runcastle burn guard (PreToolUse). Generated \u2014 edit burn-guard.ts, not this.",
|
|
2835
|
+
"set -u",
|
|
2836
|
+
"payload=$(cat 2>/dev/null || true)",
|
|
2837
|
+
'[ -n "$payload" ] || exit 0',
|
|
2838
|
+
"command -v jq >/dev/null 2>&1 || exit 0",
|
|
2839
|
+
'tool=$(printf %s "$payload" | jq -r ".tool_name // empty" 2>/dev/null || true)',
|
|
2840
|
+
'[ "$tool" = "Bash" ] || exit 0',
|
|
2841
|
+
'cmd=$(printf %s "$payload" | jq -r ".tool_input.command // empty" 2>/dev/null || true)',
|
|
2842
|
+
'[ -n "$cmd" ] || exit 0',
|
|
2843
|
+
"",
|
|
2844
|
+
"# Match against the command with quoted spans blanked, so an argument is",
|
|
2845
|
+
'# never read as a command: `grep -rn "git stash" docs/` searches for a',
|
|
2846
|
+
"# string, it does not run one.",
|
|
2847
|
+
`cmd=$(printf %s "$cmd" | sed "s/'[^']*'/ /g; s/\\"[^\\"]*\\"/ /g")`,
|
|
2848
|
+
"",
|
|
2849
|
+
"deny() {",
|
|
2850
|
+
` printf %s "$1" | jq -R -s '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:.}}'`,
|
|
2851
|
+
" exit 0",
|
|
2852
|
+
"}",
|
|
2853
|
+
""
|
|
2854
|
+
];
|
|
2855
|
+
for (const rule of rules) {
|
|
2856
|
+
lines.push(`# ${rule.id}`, `if printf %s "$cmd" | grep -Eq -- ${shSingleQuote(rule.pattern)}; then`, ` deny ${shSingleQuote(rule.reason)}`, "fi", "");
|
|
2857
|
+
}
|
|
2858
|
+
lines.push("exit 0", "");
|
|
2859
|
+
return lines.join(`
|
|
2860
|
+
`);
|
|
2861
|
+
}
|
|
2862
|
+
function shSingleQuote(value) {
|
|
2863
|
+
return `'${value.split("'").join(`'\\''`)}'`;
|
|
2864
|
+
}
|
|
2865
|
+
function renderGuardSettings() {
|
|
2866
|
+
return {
|
|
2867
|
+
hooks: {
|
|
2868
|
+
PreToolUse: [
|
|
2869
|
+
{
|
|
2870
|
+
matcher: "Bash",
|
|
2871
|
+
hooks: [{ type: "command", command: `sh ${GUARD_SCRIPT_PATH}`, timeout: 5 }]
|
|
2872
|
+
}
|
|
2873
|
+
]
|
|
2874
|
+
}
|
|
2875
|
+
};
|
|
2876
|
+
}
|
|
2877
|
+
function buildGuardInstallCommand() {
|
|
2878
|
+
const script = Buffer.from(renderGuardScript(), "utf8").toString("base64");
|
|
2879
|
+
const settings = Buffer.from(JSON.stringify(renderGuardSettings(), null, 2), "utf8").toString("base64");
|
|
2880
|
+
return [
|
|
2881
|
+
`mkdir -p "$HOME/.claude/hooks"`,
|
|
2882
|
+
`printf %s '${script}' | base64 -d > "${GUARD_SCRIPT_PATH}"`,
|
|
2883
|
+
`chmod +x "${GUARD_SCRIPT_PATH}"`,
|
|
2884
|
+
`printf %s '${settings}' | base64 -d > "${GUARD_SETTINGS_PATH}"`
|
|
2885
|
+
].join(" && ");
|
|
2886
|
+
}
|
|
2887
|
+
var CMD_START = "(^|[;&|(]|&&)[[:space:]]*", GUARD_RULES, GUARD_SCRIPT_PATH = "$HOME/.claude/hooks/burn-guard.sh", GUARD_SETTINGS_PATH = "$HOME/.claude/settings.json";
|
|
2888
|
+
var init_burn_guard = __esm(() => {
|
|
2889
|
+
GUARD_RULES = [
|
|
2890
|
+
{
|
|
2891
|
+
id: "no-stash",
|
|
2892
|
+
pattern: `${CMD_START}git[[:space:]]+stash`,
|
|
2893
|
+
reason: "git stash is blocked in burns: stashed work is invisible to the orchestrator and unrecoverable if this process dies, and only commits survive. To compare against the pre-change state use `git show HEAD:<path>`, or the pre-existing-failure baseline in your prompt."
|
|
2894
|
+
},
|
|
2895
|
+
{
|
|
2896
|
+
id: "no-serialised-tests",
|
|
2897
|
+
pattern: "--maxWorkers|--runInBand|--pool=|--shard=|singleFork|--poolOptions",
|
|
2898
|
+
reason: `Do not override the test runner's concurrency. Measured in this sandbox, a suite that runs in ~55s at its configured concurrency takes 10-20 minutes serialised, so this "safe" flag is the single most expensive habit in a burn. Run the repo's test command as configured. If it is killed for memory, run only the test files your change touches and say so in your final message.`
|
|
2899
|
+
},
|
|
2900
|
+
{
|
|
2901
|
+
id: "no-interpreter-heredoc-edit",
|
|
2902
|
+
pattern: `${CMD_START}(python3?|node|bun|perl|ruby)[[:space:]]+-[[:space:]]*<<`,
|
|
2903
|
+
reason: "Do not rewrite files by piping a heredoc into an interpreter \u2014 individual such calls have been measured at 29s, 57s, 120s and 761s, and a half-written file survives if this process dies mid-command. Use the Edit tool, which is faster, atomic, and fails loudly when its target text is not found."
|
|
2904
|
+
},
|
|
2905
|
+
{
|
|
2906
|
+
id: "no-cat-heredoc-edit",
|
|
2907
|
+
pattern: `${CMD_START}cat[[:space:]]+>>?[[:space:]]*[^[:space:]|]+[[:space:]]*<<`,
|
|
2908
|
+
reason: "Do not write files with `cat > file <<EOF`. Use the Write tool for a new file, or Edit to change part of an existing one."
|
|
2909
|
+
}
|
|
2910
|
+
];
|
|
2911
|
+
});
|
|
2912
|
+
|
|
2816
2913
|
// src/workflows/ticket-burner.ts
|
|
2817
|
-
import { existsSync as
|
|
2818
|
-
import { dirname as dirname3, join as
|
|
2914
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
|
|
2915
|
+
import { dirname as dirname3, join as join7 } from "path";
|
|
2819
2916
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2820
2917
|
import { claudeCode, run } from "@ai-hero/sandcastle";
|
|
2821
2918
|
import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
|
|
@@ -2931,13 +3028,13 @@ function parseEnvFile(content) {
|
|
|
2931
3028
|
if (line.length === 0 || line.startsWith("#"))
|
|
2932
3029
|
continue;
|
|
2933
3030
|
const body = line.startsWith("export ") ? line.slice(7).trim() : line;
|
|
2934
|
-
const
|
|
2935
|
-
if (
|
|
3031
|
+
const eq5 = body.indexOf("=");
|
|
3032
|
+
if (eq5 === -1)
|
|
2936
3033
|
continue;
|
|
2937
|
-
const key = body.slice(0,
|
|
3034
|
+
const key = body.slice(0, eq5).trim();
|
|
2938
3035
|
if (key.length === 0)
|
|
2939
3036
|
continue;
|
|
2940
|
-
let value = body.slice(
|
|
3037
|
+
let value = body.slice(eq5 + 1).trim();
|
|
2941
3038
|
if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
|
|
2942
3039
|
value = value.slice(1, -1);
|
|
2943
3040
|
}
|
|
@@ -3019,13 +3116,31 @@ function buildWorkspaceNotes(mode) {
|
|
|
3019
3116
|
].join(`
|
|
3020
3117
|
`);
|
|
3021
3118
|
}
|
|
3119
|
+
function buildVerifyNotes(config2) {
|
|
3120
|
+
const commands = config2.verifyCommands?.trim();
|
|
3121
|
+
const failures = config2.knownFailures?.trim();
|
|
3122
|
+
const out = [];
|
|
3123
|
+
if (commands) {
|
|
3124
|
+
out.push("Verify with exactly these commands \u2014 they are correct for this repo, so do not go looking for alternatives or guess at workspace/package filter names:", "", "```", commands, "```");
|
|
3125
|
+
} else {
|
|
3126
|
+
out.push("No verification commands are configured, so derive them ONCE from the repo (root `package.json` scripts, the workspace's own manifest, CI config) before your first test run \u2014 reading a manifest is free, discovering a filter name by running the wrong suite is not. Write the working commands into your notes and reuse them verbatim for the rest of the ticket.");
|
|
3127
|
+
}
|
|
3128
|
+
out.push("");
|
|
3129
|
+
if (failures) {
|
|
3130
|
+
out.push("These tests ALREADY fail on this repo before you touch anything:", "", "```", failures, "```", "", "Treat that as your baseline \u2014 do NOT spend a run establishing it yourself. Only a failure outside this set is yours to fix; if you hit one that looks pre-existing but is not listed, confirm it on a single targeted run of that one test, never a whole-suite re-run.");
|
|
3131
|
+
} else {
|
|
3132
|
+
out.push("No pre-existing-failure baseline is configured. If the suite is already red, capture the baseline ONCE \u2014 the first full run you do, before your changes land, is the baseline \u2014 and compare every later run against those saved results. Never re-run a whole suite just to re-establish what was already failing.");
|
|
3133
|
+
}
|
|
3134
|
+
return out.join(`
|
|
3135
|
+
`);
|
|
3136
|
+
}
|
|
3022
3137
|
function readRepoToolchain(repoPath) {
|
|
3023
|
-
const pkgPath =
|
|
3024
|
-
const hasPackageJson =
|
|
3138
|
+
const pkgPath = join7(repoPath, "package.json");
|
|
3139
|
+
const hasPackageJson = existsSync7(pkgPath);
|
|
3025
3140
|
let packageManagerField;
|
|
3026
3141
|
if (hasPackageJson) {
|
|
3027
3142
|
try {
|
|
3028
|
-
const pkg = JSON.parse(
|
|
3143
|
+
const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
|
|
3029
3144
|
if (typeof pkg.packageManager === "string")
|
|
3030
3145
|
packageManagerField = pkg.packageManager;
|
|
3031
3146
|
} catch {}
|
|
@@ -3034,10 +3149,10 @@ function readRepoToolchain(repoPath) {
|
|
|
3034
3149
|
hasPackageJson,
|
|
3035
3150
|
...packageManagerField !== undefined ? { packageManagerField } : {},
|
|
3036
3151
|
lockfiles: {
|
|
3037
|
-
bun:
|
|
3038
|
-
pnpm:
|
|
3039
|
-
yarn:
|
|
3040
|
-
npm:
|
|
3152
|
+
bun: existsSync7(join7(repoPath, "bun.lock")) || existsSync7(join7(repoPath, "bun.lockb")),
|
|
3153
|
+
pnpm: existsSync7(join7(repoPath, "pnpm-lock.yaml")),
|
|
3154
|
+
yarn: existsSync7(join7(repoPath, "yarn.lock")),
|
|
3155
|
+
npm: existsSync7(join7(repoPath, "package-lock.json"))
|
|
3041
3156
|
}
|
|
3042
3157
|
};
|
|
3043
3158
|
}
|
|
@@ -3075,6 +3190,63 @@ function createStreamThrottle(emit2, opts = {}) {
|
|
|
3075
3190
|
};
|
|
3076
3191
|
return { onEvent, flush: flushText };
|
|
3077
3192
|
}
|
|
3193
|
+
function normalizeCommandForClassification(command) {
|
|
3194
|
+
return command.replace(/<<\s*['"]?(\w+)['"]?[\s\S]*?(^|\n)\s*\1\b/gm, "<<$1 ").replace(/<<\s*['"]?(\w+)['"]?[\s\S]*$/, "<<$1 ").replace(/'[^']*'/g, " ").replace(/"[^"]*"/g, " ");
|
|
3195
|
+
}
|
|
3196
|
+
function classifyToolCall(name, args) {
|
|
3197
|
+
const byName = TOOL_NAME_CATEGORY[name];
|
|
3198
|
+
if (byName)
|
|
3199
|
+
return byName;
|
|
3200
|
+
if (name !== "Bash")
|
|
3201
|
+
return "other";
|
|
3202
|
+
const normalized = normalizeCommandForClassification(args);
|
|
3203
|
+
for (const [category, pattern] of BASH_PATTERNS) {
|
|
3204
|
+
if (pattern.test(normalized))
|
|
3205
|
+
return category;
|
|
3206
|
+
}
|
|
3207
|
+
return "other";
|
|
3208
|
+
}
|
|
3209
|
+
function createToolTimer() {
|
|
3210
|
+
const byCategory = {};
|
|
3211
|
+
let calls = 0;
|
|
3212
|
+
let totalMs = 0;
|
|
3213
|
+
let pending = null;
|
|
3214
|
+
let iteration = null;
|
|
3215
|
+
const charge = (category, ms) => {
|
|
3216
|
+
const slot = byCategory[category] ??= { calls: 0, ms: 0 };
|
|
3217
|
+
slot.ms += ms;
|
|
3218
|
+
totalMs += ms;
|
|
3219
|
+
};
|
|
3220
|
+
const onEvent = (event) => {
|
|
3221
|
+
if (event.type === "raw")
|
|
3222
|
+
return;
|
|
3223
|
+
const at = event.timestamp.getTime();
|
|
3224
|
+
if (iteration !== null && event.iteration !== iteration)
|
|
3225
|
+
pending = null;
|
|
3226
|
+
iteration = event.iteration;
|
|
3227
|
+
if (pending) {
|
|
3228
|
+
const gap = at - pending.at;
|
|
3229
|
+
if (gap > 0 && gap <= MAX_ATTRIBUTABLE_GAP_MS)
|
|
3230
|
+
charge(pending.category, gap);
|
|
3231
|
+
}
|
|
3232
|
+
if (event.type === "toolCall") {
|
|
3233
|
+
const category = classifyToolCall(event.name, event.formattedArgs ?? "");
|
|
3234
|
+
const slot = byCategory[category] ??= { calls: 0, ms: 0 };
|
|
3235
|
+
slot.calls += 1;
|
|
3236
|
+
calls += 1;
|
|
3237
|
+
pending = { category, at };
|
|
3238
|
+
} else {
|
|
3239
|
+
pending = { category: "model", at };
|
|
3240
|
+
}
|
|
3241
|
+
};
|
|
3242
|
+
return { onEvent, summary: () => ({ totalMs, calls, byCategory }) };
|
|
3243
|
+
}
|
|
3244
|
+
function formatTimingSummary(s) {
|
|
3245
|
+
if (s.totalMs === 0)
|
|
3246
|
+
return `${s.calls} tool call(s), no measurable time`;
|
|
3247
|
+
const parts = Object.entries(s.byCategory).sort((a, b) => (b[1]?.ms ?? 0) - (a[1]?.ms ?? 0)).slice(0, 5).map(([c, t]) => `${c} ${Math.round((t?.ms ?? 0) / s.totalMs * 100)}%`);
|
|
3248
|
+
return `${Math.round(s.totalMs / 60000)}min across ${s.calls} tool call(s) \u2014 ${parts.join(", ")}`;
|
|
3249
|
+
}
|
|
3078
3250
|
function interpretRunResult(result, blockedContent) {
|
|
3079
3251
|
const commits = result.commits.map((c) => c.sha);
|
|
3080
3252
|
if (commits.length > 0)
|
|
@@ -3121,18 +3293,18 @@ function buildRetryNotes(input) {
|
|
|
3121
3293
|
`);
|
|
3122
3294
|
}
|
|
3123
3295
|
function delayUnlessAborted(ms, signal) {
|
|
3124
|
-
return new Promise((
|
|
3296
|
+
return new Promise((resolve5) => {
|
|
3125
3297
|
if (signal.aborted) {
|
|
3126
|
-
|
|
3298
|
+
resolve5();
|
|
3127
3299
|
return;
|
|
3128
3300
|
}
|
|
3129
3301
|
const onAbort = () => {
|
|
3130
3302
|
clearTimeout(t);
|
|
3131
|
-
|
|
3303
|
+
resolve5();
|
|
3132
3304
|
};
|
|
3133
3305
|
const t = setTimeout(() => {
|
|
3134
3306
|
signal.removeEventListener("abort", onAbort);
|
|
3135
|
-
|
|
3307
|
+
resolve5();
|
|
3136
3308
|
}, ms);
|
|
3137
3309
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
3138
3310
|
});
|
|
@@ -3155,38 +3327,38 @@ function createSerialQueue() {
|
|
|
3155
3327
|
};
|
|
3156
3328
|
}
|
|
3157
3329
|
async function landWithResolve(branch, deps) {
|
|
3158
|
-
let
|
|
3330
|
+
let current = branch;
|
|
3159
3331
|
for (let attempt = 1;; attempt++) {
|
|
3160
|
-
const merge = await deps.merge(
|
|
3332
|
+
const merge = await deps.merge(current);
|
|
3161
3333
|
if (merge.ok)
|
|
3162
|
-
return { status: "landed", branch:
|
|
3334
|
+
return { status: "landed", branch: current };
|
|
3163
3335
|
const detail = merge.error ?? "merge failed";
|
|
3164
3336
|
if (!merge.conflict)
|
|
3165
|
-
return { status: "failed", branch:
|
|
3337
|
+
return { status: "failed", branch: current, error: detail };
|
|
3166
3338
|
const files = merge.files ?? [];
|
|
3167
3339
|
if (attempt > deps.maxResolveAttempts) {
|
|
3168
|
-
return { status: "conflict", branch:
|
|
3340
|
+
return { status: "conflict", branch: current, files, error: detail };
|
|
3169
3341
|
}
|
|
3170
3342
|
const where = files.length > 0 ? `${files.length} file(s)` : "the merge";
|
|
3171
3343
|
deps.emit({
|
|
3172
3344
|
type: "merge.conflict.resolving",
|
|
3173
3345
|
message: `${deps.label}: conflicts with ${deps.featureBranch} on ${where} \u2014 resolving with an agent (pass ${attempt}/${deps.maxResolveAttempts})`,
|
|
3174
|
-
data: { branch:
|
|
3346
|
+
data: { branch: current, files, attempt, maxAttempts: deps.maxResolveAttempts }
|
|
3175
3347
|
});
|
|
3176
3348
|
const resolved = await deps.resolve({
|
|
3177
|
-
branch:
|
|
3349
|
+
branch: current,
|
|
3178
3350
|
files,
|
|
3179
3351
|
attempt,
|
|
3180
3352
|
maxAttempts: deps.maxResolveAttempts
|
|
3181
3353
|
});
|
|
3182
|
-
|
|
3354
|
+
current = resolved.branch;
|
|
3183
3355
|
if (!resolved.ok) {
|
|
3184
|
-
return { status: "conflict", branch:
|
|
3356
|
+
return { status: "conflict", branch: current, files, error: resolved.error ?? detail };
|
|
3185
3357
|
}
|
|
3186
3358
|
deps.emit({
|
|
3187
3359
|
type: "merge.conflict.resolved",
|
|
3188
|
-
message: `${deps.label}: conflict resolved on ${
|
|
3189
|
-
data: { branch:
|
|
3360
|
+
message: `${deps.label}: conflict resolved on ${current} \u2014 landing`,
|
|
3361
|
+
data: { branch: current, files, attempt }
|
|
3190
3362
|
});
|
|
3191
3363
|
}
|
|
3192
3364
|
}
|
|
@@ -3344,26 +3516,26 @@ function resolverTemplatePath() {
|
|
|
3344
3516
|
}
|
|
3345
3517
|
function burnerAssetPath(file) {
|
|
3346
3518
|
const here = dirname3(fileURLToPath2(import.meta.url));
|
|
3347
|
-
return
|
|
3519
|
+
return join7(resolveSkillsRoot(here), "burner", file);
|
|
3348
3520
|
}
|
|
3349
3521
|
function readDocsDigestFromDisk(projectId, slug) {
|
|
3350
3522
|
const worktree = worktreeDir(projectId, slug);
|
|
3351
|
-
if (!
|
|
3523
|
+
if (!existsSync7(worktree))
|
|
3352
3524
|
return "_No talk worktree on disk \u2014 docs digest skipped._";
|
|
3353
|
-
const docsDir =
|
|
3354
|
-
if (!
|
|
3525
|
+
const docsDir = join7(worktree, ...featureDocsRel(slug).split("/"));
|
|
3526
|
+
if (!existsSync7(docsDir))
|
|
3355
3527
|
return "_No docs/features dir in the talk worktree \u2014 docs digest skipped._";
|
|
3356
|
-
const files =
|
|
3528
|
+
const files = readdirSync2(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync3(join7(docsDir, e.name), "utf8") }));
|
|
3357
3529
|
return buildDocsDigest(files);
|
|
3358
3530
|
}
|
|
3359
3531
|
function readBlockedFile(dirs) {
|
|
3360
3532
|
for (const dir of dirs) {
|
|
3361
3533
|
if (!dir)
|
|
3362
3534
|
continue;
|
|
3363
|
-
const p =
|
|
3364
|
-
if (
|
|
3535
|
+
const p = join7(dir, "BLOCKED.md");
|
|
3536
|
+
if (existsSync7(p)) {
|
|
3365
3537
|
try {
|
|
3366
|
-
return
|
|
3538
|
+
return readFileSync3(p, "utf8");
|
|
3367
3539
|
} catch {}
|
|
3368
3540
|
}
|
|
3369
3541
|
}
|
|
@@ -3390,10 +3562,7 @@ function buildBurnAgent(config2, token, model) {
|
|
|
3390
3562
|
return agent;
|
|
3391
3563
|
}
|
|
3392
3564
|
function selectSandbox(config2, mounts = []) {
|
|
3393
|
-
const imageOpts =
|
|
3394
|
-
imageName: resolveSandboxImage(config2),
|
|
3395
|
-
...mounts.length > 0 ? { mounts } : {}
|
|
3396
|
-
};
|
|
3565
|
+
const imageOpts = buildSandboxOptions(config2, mounts);
|
|
3397
3566
|
switch (config2.sandbox) {
|
|
3398
3567
|
case "docker":
|
|
3399
3568
|
return docker(imageOpts);
|
|
@@ -3403,6 +3572,13 @@ function selectSandbox(config2, mounts = []) {
|
|
|
3403
3572
|
return noSandbox();
|
|
3404
3573
|
}
|
|
3405
3574
|
}
|
|
3575
|
+
function buildSandboxOptions(config2, mounts = []) {
|
|
3576
|
+
return {
|
|
3577
|
+
imageName: resolveSandboxImage(config2),
|
|
3578
|
+
...mounts.length > 0 ? { mounts } : {},
|
|
3579
|
+
...config2.burnCpus !== undefined ? { cpus: config2.burnCpus } : {}
|
|
3580
|
+
};
|
|
3581
|
+
}
|
|
3406
3582
|
async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget) {
|
|
3407
3583
|
const { project, feature } = ctx;
|
|
3408
3584
|
const workspaceMode = resolveBurnWorkspaceMode(config2);
|
|
@@ -3412,30 +3588,37 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3412
3588
|
const featureBrief = buildFeatureBrief(feature);
|
|
3413
3589
|
const docsDigest = readDocsDigestFromDisk(project.id, feature.slug);
|
|
3414
3590
|
const workspaceNotes = buildWorkspaceNotes(workspaceMode);
|
|
3415
|
-
const
|
|
3591
|
+
const prepared = resolvePreparedSettings(config2, project);
|
|
3592
|
+
const verifyNotes = buildVerifyNotes(prepared);
|
|
3593
|
+
const basePrompt = renderTicketPrompt(readFileSync3(burnerTemplatePath(), "utf8"), {
|
|
3416
3594
|
TICKET_JSON: ticketJson,
|
|
3417
3595
|
FEATURE_BRIEF: featureBrief,
|
|
3418
3596
|
DOCS_DIGEST: docsDigest,
|
|
3419
3597
|
COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`,
|
|
3598
|
+
VERIFY_NOTES: verifyNotes,
|
|
3420
3599
|
WORKSPACE_NOTES: workspaceNotes
|
|
3421
3600
|
});
|
|
3422
3601
|
const toolchain = readRepoToolchain(project.repoPath);
|
|
3423
3602
|
const pm = detectPackageManager(toolchain);
|
|
3424
|
-
const setupCommand = resolveSetupCommand(toolchain,
|
|
3603
|
+
const setupCommand = resolveSetupCommand(toolchain, prepared.setupCommand);
|
|
3425
3604
|
const mounts = [];
|
|
3426
3605
|
if (config2.sandbox !== "noSandbox" && pm) {
|
|
3427
3606
|
const mount = cacheMountFor(pm, burnCacheDir(pm));
|
|
3428
3607
|
if (mount) {
|
|
3429
|
-
|
|
3608
|
+
mkdirSync3(mount.hostPath, { recursive: true });
|
|
3430
3609
|
mounts.push(mount);
|
|
3431
3610
|
}
|
|
3432
3611
|
}
|
|
3433
|
-
|
|
3434
|
-
const
|
|
3612
|
+
const guardInstall = config2.burnGuard && config2.sandbox !== "noSandbox" ? buildGuardInstallCommand() : undefined;
|
|
3613
|
+
const withGuard = (setup) => guardInstall === undefined ? setup : setup ? `${guardInstall} && ${setup}` : guardInstall;
|
|
3614
|
+
mkdirSync3(logsDir(), { recursive: true });
|
|
3615
|
+
const logFilePath = join7(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
|
|
3435
3616
|
const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
|
|
3436
3617
|
beginTranscript(ticket.id);
|
|
3618
|
+
const timer = createToolTimer();
|
|
3437
3619
|
const onStreamEvent = (event) => {
|
|
3438
3620
|
throttle.onEvent(event);
|
|
3621
|
+
timer.onEvent(event);
|
|
3439
3622
|
if (event.type === "text") {
|
|
3440
3623
|
appendTranscript(ticket.id, { kind: "text", text: event.message });
|
|
3441
3624
|
} else if (event.type === "toolCall") {
|
|
@@ -3475,17 +3658,18 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3475
3658
|
const runResolver = async (input) => {
|
|
3476
3659
|
const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
|
|
3477
3660
|
const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
|
|
3478
|
-
const prompt = renderTemplate(
|
|
3661
|
+
const prompt = renderTemplate(readFileSync3(resolverTemplatePath(), "utf8"), {
|
|
3479
3662
|
TICKET_JSON: ticketJson,
|
|
3480
3663
|
FEATURE_BRIEF: featureBrief,
|
|
3481
3664
|
DOCS_DIGEST: docsDigest,
|
|
3482
3665
|
WORKSPACE_NOTES: workspaceNotes,
|
|
3666
|
+
VERIFY_NOTES: verifyNotes,
|
|
3483
3667
|
FEATURE_BRANCH: feature.branch,
|
|
3484
3668
|
CONFLICT_FILES: buildConflictFilesBlock(input.files),
|
|
3485
3669
|
OTHER_SIDE: buildOtherSideBlock(otherSide),
|
|
3486
3670
|
MERGE_COMMAND: resolveMergeCommand(workspaceMode, feature.branch)
|
|
3487
3671
|
});
|
|
3488
|
-
const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand;
|
|
3672
|
+
const hookCommand = withGuard(workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand);
|
|
3489
3673
|
appendTranscript(ticket.id, {
|
|
3490
3674
|
kind: "text",
|
|
3491
3675
|
text: `
|
|
@@ -3615,7 +3799,7 @@ ${blocked.trim()}`
|
|
|
3615
3799
|
let tempBranch = "";
|
|
3616
3800
|
for (let attempt = 1;; attempt++) {
|
|
3617
3801
|
tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
|
|
3618
|
-
const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand, pm) : setupCommand;
|
|
3802
|
+
const hookCommand = withGuard(workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand, pm) : setupCommand);
|
|
3619
3803
|
if (hookCommand && attempt === 1) {
|
|
3620
3804
|
ctx.emitEvent({
|
|
3621
3805
|
type: "burn.setup",
|
|
@@ -3750,87 +3934,1008 @@ ${blocked.trim()}` };
|
|
|
3750
3934
|
activeTicketAborts.delete(ticket.id);
|
|
3751
3935
|
throttle.flush();
|
|
3752
3936
|
endTranscript(ticket.id);
|
|
3937
|
+
const timing = timer.summary();
|
|
3938
|
+
if (timing.calls > 0) {
|
|
3939
|
+
ctx.emitEvent({
|
|
3940
|
+
type: "ticket.timing",
|
|
3941
|
+
message: `ticket ${ticket.seq} spent ${formatTimingSummary(timing)}`,
|
|
3942
|
+
ticketId: ticket.id,
|
|
3943
|
+
data: timing
|
|
3944
|
+
});
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3948
|
+
function resolveBurnDeps(ctx) {
|
|
3949
|
+
const config2 = loadConfig();
|
|
3950
|
+
const token = readTokenFromEnvFile(envPath());
|
|
3951
|
+
const model = resolveModel("implement", config2, ctx.project, ctx.modelOverride);
|
|
3952
|
+
const land = createSerialQueue();
|
|
3953
|
+
let pushTargetReady;
|
|
3954
|
+
const ensureIsolatedPushTarget = () => pushTargetReady ??= allowPushToCheckedOutBranches(ctx.project.repoPath);
|
|
3955
|
+
return {
|
|
3956
|
+
config: config2,
|
|
3957
|
+
hasAuthToken: token !== undefined,
|
|
3958
|
+
concurrency: config2.burnConcurrency,
|
|
3959
|
+
executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model, land, ensureIsolatedPushTarget)
|
|
3960
|
+
};
|
|
3961
|
+
}
|
|
3962
|
+
function readTokenFromEnvFile(path) {
|
|
3963
|
+
let fromFile;
|
|
3964
|
+
if (existsSync7(path)) {
|
|
3965
|
+
try {
|
|
3966
|
+
fromFile = parseEnvFile(readFileSync3(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
|
|
3967
|
+
} catch {
|
|
3968
|
+
fromFile = undefined;
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
3972
|
+
return token && token.length > 0 ? token : undefined;
|
|
3973
|
+
}
|
|
3974
|
+
var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", RUN, BASH_PATTERNS, TOOL_NAME_CATEGORY, MAX_ATTRIBUTABLE_GAP_MS, BURN_WORKTREE_PATH, WORKTREE_REMOVAL_FAILURES, FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
|
|
3975
|
+
var init_ticket_burner = __esm(() => {
|
|
3976
|
+
init_src();
|
|
3977
|
+
init_config_load();
|
|
3978
|
+
init_paths();
|
|
3979
|
+
init_skills_root();
|
|
3980
|
+
init_agent_stream();
|
|
3981
|
+
init_git();
|
|
3982
|
+
init_burn_guard();
|
|
3983
|
+
PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
|
|
3984
|
+
PM_CACHE_SANDBOX_PATHS = {
|
|
3985
|
+
bun: "~/.bun/install/cache",
|
|
3986
|
+
yarn: "~/.cache/yarn",
|
|
3987
|
+
npm: "~/.npm"
|
|
3988
|
+
};
|
|
3989
|
+
RUN = String.raw`(?![\w./-])`;
|
|
3990
|
+
BASH_PATTERNS = [
|
|
3991
|
+
["tests", new RegExp(String.raw`\b(vitest|jest|pytest|go test|cargo test|bun test|playwright|maestro)${RUN}|\b(pnpm|npm|yarn|bun|npx)\b[^&;|]*?\s(run\s+)?test${RUN}|--testPathPatterns`)],
|
|
3992
|
+
["typecheck", /\btsc\b|\btypecheck\b|\bmypy\b|\bcargo check\b/],
|
|
3993
|
+
["build", new RegExp(String.raw`\bbuild${RUN}|\bprisma\s+(generate|migrate)\b|\bcodegen${RUN}`)],
|
|
3994
|
+
["lint", new RegExp(String.raw`\b(eslint|prettier|ruff|biome|lint|format)${RUN}`)],
|
|
3995
|
+
["install", /\b(pnpm|npm|yarn|bun|corepack)\s+\w*\s*install\b|\bnpm ci\b/],
|
|
3996
|
+
["git", /\bgit\b/],
|
|
3997
|
+
["search", /\b(grep|rg|ag|find)\b/],
|
|
3998
|
+
["file-read", /\b(cat|sed|head|tail|less|wc|ls)\b/],
|
|
3999
|
+
["file-edit", /<<\s*['"]?(PY|EOF|SH|JS|TS)\b|\bpython3?\s+-\s*<</]
|
|
4000
|
+
];
|
|
4001
|
+
TOOL_NAME_CATEGORY = {
|
|
4002
|
+
Read: "file-read",
|
|
4003
|
+
NotebookRead: "file-read",
|
|
4004
|
+
Grep: "search",
|
|
4005
|
+
Glob: "search",
|
|
4006
|
+
Edit: "file-edit",
|
|
4007
|
+
Write: "file-edit",
|
|
4008
|
+
NotebookEdit: "file-edit"
|
|
4009
|
+
};
|
|
4010
|
+
MAX_ATTRIBUTABLE_GAP_MS = 20 * 60000;
|
|
4011
|
+
BURN_WORKTREE_PATH = /[\\/]\.sandcastle[\\/]worktrees[\\/]/i;
|
|
4012
|
+
WORKTREE_REMOVAL_FAILURES = [
|
|
4013
|
+
/failed to delete/i,
|
|
4014
|
+
/directory not empty|enotempty/i,
|
|
4015
|
+
/unable to (unlink|delete)/i,
|
|
4016
|
+
/is not a working tree/i,
|
|
4017
|
+
/resource busy|ebusy|eperm|eacces/i
|
|
4018
|
+
];
|
|
4019
|
+
FATAL_ERROR_PATTERNS = [
|
|
4020
|
+
/invalid (api key|x-api-key)/i,
|
|
4021
|
+
/authentication|unauthorized|permission denied/i,
|
|
4022
|
+
/credit balance|billing/i,
|
|
4023
|
+
/oauth token|setup-token/i,
|
|
4024
|
+
/issue with the selected model|model not found|unknown model/i,
|
|
4025
|
+
/does not support resumeSession|resumeSession .* not found/i
|
|
4026
|
+
];
|
|
4027
|
+
RETRYABLE_ERROR_PATTERNS = [
|
|
4028
|
+
/exited with code \d+/i,
|
|
4029
|
+
/idle timeout|AgentIdleTimeout/i,
|
|
4030
|
+
/connection (closed|error|refused|reset)|socket hang up|fetch failed/i,
|
|
4031
|
+
/econnreset|etimedout|econnrefused|epipe|eai_again/i,
|
|
4032
|
+
/overloaded|rate.?limit|too many requests/i,
|
|
4033
|
+
/internal server error|service unavailable|bad gateway|gateway timeout/i,
|
|
4034
|
+
/\bapi error\b/i,
|
|
4035
|
+
/session capture failed/i
|
|
4036
|
+
];
|
|
4037
|
+
activeTicketAborts = new Map;
|
|
4038
|
+
SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
|
|
4039
|
+
ticketBurner = {
|
|
4040
|
+
id: "ticket-burner",
|
|
4041
|
+
run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
|
|
4042
|
+
};
|
|
4043
|
+
});
|
|
4044
|
+
|
|
4045
|
+
// src/workflows/project-prep.ts
|
|
4046
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync4 } from "fs";
|
|
4047
|
+
import { dirname as dirname4, join as join8 } from "path";
|
|
4048
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
4049
|
+
import { run as run2 } from "@ai-hero/sandcastle";
|
|
4050
|
+
import * as z3 from "zod";
|
|
4051
|
+
function parsePrepFindings(raw) {
|
|
4052
|
+
let json;
|
|
4053
|
+
try {
|
|
4054
|
+
json = JSON.parse(raw);
|
|
4055
|
+
} catch (e) {
|
|
4056
|
+
throw new Error(`findings file is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
4057
|
+
}
|
|
4058
|
+
const outer = RawFindings.safeParse(json);
|
|
4059
|
+
if (!outer.success)
|
|
4060
|
+
throw new Error("findings file is not a JSON object");
|
|
4061
|
+
const warnings = [];
|
|
4062
|
+
const values = {};
|
|
4063
|
+
const known = new Set(PREPARED_KEYS);
|
|
4064
|
+
for (const [key, rawValue] of Object.entries(outer.data)) {
|
|
4065
|
+
if (key === "notes")
|
|
4066
|
+
continue;
|
|
4067
|
+
if (!known.has(key)) {
|
|
4068
|
+
warnings.push(`ignored unknown key "${key}"`);
|
|
4069
|
+
continue;
|
|
4070
|
+
}
|
|
4071
|
+
const parsed = RawFinding.safeParse(rawValue);
|
|
4072
|
+
if (!parsed.success) {
|
|
4073
|
+
warnings.push(`ignored "${key}": not a string or {value, evidence} object`);
|
|
4074
|
+
continue;
|
|
4075
|
+
}
|
|
4076
|
+
const entry = parsed.data;
|
|
4077
|
+
const value = (typeof entry === "string" ? entry : entry.value ?? "").trim();
|
|
4078
|
+
if (value === "")
|
|
4079
|
+
continue;
|
|
4080
|
+
const evidence = typeof entry === "string" ? undefined : entry.evidence?.trim();
|
|
4081
|
+
values[key] = { value, ...evidence ? { evidence } : {} };
|
|
4082
|
+
}
|
|
4083
|
+
const notes = outer.data.notes?.trim();
|
|
4084
|
+
return { findings: { values, ...notes ? { notes } : {} }, warnings };
|
|
4085
|
+
}
|
|
4086
|
+
function renderPrepPrompt(template, values) {
|
|
4087
|
+
let out = template;
|
|
4088
|
+
for (const key of PLACEHOLDERS) {
|
|
4089
|
+
out = out.split(`{{${key}}}`).join(values[key]);
|
|
4090
|
+
}
|
|
4091
|
+
return out;
|
|
4092
|
+
}
|
|
4093
|
+
function buildRequestedKeysBlock(keys) {
|
|
4094
|
+
if (keys.length === 0) {
|
|
4095
|
+
return "_Everything is already established. Report an empty findings object and stop._";
|
|
4096
|
+
}
|
|
4097
|
+
const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
|
|
4098
|
+
return [...keys].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0)).map((k) => `- ${KEY_BRIEF[k]}`).join(`
|
|
4099
|
+
`);
|
|
4100
|
+
}
|
|
4101
|
+
async function prepRun(ctx, deps) {
|
|
4102
|
+
if (ctx.keys.length === 0) {
|
|
4103
|
+
ctx.emitEvent({
|
|
4104
|
+
type: "prep.skipped",
|
|
4105
|
+
message: "nothing to prepare \u2014 every field is already set"
|
|
4106
|
+
});
|
|
4107
|
+
return { status: "succeeded", summary: "nothing to prepare" };
|
|
4108
|
+
}
|
|
4109
|
+
if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
|
|
4110
|
+
ctx.emitEvent({ type: AUTH_MISSING_EVENT2, message: AUTH_MISSING_MESSAGE2 });
|
|
4111
|
+
return { status: "failed", summary: "preparation aborted: auth token missing" };
|
|
4112
|
+
}
|
|
4113
|
+
ctx.emitEvent({
|
|
4114
|
+
type: "prep.started",
|
|
4115
|
+
message: `preparing ${ctx.project.name} \u2014 establishing ${ctx.keys.join(", ")}`,
|
|
4116
|
+
data: { keys: [...ctx.keys] }
|
|
4117
|
+
});
|
|
4118
|
+
const outcome = await deps.executePrepRun(ctx);
|
|
4119
|
+
if (outcome.status === "failed") {
|
|
4120
|
+
ctx.emitEvent({
|
|
4121
|
+
type: "prep.failed",
|
|
4122
|
+
message: `preparation failed: ${errorHeadline2(outcome.error)}`,
|
|
4123
|
+
data: { error: outcome.error }
|
|
4124
|
+
});
|
|
4125
|
+
return { status: "failed", summary: outcome.error };
|
|
4126
|
+
}
|
|
4127
|
+
for (const warning of outcome.warnings) {
|
|
4128
|
+
ctx.emitEvent({ type: "prep.warning", message: `preparation: ${warning}` });
|
|
4129
|
+
}
|
|
4130
|
+
const found = Object.keys(outcome.findings.values);
|
|
4131
|
+
const missed = ctx.keys.filter((k) => !found.includes(k));
|
|
4132
|
+
const summary = found.length === 0 ? "preparation established nothing" : `established ${found.join(", ")}${missed.length > 0 ? ` (could not establish ${missed.join(", ")})` : ""}`;
|
|
4133
|
+
return { status: "succeeded", summary, findings: outcome.findings };
|
|
4134
|
+
}
|
|
4135
|
+
function errorHeadline2(s) {
|
|
4136
|
+
const lines = s.split(`
|
|
4137
|
+
`).map((l) => l.trim()).filter(Boolean);
|
|
4138
|
+
const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
|
|
4139
|
+
return causes.at(-1) ?? lines[0] ?? "";
|
|
4140
|
+
}
|
|
4141
|
+
function createPrepStreamThrottle(emit2, opts = {}) {
|
|
4142
|
+
return createStreamThrottle((e) => emit2({ ...e, type: e.type.replace(/^burn\./, "prep.") }), opts);
|
|
4143
|
+
}
|
|
4144
|
+
function prepTemplatePath() {
|
|
4145
|
+
const here = dirname4(fileURLToPath3(import.meta.url));
|
|
4146
|
+
return join8(resolveSkillsRoot(here), "burner", "prepare-project.md");
|
|
4147
|
+
}
|
|
4148
|
+
async function realExecutePrepRun(ctx, config2, token, model) {
|
|
4149
|
+
const { project } = ctx;
|
|
4150
|
+
const branch = prepBranchName(newId("b").slice(2, 10));
|
|
4151
|
+
const toolchain = readRepoToolchain(project.repoPath);
|
|
4152
|
+
const pm = detectPackageManager(toolchain);
|
|
4153
|
+
const prepared = resolvePreparedSettings(config2, project);
|
|
4154
|
+
const setupCommand = resolveSetupCommand(toolchain, prepared.setupCommand);
|
|
4155
|
+
const workspaceMode = resolveBurnWorkspaceMode(config2);
|
|
4156
|
+
const mounts = [];
|
|
4157
|
+
if (config2.sandbox !== "noSandbox" && pm) {
|
|
4158
|
+
const mount = cacheMountFor(pm, burnCacheDir(pm));
|
|
4159
|
+
if (mount) {
|
|
4160
|
+
mkdirSync4(mount.hostPath, { recursive: true });
|
|
4161
|
+
mounts.push(mount);
|
|
4162
|
+
}
|
|
4163
|
+
}
|
|
4164
|
+
const template = readFileSync4(prepTemplatePath(), "utf8");
|
|
4165
|
+
const prompt = renderPrepPrompt(template, {
|
|
4166
|
+
REQUESTED_KEYS: buildRequestedKeysBlock(ctx.keys),
|
|
4167
|
+
SETUP_COMMAND: setupCommand ?? "(no install step detected)"
|
|
4168
|
+
});
|
|
4169
|
+
mkdirSync4(logsDir(), { recursive: true });
|
|
4170
|
+
const logFilePath = join8(logsDir(), `prep-${project.id}.log`);
|
|
4171
|
+
const throttle = createPrepStreamThrottle((e) => ctx.emitEvent(e));
|
|
4172
|
+
if (setupCommand) {
|
|
4173
|
+
ctx.emitEvent({
|
|
4174
|
+
type: "prep.setup",
|
|
4175
|
+
message: `installing deps before the agent starts: ${setupCommand}`
|
|
4176
|
+
});
|
|
4177
|
+
}
|
|
4178
|
+
const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(branch, setupCommand, pm) : setupCommand;
|
|
4179
|
+
const runOptions = {
|
|
4180
|
+
agent: buildBurnAgent(config2, token, model),
|
|
4181
|
+
sandbox: selectSandbox(config2, mounts),
|
|
4182
|
+
cwd: project.repoPath,
|
|
4183
|
+
prompt,
|
|
4184
|
+
branchStrategy: { type: "branch", branch, baseBranch: project.mainBranch },
|
|
4185
|
+
signal: ctx.signal,
|
|
4186
|
+
name: "prepare",
|
|
4187
|
+
maxIterations: 1,
|
|
4188
|
+
...hookCommand ? {
|
|
4189
|
+
hooks: {
|
|
4190
|
+
sandbox: {
|
|
4191
|
+
onSandboxReady: [{ command: hookCommand, timeoutMs: PREP_SETUP_TIMEOUT_MS }]
|
|
4192
|
+
}
|
|
4193
|
+
}
|
|
4194
|
+
} : {},
|
|
4195
|
+
logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
|
|
4196
|
+
};
|
|
4197
|
+
try {
|
|
4198
|
+
let result;
|
|
4199
|
+
try {
|
|
4200
|
+
result = await run2(runOptions);
|
|
4201
|
+
} catch (err) {
|
|
4202
|
+
throttle.flush();
|
|
4203
|
+
if (ctx.signal.aborted)
|
|
4204
|
+
throw err;
|
|
4205
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4206
|
+
const salvaged = await readFindings(project.repoPath, branch);
|
|
4207
|
+
if (!salvaged)
|
|
4208
|
+
return { status: "failed", error: msg };
|
|
4209
|
+
ctx.emitEvent({
|
|
4210
|
+
type: "prep.salvaged",
|
|
4211
|
+
message: `the agent finished but sandcastle errored on teardown (${errorHeadline2(msg)}) \u2014 reading the findings it committed anyway`
|
|
4212
|
+
});
|
|
4213
|
+
return salvaged;
|
|
4214
|
+
}
|
|
4215
|
+
throttle.flush();
|
|
4216
|
+
if (result.commits.length === 0) {
|
|
4217
|
+
return {
|
|
4218
|
+
status: "failed",
|
|
4219
|
+
error: `agent produced no commits \u2014 no ${PREP_FINDINGS_PATH} landed`
|
|
4220
|
+
};
|
|
4221
|
+
}
|
|
4222
|
+
const read = await readFindings(project.repoPath, branch);
|
|
4223
|
+
return read ?? {
|
|
4224
|
+
status: "failed",
|
|
4225
|
+
error: `agent committed, but ${PREP_FINDINGS_PATH} is missing from ${branch}`
|
|
4226
|
+
};
|
|
4227
|
+
} finally {
|
|
4228
|
+
await deletePrepBranch(project.repoPath, branch);
|
|
4229
|
+
}
|
|
4230
|
+
}
|
|
4231
|
+
async function readFindings(repoPath, branch) {
|
|
4232
|
+
const raw = await readFileAtRef(repoPath, branch, PREP_FINDINGS_PATH);
|
|
4233
|
+
if (raw === undefined)
|
|
4234
|
+
return;
|
|
4235
|
+
try {
|
|
4236
|
+
const { findings, warnings } = parsePrepFindings(raw);
|
|
4237
|
+
return { status: "done", findings, warnings };
|
|
4238
|
+
} catch (e) {
|
|
4239
|
+
return { status: "failed", error: e instanceof Error ? e.message : String(e) };
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
function resolvePrepDeps(project) {
|
|
4243
|
+
const config2 = loadConfig();
|
|
4244
|
+
const token = readTokenFromEnvFile(envPath());
|
|
4245
|
+
const model = resolveModel("prepare", config2, project);
|
|
4246
|
+
return {
|
|
4247
|
+
config: config2,
|
|
4248
|
+
hasAuthToken: token !== undefined,
|
|
4249
|
+
executePrepRun: (c) => realExecutePrepRun(c, config2, token, model)
|
|
4250
|
+
};
|
|
4251
|
+
}
|
|
4252
|
+
async function prepHeadSha(project) {
|
|
4253
|
+
return existsSync8(project.repoPath) ? headSha(project.repoPath, project.mainBranch) : undefined;
|
|
4254
|
+
}
|
|
4255
|
+
var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PREP_FINDINGS_PATH = ".runcastle/prep.json", PREP_SETUP_TIMEOUT_MS, RawFinding, RawFindings, PLACEHOLDERS, KEY_BRIEF;
|
|
4256
|
+
var init_project_prep = __esm(() => {
|
|
4257
|
+
init_src();
|
|
4258
|
+
init_config_load();
|
|
4259
|
+
init_paths();
|
|
4260
|
+
init_skills_root();
|
|
4261
|
+
init_git();
|
|
4262
|
+
init_ticket_burner();
|
|
4263
|
+
PREP_SETUP_TIMEOUT_MS = 15 * 60000;
|
|
4264
|
+
RawFinding = z3.union([
|
|
4265
|
+
z3.string(),
|
|
4266
|
+
z3.object({
|
|
4267
|
+
value: z3.string().nullish(),
|
|
4268
|
+
evidence: z3.string().nullish()
|
|
4269
|
+
})
|
|
4270
|
+
]);
|
|
4271
|
+
RawFindings = z3.object({ notes: z3.string().nullish() }).catchall(z3.unknown());
|
|
4272
|
+
PLACEHOLDERS = ["REQUESTED_KEYS", "SETUP_COMMAND"];
|
|
4273
|
+
KEY_BRIEF = {
|
|
4274
|
+
setupCommand: "**`setupCommand`** \u2014 the command that takes a clean checkout to a state where the code builds and tests run: dependency install, plus any codegen or contract build that every task would otherwise discover it needed mid-flight (ORM client generation, protobuf, a `contracts:build` step). Run it and report what worked.",
|
|
4275
|
+
verifyCommands: "**`verifyCommands`** \u2014 the exact typecheck / test / lint commands for this repo, one per line. In a monorepo these must include the correct workspace filter names. Run each one and confirm it executes the thing it claims to.",
|
|
4276
|
+
knownFailures: '**`knownFailures`** \u2014 which tests are ALREADY failing on this checkout, before anything is changed. Run the full suite once and report the count plus the failing suite names. "0 known failures" is a real, useful answer.',
|
|
4277
|
+
devCommand: "**`devCommand`** \u2014 the command that starts this project's dev server on a developer's own machine. Read it from config; do not run it here.",
|
|
4278
|
+
dbResetCommand: "**`dbResetCommand`** \u2014 the command that rebuilds the dev database from the migrations currently in the working tree. Read it from config; do not run it here. Omit it entirely if this repo has no database."
|
|
4279
|
+
};
|
|
4280
|
+
});
|
|
4281
|
+
|
|
4282
|
+
// src/services/findings.ts
|
|
4283
|
+
import { and as and5, eq as eq5 } from "drizzle-orm";
|
|
4284
|
+
function isPreparedKey(key) {
|
|
4285
|
+
return PREPARED_SET.has(key);
|
|
4286
|
+
}
|
|
4287
|
+
function findingRow(ctx, projectId, key) {
|
|
4288
|
+
return ctx.db.select({
|
|
4289
|
+
source: projectFindings.source,
|
|
4290
|
+
evidence: projectFindings.evidence,
|
|
4291
|
+
establishedAt: projectFindings.establishedAt,
|
|
4292
|
+
establishedSha: projectFindings.establishedSha
|
|
4293
|
+
}).from(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).get();
|
|
4294
|
+
}
|
|
4295
|
+
function isOverwritable(ctx, projectId, key) {
|
|
4296
|
+
return findingRow(ctx, projectId, key)?.source !== "human";
|
|
4297
|
+
}
|
|
4298
|
+
function recordFinding(ctx, projectId, input) {
|
|
4299
|
+
const { key, value, source, evidence, establishedSha } = input;
|
|
4300
|
+
ctx.db.transaction((tx) => {
|
|
4301
|
+
tx.update(projects).set({ [COLUMN_NAME[key]]: value }).where(eq5(projects.id, projectId)).run();
|
|
4302
|
+
if (value === null) {
|
|
4303
|
+
tx.delete(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).run();
|
|
4304
|
+
return;
|
|
4305
|
+
}
|
|
4306
|
+
tx.insert(projectFindings).values({
|
|
4307
|
+
projectId,
|
|
4308
|
+
key,
|
|
4309
|
+
source,
|
|
4310
|
+
evidence: evidence ?? null,
|
|
4311
|
+
establishedAt: Date.now(),
|
|
4312
|
+
establishedSha: establishedSha ?? null
|
|
4313
|
+
}).onConflictDoUpdate({
|
|
4314
|
+
target: [projectFindings.projectId, projectFindings.key],
|
|
4315
|
+
set: {
|
|
4316
|
+
source,
|
|
4317
|
+
evidence: evidence ?? null,
|
|
4318
|
+
establishedAt: Date.now(),
|
|
4319
|
+
establishedSha: establishedSha ?? null
|
|
4320
|
+
}
|
|
4321
|
+
}).run();
|
|
4322
|
+
});
|
|
4323
|
+
}
|
|
4324
|
+
function recordHuman(ctx, projectId, key, value) {
|
|
4325
|
+
if (!isPreparedKey(key))
|
|
4326
|
+
return;
|
|
4327
|
+
const existing = findingRow(ctx, projectId, key);
|
|
4328
|
+
if (value === null) {
|
|
4329
|
+
if (existing) {
|
|
4330
|
+
ctx.db.delete(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).run();
|
|
4331
|
+
}
|
|
4332
|
+
return;
|
|
4333
|
+
}
|
|
4334
|
+
ctx.db.insert(projectFindings).values({
|
|
4335
|
+
projectId,
|
|
4336
|
+
key,
|
|
4337
|
+
source: "human",
|
|
4338
|
+
evidence: null,
|
|
4339
|
+
establishedAt: Date.now(),
|
|
4340
|
+
establishedSha: null
|
|
4341
|
+
}).onConflictDoUpdate({
|
|
4342
|
+
target: [projectFindings.projectId, projectFindings.key],
|
|
4343
|
+
set: { source: "human", evidence: null, establishedAt: Date.now(), establishedSha: null }
|
|
4344
|
+
}).run();
|
|
4345
|
+
}
|
|
4346
|
+
async function listFindings(ctx, project) {
|
|
4347
|
+
const rows = ctx.db.select().from(projectFindings).where(eq5(projectFindings.projectId, project.id)).all();
|
|
4348
|
+
const distances = new Map;
|
|
4349
|
+
for (const row of rows) {
|
|
4350
|
+
const sha = row.establishedSha;
|
|
4351
|
+
if (!sha || distances.has(sha))
|
|
4352
|
+
continue;
|
|
4353
|
+
distances.set(sha, await commitsSince(project.repoPath, sha, project.mainBranch));
|
|
4354
|
+
}
|
|
4355
|
+
const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
|
|
4356
|
+
return rows.filter((row) => isPreparedKey(row.key)).sort((a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0)).map((row) => {
|
|
4357
|
+
const staleCommits = row.establishedSha ? distances.get(row.establishedSha) : undefined;
|
|
4358
|
+
return {
|
|
4359
|
+
key: row.key,
|
|
4360
|
+
source: row.source,
|
|
4361
|
+
...row.evidence ? { evidence: row.evidence } : {},
|
|
4362
|
+
establishedAt: row.establishedAt,
|
|
4363
|
+
...row.establishedSha ? { establishedSha: row.establishedSha } : {},
|
|
4364
|
+
...staleCommits !== undefined ? { staleCommits } : {}
|
|
4365
|
+
};
|
|
4366
|
+
});
|
|
4367
|
+
}
|
|
4368
|
+
function unsetPreparedKeys(project) {
|
|
4369
|
+
return PREPARED_KEYS.filter((key) => {
|
|
4370
|
+
const value = project[key];
|
|
4371
|
+
return value === undefined || value.trim() === "";
|
|
4372
|
+
});
|
|
4373
|
+
}
|
|
4374
|
+
var VALUE_COLUMN, COLUMN_NAME, PREPARED_SET;
|
|
4375
|
+
var init_findings = __esm(() => {
|
|
4376
|
+
init_src();
|
|
4377
|
+
init_schema();
|
|
4378
|
+
init_git();
|
|
4379
|
+
VALUE_COLUMN = {
|
|
4380
|
+
setupCommand: projects.setupCommand,
|
|
4381
|
+
verifyCommands: projects.verifyCommands,
|
|
4382
|
+
knownFailures: projects.knownFailures,
|
|
4383
|
+
devCommand: projects.devCommand,
|
|
4384
|
+
dbResetCommand: projects.dbResetCommand
|
|
4385
|
+
};
|
|
4386
|
+
COLUMN_NAME = {
|
|
4387
|
+
setupCommand: "setupCommand",
|
|
4388
|
+
verifyCommands: "verifyCommands",
|
|
4389
|
+
knownFailures: "knownFailures",
|
|
4390
|
+
devCommand: "devCommand",
|
|
4391
|
+
dbResetCommand: "dbResetCommand"
|
|
4392
|
+
};
|
|
4393
|
+
PREPARED_SET = new Set(PREPARED_KEYS);
|
|
4394
|
+
});
|
|
4395
|
+
|
|
4396
|
+
// src/services/prep.ts
|
|
4397
|
+
import { desc as desc3, eq as eq6 } from "drizzle-orm";
|
|
4398
|
+
function isPreparing(projectId) {
|
|
4399
|
+
return controllers.has(projectId);
|
|
4400
|
+
}
|
|
4401
|
+
function keysToPrepare(ctx, project, opts = {}) {
|
|
4402
|
+
const unset = new Set(unsetPreparedKeys(project));
|
|
4403
|
+
const candidates = opts.keys ?? (opts.refresh ? PREPARED_KEYS : [...unset]);
|
|
4404
|
+
return candidates.filter((key) => (opts.refresh || unset.has(key)) && isOverwritable(ctx, project.id, key));
|
|
4405
|
+
}
|
|
4406
|
+
async function startPrep(ctx, projectId, opts = {}) {
|
|
4407
|
+
const project = requireProjectById(ctx, projectId);
|
|
4408
|
+
if (controllers.has(projectId)) {
|
|
4409
|
+
throw new InvalidInputError(`${project.name} is already being prepared`);
|
|
4410
|
+
}
|
|
4411
|
+
const keys = keysToPrepare(ctx, project, opts);
|
|
4412
|
+
const prepId = newId("prep");
|
|
4413
|
+
const headSha2 = await prepHeadSha(project);
|
|
4414
|
+
ctx.db.insert(projectPreps).values({
|
|
4415
|
+
id: prepId,
|
|
4416
|
+
projectId,
|
|
4417
|
+
status: "running",
|
|
4418
|
+
startedAt: Date.now(),
|
|
4419
|
+
endedAt: null,
|
|
4420
|
+
summary: null,
|
|
4421
|
+
headSha: headSha2 ?? null
|
|
4422
|
+
}).run();
|
|
4423
|
+
emitProject(ctx, projectId, {
|
|
4424
|
+
type: "prep.run.started",
|
|
4425
|
+
message: keys.length === 0 ? `preparing ${project.name} \u2014 nothing left to establish` : `preparing ${project.name} \u2014 ${keys.length} field(s) to establish`,
|
|
4426
|
+
data: { prepId, keys }
|
|
4427
|
+
});
|
|
4428
|
+
const controller = new AbortController;
|
|
4429
|
+
controllers.set(projectId, { runId: prepId, controller });
|
|
4430
|
+
const prepCtx = {
|
|
4431
|
+
project,
|
|
4432
|
+
keys,
|
|
4433
|
+
emitEvent: (e) => {
|
|
4434
|
+
emitProject(ctx, projectId, { type: e.type, message: e.message, data: e.data });
|
|
4435
|
+
},
|
|
4436
|
+
signal: controller.signal
|
|
4437
|
+
};
|
|
4438
|
+
const deps = opts.deps ?? resolvePrepDeps(project);
|
|
4439
|
+
const done = finalize(ctx, project, prepId, prepCtx, deps, controller, headSha2);
|
|
4440
|
+
return { prepId, keys, done };
|
|
4441
|
+
}
|
|
4442
|
+
function cancelPrep(projectId) {
|
|
4443
|
+
controllers.get(projectId)?.controller.abort();
|
|
4444
|
+
}
|
|
4445
|
+
async function finalize(ctx, project, prepId, prepCtx, deps, controller, headSha2) {
|
|
4446
|
+
let status = "failed";
|
|
4447
|
+
let summary = "preparation failed";
|
|
4448
|
+
let findings;
|
|
4449
|
+
try {
|
|
4450
|
+
const result = await prepRun(prepCtx, deps);
|
|
4451
|
+
status = result.status === "succeeded" ? "succeeded" : "failed";
|
|
4452
|
+
summary = result.summary;
|
|
4453
|
+
findings = result.findings;
|
|
4454
|
+
} catch (e) {
|
|
4455
|
+
if (controller.signal.aborted) {
|
|
4456
|
+
status = "cancelled";
|
|
4457
|
+
summary = "preparation cancelled";
|
|
4458
|
+
} else {
|
|
4459
|
+
status = "failed";
|
|
4460
|
+
summary = e instanceof Error ? e.message : "preparation failed";
|
|
4461
|
+
}
|
|
4462
|
+
} finally {
|
|
4463
|
+
controllers.delete(project.id);
|
|
4464
|
+
}
|
|
4465
|
+
const applied = findings ? applyFindings(ctx, project, findings, headSha2) : [];
|
|
4466
|
+
ctx.db.update(projectPreps).set({ status, endedAt: Date.now(), summary }).where(eq6(projectPreps.id, prepId)).run();
|
|
4467
|
+
emitProject(ctx, project.id, {
|
|
4468
|
+
type: "prep.run.finished",
|
|
4469
|
+
message: `preparation ${status}: ${summary}`,
|
|
4470
|
+
data: { prepId, status, summary, applied }
|
|
4471
|
+
});
|
|
4472
|
+
}
|
|
4473
|
+
function applyFindings(ctx, project, findings, headSha2) {
|
|
4474
|
+
const applied = [];
|
|
4475
|
+
for (const [key, finding] of Object.entries(findings.values)) {
|
|
4476
|
+
if (!isOverwritable(ctx, project.id, key)) {
|
|
4477
|
+
emitProject(ctx, project.id, {
|
|
4478
|
+
type: "prep.skipped_field",
|
|
4479
|
+
message: `kept your ${key} \u2014 preparation proposed "${truncate(finding.value)}" but you set this by hand`,
|
|
4480
|
+
data: { key, proposed: finding.value }
|
|
4481
|
+
});
|
|
4482
|
+
continue;
|
|
4483
|
+
}
|
|
4484
|
+
recordFinding(ctx, project.id, {
|
|
4485
|
+
key,
|
|
4486
|
+
value: finding.value,
|
|
4487
|
+
source: "prep",
|
|
4488
|
+
...finding.evidence ? { evidence: finding.evidence } : {},
|
|
4489
|
+
...headSha2 ? { establishedSha: headSha2 } : {}
|
|
4490
|
+
});
|
|
4491
|
+
applied.push(key);
|
|
4492
|
+
emitProject(ctx, project.id, {
|
|
4493
|
+
type: "prep.established",
|
|
4494
|
+
message: `${key}: ${truncate(finding.value)}`,
|
|
4495
|
+
data: { key, value: finding.value, evidence: finding.evidence }
|
|
4496
|
+
});
|
|
4497
|
+
}
|
|
4498
|
+
if (findings.notes) {
|
|
4499
|
+
emitProject(ctx, project.id, {
|
|
4500
|
+
type: "prep.notes",
|
|
4501
|
+
message: `preparation notes: ${findings.notes}`,
|
|
4502
|
+
data: { notes: findings.notes }
|
|
4503
|
+
});
|
|
4504
|
+
}
|
|
4505
|
+
return applied;
|
|
4506
|
+
}
|
|
4507
|
+
function truncate(value, max = 120) {
|
|
4508
|
+
const oneLine = value.replace(/\s*\n\s*/g, " \xB7 ").trim();
|
|
4509
|
+
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}\u2026` : oneLine;
|
|
4510
|
+
}
|
|
4511
|
+
function latestPrep(ctx, projectId) {
|
|
4512
|
+
const row = ctx.db.select().from(projectPreps).where(eq6(projectPreps.projectId, projectId)).orderBy(desc3(projectPreps.startedAt)).get();
|
|
4513
|
+
if (!row)
|
|
4514
|
+
return null;
|
|
4515
|
+
return {
|
|
4516
|
+
id: row.id,
|
|
4517
|
+
projectId: row.projectId,
|
|
4518
|
+
status: row.status,
|
|
4519
|
+
startedAt: row.startedAt,
|
|
4520
|
+
...row.endedAt !== null ? { endedAt: row.endedAt } : {},
|
|
4521
|
+
...row.summary !== null ? { summary: row.summary } : {},
|
|
4522
|
+
...row.headSha !== null ? { headSha: row.headSha } : {}
|
|
4523
|
+
};
|
|
4524
|
+
}
|
|
4525
|
+
function reconcilePreps(ctx) {
|
|
4526
|
+
const stale = ctx.db.select({ id: projectPreps.id, projectId: projectPreps.projectId }).from(projectPreps).where(eq6(projectPreps.status, "running")).all().filter((row) => !controllers.has(row.projectId));
|
|
4527
|
+
for (const row of stale) {
|
|
4528
|
+
ctx.db.update(projectPreps).set({ status: "failed", endedAt: Date.now(), summary: "server stopped while preparing" }).where(eq6(projectPreps.id, row.id)).run();
|
|
4529
|
+
}
|
|
4530
|
+
return stale.length;
|
|
4531
|
+
}
|
|
4532
|
+
var controllers;
|
|
4533
|
+
var init_prep = __esm(() => {
|
|
4534
|
+
init_src();
|
|
4535
|
+
init_schema();
|
|
4536
|
+
init_errors();
|
|
4537
|
+
init_project_prep();
|
|
4538
|
+
init_events();
|
|
4539
|
+
init_findings();
|
|
4540
|
+
init_repo();
|
|
4541
|
+
controllers = new Map;
|
|
4542
|
+
});
|
|
4543
|
+
|
|
4544
|
+
// src/launcher/runtime.ts
|
|
4545
|
+
function setRuntimeCtx(ctx) {
|
|
4546
|
+
current = ctx;
|
|
4547
|
+
}
|
|
4548
|
+
async function getRuntimeCtx() {
|
|
4549
|
+
if (current)
|
|
4550
|
+
return current;
|
|
4551
|
+
const [{ createDb: createDb2 }, { dbPath: dbPath2 }, { loadConfig: loadConfig2 }] = await Promise.all([
|
|
4552
|
+
Promise.resolve().then(() => (init_client(), exports_client)),
|
|
4553
|
+
Promise.resolve().then(() => (init_paths(), exports_paths)),
|
|
4554
|
+
Promise.resolve().then(() => (init_config_load(), exports_config_load))
|
|
4555
|
+
]);
|
|
4556
|
+
const db = createDb2(dbPath2());
|
|
4557
|
+
current = { db, config: loadConfig2() };
|
|
4558
|
+
return current;
|
|
4559
|
+
}
|
|
4560
|
+
var current = null;
|
|
4561
|
+
|
|
4562
|
+
// src/services/feature-docs.ts
|
|
4563
|
+
import { existsSync as existsSync9 } from "fs";
|
|
4564
|
+
import { join as join9 } from "path";
|
|
4565
|
+
function featureDocsDir(project, feature) {
|
|
4566
|
+
const relSegments = featureDocsRel(feature.slug).split("/");
|
|
4567
|
+
const worktree = worktreeDir(project.id, feature.slug);
|
|
4568
|
+
const base = existsSync9(worktree) ? worktree : project.repoPath;
|
|
4569
|
+
return join9(base, ...relSegments);
|
|
4570
|
+
}
|
|
4571
|
+
function featureDocPath(project, feature, fileName) {
|
|
4572
|
+
return join9(featureDocsDir(project, feature), fileName);
|
|
4573
|
+
}
|
|
4574
|
+
var init_feature_docs = __esm(() => {
|
|
4575
|
+
init_paths();
|
|
4576
|
+
});
|
|
4577
|
+
|
|
4578
|
+
// src/services/tickets.ts
|
|
4579
|
+
import { and as and6, asc as asc3, eq as eq7 } from "drizzle-orm";
|
|
4580
|
+
function rowToTicket(row) {
|
|
4581
|
+
return {
|
|
4582
|
+
id: row.id,
|
|
4583
|
+
featureId: row.featureId,
|
|
4584
|
+
seq: row.seq,
|
|
4585
|
+
title: row.title,
|
|
4586
|
+
goal: row.goal,
|
|
4587
|
+
context: row.context,
|
|
4588
|
+
acceptanceCriteria: row.acceptanceCriteria,
|
|
4589
|
+
seams: row.seams,
|
|
4590
|
+
blockedBy: row.blockedBy,
|
|
4591
|
+
status: row.status,
|
|
4592
|
+
commits: row.commits,
|
|
4593
|
+
error: row.error ?? undefined,
|
|
4594
|
+
attemptBranch: row.attemptBranch ?? undefined,
|
|
4595
|
+
conflictFiles: row.conflictFiles ?? undefined
|
|
4596
|
+
};
|
|
4597
|
+
}
|
|
4598
|
+
function resolveBlocking(inputs, startSeq) {
|
|
4599
|
+
try {
|
|
4600
|
+
return resolveBatchBlocking(inputs, { startSeq });
|
|
4601
|
+
} catch (e) {
|
|
4602
|
+
if (e instanceof BlockingEdgeError)
|
|
4603
|
+
throw new InvalidInputError(e.message);
|
|
4604
|
+
throw e;
|
|
4605
|
+
}
|
|
4606
|
+
}
|
|
4607
|
+
function listByFeature2(ctx, featureId) {
|
|
4608
|
+
return ctx.db.select().from(tickets).where(eq7(tickets.featureId, featureId)).orderBy(asc3(tickets.seq)).all().map(rowToTicket);
|
|
4609
|
+
}
|
|
4610
|
+
function storeTickets(ctx, featureId, inputs) {
|
|
4611
|
+
if (inputs.length === 0)
|
|
4612
|
+
return [];
|
|
4613
|
+
const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq7(tickets.featureId, featureId)).all();
|
|
4614
|
+
const startSeq = existing.reduce((max, r) => Math.max(max, r.seq), 0) + 1;
|
|
4615
|
+
const resolved = resolveBlocking(inputs, startSeq);
|
|
4616
|
+
const rows = inputs.map((t, i) => ({
|
|
4617
|
+
id: newId("tkt"),
|
|
4618
|
+
featureId,
|
|
4619
|
+
seq: resolved[i].seq,
|
|
4620
|
+
title: t.title,
|
|
4621
|
+
goal: t.goal,
|
|
4622
|
+
context: t.context,
|
|
4623
|
+
acceptanceCriteria: t.acceptanceCriteria,
|
|
4624
|
+
seams: t.seams,
|
|
4625
|
+
blockedBy: resolved[i].blockedBy,
|
|
4626
|
+
status: "pending",
|
|
4627
|
+
commits: [],
|
|
4628
|
+
error: null,
|
|
4629
|
+
attemptBranch: null,
|
|
4630
|
+
conflictFiles: null
|
|
4631
|
+
}));
|
|
4632
|
+
ctx.db.insert(tickets).values(rows).run();
|
|
4633
|
+
emit(ctx, featureId, {
|
|
4634
|
+
type: "tickets.stored",
|
|
4635
|
+
message: `${rows.length} ticket(s) stored`,
|
|
4636
|
+
data: { count: rows.length, seqs: rows.map((r) => r.seq) }
|
|
4637
|
+
});
|
|
4638
|
+
return rows.map(rowToTicket);
|
|
4639
|
+
}
|
|
4640
|
+
function getTicket(ctx, id) {
|
|
4641
|
+
const row = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
|
|
4642
|
+
if (!row)
|
|
4643
|
+
throw new NotFoundError(`ticket ${id} not found`);
|
|
4644
|
+
return rowToTicket(row);
|
|
4645
|
+
}
|
|
4646
|
+
function assertMutable(t, verb) {
|
|
4647
|
+
if (!MUTABLE_STATUSES.has(t.status)) {
|
|
4648
|
+
throw new InvalidInputError(`cannot ${verb} ticket ${t.seq} \u2014 it is ${t.status}; only pending or failed tickets can be ${verb}${verb.endsWith("l") ? "led" : "ed"}`);
|
|
4649
|
+
}
|
|
4650
|
+
}
|
|
4651
|
+
function editTicket(ctx, id, patch) {
|
|
4652
|
+
const current2 = getTicket(ctx, id);
|
|
4653
|
+
assertMutable(current2, "edit");
|
|
4654
|
+
const set = {};
|
|
4655
|
+
if (patch.title !== undefined)
|
|
4656
|
+
set.title = patch.title;
|
|
4657
|
+
if (patch.goal !== undefined)
|
|
4658
|
+
set.goal = patch.goal;
|
|
4659
|
+
if (patch.context !== undefined)
|
|
4660
|
+
set.context = patch.context;
|
|
4661
|
+
if (patch.acceptanceCriteria !== undefined)
|
|
4662
|
+
set.acceptanceCriteria = patch.acceptanceCriteria;
|
|
4663
|
+
if (patch.seams !== undefined)
|
|
4664
|
+
set.seams = patch.seams;
|
|
4665
|
+
if (Object.keys(set).length === 0) {
|
|
4666
|
+
throw new InvalidInputError("nothing to edit \u2014 pass at least one content field");
|
|
4667
|
+
}
|
|
4668
|
+
ctx.db.update(tickets).set(set).where(eq7(tickets.id, id)).run();
|
|
4669
|
+
emit(ctx, current2.featureId, {
|
|
4670
|
+
type: "ticket.edited",
|
|
4671
|
+
message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
|
|
4672
|
+
ticketId: id,
|
|
4673
|
+
data: { fields: Object.keys(set) }
|
|
4674
|
+
});
|
|
4675
|
+
return getTicket(ctx, id);
|
|
4676
|
+
}
|
|
4677
|
+
function cancelTicket(ctx, id, reason) {
|
|
4678
|
+
const current2 = getTicket(ctx, id);
|
|
4679
|
+
assertMutable(current2, "cancel");
|
|
4680
|
+
ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq7(tickets.id, id)).run();
|
|
4681
|
+
emit(ctx, current2.featureId, {
|
|
4682
|
+
type: "ticket.cancelled",
|
|
4683
|
+
message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
|
|
4684
|
+
ticketId: id,
|
|
4685
|
+
data: { reason: reason ?? null }
|
|
4686
|
+
});
|
|
4687
|
+
return getTicket(ctx, id);
|
|
4688
|
+
}
|
|
4689
|
+
function sweepOrphanedBurning(ctx, featureId, reason) {
|
|
4690
|
+
const orphaned = ctx.db.select().from(tickets).where(and6(eq7(tickets.featureId, featureId), eq7(tickets.status, "burning"))).all().map(rowToTicket);
|
|
4691
|
+
for (const t of orphaned) {
|
|
4692
|
+
updateTicket(ctx, t.id, { status: "failed", error: reason });
|
|
4693
|
+
emit(ctx, featureId, {
|
|
4694
|
+
type: "ticket.failed",
|
|
4695
|
+
message: `ticket ${t.seq} failed: ${reason}`,
|
|
4696
|
+
ticketId: t.id,
|
|
4697
|
+
data: { error: reason, orphaned: true }
|
|
4698
|
+
});
|
|
4699
|
+
}
|
|
4700
|
+
return orphaned.map((t) => ({ ...t, status: "failed", error: reason }));
|
|
4701
|
+
}
|
|
4702
|
+
function updateTicket(ctx, id, patch) {
|
|
4703
|
+
const current2 = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
|
|
4704
|
+
if (!current2)
|
|
4705
|
+
throw new NotFoundError(`ticket ${id} not found`);
|
|
4706
|
+
const set = {};
|
|
4707
|
+
if (patch.status !== undefined)
|
|
4708
|
+
set.status = patch.status;
|
|
4709
|
+
if (patch.commits !== undefined)
|
|
4710
|
+
set.commits = patch.commits;
|
|
4711
|
+
if (patch.error !== undefined)
|
|
4712
|
+
set.error = patch.error;
|
|
4713
|
+
if (patch.attemptBranch !== undefined)
|
|
4714
|
+
set.attemptBranch = patch.attemptBranch;
|
|
4715
|
+
if (patch.conflictFiles !== undefined)
|
|
4716
|
+
set.conflictFiles = patch.conflictFiles;
|
|
4717
|
+
ctx.db.update(tickets).set(set).where(eq7(tickets.id, id)).run();
|
|
4718
|
+
emit(ctx, current2.featureId, {
|
|
4719
|
+
type: "ticket.updated",
|
|
4720
|
+
message: `ticket ${current2.seq} \u2192 ${patch.status ?? current2.status}`,
|
|
4721
|
+
ticketId: id,
|
|
4722
|
+
data: patch
|
|
4723
|
+
});
|
|
4724
|
+
const updated = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
|
|
4725
|
+
return rowToTicket(updated);
|
|
4726
|
+
}
|
|
4727
|
+
var MUTABLE_STATUSES;
|
|
4728
|
+
var init_tickets = __esm(() => {
|
|
4729
|
+
init_src();
|
|
4730
|
+
init_schema();
|
|
4731
|
+
init_errors();
|
|
4732
|
+
init_events();
|
|
4733
|
+
MUTABLE_STATUSES = new Set(["pending", "failed"]);
|
|
4734
|
+
});
|
|
4735
|
+
|
|
4736
|
+
// src/services/gates.ts
|
|
4737
|
+
import { existsSync as existsSync10 } from "fs";
|
|
4738
|
+
function checkGate(ctx, check, feature) {
|
|
4739
|
+
switch (check) {
|
|
4740
|
+
case "decisions-file-exists":
|
|
4741
|
+
return fileGate(ctx, feature, "decisions.md", "run the ideation session to capture decisions first");
|
|
4742
|
+
case "all-waypoints-terminal": {
|
|
4743
|
+
const wps = listByFeature(ctx, feature.id);
|
|
4744
|
+
if (wps.length === 0) {
|
|
4745
|
+
return { satisfied: false, reason: "no waypoints charted yet \u2014 chart the map before converging" };
|
|
4746
|
+
}
|
|
4747
|
+
const open = wps.filter((w) => w.status !== "resolved" && w.status !== "dropped");
|
|
4748
|
+
return open.length === 0 ? { satisfied: true } : {
|
|
4749
|
+
satisfied: false,
|
|
4750
|
+
reason: notYetTerminal("waypoint", open.map((w) => w.status), ["open", "claimed"])
|
|
4751
|
+
};
|
|
4752
|
+
}
|
|
4753
|
+
case "spec-file-exists":
|
|
4754
|
+
return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
|
|
4755
|
+
case "tickets-approved": {
|
|
4756
|
+
const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
|
|
4757
|
+
return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
|
|
4758
|
+
}
|
|
4759
|
+
case "all-tickets-terminal": {
|
|
4760
|
+
const tickets2 = listByFeature2(ctx, feature.id);
|
|
4761
|
+
if (tickets2.length === 0)
|
|
4762
|
+
return { satisfied: false, reason: "no tickets have run yet" };
|
|
4763
|
+
const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
|
|
4764
|
+
return open.length === 0 ? { satisfied: true } : {
|
|
4765
|
+
satisfied: false,
|
|
4766
|
+
reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
|
|
4767
|
+
};
|
|
4768
|
+
}
|
|
4769
|
+
case "human-merge":
|
|
4770
|
+
return { satisfied: false, reason: "use the Merge button to ship" };
|
|
4771
|
+
}
|
|
4772
|
+
}
|
|
4773
|
+
function notYetTerminal(noun, statuses, order) {
|
|
4774
|
+
const counts = new Map;
|
|
4775
|
+
for (const s of statuses)
|
|
4776
|
+
counts.set(s, (counts.get(s) ?? 0) + 1);
|
|
4777
|
+
const ordered = [
|
|
4778
|
+
...order.filter((s) => counts.has(s)),
|
|
4779
|
+
...[...counts.keys()].filter((s) => !order.includes(s))
|
|
4780
|
+
];
|
|
4781
|
+
const breakdown = ordered.map((s) => `${counts.get(s)} ${s}`).join(", ");
|
|
4782
|
+
return `${statuses.length} ${statuses.length === 1 ? noun : `${noun}s`} not yet terminal (${breakdown})`;
|
|
4783
|
+
}
|
|
4784
|
+
function fileGate(ctx, feature, fileName, reason) {
|
|
4785
|
+
const project = projectForFeature(ctx, feature);
|
|
4786
|
+
return existsSync10(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
|
|
4787
|
+
}
|
|
4788
|
+
function overrideGate(ctx, featureId, gate, reason) {
|
|
4789
|
+
const feature = getFeatureRow(ctx, featureId);
|
|
4790
|
+
ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
|
|
4791
|
+
emit(ctx, featureId, {
|
|
4792
|
+
type: "gate.overridden",
|
|
4793
|
+
message: `gate ${gate} overridden: ${reason}`,
|
|
4794
|
+
data: { gate, reason }
|
|
4795
|
+
});
|
|
4796
|
+
const next = nextPhase(feature);
|
|
4797
|
+
if (next)
|
|
4798
|
+
return setPhase(ctx, featureId, next, "phase.advanced", `advanced to ${next} (override)`);
|
|
4799
|
+
return feature;
|
|
4800
|
+
}
|
|
4801
|
+
var init_gates = __esm(() => {
|
|
4802
|
+
init_src();
|
|
4803
|
+
init_schema();
|
|
4804
|
+
init_events();
|
|
4805
|
+
init_feature_docs();
|
|
4806
|
+
init_repo();
|
|
4807
|
+
init_tickets();
|
|
4808
|
+
init_waypoints();
|
|
4809
|
+
});
|
|
4810
|
+
|
|
4811
|
+
// src/services/knowledge.ts
|
|
4812
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, writeFileSync } from "fs";
|
|
4813
|
+
import { join as join10, relative, resolve as resolve5, sep } from "path";
|
|
4814
|
+
function scaffoldDocs(ctx, feature) {
|
|
4815
|
+
const project = projectForFeature(ctx, feature);
|
|
4816
|
+
const dir = featureDocsDir(project, feature);
|
|
4817
|
+
mkdirSync5(dir, { recursive: true });
|
|
4818
|
+
const briefPath = featureDocPath(project, feature, "brief.md");
|
|
4819
|
+
if (!existsSync11(briefPath)) {
|
|
4820
|
+
const created = new Date().toISOString();
|
|
4821
|
+
const brief = [
|
|
4822
|
+
`# ${feature.title}`,
|
|
4823
|
+
"",
|
|
4824
|
+
feature.oneLiner,
|
|
4825
|
+
"",
|
|
4826
|
+
`- Slug: ${feature.slug}`,
|
|
4827
|
+
`- Created: ${created}`,
|
|
4828
|
+
""
|
|
4829
|
+
].join(`
|
|
4830
|
+
`);
|
|
4831
|
+
writeFileSync(briefPath, brief, "utf8");
|
|
3753
4832
|
}
|
|
4833
|
+
if (feature.mapped) {
|
|
4834
|
+
scaffoldMapDoc(project, feature);
|
|
4835
|
+
}
|
|
4836
|
+
emit(ctx, feature.id, {
|
|
4837
|
+
type: "docs.scaffolded",
|
|
4838
|
+
message: feature.mapped ? `scaffolded docs/features/${feature.slug}/{brief,map}.md` : `scaffolded docs/features/${feature.slug}/brief.md`
|
|
4839
|
+
});
|
|
3754
4840
|
}
|
|
3755
|
-
function
|
|
3756
|
-
|
|
3757
|
-
const
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model, land, ensureIsolatedPushTarget)
|
|
4841
|
+
function scaffoldMapDoc(project, feature, seed) {
|
|
4842
|
+
mkdirSync5(featureDocsDir(project, feature), { recursive: true });
|
|
4843
|
+
const mapPath = featureDocPath(project, feature, "map.md");
|
|
4844
|
+
if (existsSync11(mapPath))
|
|
4845
|
+
return;
|
|
4846
|
+
writeFileSync(mapPath, mapDocBody(feature, seed), "utf8");
|
|
4847
|
+
}
|
|
4848
|
+
function mapDocBody(feature, seed) {
|
|
4849
|
+
const seeded = {
|
|
4850
|
+
Destination: seed?.destination?.trim(),
|
|
4851
|
+
Notes: seed?.notes?.trim()
|
|
3767
4852
|
};
|
|
4853
|
+
const lines = [`# ${feature.title} \u2014 map`, ""];
|
|
4854
|
+
for (const section of MAP_SECTIONS) {
|
|
4855
|
+
lines.push(`## ${section}`, "");
|
|
4856
|
+
const body = seeded[section];
|
|
4857
|
+
if (body)
|
|
4858
|
+
lines.push(body, "");
|
|
4859
|
+
}
|
|
4860
|
+
return lines.join(`
|
|
4861
|
+
`);
|
|
3768
4862
|
}
|
|
3769
|
-
function
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
4863
|
+
function listDocs(ctx, feature) {
|
|
4864
|
+
const project = projectForFeature(ctx, feature);
|
|
4865
|
+
const dir = featureDocsDir(project, feature);
|
|
4866
|
+
if (!existsSync11(dir))
|
|
4867
|
+
return [];
|
|
4868
|
+
return readdirSync3(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => ({ relPath: e.name, title: readTitle(join10(dir, e.name), e.name) })).sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
4869
|
+
}
|
|
4870
|
+
function readDoc(ctx, feature, relPath) {
|
|
4871
|
+
const project = projectForFeature(ctx, feature);
|
|
4872
|
+
const dir = featureDocsDir(project, feature);
|
|
4873
|
+
const root = resolve5(dir);
|
|
4874
|
+
const target = resolve5(dir, relPath);
|
|
4875
|
+
const rel = relative(root, target);
|
|
4876
|
+
if (rel === "" || rel.startsWith("..") || rel.split(sep).includes("..")) {
|
|
4877
|
+
throw new InvalidInputError(`doc path escapes the feature docs dir: ${relPath}`);
|
|
3777
4878
|
}
|
|
3778
|
-
|
|
3779
|
-
|
|
4879
|
+
if (!existsSync11(target))
|
|
4880
|
+
throw new NotFoundError(`doc not found: ${relPath}`);
|
|
4881
|
+
return { content: readFileSync5(target, "utf8") };
|
|
3780
4882
|
}
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
];
|
|
3803
|
-
FATAL_ERROR_PATTERNS = [
|
|
3804
|
-
/invalid (api key|x-api-key)/i,
|
|
3805
|
-
/authentication|unauthorized|permission denied/i,
|
|
3806
|
-
/credit balance|billing/i,
|
|
3807
|
-
/oauth token|setup-token/i,
|
|
3808
|
-
/issue with the selected model|model not found|unknown model/i,
|
|
3809
|
-
/does not support resumeSession|resumeSession .* not found/i
|
|
3810
|
-
];
|
|
3811
|
-
RETRYABLE_ERROR_PATTERNS = [
|
|
3812
|
-
/exited with code \d+/i,
|
|
3813
|
-
/idle timeout|AgentIdleTimeout/i,
|
|
3814
|
-
/connection (closed|error|refused|reset)|socket hang up|fetch failed/i,
|
|
3815
|
-
/econnreset|etimedout|econnrefused|epipe|eai_again/i,
|
|
3816
|
-
/overloaded|rate.?limit|too many requests/i,
|
|
3817
|
-
/internal server error|service unavailable|bad gateway|gateway timeout/i,
|
|
3818
|
-
/\bapi error\b/i,
|
|
3819
|
-
/session capture failed/i
|
|
4883
|
+
function readTitle(path, fallback) {
|
|
4884
|
+
try {
|
|
4885
|
+
const firstLine = readFileSync5(path, "utf8").split(`
|
|
4886
|
+
`, 1)[0] ?? "";
|
|
4887
|
+
const heading = firstLine.match(/^#\s+(.+)$/);
|
|
4888
|
+
return heading ? heading[1].trim() : fallback;
|
|
4889
|
+
} catch {
|
|
4890
|
+
return fallback;
|
|
4891
|
+
}
|
|
4892
|
+
}
|
|
4893
|
+
var MAP_SECTIONS;
|
|
4894
|
+
var init_knowledge = __esm(() => {
|
|
4895
|
+
init_errors();
|
|
4896
|
+
init_events();
|
|
4897
|
+
init_feature_docs();
|
|
4898
|
+
init_repo();
|
|
4899
|
+
MAP_SECTIONS = [
|
|
4900
|
+
"Destination",
|
|
4901
|
+
"Notes",
|
|
4902
|
+
"Not yet specified",
|
|
4903
|
+
"Out of scope"
|
|
3820
4904
|
];
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
4905
|
+
});
|
|
4906
|
+
|
|
4907
|
+
// src/pty/end-session.ts
|
|
4908
|
+
function endSession(ctx, sessionId) {
|
|
4909
|
+
const registry = ptyRegistry();
|
|
4910
|
+
const killed = registry.kill(sessionId);
|
|
4911
|
+
registry.remove(sessionId);
|
|
4912
|
+
const session = markSessionEnded(ctx, sessionId);
|
|
4913
|
+
if (session) {
|
|
4914
|
+
releaseForSession(ctx, sessionId);
|
|
4915
|
+
emit(ctx, session.featureId, {
|
|
4916
|
+
type: "session.ended",
|
|
4917
|
+
message: "session ended by user",
|
|
4918
|
+
data: { sessionId, killed }
|
|
4919
|
+
});
|
|
4920
|
+
} else {
|
|
4921
|
+
const existing = getSessionRow(ctx, sessionId);
|
|
4922
|
+
if (!existing)
|
|
4923
|
+
return { sessionId, ok: false, killed };
|
|
4924
|
+
}
|
|
4925
|
+
return { sessionId, ok: true, killed };
|
|
4926
|
+
}
|
|
4927
|
+
var init_end_session = __esm(() => {
|
|
4928
|
+
init_sessions();
|
|
4929
|
+
init_events();
|
|
4930
|
+
init_waypoints();
|
|
4931
|
+
init_registry();
|
|
3827
4932
|
});
|
|
3828
4933
|
|
|
3829
4934
|
// src/workflows/research.ts
|
|
3830
|
-
import { existsSync as
|
|
3831
|
-
import { dirname as
|
|
3832
|
-
import { fileURLToPath as
|
|
3833
|
-
import { run as
|
|
4935
|
+
import { existsSync as existsSync12, mkdirSync as mkdirSync6, readFileSync as readFileSync6, readdirSync as readdirSync4 } from "fs";
|
|
4936
|
+
import { dirname as dirname5, join as join11 } from "path";
|
|
4937
|
+
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
4938
|
+
import { run as run3 } from "@ai-hero/sandcastle";
|
|
3834
4939
|
import { docker as docker2 } from "@ai-hero/sandcastle/sandboxes/docker";
|
|
3835
4940
|
import { noSandbox as noSandbox2 } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
|
|
3836
4941
|
function waypointSlug(waypoint) {
|
|
@@ -3847,7 +4952,7 @@ async function researchRun(ctx, deps) {
|
|
|
3847
4952
|
return { status: "failed", summary: "no waypoint to research" };
|
|
3848
4953
|
}
|
|
3849
4954
|
if (deps.config.sandbox === "docker" && !deps.hasAuthToken) {
|
|
3850
|
-
ctx.emitEvent({ type:
|
|
4955
|
+
ctx.emitEvent({ type: AUTH_MISSING_EVENT3, message: AUTH_MISSING_MESSAGE3 });
|
|
3851
4956
|
return { status: "failed", summary: "research aborted: auth token missing" };
|
|
3852
4957
|
}
|
|
3853
4958
|
ctx.emitEvent({
|
|
@@ -3868,12 +4973,12 @@ async function researchRun(ctx, deps) {
|
|
|
3868
4973
|
}
|
|
3869
4974
|
ctx.emitEvent({
|
|
3870
4975
|
type: "research.failed",
|
|
3871
|
-
message: `waypoint ${waypoint.seq} research failed: ${
|
|
4976
|
+
message: `waypoint ${waypoint.seq} research failed: ${errorHeadline3(outcome.error)}`,
|
|
3872
4977
|
data: { waypointId: waypoint.id, error: outcome.error }
|
|
3873
4978
|
});
|
|
3874
4979
|
return { status: "failed", summary: outcome.error };
|
|
3875
4980
|
}
|
|
3876
|
-
function
|
|
4981
|
+
function errorHeadline3(s) {
|
|
3877
4982
|
const lines = s.split(`
|
|
3878
4983
|
`).map((l) => l.trim()).filter(Boolean);
|
|
3879
4984
|
const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
|
|
@@ -3884,7 +4989,7 @@ function createResearchStreamThrottle(emit2, opts = {}) {
|
|
|
3884
4989
|
}
|
|
3885
4990
|
function renderResearchPrompt(template, values) {
|
|
3886
4991
|
let out = template;
|
|
3887
|
-
for (const key of
|
|
4992
|
+
for (const key of PLACEHOLDERS2) {
|
|
3888
4993
|
out = out.split(`{{${key}}}`).join(values[key]);
|
|
3889
4994
|
}
|
|
3890
4995
|
return out;
|
|
@@ -3899,32 +5004,32 @@ function interpretResearchResult(result, docRelPath) {
|
|
|
3899
5004
|
return { status: "failed", error: "agent produced no commits \u2014 no research summary landed" };
|
|
3900
5005
|
}
|
|
3901
5006
|
function researchTemplatePath() {
|
|
3902
|
-
const here =
|
|
3903
|
-
return
|
|
5007
|
+
const here = dirname5(fileURLToPath4(import.meta.url));
|
|
5008
|
+
return join11(resolveSkillsRoot(here), "burner", "research-waypoint.md");
|
|
3904
5009
|
}
|
|
3905
5010
|
function readDocsDigestFromDisk2(projectId, slug) {
|
|
3906
5011
|
const worktree = worktreeDir(projectId, slug);
|
|
3907
|
-
if (!
|
|
5012
|
+
if (!existsSync12(worktree))
|
|
3908
5013
|
return "_No talk worktree on disk \u2014 docs digest skipped._";
|
|
3909
|
-
const docsDir =
|
|
3910
|
-
if (!
|
|
5014
|
+
const docsDir = join11(worktree, ...featureDocsRel(slug).split("/"));
|
|
5015
|
+
if (!existsSync12(docsDir))
|
|
3911
5016
|
return "_No docs/features dir in the talk worktree \u2014 docs digest skipped._";
|
|
3912
|
-
const files = readdirSync4(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content:
|
|
5017
|
+
const files = readdirSync4(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync6(join11(docsDir, e.name), "utf8") }));
|
|
3913
5018
|
return buildDocsDigest(files);
|
|
3914
5019
|
}
|
|
3915
5020
|
async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
|
|
3916
5021
|
const { project, feature } = ctx;
|
|
3917
5022
|
const docRelPath = researchDocRel(feature.slug, waypoint);
|
|
3918
5023
|
const tempBranch = researchBranchName(feature.slug, waypoint.seq, newId("b").slice(2, 10));
|
|
3919
|
-
const template =
|
|
5024
|
+
const template = readFileSync6(researchTemplatePath(), "utf8");
|
|
3920
5025
|
const prompt = renderResearchPrompt(template, {
|
|
3921
5026
|
WAYPOINT_JSON: buildWaypointJson(waypoint),
|
|
3922
5027
|
FEATURE_BRIEF: buildFeatureBrief(feature),
|
|
3923
5028
|
DOCS_DIGEST: readDocsDigestFromDisk2(project.id, feature.slug),
|
|
3924
5029
|
RESEARCH_DOC_PATH: docRelPath
|
|
3925
5030
|
});
|
|
3926
|
-
|
|
3927
|
-
const logFilePath =
|
|
5031
|
+
mkdirSync6(logsDir(), { recursive: true });
|
|
5032
|
+
const logFilePath = join11(logsDir(), `research-${feature.id}-${waypoint.seq}.log`);
|
|
3928
5033
|
const throttle = createResearchStreamThrottle((e) => ctx.emitEvent(e));
|
|
3929
5034
|
const runOptions = {
|
|
3930
5035
|
agent: buildBurnAgent(config2, token, model),
|
|
@@ -3939,7 +5044,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
|
|
|
3939
5044
|
let result;
|
|
3940
5045
|
let salvaged = [];
|
|
3941
5046
|
try {
|
|
3942
|
-
result = await
|
|
5047
|
+
result = await run3(runOptions);
|
|
3943
5048
|
} catch (err) {
|
|
3944
5049
|
throttle.flush();
|
|
3945
5050
|
if (ctx.signal.aborted)
|
|
@@ -3951,7 +5056,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
|
|
|
3951
5056
|
const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
|
|
3952
5057
|
ctx.emitEvent({
|
|
3953
5058
|
type: "burn.worktree.teardown-failed",
|
|
3954
|
-
message: `waypoint ${waypoint.seq}: agent finished but sandcastle could not remove its worktree (${
|
|
5059
|
+
message: `waypoint ${waypoint.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline3(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
|
|
3955
5060
|
data: { tempBranch, error: msg, cleanedUp: removed }
|
|
3956
5061
|
});
|
|
3957
5062
|
}
|
|
@@ -3964,7 +5069,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
|
|
|
3964
5069
|
const detail = merge.error ?? "merge failed";
|
|
3965
5070
|
ctx.emitEvent({
|
|
3966
5071
|
type: merge.conflict ? "merge.conflict.needs-human" : "research.merge_failed",
|
|
3967
|
-
message: merge.conflict ? `research branch ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then resolve the waypoint` : `could not land ${tempBranch} on ${feature.branch}: ${
|
|
5072
|
+
message: merge.conflict ? `research branch ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then resolve the waypoint` : `could not land ${tempBranch} on ${feature.branch}: ${errorHeadline3(detail)} \u2014 the branch is preserved for manual recovery`,
|
|
3968
5073
|
data: { tempBranch, featureBranch: feature.branch, conflict: merge.conflict ?? false, error: detail }
|
|
3969
5074
|
});
|
|
3970
5075
|
return {
|
|
@@ -3986,9 +5091,9 @@ function resolveResearchDeps(ctx) {
|
|
|
3986
5091
|
}
|
|
3987
5092
|
function readTokenFromEnvFile2(path) {
|
|
3988
5093
|
let fromFile;
|
|
3989
|
-
if (
|
|
5094
|
+
if (existsSync12(path)) {
|
|
3990
5095
|
try {
|
|
3991
|
-
fromFile = parseEnvFile(
|
|
5096
|
+
fromFile = parseEnvFile(readFileSync6(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
|
|
3992
5097
|
} catch {
|
|
3993
5098
|
fromFile = undefined;
|
|
3994
5099
|
}
|
|
@@ -3996,7 +5101,7 @@ function readTokenFromEnvFile2(path) {
|
|
|
3996
5101
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
3997
5102
|
return token && token.length > 0 ? token : undefined;
|
|
3998
5103
|
}
|
|
3999
|
-
var
|
|
5104
|
+
var AUTH_MISSING_EVENT3 = "auth.missing", AUTH_MISSING_MESSAGE3 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS2, research;
|
|
4000
5105
|
var init_research = __esm(() => {
|
|
4001
5106
|
init_src();
|
|
4002
5107
|
init_config_load();
|
|
@@ -4004,7 +5109,7 @@ var init_research = __esm(() => {
|
|
|
4004
5109
|
init_skills_root();
|
|
4005
5110
|
init_git();
|
|
4006
5111
|
init_ticket_burner();
|
|
4007
|
-
|
|
5112
|
+
PLACEHOLDERS2 = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
|
|
4008
5113
|
research = {
|
|
4009
5114
|
id: "research",
|
|
4010
5115
|
run: (ctx) => researchRun(ctx, resolveResearchDeps(ctx))
|
|
@@ -4026,12 +5131,12 @@ var init_registry2 = __esm(() => {
|
|
|
4026
5131
|
});
|
|
4027
5132
|
|
|
4028
5133
|
// src/workflows/runner.ts
|
|
4029
|
-
import { eq as
|
|
5134
|
+
import { eq as eq8 } from "drizzle-orm";
|
|
4030
5135
|
function workflowClaimsFeatureBranch(workflowId) {
|
|
4031
5136
|
return BRANCH_CLAIMING.has(workflowId);
|
|
4032
5137
|
}
|
|
4033
5138
|
function isRunActive(runId) {
|
|
4034
|
-
return
|
|
5139
|
+
return controllers2.has(runId);
|
|
4035
5140
|
}
|
|
4036
5141
|
async function startRun(ctx, featureId, workflowId, opts = {}) {
|
|
4037
5142
|
const feature = getFeatureRow(ctx, featureId);
|
|
@@ -4055,7 +5160,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
|
|
|
4055
5160
|
claim(ctx, opts.claimWaypointId, runId);
|
|
4056
5161
|
} catch (e) {
|
|
4057
5162
|
const summary = e instanceof Error ? e.message : "claim failed";
|
|
4058
|
-
ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(
|
|
5163
|
+
ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
|
|
4059
5164
|
throw e;
|
|
4060
5165
|
}
|
|
4061
5166
|
}
|
|
@@ -4066,7 +5171,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
|
|
|
4066
5171
|
data: { workflow: workflowId }
|
|
4067
5172
|
});
|
|
4068
5173
|
const controller = new AbortController;
|
|
4069
|
-
|
|
5174
|
+
controllers2.set(runId, controller);
|
|
4070
5175
|
const talkWorktree = worktreeDir(project.id, feature.slug);
|
|
4071
5176
|
const talkDetached = workflowClaimsFeatureBranch(workflowId) ? await detachWorktree(talkWorktree) : false;
|
|
4072
5177
|
const wctx = {
|
|
@@ -4092,16 +5197,16 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
|
|
|
4092
5197
|
},
|
|
4093
5198
|
signal: controller.signal
|
|
4094
5199
|
};
|
|
4095
|
-
const done = executeRun(ctx, runId, featureId, def.run(wctx), controller, async () => {
|
|
5200
|
+
const done = executeRun(ctx, runId, featureId, workflowId, def.run(wctx), controller, async () => {
|
|
4096
5201
|
if (talkDetached)
|
|
4097
5202
|
await reattachWorktree(talkWorktree, feature.branch);
|
|
4098
5203
|
});
|
|
4099
5204
|
return { runId, done };
|
|
4100
5205
|
}
|
|
4101
5206
|
function cancelRun(runId) {
|
|
4102
|
-
|
|
5207
|
+
controllers2.get(runId)?.abort();
|
|
4103
5208
|
}
|
|
4104
|
-
async function executeRun(ctx, runId, featureId, runPromise, controller, cleanup) {
|
|
5209
|
+
async function executeRun(ctx, runId, featureId, workflow2, runPromise, controller, cleanup) {
|
|
4105
5210
|
let status = "failed";
|
|
4106
5211
|
let summary = "run failed";
|
|
4107
5212
|
try {
|
|
@@ -4118,10 +5223,13 @@ async function executeRun(ctx, runId, featureId, runPromise, controller, cleanup
|
|
|
4118
5223
|
}
|
|
4119
5224
|
emit(ctx, featureId, { type: "run.error", message: summary, runId });
|
|
4120
5225
|
} finally {
|
|
4121
|
-
|
|
5226
|
+
controllers2.delete(runId);
|
|
4122
5227
|
}
|
|
4123
|
-
ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(
|
|
5228
|
+
ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
|
|
4124
5229
|
releaseForSession(ctx, runId);
|
|
5230
|
+
if (workflowClaimsFeatureBranch(workflow2)) {
|
|
5231
|
+
sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
|
|
5232
|
+
}
|
|
4125
5233
|
emit(ctx, featureId, {
|
|
4126
5234
|
type: "run.finished",
|
|
4127
5235
|
message: `run ${status}: ${summary}`,
|
|
@@ -4147,7 +5255,7 @@ function maybeAutoAdvance(ctx, featureId) {
|
|
|
4147
5255
|
if (next)
|
|
4148
5256
|
setPhase(ctx, featureId, next, "phase.advanced", "auto-advanced to review after successful run");
|
|
4149
5257
|
}
|
|
4150
|
-
var
|
|
5258
|
+
var controllers2, BRANCH_CLAIMING;
|
|
4151
5259
|
var init_runner = __esm(() => {
|
|
4152
5260
|
init_src();
|
|
4153
5261
|
init_paths();
|
|
@@ -4160,12 +5268,12 @@ var init_runner = __esm(() => {
|
|
|
4160
5268
|
init_tickets();
|
|
4161
5269
|
init_waypoints();
|
|
4162
5270
|
init_registry2();
|
|
4163
|
-
|
|
5271
|
+
controllers2 = new Map;
|
|
4164
5272
|
BRANCH_CLAIMING = new Set(["ticket-burner"]);
|
|
4165
5273
|
});
|
|
4166
5274
|
|
|
4167
5275
|
// src/services/features.ts
|
|
4168
|
-
import { desc as
|
|
5276
|
+
import { desc as desc4, eq as eq9 } from "drizzle-orm";
|
|
4169
5277
|
import { rmSync as rmSync2 } from "fs";
|
|
4170
5278
|
async function createFeature(ctx, input) {
|
|
4171
5279
|
const project = requireProjectById(ctx, input.projectId);
|
|
@@ -4228,7 +5336,7 @@ function getFeatureFull(ctx, id) {
|
|
|
4228
5336
|
};
|
|
4229
5337
|
}
|
|
4230
5338
|
function list(ctx, projectId) {
|
|
4231
|
-
const rows = ctx.db.select().from(features).where(
|
|
5339
|
+
const rows = ctx.db.select().from(features).where(eq9(features.projectId, projectId)).orderBy(desc4(features.createdAt)).all();
|
|
4232
5340
|
return rows.map((row) => {
|
|
4233
5341
|
const feature = rowToFeature(row);
|
|
4234
5342
|
const tickets2 = listByFeature2(ctx, feature.id);
|
|
@@ -4263,7 +5371,7 @@ function advance(ctx, featureId) {
|
|
|
4263
5371
|
async function burn(ctx, featureId, opts = {}) {
|
|
4264
5372
|
const feature = getFeatureRow(ctx, featureId);
|
|
4265
5373
|
const running = hasActiveRun(ctx, featureId);
|
|
4266
|
-
|
|
5374
|
+
let tickets2 = listByFeature2(ctx, featureId);
|
|
4267
5375
|
const pending = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
|
|
4268
5376
|
const restarting = feature.phase === "implementation" && !running;
|
|
4269
5377
|
const iterating = feature.phase === "review" && !running && pending.length >= 1;
|
|
@@ -4281,6 +5389,8 @@ async function burn(ctx, featureId, opts = {}) {
|
|
|
4281
5389
|
throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
|
|
4282
5390
|
}
|
|
4283
5391
|
if (restarting) {
|
|
5392
|
+
sweepOrphanedBurning(ctx, featureId, "orphaned \u2014 the previous run died while it was burning");
|
|
5393
|
+
tickets2 = listByFeature2(ctx, featureId);
|
|
4284
5394
|
const failed = opts.resetFailed === false ? [] : tickets2.filter((t) => t.status === "failed");
|
|
4285
5395
|
for (const t of failed) {
|
|
4286
5396
|
updateTicket(ctx, t.id, { status: "pending", error: null });
|
|
@@ -4371,7 +5481,7 @@ function escalateToMap(ctx, featureId, input) {
|
|
|
4371
5481
|
return { ok: true, warning: `feature ${feature.slug} is already mapped \u2014 no changes made` };
|
|
4372
5482
|
}
|
|
4373
5483
|
const project = projectForFeature(ctx, feature);
|
|
4374
|
-
ctx.db.update(features).set({ mapped: true }).where(
|
|
5484
|
+
ctx.db.update(features).set({ mapped: true }).where(eq9(features.id, featureId)).run();
|
|
4375
5485
|
scaffoldMapDoc(project, { ...feature, mapped: true }, input);
|
|
4376
5486
|
emit(ctx, featureId, {
|
|
4377
5487
|
type: "feature.escalated",
|
|
@@ -4388,7 +5498,7 @@ function archiveFeature(ctx, featureId) {
|
|
|
4388
5498
|
const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
|
|
4389
5499
|
if (live)
|
|
4390
5500
|
endSession(ctx, live.id);
|
|
4391
|
-
ctx.db.update(features).set({ status: "archived" }).where(
|
|
5501
|
+
ctx.db.update(features).set({ status: "archived" }).where(eq9(features.id, featureId)).run();
|
|
4392
5502
|
emit(ctx, featureId, {
|
|
4393
5503
|
type: "feature.archived",
|
|
4394
5504
|
message: `feature ${feature.slug} archived`,
|
|
@@ -4402,7 +5512,7 @@ function unarchiveFeature(ctx, featureId) {
|
|
|
4402
5512
|
throw new GateError(`feature ${feature.slug} is not archived`);
|
|
4403
5513
|
}
|
|
4404
5514
|
const restored = feature.phase === "shipped" ? "shipped" : "active";
|
|
4405
|
-
ctx.db.update(features).set({ status: restored }).where(
|
|
5515
|
+
ctx.db.update(features).set({ status: restored }).where(eq9(features.id, featureId)).run();
|
|
4406
5516
|
emit(ctx, featureId, {
|
|
4407
5517
|
type: "feature.unarchived",
|
|
4408
5518
|
message: `feature ${feature.slug} unarchived (${restored})`,
|
|
@@ -4416,9 +5526,9 @@ async function deleteFeature(ctx, featureId) {
|
|
|
4416
5526
|
throw new GateError(`feature ${feature.slug} is shipped \u2014 archive it instead (delete is for non-shipped features)`);
|
|
4417
5527
|
}
|
|
4418
5528
|
const project = projectForFeature(ctx, feature);
|
|
4419
|
-
for (const
|
|
4420
|
-
if (
|
|
4421
|
-
cancelRun(
|
|
5529
|
+
for (const run4 of listRunsByFeature(ctx, featureId)) {
|
|
5530
|
+
if (run4.status === "running")
|
|
5531
|
+
cancelRun(run4.id);
|
|
4422
5532
|
}
|
|
4423
5533
|
const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
|
|
4424
5534
|
if (live)
|
|
@@ -4442,13 +5552,13 @@ async function deleteFeature(ctx, featureId) {
|
|
|
4442
5552
|
return { ok: true, slug: feature.slug };
|
|
4443
5553
|
}
|
|
4444
5554
|
function deleteFeatureRows(ctx, featureId) {
|
|
4445
|
-
ctx.db.delete(tickets).where(
|
|
4446
|
-
ctx.db.delete(sessions).where(
|
|
4447
|
-
ctx.db.delete(runs).where(
|
|
4448
|
-
ctx.db.delete(events).where(
|
|
4449
|
-
ctx.db.delete(gateOverrides).where(
|
|
4450
|
-
ctx.db.delete(waypoints).where(
|
|
4451
|
-
ctx.db.delete(features).where(
|
|
5555
|
+
ctx.db.delete(tickets).where(eq9(tickets.featureId, featureId)).run();
|
|
5556
|
+
ctx.db.delete(sessions).where(eq9(sessions.featureId, featureId)).run();
|
|
5557
|
+
ctx.db.delete(runs).where(eq9(runs.featureId, featureId)).run();
|
|
5558
|
+
ctx.db.delete(events).where(eq9(events.featureId, featureId)).run();
|
|
5559
|
+
ctx.db.delete(gateOverrides).where(eq9(gateOverrides.featureId, featureId)).run();
|
|
5560
|
+
ctx.db.delete(waypoints).where(eq9(waypoints.featureId, featureId)).run();
|
|
5561
|
+
ctx.db.delete(features).where(eq9(features.id, featureId)).run();
|
|
4452
5562
|
}
|
|
4453
5563
|
function gateState(ctx, feature) {
|
|
4454
5564
|
const gate = nextGate(feature);
|
|
@@ -4459,7 +5569,7 @@ function gateState(ctx, feature) {
|
|
|
4459
5569
|
}
|
|
4460
5570
|
function uniqueSlug(ctx, projectId, title) {
|
|
4461
5571
|
const base = slugify(title);
|
|
4462
|
-
const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(
|
|
5572
|
+
const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(eq9(features.projectId, projectId)).all().map((r) => r.slug));
|
|
4463
5573
|
if (!existing.has(base))
|
|
4464
5574
|
return base;
|
|
4465
5575
|
let n = 2;
|
|
@@ -4491,7 +5601,7 @@ var init_features = __esm(() => {
|
|
|
4491
5601
|
import { StreamableHTTPTransport } from "@hono/mcp";
|
|
4492
5602
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4493
5603
|
import { Hono } from "hono";
|
|
4494
|
-
import * as
|
|
5604
|
+
import * as z4 from "zod";
|
|
4495
5605
|
function headerSessionId(extra) {
|
|
4496
5606
|
const headers = extra.requestInfo?.headers;
|
|
4497
5607
|
if (!headers)
|
|
@@ -4645,7 +5755,7 @@ function buildMcpServer() {
|
|
|
4645
5755
|
server.registerTool("emit_tickets", {
|
|
4646
5756
|
title: "Emit tickets",
|
|
4647
5757
|
description: "Store the ideation session's ticket batch. Each ticket: title, goal, context, acceptanceCriteria[], seams[], blockedBy[] (1-based positions within THIS batch).",
|
|
4648
|
-
inputSchema: { tickets:
|
|
5758
|
+
inputSchema: { tickets: z4.array(TicketInput) }
|
|
4649
5759
|
}, async (args, extra) => {
|
|
4650
5760
|
const rs = await resolveCtxSession(extra);
|
|
4651
5761
|
if (!rs)
|
|
@@ -4658,12 +5768,12 @@ function buildMcpServer() {
|
|
|
4658
5768
|
title: "Update ticket",
|
|
4659
5769
|
description: "Rewrite a stored ticket's content \u2014 title, goal, context, acceptanceCriteria, seams (any subset). Only pending or failed tickets can be edited; done/cancelled tickets are history and burning tickets are already running. Use during a revisit when a decision change makes a ticket stale. Get ids from get_feature_context.",
|
|
4660
5770
|
inputSchema: {
|
|
4661
|
-
id:
|
|
4662
|
-
title:
|
|
4663
|
-
goal:
|
|
4664
|
-
context:
|
|
4665
|
-
acceptanceCriteria:
|
|
4666
|
-
seams:
|
|
5771
|
+
id: z4.string(),
|
|
5772
|
+
title: z4.string().optional(),
|
|
5773
|
+
goal: z4.string().optional(),
|
|
5774
|
+
context: z4.string().optional(),
|
|
5775
|
+
acceptanceCriteria: z4.array(z4.string()).optional(),
|
|
5776
|
+
seams: z4.array(z4.string()).optional()
|
|
4667
5777
|
}
|
|
4668
5778
|
}, async (args, extra) => {
|
|
4669
5779
|
const rs = await resolveCtxSession(extra);
|
|
@@ -4676,7 +5786,7 @@ function buildMcpServer() {
|
|
|
4676
5786
|
server.registerTool("cancel_ticket", {
|
|
4677
5787
|
title: "Cancel ticket",
|
|
4678
5788
|
description: "Cancel a ticket that is no longer needed (terminal state; only pending or failed tickets). The burner skips cancelled tickets, and tickets blocked by a cancelled one still burn \u2014 cancellation counts as satisfied. Pass a short reason so the timeline explains why.",
|
|
4679
|
-
inputSchema: { id:
|
|
5789
|
+
inputSchema: { id: z4.string(), reason: z4.string().optional() }
|
|
4680
5790
|
}, async (args, extra) => {
|
|
4681
5791
|
const rs = await resolveCtxSession(extra);
|
|
4682
5792
|
if (!rs)
|
|
@@ -4688,7 +5798,7 @@ function buildMcpServer() {
|
|
|
4688
5798
|
server.registerTool("escalate_to_map", {
|
|
4689
5799
|
title: "Escalate to map",
|
|
4690
5800
|
description: "Escalate this grilling session into a map when the feature outgrows one context window. Flips the feature to mapped and scaffolds docs/features/<slug>/map.md, seeding Destination + Notes from your arguments (Not-yet-specified and Out-of-scope start empty). Idempotent: calling it on an already-mapped feature warns and changes nothing.",
|
|
4691
|
-
inputSchema: { destination:
|
|
5801
|
+
inputSchema: { destination: z4.string(), notes: z4.string().optional() }
|
|
4692
5802
|
}, async (args, extra) => {
|
|
4693
5803
|
const rs = await resolveCtxSession(extra);
|
|
4694
5804
|
if (!rs)
|
|
@@ -4702,7 +5812,7 @@ function buildMcpServer() {
|
|
|
4702
5812
|
server.registerTool("emit_waypoints", {
|
|
4703
5813
|
title: "Emit waypoints",
|
|
4704
5814
|
description: "Batch-create waypoints on the map (the feature must already be mapped \u2014 call escalate_to_map first). Each waypoint: title, type (grilling|research|prototype|task), question, blockedBy[] (1-based positions within THIS batch, and/or ids of already-stored waypoints). Available from any session once mapped \u2014 any session may branch the map.",
|
|
4705
|
-
inputSchema: { waypoints:
|
|
5815
|
+
inputSchema: { waypoints: z4.array(WaypointInput) }
|
|
4706
5816
|
}, async (args, extra) => {
|
|
4707
5817
|
const rs = await resolveCtxSession(extra);
|
|
4708
5818
|
if (!rs)
|
|
@@ -4712,7 +5822,7 @@ function buildMcpServer() {
|
|
|
4712
5822
|
server.registerTool("resolve_waypoint", {
|
|
4713
5823
|
title: "Resolve waypoint",
|
|
4714
5824
|
description: "End the current waypoint: `resolved` (its question is answered) or `dropped` (no longer needed). Write the decision prose to decisions.md (or the gist to map.md Out-of-scope for a drop) FIRST \u2014 this tool flips machinery only (marks the waypoint terminal, frees any dependents on the frontier). `summary` is the one-line gist shown in the UI. Call this exactly once, as the last thing you do.",
|
|
4715
|
-
inputSchema: { id:
|
|
5825
|
+
inputSchema: { id: z4.string(), disposition: WaypointDisposition, summary: z4.string() }
|
|
4716
5826
|
}, async (args, extra) => {
|
|
4717
5827
|
const rs = await resolveCtxSession(extra);
|
|
4718
5828
|
if (!rs)
|
|
@@ -4724,7 +5834,7 @@ function buildMcpServer() {
|
|
|
4724
5834
|
server.registerTool("record_event", {
|
|
4725
5835
|
title: "Record event",
|
|
4726
5836
|
description: "Add a note to the feature timeline (decisions recorded, spec saved, milestones).",
|
|
4727
|
-
inputSchema: { type:
|
|
5837
|
+
inputSchema: { type: z4.string(), message: z4.string() }
|
|
4728
5838
|
}, async (args, extra) => {
|
|
4729
5839
|
const rs = await resolveCtxSession(extra);
|
|
4730
5840
|
if (!rs)
|
|
@@ -4847,12 +5957,16 @@ import { Hono as Hono2 } from "hono";
|
|
|
4847
5957
|
function handleSessionStart(ctx, sessionId, feature, payload) {
|
|
4848
5958
|
const ccSessionId = typeof payload?.session_id === "string" ? payload.session_id : undefined;
|
|
4849
5959
|
const transcriptPath = typeof payload?.transcript_path === "string" ? payload.transcript_path : undefined;
|
|
5960
|
+
const source = typeof payload?.source === "string" ? payload.source : undefined;
|
|
5961
|
+
const wasLive = getSessionRow(ctx, sessionId)?.status === "live";
|
|
4850
5962
|
markSessionLive(ctx, sessionId, { ccSessionId, transcriptPath });
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
5963
|
+
if (!wasLive) {
|
|
5964
|
+
emit(ctx, feature.id, {
|
|
5965
|
+
type: "session.started",
|
|
5966
|
+
message: source === "resume" ? "session live (conversation resumed)" : "session live",
|
|
5967
|
+
data: { sessionId, ccSessionId, transcriptPath, source: source ?? null }
|
|
5968
|
+
});
|
|
5969
|
+
}
|
|
4856
5970
|
return {
|
|
4857
5971
|
hookSpecificOutput: {
|
|
4858
5972
|
hookEventName: "SessionStart",
|
|
@@ -4860,7 +5974,9 @@ function handleSessionStart(ctx, sessionId, feature, payload) {
|
|
|
4860
5974
|
}
|
|
4861
5975
|
};
|
|
4862
5976
|
}
|
|
4863
|
-
function handleUserPrompt(ctx, feature) {
|
|
5977
|
+
function handleUserPrompt(ctx, sessionId, feature, payload) {
|
|
5978
|
+
const prompt = typeof payload?.prompt === "string" ? payload.prompt : undefined;
|
|
5979
|
+
noteKickoffPrompt(ctx, sessionId, prompt);
|
|
4864
5980
|
const tickets2 = listByFeature2(ctx, feature.id).length;
|
|
4865
5981
|
return {
|
|
4866
5982
|
hookSpecificOutput: {
|
|
@@ -4927,7 +6043,7 @@ var init_hooks = __esm(() => {
|
|
|
4927
6043
|
case "session-start":
|
|
4928
6044
|
return c.json(handleSessionStart(ctx, sessionId, feature, body.payload));
|
|
4929
6045
|
case "user-prompt":
|
|
4930
|
-
return c.json(handleUserPrompt(ctx, feature));
|
|
6046
|
+
return c.json(handleUserPrompt(ctx, sessionId, feature, body.payload));
|
|
4931
6047
|
case "session-end":
|
|
4932
6048
|
return c.json(handleSessionEnd(ctx, sessionId, feature));
|
|
4933
6049
|
default:
|
|
@@ -4992,9 +6108,9 @@ var init_stream = __esm(() => {
|
|
|
4992
6108
|
});
|
|
4993
6109
|
|
|
4994
6110
|
// src/routes/web.ts
|
|
4995
|
-
import { existsSync as
|
|
6111
|
+
import { existsSync as existsSync13, statSync } from "fs";
|
|
4996
6112
|
import { readFile } from "fs/promises";
|
|
4997
|
-
import { extname, join as
|
|
6113
|
+
import { extname, join as join12, normalize, resolve as resolve6, sep as sep2 } from "path";
|
|
4998
6114
|
function contentType(path) {
|
|
4999
6115
|
return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
5000
6116
|
}
|
|
@@ -5012,7 +6128,7 @@ function safeJoin(root, pathname) {
|
|
|
5012
6128
|
} catch {
|
|
5013
6129
|
return null;
|
|
5014
6130
|
}
|
|
5015
|
-
const joined = normalize(
|
|
6131
|
+
const joined = normalize(join12(root, decoded));
|
|
5016
6132
|
if (joined !== root && !joined.startsWith(root + sep2))
|
|
5017
6133
|
return null;
|
|
5018
6134
|
return joined;
|
|
@@ -5025,7 +6141,7 @@ function resolveWebDist() {
|
|
|
5025
6141
|
}
|
|
5026
6142
|
function mountWebApp(app, distDir) {
|
|
5027
6143
|
const root = resolve6(distDir);
|
|
5028
|
-
const indexHtml =
|
|
6144
|
+
const indexHtml = join12(root, "index.html");
|
|
5029
6145
|
app.get("*", async (c, next) => {
|
|
5030
6146
|
const { pathname } = new URL(c.req.url);
|
|
5031
6147
|
if (/^\/(api|mcp|ws|health)(\/|$)/.test(pathname))
|
|
@@ -5045,7 +6161,7 @@ function mountWebApp(app, distDir) {
|
|
|
5045
6161
|
}
|
|
5046
6162
|
function mountWebAppIfBuilt(app) {
|
|
5047
6163
|
const dist = resolveWebDist();
|
|
5048
|
-
if (
|
|
6164
|
+
if (existsSync13(join12(dist, "index.html")))
|
|
5049
6165
|
mountWebApp(app, dist);
|
|
5050
6166
|
}
|
|
5051
6167
|
var MIME;
|
|
@@ -5092,14 +6208,14 @@ var init_context = __esm(() => {
|
|
|
5092
6208
|
});
|
|
5093
6209
|
|
|
5094
6210
|
// src/trpc/routers/docs.ts
|
|
5095
|
-
import * as
|
|
6211
|
+
import * as z5 from "zod";
|
|
5096
6212
|
var docsRouter;
|
|
5097
6213
|
var init_docs = __esm(() => {
|
|
5098
6214
|
init_knowledge();
|
|
5099
6215
|
init_repo();
|
|
5100
6216
|
init_context();
|
|
5101
6217
|
docsRouter = router({
|
|
5102
|
-
read: publicProcedure.input(
|
|
6218
|
+
read: publicProcedure.input(z5.object({ featureId: z5.string(), relPath: z5.string() })).query(({ ctx, input }) => {
|
|
5103
6219
|
const feature = getFeatureRow(ctx, input.featureId);
|
|
5104
6220
|
return readDoc(ctx, feature, input.relPath);
|
|
5105
6221
|
})
|
|
@@ -5107,23 +6223,23 @@ var init_docs = __esm(() => {
|
|
|
5107
6223
|
});
|
|
5108
6224
|
|
|
5109
6225
|
// src/trpc/routers/events.ts
|
|
5110
|
-
import * as
|
|
6226
|
+
import * as z6 from "zod";
|
|
5111
6227
|
var eventsRouter;
|
|
5112
6228
|
var init_events2 = __esm(() => {
|
|
5113
6229
|
init_events();
|
|
5114
6230
|
init_context();
|
|
5115
6231
|
eventsRouter = router({
|
|
5116
|
-
list: publicProcedure.input(
|
|
5117
|
-
listByProject: publicProcedure.input(
|
|
6232
|
+
list: publicProcedure.input(z6.object({ featureId: z6.string(), afterId: z6.number().optional() })).query(({ ctx, input }) => listAfter(ctx, input.featureId, input.afterId)),
|
|
6233
|
+
listByProject: publicProcedure.input(z6.object({ projectId: z6.string(), afterId: z6.number().optional() })).query(({ ctx, input }) => listByProject(ctx, input.projectId, input.afterId))
|
|
5118
6234
|
});
|
|
5119
6235
|
});
|
|
5120
6236
|
|
|
5121
6237
|
// src/launcher/artifacts.ts
|
|
5122
|
-
import { mkdirSync as
|
|
5123
|
-
import { fileURLToPath as
|
|
5124
|
-
import { join as
|
|
6238
|
+
import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync2 } from "fs";
|
|
6239
|
+
import { fileURLToPath as fileURLToPath5 } from "url";
|
|
6240
|
+
import { join as join13 } from "path";
|
|
5125
6241
|
function hookClientPath() {
|
|
5126
|
-
return resolveAsset(ASSET_ENV.hookClient,
|
|
6242
|
+
return resolveAsset(ASSET_ENV.hookClient, fileURLToPath5(new URL("./hook-client.ts", import.meta.url)));
|
|
5127
6243
|
}
|
|
5128
6244
|
function serverUrlFor(config2) {
|
|
5129
6245
|
return `http://localhost:${config2.serverPort}`;
|
|
@@ -5324,7 +6440,10 @@ function renderSettings(hookClient) {
|
|
|
5324
6440
|
return {
|
|
5325
6441
|
permissions: { allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...SESSION_BASH_ALLOW_RULES] },
|
|
5326
6442
|
hooks: {
|
|
5327
|
-
SessionStart:
|
|
6443
|
+
SessionStart: SESSION_START_SOURCES.map((source) => ({
|
|
6444
|
+
matcher: source,
|
|
6445
|
+
hooks: [cmd("session-start")]
|
|
6446
|
+
})),
|
|
5328
6447
|
UserPromptSubmit: [{ hooks: [cmd("user-prompt")] }],
|
|
5329
6448
|
SessionEnd: [{ hooks: [cmd("session-end")] }]
|
|
5330
6449
|
}
|
|
@@ -5344,16 +6463,16 @@ function renderMcpConfig(session, config2) {
|
|
|
5344
6463
|
async function writeSessionArtifacts(input) {
|
|
5345
6464
|
const { session, feature, config: config2, waypoint } = input;
|
|
5346
6465
|
const dir = sessionDir(session.id);
|
|
5347
|
-
|
|
5348
|
-
const systemPromptPath =
|
|
5349
|
-
const settingsPath =
|
|
5350
|
-
const mcpConfigPath =
|
|
6466
|
+
mkdirSync7(dir, { recursive: true });
|
|
6467
|
+
const systemPromptPath = join13(dir, "system-prompt.md");
|
|
6468
|
+
const settingsPath = join13(dir, "settings.json");
|
|
6469
|
+
const mcpConfigPath = join13(dir, "mcp.json");
|
|
5351
6470
|
writeFileSync2(systemPromptPath, renderSystemPrompt(feature, session.kind, waypoint), "utf8");
|
|
5352
6471
|
writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath()), null, 2), "utf8");
|
|
5353
6472
|
writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
|
|
5354
6473
|
return { systemPromptPath, settingsPath, mcpConfigPath };
|
|
5355
6474
|
}
|
|
5356
|
-
var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_ALLOW_RULES;
|
|
6475
|
+
var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_ALLOW_RULES, SESSION_START_SOURCES;
|
|
5357
6476
|
var init_artifacts = __esm(() => {
|
|
5358
6477
|
init_paths();
|
|
5359
6478
|
init_asset_paths();
|
|
@@ -5378,13 +6497,14 @@ var init_artifacts = __esm(() => {
|
|
|
5378
6497
|
"Bash(git branch:*)",
|
|
5379
6498
|
"Bash(git show:*)"
|
|
5380
6499
|
];
|
|
6500
|
+
SESSION_START_SOURCES = ["startup", "resume", "clear", "compact", "fork"];
|
|
5381
6501
|
});
|
|
5382
6502
|
|
|
5383
6503
|
// src/launcher/launcher.ts
|
|
5384
|
-
import { existsSync as
|
|
5385
|
-
import { dirname as
|
|
5386
|
-
import { fileURLToPath as
|
|
5387
|
-
import { and as
|
|
6504
|
+
import { existsSync as existsSync14 } from "fs";
|
|
6505
|
+
import { dirname as dirname6, join as join14, resolve as resolve7 } from "path";
|
|
6506
|
+
import { fileURLToPath as fileURLToPath6 } from "url";
|
|
6507
|
+
import { and as and7, eq as eq10 } from "drizzle-orm";
|
|
5388
6508
|
function ptyExitMessage(exitCode) {
|
|
5389
6509
|
const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
|
|
5390
6510
|
return `terminal exited (code ${label})`;
|
|
@@ -5410,32 +6530,28 @@ function buildClaudeArgs(input) {
|
|
|
5410
6530
|
];
|
|
5411
6531
|
}
|
|
5412
6532
|
function resolveClaudeExecutable() {
|
|
5413
|
-
return
|
|
6533
|
+
return resolveTool("claude");
|
|
5414
6534
|
}
|
|
5415
6535
|
function claudeSpawnTarget(claudeArgs) {
|
|
5416
|
-
|
|
5417
|
-
if (/\.(cmd|bat)$/i.test(exe)) {
|
|
5418
|
-
return { file: process.env.ComSpec ?? "cmd.exe", args: ["/c", exe, ...claudeArgs] };
|
|
5419
|
-
}
|
|
5420
|
-
return { file: exe, args: claudeArgs };
|
|
6536
|
+
return spawnTargetFor(resolveClaudeExecutable(), claudeArgs);
|
|
5421
6537
|
}
|
|
5422
|
-
function resolvePluginDir(fromDir =
|
|
5423
|
-
const rel =
|
|
6538
|
+
function resolvePluginDir(fromDir = dirname6(fileURLToPath6(import.meta.url))) {
|
|
6539
|
+
const rel = join14("packages", "skills", "packs", "runcastle");
|
|
5424
6540
|
const override = process.env[SKILLS_DIR_ENV];
|
|
5425
6541
|
if (override) {
|
|
5426
|
-
const dir2 =
|
|
5427
|
-
if (
|
|
6542
|
+
const dir2 = join14(resolve7(override), "packs", "runcastle");
|
|
6543
|
+
if (existsSync14(dir2))
|
|
5428
6544
|
return dir2;
|
|
5429
6545
|
throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
|
|
5430
6546
|
}
|
|
5431
6547
|
const searched = [];
|
|
5432
6548
|
let dir = fromDir;
|
|
5433
6549
|
for (let i = 0;i < 8; i += 1) {
|
|
5434
|
-
const candidate =
|
|
6550
|
+
const candidate = join14(dir, rel);
|
|
5435
6551
|
searched.push(candidate);
|
|
5436
|
-
if (
|
|
6552
|
+
if (existsSync14(candidate))
|
|
5437
6553
|
return candidate;
|
|
5438
|
-
const parent =
|
|
6554
|
+
const parent = dirname6(dir);
|
|
5439
6555
|
if (parent === dir)
|
|
5440
6556
|
break;
|
|
5441
6557
|
dir = parent;
|
|
@@ -5445,7 +6561,7 @@ function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
|
|
|
5445
6561
|
`)}`);
|
|
5446
6562
|
}
|
|
5447
6563
|
function activeRunFor(ctx, featureId) {
|
|
5448
|
-
const row = ctx.db.select().from(runs).where(
|
|
6564
|
+
const row = ctx.db.select().from(runs).where(and7(eq10(runs.featureId, featureId), eq10(runs.status, "running"))).limit(1).get();
|
|
5449
6565
|
return row ? rowToRun(row) : null;
|
|
5450
6566
|
}
|
|
5451
6567
|
function assertSpawnable(ctx, feature, excludeSessionId) {
|
|
@@ -5696,6 +6812,7 @@ function spawnEmbeddedPty(ctx, feature, session, worktreePath, serverUrl, claude
|
|
|
5696
6812
|
message: "embedded terminal spawned",
|
|
5697
6813
|
data: { sessionId: session.id, mode: "embedded", pid: entry.pty.pid }
|
|
5698
6814
|
});
|
|
6815
|
+
armSessionReadyWatchdog(ctx, session);
|
|
5699
6816
|
} catch (err) {
|
|
5700
6817
|
markSessionEnded(ctx, session.id);
|
|
5701
6818
|
releaseForSession(ctx, session.id);
|
|
@@ -5724,6 +6841,7 @@ var init_launcher = __esm(() => {
|
|
|
5724
6841
|
init_runner();
|
|
5725
6842
|
init_artifacts();
|
|
5726
6843
|
init_sessions();
|
|
6844
|
+
init_sessions();
|
|
5727
6845
|
init_end_session();
|
|
5728
6846
|
CC_NESTING_ENV = [
|
|
5729
6847
|
"CLAUDE_CODE_CHILD_SESSION",
|
|
@@ -5738,7 +6856,7 @@ var init_launcher = __esm(() => {
|
|
|
5738
6856
|
});
|
|
5739
6857
|
|
|
5740
6858
|
// src/trpc/routers/feature.ts
|
|
5741
|
-
import * as
|
|
6859
|
+
import * as z7 from "zod";
|
|
5742
6860
|
var gateId, featureRouter;
|
|
5743
6861
|
var init_feature = __esm(() => {
|
|
5744
6862
|
init_src();
|
|
@@ -5749,37 +6867,38 @@ var init_feature = __esm(() => {
|
|
|
5749
6867
|
init_git();
|
|
5750
6868
|
init_repo();
|
|
5751
6869
|
init_context();
|
|
5752
|
-
gateId =
|
|
6870
|
+
gateId = z7.enum(["G1", "G2", "G3", "G4", "G5"]);
|
|
5753
6871
|
featureRouter = router({
|
|
5754
|
-
create: publicProcedure.input(
|
|
5755
|
-
projectId:
|
|
5756
|
-
title:
|
|
5757
|
-
oneLiner:
|
|
5758
|
-
baseBranch:
|
|
6872
|
+
create: publicProcedure.input(z7.object({
|
|
6873
|
+
projectId: z7.string(),
|
|
6874
|
+
title: z7.string().min(1),
|
|
6875
|
+
oneLiner: z7.string(),
|
|
6876
|
+
baseBranch: z7.string().optional()
|
|
5759
6877
|
})).mutation(({ ctx, input }) => createFeature(ctx, input)),
|
|
5760
|
-
list: publicProcedure.input(
|
|
5761
|
-
get: publicProcedure.input(
|
|
5762
|
-
launchSession: publicProcedure.input(
|
|
5763
|
-
featureId:
|
|
6878
|
+
list: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => list(ctx, input.projectId)),
|
|
6879
|
+
get: publicProcedure.input(z7.object({ id: z7.string() })).query(({ ctx, input }) => getFeatureFull(ctx, input.id)),
|
|
6880
|
+
launchSession: publicProcedure.input(z7.object({
|
|
6881
|
+
featureId: z7.string(),
|
|
5764
6882
|
kind: SessionKind,
|
|
5765
|
-
kickoffLine:
|
|
6883
|
+
kickoffLine: z7.string().min(1).optional()
|
|
5766
6884
|
})).mutation(({ ctx, input }) => launchSession(ctx, input)),
|
|
5767
|
-
workWaypoint: publicProcedure.input(
|
|
5768
|
-
converge: publicProcedure.input(
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
|
|
6885
|
+
workWaypoint: publicProcedure.input(z7.object({ featureId: z7.string(), waypointId: z7.string() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
|
|
6886
|
+
converge: publicProcedure.input(z7.object({ featureId: z7.string(), overrideReason: z7.string().min(1).optional() })).mutation(({ ctx, input }) => converge(ctx, { featureId: input.featureId, overrideReason: input.overrideReason })),
|
|
6887
|
+
resendKickoff: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => resendKickoff(ctx, input.sessionId)),
|
|
6888
|
+
endSession: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
|
|
6889
|
+
advance: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => advance(ctx, input.featureId)),
|
|
6890
|
+
overrideGate: publicProcedure.input(z7.object({ featureId: z7.string(), gate: gateId, reason: z7.string().min(1) })).mutation(({ ctx, input }) => overrideGate(ctx, input.featureId, input.gate, input.reason)),
|
|
6891
|
+
archive: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => archiveFeature(ctx, input.featureId)),
|
|
6892
|
+
unarchive: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => unarchiveFeature(ctx, input.featureId)),
|
|
6893
|
+
delete: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => deleteFeature(ctx, input.featureId)),
|
|
6894
|
+
burn: publicProcedure.input(z7.object({ featureId: z7.string(), model: z7.string().min(1).optional() })).mutation(({ ctx, input }) => burn(ctx, input.featureId, { modelOverride: input.model })),
|
|
6895
|
+
testDrive: publicProcedure.input(z7.object({ featureId: z7.string(), action: z7.enum(["start", "stop"]) })).mutation(async ({ ctx, input }) => {
|
|
5777
6896
|
const feature = getFeatureRow(ctx, input.featureId);
|
|
5778
6897
|
const project = projectForFeature(ctx, feature);
|
|
5779
6898
|
return testDrive(ctx, project, feature, input.action);
|
|
5780
6899
|
}),
|
|
5781
6900
|
driveInfo: publicProcedure.query(() => activeDriveInfo()),
|
|
5782
|
-
merge: publicProcedure.input(
|
|
6901
|
+
merge: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(async ({ ctx, input }) => {
|
|
5783
6902
|
const feature = getFeatureRow(ctx, input.featureId);
|
|
5784
6903
|
const project = projectForFeature(ctx, feature);
|
|
5785
6904
|
if (activeTestDriveFeatureId() === feature.id) {
|
|
@@ -5802,9 +6921,9 @@ var init_feature = __esm(() => {
|
|
|
5802
6921
|
});
|
|
5803
6922
|
|
|
5804
6923
|
// src/services/fsbrowse.ts
|
|
5805
|
-
import { existsSync as
|
|
6924
|
+
import { existsSync as existsSync15, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
|
|
5806
6925
|
import { homedir as homedir2 } from "os";
|
|
5807
|
-
import { basename, dirname as
|
|
6926
|
+
import { basename, dirname as dirname7, isAbsolute, join as join15, resolve as resolve8, sep as sep3 } from "path";
|
|
5808
6927
|
function expandPath(input, home = homedir2()) {
|
|
5809
6928
|
const trimmed = input.trim();
|
|
5810
6929
|
if (trimmed === "")
|
|
@@ -5813,12 +6932,12 @@ function expandPath(input, home = homedir2()) {
|
|
|
5813
6932
|
if (out === "~")
|
|
5814
6933
|
out = home;
|
|
5815
6934
|
else if (out.startsWith("~/") || out.startsWith("~\\"))
|
|
5816
|
-
out =
|
|
6935
|
+
out = join15(home, out.slice(2));
|
|
5817
6936
|
return resolve8(out);
|
|
5818
6937
|
}
|
|
5819
6938
|
function looksLikeRepo(dir) {
|
|
5820
6939
|
try {
|
|
5821
|
-
return
|
|
6940
|
+
return existsSync15(join15(dir, ".git"));
|
|
5822
6941
|
} catch {
|
|
5823
6942
|
return false;
|
|
5824
6943
|
}
|
|
@@ -5829,20 +6948,20 @@ function listRoots(home = homedir2()) {
|
|
|
5829
6948
|
for (let code = 67;code <= 90; code++) {
|
|
5830
6949
|
const drive = `${String.fromCharCode(code)}:${sep3}`;
|
|
5831
6950
|
try {
|
|
5832
|
-
if (
|
|
6951
|
+
if (existsSync15(drive))
|
|
5833
6952
|
roots.push({ label: drive.replace(/\\$/, ""), path: drive, kind: "drive" });
|
|
5834
6953
|
} catch {}
|
|
5835
6954
|
}
|
|
5836
6955
|
} else {
|
|
5837
6956
|
roots.push({ label: "/", path: "/", kind: "drive" });
|
|
5838
6957
|
}
|
|
5839
|
-
if (
|
|
6958
|
+
if (existsSync15(home))
|
|
5840
6959
|
roots.push({ label: "Home", path: home, kind: "home" });
|
|
5841
6960
|
const seen = new Set(roots.map(pathKey));
|
|
5842
6961
|
for (const name of COMMON_DIRS) {
|
|
5843
|
-
const candidate =
|
|
6962
|
+
const candidate = join15(home, name);
|
|
5844
6963
|
const key = pathKey({ path: candidate });
|
|
5845
|
-
if (seen.has(key) || !
|
|
6964
|
+
if (seen.has(key) || !existsSync15(candidate))
|
|
5846
6965
|
continue;
|
|
5847
6966
|
seen.add(key);
|
|
5848
6967
|
roots.push({ label: name, path: candidate, kind: "common" });
|
|
@@ -5857,7 +6976,7 @@ function crumbsFor(dir) {
|
|
|
5857
6976
|
let cur = dir;
|
|
5858
6977
|
for (;; ) {
|
|
5859
6978
|
out.unshift({ name: basename(cur) || cur, path: cur });
|
|
5860
|
-
const parent =
|
|
6979
|
+
const parent = dirname7(cur);
|
|
5861
6980
|
if (parent === cur)
|
|
5862
6981
|
break;
|
|
5863
6982
|
cur = parent;
|
|
@@ -5865,7 +6984,7 @@ function crumbsFor(dir) {
|
|
|
5865
6984
|
return out;
|
|
5866
6985
|
}
|
|
5867
6986
|
function parentOf(dir) {
|
|
5868
|
-
const parent =
|
|
6987
|
+
const parent = dirname7(dir);
|
|
5869
6988
|
return parent === dir ? null : parent;
|
|
5870
6989
|
}
|
|
5871
6990
|
function browseDir(input, showHidden = false, home = homedir2()) {
|
|
@@ -5873,13 +6992,13 @@ function browseDir(input, showHidden = false, home = homedir2()) {
|
|
|
5873
6992
|
if (!isAbsolute(target)) {
|
|
5874
6993
|
throw new InvalidInputError(`path is not absolute: ${target}`);
|
|
5875
6994
|
}
|
|
5876
|
-
if (!
|
|
6995
|
+
if (!existsSync15(target)) {
|
|
5877
6996
|
throw new InvalidInputError(`path does not exist: ${target}`);
|
|
5878
6997
|
}
|
|
5879
6998
|
let dir = target;
|
|
5880
6999
|
try {
|
|
5881
7000
|
if (!statSync2(target).isDirectory())
|
|
5882
|
-
dir =
|
|
7001
|
+
dir = dirname7(target);
|
|
5883
7002
|
} catch (e) {
|
|
5884
7003
|
throw new InvalidInputError(`cannot read path: ${target} (${errMsg2(e)})`);
|
|
5885
7004
|
}
|
|
@@ -5901,7 +7020,7 @@ function browseDir(input, showHidden = false, home = homedir2()) {
|
|
|
5901
7020
|
continue;
|
|
5902
7021
|
if (!showHidden && name.startsWith("."))
|
|
5903
7022
|
continue;
|
|
5904
|
-
const path =
|
|
7023
|
+
const path = join15(dir, name);
|
|
5905
7024
|
const isSymlink = dirent.isSymbolicLink();
|
|
5906
7025
|
let isDirectory = dirent.isDirectory();
|
|
5907
7026
|
if (isSymlink) {
|
|
@@ -5936,9 +7055,9 @@ var init_fsbrowse = __esm(() => {
|
|
|
5936
7055
|
});
|
|
5937
7056
|
|
|
5938
7057
|
// src/services/projects.ts
|
|
5939
|
-
import { existsSync as
|
|
5940
|
-
import { basename as basename2, join as
|
|
5941
|
-
import { eq as
|
|
7058
|
+
import { existsSync as existsSync16 } from "fs";
|
|
7059
|
+
import { basename as basename2, join as join16 } from "path";
|
|
7060
|
+
import { eq as eq11, isNull } from "drizzle-orm";
|
|
5942
7061
|
function listProjects(ctx) {
|
|
5943
7062
|
return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
|
|
5944
7063
|
}
|
|
@@ -5960,7 +7079,7 @@ async function openProject(ctx, rawPath) {
|
|
|
5960
7079
|
const name = basename2(repoPath) || repoPath;
|
|
5961
7080
|
const existing = getProjectByRepoPath(ctx, repoPath) ?? findProjectByCanonPath(ctx, repoPath);
|
|
5962
7081
|
if (existing) {
|
|
5963
|
-
ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(
|
|
7082
|
+
ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq11(projects.id, existing.id)).run();
|
|
5964
7083
|
const reopened = { ...existing, mainBranch, repoPath };
|
|
5965
7084
|
emitProject(ctx, existing.id, {
|
|
5966
7085
|
type: "project.opened",
|
|
@@ -5984,14 +7103,25 @@ async function openProject(ctx, rawPath) {
|
|
|
5984
7103
|
message: `project ${name} at ${repoPath} (${mainBranch})`
|
|
5985
7104
|
});
|
|
5986
7105
|
warnIfTranslatedMount(ctx, project.id, repoPath);
|
|
7106
|
+
maybeAutoPrepare(ctx, project);
|
|
5987
7107
|
return project;
|
|
5988
7108
|
}
|
|
7109
|
+
function maybeAutoPrepare(ctx, project) {
|
|
7110
|
+
if (!ctx.config.autoPrepare)
|
|
7111
|
+
return;
|
|
7112
|
+
startPrep(ctx, project.id).catch((e) => {
|
|
7113
|
+
emitProject(ctx, project.id, {
|
|
7114
|
+
type: "prep.run.failed",
|
|
7115
|
+
message: `could not start preparation: ${e instanceof Error ? e.message : String(e)}`
|
|
7116
|
+
});
|
|
7117
|
+
});
|
|
7118
|
+
}
|
|
5989
7119
|
function closeProject(ctx, projectId) {
|
|
5990
7120
|
const project = requireProjectById(ctx, projectId);
|
|
5991
7121
|
if (projectHasActiveRun(ctx, projectId)) {
|
|
5992
7122
|
throw new InvalidInputError(`cannot close ${project.name}: a run is in flight \u2014 wait for it to finish or cancel it first`);
|
|
5993
7123
|
}
|
|
5994
|
-
ctx.db.update(projects).set({ closedAt: Date.now() }).where(
|
|
7124
|
+
ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq11(projects.id, projectId)).run();
|
|
5995
7125
|
emitProject(ctx, projectId, {
|
|
5996
7126
|
type: "project.closed",
|
|
5997
7127
|
message: `project ${project.name} closed`
|
|
@@ -6000,7 +7130,7 @@ function closeProject(ctx, projectId) {
|
|
|
6000
7130
|
}
|
|
6001
7131
|
function renameProject(ctx, projectId, name) {
|
|
6002
7132
|
const project = requireProjectById(ctx, projectId);
|
|
6003
|
-
ctx.db.update(projects).set({ name }).where(
|
|
7133
|
+
ctx.db.update(projects).set({ name }).where(eq11(projects.id, projectId)).run();
|
|
6004
7134
|
emitProject(ctx, projectId, {
|
|
6005
7135
|
type: "project.renamed",
|
|
6006
7136
|
message: `project renamed ${project.name} \u2192 ${name}`,
|
|
@@ -6018,7 +7148,7 @@ async function assertRepoTolerant(ctx, repoPath) {
|
|
|
6018
7148
|
} catch (e) {
|
|
6019
7149
|
if (!isNotImplemented(e))
|
|
6020
7150
|
throw e;
|
|
6021
|
-
if (!
|
|
7151
|
+
if (!existsSync16(join16(repoPath, ".git"))) {
|
|
6022
7152
|
throw new InvalidInputError(`not a git repository: ${repoPath}`);
|
|
6023
7153
|
}
|
|
6024
7154
|
}
|
|
@@ -6039,31 +7169,59 @@ var init_projects = __esm(() => {
|
|
|
6039
7169
|
init_events();
|
|
6040
7170
|
init_fsbrowse();
|
|
6041
7171
|
init_git();
|
|
7172
|
+
init_prep();
|
|
6042
7173
|
init_repo();
|
|
6043
7174
|
});
|
|
6044
7175
|
|
|
6045
7176
|
// src/trpc/routers/project.ts
|
|
6046
|
-
import * as
|
|
7177
|
+
import * as z8 from "zod";
|
|
6047
7178
|
var projectRouter;
|
|
6048
7179
|
var init_project = __esm(() => {
|
|
7180
|
+
init_src();
|
|
7181
|
+
init_findings();
|
|
6049
7182
|
init_fsbrowse();
|
|
6050
7183
|
init_git();
|
|
7184
|
+
init_prep();
|
|
6051
7185
|
init_projects();
|
|
6052
7186
|
init_repo();
|
|
6053
7187
|
init_context();
|
|
6054
7188
|
projectRouter = router({
|
|
6055
7189
|
list: publicProcedure.query(({ ctx }) => listProjects(ctx)),
|
|
6056
7190
|
roots: publicProcedure.query(() => listRoots()),
|
|
6057
|
-
browse: publicProcedure.input(
|
|
6058
|
-
branches: publicProcedure.input(
|
|
6059
|
-
open: publicProcedure.input(
|
|
6060
|
-
close: publicProcedure.input(
|
|
6061
|
-
rename: publicProcedure.input(
|
|
7191
|
+
browse: publicProcedure.input(z8.object({ dir: z8.string().optional(), showHidden: z8.boolean().optional() }).optional()).query(({ input }) => browseDir(input?.dir, input?.showHidden ?? false)),
|
|
7192
|
+
branches: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => listBranches(requireProjectById(ctx, input.projectId))),
|
|
7193
|
+
open: publicProcedure.input(z8.object({ repoPath: z8.string().min(1) })).mutation(({ ctx, input }) => openProject(ctx, input.repoPath)),
|
|
7194
|
+
close: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => closeProject(ctx, input.projectId)),
|
|
7195
|
+
rename: publicProcedure.input(z8.object({ projectId: z8.string(), name: z8.string().min(1) })).mutation(({ ctx, input }) => renameProject(ctx, input.projectId, input.name)),
|
|
7196
|
+
prepare: publicProcedure.input(z8.object({
|
|
7197
|
+
projectId: z8.string(),
|
|
7198
|
+
refresh: z8.boolean().optional(),
|
|
7199
|
+
keys: z8.array(PreparedKey).optional()
|
|
7200
|
+
})).mutation(async ({ ctx, input }) => {
|
|
7201
|
+
const { prepId, keys } = await startPrep(ctx, input.projectId, {
|
|
7202
|
+
...input.refresh !== undefined ? { refresh: input.refresh } : {},
|
|
7203
|
+
...input.keys ? { keys: input.keys } : {}
|
|
7204
|
+
});
|
|
7205
|
+
return { prepId, keys };
|
|
7206
|
+
}),
|
|
7207
|
+
cancelPrepare: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ input }) => {
|
|
7208
|
+
cancelPrep(input.projectId);
|
|
7209
|
+
return { ok: true };
|
|
7210
|
+
}),
|
|
7211
|
+
prep: publicProcedure.input(z8.object({ projectId: z8.string() })).query(async ({ ctx, input }) => {
|
|
7212
|
+
const project = requireProjectById(ctx, input.projectId);
|
|
7213
|
+
return {
|
|
7214
|
+
latest: latestPrep(ctx, project.id),
|
|
7215
|
+
running: isPreparing(project.id),
|
|
7216
|
+
pendingKeys: keysToPrepare(ctx, project),
|
|
7217
|
+
findings: await listFindings(ctx, project)
|
|
7218
|
+
};
|
|
7219
|
+
})
|
|
6062
7220
|
});
|
|
6063
7221
|
});
|
|
6064
7222
|
|
|
6065
7223
|
// src/trpc/routers/run.ts
|
|
6066
|
-
import * as
|
|
7224
|
+
import * as z9 from "zod";
|
|
6067
7225
|
var runRouter;
|
|
6068
7226
|
var init_run = __esm(() => {
|
|
6069
7227
|
init_agent_stream();
|
|
@@ -6071,9 +7229,9 @@ var init_run = __esm(() => {
|
|
|
6071
7229
|
init_runner();
|
|
6072
7230
|
init_context();
|
|
6073
7231
|
runRouter = router({
|
|
6074
|
-
get: publicProcedure.input(
|
|
6075
|
-
agentTranscript: publicProcedure.input(
|
|
6076
|
-
cancel: publicProcedure.input(
|
|
7232
|
+
get: publicProcedure.input(z9.object({ runId: z9.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
|
|
7233
|
+
agentTranscript: publicProcedure.input(z9.object({ ticketId: z9.string(), after: z9.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
|
|
7234
|
+
cancel: publicProcedure.input(z9.object({ runId: z9.string() })).mutation(({ input }) => {
|
|
6077
7235
|
cancelRun(input.runId);
|
|
6078
7236
|
return { ok: true };
|
|
6079
7237
|
})
|
|
@@ -6081,15 +7239,15 @@ var init_run = __esm(() => {
|
|
|
6081
7239
|
});
|
|
6082
7240
|
|
|
6083
7241
|
// src/services/settings.ts
|
|
6084
|
-
import { existsSync as
|
|
6085
|
-
import { dirname as
|
|
6086
|
-
import * as
|
|
6087
|
-
import { eq as
|
|
7242
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "fs";
|
|
7243
|
+
import { dirname as dirname8 } from "path";
|
|
7244
|
+
import * as z10 from "zod";
|
|
7245
|
+
import { eq as eq12 } from "drizzle-orm";
|
|
6088
7246
|
function readRawConfig(configFile) {
|
|
6089
|
-
if (!
|
|
7247
|
+
if (!existsSync17(configFile))
|
|
6090
7248
|
return {};
|
|
6091
7249
|
try {
|
|
6092
|
-
const parsed = JSON.parse(
|
|
7250
|
+
const parsed = JSON.parse(readFileSync7(configFile, "utf8"));
|
|
6093
7251
|
if (typeof parsed !== "object" || parsed === null)
|
|
6094
7252
|
return {};
|
|
6095
7253
|
return foldLegacyModelConfig(parsed);
|
|
@@ -6113,33 +7271,45 @@ function stepModelFields(fileRaw) {
|
|
|
6113
7271
|
});
|
|
6114
7272
|
}
|
|
6115
7273
|
function projectOverrides(ctx, projectId) {
|
|
6116
|
-
const row = ctx.db.select({
|
|
7274
|
+
const row = ctx.db.select({
|
|
7275
|
+
model: projects.model,
|
|
7276
|
+
sandbox: projects.sandbox,
|
|
7277
|
+
devCommand: projects.devCommand,
|
|
7278
|
+
setupCommand: projects.setupCommand,
|
|
7279
|
+
verifyCommands: projects.verifyCommands,
|
|
7280
|
+
knownFailures: projects.knownFailures,
|
|
7281
|
+
dbResetCommand: projects.dbResetCommand
|
|
7282
|
+
}).from(projects).where(eq12(projects.id, projectId)).get();
|
|
6117
7283
|
return {
|
|
6118
7284
|
model: row?.model ?? null,
|
|
6119
7285
|
sandbox: row?.sandbox ?? null,
|
|
6120
|
-
devCommand: row?.devCommand ?? null
|
|
7286
|
+
devCommand: row?.devCommand ?? null,
|
|
7287
|
+
setupCommand: row?.setupCommand ?? null,
|
|
7288
|
+
verifyCommands: row?.verifyCommands ?? null,
|
|
7289
|
+
knownFailures: row?.knownFailures ?? null,
|
|
7290
|
+
dbResetCommand: row?.dbResetCommand ?? null
|
|
6121
7291
|
};
|
|
6122
7292
|
}
|
|
6123
|
-
function resolveField(
|
|
7293
|
+
function resolveField(desc5, layers) {
|
|
6124
7294
|
const scoped = layers.overrides !== null;
|
|
6125
|
-
const scope = scoped &&
|
|
6126
|
-
const base = { key:
|
|
6127
|
-
if (
|
|
6128
|
-
const raw = layers.env[
|
|
7295
|
+
const scope = scoped && desc5.projectColumn ? "project" : "global";
|
|
7296
|
+
const base = { key: desc5.key, restartRequired: desc5.restartRequired, scope };
|
|
7297
|
+
if (desc5.envVar) {
|
|
7298
|
+
const raw = layers.env[desc5.envVar];
|
|
6129
7299
|
if (raw !== undefined && raw !== "") {
|
|
6130
|
-
return { ...base, value:
|
|
7300
|
+
return { ...base, value: desc5.parseEnv(raw), source: "env", editable: false };
|
|
6131
7301
|
}
|
|
6132
7302
|
}
|
|
6133
|
-
if (layers.overrides &&
|
|
6134
|
-
const ov = layers.overrides[
|
|
7303
|
+
if (layers.overrides && desc5.projectColumn) {
|
|
7304
|
+
const ov = layers.overrides[desc5.projectColumn];
|
|
6135
7305
|
if (ov !== null && ov !== "") {
|
|
6136
7306
|
return { ...base, value: ov, source: "project", editable: true };
|
|
6137
7307
|
}
|
|
6138
7308
|
}
|
|
6139
|
-
if (
|
|
6140
|
-
return { ...base, value: layers.fileRaw[
|
|
7309
|
+
if (desc5.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc5.configKey)) {
|
|
7310
|
+
return { ...base, value: layers.fileRaw[desc5.configKey], source: "file", editable: true };
|
|
6141
7311
|
}
|
|
6142
|
-
const value =
|
|
7312
|
+
const value = desc5.configKey ? DEFAULTS[desc5.configKey] ?? null : null;
|
|
6143
7313
|
return { ...base, value, source: "default", editable: true };
|
|
6144
7314
|
}
|
|
6145
7315
|
function getSettings(ctx, projectId, io = {}) {
|
|
@@ -6156,59 +7326,61 @@ function updateSettings(ctx, input, io = {}) {
|
|
|
6156
7326
|
if (input.key.startsWith("stepModels.")) {
|
|
6157
7327
|
return updateStepModel(ctx, input, configFile, io);
|
|
6158
7328
|
}
|
|
6159
|
-
const
|
|
6160
|
-
if (!
|
|
7329
|
+
const desc5 = DESCRIPTORS.find((d) => d.key === input.key);
|
|
7330
|
+
if (!desc5)
|
|
6161
7331
|
throw new InvalidInputError(`unknown setting: ${input.key}`);
|
|
6162
|
-
if (
|
|
6163
|
-
const raw = env[
|
|
7332
|
+
if (desc5.envVar) {
|
|
7333
|
+
const raw = env[desc5.envVar];
|
|
6164
7334
|
if (raw !== undefined && raw !== "") {
|
|
6165
|
-
throw new InvalidInputError(`${
|
|
7335
|
+
throw new InvalidInputError(`${desc5.key} is set by environment variable ${desc5.envVar}; unset it to edit`);
|
|
6166
7336
|
}
|
|
6167
7337
|
}
|
|
6168
|
-
const toProject = input.projectId !== undefined &&
|
|
7338
|
+
const toProject = input.projectId !== undefined && desc5.projectColumn !== undefined;
|
|
6169
7339
|
if (input.value === null) {
|
|
6170
7340
|
if (!toProject)
|
|
6171
|
-
throw new InvalidInputError(`${
|
|
7341
|
+
throw new InvalidInputError(`${desc5.key} cannot be cleared`);
|
|
6172
7342
|
const project = requireProjectById(ctx, input.projectId);
|
|
6173
|
-
ctx.db.update(projects).set({ [
|
|
7343
|
+
ctx.db.update(projects).set({ [desc5.projectColumn]: null }).where(eq12(projects.id, project.id)).run();
|
|
7344
|
+
recordHuman(ctx, project.id, desc5.key, null);
|
|
6174
7345
|
emitProject(ctx, project.id, {
|
|
6175
7346
|
type: "settings.updated",
|
|
6176
|
-
message: `${
|
|
6177
|
-
data: { key:
|
|
7347
|
+
message: `${desc5.key} override cleared`,
|
|
7348
|
+
data: { key: desc5.key, scope: "project", value: null }
|
|
6178
7349
|
});
|
|
6179
|
-
return field(getSettings(ctx, input.projectId, io),
|
|
7350
|
+
return field(getSettings(ctx, input.projectId, io), desc5.key);
|
|
6180
7351
|
}
|
|
6181
|
-
const parsed =
|
|
7352
|
+
const parsed = desc5.valueSchema.safeParse(input.value);
|
|
6182
7353
|
if (!parsed.success) {
|
|
6183
|
-
throw new InvalidInputError(`invalid value for ${
|
|
7354
|
+
throw new InvalidInputError(`invalid value for ${desc5.key}: ${parsed.error.issues[0]?.message}`);
|
|
6184
7355
|
}
|
|
6185
7356
|
const value = parsed.data;
|
|
6186
7357
|
if (toProject) {
|
|
6187
7358
|
const project = requireProjectById(ctx, input.projectId);
|
|
6188
|
-
ctx.db.update(projects).set({ [
|
|
7359
|
+
ctx.db.update(projects).set({ [desc5.projectColumn]: String(value) }).where(eq12(projects.id, project.id)).run();
|
|
7360
|
+
recordHuman(ctx, project.id, desc5.key, String(value));
|
|
6189
7361
|
emitProject(ctx, project.id, {
|
|
6190
7362
|
type: "settings.updated",
|
|
6191
|
-
message: `${
|
|
6192
|
-
data: { key:
|
|
7363
|
+
message: `${desc5.key} override set to ${String(value)}`,
|
|
7364
|
+
data: { key: desc5.key, scope: "project", value }
|
|
6193
7365
|
});
|
|
6194
|
-
return field(getSettings(ctx, input.projectId, io),
|
|
7366
|
+
return field(getSettings(ctx, input.projectId, io), desc5.key);
|
|
6195
7367
|
}
|
|
6196
|
-
if (!
|
|
6197
|
-
throw new InvalidInputError(`${
|
|
7368
|
+
if (!desc5.configKey) {
|
|
7369
|
+
throw new InvalidInputError(`${desc5.key} is a per-project setting; provide a projectId`);
|
|
6198
7370
|
}
|
|
6199
|
-
writeGlobal(configFile,
|
|
6200
|
-
ctx.config[
|
|
7371
|
+
writeGlobal(configFile, desc5.configKey, value);
|
|
7372
|
+
ctx.config[desc5.configKey] = value;
|
|
6201
7373
|
emitProject(ctx, GLOBAL_EVENT_KEY, {
|
|
6202
7374
|
type: "settings.updated",
|
|
6203
|
-
message: `${
|
|
6204
|
-
data: { key:
|
|
7375
|
+
message: `${desc5.key} set to ${String(value)}`,
|
|
7376
|
+
data: { key: desc5.key, scope: "global", value }
|
|
6205
7377
|
});
|
|
6206
|
-
return field(getSettings(ctx, input.projectId, io),
|
|
7378
|
+
return field(getSettings(ctx, input.projectId, io), desc5.key);
|
|
6207
7379
|
}
|
|
6208
7380
|
function writeGlobal(configFile, configKey, value) {
|
|
6209
7381
|
const raw = readRawConfig(configFile);
|
|
6210
7382
|
raw[configKey] = value;
|
|
6211
|
-
|
|
7383
|
+
mkdirSync8(dirname8(configFile), { recursive: true });
|
|
6212
7384
|
writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
|
|
6213
7385
|
`);
|
|
6214
7386
|
}
|
|
@@ -6219,7 +7391,7 @@ function updateStepModel(ctx, input, configFile, io) {
|
|
|
6219
7391
|
const modelStep = step;
|
|
6220
7392
|
let value = null;
|
|
6221
7393
|
if (input.value !== null) {
|
|
6222
|
-
const parsed =
|
|
7394
|
+
const parsed = z10.string().min(1).safeParse(input.value);
|
|
6223
7395
|
if (!parsed.success) {
|
|
6224
7396
|
throw new InvalidInputError(`invalid value for ${input.key}: ${parsed.error.issues[0]?.message}`);
|
|
6225
7397
|
}
|
|
@@ -6247,7 +7419,7 @@ function writeStepModel(configFile, step, value) {
|
|
|
6247
7419
|
else
|
|
6248
7420
|
stepModels[step] = value;
|
|
6249
7421
|
raw.stepModels = stepModels;
|
|
6250
|
-
|
|
7422
|
+
mkdirSync8(dirname8(configFile), { recursive: true });
|
|
6251
7423
|
writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
|
|
6252
7424
|
`);
|
|
6253
7425
|
}
|
|
@@ -6264,6 +7436,7 @@ var init_settings = __esm(() => {
|
|
|
6264
7436
|
init_schema();
|
|
6265
7437
|
init_errors();
|
|
6266
7438
|
init_events();
|
|
7439
|
+
init_findings();
|
|
6267
7440
|
init_repo();
|
|
6268
7441
|
DESCRIPTORS = [
|
|
6269
7442
|
{
|
|
@@ -6271,7 +7444,7 @@ var init_settings = __esm(() => {
|
|
|
6271
7444
|
configKey: "serverPort",
|
|
6272
7445
|
envVar: "RUNCASTLE_SERVER_PORT",
|
|
6273
7446
|
restartRequired: true,
|
|
6274
|
-
valueSchema:
|
|
7447
|
+
valueSchema: z10.number().int().positive(),
|
|
6275
7448
|
parseEnv: (raw) => Number(raw)
|
|
6276
7449
|
},
|
|
6277
7450
|
{
|
|
@@ -6280,7 +7453,7 @@ var init_settings = __esm(() => {
|
|
|
6280
7453
|
envVar: "RUNCASTLE_MODEL",
|
|
6281
7454
|
projectColumn: "model",
|
|
6282
7455
|
restartRequired: false,
|
|
6283
|
-
valueSchema:
|
|
7456
|
+
valueSchema: z10.string().min(1),
|
|
6284
7457
|
parseEnv: idEnv
|
|
6285
7458
|
},
|
|
6286
7459
|
{
|
|
@@ -6289,7 +7462,7 @@ var init_settings = __esm(() => {
|
|
|
6289
7462
|
envVar: "RUNCASTLE_SANDBOX",
|
|
6290
7463
|
projectColumn: "sandbox",
|
|
6291
7464
|
restartRequired: false,
|
|
6292
|
-
valueSchema:
|
|
7465
|
+
valueSchema: z10.enum(["docker", "podman", "noSandbox"]),
|
|
6293
7466
|
parseEnv: idEnv
|
|
6294
7467
|
},
|
|
6295
7468
|
{
|
|
@@ -6297,7 +7470,7 @@ var init_settings = __esm(() => {
|
|
|
6297
7470
|
configKey: "sandboxImage",
|
|
6298
7471
|
envVar: "RUNCASTLE_SANDBOX_IMAGE",
|
|
6299
7472
|
restartRequired: false,
|
|
6300
|
-
valueSchema:
|
|
7473
|
+
valueSchema: z10.string().min(1),
|
|
6301
7474
|
parseEnv: idEnv
|
|
6302
7475
|
},
|
|
6303
7476
|
{
|
|
@@ -6305,7 +7478,7 @@ var init_settings = __esm(() => {
|
|
|
6305
7478
|
configKey: "burnConcurrency",
|
|
6306
7479
|
envVar: "RUNCASTLE_BURN_CONCURRENCY",
|
|
6307
7480
|
restartRequired: false,
|
|
6308
|
-
valueSchema:
|
|
7481
|
+
valueSchema: z10.number().int().min(1).max(8),
|
|
6309
7482
|
parseEnv: (raw) => Number(raw)
|
|
6310
7483
|
},
|
|
6311
7484
|
{
|
|
@@ -6313,7 +7486,7 @@ var init_settings = __esm(() => {
|
|
|
6313
7486
|
configKey: "burnMaxIterations",
|
|
6314
7487
|
envVar: "RUNCASTLE_BURN_MAX_ITERATIONS",
|
|
6315
7488
|
restartRequired: false,
|
|
6316
|
-
valueSchema:
|
|
7489
|
+
valueSchema: z10.number().int().min(1).max(10),
|
|
6317
7490
|
parseEnv: (raw) => Number(raw)
|
|
6318
7491
|
},
|
|
6319
7492
|
{
|
|
@@ -6321,7 +7494,7 @@ var init_settings = __esm(() => {
|
|
|
6321
7494
|
configKey: "burnAttempts",
|
|
6322
7495
|
envVar: "RUNCASTLE_BURN_ATTEMPTS",
|
|
6323
7496
|
restartRequired: false,
|
|
6324
|
-
valueSchema:
|
|
7497
|
+
valueSchema: z10.number().int().min(1).max(5),
|
|
6325
7498
|
parseEnv: (raw) => Number(raw)
|
|
6326
7499
|
},
|
|
6327
7500
|
{
|
|
@@ -6329,15 +7502,50 @@ var init_settings = __esm(() => {
|
|
|
6329
7502
|
configKey: "burnConflictAttempts",
|
|
6330
7503
|
envVar: "RUNCASTLE_BURN_CONFLICT_ATTEMPTS",
|
|
6331
7504
|
restartRequired: false,
|
|
6332
|
-
valueSchema:
|
|
7505
|
+
valueSchema: z10.number().int().min(0).max(3),
|
|
7506
|
+
parseEnv: (raw) => Number(raw)
|
|
7507
|
+
},
|
|
7508
|
+
{
|
|
7509
|
+
key: "burnCpus",
|
|
7510
|
+
configKey: "burnCpus",
|
|
7511
|
+
envVar: "RUNCASTLE_BURN_CPUS",
|
|
7512
|
+
restartRequired: false,
|
|
7513
|
+
valueSchema: z10.number().positive().max(256),
|
|
6333
7514
|
parseEnv: (raw) => Number(raw)
|
|
6334
7515
|
},
|
|
7516
|
+
{
|
|
7517
|
+
key: "autoPrepare",
|
|
7518
|
+
configKey: "autoPrepare",
|
|
7519
|
+
envVar: "RUNCASTLE_AUTO_PREPARE",
|
|
7520
|
+
restartRequired: false,
|
|
7521
|
+
valueSchema: z10.boolean(),
|
|
7522
|
+
parseEnv: (raw) => raw !== "0" && raw.toLowerCase() !== "false"
|
|
7523
|
+
},
|
|
6335
7524
|
{
|
|
6336
7525
|
key: "setupCommand",
|
|
6337
7526
|
configKey: "setupCommand",
|
|
6338
7527
|
envVar: "RUNCASTLE_SETUP_COMMAND",
|
|
7528
|
+
projectColumn: "setupCommand",
|
|
7529
|
+
restartRequired: false,
|
|
7530
|
+
valueSchema: z10.string().min(1),
|
|
7531
|
+
parseEnv: idEnv
|
|
7532
|
+
},
|
|
7533
|
+
{
|
|
7534
|
+
key: "verifyCommands",
|
|
7535
|
+
configKey: "verifyCommands",
|
|
7536
|
+
envVar: "RUNCASTLE_VERIFY_COMMANDS",
|
|
7537
|
+
projectColumn: "verifyCommands",
|
|
7538
|
+
restartRequired: false,
|
|
7539
|
+
valueSchema: z10.string().min(1),
|
|
7540
|
+
parseEnv: idEnv
|
|
7541
|
+
},
|
|
7542
|
+
{
|
|
7543
|
+
key: "knownFailures",
|
|
7544
|
+
configKey: "knownFailures",
|
|
7545
|
+
envVar: "RUNCASTLE_KNOWN_FAILURES",
|
|
7546
|
+
projectColumn: "knownFailures",
|
|
6339
7547
|
restartRequired: false,
|
|
6340
|
-
valueSchema:
|
|
7548
|
+
valueSchema: z10.string().min(1),
|
|
6341
7549
|
parseEnv: idEnv
|
|
6342
7550
|
},
|
|
6343
7551
|
{
|
|
@@ -6345,14 +7553,21 @@ var init_settings = __esm(() => {
|
|
|
6345
7553
|
configKey: "mainBranch",
|
|
6346
7554
|
envVar: "RUNCASTLE_MAIN_BRANCH",
|
|
6347
7555
|
restartRequired: false,
|
|
6348
|
-
valueSchema:
|
|
7556
|
+
valueSchema: z10.string().min(1),
|
|
6349
7557
|
parseEnv: idEnv
|
|
6350
7558
|
},
|
|
6351
7559
|
{
|
|
6352
7560
|
key: "devCommand",
|
|
6353
7561
|
projectColumn: "devCommand",
|
|
6354
7562
|
restartRequired: false,
|
|
6355
|
-
valueSchema:
|
|
7563
|
+
valueSchema: z10.string().min(1),
|
|
7564
|
+
parseEnv: idEnv
|
|
7565
|
+
},
|
|
7566
|
+
{
|
|
7567
|
+
key: "dbResetCommand",
|
|
7568
|
+
projectColumn: "dbResetCommand",
|
|
7569
|
+
restartRequired: false,
|
|
7570
|
+
valueSchema: z10.string().min(1),
|
|
6356
7571
|
parseEnv: idEnv
|
|
6357
7572
|
}
|
|
6358
7573
|
];
|
|
@@ -6361,14 +7576,14 @@ var init_settings = __esm(() => {
|
|
|
6361
7576
|
});
|
|
6362
7577
|
|
|
6363
7578
|
// src/trpc/routers/settings.ts
|
|
6364
|
-
import * as
|
|
7579
|
+
import * as z11 from "zod";
|
|
6365
7580
|
var settingsRouter;
|
|
6366
7581
|
var init_settings2 = __esm(() => {
|
|
6367
7582
|
init_src();
|
|
6368
7583
|
init_settings();
|
|
6369
7584
|
init_context();
|
|
6370
7585
|
settingsRouter = router({
|
|
6371
|
-
get: publicProcedure.input(
|
|
7586
|
+
get: publicProcedure.input(z11.object({ projectId: z11.string().optional() }).optional()).query(({ ctx, input }) => getSettings(ctx, input?.projectId)),
|
|
6372
7587
|
update: publicProcedure.input(SettingsUpdateInput).mutation(({ ctx, input }) => updateSettings(ctx, input))
|
|
6373
7588
|
});
|
|
6374
7589
|
});
|
|
@@ -6538,10 +7753,7 @@ var init_doctor = __esm(() => {
|
|
|
6538
7753
|
import { spawn as spawn2 } from "child_process";
|
|
6539
7754
|
function createSystemExec(opts = {}) {
|
|
6540
7755
|
return (command, args) => new Promise((resolve9) => {
|
|
6541
|
-
const
|
|
6542
|
-
const isShim = /\.(cmd|bat)$/i.test(resolved);
|
|
6543
|
-
const file = isShim ? process.env.ComSpec ?? "cmd.exe" : resolved;
|
|
6544
|
-
const spawnArgs = isShim ? ["/c", resolved, ...args] : args;
|
|
7756
|
+
const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
|
|
6545
7757
|
let stdout = "";
|
|
6546
7758
|
let stderr = "";
|
|
6547
7759
|
const child = spawn2(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
|
|
@@ -6564,9 +7776,9 @@ var init_system_exec = __esm(() => {
|
|
|
6564
7776
|
});
|
|
6565
7777
|
|
|
6566
7778
|
// src/services/setup.ts
|
|
6567
|
-
import { copyFileSync, existsSync as
|
|
6568
|
-
import { dirname as
|
|
6569
|
-
import { fileURLToPath as
|
|
7779
|
+
import { copyFileSync, existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "fs";
|
|
7780
|
+
import { dirname as dirname9, join as join17 } from "path";
|
|
7781
|
+
import { fileURLToPath as fileURLToPath7 } from "url";
|
|
6570
7782
|
async function writeGitIdentity(exec, identity) {
|
|
6571
7783
|
const name = identity.name.trim();
|
|
6572
7784
|
const email = identity.email.trim();
|
|
@@ -6641,16 +7853,16 @@ async function resolveRuntime(exec, preferred) {
|
|
|
6641
7853
|
function resolveSandcastleBin() {
|
|
6642
7854
|
try {
|
|
6643
7855
|
const mainUrl = import.meta.resolve("@ai-hero/sandcastle");
|
|
6644
|
-
let dir =
|
|
7856
|
+
let dir = dirname9(fileURLToPath7(mainUrl));
|
|
6645
7857
|
for (let hops = 0;hops < 6; hops++) {
|
|
6646
|
-
const manifestPath =
|
|
6647
|
-
if (
|
|
6648
|
-
const manifest = JSON.parse(
|
|
7858
|
+
const manifestPath = join17(dir, "package.json");
|
|
7859
|
+
if (existsSync18(manifestPath)) {
|
|
7860
|
+
const manifest = JSON.parse(readFileSync8(manifestPath, "utf8"));
|
|
6649
7861
|
if (manifest.name === "@ai-hero/sandcastle" && manifest.bin?.sandcastle) {
|
|
6650
|
-
return
|
|
7862
|
+
return join17(dir, manifest.bin.sandcastle);
|
|
6651
7863
|
}
|
|
6652
7864
|
}
|
|
6653
|
-
const parent =
|
|
7865
|
+
const parent = dirname9(dir);
|
|
6654
7866
|
if (parent === dir)
|
|
6655
7867
|
break;
|
|
6656
7868
|
dir = parent;
|
|
@@ -6672,32 +7884,49 @@ function terminalSpec(kind, opts) {
|
|
|
6672
7884
|
};
|
|
6673
7885
|
}
|
|
6674
7886
|
function sandcastleTemplateDir() {
|
|
6675
|
-
return resolveAsset(ASSET_ENV.sandcastleTemplate,
|
|
7887
|
+
return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath7(new URL("../assets/sandcastle", import.meta.url)));
|
|
6676
7888
|
}
|
|
6677
7889
|
function scaffoldSandcastleConfig(templateDir, targetDir) {
|
|
6678
|
-
const dir =
|
|
6679
|
-
if (
|
|
7890
|
+
const dir = join17(targetDir, ".sandcastle");
|
|
7891
|
+
if (existsSync18(dir))
|
|
6680
7892
|
return { scaffolded: false, dir };
|
|
6681
|
-
|
|
7893
|
+
mkdirSync9(dir, { recursive: true });
|
|
6682
7894
|
for (const name of readdirSync6(templateDir)) {
|
|
6683
|
-
copyFileSync(
|
|
7895
|
+
copyFileSync(join17(templateDir, name), join17(dir, name));
|
|
6684
7896
|
}
|
|
6685
7897
|
return { scaffolded: true, dir };
|
|
6686
7898
|
}
|
|
6687
7899
|
function prepareSandboxBuildContext() {
|
|
6688
7900
|
const target = sandboxBuildDir();
|
|
6689
|
-
|
|
7901
|
+
mkdirSync9(target, { recursive: true });
|
|
6690
7902
|
scaffoldSandcastleConfig(sandcastleTemplateDir(), target);
|
|
6691
7903
|
return target;
|
|
6692
7904
|
}
|
|
6693
7905
|
function createTokenVerifier(exec) {
|
|
6694
7906
|
return async (token) => {
|
|
7907
|
+
if (token.length < 8) {
|
|
7908
|
+
return {
|
|
7909
|
+
valid: false,
|
|
7910
|
+
detail: "token looks malformed (too short) \u2014 saved to ~/.runcastle/.env anyway",
|
|
7911
|
+
fix: "Re-run `claude setup-token` and paste the whole line it prints (it starts with `sk-ant-oat`)."
|
|
7912
|
+
};
|
|
7913
|
+
}
|
|
6695
7914
|
const out = await exec("claude", ["--version"]);
|
|
6696
|
-
if (!
|
|
6697
|
-
return {
|
|
7915
|
+
if (!out.ok) {
|
|
7916
|
+
return {
|
|
7917
|
+
valid: false,
|
|
7918
|
+
detail: "Token saved to ~/.runcastle/.env, but runcastle could not launch `claude` to verify it. " + "That is a PATH problem in this server process, not a missing install \u2014 a terminal that finds " + "`claude` proves nothing about the PATH runcastle was started with.",
|
|
7919
|
+
fix: "Quit runcastle and start it again from a terminal where `claude --version` works. " + "If it still fails, pin the path: set RUNCASTLE_CLAUDE_BIN to the full path from " + "`where.exe claude` (Windows) or `which claude` (macOS/Linux), then restart runcastle."
|
|
7920
|
+
};
|
|
6698
7921
|
}
|
|
6699
|
-
if (
|
|
6700
|
-
|
|
7922
|
+
if (out.code !== 0) {
|
|
7923
|
+
const why = (out.stderr.trim() || out.stdout.trim()).split(`
|
|
7924
|
+
`)[0] ?? "";
|
|
7925
|
+
return {
|
|
7926
|
+
valid: false,
|
|
7927
|
+
detail: `Token saved to ~/.runcastle/.env, but \`claude --version\` exited ${out.code}` + `${why ? `: ${why}` : " with no output"}.`,
|
|
7928
|
+
fix: "Run `claude --version` yourself and fix what it reports \u2014 the Claude Code install is broken, not the token."
|
|
7929
|
+
};
|
|
6701
7930
|
}
|
|
6702
7931
|
return { valid: true, detail: "token captured to ~/.runcastle/.env" };
|
|
6703
7932
|
};
|
|
@@ -6705,9 +7934,9 @@ function createTokenVerifier(exec) {
|
|
|
6705
7934
|
function fileAfkTokenIo(exec) {
|
|
6706
7935
|
const path = envPath();
|
|
6707
7936
|
return {
|
|
6708
|
-
read: () =>
|
|
7937
|
+
read: () => existsSync18(path) ? readFileSync8(path, "utf8") : "",
|
|
6709
7938
|
write: (content) => {
|
|
6710
|
-
|
|
7939
|
+
mkdirSync9(dirname9(path), { recursive: true });
|
|
6711
7940
|
writeFileSync4(path, content, "utf8");
|
|
6712
7941
|
},
|
|
6713
7942
|
verify: createTokenVerifier(exec)
|
|
@@ -6722,7 +7951,7 @@ var init_setup = __esm(() => {
|
|
|
6722
7951
|
});
|
|
6723
7952
|
|
|
6724
7953
|
// src/trpc/routers/setup.ts
|
|
6725
|
-
import * as
|
|
7954
|
+
import * as z12 from "zod";
|
|
6726
7955
|
var setupRouter;
|
|
6727
7956
|
var init_setup2 = __esm(() => {
|
|
6728
7957
|
init_src();
|
|
@@ -6738,9 +7967,9 @@ var init_setup2 = __esm(() => {
|
|
|
6738
7967
|
...ctx.config.sandboxImage ? { imageName: ctx.config.sandboxImage } : {}
|
|
6739
7968
|
})),
|
|
6740
7969
|
runtimeGuide: publicProcedure.query(() => runtimeInstallGuide(process.platform)),
|
|
6741
|
-
gitIdentity: publicProcedure.input(
|
|
6742
|
-
afkToken: publicProcedure.input(
|
|
6743
|
-
startTerminal: publicProcedure.input(
|
|
7970
|
+
gitIdentity: publicProcedure.input(z12.object({ name: z12.string(), email: z12.string() })).mutation(({ input }) => writeGitIdentity(createSystemExec(), input)),
|
|
7971
|
+
afkToken: publicProcedure.input(z12.object({ token: z12.string() })).mutation(({ input }) => saveAfkToken(fileAfkTokenIo(createSystemExec()), input.token)),
|
|
7972
|
+
startTerminal: publicProcedure.input(z12.object({ kind: z12.enum(["setup-token", "build-image"]) })).mutation(async ({ ctx, input }) => {
|
|
6744
7973
|
const exec = createSystemExec();
|
|
6745
7974
|
const preferred = ctx.config.sandbox === "podman" ? "podman" : "docker";
|
|
6746
7975
|
const runtime = await resolveRuntime(exec, preferred);
|
|
@@ -6749,10 +7978,7 @@ var init_setup2 = __esm(() => {
|
|
|
6749
7978
|
const spec = terminalSpec(input.kind, { runtime, imageName, sandcastleBin });
|
|
6750
7979
|
const sessionId = newId("setup");
|
|
6751
7980
|
const cwd = input.kind === "build-image" ? prepareSandboxBuildContext() : process.cwd();
|
|
6752
|
-
const
|
|
6753
|
-
const isShim = /\.(cmd|bat)$/i.test(resolved);
|
|
6754
|
-
const file = isShim ? process.env.ComSpec ?? "cmd.exe" : resolved;
|
|
6755
|
-
const args = isShim ? ["/c", resolved, ...spec.args] : spec.args;
|
|
7981
|
+
const { file, args } = resolveSpawnTarget(spec.cmd, spec.args);
|
|
6756
7982
|
ptyRegistry().create({
|
|
6757
7983
|
sessionId,
|
|
6758
7984
|
cmd: file,
|
|
@@ -6843,16 +8069,16 @@ var init_update_check = __esm(() => {
|
|
|
6843
8069
|
});
|
|
6844
8070
|
|
|
6845
8071
|
// src/version.ts
|
|
6846
|
-
import { readFileSync as
|
|
6847
|
-
import { dirname as
|
|
6848
|
-
import { fileURLToPath as
|
|
8072
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
8073
|
+
import { dirname as dirname10, join as join18 } from "path";
|
|
8074
|
+
import { fileURLToPath as fileURLToPath8 } from "url";
|
|
6849
8075
|
function runcastleVersion() {
|
|
6850
8076
|
if (cached2)
|
|
6851
8077
|
return cached2;
|
|
6852
|
-
const here =
|
|
6853
|
-
for (const pkgPath of [
|
|
8078
|
+
const here = dirname10(fileURLToPath8(import.meta.url));
|
|
8079
|
+
for (const pkgPath of [join18(here, "..", "package.json"), join18(here, "package.json")]) {
|
|
6854
8080
|
try {
|
|
6855
|
-
const pkg = JSON.parse(
|
|
8081
|
+
const pkg = JSON.parse(readFileSync9(pkgPath, "utf8"));
|
|
6856
8082
|
if (typeof pkg.version === "string") {
|
|
6857
8083
|
cached2 = pkg.version;
|
|
6858
8084
|
return cached2;
|
|
@@ -6878,17 +8104,27 @@ var init_system = __esm(() => {
|
|
|
6878
8104
|
});
|
|
6879
8105
|
|
|
6880
8106
|
// src/trpc/routers/ticket.ts
|
|
6881
|
-
import * as
|
|
8107
|
+
import * as z13 from "zod";
|
|
6882
8108
|
var ticketRouter;
|
|
6883
8109
|
var init_ticket = __esm(() => {
|
|
6884
8110
|
init_features();
|
|
8111
|
+
init_repo();
|
|
6885
8112
|
init_tickets();
|
|
6886
8113
|
init_ticket_burner();
|
|
6887
8114
|
init_context();
|
|
6888
8115
|
ticketRouter = router({
|
|
6889
|
-
retry: publicProcedure.input(
|
|
6890
|
-
stop: publicProcedure.input(
|
|
6891
|
-
|
|
8116
|
+
retry: publicProcedure.input(z13.object({ ticketId: z13.string(), fresh: z13.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
|
|
8117
|
+
stop: publicProcedure.input(z13.object({ ticketId: z13.string() })).mutation(({ ctx, input }) => {
|
|
8118
|
+
if (stopTicketRun(input.ticketId))
|
|
8119
|
+
return { stopped: true, swept: false };
|
|
8120
|
+
const ticket = getTicket(ctx, input.ticketId);
|
|
8121
|
+
if (ticket.status === "burning" && !hasActiveRun(ctx, ticket.featureId)) {
|
|
8122
|
+
sweepOrphanedBurning(ctx, ticket.featureId, "orphaned \u2014 the run that was burning it is gone");
|
|
8123
|
+
return { stopped: false, swept: true };
|
|
8124
|
+
}
|
|
8125
|
+
return { stopped: false, swept: false };
|
|
8126
|
+
}),
|
|
8127
|
+
cancel: publicProcedure.input(z13.object({ ticketId: z13.string(), reason: z13.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
|
|
6892
8128
|
});
|
|
6893
8129
|
});
|
|
6894
8130
|
|
|
@@ -6919,17 +8155,18 @@ var init_router = __esm(() => {
|
|
|
6919
8155
|
});
|
|
6920
8156
|
|
|
6921
8157
|
// src/workflows/reconcile-runs.ts
|
|
6922
|
-
import { eq as
|
|
8158
|
+
import { eq as eq13 } from "drizzle-orm";
|
|
6923
8159
|
async function reconcileStaleRuns(ctx) {
|
|
6924
|
-
const stale = ctx.db.select().from(runs).where(
|
|
8160
|
+
const stale = ctx.db.select().from(runs).where(eq13(runs.status, "running")).all().map(rowToRun);
|
|
6925
8161
|
const reconciled = [];
|
|
6926
|
-
for (const
|
|
6927
|
-
if (isRunActive(
|
|
8162
|
+
for (const run4 of stale) {
|
|
8163
|
+
if (isRunActive(run4.id))
|
|
6928
8164
|
continue;
|
|
6929
|
-
ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(
|
|
6930
|
-
const released = releaseForSession(ctx,
|
|
6931
|
-
|
|
6932
|
-
|
|
8165
|
+
ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq13(runs.id, run4.id)).run();
|
|
8166
|
+
const released = releaseForSession(ctx, run4.id);
|
|
8167
|
+
const swept = workflowClaimsFeatureBranch(run4.workflow) ? sweepOrphanedBurning(ctx, run4.featureId, "orphaned by server restart \u2014 retry to resume its commits") : [];
|
|
8168
|
+
if (workflowClaimsFeatureBranch(run4.workflow)) {
|
|
8169
|
+
const feature = tryGetFeature(ctx, run4.featureId);
|
|
6933
8170
|
const project = feature ? getProjectById(ctx, feature.projectId) : null;
|
|
6934
8171
|
if (feature && project) {
|
|
6935
8172
|
try {
|
|
@@ -6937,17 +8174,18 @@ async function reconcileStaleRuns(ctx) {
|
|
|
6937
8174
|
} catch {}
|
|
6938
8175
|
}
|
|
6939
8176
|
}
|
|
6940
|
-
emit(ctx,
|
|
8177
|
+
emit(ctx, run4.featureId, {
|
|
6941
8178
|
type: "run.reconciled",
|
|
6942
|
-
message: `run marked failed at boot \u2014 the server restarted while it was running (${
|
|
6943
|
-
runId:
|
|
8179
|
+
message: `run marked failed at boot \u2014 the server restarted while it was running (${run4.workflow})`,
|
|
8180
|
+
runId: run4.id,
|
|
6944
8181
|
data: {
|
|
6945
|
-
runId:
|
|
6946
|
-
workflow:
|
|
6947
|
-
releasedWaypointIds: released.map((w) => w.id)
|
|
8182
|
+
runId: run4.id,
|
|
8183
|
+
workflow: run4.workflow,
|
|
8184
|
+
releasedWaypointIds: released.map((w) => w.id),
|
|
8185
|
+
sweptTicketSeqs: swept.map((t2) => t2.seq)
|
|
6948
8186
|
}
|
|
6949
8187
|
});
|
|
6950
|
-
reconciled.push(
|
|
8188
|
+
reconciled.push(run4);
|
|
6951
8189
|
}
|
|
6952
8190
|
for (const project of allProjects(ctx)) {
|
|
6953
8191
|
try {
|
|
@@ -6962,6 +8200,7 @@ var init_reconcile_runs = __esm(() => {
|
|
|
6962
8200
|
init_events();
|
|
6963
8201
|
init_git();
|
|
6964
8202
|
init_repo();
|
|
8203
|
+
init_tickets();
|
|
6965
8204
|
init_waypoints();
|
|
6966
8205
|
init_runner();
|
|
6967
8206
|
});
|
|
@@ -7009,6 +8248,10 @@ async function startServer() {
|
|
|
7009
8248
|
if (staleRuns.length > 0) {
|
|
7010
8249
|
console.log(`reconciled ${staleRuns.length} stale run(s) from a previous server run`);
|
|
7011
8250
|
}
|
|
8251
|
+
const stalePreps = reconcilePreps(ctx);
|
|
8252
|
+
if (stalePreps > 0) {
|
|
8253
|
+
console.log(`reconciled ${stalePreps} stale preparation run(s) from a previous server run`);
|
|
8254
|
+
}
|
|
7012
8255
|
const server = Bun.serve({
|
|
7013
8256
|
port: config2.serverPort,
|
|
7014
8257
|
fetch(req, srv) {
|
|
@@ -7038,6 +8281,7 @@ var init_src2 = __esm(() => {
|
|
|
7038
8281
|
init_client();
|
|
7039
8282
|
init_migrate();
|
|
7040
8283
|
init_reconcile();
|
|
8284
|
+
init_prep();
|
|
7041
8285
|
init_server();
|
|
7042
8286
|
init_registry();
|
|
7043
8287
|
init_ws();
|