@edspencer/paddock 0.64.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/packages/server/dist/cli/args.d.ts +4 -4
- package/packages/server/dist/cli/args.js +5 -5
- package/packages/server/dist/cli/paddock.js +1 -1
- package/packages/server/dist/config.d.ts +7 -0
- package/packages/server/dist/config.js +9 -1
- package/packages/server/dist/herdctl.d.ts +89 -0
- package/packages/server/dist/herdctl.js +169 -2
- package/packages/server/dist/instance-config.js +12 -1
- package/packages/server/dist/management-ops.js +40 -1
- package/packages/server/dist/management-policy.d.ts +4 -3
- package/packages/server/dist/management-policy.js +5 -2
- package/packages/server/dist/projects.d.ts +23 -3
- package/packages/server/dist/projects.js +54 -7
- package/packages/server/dist/routes/chats.js +77 -6
- package/packages/server/dist/routes/projects.js +22 -2
- package/packages/server/dist/schema-version.d.ts +111 -0
- package/packages/server/dist/schema-version.js +142 -0
- package/packages/server/dist/self-mcp-descriptions.d.ts +1 -0
- package/packages/server/dist/self-mcp-descriptions.js +14 -1
- package/packages/server/dist/self-mcp-types.d.ts +26 -4
- package/packages/server/dist/self-mcp-write.d.ts +21 -0
- package/packages/server/dist/self-mcp-write.js +44 -0
- package/packages/server/dist/self-mcp.d.ts +7 -6
- package/packages/server/dist/self-mcp.js +36 -10
- package/packages/server/dist/subagents.d.ts +65 -0
- package/packages/server/dist/subagents.js +159 -19
- package/packages/server/dist/tooldetails.d.ts +10 -4
- package/packages/server/dist/tooldetails.js +23 -6
- package/packages/server/dist/trusted-proxy.js +1 -1
- package/packages/server/dist/turn-interlock.d.ts +101 -0
- package/packages/server/dist/turn-interlock.js +76 -0
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-DBrwtZjx.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-R6YzfI9Y.js → MarkdownRenderer-Bn6VPBdL.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-Cy-7Thc-.js → ProjectRedirect-CQmDBtd-.js} +1 -1
- package/packages/web/dist/assets/ProjectView-BVKu9oIg.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-C9XHf4eS.js → ProjectsGrid-BW56zqke.js} +1 -1
- package/packages/web/dist/assets/{arc-B_42F7Yk.js → arc-CGy6WkLl.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BAXzSOyw.js → architectureDiagram-3BPJPVTR-DJH1WIxY.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BdYEdA-3.js → blockDiagram-GPEHLZMM-Cufb_pvX.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-Cc4o15R5.js → c4Diagram-AAUBKEIU-glqeg1Ky.js} +1 -1
- package/packages/web/dist/assets/channel-C4kPYn_d.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-CeuxHjIm.js → chunk-2J33WTMH-SOFTHYgh.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DnZfrAaV.js → chunk-4BX2VUAB-DFxFAiG8.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-DFE6UYOT.js → chunk-55IACEB6-CEEDxZVG.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-YVHQ5cHc.js → chunk-727SXJPM--3eiptsh.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-5BYSbg-F.js → chunk-AQP2D5EJ-DIHIugCo.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-HBcelndR.js → chunk-FMBD7UC4-BmxWZl5c.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-2AwEsdxJ.js → chunk-ND2GUHAM-hMHtJs_I.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-BPpaZ6ct.js → chunk-QZHKN3VN-CRnnasyy.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCg6gTVh.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCg6gTVh.js +2 -0
- package/packages/web/dist/assets/{core-Cu_4vw1U.js → core-CGW2CVAY.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-DWkF0NrF.js → cose-bilkent-S5V4N54A-kcl9qSfK.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-DTgNmO9I.js → dagre-BM42HDAG-BiLsFx1W.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-DC28b7YS.js → diagram-2AECGRRQ-BcIpoY66.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-_WvuLno6.js → diagram-5GNKFQAL-BK5p5kJa.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-BEYxPfWq.js → diagram-KO2AKTUF-BmJYalCP.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-Biyev7x0.js → diagram-LMA3HP47-BHs0SWEM.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-u47NLW3S.js → diagram-OG6HWLK6-CSs8hie4.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-Bud2ISlK.js → erDiagram-TEJ5UH35-4qd_Td27.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-D-18vLQ1.js → flowDiagram-I6XJVG4X-CstUgr7V.js} +1 -1
- package/packages/web/dist/assets/{format-CQ2BDlby.js → format-B_kAvAW3.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CzyRX39N.js → ganttDiagram-6RSMTGT7-DTn9OuIc.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DLogfrjg.js → gitGraphDiagram-PVQCEYII-E_bCor2O.js} +1 -1
- package/packages/web/dist/assets/{index-B0gJ0qWe.js → index-B-QAOq9a.js} +2 -2
- package/packages/web/dist/assets/index-B3K5nsLe.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-OgNPt6RG.js → infoDiagram-5YYISTIA-foRUrIOQ.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CrM0t0_X.js → ishikawaDiagram-YF4QCWOH-CTON8v5t.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-xbxfHfwR.js → journeyDiagram-JHISSGLW-D-qwGjr7.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-DpszBaZb.js → kanban-definition-UN3LZRKU-5l4y83rE.js} +1 -1
- package/packages/web/dist/assets/{linear-BLujdbjQ.js → linear-B1GiUWpI.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-DzEhy9Qq.js → mermaid.core-btQIOU6d.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DaH3Lmoo.js → mindmap-definition-RKZ34NQL-DduczfMv.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BxeW1kuf.js → pieDiagram-4H26LBE5-D86uGusc.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-BKs2CT0r.js → quadrantDiagram-W4KKPZXB-Bo5EW3MZ.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CVdO-hXD.js → requirementDiagram-4Y6WPE33-C9aZJK93.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-yusj_kJC.js → sankeyDiagram-5OEKKPKP-b__W6SAJ.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C0h-9c8Y.js → sequenceDiagram-3UESZ5HK-Lr4Df8kX.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-BSBY8Psb.js → stateDiagram-AJRCARHV-R0XxGxD5.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-i2pZN-iR.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-DEFUdNb8.js → timeline-definition-PNZ67QCA-D42Je9LW.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-DunhMqxK.js → vennDiagram-CIIHVFJN-KCu14-QB.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-CXJsBGKl.js → wardley-L42UT6IY-Cfir3V9o.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DfT_-dqX.js → wardleyDiagram-YWT4CUSO-BkXjbTXu.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BysTtULW.js → xychartDiagram-2RQKCTM6-BVT60Pln.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-Blr1a-hx.js +0 -2
- package/packages/web/dist/assets/ProjectView-bMX4GEau.js +0 -13
- package/packages/web/dist/assets/channel-BZnRb_8C.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BGCpL0-K.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BGCpL0-K.js +0 -2
- package/packages/web/dist/assets/index-B6etJCHf.css +0 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-4DGE0Z1g.js +0 -2
|
@@ -23,6 +23,8 @@ export declare class ProjectStore {
|
|
|
23
23
|
constructor(root: string, dataDir?: string | undefined, log?: {
|
|
24
24
|
warn: (message: string) => void;
|
|
25
25
|
});
|
|
26
|
+
/** `${file} ${declaredVersion}` pairs already warned about — see {@link warnOnceAboutSchema}. */
|
|
27
|
+
private readonly schemaWarned;
|
|
26
28
|
/** Ensure the projects root exists. Call once at startup. */
|
|
27
29
|
init(): Promise<void>;
|
|
28
30
|
/**
|
|
@@ -67,9 +69,18 @@ export declare class ProjectStore {
|
|
|
67
69
|
* root, and that implicit safety is gone.
|
|
68
70
|
*
|
|
69
71
|
* So the check is made explicit and centralised here rather than re-derived at
|
|
70
|
-
* each call site: an `fs.rm` that escapes throws instead of deleting.
|
|
71
|
-
*
|
|
72
|
-
*
|
|
72
|
+
* each call site: an `fs.rm` that escapes throws instead of deleting. Every
|
|
73
|
+
* caller already only passes in-root paths — this exists so that stays true
|
|
74
|
+
* when someone later adds another one.
|
|
75
|
+
*
|
|
76
|
+
* `promote()`'s two rollbacks route through here as well. Their target is
|
|
77
|
+
* `<projectsRoot>/<slug>/<checkoutName>`, which is in-root by construction, so
|
|
78
|
+
* the guard is inert there today — that is the point. This is the choke point
|
|
79
|
+
* the comment above promised, and a recursive delete that skips it is exactly
|
|
80
|
+
* the drift it exists to prevent. {@link rollback} is the deliberate exception:
|
|
81
|
+
* it deletes paths that may legitimately sit OUTSIDE the root (a user-nominated
|
|
82
|
+
* clone target), and is safe by a different mechanism — it only ever removes
|
|
83
|
+
* directories this operation created.
|
|
73
84
|
*/
|
|
74
85
|
private rmInsideRoot;
|
|
75
86
|
/**
|
|
@@ -346,6 +357,15 @@ export declare class ProjectStore {
|
|
|
346
357
|
*/
|
|
347
358
|
private ensureSidecarGitignore;
|
|
348
359
|
private writeYaml;
|
|
360
|
+
/**
|
|
361
|
+
* Say out loud that a project was skipped for being from the future (#724),
|
|
362
|
+
* once per (file, declared version).
|
|
363
|
+
*
|
|
364
|
+
* `list()` runs on every `GET /api/projects`, so an unconditional warn would
|
|
365
|
+
* bury the log. Keyed on the declared version as well as the path so a
|
|
366
|
+
* hand-edit — or the same instance being downgraded further — speaks up again.
|
|
367
|
+
*/
|
|
368
|
+
private warnOnceAboutSchema;
|
|
349
369
|
private toDto;
|
|
350
370
|
/**
|
|
351
371
|
* Drop the derived DTO-only fields, leaving the on-disk shape.
|
|
@@ -30,6 +30,7 @@ import { sanitizeTrigger, sanitizeTriggers, isValidTriggerName, } from "./trigge
|
|
|
30
30
|
import { sanitizeRecoveryOverride } from "./recovery-config.js";
|
|
31
31
|
import { sanitizeCurationOverride } from "./curation-config.js";
|
|
32
32
|
import { sanitizeAttachmentsOverride } from "./attachments-config.js";
|
|
33
|
+
import { PROJECT_SCHEMA_VERSION, SCHEMA_VERSION_KEY, projectSchemaSkip, } from "./schema-version.js";
|
|
33
34
|
// --- re-export barrels (issue #403) ------------------------------------------
|
|
34
35
|
// The moved symbols are re-exported from here so external importers keep finding
|
|
35
36
|
// them at `./projects.js` — the same one-place-to-import discipline as before.
|
|
@@ -98,6 +99,8 @@ export class ProjectStore {
|
|
|
98
99
|
this.dataDir = dataDir;
|
|
99
100
|
this.log = log;
|
|
100
101
|
}
|
|
102
|
+
/** `${file} ${declaredVersion}` pairs already warned about — see {@link warnOnceAboutSchema}. */
|
|
103
|
+
schemaWarned = new Set();
|
|
101
104
|
/** Ensure the projects root exists. Call once at startup. */
|
|
102
105
|
async init() {
|
|
103
106
|
await fs.mkdir(this.root, { recursive: true });
|
|
@@ -202,9 +205,18 @@ export class ProjectStore {
|
|
|
202
205
|
* root, and that implicit safety is gone.
|
|
203
206
|
*
|
|
204
207
|
* So the check is made explicit and centralised here rather than re-derived at
|
|
205
|
-
* each call site: an `fs.rm` that escapes throws instead of deleting.
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
+
* each call site: an `fs.rm` that escapes throws instead of deleting. Every
|
|
209
|
+
* caller already only passes in-root paths — this exists so that stays true
|
|
210
|
+
* when someone later adds another one.
|
|
211
|
+
*
|
|
212
|
+
* `promote()`'s two rollbacks route through here as well. Their target is
|
|
213
|
+
* `<projectsRoot>/<slug>/<checkoutName>`, which is in-root by construction, so
|
|
214
|
+
* the guard is inert there today — that is the point. This is the choke point
|
|
215
|
+
* the comment above promised, and a recursive delete that skips it is exactly
|
|
216
|
+
* the drift it exists to prevent. {@link rollback} is the deliberate exception:
|
|
217
|
+
* it deletes paths that may legitimately sit OUTSIDE the root (a user-nominated
|
|
218
|
+
* clone target), and is safe by a different mechanism — it only ever removes
|
|
219
|
+
* directories this operation created.
|
|
208
220
|
*/
|
|
209
221
|
async rmInsideRoot(target) {
|
|
210
222
|
const resolved = path.resolve(target);
|
|
@@ -641,7 +653,7 @@ export class ProjectStore {
|
|
|
641
653
|
await cloneRepo(repo, checkoutDir);
|
|
642
654
|
}
|
|
643
655
|
catch (err) {
|
|
644
|
-
await
|
|
656
|
+
await this.rmInsideRoot(checkoutDir).catch(() => undefined);
|
|
645
657
|
throw new ProjectError(err instanceof Error ? err.message : `Failed to clone ${repo}`, "invalid");
|
|
646
658
|
}
|
|
647
659
|
// `writeYaml` is the atomic COMMIT point: until it succeeds the notebook is
|
|
@@ -666,7 +678,7 @@ export class ProjectStore {
|
|
|
666
678
|
await this.writeYaml(slug, next);
|
|
667
679
|
}
|
|
668
680
|
catch (err) {
|
|
669
|
-
await
|
|
681
|
+
await this.rmInsideRoot(checkoutDir).catch(() => undefined);
|
|
670
682
|
throw err;
|
|
671
683
|
}
|
|
672
684
|
// Post-commit finalization — the project is now validly repo-backed; these
|
|
@@ -1043,9 +1055,10 @@ export class ProjectStore {
|
|
|
1043
1055
|
*/
|
|
1044
1056
|
async readSafe(key) {
|
|
1045
1057
|
const dir = this.dirFor(key);
|
|
1058
|
+
const file = path.join(dir, PROJECT_FILE);
|
|
1046
1059
|
let yaml;
|
|
1047
1060
|
try {
|
|
1048
|
-
const raw = await fs.readFile(
|
|
1061
|
+
const raw = await fs.readFile(file, "utf8");
|
|
1049
1062
|
const parsed = YAML.parse(raw);
|
|
1050
1063
|
if (!parsed || typeof parsed !== "object") {
|
|
1051
1064
|
if (!isRootKey(key))
|
|
@@ -1053,6 +1066,18 @@ export class ProjectStore {
|
|
|
1053
1066
|
yaml = this.normalize({}, key);
|
|
1054
1067
|
}
|
|
1055
1068
|
else {
|
|
1069
|
+
// The downgrade guard (#724), BEFORE `normalize` gets to be lenient with
|
|
1070
|
+
// it. A file from the future is skipped whole — including the root's,
|
|
1071
|
+
// which falls through to a `not_found` rather than to the empty-record
|
|
1072
|
+
// defaults, because those defaults are precisely what a subsequent write
|
|
1073
|
+
// would flatten it to. Nothing is written here, and every mutator reaches
|
|
1074
|
+
// the file through `get()`, so a skipped record cannot be rewritten.
|
|
1075
|
+
const declared = parsed[SCHEMA_VERSION_KEY];
|
|
1076
|
+
const skip = projectSchemaSkip(declared, file);
|
|
1077
|
+
if (skip !== undefined) {
|
|
1078
|
+
this.warnOnceAboutSchema(file, declared, skip);
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
1056
1081
|
yaml = this.normalize(parsed, key);
|
|
1057
1082
|
}
|
|
1058
1083
|
}
|
|
@@ -1227,9 +1252,31 @@ export class ProjectStore {
|
|
|
1227
1252
|
async writeYaml(slug, yaml) {
|
|
1228
1253
|
const header = "# Paddock project metadata. Directory name MUST equal `slug`.\n" +
|
|
1229
1254
|
"# status: idea | active | paused | blocked | done | abandoned\n";
|
|
1230
|
-
|
|
1255
|
+
// Stamped here rather than threaded through ProjectYaml/the DTO, because
|
|
1256
|
+
// this is the one write choke point and because what it describes is the
|
|
1257
|
+
// FILE, not the project (#724). Unconditional and first: every write is a
|
|
1258
|
+
// full rewrite from the normalized record, so what lands on disk is by
|
|
1259
|
+
// construction this build's shape whatever the file said before. A legacy
|
|
1260
|
+
// file therefore gains the key the next time it is saved for some other
|
|
1261
|
+
// reason — there is no backfill pass, and merely reading one writes nothing.
|
|
1262
|
+
const body = YAML.stringify({ [SCHEMA_VERSION_KEY]: PROJECT_SCHEMA_VERSION, ...yaml });
|
|
1231
1263
|
await fs.writeFile(path.join(this.dirFor(slug), PROJECT_FILE), header + body, "utf8");
|
|
1232
1264
|
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Say out loud that a project was skipped for being from the future (#724),
|
|
1267
|
+
* once per (file, declared version).
|
|
1268
|
+
*
|
|
1269
|
+
* `list()` runs on every `GET /api/projects`, so an unconditional warn would
|
|
1270
|
+
* bury the log. Keyed on the declared version as well as the path so a
|
|
1271
|
+
* hand-edit — or the same instance being downgraded further — speaks up again.
|
|
1272
|
+
*/
|
|
1273
|
+
warnOnceAboutSchema(file, declared, message) {
|
|
1274
|
+
const seen = `${file} @ ${JSON.stringify(declared) ?? "?"}`;
|
|
1275
|
+
if (this.schemaWarned.has(seen))
|
|
1276
|
+
return;
|
|
1277
|
+
this.schemaWarned.add(seen);
|
|
1278
|
+
this.log.warn(message);
|
|
1279
|
+
}
|
|
1233
1280
|
toDto(dir, yaml, hasOverview) {
|
|
1234
1281
|
return {
|
|
1235
1282
|
...yaml,
|
|
@@ -24,6 +24,7 @@ import { childOf, HUMAN_ROOT, ADOPTED_ROOT } from "../run-provenance.js";
|
|
|
24
24
|
import { sendProjectError } from "../route-errors.js";
|
|
25
25
|
import { mapWithConcurrency, FILE_FANOUT_CONCURRENCY } from "../concurrency.js";
|
|
26
26
|
import { CHAT_USAGE_SCOPES, SAFE_SESSION_ID, RUNS_SEEN_SESSION, clampRunsLimit, toChatUsage, buildProjectChats, makeTriggerResolver, makeParentResolver, normalizeBatchSessionIds, BATCH_SESSIONS_MAX, } from "../chat-dto.js";
|
|
27
|
+
import { quiesceSession, turnRunningError } from "../turn-interlock.js";
|
|
27
28
|
/**
|
|
28
29
|
* Workspace-scoped chat routes: paths are declared RELATIVE to the workspace
|
|
29
30
|
* (e.g. `/chats/:sessionId`), and this plugin is mounted TWICE — once at
|
|
@@ -56,6 +57,17 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
56
57
|
* feed then degrades to its unread half instead of 500ing.
|
|
57
58
|
*/
|
|
58
59
|
const isRunning = (sessionId) => ctx.managementOpsContext?.hub.isRunning(sessionId) ?? false;
|
|
60
|
+
/**
|
|
61
|
+
* The interlock every DESTRUCTIVE chat route runs first (issue #731).
|
|
62
|
+
*
|
|
63
|
+
* `claude` writes the transcript itself, so unlinking or rewriting it while a
|
|
64
|
+
* turn is live does not remove the chat — the surviving process re-creates the
|
|
65
|
+
* file from its own tail and the chat comes back with its history gone. These
|
|
66
|
+
* routes therefore stop the turn and WAIT for it to be dead before touching a
|
|
67
|
+
* byte; `stuck` means we could not confirm that, and the only safe answer then
|
|
68
|
+
* is to refuse.
|
|
69
|
+
*/
|
|
70
|
+
const interlock = { hub: ctx.managementOpsContext?.hub, herdctl };
|
|
59
71
|
// --- chats (sessions) --------------------------------------------------
|
|
60
72
|
app.get("/chats", {
|
|
61
73
|
schema: {
|
|
@@ -595,7 +607,12 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
595
607
|
},
|
|
596
608
|
response: {
|
|
597
609
|
200: {
|
|
598
|
-
description: "Object `{ ok, removed }`
|
|
610
|
+
description: "Object `{ ok, removed, retained, cancelledTurn }` — whether the transcript was removed, and whether a live turn had to be stopped first (#731).",
|
|
611
|
+
type: "object",
|
|
612
|
+
additionalProperties: true,
|
|
613
|
+
},
|
|
614
|
+
409: {
|
|
615
|
+
description: "`{ code: 'turn_running' }` — a turn is still running on this chat and could not be stopped, so nothing was deleted (#731).",
|
|
599
616
|
type: "object",
|
|
600
617
|
additionalProperties: true,
|
|
601
618
|
},
|
|
@@ -604,6 +621,15 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
604
621
|
}, async (req, reply) => {
|
|
605
622
|
try {
|
|
606
623
|
const agent = await agentForSlug(req.params.slug);
|
|
624
|
+
// #731: stop any in-flight turn FIRST. A delete is an unambiguous "this
|
|
625
|
+
// chat is going away", so we cancel rather than refuse — refusing would
|
|
626
|
+
// strand the user behind a turn that may never end (a hung turn was
|
|
627
|
+
// exactly how this was found). If the turn will not die we refuse, since
|
|
628
|
+
// deleting under a live writer is what resurrected the chat.
|
|
629
|
+
const quiesced = await quiesceSession(interlock, req.params.sessionId);
|
|
630
|
+
if (quiesced === "stuck") {
|
|
631
|
+
return reply.code(409).send(turnRunningError([req.params.sessionId]));
|
|
632
|
+
}
|
|
607
633
|
await cleanupAttachments(agent, req.params.sessionId);
|
|
608
634
|
const { removed, retained } = await herdctl.deleteSession(agent, req.params.sessionId);
|
|
609
635
|
// Drop any archived/starred/unread/detached flag so a future session id
|
|
@@ -619,7 +645,9 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
619
645
|
// has to be cleared on the same path — otherwise a recycled session id
|
|
620
646
|
// silently starts life detached from a parent it never had.
|
|
621
647
|
await parentDetach.setDetached(agent, req.params.sessionId, false).catch(() => undefined);
|
|
622
|
-
return reply
|
|
648
|
+
return reply
|
|
649
|
+
.code(200)
|
|
650
|
+
.send({ ok: true, removed, retained, cancelledTurn: quiesced === "stopped" });
|
|
623
651
|
}
|
|
624
652
|
catch (err) {
|
|
625
653
|
return sendProjectError(reply, err);
|
|
@@ -751,7 +779,12 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
751
779
|
},
|
|
752
780
|
response: {
|
|
753
781
|
200: {
|
|
754
|
-
description: "Object reporting how many messages were removed.",
|
|
782
|
+
description: "Object reporting how many messages were removed, and whether a live turn had to be stopped first (`cancelledTurn`, #731).",
|
|
783
|
+
type: "object",
|
|
784
|
+
additionalProperties: true,
|
|
785
|
+
},
|
|
786
|
+
409: {
|
|
787
|
+
description: "`{ code: 'turn_running' }` — a turn is still running on this chat and could not be stopped, so nothing was truncated (#731).",
|
|
755
788
|
type: "object",
|
|
756
789
|
additionalProperties: true,
|
|
757
790
|
},
|
|
@@ -763,8 +796,19 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
763
796
|
if (!uuid)
|
|
764
797
|
return reply.code(400).send({ error: "uuid is required" });
|
|
765
798
|
const project = await projects.get(req.params.slug);
|
|
799
|
+
// #731: revert truncates the LIVE transcript in place — the same file a
|
|
800
|
+
// running turn is appending to — so mid-turn it left an orphan
|
|
801
|
+
// `tool_result` whose `tool_use` had just been truncated away. Cancel
|
|
802
|
+
// rather than refuse: an in-flight turn is by definition part of the tail
|
|
803
|
+
// the user just asked to throw away.
|
|
804
|
+
const quiesced = await quiesceSession(interlock, req.params.sessionId);
|
|
805
|
+
if (quiesced === "stuck") {
|
|
806
|
+
return reply.code(409).send(turnRunningError([req.params.sessionId]));
|
|
807
|
+
}
|
|
766
808
|
const { removed } = await herdctl.revertSession(project, req.params.sessionId, uuid);
|
|
767
|
-
return reply
|
|
809
|
+
return reply
|
|
810
|
+
.code(200)
|
|
811
|
+
.send({ ok: true, removed, cancelledTurn: quiesced === "stopped" });
|
|
768
812
|
}
|
|
769
813
|
catch (err) {
|
|
770
814
|
return sendProjectError(reply, err);
|
|
@@ -1206,6 +1250,14 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1206
1250
|
const failed = [];
|
|
1207
1251
|
for (const sessionId of sessionIds) {
|
|
1208
1252
|
try {
|
|
1253
|
+
// #731: same interlock as the single delete, per id. A chat whose
|
|
1254
|
+
// turn refuses to die lands in `failed` rather than aborting the
|
|
1255
|
+
// batch — the route is already best-effort-and-honest per chat, and
|
|
1256
|
+
// "still running" is exactly the kind of partial failure it reports.
|
|
1257
|
+
if ((await quiesceSession(interlock, sessionId)) === "stuck") {
|
|
1258
|
+
failed.push(sessionId);
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1209
1261
|
await cleanupAttachments(agent, sessionId);
|
|
1210
1262
|
const gone = await herdctl.deleteSession(agent, sessionId);
|
|
1211
1263
|
// `retained` counts as success here, and the distinction matters for
|
|
@@ -1279,7 +1331,12 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1279
1331
|
},
|
|
1280
1332
|
response: {
|
|
1281
1333
|
201: {
|
|
1282
|
-
description: "Object `{ project, promoted, sessionId }` describing the created project
|
|
1334
|
+
description: "Object `{ project, promoted, sessionId, cancelledTurn }` describing the created project, whether the transcript was re-homed, and whether a live turn had to be stopped first (#731).",
|
|
1335
|
+
type: "object",
|
|
1336
|
+
additionalProperties: true,
|
|
1337
|
+
},
|
|
1338
|
+
409: {
|
|
1339
|
+
description: "`{ code: 'turn_running' }` — a turn is still running on this chat and could not be stopped. Nothing was moved and NO project was created (#731).",
|
|
1283
1340
|
type: "object",
|
|
1284
1341
|
additionalProperties: true,
|
|
1285
1342
|
},
|
|
@@ -1292,6 +1349,15 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1292
1349
|
}
|
|
1293
1350
|
try {
|
|
1294
1351
|
const from = await projects.get(req.params.slug);
|
|
1352
|
+
// #731: promote MOVES the transcript, so mid-turn it lost the chat from
|
|
1353
|
+
// BOTH projects — the surviving process rewrote the source path it still
|
|
1354
|
+
// held, while the moved copy went unlisted in the target with no recovery
|
|
1355
|
+
// path in the UI. Quiesce BEFORE `projects.create` so a refusal doesn't
|
|
1356
|
+
// leave an empty project behind as a side-effect of a failed promote.
|
|
1357
|
+
const quiesced = await quiesceSession(interlock, req.params.sessionId);
|
|
1358
|
+
if (quiesced === "stuck") {
|
|
1359
|
+
return reply.code(409).send(turnRunningError([req.params.sessionId]));
|
|
1360
|
+
}
|
|
1295
1361
|
const project = await projects.create({
|
|
1296
1362
|
name: body.name,
|
|
1297
1363
|
slug: body.slug,
|
|
@@ -1315,7 +1381,12 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1315
1381
|
catch (err) {
|
|
1316
1382
|
req.log.warn({ err }, "promote: could not re-home the transcript");
|
|
1317
1383
|
}
|
|
1318
|
-
return reply.code(201).send({
|
|
1384
|
+
return reply.code(201).send({
|
|
1385
|
+
project,
|
|
1386
|
+
promoted,
|
|
1387
|
+
sessionId: req.params.sessionId,
|
|
1388
|
+
cancelledTurn: quiesced === "stopped",
|
|
1389
|
+
});
|
|
1319
1390
|
}
|
|
1320
1391
|
catch (err) {
|
|
1321
1392
|
return sendProjectError(reply, err);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { ProjectError, ROOT_KEY, } from "../projects.js";
|
|
1
|
+
import { ProjectError, ROOT_KEY, isRootKey, } from "../projects.js";
|
|
2
2
|
import { keeperAgentName } from "../herdctl.js";
|
|
3
3
|
import { isKnownModel, isKnownPermissionMode, isValidMaxTurns, isKnownDriveMode, resolveModels, MAX_TURNS_LIMIT, } from "../models.js";
|
|
4
4
|
import { isValidMaxSpawnDepth, MAX_SPAWN_DEPTH_LIMIT } from "../spawn-capability.js";
|
|
5
5
|
import { sendProjectError } from "../route-errors.js";
|
|
6
6
|
import { buildProjectChats, makeTriggerResolver, makeParentResolver } from "../chat-dto.js";
|
|
7
|
+
import { quiesceProject, turnRunningError } from "../turn-interlock.js";
|
|
7
8
|
/**
|
|
8
9
|
* Build ONE workspace's compact `{ sessionId, lastTurnCompletedAt, lastSeen?,
|
|
9
10
|
* unread? }` list — the sidebar's UNREAD-badge feed (#161), server-backed (#189).
|
|
@@ -406,11 +407,30 @@ export function registerProjectWorkspaceRoutes(app, ctx) {
|
|
|
406
407
|
type: "object",
|
|
407
408
|
additionalProperties: true,
|
|
408
409
|
},
|
|
410
|
+
409: {
|
|
411
|
+
description: "`{ code: 'turn_running', sessionIds }` — one or more of the project's chats has a turn that could not be stopped, so nothing was deleted (#731).",
|
|
412
|
+
type: "object",
|
|
413
|
+
additionalProperties: true,
|
|
414
|
+
},
|
|
409
415
|
},
|
|
410
416
|
},
|
|
411
417
|
}, async (req, reply) => {
|
|
412
418
|
try {
|
|
413
|
-
|
|
419
|
+
// #731: a project delete takes every chat in it, so it races the same live
|
|
420
|
+
// writers a chat delete does — and the orphaned `claude` was observed
|
|
421
|
+
// running on with a cwd of `…/<slug> (deleted)`. Stop them all first, and
|
|
422
|
+
// refuse rather than remove the directory out from under one.
|
|
423
|
+
//
|
|
424
|
+
// `remove()` refuses the ROOT workspace, and a refusal must not have killed
|
|
425
|
+
// the root's live turns on the way to saying no — so that one check comes
|
|
426
|
+
// first, mirroring the guard in `remove()` rather than duplicating its
|
|
427
|
+
// error.
|
|
428
|
+
if (!isRootKey(req.params.slug)) {
|
|
429
|
+
const stuck = await quiesceProject({ hub: ctx.managementOpsContext?.hub, herdctl }, req.params.slug);
|
|
430
|
+
if (stuck.length > 0)
|
|
431
|
+
return reply.code(409).send(turnRunningError(stuck));
|
|
432
|
+
}
|
|
433
|
+
const project = await projects.remove(req.params.slug); // throws not_found / refuses root
|
|
414
434
|
try {
|
|
415
435
|
await herdctl.removeProjectAgent(project.slug, Object.keys(project.hooks ?? {}), Object.keys(project.triggers ?? {}));
|
|
416
436
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema versioning for the two on-disk formats paddock OWNS (issue #724).
|
|
3
|
+
*
|
|
4
|
+
* Those two are `project.yaml` and `paddock.config.yaml`, and only those two.
|
|
5
|
+
* The `version: 1` fields elsewhere in the tree belong to herdctl — its fleet
|
|
6
|
+
* config (`herdctl-agent-config.ts`) and `@herdctl/core`'s session metadata —
|
|
7
|
+
* and transcripts are Claude Code's format. Paddock reads neither back.
|
|
8
|
+
*
|
|
9
|
+
* ## What this is for: the DOWNGRADE guard, not migrations
|
|
10
|
+
*
|
|
11
|
+
* There is no migration runner here, deliberately (#724, and #717 for the
|
|
12
|
+
* sequencing). With `1` as the only version there is nothing to migrate, and a
|
|
13
|
+
* runner with zero migrations cannot be meaningfully tested.
|
|
14
|
+
*
|
|
15
|
+
* The value delivered on its own is the guard. Running an OLDER paddock against
|
|
16
|
+
* a data dir a newer one wrote — `npx @edspencer/paddock@0.62.0` is one command
|
|
17
|
+
* away — lenient-parses it: `ProjectStore.normalize` drops every key it does not
|
|
18
|
+
* recognise, and the next `update()` writes the file back without them. A
|
|
19
|
+
* `path:` or a `managed:` disappears with no error. A declared version turns
|
|
20
|
+
* that into "this file is version 4, I understand 1, I am not touching it".
|
|
21
|
+
*
|
|
22
|
+
* **Never lenient-parse a file from the future.** That is the whole point.
|
|
23
|
+
*
|
|
24
|
+
* ## Adoption cost: nothing
|
|
25
|
+
*
|
|
26
|
+
* The CURRENT on-disk shape *is* version 1, and an ABSENT `schemaVersion` reads
|
|
27
|
+
* as 1 — so no live instance needs rewriting and nothing pre-adoption can break,
|
|
28
|
+
* because those files genuinely are v1. Files we write carry the field
|
|
29
|
+
* explicitly; existing files gain it whenever they are next written for some
|
|
30
|
+
* other reason. There is no backfill.
|
|
31
|
+
*
|
|
32
|
+
* ## When to bump — read this before changing either format
|
|
33
|
+
*
|
|
34
|
+
* Bump ONLY when an old reader would get the wrong answer, or a new reader
|
|
35
|
+
* cannot recover the truth from an old file:
|
|
36
|
+
*
|
|
37
|
+
* | Change | Bump? |
|
|
38
|
+
* | --------------------------------------------------- | ----- |
|
|
39
|
+
* | Add an optional key with a safe default | **No** — lenient parsing already handles it |
|
|
40
|
+
* | Rename a key | Yes |
|
|
41
|
+
* | Change what an existing key *means* | Yes — silent misinterpretation, the dangerous one |
|
|
42
|
+
* | Change a default so *absence* now means something new | Yes |
|
|
43
|
+
* | Remove a load-bearing key | Yes |
|
|
44
|
+
*
|
|
45
|
+
* Adding a key does not bump, which covers most changes. The additive-vs-
|
|
46
|
+
* breaking distinction is expressed by NOT bumping, rather than by a second
|
|
47
|
+
* version component — hence a monotonic integer and never semver. A data file
|
|
48
|
+
* has one consumer, the app, and it always wants latest: there is no version
|
|
49
|
+
* negotiation for a minor/patch to describe, and migrations chain (1→2→3), which
|
|
50
|
+
* "does 1.2→2.0 include 1.3?" cannot express. Rails, Django, SQLite's
|
|
51
|
+
* `user_version` and IndexedDB all land in the same place.
|
|
52
|
+
*
|
|
53
|
+
* Worked example, so the table is not abstract. v0.64.0's `managed` default is
|
|
54
|
+
* `managed ?? !(repo || path)` (`project-paths.ts`) — row four, the absence of a
|
|
55
|
+
* key acquiring a derived meaning. Under this scheme that was a 1→2 migration:
|
|
56
|
+
* read v1, compute `managed`, write it explicitly, stamp 2. Instead it is a
|
|
57
|
+
* derivation re-evaluated on every read, forever, with `stripDto` deliberately
|
|
58
|
+
* letting `managed` survive so the first PATCH pins it. It works, but it is a
|
|
59
|
+
* migration in disguise, and that pattern accretes.
|
|
60
|
+
*
|
|
61
|
+
* The two constants are independent on purpose: a breaking change to
|
|
62
|
+
* `project.yaml` should not force every `paddock.config.yaml` to claim a version
|
|
63
|
+
* whose shape it never had. They happen to both be 1 today.
|
|
64
|
+
*/
|
|
65
|
+
/** Schema version of the `project.yaml` format this build writes and understands. */
|
|
66
|
+
export declare const PROJECT_SCHEMA_VERSION = 1;
|
|
67
|
+
/** Schema version of the `paddock.config.yaml` format this build writes and understands. */
|
|
68
|
+
export declare const CONFIG_SCHEMA_VERSION = 1;
|
|
69
|
+
/** The key both formats carry it under. */
|
|
70
|
+
export declare const SCHEMA_VERSION_KEY = "schemaVersion";
|
|
71
|
+
/**
|
|
72
|
+
* The version a parsed file declares, or `null` if it declares something we
|
|
73
|
+
* cannot make sense of.
|
|
74
|
+
*
|
|
75
|
+
* - **Absent / null** → {@link PROJECT_SCHEMA_VERSION}-era, i.e. `1`. Every file
|
|
76
|
+
* written before adoption is genuinely a v1 file.
|
|
77
|
+
* - A positive integer (or a string spelling one, since YAML quoting is easy to
|
|
78
|
+
* get wrong by hand) → that integer.
|
|
79
|
+
* - Anything else — `0`, `-1`, `1.5`, `"banana"`, a list — → `null`.
|
|
80
|
+
*
|
|
81
|
+
* `null` is deliberately treated by callers the same way a from-the-future
|
|
82
|
+
* version is, not the same way an absent one is. A file whose own claim about
|
|
83
|
+
* its shape is unreadable is a file we cannot establish is safe to rewrite, and
|
|
84
|
+
* the failure mode this whole mechanism exists to prevent is rewriting one we
|
|
85
|
+
* did not understand.
|
|
86
|
+
*/
|
|
87
|
+
export declare function readSchemaVersion(raw: unknown): number | null;
|
|
88
|
+
/** Whether `raw` declares a version this build cannot safely read. */
|
|
89
|
+
export declare function isFromTheFuture(raw: unknown, supported: number): boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Why `paddock.config.yaml` is unreadable by this build, or `undefined` if it is
|
|
92
|
+
* fine. Pure, so the decision is testable without a filesystem.
|
|
93
|
+
*
|
|
94
|
+
* Fail-closed, in the same shape as the `claudeHome`-points-at-your-own-`~/.claude`
|
|
95
|
+
* refusal in `config.ts`: an instance config from the future governs auth mode,
|
|
96
|
+
* bind host and data paths, and half-understanding those is worse than not
|
|
97
|
+
* starting.
|
|
98
|
+
*/
|
|
99
|
+
export declare function configSchemaRefusal(raw: unknown, configPath: string): string | undefined;
|
|
100
|
+
/**
|
|
101
|
+
* Why a `project.yaml` cannot be read by this build, or `undefined` if it is fine.
|
|
102
|
+
*
|
|
103
|
+
* Asymmetric with {@link configSchemaRefusal} on purpose, and this is the
|
|
104
|
+
* judgement call worth knowing about: a project from the future is SKIPPED,
|
|
105
|
+
* loudly, rather than refusing the boot. `readSafe` already returns `null` for
|
|
106
|
+
* an unreadable `project.yaml` and the project simply vanishes from `list()`, so
|
|
107
|
+
* saying it out loud is a strict improvement on today. Bricking a whole instance
|
|
108
|
+
* because one project directory was copied in from a newer box would be a worse
|
|
109
|
+
* failure than the data loss being prevented.
|
|
110
|
+
*/
|
|
111
|
+
export declare function projectSchemaSkip(raw: unknown, file: string): string | undefined;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema versioning for the two on-disk formats paddock OWNS (issue #724).
|
|
3
|
+
*
|
|
4
|
+
* Those two are `project.yaml` and `paddock.config.yaml`, and only those two.
|
|
5
|
+
* The `version: 1` fields elsewhere in the tree belong to herdctl — its fleet
|
|
6
|
+
* config (`herdctl-agent-config.ts`) and `@herdctl/core`'s session metadata —
|
|
7
|
+
* and transcripts are Claude Code's format. Paddock reads neither back.
|
|
8
|
+
*
|
|
9
|
+
* ## What this is for: the DOWNGRADE guard, not migrations
|
|
10
|
+
*
|
|
11
|
+
* There is no migration runner here, deliberately (#724, and #717 for the
|
|
12
|
+
* sequencing). With `1` as the only version there is nothing to migrate, and a
|
|
13
|
+
* runner with zero migrations cannot be meaningfully tested.
|
|
14
|
+
*
|
|
15
|
+
* The value delivered on its own is the guard. Running an OLDER paddock against
|
|
16
|
+
* a data dir a newer one wrote — `npx @edspencer/paddock@0.62.0` is one command
|
|
17
|
+
* away — lenient-parses it: `ProjectStore.normalize` drops every key it does not
|
|
18
|
+
* recognise, and the next `update()` writes the file back without them. A
|
|
19
|
+
* `path:` or a `managed:` disappears with no error. A declared version turns
|
|
20
|
+
* that into "this file is version 4, I understand 1, I am not touching it".
|
|
21
|
+
*
|
|
22
|
+
* **Never lenient-parse a file from the future.** That is the whole point.
|
|
23
|
+
*
|
|
24
|
+
* ## Adoption cost: nothing
|
|
25
|
+
*
|
|
26
|
+
* The CURRENT on-disk shape *is* version 1, and an ABSENT `schemaVersion` reads
|
|
27
|
+
* as 1 — so no live instance needs rewriting and nothing pre-adoption can break,
|
|
28
|
+
* because those files genuinely are v1. Files we write carry the field
|
|
29
|
+
* explicitly; existing files gain it whenever they are next written for some
|
|
30
|
+
* other reason. There is no backfill.
|
|
31
|
+
*
|
|
32
|
+
* ## When to bump — read this before changing either format
|
|
33
|
+
*
|
|
34
|
+
* Bump ONLY when an old reader would get the wrong answer, or a new reader
|
|
35
|
+
* cannot recover the truth from an old file:
|
|
36
|
+
*
|
|
37
|
+
* | Change | Bump? |
|
|
38
|
+
* | --------------------------------------------------- | ----- |
|
|
39
|
+
* | Add an optional key with a safe default | **No** — lenient parsing already handles it |
|
|
40
|
+
* | Rename a key | Yes |
|
|
41
|
+
* | Change what an existing key *means* | Yes — silent misinterpretation, the dangerous one |
|
|
42
|
+
* | Change a default so *absence* now means something new | Yes |
|
|
43
|
+
* | Remove a load-bearing key | Yes |
|
|
44
|
+
*
|
|
45
|
+
* Adding a key does not bump, which covers most changes. The additive-vs-
|
|
46
|
+
* breaking distinction is expressed by NOT bumping, rather than by a second
|
|
47
|
+
* version component — hence a monotonic integer and never semver. A data file
|
|
48
|
+
* has one consumer, the app, and it always wants latest: there is no version
|
|
49
|
+
* negotiation for a minor/patch to describe, and migrations chain (1→2→3), which
|
|
50
|
+
* "does 1.2→2.0 include 1.3?" cannot express. Rails, Django, SQLite's
|
|
51
|
+
* `user_version` and IndexedDB all land in the same place.
|
|
52
|
+
*
|
|
53
|
+
* Worked example, so the table is not abstract. v0.64.0's `managed` default is
|
|
54
|
+
* `managed ?? !(repo || path)` (`project-paths.ts`) — row four, the absence of a
|
|
55
|
+
* key acquiring a derived meaning. Under this scheme that was a 1→2 migration:
|
|
56
|
+
* read v1, compute `managed`, write it explicitly, stamp 2. Instead it is a
|
|
57
|
+
* derivation re-evaluated on every read, forever, with `stripDto` deliberately
|
|
58
|
+
* letting `managed` survive so the first PATCH pins it. It works, but it is a
|
|
59
|
+
* migration in disguise, and that pattern accretes.
|
|
60
|
+
*
|
|
61
|
+
* The two constants are independent on purpose: a breaking change to
|
|
62
|
+
* `project.yaml` should not force every `paddock.config.yaml` to claim a version
|
|
63
|
+
* whose shape it never had. They happen to both be 1 today.
|
|
64
|
+
*/
|
|
65
|
+
/** Schema version of the `project.yaml` format this build writes and understands. */
|
|
66
|
+
export const PROJECT_SCHEMA_VERSION = 1;
|
|
67
|
+
/** Schema version of the `paddock.config.yaml` format this build writes and understands. */
|
|
68
|
+
export const CONFIG_SCHEMA_VERSION = 1;
|
|
69
|
+
/** The key both formats carry it under. */
|
|
70
|
+
export const SCHEMA_VERSION_KEY = "schemaVersion";
|
|
71
|
+
/**
|
|
72
|
+
* The version a parsed file declares, or `null` if it declares something we
|
|
73
|
+
* cannot make sense of.
|
|
74
|
+
*
|
|
75
|
+
* - **Absent / null** → {@link PROJECT_SCHEMA_VERSION}-era, i.e. `1`. Every file
|
|
76
|
+
* written before adoption is genuinely a v1 file.
|
|
77
|
+
* - A positive integer (or a string spelling one, since YAML quoting is easy to
|
|
78
|
+
* get wrong by hand) → that integer.
|
|
79
|
+
* - Anything else — `0`, `-1`, `1.5`, `"banana"`, a list — → `null`.
|
|
80
|
+
*
|
|
81
|
+
* `null` is deliberately treated by callers the same way a from-the-future
|
|
82
|
+
* version is, not the same way an absent one is. A file whose own claim about
|
|
83
|
+
* its shape is unreadable is a file we cannot establish is safe to rewrite, and
|
|
84
|
+
* the failure mode this whole mechanism exists to prevent is rewriting one we
|
|
85
|
+
* did not understand.
|
|
86
|
+
*/
|
|
87
|
+
export function readSchemaVersion(raw) {
|
|
88
|
+
if (raw === undefined || raw === null)
|
|
89
|
+
return 1;
|
|
90
|
+
const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw.trim()) : NaN;
|
|
91
|
+
if (!Number.isInteger(n) || n < 1)
|
|
92
|
+
return null;
|
|
93
|
+
return n;
|
|
94
|
+
}
|
|
95
|
+
/** How a declared version reads in a message, including the unreadable case. */
|
|
96
|
+
function describeDeclared(raw) {
|
|
97
|
+
const found = readSchemaVersion(raw);
|
|
98
|
+
return found === null ? `an unreadable \`schemaVersion\` (${JSON.stringify(raw)})` : `version ${found}`;
|
|
99
|
+
}
|
|
100
|
+
/** Whether `raw` declares a version this build cannot safely read. */
|
|
101
|
+
export function isFromTheFuture(raw, supported) {
|
|
102
|
+
const found = readSchemaVersion(raw);
|
|
103
|
+
return found === null || found > supported;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Why `paddock.config.yaml` is unreadable by this build, or `undefined` if it is
|
|
107
|
+
* fine. Pure, so the decision is testable without a filesystem.
|
|
108
|
+
*
|
|
109
|
+
* Fail-closed, in the same shape as the `claudeHome`-points-at-your-own-`~/.claude`
|
|
110
|
+
* refusal in `config.ts`: an instance config from the future governs auth mode,
|
|
111
|
+
* bind host and data paths, and half-understanding those is worse than not
|
|
112
|
+
* starting.
|
|
113
|
+
*/
|
|
114
|
+
export function configSchemaRefusal(raw, configPath) {
|
|
115
|
+
if (!isFromTheFuture(raw, CONFIG_SCHEMA_VERSION))
|
|
116
|
+
return undefined;
|
|
117
|
+
return (`refusing to start: ${configPath} declares ${describeDeclared(raw)} of the paddock config ` +
|
|
118
|
+
`format, but this build understands up to version ${CONFIG_SCHEMA_VERSION}. It was written ` +
|
|
119
|
+
`by a NEWER paddock. Reading it with this build would silently drop every key this version ` +
|
|
120
|
+
`does not know, and the next write would persist the loss. Upgrade paddock to a version that ` +
|
|
121
|
+
`understands it, or point PADDOCK_CONFIG at a config file this build wrote.`);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Why a `project.yaml` cannot be read by this build, or `undefined` if it is fine.
|
|
125
|
+
*
|
|
126
|
+
* Asymmetric with {@link configSchemaRefusal} on purpose, and this is the
|
|
127
|
+
* judgement call worth knowing about: a project from the future is SKIPPED,
|
|
128
|
+
* loudly, rather than refusing the boot. `readSafe` already returns `null` for
|
|
129
|
+
* an unreadable `project.yaml` and the project simply vanishes from `list()`, so
|
|
130
|
+
* saying it out loud is a strict improvement on today. Bricking a whole instance
|
|
131
|
+
* because one project directory was copied in from a newer box would be a worse
|
|
132
|
+
* failure than the data loss being prevented.
|
|
133
|
+
*/
|
|
134
|
+
export function projectSchemaSkip(raw, file) {
|
|
135
|
+
if (!isFromTheFuture(raw, PROJECT_SCHEMA_VERSION))
|
|
136
|
+
return undefined;
|
|
137
|
+
return (`skipping a project: ${file} declares ${describeDeclared(raw)} of the paddock project ` +
|
|
138
|
+
`format, but this build understands up to version ${PROJECT_SCHEMA_VERSION}. It was written ` +
|
|
139
|
+
`by a NEWER paddock. The project is hidden — NOT deleted, and the file is left exactly as it ` +
|
|
140
|
+
`is — because reading it here would drop the keys this version does not know and the next ` +
|
|
141
|
+
`save would persist that loss. Upgrade paddock to see it again.`);
|
|
142
|
+
}
|
|
@@ -14,6 +14,7 @@ export declare const ARCHIVE_CHAT_DESC: string;
|
|
|
14
14
|
export declare const UNARCHIVE_CHAT_DESC: string;
|
|
15
15
|
export declare const FORK_CHAT_BATCH_DESC: string;
|
|
16
16
|
export declare const CREATE_PROJECT_DESC: string;
|
|
17
|
+
export declare const PROMOTE_PROJECT_DESC: string;
|
|
17
18
|
export declare const SET_TRIGGER_DESC: string;
|
|
18
19
|
export declare const REMOVE_TRIGGER_DESC: string;
|
|
19
20
|
export declare const RUN_TRIGGER_DESC: string;
|