syntaur 0.16.3 → 0.17.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/dashboard/dist/assets/{_basePickBy-DTuHiZCP.js → _basePickBy-Dek5-ACb.js} +1 -1
- package/dashboard/dist/assets/{_baseUniq-BjbISCNN.js → _baseUniq-BeLddDKU.js} +1 -1
- package/dashboard/dist/assets/{arc-D9mCa8If.js → arc-kotdj4Uh.js} +1 -1
- package/dashboard/dist/assets/{architectureDiagram-2XIMDMQ5-CLWheiZu.js → architectureDiagram-2XIMDMQ5-BaqFXQ-q.js} +1 -1
- package/dashboard/dist/assets/{blockDiagram-WCTKOSBZ-BBxrVTWB.js → blockDiagram-WCTKOSBZ-BSyGne6h.js} +1 -1
- package/dashboard/dist/assets/{c4Diagram-IC4MRINW-DnhDZ2W3.js → c4Diagram-IC4MRINW-iYjZFKOW.js} +1 -1
- package/dashboard/dist/assets/channel-eXdj7ayV.js +1 -0
- package/dashboard/dist/assets/{chunk-4BX2VUAB-DeAfVeDa.js → chunk-4BX2VUAB-DvY9Y7jB.js} +1 -1
- package/dashboard/dist/assets/{chunk-55IACEB6-cKEeGDNI.js → chunk-55IACEB6-2vCDm2cJ.js} +1 -1
- package/dashboard/dist/assets/{chunk-FMBD7UC4-7RuZ6HEq.js → chunk-FMBD7UC4-BVPaoRtV.js} +1 -1
- package/dashboard/dist/assets/{chunk-JSJVCQXG-B5dwG0bv.js → chunk-JSJVCQXG-B9uJpMzd.js} +1 -1
- package/dashboard/dist/assets/{chunk-KX2RTZJC-DeyQYDVj.js → chunk-KX2RTZJC-CHcjjGaq.js} +1 -1
- package/dashboard/dist/assets/{chunk-NQ4KR5QH-NYo4hSqA.js → chunk-NQ4KR5QH-B8n19pSs.js} +1 -1
- package/dashboard/dist/assets/{chunk-QZHKN3VN-CtJFICF8.js → chunk-QZHKN3VN-CF9ZYFGK.js} +1 -1
- package/dashboard/dist/assets/{chunk-WL4C6EOR-DZ3WYdab.js → chunk-WL4C6EOR-mY_1Vmdf.js} +1 -1
- package/dashboard/dist/assets/classDiagram-VBA2DB6C-B-bBQACr.js +1 -0
- package/dashboard/dist/assets/classDiagram-v2-RAHNMMFH-B-bBQACr.js +1 -0
- package/dashboard/dist/assets/clone-CL04pv9J.js +1 -0
- package/dashboard/dist/assets/{cose-bilkent-S5V4N54A-DaYOsf-M.js → cose-bilkent-S5V4N54A-DOYoG3gB.js} +1 -1
- package/dashboard/dist/assets/{dagre-KLK3FWXG-DzLc7ykF.js → dagre-KLK3FWXG-CZFrp-Yq.js} +1 -1
- package/dashboard/dist/assets/{diagram-E7M64L7V-kpjEdOqb.js → diagram-E7M64L7V-F3rF5YwM.js} +1 -1
- package/dashboard/dist/assets/{diagram-IFDJBPK2-D3EHoh6j.js → diagram-IFDJBPK2-BynWv-Vo.js} +1 -1
- package/dashboard/dist/assets/{diagram-P4PSJMXO-Cp6Un2ys.js → diagram-P4PSJMXO-NIyzhm_M.js} +1 -1
- package/dashboard/dist/assets/{erDiagram-INFDFZHY-DDjOUPWk.js → erDiagram-INFDFZHY-Ca9NbihE.js} +1 -1
- package/dashboard/dist/assets/{flowDiagram-PKNHOUZH-CfO51xge.js → flowDiagram-PKNHOUZH-YhS0MOu4.js} +1 -1
- package/dashboard/dist/assets/{ganttDiagram-A5KZAMGK-BDzAtkcp.js → ganttDiagram-A5KZAMGK-G7P3qq_2.js} +1 -1
- package/dashboard/dist/assets/{gitGraphDiagram-K3NZZRJ6-JPMFN2zF.js → gitGraphDiagram-K3NZZRJ6-BJAK7M7E.js} +1 -1
- package/dashboard/dist/assets/{graph-DXDryilu.js → graph-11geMy3f.js} +1 -1
- package/dashboard/dist/assets/index--qwybK3W.js +535 -0
- package/dashboard/dist/assets/index-DwrhlK8r.css +1 -0
- package/dashboard/dist/assets/{infoDiagram-LFFYTUFH-CYi5kkvz.js → infoDiagram-LFFYTUFH-COcQYcLf.js} +1 -1
- package/dashboard/dist/assets/{ishikawaDiagram-PHBUUO56-DQl_IUe8.js → ishikawaDiagram-PHBUUO56-B7Jjyw8J.js} +1 -1
- package/dashboard/dist/assets/{journeyDiagram-4ABVD52K-BXXGPcrS.js → journeyDiagram-4ABVD52K--snU-QAX.js} +1 -1
- package/dashboard/dist/assets/{kanban-definition-K7BYSVSG-BqJestUY.js → kanban-definition-K7BYSVSG-BucLoc89.js} +1 -1
- package/dashboard/dist/assets/{layout-D5VdYmWn.js → layout-Bcgxdz8A.js} +1 -1
- package/dashboard/dist/assets/{linear-dZA_O_GN.js → linear-CRF8xwvk.js} +1 -1
- package/dashboard/dist/assets/{mermaid.core-B0Ixd1yP.js → mermaid.core-D5JD5uYg.js} +4 -4
- package/dashboard/dist/assets/{mindmap-definition-YRQLILUH-CSJYdSMG.js → mindmap-definition-YRQLILUH-BqGcTcbk.js} +1 -1
- package/dashboard/dist/assets/{pieDiagram-SKSYHLDU-DmYrRZHN.js → pieDiagram-SKSYHLDU-CnQ2EkyX.js} +1 -1
- package/dashboard/dist/assets/{quadrantDiagram-337W2JSQ-La3ce5kE.js → quadrantDiagram-337W2JSQ-CKCfmTRc.js} +1 -1
- package/dashboard/dist/assets/{requirementDiagram-Z7DCOOCP-DPitIZQl.js → requirementDiagram-Z7DCOOCP-B9J4bvUK.js} +1 -1
- package/dashboard/dist/assets/{sankeyDiagram-WA2Y5GQK-CAmCqGkr.js → sankeyDiagram-WA2Y5GQK-CMSzI5rg.js} +1 -1
- package/dashboard/dist/assets/{sequenceDiagram-2WXFIKYE-6lEzNTWG.js → sequenceDiagram-2WXFIKYE-BFQDtxd0.js} +1 -1
- package/dashboard/dist/assets/{stateDiagram-RAJIS63D-DyGKCS2C.js → stateDiagram-RAJIS63D-DgWYKT02.js} +1 -1
- package/dashboard/dist/assets/stateDiagram-v2-FVOUBMTO-Nmwft4cR.js +1 -0
- package/dashboard/dist/assets/{timeline-definition-YZTLITO2-D9RxQE3j.js → timeline-definition-YZTLITO2-jANu0e7M.js} +1 -1
- package/dashboard/dist/assets/{treemap-KZPCXAKY-TuXbGNN4.js → treemap-KZPCXAKY-BFjWMkyM.js} +1 -1
- package/dashboard/dist/assets/{vennDiagram-LZ73GAT5-Bly5knr-.js → vennDiagram-LZ73GAT5-DGwrAJYj.js} +1 -1
- package/dashboard/dist/assets/{xychartDiagram-JWTSCODW-CA-5z7-4.js → xychartDiagram-JWTSCODW-BLCPfoFM.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dist/dashboard/server.js +1945 -312
- package/dist/dashboard/server.js.map +1 -1
- package/dist/db/leases-db.js.map +1 -1
- package/dist/index.js +4390 -1126
- package/dist/index.js.map +1 -1
- package/dist/launch/index.js +30 -8
- package/dist/launch/index.js.map +1 -1
- package/package.json +1 -1
- package/platforms/claude-code/skills/bundle-worktree/SKILL.md +107 -0
- package/platforms/claude-code/skills/complete-bundle/SKILL.md +113 -0
- package/platforms/claude-code/skills/grab-bundle/SKILL.md +111 -0
- package/platforms/claude-code/skills/plan-bundle/SKILL.md +88 -0
- package/platforms/codex/skills/bundle-worktree/SKILL.md +107 -0
- package/platforms/codex/skills/complete-bundle/SKILL.md +113 -0
- package/platforms/codex/skills/grab-bundle/SKILL.md +111 -0
- package/platforms/codex/skills/plan-bundle/SKILL.md +88 -0
- package/skills/bundle-worktree/SKILL.md +107 -0
- package/skills/complete-bundle/SKILL.md +113 -0
- package/skills/grab-bundle/SKILL.md +111 -0
- package/skills/plan-bundle/SKILL.md +88 -0
- package/dashboard/dist/assets/channel-CN5VmjNa.js +0 -1
- package/dashboard/dist/assets/classDiagram-VBA2DB6C-CqLb9GuU.js +0 -1
- package/dashboard/dist/assets/classDiagram-v2-RAHNMMFH-CqLb9GuU.js +0 -1
- package/dashboard/dist/assets/clone-DwvrjCQs.js +0 -1
- package/dashboard/dist/assets/index-D7UtkCYM.js +0 -515
- package/dashboard/dist/assets/index-DTXSWSzH.css +0 -1
- package/dashboard/dist/assets/stateDiagram-v2-FVOUBMTO-BOmRICoY.js +0 -1
package/dist/dashboard/server.js
CHANGED
|
@@ -12,11 +12,15 @@ var __export = (target, all) => {
|
|
|
12
12
|
var paths_exports = {};
|
|
13
13
|
__export(paths_exports, {
|
|
14
14
|
assignmentsDir: () => assignmentsDir,
|
|
15
|
+
bundlePlanDir: () => bundlePlanDir,
|
|
16
|
+
bundlesDir: () => bundlesDir,
|
|
17
|
+
bundlesPath: () => bundlesPath,
|
|
15
18
|
defaultProjectDir: () => defaultProjectDir,
|
|
16
19
|
expandHome: () => expandHome,
|
|
17
20
|
playbooksDir: () => playbooksDir,
|
|
18
21
|
projectTodosDir: () => projectTodosDir,
|
|
19
22
|
proofDir: () => proofDir,
|
|
23
|
+
savedViewsFile: () => savedViewsFile,
|
|
20
24
|
serversDir: () => serversDir,
|
|
21
25
|
syntaurRoot: () => syntaurRoot,
|
|
22
26
|
todoPlanDir: () => todoPlanDir,
|
|
@@ -56,12 +60,24 @@ function todosDir() {
|
|
|
56
60
|
function viewPrefsFile() {
|
|
57
61
|
return resolve(syntaurRoot(), "view-prefs.json");
|
|
58
62
|
}
|
|
63
|
+
function savedViewsFile() {
|
|
64
|
+
return resolve(syntaurRoot(), "saved-views.json");
|
|
65
|
+
}
|
|
59
66
|
function projectTodosDir(projectsDir, projectSlug) {
|
|
60
67
|
return resolve(projectsDir, projectSlug, "todos");
|
|
61
68
|
}
|
|
62
69
|
function todoPlanDir(todosDir2, workspaceOrProject, todoId) {
|
|
63
70
|
return resolve(todosDir2, "plans", workspaceOrProject, todoId);
|
|
64
71
|
}
|
|
72
|
+
function bundlePlanDir(todosDir2, scopeOrProject, bundleId) {
|
|
73
|
+
return resolve(todosDir2, "plans", scopeOrProject, "bundles", bundleId);
|
|
74
|
+
}
|
|
75
|
+
function bundlesDir(todosDir2) {
|
|
76
|
+
return resolve(todosDir2, "bundles");
|
|
77
|
+
}
|
|
78
|
+
function bundlesPath(todosDir2) {
|
|
79
|
+
return resolve(todosDir2, "bundles", "index.md");
|
|
80
|
+
}
|
|
65
81
|
function proofDir(assignmentDir) {
|
|
66
82
|
return resolve(assignmentDir, "proof");
|
|
67
83
|
}
|
|
@@ -99,7 +115,7 @@ function buildTransitionTable(transitions) {
|
|
|
99
115
|
return table;
|
|
100
116
|
}
|
|
101
117
|
function getTargetStatus(_from, command, table) {
|
|
102
|
-
if (!table
|
|
118
|
+
if (!table) {
|
|
103
119
|
return DEFAULT_COMMAND_TARGETS.get(command) ?? null;
|
|
104
120
|
}
|
|
105
121
|
return table.get(command) ?? table.get(`${_from}:${command}`) ?? null;
|
|
@@ -772,7 +788,8 @@ function emptyMetaFields() {
|
|
|
772
788
|
updatedAt: null,
|
|
773
789
|
planDir: null,
|
|
774
790
|
linkedAssignmentId: null,
|
|
775
|
-
linkedAssignmentRef: null
|
|
791
|
+
linkedAssignmentRef: null,
|
|
792
|
+
bundleId: null
|
|
776
793
|
};
|
|
777
794
|
}
|
|
778
795
|
function parseMetaToken(line) {
|
|
@@ -811,6 +828,9 @@ function parseMetaToken(line) {
|
|
|
811
828
|
case "lr":
|
|
812
829
|
fields.linkedAssignmentRef = value;
|
|
813
830
|
break;
|
|
831
|
+
case "bn":
|
|
832
|
+
fields.bundleId = value;
|
|
833
|
+
break;
|
|
814
834
|
}
|
|
815
835
|
}
|
|
816
836
|
return fields;
|
|
@@ -824,6 +844,7 @@ function serializeMetaToken(item) {
|
|
|
824
844
|
if (item.planDir !== null) pairs.push(`p=${encodeMetaValue(item.planDir)}`);
|
|
825
845
|
if (item.linkedAssignmentId !== null) pairs.push(`l=${encodeMetaValue(item.linkedAssignmentId)}`);
|
|
826
846
|
if (item.linkedAssignmentRef !== null) pairs.push(`lr=${encodeMetaValue(item.linkedAssignmentRef)}`);
|
|
847
|
+
if (item.bundleId !== null) pairs.push(`bn=${encodeMetaValue(item.bundleId)}`);
|
|
827
848
|
if (pairs.length === 0) return "";
|
|
828
849
|
return `<${pairs.join(";")}>`;
|
|
829
850
|
}
|
|
@@ -884,7 +905,8 @@ function parseChecklistItem(line) {
|
|
|
884
905
|
updatedAt: meta.updatedAt,
|
|
885
906
|
planDir: meta.planDir,
|
|
886
907
|
linkedAssignmentId: meta.linkedAssignmentId,
|
|
887
|
-
linkedAssignmentRef: meta.linkedAssignmentRef
|
|
908
|
+
linkedAssignmentRef: meta.linkedAssignmentRef,
|
|
909
|
+
bundleId: meta.bundleId
|
|
888
910
|
};
|
|
889
911
|
}
|
|
890
912
|
function serializeChecklistItem(item) {
|
|
@@ -2835,7 +2857,20 @@ function getAssignmentTypes(config) {
|
|
|
2835
2857
|
return config.types ?? DEFAULT_ASSIGNMENT_TYPES;
|
|
2836
2858
|
}
|
|
2837
2859
|
function getAgents(config) {
|
|
2838
|
-
|
|
2860
|
+
if (config.agents === null) return BUILTIN_AGENTS;
|
|
2861
|
+
const builtinById = new Map(BUILTIN_AGENTS.map((a) => [a.id, a]));
|
|
2862
|
+
return config.agents.map((agent) => {
|
|
2863
|
+
const builtin = builtinById.get(agent.id);
|
|
2864
|
+
if (!builtin) return agent;
|
|
2865
|
+
const resume = agent.resume ?? builtin.resume;
|
|
2866
|
+
const fork = agent.fork ?? builtin.fork;
|
|
2867
|
+
if (resume === agent.resume && fork === agent.fork) return agent;
|
|
2868
|
+
return {
|
|
2869
|
+
...agent,
|
|
2870
|
+
...resume ? { resume } : {},
|
|
2871
|
+
...fork ? { fork } : {}
|
|
2872
|
+
};
|
|
2873
|
+
});
|
|
2839
2874
|
}
|
|
2840
2875
|
function parseTerminalConfig(value) {
|
|
2841
2876
|
if (value === void 0 || value === null || value === "") return null;
|
|
@@ -3836,8 +3871,8 @@ async function migrateFromMarkdown(projectsDir) {
|
|
|
3836
3871
|
return allSessions.length;
|
|
3837
3872
|
}
|
|
3838
3873
|
async function parseMarkdownSessionsIndex(filePath, projectSlug) {
|
|
3839
|
-
const { readFile:
|
|
3840
|
-
const raw = await
|
|
3874
|
+
const { readFile: readFile21 } = await import("fs/promises");
|
|
3875
|
+
const raw = await readFile21(filePath, "utf-8");
|
|
3841
3876
|
const sessions = [];
|
|
3842
3877
|
const lines = raw.split("\n");
|
|
3843
3878
|
let inTable = false;
|
|
@@ -3926,8 +3961,8 @@ function rowToSession(row) {
|
|
|
3926
3961
|
};
|
|
3927
3962
|
}
|
|
3928
3963
|
async function appendSession(_projectDir, session) {
|
|
3929
|
-
const
|
|
3930
|
-
|
|
3964
|
+
const db4 = getSessionDb();
|
|
3965
|
+
db4.prepare(`
|
|
3931
3966
|
INSERT INTO sessions (session_id, project_slug, assignment_slug, agent, started, status, path, description, transcript_path, pid, pid_started_at)
|
|
3932
3967
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
3933
3968
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
@@ -3956,41 +3991,41 @@ async function appendSession(_projectDir, session) {
|
|
|
3956
3991
|
);
|
|
3957
3992
|
}
|
|
3958
3993
|
async function updateSessionStatus(_projectDir, sessionId, status) {
|
|
3959
|
-
const
|
|
3994
|
+
const db4 = getSessionDb();
|
|
3960
3995
|
const isTerminal = status === "completed" || status === "stopped";
|
|
3961
|
-
const result = isTerminal ?
|
|
3996
|
+
const result = isTerminal ? db4.prepare(
|
|
3962
3997
|
"UPDATE sessions SET status = ?, ended = datetime('now'), updated_at = datetime('now') WHERE session_id = ?"
|
|
3963
|
-
).run(status, sessionId) :
|
|
3998
|
+
).run(status, sessionId) : db4.prepare(
|
|
3964
3999
|
"UPDATE sessions SET status = ?, updated_at = datetime('now') WHERE session_id = ?"
|
|
3965
4000
|
).run(status, sessionId);
|
|
3966
4001
|
return result.changes > 0;
|
|
3967
4002
|
}
|
|
3968
4003
|
async function listAllSessions(_projectsDir) {
|
|
3969
|
-
const
|
|
3970
|
-
const rows =
|
|
4004
|
+
const db4 = getSessionDb();
|
|
4005
|
+
const rows = db4.prepare("SELECT * FROM sessions ORDER BY started DESC").all();
|
|
3971
4006
|
return rows.map(rowToSession);
|
|
3972
4007
|
}
|
|
3973
4008
|
function getSessionById(sessionId) {
|
|
3974
|
-
const
|
|
3975
|
-
const row =
|
|
4009
|
+
const db4 = getSessionDb();
|
|
4010
|
+
const row = db4.prepare("SELECT * FROM sessions WHERE session_id = ? LIMIT 1").get(sessionId);
|
|
3976
4011
|
return row ? rowToSession(row) : null;
|
|
3977
4012
|
}
|
|
3978
4013
|
async function listProjectSessions(_projectsDir, projectSlug, assignmentSlug) {
|
|
3979
|
-
const
|
|
4014
|
+
const db4 = getSessionDb();
|
|
3980
4015
|
if (assignmentSlug) {
|
|
3981
|
-
const rows2 =
|
|
4016
|
+
const rows2 = db4.prepare(
|
|
3982
4017
|
"SELECT * FROM sessions WHERE project_slug = ? AND assignment_slug = ? ORDER BY started DESC"
|
|
3983
4018
|
).all(projectSlug, assignmentSlug);
|
|
3984
4019
|
return rows2.map(rowToSession);
|
|
3985
4020
|
}
|
|
3986
|
-
const rows =
|
|
4021
|
+
const rows = db4.prepare("SELECT * FROM sessions WHERE project_slug = ? ORDER BY started DESC").all(projectSlug);
|
|
3987
4022
|
return rows.map(rowToSession);
|
|
3988
4023
|
}
|
|
3989
4024
|
async function deleteSessions(sessionIds) {
|
|
3990
4025
|
if (sessionIds.length === 0) return 0;
|
|
3991
|
-
const
|
|
4026
|
+
const db4 = getSessionDb();
|
|
3992
4027
|
const placeholders = sessionIds.map(() => "?").join(", ");
|
|
3993
|
-
const result =
|
|
4028
|
+
const result = db4.prepare(`DELETE FROM sessions WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
|
3994
4029
|
return result.changes;
|
|
3995
4030
|
}
|
|
3996
4031
|
async function readAssignmentStatusFromPath(assignmentMdPath) {
|
|
@@ -4005,8 +4040,8 @@ async function readAssignmentStatus(projectDir, assignmentSlug) {
|
|
|
4005
4040
|
);
|
|
4006
4041
|
}
|
|
4007
4042
|
async function reconcileActiveSessions(projectsDir, assignmentsDir2) {
|
|
4008
|
-
const
|
|
4009
|
-
const activeSessions =
|
|
4043
|
+
const db4 = getSessionDb();
|
|
4044
|
+
const activeSessions = db4.prepare("SELECT * FROM sessions WHERE status = 'active' AND assignment_slug IS NOT NULL").all();
|
|
4010
4045
|
if (activeSessions.length === 0) return 0;
|
|
4011
4046
|
const assignmentStatuses = /* @__PURE__ */ new Map();
|
|
4012
4047
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -4043,10 +4078,10 @@ async function reconcileActiveSessions(projectsDir, assignmentsDir2) {
|
|
|
4043
4078
|
return totalUpdated;
|
|
4044
4079
|
}
|
|
4045
4080
|
async function listSessionsByAssignment(projectSlug, assignmentSlug) {
|
|
4046
|
-
const
|
|
4047
|
-
const rows = projectSlug === null ?
|
|
4081
|
+
const db4 = getSessionDb();
|
|
4082
|
+
const rows = projectSlug === null ? db4.prepare(
|
|
4048
4083
|
"SELECT * FROM sessions WHERE assignment_slug = ? AND project_slug IS NULL ORDER BY started DESC"
|
|
4049
|
-
).all(assignmentSlug) :
|
|
4084
|
+
).all(assignmentSlug) : db4.prepare(
|
|
4050
4085
|
"SELECT * FROM sessions WHERE project_slug = ? AND assignment_slug = ? ORDER BY started DESC"
|
|
4051
4086
|
).all(projectSlug, assignmentSlug);
|
|
4052
4087
|
return rows.map(rowToSession);
|
|
@@ -4726,12 +4761,17 @@ async function getStatusConfig() {
|
|
|
4726
4761
|
const terminalSet = new Set(
|
|
4727
4762
|
config.statuses.statuses.filter((s) => s.terminal).map((s) => s.id)
|
|
4728
4763
|
);
|
|
4764
|
+
const hasCustomTransitions = config.statuses.transitions.length > 0;
|
|
4765
|
+
const effectiveTransitions = hasCustomTransitions ? config.statuses.transitions : Array.from(DEFAULT_TRANSITION_TABLE.entries()).map(([key, to]) => {
|
|
4766
|
+
const [from, command] = key.split(":");
|
|
4767
|
+
return { from, command, to };
|
|
4768
|
+
});
|
|
4729
4769
|
_cachedConfig = {
|
|
4730
4770
|
custom: true,
|
|
4731
4771
|
statuses: config.statuses.statuses,
|
|
4732
4772
|
order: config.statuses.order,
|
|
4733
|
-
transitions:
|
|
4734
|
-
transitionTable: buildTransitionTable(
|
|
4773
|
+
transitions: effectiveTransitions,
|
|
4774
|
+
transitionTable: buildTransitionTable(effectiveTransitions),
|
|
4735
4775
|
terminalStatuses: terminalSet.size > 0 ? terminalSet : /* @__PURE__ */ new Set(["completed", "failed"])
|
|
4736
4776
|
};
|
|
4737
4777
|
} else {
|
|
@@ -4979,16 +5019,17 @@ async function getStandaloneAvailableTransitions(assignment) {
|
|
|
4979
5019
|
const transitionDefs = getTransitionDefinitions(config);
|
|
4980
5020
|
const actions = [];
|
|
4981
5021
|
for (const definition of transitionDefs) {
|
|
5022
|
+
const target = getTargetStatus(assignment.status, definition.command, config.transitionTable);
|
|
5023
|
+
if (target === null) continue;
|
|
4982
5024
|
let warning = null;
|
|
4983
5025
|
if (definition.command === "start" && !assignment.assignee) {
|
|
4984
5026
|
warning = "No assignee set \u2014 consider assigning before starting.";
|
|
4985
5027
|
}
|
|
4986
|
-
const target = getTargetStatus(assignment.status, definition.command, config.transitionTable);
|
|
4987
5028
|
actions.push({
|
|
4988
5029
|
command: definition.command,
|
|
4989
5030
|
label: definition.label,
|
|
4990
5031
|
description: definition.description,
|
|
4991
|
-
targetStatus: target
|
|
5032
|
+
targetStatus: target,
|
|
4992
5033
|
disabled: false,
|
|
4993
5034
|
disabledReason: null,
|
|
4994
5035
|
warning,
|
|
@@ -5166,6 +5207,7 @@ async function getAssignmentDetail(projectsDir, projectSlug, assignmentSlug) {
|
|
|
5166
5207
|
slug: assignment.slug || assignmentSlug,
|
|
5167
5208
|
title: assignment.title,
|
|
5168
5209
|
status: assignment.status,
|
|
5210
|
+
type: assignment.type,
|
|
5169
5211
|
priority: assignment.priority,
|
|
5170
5212
|
assignee: assignment.assignee,
|
|
5171
5213
|
dependsOn: assignment.dependsOn,
|
|
@@ -5414,6 +5456,7 @@ async function buildStandaloneAssignmentDetail(resolved) {
|
|
|
5414
5456
|
slug: assignment.slug || resolved.id,
|
|
5415
5457
|
title: assignment.title,
|
|
5416
5458
|
status: assignment.status,
|
|
5459
|
+
type: assignment.type,
|
|
5417
5460
|
priority: assignment.priority,
|
|
5418
5461
|
assignee: assignment.assignee,
|
|
5419
5462
|
dependsOn: [],
|
|
@@ -5722,6 +5765,7 @@ function toAssignmentSummary(assignment) {
|
|
|
5722
5765
|
slug: assignment.slug,
|
|
5723
5766
|
title: assignment.title,
|
|
5724
5767
|
status: assignment.status,
|
|
5768
|
+
type: assignment.type,
|
|
5725
5769
|
priority: assignment.priority,
|
|
5726
5770
|
assignee: assignment.assignee,
|
|
5727
5771
|
dependsOn: assignment.dependsOn,
|
|
@@ -5777,6 +5821,8 @@ async function getAvailableTransitions(projectsDir, projectSlug, assignmentSlug,
|
|
|
5777
5821
|
const projectPath = resolve13(projectsDir, projectSlug);
|
|
5778
5822
|
const traces = options?.traces;
|
|
5779
5823
|
for (const definition of transitionDefs) {
|
|
5824
|
+
const target = getTargetStatus(assignment.status, definition.command, config.transitionTable);
|
|
5825
|
+
if (target === null) continue;
|
|
5780
5826
|
let warning = null;
|
|
5781
5827
|
if (definition.command === "start" && !assignment.assignee) {
|
|
5782
5828
|
warning = "No assignee set \u2014 consider assigning before starting.";
|
|
@@ -5794,12 +5840,11 @@ async function getAvailableTransitions(projectsDir, projectSlug, assignmentSlug,
|
|
|
5794
5840
|
warning = `Unmet dependencies: ${unmetDependencies.join(", ")}.`;
|
|
5795
5841
|
}
|
|
5796
5842
|
}
|
|
5797
|
-
const target = getTargetStatus(assignment.status, definition.command, config.transitionTable);
|
|
5798
5843
|
actions.push({
|
|
5799
5844
|
command: definition.command,
|
|
5800
5845
|
label: definition.label,
|
|
5801
5846
|
description: definition.description,
|
|
5802
|
-
targetStatus: target
|
|
5847
|
+
targetStatus: target,
|
|
5803
5848
|
disabled: false,
|
|
5804
5849
|
disabledReason: null,
|
|
5805
5850
|
warning,
|
|
@@ -6415,8 +6460,8 @@ init_assignment_resolver();
|
|
|
6415
6460
|
init_agent_sessions();
|
|
6416
6461
|
import express from "express";
|
|
6417
6462
|
import { createServer } from "http";
|
|
6418
|
-
import { resolve as
|
|
6419
|
-
import { writeFile as
|
|
6463
|
+
import { resolve as resolve28 } from "path";
|
|
6464
|
+
import { writeFile as writeFile6, unlink as unlink6 } from "fs/promises";
|
|
6420
6465
|
import { WebSocketServer, WebSocket } from "ws";
|
|
6421
6466
|
|
|
6422
6467
|
// src/dashboard/session-liveness.ts
|
|
@@ -6740,6 +6785,7 @@ var DENSITIES = ["comfortable", "compact"];
|
|
|
6740
6785
|
var GROUPINGS = [
|
|
6741
6786
|
"none",
|
|
6742
6787
|
"status",
|
|
6788
|
+
"type",
|
|
6743
6789
|
"priority",
|
|
6744
6790
|
"assignee",
|
|
6745
6791
|
"project"
|
|
@@ -6753,6 +6799,7 @@ var DEFAULT_VIEW_PREFS = {
|
|
|
6753
6799
|
grouping: "none",
|
|
6754
6800
|
filters: {
|
|
6755
6801
|
status: "all",
|
|
6802
|
+
type: "all",
|
|
6756
6803
|
priority: "all",
|
|
6757
6804
|
assignee: "all",
|
|
6758
6805
|
project: "all",
|
|
@@ -6909,6 +6956,7 @@ function isViewPrefsDefaults(file) {
|
|
|
6909
6956
|
const gf = g.filters;
|
|
6910
6957
|
const df = d.filters;
|
|
6911
6958
|
if (gf.status !== df.status) return false;
|
|
6959
|
+
if (gf.type !== df.type) return false;
|
|
6912
6960
|
if (gf.priority !== df.priority) return false;
|
|
6913
6961
|
if (gf.assignee !== df.assignee) return false;
|
|
6914
6962
|
if (gf.project !== df.project) return false;
|
|
@@ -6916,6 +6964,250 @@ function isViewPrefsDefaults(file) {
|
|
|
6916
6964
|
return true;
|
|
6917
6965
|
}
|
|
6918
6966
|
|
|
6967
|
+
// src/dashboard/api-saved-views.ts
|
|
6968
|
+
import { Router } from "express";
|
|
6969
|
+
|
|
6970
|
+
// src/utils/saved-views-schema.ts
|
|
6971
|
+
var TABLE_COLUMN_IDS = [
|
|
6972
|
+
"title",
|
|
6973
|
+
"status",
|
|
6974
|
+
"priority",
|
|
6975
|
+
"assignee",
|
|
6976
|
+
"dependencies",
|
|
6977
|
+
"updated"
|
|
6978
|
+
];
|
|
6979
|
+
function isTableColumnId(value) {
|
|
6980
|
+
return typeof value === "string" && TABLE_COLUMN_IDS.includes(value);
|
|
6981
|
+
}
|
|
6982
|
+
var DEFAULT_FILTERS = {
|
|
6983
|
+
status: "all",
|
|
6984
|
+
priority: "all",
|
|
6985
|
+
assignee: "all",
|
|
6986
|
+
project: "all",
|
|
6987
|
+
activity: "all"
|
|
6988
|
+
};
|
|
6989
|
+
var SEED_TIMESTAMP = "2026-05-21T00:00:00Z";
|
|
6990
|
+
function makeSeededView(id, name, filterOverrides) {
|
|
6991
|
+
return {
|
|
6992
|
+
id,
|
|
6993
|
+
name,
|
|
6994
|
+
workspace: null,
|
|
6995
|
+
config: {
|
|
6996
|
+
viewMode: "list",
|
|
6997
|
+
filters: { ...DEFAULT_FILTERS, ...filterOverrides },
|
|
6998
|
+
sortField: "updated",
|
|
6999
|
+
sortDirection: "desc",
|
|
7000
|
+
listSectionVisibility: { collapsed: [] },
|
|
7001
|
+
kanbanColumnVisibility: { hidden: [] },
|
|
7002
|
+
tableColumnVisibility: { hidden: [] }
|
|
7003
|
+
},
|
|
7004
|
+
createdAt: SEED_TIMESTAMP,
|
|
7005
|
+
updatedAt: SEED_TIMESTAMP
|
|
7006
|
+
};
|
|
7007
|
+
}
|
|
7008
|
+
var DEFAULT_SAVED_VIEWS_FILE = {
|
|
7009
|
+
version: 1,
|
|
7010
|
+
views: [
|
|
7011
|
+
makeSeededView("default-recently-updated", "Recently updated", {}),
|
|
7012
|
+
makeSeededView("default-high-priority", "High priority", { priority: "high" }),
|
|
7013
|
+
makeSeededView("default-stale", "Stale", { activity: "stale" })
|
|
7014
|
+
],
|
|
7015
|
+
dashboard: {
|
|
7016
|
+
version: 1,
|
|
7017
|
+
slots: [
|
|
7018
|
+
{ id: "slot-0", widget: { kind: "agent-sessions" } },
|
|
7019
|
+
{ id: "slot-1", widget: { kind: "saved-view", viewId: "default-recently-updated" } },
|
|
7020
|
+
{ id: "slot-2", widget: { kind: "saved-view", viewId: "default-high-priority" } },
|
|
7021
|
+
{ id: "slot-3", widget: { kind: "saved-view", viewId: "default-stale" } },
|
|
7022
|
+
{ id: "slot-4", widget: { kind: "inventories" } }
|
|
7023
|
+
]
|
|
7024
|
+
}
|
|
7025
|
+
};
|
|
7026
|
+
function isWidgetConfig(value) {
|
|
7027
|
+
if (!value || typeof value !== "object") return false;
|
|
7028
|
+
const obj = value;
|
|
7029
|
+
if (obj.kind === "saved-view") {
|
|
7030
|
+
return typeof obj.viewId === "string" && obj.viewId.length > 0;
|
|
7031
|
+
}
|
|
7032
|
+
return obj.kind === "agent-sessions" || obj.kind === "inventories";
|
|
7033
|
+
}
|
|
7034
|
+
function isListSectionVisibility(value) {
|
|
7035
|
+
if (!value || typeof value !== "object") return false;
|
|
7036
|
+
const obj = value;
|
|
7037
|
+
return Array.isArray(obj.collapsed) && obj.collapsed.every((s) => typeof s === "string");
|
|
7038
|
+
}
|
|
7039
|
+
function isKanbanColumnVisibility(value) {
|
|
7040
|
+
if (!value || typeof value !== "object") return false;
|
|
7041
|
+
const obj = value;
|
|
7042
|
+
return Array.isArray(obj.hidden) && obj.hidden.every((s) => typeof s === "string");
|
|
7043
|
+
}
|
|
7044
|
+
function isTableColumnVisibility(value) {
|
|
7045
|
+
if (!value || typeof value !== "object") return false;
|
|
7046
|
+
const obj = value;
|
|
7047
|
+
return Array.isArray(obj.hidden) && obj.hidden.every((s) => isTableColumnId(s));
|
|
7048
|
+
}
|
|
7049
|
+
function isViewFilters(value) {
|
|
7050
|
+
if (!value || typeof value !== "object") return false;
|
|
7051
|
+
const obj = value;
|
|
7052
|
+
for (const key of ["status", "priority", "assignee", "project", "activity"]) {
|
|
7053
|
+
const v = obj[key];
|
|
7054
|
+
if (v !== void 0 && typeof v !== "string") return false;
|
|
7055
|
+
}
|
|
7056
|
+
return true;
|
|
7057
|
+
}
|
|
7058
|
+
function isSavedViewConfig(value) {
|
|
7059
|
+
if (!value || typeof value !== "object") return false;
|
|
7060
|
+
const obj = value;
|
|
7061
|
+
return isViewMode(obj.viewMode) && isViewFilters(obj.filters) && isSortField(obj.sortField) && isSortDirection(obj.sortDirection) && isListSectionVisibility(obj.listSectionVisibility) && isKanbanColumnVisibility(obj.kanbanColumnVisibility) && isTableColumnVisibility(obj.tableColumnVisibility);
|
|
7062
|
+
}
|
|
7063
|
+
function isSavedView(value) {
|
|
7064
|
+
if (!value || typeof value !== "object") return false;
|
|
7065
|
+
const obj = value;
|
|
7066
|
+
return typeof obj.id === "string" && obj.id.length > 0 && typeof obj.name === "string" && (obj.workspace === null || typeof obj.workspace === "string") && isSavedViewConfig(obj.config) && typeof obj.createdAt === "string" && typeof obj.updatedAt === "string";
|
|
7067
|
+
}
|
|
7068
|
+
function isDashboardSlot(value) {
|
|
7069
|
+
if (!value || typeof value !== "object") return false;
|
|
7070
|
+
const obj = value;
|
|
7071
|
+
if (typeof obj.id !== "string" || obj.id.length === 0) return false;
|
|
7072
|
+
if (obj.widget === null) return true;
|
|
7073
|
+
return isWidgetConfig(obj.widget);
|
|
7074
|
+
}
|
|
7075
|
+
|
|
7076
|
+
// src/utils/saved-views.ts
|
|
7077
|
+
init_paths();
|
|
7078
|
+
init_fs();
|
|
7079
|
+
import { readFile as readFile12, rename as rename4, unlink as unlink4 } from "fs/promises";
|
|
7080
|
+
import { randomUUID } from "crypto";
|
|
7081
|
+
import { resolve as resolve15 } from "path";
|
|
7082
|
+
function corruptFilePath2() {
|
|
7083
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
7084
|
+
return resolve15(syntaurRoot(), `saved-views.corrupt-${ts}.json`);
|
|
7085
|
+
}
|
|
7086
|
+
function isSavedViewsFileShape(value) {
|
|
7087
|
+
if (!value || typeof value !== "object") return false;
|
|
7088
|
+
const obj = value;
|
|
7089
|
+
if (obj.version !== void 0 && obj.version !== 1) return false;
|
|
7090
|
+
if (!Array.isArray(obj.views)) return false;
|
|
7091
|
+
if (!obj.views.every(isSavedView)) return false;
|
|
7092
|
+
if (!obj.dashboard || typeof obj.dashboard !== "object") return false;
|
|
7093
|
+
const dash = obj.dashboard;
|
|
7094
|
+
if (dash.version !== void 0 && dash.version !== 1) return false;
|
|
7095
|
+
if (!Array.isArray(dash.slots)) return false;
|
|
7096
|
+
if (!dash.slots.every(isDashboardSlot)) return false;
|
|
7097
|
+
return true;
|
|
7098
|
+
}
|
|
7099
|
+
function cloneDefault() {
|
|
7100
|
+
return JSON.parse(JSON.stringify(DEFAULT_SAVED_VIEWS_FILE));
|
|
7101
|
+
}
|
|
7102
|
+
async function readSavedViewsFile() {
|
|
7103
|
+
const path = savedViewsFile();
|
|
7104
|
+
if (!await fileExists(path)) {
|
|
7105
|
+
return cloneDefault();
|
|
7106
|
+
}
|
|
7107
|
+
let raw;
|
|
7108
|
+
try {
|
|
7109
|
+
raw = await readFile12(path, "utf-8");
|
|
7110
|
+
} catch {
|
|
7111
|
+
return cloneDefault();
|
|
7112
|
+
}
|
|
7113
|
+
let parsed;
|
|
7114
|
+
try {
|
|
7115
|
+
parsed = JSON.parse(raw);
|
|
7116
|
+
} catch {
|
|
7117
|
+
await backupCorrupt2(path);
|
|
7118
|
+
return cloneDefault();
|
|
7119
|
+
}
|
|
7120
|
+
if (parsed && typeof parsed === "object" && parsed.version !== void 0) {
|
|
7121
|
+
const v = parsed.version;
|
|
7122
|
+
if (typeof v === "number" && v > 1) {
|
|
7123
|
+
console.warn(
|
|
7124
|
+
`[saved-views] Unknown version ${v} at ${path}; returning defaults (file left intact).`
|
|
7125
|
+
);
|
|
7126
|
+
return cloneDefault();
|
|
7127
|
+
}
|
|
7128
|
+
}
|
|
7129
|
+
if (!isSavedViewsFileShape(parsed)) {
|
|
7130
|
+
await backupCorrupt2(path);
|
|
7131
|
+
return cloneDefault();
|
|
7132
|
+
}
|
|
7133
|
+
const normalized = {
|
|
7134
|
+
...parsed,
|
|
7135
|
+
version: 1,
|
|
7136
|
+
dashboard: { ...parsed.dashboard, version: 1 }
|
|
7137
|
+
};
|
|
7138
|
+
return normalized;
|
|
7139
|
+
}
|
|
7140
|
+
async function backupCorrupt2(path) {
|
|
7141
|
+
const backup = corruptFilePath2();
|
|
7142
|
+
try {
|
|
7143
|
+
await rename4(path, backup);
|
|
7144
|
+
console.warn(`[saved-views] Corrupt file moved to ${backup}; using defaults.`);
|
|
7145
|
+
} catch (err) {
|
|
7146
|
+
console.warn(`[saved-views] Failed to back up corrupt file ${path}: ${String(err)}`);
|
|
7147
|
+
}
|
|
7148
|
+
}
|
|
7149
|
+
async function writeSavedViewsFile(file) {
|
|
7150
|
+
await writeFileForce(savedViewsFile(), `${JSON.stringify(file, null, 2)}
|
|
7151
|
+
`);
|
|
7152
|
+
}
|
|
7153
|
+
function createSavedView(file, input, now = () => (/* @__PURE__ */ new Date()).toISOString()) {
|
|
7154
|
+
const ts = now();
|
|
7155
|
+
const view = {
|
|
7156
|
+
id: randomUUID(),
|
|
7157
|
+
name: input.name,
|
|
7158
|
+
workspace: input.workspace,
|
|
7159
|
+
config: input.config,
|
|
7160
|
+
createdAt: ts,
|
|
7161
|
+
updatedAt: ts
|
|
7162
|
+
};
|
|
7163
|
+
const next = {
|
|
7164
|
+
...file,
|
|
7165
|
+
views: [...file.views, view]
|
|
7166
|
+
};
|
|
7167
|
+
return { file: next, view };
|
|
7168
|
+
}
|
|
7169
|
+
function updateSavedView(file, id, patch, now = () => (/* @__PURE__ */ new Date()).toISOString()) {
|
|
7170
|
+
const idx = file.views.findIndex((v) => v.id === id);
|
|
7171
|
+
if (idx < 0) return { error: "not-found" };
|
|
7172
|
+
const prev = file.views[idx];
|
|
7173
|
+
const updated = {
|
|
7174
|
+
...prev,
|
|
7175
|
+
name: patch.name ?? prev.name,
|
|
7176
|
+
workspace: patch.workspace !== void 0 ? patch.workspace : prev.workspace,
|
|
7177
|
+
config: patch.config ?? prev.config,
|
|
7178
|
+
updatedAt: now()
|
|
7179
|
+
};
|
|
7180
|
+
const views = [...file.views];
|
|
7181
|
+
views[idx] = updated;
|
|
7182
|
+
return { file: { ...file, views }, view: updated };
|
|
7183
|
+
}
|
|
7184
|
+
function deleteSavedView(file, id) {
|
|
7185
|
+
const idx = file.views.findIndex((v) => v.id === id);
|
|
7186
|
+
if (idx < 0) return { file, deleted: false };
|
|
7187
|
+
const views = file.views.filter((v) => v.id !== id);
|
|
7188
|
+
const slots = file.dashboard.slots.map((s) => {
|
|
7189
|
+
if (s.widget && s.widget.kind === "saved-view" && s.widget.viewId === id) {
|
|
7190
|
+
return { ...s, widget: null };
|
|
7191
|
+
}
|
|
7192
|
+
return s;
|
|
7193
|
+
});
|
|
7194
|
+
return {
|
|
7195
|
+
file: { ...file, views, dashboard: { ...file.dashboard, slots } },
|
|
7196
|
+
deleted: true
|
|
7197
|
+
};
|
|
7198
|
+
}
|
|
7199
|
+
function setDashboardLayout(file, slots) {
|
|
7200
|
+
const knownIds = new Set(file.views.map((v) => v.id));
|
|
7201
|
+
for (const slot of slots) {
|
|
7202
|
+
if (slot.widget && slot.widget.kind === "saved-view" && !knownIds.has(slot.widget.viewId)) {
|
|
7203
|
+
return { error: "unknown-view-id", viewId: slot.widget.viewId };
|
|
7204
|
+
}
|
|
7205
|
+
}
|
|
7206
|
+
return {
|
|
7207
|
+
file: { ...file, dashboard: { ...file.dashboard, slots } }
|
|
7208
|
+
};
|
|
7209
|
+
}
|
|
7210
|
+
|
|
6919
7211
|
// src/dashboard/todos-locks.ts
|
|
6920
7212
|
var writeLocks = /* @__PURE__ */ new Map();
|
|
6921
7213
|
function withLock(lockKey, fn) {
|
|
@@ -6944,18 +7236,199 @@ function withTwoLocks(keyA, keyB, fn) {
|
|
|
6944
7236
|
return withLock(first, () => withLock(second, fn));
|
|
6945
7237
|
}
|
|
6946
7238
|
|
|
7239
|
+
// src/dashboard/api-saved-views.ts
|
|
7240
|
+
var SAVED_VIEWS_LOCK = "sv:global";
|
|
7241
|
+
function validateCreateBody(body) {
|
|
7242
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
7243
|
+
return { ok: false, error: "body must be an object with `name`, `workspace`, and `config`" };
|
|
7244
|
+
}
|
|
7245
|
+
const obj = body;
|
|
7246
|
+
if (typeof obj.name !== "string" || obj.name.trim().length === 0 || obj.name.length > 200) {
|
|
7247
|
+
return { ok: false, error: "name must be a non-empty string up to 200 characters" };
|
|
7248
|
+
}
|
|
7249
|
+
if (obj.workspace !== null && (typeof obj.workspace !== "string" || obj.workspace.length === 0)) {
|
|
7250
|
+
return { ok: false, error: "workspace must be a non-empty string or null" };
|
|
7251
|
+
}
|
|
7252
|
+
if (!isSavedViewConfig(obj.config)) {
|
|
7253
|
+
return { ok: false, error: "config must be a valid SavedViewConfig" };
|
|
7254
|
+
}
|
|
7255
|
+
return {
|
|
7256
|
+
ok: true,
|
|
7257
|
+
value: {
|
|
7258
|
+
name: obj.name.trim(),
|
|
7259
|
+
workspace: obj.workspace,
|
|
7260
|
+
config: obj.config
|
|
7261
|
+
}
|
|
7262
|
+
};
|
|
7263
|
+
}
|
|
7264
|
+
function validateUpdateBody(body) {
|
|
7265
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
7266
|
+
return { ok: false, error: "body must be an object" };
|
|
7267
|
+
}
|
|
7268
|
+
const obj = body;
|
|
7269
|
+
const patch = {};
|
|
7270
|
+
if (obj.name !== void 0) {
|
|
7271
|
+
if (typeof obj.name !== "string" || obj.name.trim().length === 0 || obj.name.length > 200) {
|
|
7272
|
+
return { ok: false, error: "name must be a non-empty string up to 200 characters" };
|
|
7273
|
+
}
|
|
7274
|
+
patch.name = obj.name.trim();
|
|
7275
|
+
}
|
|
7276
|
+
if (obj.workspace !== void 0) {
|
|
7277
|
+
if (obj.workspace !== null && (typeof obj.workspace !== "string" || obj.workspace.length === 0)) {
|
|
7278
|
+
return { ok: false, error: "workspace must be a non-empty string or null" };
|
|
7279
|
+
}
|
|
7280
|
+
patch.workspace = obj.workspace;
|
|
7281
|
+
}
|
|
7282
|
+
if (obj.config !== void 0) {
|
|
7283
|
+
if (!isSavedViewConfig(obj.config)) {
|
|
7284
|
+
return { ok: false, error: "config must be a valid SavedViewConfig" };
|
|
7285
|
+
}
|
|
7286
|
+
patch.config = obj.config;
|
|
7287
|
+
}
|
|
7288
|
+
if (Object.keys(patch).length === 0) {
|
|
7289
|
+
return { ok: false, error: "patch must include at least one of name, workspace, or config" };
|
|
7290
|
+
}
|
|
7291
|
+
return { ok: true, value: patch };
|
|
7292
|
+
}
|
|
7293
|
+
function validateDashboardBody(body) {
|
|
7294
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
7295
|
+
return { ok: false, error: "body must be an object with a `slots` array" };
|
|
7296
|
+
}
|
|
7297
|
+
const obj = body;
|
|
7298
|
+
if (!Array.isArray(obj.slots)) {
|
|
7299
|
+
return { ok: false, error: "slots must be an array" };
|
|
7300
|
+
}
|
|
7301
|
+
if (!obj.slots.every(isDashboardSlot)) {
|
|
7302
|
+
return { ok: false, error: "every slot must be { id: string, widget: WidgetConfig | null }" };
|
|
7303
|
+
}
|
|
7304
|
+
return { ok: true, value: obj.slots };
|
|
7305
|
+
}
|
|
7306
|
+
function createSavedViewsRouter() {
|
|
7307
|
+
const router = Router();
|
|
7308
|
+
router.get("/", async (_req, res) => {
|
|
7309
|
+
try {
|
|
7310
|
+
const file = await readSavedViewsFile();
|
|
7311
|
+
res.json(file);
|
|
7312
|
+
} catch (error) {
|
|
7313
|
+
console.error("Error reading saved-views:", error);
|
|
7314
|
+
res.status(500).json({ error: "Failed to read saved-views" });
|
|
7315
|
+
}
|
|
7316
|
+
});
|
|
7317
|
+
router.post("/", async (req, res) => {
|
|
7318
|
+
const result = validateCreateBody(req.body);
|
|
7319
|
+
if (!result.ok) {
|
|
7320
|
+
res.status(400).json({ error: result.error });
|
|
7321
|
+
return;
|
|
7322
|
+
}
|
|
7323
|
+
try {
|
|
7324
|
+
const file = await withLock(SAVED_VIEWS_LOCK, async () => {
|
|
7325
|
+
const current = await readSavedViewsFile();
|
|
7326
|
+
const next = createSavedView(current, result.value);
|
|
7327
|
+
await writeSavedViewsFile(next.file);
|
|
7328
|
+
return next.file;
|
|
7329
|
+
});
|
|
7330
|
+
res.status(201).json(file);
|
|
7331
|
+
} catch (error) {
|
|
7332
|
+
console.error("Error creating saved-view:", error);
|
|
7333
|
+
res.status(500).json({ error: "Failed to create saved-view" });
|
|
7334
|
+
}
|
|
7335
|
+
});
|
|
7336
|
+
router.patch("/:id", async (req, res) => {
|
|
7337
|
+
const idParam = req.params.id;
|
|
7338
|
+
if (typeof idParam !== "string" || idParam.length === 0) {
|
|
7339
|
+
res.status(400).json({ error: "id required" });
|
|
7340
|
+
return;
|
|
7341
|
+
}
|
|
7342
|
+
const result = validateUpdateBody(req.body);
|
|
7343
|
+
if (!result.ok) {
|
|
7344
|
+
res.status(400).json({ error: result.error });
|
|
7345
|
+
return;
|
|
7346
|
+
}
|
|
7347
|
+
try {
|
|
7348
|
+
const outcome = await withLock(SAVED_VIEWS_LOCK, async () => {
|
|
7349
|
+
const current = await readSavedViewsFile();
|
|
7350
|
+
const next = updateSavedView(current, idParam, result.value);
|
|
7351
|
+
if ("error" in next) return { kind: "not-found" };
|
|
7352
|
+
await writeSavedViewsFile(next.file);
|
|
7353
|
+
return { kind: "ok", file: next.file };
|
|
7354
|
+
});
|
|
7355
|
+
if (outcome.kind === "not-found") {
|
|
7356
|
+
res.status(404).json({ error: "view-not-found" });
|
|
7357
|
+
return;
|
|
7358
|
+
}
|
|
7359
|
+
res.json(outcome.file);
|
|
7360
|
+
} catch (error) {
|
|
7361
|
+
console.error("Error updating saved-view:", error);
|
|
7362
|
+
res.status(500).json({ error: "Failed to update saved-view" });
|
|
7363
|
+
}
|
|
7364
|
+
});
|
|
7365
|
+
router.delete("/:id", async (req, res) => {
|
|
7366
|
+
const idParam = req.params.id;
|
|
7367
|
+
if (typeof idParam !== "string" || idParam.length === 0) {
|
|
7368
|
+
res.status(400).json({ error: "id required" });
|
|
7369
|
+
return;
|
|
7370
|
+
}
|
|
7371
|
+
try {
|
|
7372
|
+
const outcome = await withLock(SAVED_VIEWS_LOCK, async () => {
|
|
7373
|
+
const current = await readSavedViewsFile();
|
|
7374
|
+
const next = deleteSavedView(current, idParam);
|
|
7375
|
+
if (!next.deleted) return { kind: "not-found" };
|
|
7376
|
+
await writeSavedViewsFile(next.file);
|
|
7377
|
+
return { kind: "ok", file: next.file };
|
|
7378
|
+
});
|
|
7379
|
+
if (outcome.kind === "not-found") {
|
|
7380
|
+
res.status(404).json({ error: "view-not-found" });
|
|
7381
|
+
return;
|
|
7382
|
+
}
|
|
7383
|
+
res.json(outcome.file);
|
|
7384
|
+
} catch (error) {
|
|
7385
|
+
console.error("Error deleting saved-view:", error);
|
|
7386
|
+
res.status(500).json({ error: "Failed to delete saved-view" });
|
|
7387
|
+
}
|
|
7388
|
+
});
|
|
7389
|
+
return router;
|
|
7390
|
+
}
|
|
7391
|
+
function createDashboardLayoutRouter() {
|
|
7392
|
+
const router = Router();
|
|
7393
|
+
router.put("/", async (req, res) => {
|
|
7394
|
+
const result = validateDashboardBody(req.body);
|
|
7395
|
+
if (!result.ok) {
|
|
7396
|
+
res.status(400).json({ error: result.error });
|
|
7397
|
+
return;
|
|
7398
|
+
}
|
|
7399
|
+
try {
|
|
7400
|
+
const outcome = await withLock(SAVED_VIEWS_LOCK, async () => {
|
|
7401
|
+
const current = await readSavedViewsFile();
|
|
7402
|
+
const next = setDashboardLayout(current, result.value);
|
|
7403
|
+
if ("error" in next) return { kind: "bad-ref", viewId: next.viewId };
|
|
7404
|
+
await writeSavedViewsFile(next.file);
|
|
7405
|
+
return { kind: "ok", file: next.file };
|
|
7406
|
+
});
|
|
7407
|
+
if (outcome.kind === "bad-ref") {
|
|
7408
|
+
res.status(400).json({ error: "unknown-view-id", viewId: outcome.viewId });
|
|
7409
|
+
return;
|
|
7410
|
+
}
|
|
7411
|
+
res.json(outcome.file);
|
|
7412
|
+
} catch (error) {
|
|
7413
|
+
console.error("Error updating dashboard:", error);
|
|
7414
|
+
res.status(500).json({ error: "Failed to update dashboard" });
|
|
7415
|
+
}
|
|
7416
|
+
});
|
|
7417
|
+
return router;
|
|
7418
|
+
}
|
|
7419
|
+
|
|
6947
7420
|
// src/dashboard/api-write.ts
|
|
6948
7421
|
init_lifecycle();
|
|
6949
7422
|
init_slug();
|
|
6950
|
-
import { Router } from "express";
|
|
6951
|
-
import { resolve as
|
|
6952
|
-
import { rm, readFile as
|
|
7423
|
+
import { Router as Router2 } from "express";
|
|
7424
|
+
import { resolve as resolve18, basename as basename3, isAbsolute as isAbsolute2 } from "path";
|
|
7425
|
+
import { rm, readFile as readFile15, open as fsOpen, stat as fsStat, realpath as fsRealpath } from "fs/promises";
|
|
6953
7426
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
6954
7427
|
|
|
6955
7428
|
// src/utils/uuid.ts
|
|
6956
|
-
import { randomUUID } from "crypto";
|
|
7429
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
6957
7430
|
function generateId() {
|
|
6958
|
-
return
|
|
7431
|
+
return randomUUID2();
|
|
6959
7432
|
}
|
|
6960
7433
|
|
|
6961
7434
|
// src/dashboard/api-write.ts
|
|
@@ -6966,7 +7439,7 @@ init_fs();
|
|
|
6966
7439
|
init_frontmatter();
|
|
6967
7440
|
init_fs();
|
|
6968
7441
|
import { spawn } from "child_process";
|
|
6969
|
-
import { readFile as
|
|
7442
|
+
import { readFile as readFile13 } from "fs/promises";
|
|
6970
7443
|
function run(command, args, cwd) {
|
|
6971
7444
|
return new Promise((resolvePromise) => {
|
|
6972
7445
|
const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -7016,7 +7489,7 @@ async function createWorktreeAndRecord(opts) {
|
|
|
7016
7489
|
const { assignmentPath, repository, branch, worktreePath, parentBranch } = opts;
|
|
7017
7490
|
await createWorktree({ repository, branch, worktreePath, parentBranch });
|
|
7018
7491
|
try {
|
|
7019
|
-
const content = await
|
|
7492
|
+
const content = await readFile13(assignmentPath, "utf-8");
|
|
7020
7493
|
const updated = updateAssignmentWorkspace(content, {
|
|
7021
7494
|
repository,
|
|
7022
7495
|
worktreePath,
|
|
@@ -7041,29 +7514,30 @@ async function createWorktreeAndRecord(opts) {
|
|
|
7041
7514
|
}
|
|
7042
7515
|
function formatRollbackError(opts) {
|
|
7043
7516
|
const { writeMsg, worktreePath, branch, worktreeCleanup, branchCleanup } = opts;
|
|
7517
|
+
const subject = opts.subject ?? "assignment frontmatter";
|
|
7044
7518
|
const wtMsg = worktreeCleanup.stderr.trim() || "(no stderr)";
|
|
7045
7519
|
const brMsg = branchCleanup.stderr.trim() || "(no stderr)";
|
|
7046
7520
|
if (!worktreeCleanup.ok && !branchCleanup.ok) {
|
|
7047
|
-
return `Failed to update
|
|
7521
|
+
return `Failed to update ${subject} AND failed to clean up both worktree and branch. Write error: ${writeMsg}. Worktree cleanup error: ${wtMsg}. Branch cleanup error: ${brMsg}. Orphan worktree at ${worktreePath} and orphan branch "${branch}" \u2014 remove them manually.`;
|
|
7048
7522
|
}
|
|
7049
7523
|
if (!worktreeCleanup.ok) {
|
|
7050
|
-
return `Failed to update
|
|
7524
|
+
return `Failed to update ${subject} AND failed to clean up worktree. Write error: ${writeMsg}. Worktree cleanup error: ${wtMsg}. Orphan worktree at ${worktreePath} \u2014 remove it manually.`;
|
|
7051
7525
|
}
|
|
7052
7526
|
if (!branchCleanup.ok) {
|
|
7053
|
-
return `Failed to update
|
|
7527
|
+
return `Failed to update ${subject}: ${writeMsg}. Rolled back git worktree at ${worktreePath}, but could not delete branch "${branch}": ${brMsg}. Remove the branch manually.`;
|
|
7054
7528
|
}
|
|
7055
|
-
return `Failed to update
|
|
7529
|
+
return `Failed to update ${subject}: ${writeMsg}. Rolled back git worktree at ${worktreePath} and branch "${branch}".`;
|
|
7056
7530
|
}
|
|
7057
7531
|
|
|
7058
7532
|
// src/utils/worktree-defaults.ts
|
|
7059
7533
|
init_paths();
|
|
7060
7534
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
7061
|
-
import { resolve as
|
|
7535
|
+
import { resolve as resolve16 } from "path";
|
|
7062
7536
|
function computeWorktreeDefaults(opts) {
|
|
7063
7537
|
const repository = opts.existing.repository ?? detectCurrentGitRoot(opts.cwd);
|
|
7064
7538
|
const branch = opts.projectSlug ? `syntaur/${opts.projectSlug}/${opts.assignmentSlug}` : `syntaur/${opts.assignmentSlug}`;
|
|
7065
7539
|
const parentBranch = opts.existing.parentBranch ?? detectCurrentBranch(opts.cwd) ?? "main";
|
|
7066
|
-
const worktreeBase = repository ?
|
|
7540
|
+
const worktreeBase = repository ? resolve16(repository, ".worktrees", branch) : resolve16(
|
|
7067
7541
|
syntaurRoot(),
|
|
7068
7542
|
"worktrees",
|
|
7069
7543
|
opts.projectSlug || "standalone",
|
|
@@ -7099,34 +7573,34 @@ function detectCurrentBranch(cwd) {
|
|
|
7099
7573
|
// src/dashboard/repository-candidates.ts
|
|
7100
7574
|
init_fs();
|
|
7101
7575
|
init_parser();
|
|
7102
|
-
import { readdir as readdir9, readFile as
|
|
7103
|
-
import { resolve as
|
|
7576
|
+
import { readdir as readdir9, readFile as readFile14 } from "fs/promises";
|
|
7577
|
+
import { resolve as resolve17 } from "path";
|
|
7104
7578
|
async function getProjectRepositoryCandidates(projectsDir, projectSlug) {
|
|
7105
7579
|
const seen = /* @__PURE__ */ new Set();
|
|
7106
7580
|
const out = [];
|
|
7107
|
-
const projectPath =
|
|
7581
|
+
const projectPath = resolve17(projectsDir, projectSlug, "project.md");
|
|
7108
7582
|
if (await fileExists(projectPath)) {
|
|
7109
|
-
const project = parseProject(await
|
|
7583
|
+
const project = parseProject(await readFile14(projectPath, "utf-8"));
|
|
7110
7584
|
for (const raw of project.repositories) {
|
|
7111
7585
|
const path = raw.trim();
|
|
7112
7586
|
if (!path) continue;
|
|
7113
|
-
const abs =
|
|
7587
|
+
const abs = resolve17(path);
|
|
7114
7588
|
if (seen.has(abs)) continue;
|
|
7115
7589
|
seen.add(abs);
|
|
7116
7590
|
out.push({ path: abs, source: "project", sourceAssignmentSlug: null });
|
|
7117
7591
|
}
|
|
7118
7592
|
}
|
|
7119
|
-
const assignmentsDir2 =
|
|
7593
|
+
const assignmentsDir2 = resolve17(projectsDir, projectSlug, "assignments");
|
|
7120
7594
|
if (await fileExists(assignmentsDir2)) {
|
|
7121
7595
|
const entries = await readdir9(assignmentsDir2, { withFileTypes: true });
|
|
7122
7596
|
for (const entry of entries) {
|
|
7123
7597
|
if (!entry.isDirectory()) continue;
|
|
7124
|
-
const assignmentMd =
|
|
7598
|
+
const assignmentMd = resolve17(assignmentsDir2, entry.name, "assignment.md");
|
|
7125
7599
|
if (!await fileExists(assignmentMd)) continue;
|
|
7126
|
-
const parsed = parseAssignmentFull(await
|
|
7600
|
+
const parsed = parseAssignmentFull(await readFile14(assignmentMd, "utf-8"));
|
|
7127
7601
|
const repo = parsed.workspace.repository?.trim();
|
|
7128
7602
|
if (!repo) continue;
|
|
7129
|
-
const abs =
|
|
7603
|
+
const abs = resolve17(repo);
|
|
7130
7604
|
if (seen.has(abs)) continue;
|
|
7131
7605
|
seen.add(abs);
|
|
7132
7606
|
out.push({ path: abs, source: "sibling", sourceAssignmentSlug: parsed.slug });
|
|
@@ -7144,12 +7618,12 @@ async function getStandaloneRepositoryCandidates(assignmentsDir2, excludeAssignm
|
|
|
7144
7618
|
for (const entry of entries) {
|
|
7145
7619
|
if (!entry.isDirectory()) continue;
|
|
7146
7620
|
if (entry.name === excludeAssignmentId) continue;
|
|
7147
|
-
const assignmentMd =
|
|
7621
|
+
const assignmentMd = resolve17(assignmentsDir2, entry.name, "assignment.md");
|
|
7148
7622
|
if (!await fileExists(assignmentMd)) continue;
|
|
7149
|
-
const parsed = parseAssignmentFull(await
|
|
7623
|
+
const parsed = parseAssignmentFull(await readFile14(assignmentMd, "utf-8"));
|
|
7150
7624
|
const repo = parsed.workspace.repository?.trim();
|
|
7151
7625
|
if (!repo) continue;
|
|
7152
|
-
const abs =
|
|
7626
|
+
const abs = resolve17(repo);
|
|
7153
7627
|
if (seen.has(abs)) continue;
|
|
7154
7628
|
seen.add(abs);
|
|
7155
7629
|
out.push({ path: abs, source: "sibling", sourceAssignmentSlug: parsed.slug });
|
|
@@ -7712,14 +8186,14 @@ async function readCurrentDocument(filePath) {
|
|
|
7712
8186
|
if (!await fileExists(filePath)) {
|
|
7713
8187
|
return null;
|
|
7714
8188
|
}
|
|
7715
|
-
return
|
|
8189
|
+
return readFile15(filePath, "utf-8");
|
|
7716
8190
|
}
|
|
7717
8191
|
async function handleWorktreeCreate(req, res, ctx) {
|
|
7718
8192
|
if (!await fileExists(ctx.assignmentPath)) {
|
|
7719
8193
|
res.status(404).json({ error: "Assignment not found" });
|
|
7720
8194
|
return;
|
|
7721
8195
|
}
|
|
7722
|
-
const parsed = parseAssignmentFull(await
|
|
8196
|
+
const parsed = parseAssignmentFull(await readFile15(ctx.assignmentPath, "utf-8"));
|
|
7723
8197
|
if (parsed.workspace.worktreePath) {
|
|
7724
8198
|
res.status(409).json({ error: "Worktree already configured for this assignment" });
|
|
7725
8199
|
return;
|
|
@@ -7769,7 +8243,7 @@ async function handleWorktreeCreate(req, res, ctx) {
|
|
|
7769
8243
|
});
|
|
7770
8244
|
const branch = typeof bodyBranch === "string" && bodyBranch.trim() ? bodyBranch.trim() : defaults.branch;
|
|
7771
8245
|
const parentBranch = typeof bodyParent === "string" && bodyParent.trim() ? bodyParent.trim() : defaults.parentBranch;
|
|
7772
|
-
const worktreePath =
|
|
8246
|
+
const worktreePath = resolve18(repository, ".worktrees", branch);
|
|
7773
8247
|
try {
|
|
7774
8248
|
await fsStat(worktreePath);
|
|
7775
8249
|
res.status(409).json({
|
|
@@ -7810,7 +8284,7 @@ async function handleWorktreeCreate(req, res, ctx) {
|
|
|
7810
8284
|
}
|
|
7811
8285
|
function createWriteRouter(projectsDir, assignmentsDir2, todosDir2) {
|
|
7812
8286
|
const linkedTodosLookup = todosDir2 ? { todosDir: todosDir2, projectsDir } : void 0;
|
|
7813
|
-
const router =
|
|
8287
|
+
const router = Router2();
|
|
7814
8288
|
router.get("/api/templates/project", (_req, res) => {
|
|
7815
8289
|
const content = renderProject({
|
|
7816
8290
|
id: generateId(),
|
|
@@ -8035,9 +8509,9 @@ ${body.startsWith("\n") ? body.slice(1) : body}${body.endsWith("\n") ? "" : "\n"
|
|
|
8035
8509
|
});
|
|
8036
8510
|
return;
|
|
8037
8511
|
}
|
|
8038
|
-
const folderPath =
|
|
8512
|
+
const folderPath = resolve18(projectDir, folder);
|
|
8039
8513
|
await ensureDir(folderPath);
|
|
8040
|
-
const filePath =
|
|
8514
|
+
const filePath = resolve18(folderPath, `${requestedSlug}.md`);
|
|
8041
8515
|
const timestamp = nowTimestamp();
|
|
8042
8516
|
let content = renderItemStub(kind, {
|
|
8043
8517
|
slug: requestedSlug,
|
|
@@ -8081,14 +8555,14 @@ ${body.startsWith("\n") ? body.slice(1) : body}${body.endsWith("\n") ? "" : "\n"
|
|
|
8081
8555
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
8082
8556
|
return;
|
|
8083
8557
|
}
|
|
8084
|
-
const filePath =
|
|
8558
|
+
const filePath = resolve18(projectDir, folder, `${itemSlug}.md`);
|
|
8085
8559
|
if (!await fileExists(filePath)) {
|
|
8086
8560
|
res.status(404).json({ error: `${kind === "memory" ? "Memory" : "Resource"} not found` });
|
|
8087
8561
|
return;
|
|
8088
8562
|
}
|
|
8089
8563
|
const nextContentRaw = requireContent(req, res);
|
|
8090
8564
|
if (!nextContentRaw) return;
|
|
8091
|
-
const currentContent = await
|
|
8565
|
+
const currentContent = await readFile15(filePath, "utf-8");
|
|
8092
8566
|
const frontmatterBlock = extractFrontmatterBlock(currentContent);
|
|
8093
8567
|
if (!frontmatterBlock) {
|
|
8094
8568
|
res.status(500).json({ error: `${kind} file is malformed (no frontmatter)` });
|
|
@@ -8117,7 +8591,7 @@ ${nextBody}${nextBody.endsWith("\n") ? "" : "\n"}`;
|
|
|
8117
8591
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
8118
8592
|
return;
|
|
8119
8593
|
}
|
|
8120
|
-
const filePath =
|
|
8594
|
+
const filePath = resolve18(projectDir, folder, `${itemSlug}.md`);
|
|
8121
8595
|
if (!await fileExists(filePath)) {
|
|
8122
8596
|
res.status(404).json({ error: `${kind === "memory" ? "Memory" : "Resource"} not found` });
|
|
8123
8597
|
return;
|
|
@@ -8151,26 +8625,26 @@ ${nextBody}${nextBody.endsWith("\n") ? "" : "\n"}`;
|
|
|
8151
8625
|
res.status(400).json({ error: `Invalid slug "${slug}". Must be lowercase and hyphen-separated.` });
|
|
8152
8626
|
return;
|
|
8153
8627
|
}
|
|
8154
|
-
const projectDir =
|
|
8628
|
+
const projectDir = resolve18(projectsDir, slug);
|
|
8155
8629
|
if (await fileExists(projectDir)) {
|
|
8156
8630
|
res.status(409).json({ error: `Project "${slug}" already exists` });
|
|
8157
8631
|
return;
|
|
8158
8632
|
}
|
|
8159
8633
|
const title = fields.title;
|
|
8160
8634
|
const timestamp = fields.created || nowTimestamp();
|
|
8161
|
-
await ensureDir(
|
|
8162
|
-
await ensureDir(
|
|
8163
|
-
await ensureDir(
|
|
8164
|
-
await writeFileForce(
|
|
8635
|
+
await ensureDir(resolve18(projectDir, "assignments"));
|
|
8636
|
+
await ensureDir(resolve18(projectDir, "resources"));
|
|
8637
|
+
await ensureDir(resolve18(projectDir, "memories"));
|
|
8638
|
+
await writeFileForce(resolve18(projectDir, "project.md"), content);
|
|
8165
8639
|
try {
|
|
8166
8640
|
const companions = [
|
|
8167
|
-
[
|
|
8168
|
-
[
|
|
8169
|
-
[
|
|
8170
|
-
[
|
|
8171
|
-
[
|
|
8172
|
-
[
|
|
8173
|
-
[
|
|
8641
|
+
[resolve18(projectDir, "manifest.md"), renderManifest({ slug, timestamp })],
|
|
8642
|
+
[resolve18(projectDir, "_index-assignments.md"), renderIndexAssignments({ slug, title, timestamp })],
|
|
8643
|
+
[resolve18(projectDir, "_index-plans.md"), renderIndexPlans({ slug, title, timestamp })],
|
|
8644
|
+
[resolve18(projectDir, "_index-decisions.md"), renderIndexDecisions({ slug, title, timestamp })],
|
|
8645
|
+
[resolve18(projectDir, "_status.md"), renderStatus({ slug, title, timestamp })],
|
|
8646
|
+
[resolve18(projectDir, "resources", "_index.md"), renderResourcesIndex({ slug, title, timestamp })],
|
|
8647
|
+
[resolve18(projectDir, "memories", "_index.md"), renderMemoriesIndex({ slug, title, timestamp })]
|
|
8174
8648
|
];
|
|
8175
8649
|
for (const [filePath, fileContent] of companions) {
|
|
8176
8650
|
await writeFileForce(filePath, fileContent);
|
|
@@ -8191,8 +8665,8 @@ ${nextBody}${nextBody.endsWith("\n") ? "" : "\n"}`;
|
|
|
8191
8665
|
router.post("/api/projects/:slug/assignments", async (req, res) => {
|
|
8192
8666
|
try {
|
|
8193
8667
|
const projectSlug = getParam(req.params.slug);
|
|
8194
|
-
const projectDir =
|
|
8195
|
-
const projectMdPath =
|
|
8668
|
+
const projectDir = resolve18(projectsDir, projectSlug);
|
|
8669
|
+
const projectMdPath = resolve18(projectDir, "project.md");
|
|
8196
8670
|
if (!await fileExists(projectMdPath)) {
|
|
8197
8671
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
8198
8672
|
return;
|
|
@@ -8222,7 +8696,7 @@ ${nextBody}${nextBody.endsWith("\n") ? "" : "\n"}`;
|
|
|
8222
8696
|
res.status(400).json({ error: `Invalid priority "${priority}". Must be low, medium, high, or critical.` });
|
|
8223
8697
|
return;
|
|
8224
8698
|
}
|
|
8225
|
-
const assignmentDir =
|
|
8699
|
+
const assignmentDir = resolve18(projectDir, "assignments", assignmentSlug);
|
|
8226
8700
|
if (await fileExists(assignmentDir)) {
|
|
8227
8701
|
res.status(409).json({
|
|
8228
8702
|
error: `Assignment "${assignmentSlug}" already exists in project "${projectSlug}"`
|
|
@@ -8231,12 +8705,12 @@ ${nextBody}${nextBody.endsWith("\n") ? "" : "\n"}`;
|
|
|
8231
8705
|
}
|
|
8232
8706
|
const timestamp = fields.created || nowTimestamp();
|
|
8233
8707
|
await ensureDir(assignmentDir);
|
|
8234
|
-
await writeFileForce(
|
|
8708
|
+
await writeFileForce(resolve18(assignmentDir, "assignment.md"), content);
|
|
8235
8709
|
try {
|
|
8236
8710
|
const companions = [
|
|
8237
|
-
[
|
|
8238
|
-
[
|
|
8239
|
-
[
|
|
8711
|
+
[resolve18(assignmentDir, "scratchpad.md"), renderScratchpad({ assignmentSlug, timestamp })],
|
|
8712
|
+
[resolve18(assignmentDir, "handoff.md"), renderHandoff({ assignmentSlug, timestamp })],
|
|
8713
|
+
[resolve18(assignmentDir, "decision-record.md"), renderDecisionRecord({ assignmentSlug, timestamp })]
|
|
8240
8714
|
];
|
|
8241
8715
|
for (const [filePath, fileContent] of companions) {
|
|
8242
8716
|
await writeFileForce(filePath, fileContent);
|
|
@@ -8257,7 +8731,7 @@ ${nextBody}${nextBody.endsWith("\n") ? "" : "\n"}`;
|
|
|
8257
8731
|
router.patch("/api/projects/:slug", async (req, res) => {
|
|
8258
8732
|
try {
|
|
8259
8733
|
const projectSlug = getParam(req.params.slug);
|
|
8260
|
-
const projectPath =
|
|
8734
|
+
const projectPath = resolve18(projectsDir, projectSlug, "project.md");
|
|
8261
8735
|
const currentContent = await readCurrentDocument(projectPath);
|
|
8262
8736
|
if (!currentContent) {
|
|
8263
8737
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
@@ -8290,7 +8764,7 @@ ${nextBody}${nextBody.endsWith("\n") ? "" : "\n"}`;
|
|
|
8290
8764
|
try {
|
|
8291
8765
|
const projectSlug = getParam(req.params.slug);
|
|
8292
8766
|
const assignmentSlug = getParam(req.params.aslug);
|
|
8293
|
-
const assignmentPath =
|
|
8767
|
+
const assignmentPath = resolve18(
|
|
8294
8768
|
projectsDir,
|
|
8295
8769
|
projectSlug,
|
|
8296
8770
|
"assignments",
|
|
@@ -8333,7 +8807,7 @@ ${nextBody}${nextBody.endsWith("\n") ? "" : "\n"}`;
|
|
|
8333
8807
|
try {
|
|
8334
8808
|
const projectSlug = getParam(req.params.slug);
|
|
8335
8809
|
const assignmentSlug = getParam(req.params.aslug);
|
|
8336
|
-
const assignmentPath =
|
|
8810
|
+
const assignmentPath = resolve18(
|
|
8337
8811
|
projectsDir,
|
|
8338
8812
|
projectSlug,
|
|
8339
8813
|
"assignments",
|
|
@@ -8369,7 +8843,7 @@ ${nextBody}${nextBody.endsWith("\n") ? "" : "\n"}`;
|
|
|
8369
8843
|
try {
|
|
8370
8844
|
const projectSlug = getParam(req.params.slug);
|
|
8371
8845
|
const assignmentSlug = getParam(req.params.aslug);
|
|
8372
|
-
const planPath =
|
|
8846
|
+
const planPath = resolve18(
|
|
8373
8847
|
projectsDir,
|
|
8374
8848
|
projectSlug,
|
|
8375
8849
|
"assignments",
|
|
@@ -8407,7 +8881,7 @@ ${nextBody}${nextBody.endsWith("\n") ? "" : "\n"}`;
|
|
|
8407
8881
|
try {
|
|
8408
8882
|
const projectSlug = getParam(req.params.slug);
|
|
8409
8883
|
const assignmentSlug = getParam(req.params.aslug);
|
|
8410
|
-
const scratchpadPath =
|
|
8884
|
+
const scratchpadPath = resolve18(
|
|
8411
8885
|
projectsDir,
|
|
8412
8886
|
projectSlug,
|
|
8413
8887
|
"assignments",
|
|
@@ -8445,7 +8919,7 @@ ${nextBody}${nextBody.endsWith("\n") ? "" : "\n"}`;
|
|
|
8445
8919
|
try {
|
|
8446
8920
|
const projectSlug = getParam(req.params.slug);
|
|
8447
8921
|
const assignmentSlug = getParam(req.params.aslug);
|
|
8448
|
-
const handoffPath =
|
|
8922
|
+
const handoffPath = resolve18(
|
|
8449
8923
|
projectsDir,
|
|
8450
8924
|
projectSlug,
|
|
8451
8925
|
"assignments",
|
|
@@ -8483,7 +8957,7 @@ ${nextBody}${nextBody.endsWith("\n") ? "" : "\n"}`;
|
|
|
8483
8957
|
try {
|
|
8484
8958
|
const projectSlug = getParam(req.params.slug);
|
|
8485
8959
|
const assignmentSlug = getParam(req.params.aslug);
|
|
8486
|
-
const decisionPath =
|
|
8960
|
+
const decisionPath = resolve18(
|
|
8487
8961
|
projectsDir,
|
|
8488
8962
|
projectSlug,
|
|
8489
8963
|
"assignments",
|
|
@@ -8521,7 +8995,7 @@ ${nextBody}${nextBody.endsWith("\n") ? "" : "\n"}`;
|
|
|
8521
8995
|
try {
|
|
8522
8996
|
const projectSlug = getParam(req.params.slug);
|
|
8523
8997
|
const assignmentSlug = getParam(req.params.aslug);
|
|
8524
|
-
const commentsPath =
|
|
8998
|
+
const commentsPath = resolve18(
|
|
8525
8999
|
projectsDir,
|
|
8526
9000
|
projectSlug,
|
|
8527
9001
|
"assignments",
|
|
@@ -8539,7 +9013,7 @@ ${nextBody}${nextBody.endsWith("\n") ? "" : "\n"}`;
|
|
|
8539
9013
|
let currentContent;
|
|
8540
9014
|
let currentCount = 0;
|
|
8541
9015
|
if (await fileExists(commentsPath)) {
|
|
8542
|
-
currentContent = await
|
|
9016
|
+
currentContent = await readFile15(commentsPath, "utf-8");
|
|
8543
9017
|
const countMatch = currentContent.match(/^entryCount:\s*(\d+)/m);
|
|
8544
9018
|
if (countMatch) currentCount = parseInt(countMatch[1], 10);
|
|
8545
9019
|
} else {
|
|
@@ -8580,7 +9054,7 @@ ${entry}`;
|
|
|
8580
9054
|
const projectSlug = getParam(req.params.slug);
|
|
8581
9055
|
const assignmentSlug = getParam(req.params.aslug);
|
|
8582
9056
|
const commentId = getParam(req.params.commentId);
|
|
8583
|
-
const commentsPath =
|
|
9057
|
+
const commentsPath = resolve18(
|
|
8584
9058
|
projectsDir,
|
|
8585
9059
|
projectSlug,
|
|
8586
9060
|
"assignments",
|
|
@@ -8596,7 +9070,7 @@ ${entry}`;
|
|
|
8596
9070
|
res.status(400).json({ error: "resolved (boolean) is required" });
|
|
8597
9071
|
return;
|
|
8598
9072
|
}
|
|
8599
|
-
const content = await
|
|
9073
|
+
const content = await readFile15(commentsPath, "utf-8");
|
|
8600
9074
|
const parsed = parseComments(content);
|
|
8601
9075
|
const target = parsed.entries.find((e) => e.id === commentId);
|
|
8602
9076
|
if (!target) {
|
|
@@ -8631,7 +9105,7 @@ ${entry}`;
|
|
|
8631
9105
|
router.post("/api/projects/:slug/move-workspace", async (req, res) => {
|
|
8632
9106
|
try {
|
|
8633
9107
|
const projectSlug = getParam(req.params.slug);
|
|
8634
|
-
const projectPath =
|
|
9108
|
+
const projectPath = resolve18(projectsDir, projectSlug, "project.md");
|
|
8635
9109
|
if (!await fileExists(projectPath)) {
|
|
8636
9110
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
8637
9111
|
return;
|
|
@@ -8643,7 +9117,7 @@ ${entry}`;
|
|
|
8643
9117
|
});
|
|
8644
9118
|
return;
|
|
8645
9119
|
}
|
|
8646
|
-
let content = await
|
|
9120
|
+
let content = await readFile15(projectPath, "utf-8");
|
|
8647
9121
|
content = setTopLevelField(content, "workspace", workspace ?? null);
|
|
8648
9122
|
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
8649
9123
|
await writeFileForce(projectPath, content);
|
|
@@ -8679,8 +9153,8 @@ ${entry}`;
|
|
|
8679
9153
|
});
|
|
8680
9154
|
return;
|
|
8681
9155
|
}
|
|
8682
|
-
const assignmentPath =
|
|
8683
|
-
let content = await
|
|
9156
|
+
const assignmentPath = resolve18(resolved.assignmentDir, "assignment.md");
|
|
9157
|
+
let content = await readFile15(assignmentPath, "utf-8");
|
|
8684
9158
|
content = setTopLevelField(content, "workspaceGroup", workspaceGroup ?? null);
|
|
8685
9159
|
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
8686
9160
|
await writeFileForce(assignmentPath, content);
|
|
@@ -8696,7 +9170,7 @@ ${entry}`;
|
|
|
8696
9170
|
async (req, res) => {
|
|
8697
9171
|
try {
|
|
8698
9172
|
const projectSlug = getParam(req.params.slug);
|
|
8699
|
-
const projectPath =
|
|
9173
|
+
const projectPath = resolve18(projectsDir, projectSlug, "project.md");
|
|
8700
9174
|
if (!await fileExists(projectPath)) {
|
|
8701
9175
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
8702
9176
|
return;
|
|
@@ -8741,7 +9215,7 @@ ${entry}`;
|
|
|
8741
9215
|
try {
|
|
8742
9216
|
const projectSlug = getParam(req.params.slug);
|
|
8743
9217
|
const assignmentSlug = getParam(req.params.aslug);
|
|
8744
|
-
const assignmentPath =
|
|
9218
|
+
const assignmentPath = resolve18(
|
|
8745
9219
|
projectsDir,
|
|
8746
9220
|
projectSlug,
|
|
8747
9221
|
"assignments",
|
|
@@ -8774,8 +9248,8 @@ ${entry}`;
|
|
|
8774
9248
|
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
8775
9249
|
return;
|
|
8776
9250
|
}
|
|
8777
|
-
const assignmentPath =
|
|
8778
|
-
const parsedForSlug = parseAssignmentFull(await
|
|
9251
|
+
const assignmentPath = resolve18(resolved.assignmentDir, "assignment.md");
|
|
9252
|
+
const parsedForSlug = parseAssignmentFull(await readFile15(assignmentPath, "utf-8"));
|
|
8779
9253
|
const assignmentSlugForBranch = parsedForSlug.slug || resolved.id;
|
|
8780
9254
|
await handleWorktreeCreate(req, res, {
|
|
8781
9255
|
assignmentPath,
|
|
@@ -8792,7 +9266,7 @@ ${entry}`;
|
|
|
8792
9266
|
router.post("/api/projects/:slug/status-override", async (req, res) => {
|
|
8793
9267
|
try {
|
|
8794
9268
|
const projectSlug = getParam(req.params.slug);
|
|
8795
|
-
const projectPath =
|
|
9269
|
+
const projectPath = resolve18(projectsDir, projectSlug, "project.md");
|
|
8796
9270
|
if (!await fileExists(projectPath)) {
|
|
8797
9271
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
8798
9272
|
return;
|
|
@@ -8804,7 +9278,7 @@ ${entry}`;
|
|
|
8804
9278
|
res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(", ")}, or null to clear.` });
|
|
8805
9279
|
return;
|
|
8806
9280
|
}
|
|
8807
|
-
let content = await
|
|
9281
|
+
let content = await readFile15(projectPath, "utf-8");
|
|
8808
9282
|
content = setTopLevelField(content, "statusOverride", status ?? null);
|
|
8809
9283
|
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
8810
9284
|
await writeFileForce(projectPath, content);
|
|
@@ -8819,7 +9293,7 @@ ${entry}`;
|
|
|
8819
9293
|
try {
|
|
8820
9294
|
const projectSlug = getParam(req.params.slug);
|
|
8821
9295
|
const assignmentSlug = getParam(req.params.aslug);
|
|
8822
|
-
const assignmentPath =
|
|
9296
|
+
const assignmentPath = resolve18(
|
|
8823
9297
|
projectsDir,
|
|
8824
9298
|
projectSlug,
|
|
8825
9299
|
"assignments",
|
|
@@ -8837,7 +9311,7 @@ ${entry}`;
|
|
|
8837
9311
|
res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(", ")}.` });
|
|
8838
9312
|
return;
|
|
8839
9313
|
}
|
|
8840
|
-
let content = await
|
|
9314
|
+
let content = await readFile15(assignmentPath, "utf-8");
|
|
8841
9315
|
content = setTopLevelField(content, "status", status);
|
|
8842
9316
|
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
8843
9317
|
if (status !== "blocked") {
|
|
@@ -8855,7 +9329,7 @@ ${entry}`;
|
|
|
8855
9329
|
try {
|
|
8856
9330
|
const projectSlug = getParam(req.params.slug);
|
|
8857
9331
|
const assignmentSlug = getParam(req.params.aslug);
|
|
8858
|
-
const assignmentPath =
|
|
9332
|
+
const assignmentPath = resolve18(
|
|
8859
9333
|
projectsDir,
|
|
8860
9334
|
projectSlug,
|
|
8861
9335
|
"assignments",
|
|
@@ -8871,7 +9345,7 @@ ${entry}`;
|
|
|
8871
9345
|
res.status(400).json({ error: validation.error });
|
|
8872
9346
|
return;
|
|
8873
9347
|
}
|
|
8874
|
-
let content = await
|
|
9348
|
+
let content = await readFile15(assignmentPath, "utf-8");
|
|
8875
9349
|
content = setTopLevelField(content, "assignee", validation.value);
|
|
8876
9350
|
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
8877
9351
|
await writeFileForce(assignmentPath, content);
|
|
@@ -8882,6 +9356,37 @@ ${entry}`;
|
|
|
8882
9356
|
res.status(500).json({ error: `Failed to update assignee: ${error.message}` });
|
|
8883
9357
|
}
|
|
8884
9358
|
});
|
|
9359
|
+
router.patch("/api/projects/:slug/assignments/:aslug/title", async (req, res) => {
|
|
9360
|
+
try {
|
|
9361
|
+
const projectSlug = getParam(req.params.slug);
|
|
9362
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
9363
|
+
const assignmentPath = resolve18(
|
|
9364
|
+
projectsDir,
|
|
9365
|
+
projectSlug,
|
|
9366
|
+
"assignments",
|
|
9367
|
+
assignmentSlug,
|
|
9368
|
+
"assignment.md"
|
|
9369
|
+
);
|
|
9370
|
+
if (!await fileExists(assignmentPath)) {
|
|
9371
|
+
res.status(404).json({ error: "Assignment not found" });
|
|
9372
|
+
return;
|
|
9373
|
+
}
|
|
9374
|
+
const validation = validateTitleBody(req.body);
|
|
9375
|
+
if (!validation.ok) {
|
|
9376
|
+
res.status(400).json({ error: validation.error });
|
|
9377
|
+
return;
|
|
9378
|
+
}
|
|
9379
|
+
let content = await readFile15(assignmentPath, "utf-8");
|
|
9380
|
+
content = setTopLevelField(content, "title", validation.value);
|
|
9381
|
+
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
9382
|
+
await writeFileForce(assignmentPath, content);
|
|
9383
|
+
const assignment = await getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
|
|
9384
|
+
res.json({ assignment });
|
|
9385
|
+
} catch (error) {
|
|
9386
|
+
console.error("Error updating title:", error);
|
|
9387
|
+
res.status(500).json({ error: `Failed to update title: ${error.message}` });
|
|
9388
|
+
}
|
|
9389
|
+
});
|
|
8885
9390
|
router.post("/api/projects/:slug/assignments/:aslug/transitions/:command", async (req, res) => {
|
|
8886
9391
|
try {
|
|
8887
9392
|
const projectSlug = getParam(req.params.slug);
|
|
@@ -8893,8 +9398,8 @@ ${entry}`;
|
|
|
8893
9398
|
res.status(400).json({ error: `Unsupported transition command "${req.params.command}"` });
|
|
8894
9399
|
return;
|
|
8895
9400
|
}
|
|
8896
|
-
const projectDir =
|
|
8897
|
-
const assignmentPath =
|
|
9401
|
+
const projectDir = resolve18(projectsDir, projectSlug);
|
|
9402
|
+
const assignmentPath = resolve18(projectDir, "assignments", assignmentSlug, "assignment.md");
|
|
8898
9403
|
if (!await fileExists(assignmentPath)) {
|
|
8899
9404
|
res.status(404).json({ error: "Assignment not found" });
|
|
8900
9405
|
return;
|
|
@@ -8921,8 +9426,8 @@ ${entry}`;
|
|
|
8921
9426
|
try {
|
|
8922
9427
|
const projectSlug = getParam(req.params.slug);
|
|
8923
9428
|
const assignmentSlug = getParam(req.params.aslug);
|
|
8924
|
-
const assignmentDir =
|
|
8925
|
-
const assignmentPath =
|
|
9429
|
+
const assignmentDir = resolve18(projectsDir, projectSlug, "assignments", assignmentSlug);
|
|
9430
|
+
const assignmentPath = resolve18(assignmentDir, "assignment.md");
|
|
8926
9431
|
if (!await fileExists(assignmentPath)) {
|
|
8927
9432
|
res.status(404).json({ error: `Assignment "${assignmentSlug}" not found in project "${projectSlug}"` });
|
|
8928
9433
|
return;
|
|
@@ -8977,7 +9482,7 @@ ${entry}`;
|
|
|
8977
9482
|
return;
|
|
8978
9483
|
}
|
|
8979
9484
|
const id2 = generateId();
|
|
8980
|
-
const assignmentDir2 =
|
|
9485
|
+
const assignmentDir2 = resolve18(assignmentsDir2, id2);
|
|
8981
9486
|
if (await fileExists(assignmentDir2)) {
|
|
8982
9487
|
res.status(500).json({ error: "UUID collision \u2014 try again" });
|
|
8983
9488
|
return;
|
|
@@ -8985,25 +9490,25 @@ ${entry}`;
|
|
|
8985
9490
|
const timestamp2 = fields.created || nowTimestamp();
|
|
8986
9491
|
await ensureDir(assignmentDir2);
|
|
8987
9492
|
const normalizedContent = setTopLevelField(rawContent, "id", id2);
|
|
8988
|
-
await writeFileForce(
|
|
9493
|
+
await writeFileForce(resolve18(assignmentDir2, "assignment.md"), normalizedContent);
|
|
8989
9494
|
await writeFileForce(
|
|
8990
|
-
|
|
9495
|
+
resolve18(assignmentDir2, "scratchpad.md"),
|
|
8991
9496
|
renderScratchpad({ assignmentSlug: id2, timestamp: timestamp2 })
|
|
8992
9497
|
);
|
|
8993
9498
|
await writeFileForce(
|
|
8994
|
-
|
|
9499
|
+
resolve18(assignmentDir2, "handoff.md"),
|
|
8995
9500
|
renderHandoff({ assignmentSlug: id2, timestamp: timestamp2 })
|
|
8996
9501
|
);
|
|
8997
9502
|
await writeFileForce(
|
|
8998
|
-
|
|
9503
|
+
resolve18(assignmentDir2, "decision-record.md"),
|
|
8999
9504
|
renderDecisionRecord({ assignmentSlug: id2, timestamp: timestamp2 })
|
|
9000
9505
|
);
|
|
9001
9506
|
await writeFileForce(
|
|
9002
|
-
|
|
9507
|
+
resolve18(assignmentDir2, "progress.md"),
|
|
9003
9508
|
renderProgress({ assignment: id2, timestamp: timestamp2 })
|
|
9004
9509
|
);
|
|
9005
9510
|
await writeFileForce(
|
|
9006
|
-
|
|
9511
|
+
resolve18(assignmentDir2, "comments.md"),
|
|
9007
9512
|
renderComments({ assignment: id2, timestamp: timestamp2 })
|
|
9008
9513
|
);
|
|
9009
9514
|
const detail2 = await getAssignmentDetailById(projectsDir, assignmentsDir2, id2);
|
|
@@ -9021,7 +9526,7 @@ ${entry}`;
|
|
|
9021
9526
|
return;
|
|
9022
9527
|
}
|
|
9023
9528
|
const id = generateId();
|
|
9024
|
-
const assignmentDir =
|
|
9529
|
+
const assignmentDir = resolve18(assignmentsDir2, id);
|
|
9025
9530
|
if (await fileExists(assignmentDir)) {
|
|
9026
9531
|
res.status(500).json({ error: "UUID collision \u2014 try again" });
|
|
9027
9532
|
return;
|
|
@@ -9041,25 +9546,25 @@ ${entry}`;
|
|
|
9041
9546
|
project: null,
|
|
9042
9547
|
type: typeof type === "string" ? type : void 0
|
|
9043
9548
|
});
|
|
9044
|
-
await writeFileForce(
|
|
9549
|
+
await writeFileForce(resolve18(assignmentDir, "assignment.md"), assignmentContent);
|
|
9045
9550
|
await writeFileForce(
|
|
9046
|
-
|
|
9551
|
+
resolve18(assignmentDir, "scratchpad.md"),
|
|
9047
9552
|
renderScratchpad({ assignmentSlug: id, timestamp })
|
|
9048
9553
|
);
|
|
9049
9554
|
await writeFileForce(
|
|
9050
|
-
|
|
9555
|
+
resolve18(assignmentDir, "handoff.md"),
|
|
9051
9556
|
renderHandoff({ assignmentSlug: id, timestamp })
|
|
9052
9557
|
);
|
|
9053
9558
|
await writeFileForce(
|
|
9054
|
-
|
|
9559
|
+
resolve18(assignmentDir, "decision-record.md"),
|
|
9055
9560
|
renderDecisionRecord({ assignmentSlug: id, timestamp })
|
|
9056
9561
|
);
|
|
9057
9562
|
await writeFileForce(
|
|
9058
|
-
|
|
9563
|
+
resolve18(assignmentDir, "progress.md"),
|
|
9059
9564
|
renderProgress({ assignment: id, timestamp })
|
|
9060
9565
|
);
|
|
9061
9566
|
await writeFileForce(
|
|
9062
|
-
|
|
9567
|
+
resolve18(assignmentDir, "comments.md"),
|
|
9063
9568
|
renderComments({ assignment: id, timestamp })
|
|
9064
9569
|
);
|
|
9065
9570
|
const detail = await getAssignmentDetailById(projectsDir, assignmentsDir2, id);
|
|
@@ -9187,7 +9692,7 @@ ${entry}`;
|
|
|
9187
9692
|
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
9188
9693
|
return;
|
|
9189
9694
|
}
|
|
9190
|
-
const assignmentPath =
|
|
9695
|
+
const assignmentPath = resolve18(resolved.assignmentDir, "assignment.md");
|
|
9191
9696
|
const currentContent = await readCurrentDocument(assignmentPath);
|
|
9192
9697
|
if (!currentContent) {
|
|
9193
9698
|
res.status(404).json({ error: "Assignment not found" });
|
|
@@ -9229,7 +9734,7 @@ ${entry}`;
|
|
|
9229
9734
|
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
9230
9735
|
return;
|
|
9231
9736
|
}
|
|
9232
|
-
const planPath =
|
|
9737
|
+
const planPath = resolve18(resolved.assignmentDir, "plan.md");
|
|
9233
9738
|
const currentContent = await readCurrentDocument(planPath);
|
|
9234
9739
|
if (!currentContent) {
|
|
9235
9740
|
res.status(404).json({ error: "Plan not found" });
|
|
@@ -9263,7 +9768,7 @@ ${entry}`;
|
|
|
9263
9768
|
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
9264
9769
|
return;
|
|
9265
9770
|
}
|
|
9266
|
-
const scratchpadPath =
|
|
9771
|
+
const scratchpadPath = resolve18(resolved.assignmentDir, "scratchpad.md");
|
|
9267
9772
|
const currentContent = await readCurrentDocument(scratchpadPath);
|
|
9268
9773
|
if (!currentContent) {
|
|
9269
9774
|
res.status(404).json({ error: "Scratchpad not found" });
|
|
@@ -9297,7 +9802,7 @@ ${entry}`;
|
|
|
9297
9802
|
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
9298
9803
|
return;
|
|
9299
9804
|
}
|
|
9300
|
-
const handoffPath =
|
|
9805
|
+
const handoffPath = resolve18(resolved.assignmentDir, "handoff.md");
|
|
9301
9806
|
const currentContent = await readCurrentDocument(handoffPath);
|
|
9302
9807
|
if (!currentContent) {
|
|
9303
9808
|
res.status(404).json({ error: "Handoff log not found" });
|
|
@@ -9337,7 +9842,7 @@ ${entry}`;
|
|
|
9337
9842
|
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
9338
9843
|
return;
|
|
9339
9844
|
}
|
|
9340
|
-
const decisionPath =
|
|
9845
|
+
const decisionPath = resolve18(resolved.assignmentDir, "decision-record.md");
|
|
9341
9846
|
const currentContent = await readCurrentDocument(decisionPath);
|
|
9342
9847
|
if (!currentContent) {
|
|
9343
9848
|
res.status(404).json({ error: "Decision record not found" });
|
|
@@ -9377,7 +9882,7 @@ ${entry}`;
|
|
|
9377
9882
|
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
9378
9883
|
return;
|
|
9379
9884
|
}
|
|
9380
|
-
const assignmentPath =
|
|
9885
|
+
const assignmentPath = resolve18(resolved.assignmentDir, "assignment.md");
|
|
9381
9886
|
if (!await fileExists(assignmentPath)) {
|
|
9382
9887
|
res.status(404).json({ error: "Assignment not found" });
|
|
9383
9888
|
return;
|
|
@@ -9389,7 +9894,7 @@ ${entry}`;
|
|
|
9389
9894
|
res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(", ")}.` });
|
|
9390
9895
|
return;
|
|
9391
9896
|
}
|
|
9392
|
-
let content = await
|
|
9897
|
+
let content = await readFile15(assignmentPath, "utf-8");
|
|
9393
9898
|
content = setTopLevelField(content, "status", status);
|
|
9394
9899
|
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
9395
9900
|
if (status !== "blocked") {
|
|
@@ -9415,7 +9920,7 @@ ${entry}`;
|
|
|
9415
9920
|
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
9416
9921
|
return;
|
|
9417
9922
|
}
|
|
9418
|
-
const assignmentPath =
|
|
9923
|
+
const assignmentPath = resolve18(resolved.assignmentDir, "assignment.md");
|
|
9419
9924
|
if (!await fileExists(assignmentPath)) {
|
|
9420
9925
|
res.status(404).json({ error: "Assignment not found" });
|
|
9421
9926
|
return;
|
|
@@ -9425,7 +9930,7 @@ ${entry}`;
|
|
|
9425
9930
|
res.status(400).json({ error: validation.error });
|
|
9426
9931
|
return;
|
|
9427
9932
|
}
|
|
9428
|
-
let content = await
|
|
9933
|
+
let content = await readFile15(assignmentPath, "utf-8");
|
|
9429
9934
|
content = setTopLevelField(content, "assignee", validation.value);
|
|
9430
9935
|
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
9431
9936
|
await writeFileForce(assignmentPath, content);
|
|
@@ -9436,29 +9941,62 @@ ${entry}`;
|
|
|
9436
9941
|
res.status(500).json({ error: `Failed to update assignee: ${error.message}` });
|
|
9437
9942
|
}
|
|
9438
9943
|
});
|
|
9439
|
-
router.
|
|
9944
|
+
router.patch("/api/assignments/:id/title", async (req, res) => {
|
|
9440
9945
|
try {
|
|
9441
|
-
|
|
9442
|
-
|
|
9443
|
-
res.status(400).json({ error: "Request body must include `items` (array)." });
|
|
9946
|
+
if (!assignmentsDir2) {
|
|
9947
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
9444
9948
|
return;
|
|
9445
9949
|
}
|
|
9446
|
-
const
|
|
9447
|
-
|
|
9448
|
-
|
|
9950
|
+
const id = getParam(req.params.id);
|
|
9951
|
+
const resolved = await resolveAssignmentById(projectsDir, assignmentsDir2, id);
|
|
9952
|
+
if (!resolved) {
|
|
9953
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
9449
9954
|
return;
|
|
9450
9955
|
}
|
|
9451
|
-
|
|
9452
|
-
|
|
9956
|
+
const assignmentPath = resolve18(resolved.assignmentDir, "assignment.md");
|
|
9957
|
+
if (!await fileExists(assignmentPath)) {
|
|
9958
|
+
res.status(404).json({ error: "Assignment not found" });
|
|
9453
9959
|
return;
|
|
9454
9960
|
}
|
|
9455
|
-
const
|
|
9456
|
-
|
|
9457
|
-
|
|
9458
|
-
|
|
9459
|
-
|
|
9460
|
-
let
|
|
9461
|
-
|
|
9961
|
+
const validation = validateTitleBody(req.body);
|
|
9962
|
+
if (!validation.ok) {
|
|
9963
|
+
res.status(400).json({ error: validation.error });
|
|
9964
|
+
return;
|
|
9965
|
+
}
|
|
9966
|
+
let content = await readFile15(assignmentPath, "utf-8");
|
|
9967
|
+
content = setTopLevelField(content, "title", validation.value);
|
|
9968
|
+
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
9969
|
+
await writeFileForce(assignmentPath, content);
|
|
9970
|
+
const assignment = await getAssignmentDetailById(projectsDir, assignmentsDir2, id);
|
|
9971
|
+
res.json({ assignment });
|
|
9972
|
+
} catch (error) {
|
|
9973
|
+
console.error("Error updating standalone title:", error);
|
|
9974
|
+
res.status(500).json({ error: `Failed to update title: ${error.message}` });
|
|
9975
|
+
}
|
|
9976
|
+
});
|
|
9977
|
+
router.post("/api/assignments/bulk-status-override", async (req, res) => {
|
|
9978
|
+
try {
|
|
9979
|
+
const body = req.body;
|
|
9980
|
+
if (!body || typeof body !== "object" || !Array.isArray(body.items)) {
|
|
9981
|
+
res.status(400).json({ error: "Request body must include `items` (array)." });
|
|
9982
|
+
return;
|
|
9983
|
+
}
|
|
9984
|
+
const items = body.items;
|
|
9985
|
+
if (items.length === 0) {
|
|
9986
|
+
res.status(400).json({ error: "`items` must contain at least one entry." });
|
|
9987
|
+
return;
|
|
9988
|
+
}
|
|
9989
|
+
if (items.length > 200) {
|
|
9990
|
+
res.status(400).json({ error: "`items` is capped at 200 entries per call." });
|
|
9991
|
+
return;
|
|
9992
|
+
}
|
|
9993
|
+
const config = await getStatusConfig();
|
|
9994
|
+
const validStatuses = new Set(config.statuses.map((s) => s.id));
|
|
9995
|
+
const timestamp = nowTimestamp();
|
|
9996
|
+
const results = [];
|
|
9997
|
+
let succeeded = 0;
|
|
9998
|
+
let failed = 0;
|
|
9999
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
9462
10000
|
const raw = items[index];
|
|
9463
10001
|
const itemKey = buildBulkItemKey(raw, index);
|
|
9464
10002
|
try {
|
|
@@ -9479,9 +10017,9 @@ ${entry}`;
|
|
|
9479
10017
|
if (!resolved) {
|
|
9480
10018
|
throw new Error(`assignment "${item.id}" not found`);
|
|
9481
10019
|
}
|
|
9482
|
-
assignmentPath =
|
|
10020
|
+
assignmentPath = resolve18(resolved.assignmentDir, "assignment.md");
|
|
9483
10021
|
} else if (typeof item.projectSlug === "string" && typeof item.assignmentSlug === "string" && item.projectSlug && item.assignmentSlug) {
|
|
9484
|
-
assignmentPath =
|
|
10022
|
+
assignmentPath = resolve18(
|
|
9485
10023
|
projectsDir,
|
|
9486
10024
|
item.projectSlug,
|
|
9487
10025
|
"assignments",
|
|
@@ -9494,7 +10032,7 @@ ${entry}`;
|
|
|
9494
10032
|
if (!await fileExists(assignmentPath)) {
|
|
9495
10033
|
throw new Error("assignment file not found");
|
|
9496
10034
|
}
|
|
9497
|
-
let content = await
|
|
10035
|
+
let content = await readFile15(assignmentPath, "utf-8");
|
|
9498
10036
|
content = setTopLevelField(content, "status", status);
|
|
9499
10037
|
content = setTopLevelField(content, "updated", timestamp);
|
|
9500
10038
|
if (status !== "blocked") {
|
|
@@ -9526,7 +10064,7 @@ ${entry}`;
|
|
|
9526
10064
|
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
9527
10065
|
return;
|
|
9528
10066
|
}
|
|
9529
|
-
const assignmentPath =
|
|
10067
|
+
const assignmentPath = resolve18(resolved.assignmentDir, "assignment.md");
|
|
9530
10068
|
const currentContent = await readCurrentDocument(assignmentPath);
|
|
9531
10069
|
if (!currentContent) {
|
|
9532
10070
|
res.status(404).json({ error: "Assignment not found" });
|
|
@@ -9607,6 +10145,26 @@ function validateAssigneeBody(body) {
|
|
|
9607
10145
|
}
|
|
9608
10146
|
return { ok: true, value: trimmed };
|
|
9609
10147
|
}
|
|
10148
|
+
function validateTitleBody(body) {
|
|
10149
|
+
if (!body || typeof body !== "object") {
|
|
10150
|
+
return { ok: false, error: "Body must include `title` (non-empty string)." };
|
|
10151
|
+
}
|
|
10152
|
+
const title = body.title;
|
|
10153
|
+
if (typeof title !== "string") {
|
|
10154
|
+
return { ok: false, error: "`title` must be a string." };
|
|
10155
|
+
}
|
|
10156
|
+
const trimmed = title.trim();
|
|
10157
|
+
if (trimmed.length === 0) {
|
|
10158
|
+
return { ok: false, error: "`title` is required." };
|
|
10159
|
+
}
|
|
10160
|
+
if (trimmed.length > 200) {
|
|
10161
|
+
return { ok: false, error: "`title` must be 200 characters or fewer." };
|
|
10162
|
+
}
|
|
10163
|
+
if (/["\r\n]/.test(trimmed)) {
|
|
10164
|
+
return { ok: false, error: "`title` may not contain double quotes or line breaks." };
|
|
10165
|
+
}
|
|
10166
|
+
return { ok: true, value: trimmed };
|
|
10167
|
+
}
|
|
9610
10168
|
function buildBulkItemKey(raw, index) {
|
|
9611
10169
|
if (raw && typeof raw === "object") {
|
|
9612
10170
|
const item = raw;
|
|
@@ -9618,7 +10176,7 @@ function buildBulkItemKey(raw, index) {
|
|
|
9618
10176
|
return `#${index}`;
|
|
9619
10177
|
}
|
|
9620
10178
|
async function appendCommentTo(assignmentDir, assignmentRef, req, res, reloadDetail) {
|
|
9621
|
-
const commentsPath =
|
|
10179
|
+
const commentsPath = resolve18(assignmentDir, "comments.md");
|
|
9622
10180
|
const { body, author, type, replyTo } = req.body || {};
|
|
9623
10181
|
if (!body || typeof body !== "string" || !body.trim()) {
|
|
9624
10182
|
res.status(400).json({ error: "body is required" });
|
|
@@ -9630,7 +10188,7 @@ async function appendCommentTo(assignmentDir, assignmentRef, req, res, reloadDet
|
|
|
9630
10188
|
let currentContent;
|
|
9631
10189
|
let currentCount = 0;
|
|
9632
10190
|
if (await fileExists(commentsPath)) {
|
|
9633
|
-
currentContent = await
|
|
10191
|
+
currentContent = await readFile15(commentsPath, "utf-8");
|
|
9634
10192
|
const countMatch = currentContent.match(/^entryCount:\s*(\d+)/m);
|
|
9635
10193
|
if (countMatch) currentCount = parseInt(countMatch[1], 10);
|
|
9636
10194
|
} else {
|
|
@@ -9660,7 +10218,7 @@ ${entry}`;
|
|
|
9660
10218
|
res.status(201).json({ assignment, comment: { id: comment.id } });
|
|
9661
10219
|
}
|
|
9662
10220
|
async function toggleCommentResolvedAt(assignmentDir, commentId, req, res, reloadDetail) {
|
|
9663
|
-
const commentsPath =
|
|
10221
|
+
const commentsPath = resolve18(assignmentDir, "comments.md");
|
|
9664
10222
|
if (!await fileExists(commentsPath)) {
|
|
9665
10223
|
res.status(404).json({ error: "Comments file not found" });
|
|
9666
10224
|
return;
|
|
@@ -9670,7 +10228,7 @@ async function toggleCommentResolvedAt(assignmentDir, commentId, req, res, reloa
|
|
|
9670
10228
|
res.status(400).json({ error: "resolved (boolean) is required" });
|
|
9671
10229
|
return;
|
|
9672
10230
|
}
|
|
9673
|
-
const content = await
|
|
10231
|
+
const content = await readFile15(commentsPath, "utf-8");
|
|
9674
10232
|
const parsed = parseComments(content);
|
|
9675
10233
|
const target = parsed.entries.find((e) => e.id === commentId);
|
|
9676
10234
|
if (!target) {
|
|
@@ -9699,9 +10257,9 @@ async function toggleCommentResolvedAt(assignmentDir, commentId, req, res, reloa
|
|
|
9699
10257
|
// src/dashboard/api-servers.ts
|
|
9700
10258
|
init_servers();
|
|
9701
10259
|
init_scanner();
|
|
9702
|
-
import { Router as
|
|
10260
|
+
import { Router as Router3 } from "express";
|
|
9703
10261
|
function createServersRouter(serversDir2, projectsDir, assignmentsDir2) {
|
|
9704
|
-
const router =
|
|
10262
|
+
const router = Router3();
|
|
9705
10263
|
router.get("/", async (_req, res) => {
|
|
9706
10264
|
try {
|
|
9707
10265
|
const result = await scanAllSessions(serversDir2, projectsDir, { assignmentsDir: assignmentsDir2 });
|
|
@@ -9816,8 +10374,8 @@ function createServersRouter(serversDir2, projectsDir, assignmentsDir2) {
|
|
|
9816
10374
|
// src/dashboard/api-agent-sessions.ts
|
|
9817
10375
|
init_agent_sessions();
|
|
9818
10376
|
init_fs();
|
|
9819
|
-
import { Router as
|
|
9820
|
-
import { resolve as
|
|
10377
|
+
import { Router as Router4 } from "express";
|
|
10378
|
+
import { resolve as resolve19 } from "path";
|
|
9821
10379
|
|
|
9822
10380
|
// src/utils/transcript.ts
|
|
9823
10381
|
import { open } from "fs/promises";
|
|
@@ -9897,7 +10455,7 @@ function captureProcessStartedAt(pid) {
|
|
|
9897
10455
|
|
|
9898
10456
|
// src/dashboard/api-agent-sessions.ts
|
|
9899
10457
|
function createAgentSessionsRouter(projectsDir, broadcast, assignmentsDir2) {
|
|
9900
|
-
const router =
|
|
10458
|
+
const router = Router4();
|
|
9901
10459
|
router.get("/", async (_req, res) => {
|
|
9902
10460
|
try {
|
|
9903
10461
|
await reconcileActiveSessions(projectsDir, assignmentsDir2);
|
|
@@ -9915,7 +10473,7 @@ function createAgentSessionsRouter(projectsDir, broadcast, assignmentsDir2) {
|
|
|
9915
10473
|
try {
|
|
9916
10474
|
const { projectSlug } = req.params;
|
|
9917
10475
|
const assignment = req.query.assignment;
|
|
9918
|
-
const projectDir =
|
|
10476
|
+
const projectDir = resolve19(projectsDir, projectSlug);
|
|
9919
10477
|
if (!await fileExists(projectDir)) {
|
|
9920
10478
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
9921
10479
|
return;
|
|
@@ -9945,7 +10503,7 @@ function createAgentSessionsRouter(projectsDir, broadcast, assignmentsDir2) {
|
|
|
9945
10503
|
return;
|
|
9946
10504
|
}
|
|
9947
10505
|
if (projectSlug) {
|
|
9948
|
-
const projectDir =
|
|
10506
|
+
const projectDir = resolve19(projectsDir, projectSlug);
|
|
9949
10507
|
if (!await fileExists(projectDir)) {
|
|
9950
10508
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
9951
10509
|
return;
|
|
@@ -10043,7 +10601,7 @@ function createAgentSessionsRouter(projectsDir, broadcast, assignmentsDir2) {
|
|
|
10043
10601
|
// src/dashboard/api-agents.ts
|
|
10044
10602
|
init_config2();
|
|
10045
10603
|
init_agents_schema();
|
|
10046
|
-
import { Router as
|
|
10604
|
+
import { Router as Router5 } from "express";
|
|
10047
10605
|
function mapAgentErrorToFieldErrors(err) {
|
|
10048
10606
|
const message = err.message;
|
|
10049
10607
|
let match = message.match(/^agent id "([^"]*)" is invalid/);
|
|
@@ -10213,7 +10771,7 @@ function coerceAgentRow(raw, index) {
|
|
|
10213
10771
|
return { ok: true, value: cleaned };
|
|
10214
10772
|
}
|
|
10215
10773
|
function createAgentsRouter() {
|
|
10216
|
-
const router =
|
|
10774
|
+
const router = Router5();
|
|
10217
10775
|
router.get("/", async (_req, res) => {
|
|
10218
10776
|
try {
|
|
10219
10777
|
const config = await readConfig();
|
|
@@ -10271,7 +10829,7 @@ function createAgentsRouter() {
|
|
|
10271
10829
|
|
|
10272
10830
|
// src/dashboard/api-launch-preflight.ts
|
|
10273
10831
|
init_config2();
|
|
10274
|
-
import { Router as
|
|
10832
|
+
import { Router as Router6 } from "express";
|
|
10275
10833
|
|
|
10276
10834
|
// src/utils/terminal-probe.ts
|
|
10277
10835
|
import { spawnSync as spawnSync4 } from "child_process";
|
|
@@ -10311,7 +10869,7 @@ function probeTerminalInstalled(terminal) {
|
|
|
10311
10869
|
|
|
10312
10870
|
// src/dashboard/api-launch-preflight.ts
|
|
10313
10871
|
function createLaunchPreflightRouter() {
|
|
10314
|
-
const router =
|
|
10872
|
+
const router = Router6();
|
|
10315
10873
|
router.post("/preflight", async (req, res) => {
|
|
10316
10874
|
try {
|
|
10317
10875
|
const body = req.body ?? {};
|
|
@@ -10347,9 +10905,9 @@ function createLaunchPreflightRouter() {
|
|
|
10347
10905
|
|
|
10348
10906
|
// src/dashboard/api-terminal-config.ts
|
|
10349
10907
|
init_config2();
|
|
10350
|
-
import { Router as
|
|
10908
|
+
import { Router as Router7 } from "express";
|
|
10351
10909
|
function createTerminalConfigRouter() {
|
|
10352
|
-
const router =
|
|
10910
|
+
const router = Router7();
|
|
10353
10911
|
router.get("/", async (_req, res) => {
|
|
10354
10912
|
try {
|
|
10355
10913
|
const config = await readConfig();
|
|
@@ -10384,21 +10942,565 @@ function createTerminalConfigRouter() {
|
|
|
10384
10942
|
const config = await readConfig();
|
|
10385
10943
|
res.json({ terminal: getTerminal(config), custom: false });
|
|
10386
10944
|
} catch (error) {
|
|
10387
|
-
console.error("Error resetting terminal config:", error);
|
|
10388
|
-
res.status(500).json({ error: "Failed to reset terminal config" });
|
|
10945
|
+
console.error("Error resetting terminal config:", error);
|
|
10946
|
+
res.status(500).json({ error: "Failed to reset terminal config" });
|
|
10947
|
+
}
|
|
10948
|
+
});
|
|
10949
|
+
return router;
|
|
10950
|
+
}
|
|
10951
|
+
|
|
10952
|
+
// src/dashboard/api-status-config.ts
|
|
10953
|
+
init_config2();
|
|
10954
|
+
init_api();
|
|
10955
|
+
import { Router as Router8 } from "express";
|
|
10956
|
+
|
|
10957
|
+
// src/utils/status-config-resolution.ts
|
|
10958
|
+
init_frontmatter();
|
|
10959
|
+
import { readFile as readFile16, writeFile as writeFile4, rm as rm2 } from "fs/promises";
|
|
10960
|
+
import { dirname as dirname4 } from "path";
|
|
10961
|
+
|
|
10962
|
+
// src/utils/assignment-walk.ts
|
|
10963
|
+
init_fs();
|
|
10964
|
+
import { resolve as resolve20 } from "path";
|
|
10965
|
+
import { readdir as readdir10 } from "fs/promises";
|
|
10966
|
+
async function listAssignmentsByProject(projectsDir, standaloneDir) {
|
|
10967
|
+
const result = {
|
|
10968
|
+
withAssignmentMd: [],
|
|
10969
|
+
orphanFolders: []
|
|
10970
|
+
};
|
|
10971
|
+
if (await fileExists(projectsDir)) {
|
|
10972
|
+
const projects = await readdir10(projectsDir, { withFileTypes: true });
|
|
10973
|
+
for (const m of projects) {
|
|
10974
|
+
if (!m.isDirectory()) continue;
|
|
10975
|
+
if (m.name.startsWith(".") || m.name.startsWith("_")) continue;
|
|
10976
|
+
const assignmentsDir2 = resolve20(projectsDir, m.name, "assignments");
|
|
10977
|
+
if (!await fileExists(assignmentsDir2)) continue;
|
|
10978
|
+
const entries = await readdir10(assignmentsDir2, { withFileTypes: true });
|
|
10979
|
+
for (const a of entries) {
|
|
10980
|
+
if (!a.isDirectory()) continue;
|
|
10981
|
+
if (a.name.startsWith(".") || a.name.startsWith("_")) continue;
|
|
10982
|
+
const assignmentDir = resolve20(assignmentsDir2, a.name);
|
|
10983
|
+
const assignmentMd = resolve20(assignmentDir, "assignment.md");
|
|
10984
|
+
const entry = {
|
|
10985
|
+
projectDir: resolve20(projectsDir, m.name),
|
|
10986
|
+
projectSlug: m.name,
|
|
10987
|
+
assignmentDir,
|
|
10988
|
+
assignmentSlug: a.name,
|
|
10989
|
+
standalone: false
|
|
10990
|
+
};
|
|
10991
|
+
if (await fileExists(assignmentMd)) {
|
|
10992
|
+
result.withAssignmentMd.push(entry);
|
|
10993
|
+
} else {
|
|
10994
|
+
result.orphanFolders.push(entry);
|
|
10995
|
+
}
|
|
10996
|
+
}
|
|
10997
|
+
}
|
|
10998
|
+
}
|
|
10999
|
+
if (standaloneDir !== null && await fileExists(standaloneDir)) {
|
|
11000
|
+
const entries = await readdir10(standaloneDir, { withFileTypes: true });
|
|
11001
|
+
for (const a of entries) {
|
|
11002
|
+
if (!a.isDirectory()) continue;
|
|
11003
|
+
if (a.name.startsWith(".") || a.name.startsWith("_")) continue;
|
|
11004
|
+
const assignmentDir = resolve20(standaloneDir, a.name);
|
|
11005
|
+
const assignmentMd = resolve20(assignmentDir, "assignment.md");
|
|
11006
|
+
const entry = {
|
|
11007
|
+
projectDir: standaloneDir,
|
|
11008
|
+
projectSlug: null,
|
|
11009
|
+
assignmentDir,
|
|
11010
|
+
assignmentSlug: a.name,
|
|
11011
|
+
standalone: true
|
|
11012
|
+
};
|
|
11013
|
+
if (await fileExists(assignmentMd)) {
|
|
11014
|
+
result.withAssignmentMd.push(entry);
|
|
11015
|
+
} else {
|
|
11016
|
+
result.orphanFolders.push(entry);
|
|
11017
|
+
}
|
|
11018
|
+
}
|
|
11019
|
+
}
|
|
11020
|
+
return result;
|
|
11021
|
+
}
|
|
11022
|
+
|
|
11023
|
+
// src/utils/status-config-resolution.ts
|
|
11024
|
+
init_timestamp();
|
|
11025
|
+
var StatusResolutionError = class extends Error {
|
|
11026
|
+
constructor(message, code) {
|
|
11027
|
+
super(message);
|
|
11028
|
+
this.code = code;
|
|
11029
|
+
this.name = "StatusResolutionError";
|
|
11030
|
+
}
|
|
11031
|
+
};
|
|
11032
|
+
async function scanAssignmentsByStatus(projectsDir, standaloneDir, ids) {
|
|
11033
|
+
const result = /* @__PURE__ */ new Map();
|
|
11034
|
+
for (const id of ids) result.set(id, []);
|
|
11035
|
+
if (ids.length === 0) return result;
|
|
11036
|
+
const idSet = new Set(ids);
|
|
11037
|
+
const walk = await listAssignmentsByProject(projectsDir, standaloneDir);
|
|
11038
|
+
for (const entry of walk.withAssignmentMd) {
|
|
11039
|
+
const assignmentPath = `${entry.assignmentDir}/assignment.md`;
|
|
11040
|
+
let content;
|
|
11041
|
+
try {
|
|
11042
|
+
content = await readFile16(assignmentPath, "utf-8");
|
|
11043
|
+
} catch (err) {
|
|
11044
|
+
const code = err?.code;
|
|
11045
|
+
if (code === "ENOENT") {
|
|
11046
|
+
continue;
|
|
11047
|
+
}
|
|
11048
|
+
throw new StatusResolutionError(
|
|
11049
|
+
`failed to read ${assignmentPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
11050
|
+
"scan-failed"
|
|
11051
|
+
);
|
|
11052
|
+
}
|
|
11053
|
+
const fm = parseAssignmentFrontmatter(content);
|
|
11054
|
+
if (!idSet.has(fm.status)) continue;
|
|
11055
|
+
const display = entry.standalone ? `(standalone) ${entry.assignmentSlug}` : `${entry.projectSlug}/${entry.assignmentSlug}`;
|
|
11056
|
+
const affected = {
|
|
11057
|
+
path: assignmentPath,
|
|
11058
|
+
display,
|
|
11059
|
+
projectSlug: entry.projectSlug,
|
|
11060
|
+
assignmentSlug: entry.assignmentSlug,
|
|
11061
|
+
status: fm.status
|
|
11062
|
+
};
|
|
11063
|
+
const bucket = result.get(fm.status);
|
|
11064
|
+
if (bucket) bucket.push(affected);
|
|
11065
|
+
}
|
|
11066
|
+
return result;
|
|
11067
|
+
}
|
|
11068
|
+
async function applyStatusResolutions(resolutions, affected, validTargets) {
|
|
11069
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
11070
|
+
for (const r of resolutions) {
|
|
11071
|
+
if (seenIds.has(r.id)) {
|
|
11072
|
+
throw new StatusResolutionError(
|
|
11073
|
+
`duplicate resolution for status id "${r.id}"`,
|
|
11074
|
+
"duplicate-id"
|
|
11075
|
+
);
|
|
11076
|
+
}
|
|
11077
|
+
seenIds.add(r.id);
|
|
11078
|
+
if (!affected.has(r.id)) {
|
|
11079
|
+
throw new StatusResolutionError(
|
|
11080
|
+
`stale resolution: status id "${r.id}" was not scanned`,
|
|
11081
|
+
"stale-resolution"
|
|
11082
|
+
);
|
|
11083
|
+
}
|
|
11084
|
+
if (r.mode === "remap") {
|
|
11085
|
+
if (r.target === r.id) {
|
|
11086
|
+
throw new StatusResolutionError(
|
|
11087
|
+
`invalid remap target "${r.target}" \u2014 same as source`,
|
|
11088
|
+
"invalid-target"
|
|
11089
|
+
);
|
|
11090
|
+
}
|
|
11091
|
+
if (!validTargets.has(r.target)) {
|
|
11092
|
+
throw new StatusResolutionError(
|
|
11093
|
+
`invalid remap target "${r.target}" \u2014 not in valid targets`,
|
|
11094
|
+
"invalid-target"
|
|
11095
|
+
);
|
|
11096
|
+
}
|
|
11097
|
+
}
|
|
11098
|
+
}
|
|
11099
|
+
const buffer = /* @__PURE__ */ new Map();
|
|
11100
|
+
for (const r of resolutions) {
|
|
11101
|
+
if (r.mode !== "remap") continue;
|
|
11102
|
+
const list = affected.get(r.id) ?? [];
|
|
11103
|
+
for (const a of list) {
|
|
11104
|
+
try {
|
|
11105
|
+
const content = await readFile16(a.path, "utf-8");
|
|
11106
|
+
buffer.set(a.path, content);
|
|
11107
|
+
} catch (err) {
|
|
11108
|
+
const code = err?.code;
|
|
11109
|
+
if (code === "ENOENT") {
|
|
11110
|
+
continue;
|
|
11111
|
+
}
|
|
11112
|
+
throw new StatusResolutionError(
|
|
11113
|
+
`failed to buffer ${a.path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
11114
|
+
"scan-failed"
|
|
11115
|
+
);
|
|
11116
|
+
}
|
|
11117
|
+
}
|
|
11118
|
+
}
|
|
11119
|
+
const writtenPaths = [];
|
|
11120
|
+
let remapped = 0;
|
|
11121
|
+
const byId = /* @__PURE__ */ new Map();
|
|
11122
|
+
for (const r of resolutions) {
|
|
11123
|
+
if (r.mode === "remap") byId.set(r.id, { mode: "remap", count: 0, target: r.target });
|
|
11124
|
+
else byId.set(r.id, { mode: "delete", count: 0 });
|
|
11125
|
+
}
|
|
11126
|
+
try {
|
|
11127
|
+
for (const r of resolutions) {
|
|
11128
|
+
if (r.mode !== "remap") continue;
|
|
11129
|
+
const list = affected.get(r.id) ?? [];
|
|
11130
|
+
for (const a of list) {
|
|
11131
|
+
let current;
|
|
11132
|
+
try {
|
|
11133
|
+
current = await readFile16(a.path, "utf-8");
|
|
11134
|
+
} catch (err) {
|
|
11135
|
+
const code = err?.code;
|
|
11136
|
+
if (code === "ENOENT") {
|
|
11137
|
+
continue;
|
|
11138
|
+
}
|
|
11139
|
+
throw err;
|
|
11140
|
+
}
|
|
11141
|
+
const fm = parseAssignmentFrontmatter(current);
|
|
11142
|
+
if (fm.status !== r.id) {
|
|
11143
|
+
console.warn(
|
|
11144
|
+
`status-config-resolution: skipping remap of ${a.display} \u2014 status drifted from "${r.id}" to "${fm.status}"`
|
|
11145
|
+
);
|
|
11146
|
+
continue;
|
|
11147
|
+
}
|
|
11148
|
+
const next = updateAssignmentFile(current, {
|
|
11149
|
+
status: r.target,
|
|
11150
|
+
updated: nowTimestamp()
|
|
11151
|
+
});
|
|
11152
|
+
await writeFile4(a.path, next, "utf-8");
|
|
11153
|
+
writtenPaths.push(a.path);
|
|
11154
|
+
remapped++;
|
|
11155
|
+
const bucket = byId.get(r.id);
|
|
11156
|
+
if (bucket) bucket.count++;
|
|
11157
|
+
}
|
|
11158
|
+
}
|
|
11159
|
+
} catch (err) {
|
|
11160
|
+
for (const p of writtenPaths) {
|
|
11161
|
+
const original = buffer.get(p);
|
|
11162
|
+
if (original !== void 0) {
|
|
11163
|
+
await writeFile4(p, original, "utf-8").catch((rollbackErr) => {
|
|
11164
|
+
console.error(
|
|
11165
|
+
`status-config-resolution: rollback write failed for ${p}:`,
|
|
11166
|
+
rollbackErr
|
|
11167
|
+
);
|
|
11168
|
+
});
|
|
11169
|
+
}
|
|
11170
|
+
}
|
|
11171
|
+
throw new StatusResolutionError(
|
|
11172
|
+
`remap write failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
11173
|
+
"write-failed"
|
|
11174
|
+
);
|
|
11175
|
+
}
|
|
11176
|
+
let deleted = 0;
|
|
11177
|
+
for (const r of resolutions) {
|
|
11178
|
+
if (r.mode !== "delete") continue;
|
|
11179
|
+
const list = affected.get(r.id) ?? [];
|
|
11180
|
+
for (const a of list) {
|
|
11181
|
+
try {
|
|
11182
|
+
const current = await readFile16(a.path, "utf-8");
|
|
11183
|
+
const fm = parseAssignmentFrontmatter(current);
|
|
11184
|
+
if (fm.status !== r.id) {
|
|
11185
|
+
console.warn(
|
|
11186
|
+
`status-config-resolution: skipping delete of ${a.display} \u2014 status drifted from "${r.id}" to "${fm.status}"`
|
|
11187
|
+
);
|
|
11188
|
+
continue;
|
|
11189
|
+
}
|
|
11190
|
+
} catch {
|
|
11191
|
+
continue;
|
|
11192
|
+
}
|
|
11193
|
+
const assignmentDir = dirname4(a.path);
|
|
11194
|
+
try {
|
|
11195
|
+
await rm2(assignmentDir, { recursive: true, force: true });
|
|
11196
|
+
deleted++;
|
|
11197
|
+
const bucket = byId.get(r.id);
|
|
11198
|
+
if (bucket) bucket.count++;
|
|
11199
|
+
} catch (err) {
|
|
11200
|
+
throw new StatusResolutionError(
|
|
11201
|
+
`delete failed for ${a.display}: ${err instanceof Error ? err.message : String(err)}`,
|
|
11202
|
+
"delete-failed"
|
|
11203
|
+
);
|
|
11204
|
+
}
|
|
11205
|
+
}
|
|
11206
|
+
}
|
|
11207
|
+
return { remapped, deleted, byId };
|
|
11208
|
+
}
|
|
11209
|
+
async function verifyNoDriftedOrphans(projectsDir, standaloneDir, droppedIds) {
|
|
11210
|
+
if (droppedIds.length === 0) return;
|
|
11211
|
+
const finalScan = await scanAssignmentsByStatus(projectsDir, standaloneDir, droppedIds);
|
|
11212
|
+
const remaining = [];
|
|
11213
|
+
for (const id of droppedIds) {
|
|
11214
|
+
const list = finalScan.get(id) ?? [];
|
|
11215
|
+
for (const a of list) {
|
|
11216
|
+
remaining.push(`${a.display} (status: ${a.status})`);
|
|
11217
|
+
}
|
|
11218
|
+
}
|
|
11219
|
+
if (remaining.length > 0) {
|
|
11220
|
+
throw new StatusResolutionError(
|
|
11221
|
+
`concurrent edit detected: ${remaining.length} assignment(s) still reference a dropped status after resolutions applied: ${remaining.join(", ")}`,
|
|
11222
|
+
"drift-detected"
|
|
11223
|
+
);
|
|
11224
|
+
}
|
|
11225
|
+
}
|
|
11226
|
+
|
|
11227
|
+
// src/dashboard/api-status-config.ts
|
|
11228
|
+
var AFFECTED_SAMPLE_CAP = 50;
|
|
11229
|
+
function toSummary(a) {
|
|
11230
|
+
return {
|
|
11231
|
+
display: a.display,
|
|
11232
|
+
projectSlug: a.projectSlug,
|
|
11233
|
+
assignmentSlug: a.assignmentSlug,
|
|
11234
|
+
status: a.status
|
|
11235
|
+
};
|
|
11236
|
+
}
|
|
11237
|
+
function buildAffectedResponse(id, list) {
|
|
11238
|
+
const truncated = list.length > AFFECTED_SAMPLE_CAP;
|
|
11239
|
+
return {
|
|
11240
|
+
id,
|
|
11241
|
+
count: list.length,
|
|
11242
|
+
truncated,
|
|
11243
|
+
assignments: list.slice(0, AFFECTED_SAMPLE_CAP).map(toSummary)
|
|
11244
|
+
};
|
|
11245
|
+
}
|
|
11246
|
+
function isString(x) {
|
|
11247
|
+
return typeof x === "string" && x.length > 0;
|
|
11248
|
+
}
|
|
11249
|
+
function parseResolutions(raw) {
|
|
11250
|
+
if (raw === void 0) {
|
|
11251
|
+
return { resolutions: [], malformed: null, duplicateIds: null };
|
|
11252
|
+
}
|
|
11253
|
+
if (!Array.isArray(raw)) {
|
|
11254
|
+
return { resolutions: [], malformed: "resolutions must be an array", duplicateIds: null };
|
|
11255
|
+
}
|
|
11256
|
+
const out = [];
|
|
11257
|
+
const seen = /* @__PURE__ */ new Set();
|
|
11258
|
+
const dups = /* @__PURE__ */ new Set();
|
|
11259
|
+
for (let i = 0; i < raw.length; i++) {
|
|
11260
|
+
const r = raw[i];
|
|
11261
|
+
if (!r || typeof r !== "object") {
|
|
11262
|
+
return { resolutions: [], malformed: `resolutions[${i}] must be an object`, duplicateIds: null };
|
|
11263
|
+
}
|
|
11264
|
+
const obj = r;
|
|
11265
|
+
if (!isString(obj.id)) {
|
|
11266
|
+
return { resolutions: [], malformed: `resolutions[${i}].id must be a non-empty string`, duplicateIds: null };
|
|
11267
|
+
}
|
|
11268
|
+
if (obj.mode === "remap") {
|
|
11269
|
+
if (!isString(obj.target)) {
|
|
11270
|
+
return { resolutions: [], malformed: `resolutions[${i}].target must be a non-empty string for mode=remap`, duplicateIds: null };
|
|
11271
|
+
}
|
|
11272
|
+
if (seen.has(obj.id)) dups.add(obj.id);
|
|
11273
|
+
seen.add(obj.id);
|
|
11274
|
+
out.push({ id: obj.id, mode: "remap", target: obj.target });
|
|
11275
|
+
} else if (obj.mode === "delete") {
|
|
11276
|
+
if (seen.has(obj.id)) dups.add(obj.id);
|
|
11277
|
+
seen.add(obj.id);
|
|
11278
|
+
out.push({ id: obj.id, mode: "delete" });
|
|
11279
|
+
} else {
|
|
11280
|
+
return { resolutions: [], malformed: `resolutions[${i}].mode must be 'remap' or 'delete'`, duplicateIds: null };
|
|
11281
|
+
}
|
|
11282
|
+
}
|
|
11283
|
+
if (dups.size > 0) {
|
|
11284
|
+
return { resolutions: [], malformed: null, duplicateIds: [...dups] };
|
|
11285
|
+
}
|
|
11286
|
+
return { resolutions: out, malformed: null, duplicateIds: null };
|
|
11287
|
+
}
|
|
11288
|
+
function mapResolutionErrorToHttp(err, applied) {
|
|
11289
|
+
switch (err.code) {
|
|
11290
|
+
case "duplicate-id":
|
|
11291
|
+
return { status: 400, body: { error: "duplicate-resolution-ids", message: err.message } };
|
|
11292
|
+
case "stale-resolution":
|
|
11293
|
+
return { status: 400, body: { error: "stale-resolution", message: err.message } };
|
|
11294
|
+
case "invalid-target":
|
|
11295
|
+
return { status: 400, body: { error: "invalid-remap-target", message: err.message } };
|
|
11296
|
+
case "scan-failed":
|
|
11297
|
+
return { status: 500, body: { error: "scan-failed", cause: err.message } };
|
|
11298
|
+
case "write-failed":
|
|
11299
|
+
return { status: 500, body: { error: "remap-write-failed", cause: err.message } };
|
|
11300
|
+
case "delete-failed":
|
|
11301
|
+
return {
|
|
11302
|
+
status: 500,
|
|
11303
|
+
body: { error: "delete-failed", cause: err.message, applied: applied ?? void 0 }
|
|
11304
|
+
};
|
|
11305
|
+
case "drift-detected":
|
|
11306
|
+
return {
|
|
11307
|
+
status: 409,
|
|
11308
|
+
body: {
|
|
11309
|
+
error: "concurrent-edit",
|
|
11310
|
+
cause: err.message,
|
|
11311
|
+
applied: applied ?? void 0
|
|
11312
|
+
}
|
|
11313
|
+
};
|
|
11314
|
+
}
|
|
11315
|
+
}
|
|
11316
|
+
function createStatusConfigRouter(projectsDir, assignmentsDir2) {
|
|
11317
|
+
const router = Router8();
|
|
11318
|
+
router.get("/", async (_req, res) => {
|
|
11319
|
+
try {
|
|
11320
|
+
const config = await getStatusConfig();
|
|
11321
|
+
res.json({
|
|
11322
|
+
statuses: config.statuses,
|
|
11323
|
+
order: config.order,
|
|
11324
|
+
transitions: config.transitions,
|
|
11325
|
+
custom: config.custom
|
|
11326
|
+
});
|
|
11327
|
+
} catch (error) {
|
|
11328
|
+
console.error("Error getting status config:", error);
|
|
11329
|
+
res.status(500).json({ error: "Failed to get status config" });
|
|
11330
|
+
}
|
|
11331
|
+
});
|
|
11332
|
+
router.get("/affected/:id", async (req, res) => {
|
|
11333
|
+
try {
|
|
11334
|
+
const id = req.params.id;
|
|
11335
|
+
if (!isString(id)) {
|
|
11336
|
+
res.status(400).json({ error: "malformed-id" });
|
|
11337
|
+
return;
|
|
11338
|
+
}
|
|
11339
|
+
const affected = await scanAssignmentsByStatus(projectsDir, assignmentsDir2, [id]);
|
|
11340
|
+
const list = affected.get(id) ?? [];
|
|
11341
|
+
res.json(buildAffectedResponse(id, list));
|
|
11342
|
+
} catch (error) {
|
|
11343
|
+
console.error("Error getting affected assignments:", error);
|
|
11344
|
+
res.status(500).json({ error: "Failed to get affected assignments" });
|
|
11345
|
+
}
|
|
11346
|
+
});
|
|
11347
|
+
router.post("/", async (req, res) => {
|
|
11348
|
+
try {
|
|
11349
|
+
const { statuses, order, transitions, resolutions: rawResolutions } = req.body ?? {};
|
|
11350
|
+
if (!Array.isArray(statuses) || !Array.isArray(order) || !Array.isArray(transitions)) {
|
|
11351
|
+
res.status(400).json({ error: "malformed-statuses", message: "Request body must include statuses, order, and transitions arrays" });
|
|
11352
|
+
return;
|
|
11353
|
+
}
|
|
11354
|
+
const parsed = parseResolutions(rawResolutions);
|
|
11355
|
+
if (parsed.malformed) {
|
|
11356
|
+
res.status(400).json({ error: "malformed-resolutions", message: parsed.malformed });
|
|
11357
|
+
return;
|
|
11358
|
+
}
|
|
11359
|
+
if (parsed.duplicateIds) {
|
|
11360
|
+
res.status(400).json({ error: "duplicate-resolution-ids", ids: parsed.duplicateIds });
|
|
11361
|
+
return;
|
|
11362
|
+
}
|
|
11363
|
+
const resolutions = parsed.resolutions;
|
|
11364
|
+
const currentConfig = await getStatusConfig();
|
|
11365
|
+
const oldIds = new Set(currentConfig.statuses.map((s) => s.id));
|
|
11366
|
+
const newIds = /* @__PURE__ */ new Set();
|
|
11367
|
+
for (const s of statuses) {
|
|
11368
|
+
if (s && typeof s === "object" && isString(s.id)) {
|
|
11369
|
+
newIds.add(s.id);
|
|
11370
|
+
}
|
|
11371
|
+
}
|
|
11372
|
+
const droppedIds = [];
|
|
11373
|
+
for (const id of oldIds) {
|
|
11374
|
+
if (!newIds.has(id)) droppedIds.push(id);
|
|
11375
|
+
}
|
|
11376
|
+
for (const r of resolutions) {
|
|
11377
|
+
if (!droppedIds.includes(r.id)) {
|
|
11378
|
+
res.status(400).json({ error: "stale-resolution", id: r.id });
|
|
11379
|
+
return;
|
|
11380
|
+
}
|
|
11381
|
+
}
|
|
11382
|
+
const validTargets = /* @__PURE__ */ new Set();
|
|
11383
|
+
for (const id of newIds) {
|
|
11384
|
+
if (oldIds.has(id)) validTargets.add(id);
|
|
11385
|
+
}
|
|
11386
|
+
for (const r of resolutions) {
|
|
11387
|
+
if (r.mode !== "remap") continue;
|
|
11388
|
+
if (r.target === r.id) {
|
|
11389
|
+
res.status(400).json({ error: "invalid-remap-target", reason: "same-as-source", id: r.id, target: r.target });
|
|
11390
|
+
return;
|
|
11391
|
+
}
|
|
11392
|
+
if (!newIds.has(r.target)) {
|
|
11393
|
+
res.status(400).json({ error: "invalid-remap-target", reason: "not-in-new-config", id: r.id, target: r.target });
|
|
11394
|
+
return;
|
|
11395
|
+
}
|
|
11396
|
+
if (!oldIds.has(r.target)) {
|
|
11397
|
+
res.status(400).json({ error: "invalid-remap-target", reason: "not-in-old-config", id: r.id, target: r.target });
|
|
11398
|
+
return;
|
|
11399
|
+
}
|
|
11400
|
+
}
|
|
11401
|
+
let affectedMap;
|
|
11402
|
+
try {
|
|
11403
|
+
affectedMap = await scanAssignmentsByStatus(projectsDir, assignmentsDir2, droppedIds);
|
|
11404
|
+
} catch (err) {
|
|
11405
|
+
if (err instanceof StatusResolutionError) {
|
|
11406
|
+
const mapped = mapResolutionErrorToHttp(err, null);
|
|
11407
|
+
res.status(mapped.status).json(mapped.body);
|
|
11408
|
+
return;
|
|
11409
|
+
}
|
|
11410
|
+
throw err;
|
|
11411
|
+
}
|
|
11412
|
+
const unresolved = [];
|
|
11413
|
+
const resolvedById = new Set(resolutions.map((r) => r.id));
|
|
11414
|
+
for (const id of droppedIds) {
|
|
11415
|
+
const list = affectedMap.get(id) ?? [];
|
|
11416
|
+
if (list.length > 0 && !resolvedById.has(id)) {
|
|
11417
|
+
unresolved.push(buildAffectedResponse(id, list));
|
|
11418
|
+
}
|
|
11419
|
+
}
|
|
11420
|
+
if (unresolved.length > 0) {
|
|
11421
|
+
res.status(409).json({ error: "unresolved-orphans", unresolved });
|
|
11422
|
+
return;
|
|
11423
|
+
}
|
|
11424
|
+
let applied;
|
|
11425
|
+
try {
|
|
11426
|
+
applied = await applyStatusResolutions(resolutions, affectedMap, validTargets);
|
|
11427
|
+
} catch (err) {
|
|
11428
|
+
if (err instanceof StatusResolutionError) {
|
|
11429
|
+
const mapped = mapResolutionErrorToHttp(err, null);
|
|
11430
|
+
res.status(mapped.status).json(mapped.body);
|
|
11431
|
+
return;
|
|
11432
|
+
}
|
|
11433
|
+
throw err;
|
|
11434
|
+
}
|
|
11435
|
+
try {
|
|
11436
|
+
await verifyNoDriftedOrphans(projectsDir, assignmentsDir2, droppedIds);
|
|
11437
|
+
} catch (err) {
|
|
11438
|
+
if (err instanceof StatusResolutionError) {
|
|
11439
|
+
const mapped = mapResolutionErrorToHttp(err, {
|
|
11440
|
+
remapped: applied.remapped,
|
|
11441
|
+
deleted: applied.deleted
|
|
11442
|
+
});
|
|
11443
|
+
res.status(mapped.status).json(mapped.body);
|
|
11444
|
+
return;
|
|
11445
|
+
}
|
|
11446
|
+
throw err;
|
|
11447
|
+
}
|
|
11448
|
+
try {
|
|
11449
|
+
await writeStatusConfig({ statuses, order, transitions });
|
|
11450
|
+
} catch (err) {
|
|
11451
|
+
console.error("Error saving status config after applying resolutions:", err);
|
|
11452
|
+
res.status(500).json({
|
|
11453
|
+
error: "config-write-failed",
|
|
11454
|
+
message: err instanceof Error ? err.message : String(err),
|
|
11455
|
+
applied: { remapped: applied.remapped, deleted: applied.deleted }
|
|
11456
|
+
});
|
|
11457
|
+
return;
|
|
11458
|
+
}
|
|
11459
|
+
clearStatusConfigCache();
|
|
11460
|
+
const config = await getStatusConfig();
|
|
11461
|
+
const byId = {};
|
|
11462
|
+
for (const [id, entry] of applied.byId) {
|
|
11463
|
+
byId[id] = entry.target !== void 0 ? { mode: entry.mode, count: entry.count, target: entry.target } : { mode: entry.mode, count: entry.count };
|
|
11464
|
+
}
|
|
11465
|
+
res.json({
|
|
11466
|
+
statuses: config.statuses,
|
|
11467
|
+
order: config.order,
|
|
11468
|
+
transitions: config.transitions,
|
|
11469
|
+
custom: config.custom,
|
|
11470
|
+
applied: { remapped: applied.remapped, deleted: applied.deleted, byId }
|
|
11471
|
+
});
|
|
11472
|
+
} catch (error) {
|
|
11473
|
+
console.error("Error saving status config:", error);
|
|
11474
|
+
res.status(500).json({ error: "Failed to save status config" });
|
|
11475
|
+
}
|
|
11476
|
+
});
|
|
11477
|
+
router.delete("/", async (_req, res) => {
|
|
11478
|
+
try {
|
|
11479
|
+
await deleteStatusConfig();
|
|
11480
|
+
clearStatusConfigCache();
|
|
11481
|
+
const config = await getStatusConfig();
|
|
11482
|
+
res.json({
|
|
11483
|
+
statuses: config.statuses,
|
|
11484
|
+
order: config.order,
|
|
11485
|
+
transitions: config.transitions,
|
|
11486
|
+
custom: config.custom
|
|
11487
|
+
});
|
|
11488
|
+
} catch (error) {
|
|
11489
|
+
console.error("Error resetting status config:", error);
|
|
11490
|
+
res.status(500).json({ error: "Failed to reset status config" });
|
|
10389
11491
|
}
|
|
10390
11492
|
});
|
|
10391
11493
|
return router;
|
|
10392
11494
|
}
|
|
10393
11495
|
|
|
10394
11496
|
// src/dashboard/api-leases.ts
|
|
10395
|
-
import { Router as
|
|
11497
|
+
import { Router as Router9 } from "express";
|
|
10396
11498
|
|
|
10397
11499
|
// src/db/leases-db.ts
|
|
10398
11500
|
init_paths();
|
|
10399
11501
|
import Database2 from "better-sqlite3";
|
|
10400
|
-
import { randomUUID as
|
|
10401
|
-
import { resolve as
|
|
11502
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
11503
|
+
import { resolve as resolve21 } from "path";
|
|
10402
11504
|
var db2 = null;
|
|
10403
11505
|
var LEASE_SCHEMA_VERSION = "1";
|
|
10404
11506
|
var SCHEMA_SQL2 = `
|
|
@@ -10485,7 +11587,7 @@ function isBusyError(err) {
|
|
|
10485
11587
|
}
|
|
10486
11588
|
function initLeasesDb(dbPath) {
|
|
10487
11589
|
if (db2) return db2;
|
|
10488
|
-
const finalPath = dbPath ??
|
|
11590
|
+
const finalPath = dbPath ?? resolve21(syntaurRoot(), "syntaur.db");
|
|
10489
11591
|
db2 = new Database2(finalPath);
|
|
10490
11592
|
db2.pragma("journal_mode = WAL");
|
|
10491
11593
|
db2.pragma("busy_timeout = 5000");
|
|
@@ -10574,7 +11676,7 @@ function forceReleaseLease(lease_id) {
|
|
|
10574
11676
|
|
|
10575
11677
|
// src/dashboard/api-leases.ts
|
|
10576
11678
|
function createLeasesRouter(broadcast) {
|
|
10577
|
-
const router =
|
|
11679
|
+
const router = Router9();
|
|
10578
11680
|
router.get("/", (_req, res) => {
|
|
10579
11681
|
try {
|
|
10580
11682
|
initLeasesDb();
|
|
@@ -10628,15 +11730,302 @@ function createLeasesRouter(broadcast) {
|
|
|
10628
11730
|
return router;
|
|
10629
11731
|
}
|
|
10630
11732
|
|
|
11733
|
+
// src/dashboard/api-usage.ts
|
|
11734
|
+
import { Router as Router10 } from "express";
|
|
11735
|
+
|
|
11736
|
+
// src/db/usage-db.ts
|
|
11737
|
+
init_paths();
|
|
11738
|
+
import Database3 from "better-sqlite3";
|
|
11739
|
+
import { resolve as resolve22 } from "path";
|
|
11740
|
+
var db3 = null;
|
|
11741
|
+
var USAGE_SCHEMA_VERSION = "1";
|
|
11742
|
+
var SCHEMA_SQL3 = `
|
|
11743
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
11744
|
+
key TEXT PRIMARY KEY,
|
|
11745
|
+
value TEXT
|
|
11746
|
+
);
|
|
11747
|
+
|
|
11748
|
+
CREATE TABLE IF NOT EXISTS usage_events (
|
|
11749
|
+
session_id TEXT NOT NULL,
|
|
11750
|
+
model TEXT NOT NULL,
|
|
11751
|
+
tool TEXT NOT NULL,
|
|
11752
|
+
event_ts TEXT NOT NULL,
|
|
11753
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
11754
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
11755
|
+
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
11756
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
11757
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
11758
|
+
total_cost REAL NOT NULL DEFAULT 0,
|
|
11759
|
+
cwd TEXT,
|
|
11760
|
+
project_slug TEXT NOT NULL DEFAULT '',
|
|
11761
|
+
assignment_slug TEXT NOT NULL DEFAULT '',
|
|
11762
|
+
raw_json TEXT,
|
|
11763
|
+
updated_at TEXT NOT NULL,
|
|
11764
|
+
PRIMARY KEY (session_id, model)
|
|
11765
|
+
);
|
|
11766
|
+
|
|
11767
|
+
CREATE INDEX IF NOT EXISTS idx_usage_events_ts
|
|
11768
|
+
ON usage_events (event_ts);
|
|
11769
|
+
CREATE INDEX IF NOT EXISTS idx_usage_events_attribution
|
|
11770
|
+
ON usage_events (project_slug, assignment_slug);
|
|
11771
|
+
CREATE INDEX IF NOT EXISTS idx_usage_events_cwd
|
|
11772
|
+
ON usage_events (cwd, event_ts);
|
|
11773
|
+
|
|
11774
|
+
CREATE TABLE IF NOT EXISTS usage_daily (
|
|
11775
|
+
day TEXT NOT NULL,
|
|
11776
|
+
tool TEXT NOT NULL,
|
|
11777
|
+
model TEXT NOT NULL,
|
|
11778
|
+
project_slug TEXT NOT NULL DEFAULT '',
|
|
11779
|
+
assignment_slug TEXT NOT NULL DEFAULT '',
|
|
11780
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
11781
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
11782
|
+
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
11783
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
11784
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
11785
|
+
total_cost REAL NOT NULL DEFAULT 0,
|
|
11786
|
+
frozen INTEGER NOT NULL DEFAULT 0,
|
|
11787
|
+
computed_at TEXT NOT NULL,
|
|
11788
|
+
PRIMARY KEY (day, tool, model, project_slug, assignment_slug)
|
|
11789
|
+
);
|
|
11790
|
+
|
|
11791
|
+
CREATE INDEX IF NOT EXISTS idx_usage_daily_day
|
|
11792
|
+
ON usage_daily (day);
|
|
11793
|
+
`;
|
|
11794
|
+
function initUsageDb(dbPath) {
|
|
11795
|
+
if (db3) return db3;
|
|
11796
|
+
const finalPath = dbPath ?? resolve22(syntaurRoot(), "syntaur.db");
|
|
11797
|
+
db3 = new Database3(finalPath);
|
|
11798
|
+
db3.pragma("journal_mode = WAL");
|
|
11799
|
+
db3.pragma("busy_timeout = 5000");
|
|
11800
|
+
db3.pragma("foreign_keys = ON");
|
|
11801
|
+
const database = db3;
|
|
11802
|
+
const runMigrations = database.transaction(() => {
|
|
11803
|
+
database.exec(SCHEMA_SQL3);
|
|
11804
|
+
database.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)").run("usage_schema_version", USAGE_SCHEMA_VERSION);
|
|
11805
|
+
});
|
|
11806
|
+
runMigrations.exclusive();
|
|
11807
|
+
return db3;
|
|
11808
|
+
}
|
|
11809
|
+
function getUsageDb() {
|
|
11810
|
+
if (!db3) {
|
|
11811
|
+
throw new Error("Usage database not initialized. Call initUsageDb() first.");
|
|
11812
|
+
}
|
|
11813
|
+
return db3;
|
|
11814
|
+
}
|
|
11815
|
+
function closeUsageDb() {
|
|
11816
|
+
if (db3) {
|
|
11817
|
+
db3.close();
|
|
11818
|
+
db3 = null;
|
|
11819
|
+
}
|
|
11820
|
+
}
|
|
11821
|
+
function listEvents(filter = {}) {
|
|
11822
|
+
const database = getUsageDb();
|
|
11823
|
+
const where = [];
|
|
11824
|
+
const params2 = [];
|
|
11825
|
+
if (filter.since) {
|
|
11826
|
+
where.push("event_ts >= ?");
|
|
11827
|
+
params2.push(filter.since);
|
|
11828
|
+
}
|
|
11829
|
+
if (filter.until) {
|
|
11830
|
+
where.push("event_ts <= ?");
|
|
11831
|
+
params2.push(filter.until);
|
|
11832
|
+
}
|
|
11833
|
+
if (filter.projectSlug !== void 0) {
|
|
11834
|
+
where.push("project_slug = ?");
|
|
11835
|
+
params2.push(filter.projectSlug);
|
|
11836
|
+
}
|
|
11837
|
+
if (filter.assignmentSlug !== void 0) {
|
|
11838
|
+
where.push("assignment_slug = ?");
|
|
11839
|
+
params2.push(filter.assignmentSlug);
|
|
11840
|
+
}
|
|
11841
|
+
if (filter.tool) {
|
|
11842
|
+
where.push("tool = ?");
|
|
11843
|
+
params2.push(filter.tool);
|
|
11844
|
+
}
|
|
11845
|
+
const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
11846
|
+
return database.prepare(
|
|
11847
|
+
`SELECT session_id, model, tool, event_ts,
|
|
11848
|
+
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
|
|
11849
|
+
total_tokens, total_cost,
|
|
11850
|
+
cwd, project_slug, assignment_slug, raw_json, updated_at
|
|
11851
|
+
FROM usage_events ${whereSql}
|
|
11852
|
+
ORDER BY event_ts DESC`
|
|
11853
|
+
).all(...params2);
|
|
11854
|
+
}
|
|
11855
|
+
function listDaily(filter = {}) {
|
|
11856
|
+
const database = getUsageDb();
|
|
11857
|
+
const where = [];
|
|
11858
|
+
const params2 = [];
|
|
11859
|
+
if (filter.since) {
|
|
11860
|
+
where.push("day >= ?");
|
|
11861
|
+
params2.push(filter.since);
|
|
11862
|
+
}
|
|
11863
|
+
if (filter.until) {
|
|
11864
|
+
where.push("day <= ?");
|
|
11865
|
+
params2.push(filter.until);
|
|
11866
|
+
}
|
|
11867
|
+
if (filter.projectSlug !== void 0) {
|
|
11868
|
+
where.push("project_slug = ?");
|
|
11869
|
+
params2.push(filter.projectSlug);
|
|
11870
|
+
}
|
|
11871
|
+
if (filter.assignmentSlug !== void 0) {
|
|
11872
|
+
where.push("assignment_slug = ?");
|
|
11873
|
+
params2.push(filter.assignmentSlug);
|
|
11874
|
+
}
|
|
11875
|
+
if (filter.tool) {
|
|
11876
|
+
where.push("tool = ?");
|
|
11877
|
+
params2.push(filter.tool);
|
|
11878
|
+
}
|
|
11879
|
+
const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
11880
|
+
return database.prepare(
|
|
11881
|
+
`SELECT day, tool, model, project_slug, assignment_slug,
|
|
11882
|
+
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
|
|
11883
|
+
total_tokens, total_cost, frozen, computed_at
|
|
11884
|
+
FROM usage_daily ${whereSql}
|
|
11885
|
+
ORDER BY day DESC, project_slug, assignment_slug, tool, model`
|
|
11886
|
+
).all(...params2);
|
|
11887
|
+
}
|
|
11888
|
+
|
|
11889
|
+
// src/dashboard/api-usage.ts
|
|
11890
|
+
function createUsageRouter() {
|
|
11891
|
+
const router = Router10();
|
|
11892
|
+
router.get("/", (req, res) => {
|
|
11893
|
+
try {
|
|
11894
|
+
initUsageDb();
|
|
11895
|
+
const filter = extractCommonFilter(req.query);
|
|
11896
|
+
const rows = listDaily(filter);
|
|
11897
|
+
res.json({
|
|
11898
|
+
daily: rows,
|
|
11899
|
+
summary: summarize(rows, groupByMode(req.query.groupBy))
|
|
11900
|
+
});
|
|
11901
|
+
} catch (error) {
|
|
11902
|
+
res.status(500).json({
|
|
11903
|
+
error: error instanceof Error ? error.message : "List failed"
|
|
11904
|
+
});
|
|
11905
|
+
}
|
|
11906
|
+
});
|
|
11907
|
+
router.get("/projects/:projectSlug", (req, res) => {
|
|
11908
|
+
try {
|
|
11909
|
+
initUsageDb();
|
|
11910
|
+
const projectSlug = req.params.projectSlug;
|
|
11911
|
+
const rows = listDaily({
|
|
11912
|
+
...extractCommonFilter(req.query),
|
|
11913
|
+
projectSlug
|
|
11914
|
+
});
|
|
11915
|
+
res.json({
|
|
11916
|
+
projectSlug,
|
|
11917
|
+
daily: rows,
|
|
11918
|
+
summary: summarize(rows, "assignment")
|
|
11919
|
+
});
|
|
11920
|
+
} catch (error) {
|
|
11921
|
+
res.status(500).json({
|
|
11922
|
+
error: error instanceof Error ? error.message : "List failed"
|
|
11923
|
+
});
|
|
11924
|
+
}
|
|
11925
|
+
});
|
|
11926
|
+
router.get("/projects/:projectSlug/assignments/:assignmentSlug", (req, res) => {
|
|
11927
|
+
try {
|
|
11928
|
+
initUsageDb();
|
|
11929
|
+
const { projectSlug, assignmentSlug } = req.params;
|
|
11930
|
+
const common = extractCommonFilter(req.query);
|
|
11931
|
+
const dailyRows = listDaily({
|
|
11932
|
+
...common,
|
|
11933
|
+
projectSlug,
|
|
11934
|
+
assignmentSlug
|
|
11935
|
+
});
|
|
11936
|
+
const eventRows = listEvents(
|
|
11937
|
+
eventsFilterFromDaily({ ...common, projectSlug, assignmentSlug })
|
|
11938
|
+
);
|
|
11939
|
+
res.json({
|
|
11940
|
+
projectSlug,
|
|
11941
|
+
assignmentSlug,
|
|
11942
|
+
daily: dailyRows,
|
|
11943
|
+
events: eventRows
|
|
11944
|
+
});
|
|
11945
|
+
} catch (error) {
|
|
11946
|
+
res.status(500).json({
|
|
11947
|
+
error: error instanceof Error ? error.message : "List failed"
|
|
11948
|
+
});
|
|
11949
|
+
}
|
|
11950
|
+
});
|
|
11951
|
+
router.get("/standalone/:assignmentId", (req, res) => {
|
|
11952
|
+
try {
|
|
11953
|
+
initUsageDb();
|
|
11954
|
+
const assignmentSlug = req.params.assignmentId;
|
|
11955
|
+
const common = extractCommonFilter(req.query);
|
|
11956
|
+
const dailyRows = listDaily({
|
|
11957
|
+
...common,
|
|
11958
|
+
projectSlug: "",
|
|
11959
|
+
assignmentSlug
|
|
11960
|
+
});
|
|
11961
|
+
const eventRows = listEvents(
|
|
11962
|
+
eventsFilterFromDaily({ ...common, projectSlug: "", assignmentSlug })
|
|
11963
|
+
);
|
|
11964
|
+
res.json({
|
|
11965
|
+
assignmentId: assignmentSlug,
|
|
11966
|
+
daily: dailyRows,
|
|
11967
|
+
events: eventRows
|
|
11968
|
+
});
|
|
11969
|
+
} catch (error) {
|
|
11970
|
+
res.status(500).json({
|
|
11971
|
+
error: error instanceof Error ? error.message : "List failed"
|
|
11972
|
+
});
|
|
11973
|
+
}
|
|
11974
|
+
});
|
|
11975
|
+
return router;
|
|
11976
|
+
}
|
|
11977
|
+
function extractCommonFilter(query) {
|
|
11978
|
+
const out = {};
|
|
11979
|
+
if (typeof query.since === "string") out.since = query.since;
|
|
11980
|
+
if (typeof query.until === "string") out.until = query.until;
|
|
11981
|
+
if (typeof query.tool === "string") out.tool = query.tool;
|
|
11982
|
+
if (typeof query.project === "string") out.projectSlug = query.project;
|
|
11983
|
+
if (typeof query.assignment === "string") out.assignmentSlug = query.assignment;
|
|
11984
|
+
return out;
|
|
11985
|
+
}
|
|
11986
|
+
function eventsFilterFromDaily(common) {
|
|
11987
|
+
const out = {};
|
|
11988
|
+
if (common.since) out.since = `${common.since}T00:00:00.000Z`;
|
|
11989
|
+
if (common.until) out.until = `${common.until}T23:59:59.999Z`;
|
|
11990
|
+
if (common.tool) out.tool = common.tool;
|
|
11991
|
+
if (common.projectSlug !== void 0) out.projectSlug = common.projectSlug;
|
|
11992
|
+
if (common.assignmentSlug !== void 0) out.assignmentSlug = common.assignmentSlug;
|
|
11993
|
+
return out;
|
|
11994
|
+
}
|
|
11995
|
+
function groupByMode(q) {
|
|
11996
|
+
return q === "assignment" ? "assignment" : "project";
|
|
11997
|
+
}
|
|
11998
|
+
function summarize(rows, mode) {
|
|
11999
|
+
const map = /* @__PURE__ */ new Map();
|
|
12000
|
+
for (const r of rows) {
|
|
12001
|
+
const key = mode === "project" ? r.project_slug : `${r.project_slug}\0${r.assignment_slug}`;
|
|
12002
|
+
const existing = map.get(key);
|
|
12003
|
+
if (existing) {
|
|
12004
|
+
existing.totalTokens += r.total_tokens;
|
|
12005
|
+
existing.totalCost += r.total_cost;
|
|
12006
|
+
if (r.day > existing.lastEventDay) existing.lastEventDay = r.day;
|
|
12007
|
+
} else {
|
|
12008
|
+
map.set(key, {
|
|
12009
|
+
projectSlug: r.project_slug,
|
|
12010
|
+
assignmentSlug: mode === "project" ? "" : r.assignment_slug,
|
|
12011
|
+
totalTokens: r.total_tokens,
|
|
12012
|
+
totalCost: r.total_cost,
|
|
12013
|
+
lastEventDay: r.day
|
|
12014
|
+
});
|
|
12015
|
+
}
|
|
12016
|
+
}
|
|
12017
|
+
return [...map.values()].sort((a, b) => b.totalTokens - a.totalTokens);
|
|
12018
|
+
}
|
|
12019
|
+
|
|
10631
12020
|
// src/dashboard/api-playbooks.ts
|
|
10632
12021
|
init_api();
|
|
10633
12022
|
init_parser();
|
|
10634
12023
|
init_slug();
|
|
10635
12024
|
init_timestamp();
|
|
10636
12025
|
init_fs();
|
|
10637
|
-
import { Router as
|
|
10638
|
-
import { resolve as
|
|
10639
|
-
import { readFile as
|
|
12026
|
+
import { Router as Router11 } from "express";
|
|
12027
|
+
import { resolve as resolve23 } from "path";
|
|
12028
|
+
import { readFile as readFile17 } from "fs/promises";
|
|
10640
12029
|
init_playbooks();
|
|
10641
12030
|
function statusForPlaybookError(code) {
|
|
10642
12031
|
switch (code) {
|
|
@@ -10651,7 +12040,7 @@ function statusForPlaybookError(code) {
|
|
|
10651
12040
|
}
|
|
10652
12041
|
}
|
|
10653
12042
|
function createPlaybooksRouter(playbooksDir2) {
|
|
10654
|
-
const router =
|
|
12043
|
+
const router = Router11();
|
|
10655
12044
|
router.get("/", async (_req, res) => {
|
|
10656
12045
|
try {
|
|
10657
12046
|
const playbooks = await listPlaybooks(playbooksDir2);
|
|
@@ -10718,8 +12107,8 @@ function createPlaybooksRouter(playbooksDir2) {
|
|
|
10718
12107
|
res.status(404).json({ error: `Playbook "${req.params.slug}" not found` });
|
|
10719
12108
|
return;
|
|
10720
12109
|
}
|
|
10721
|
-
const filePath =
|
|
10722
|
-
const content = await
|
|
12110
|
+
const filePath = resolve23(playbooksDir2, resolved.filename);
|
|
12111
|
+
const content = await readFile17(filePath, "utf-8");
|
|
10723
12112
|
res.json({
|
|
10724
12113
|
documentType: "playbook",
|
|
10725
12114
|
title: `Edit Playbook: ${resolved.slug}`,
|
|
@@ -10744,7 +12133,7 @@ function createPlaybooksRouter(playbooksDir2) {
|
|
|
10744
12133
|
return;
|
|
10745
12134
|
}
|
|
10746
12135
|
await ensureDir(playbooksDir2);
|
|
10747
|
-
const filePath =
|
|
12136
|
+
const filePath = resolve23(playbooksDir2, `${slug}.md`);
|
|
10748
12137
|
if (await fileExists(filePath)) {
|
|
10749
12138
|
res.status(409).json({ error: `Playbook "${slug}" already exists` });
|
|
10750
12139
|
return;
|
|
@@ -10768,7 +12157,7 @@ function createPlaybooksRouter(playbooksDir2) {
|
|
|
10768
12157
|
res.status(404).json({ error: `Playbook "${req.params.slug}" not found` });
|
|
10769
12158
|
return;
|
|
10770
12159
|
}
|
|
10771
|
-
const filePath =
|
|
12160
|
+
const filePath = resolve23(playbooksDir2, resolved.filename);
|
|
10772
12161
|
await writeFileForce(filePath, content);
|
|
10773
12162
|
await rebuildPlaybookManifest(playbooksDir2);
|
|
10774
12163
|
res.json({ slug: resolved.slug, path: filePath });
|
|
@@ -10815,10 +12204,10 @@ init_fs_migration();
|
|
|
10815
12204
|
init_parser2();
|
|
10816
12205
|
init_fs();
|
|
10817
12206
|
init_paths();
|
|
10818
|
-
import { Router as
|
|
10819
|
-
import { readdir as
|
|
10820
|
-
import { resolve as resolvePath, dirname as
|
|
10821
|
-
import { rename as
|
|
12207
|
+
import { Router as Router12 } from "express";
|
|
12208
|
+
import { readdir as readdir11 } from "fs/promises";
|
|
12209
|
+
import { resolve as resolvePath, dirname as dirname5 } from "path";
|
|
12210
|
+
import { rename as rename5, mkdir as mkdir2 } from "fs/promises";
|
|
10822
12211
|
init_slug();
|
|
10823
12212
|
|
|
10824
12213
|
// src/utils/promote-todos.ts
|
|
@@ -10827,7 +12216,7 @@ init_parser2();
|
|
|
10827
12216
|
// src/commands/create-assignment.ts
|
|
10828
12217
|
init_slug();
|
|
10829
12218
|
init_timestamp();
|
|
10830
|
-
import { resolve as
|
|
12219
|
+
import { resolve as resolve24 } from "path";
|
|
10831
12220
|
init_paths();
|
|
10832
12221
|
init_fs();
|
|
10833
12222
|
init_config2();
|
|
@@ -10905,14 +12294,14 @@ async function createAssignmentCommand(title, options) {
|
|
|
10905
12294
|
if (options.oneOff) {
|
|
10906
12295
|
const standaloneRoot = assignmentsDir();
|
|
10907
12296
|
folderName = id;
|
|
10908
|
-
assignmentDir =
|
|
12297
|
+
assignmentDir = resolve24(standaloneRoot, folderName);
|
|
10909
12298
|
projectSlug = null;
|
|
10910
12299
|
await ensureDir(standaloneRoot);
|
|
10911
12300
|
} else {
|
|
10912
12301
|
const baseDir = options.dir ? expandHome(options.dir) : config.defaultProjectDir;
|
|
10913
12302
|
projectSlug = options.project;
|
|
10914
|
-
const projectDir =
|
|
10915
|
-
const projectMdPath =
|
|
12303
|
+
const projectDir = resolve24(baseDir, projectSlug);
|
|
12304
|
+
const projectMdPath = resolve24(projectDir, "project.md");
|
|
10916
12305
|
if (!await fileExists(projectDir) || !await fileExists(projectMdPath)) {
|
|
10917
12306
|
throw new Error(
|
|
10918
12307
|
`Project "${projectSlug}" not found at ${projectDir}.
|
|
@@ -10920,9 +12309,9 @@ Run 'syntaur create-project' first or use --one-off.`
|
|
|
10920
12309
|
);
|
|
10921
12310
|
}
|
|
10922
12311
|
if (dependsOn.length > 0) {
|
|
10923
|
-
const depDirBase =
|
|
12312
|
+
const depDirBase = resolve24(projectDir, "assignments");
|
|
10924
12313
|
for (const dep of dependsOn) {
|
|
10925
|
-
const depDir =
|
|
12314
|
+
const depDir = resolve24(depDirBase, dep);
|
|
10926
12315
|
if (!await fileExists(depDir)) {
|
|
10927
12316
|
console.warn(
|
|
10928
12317
|
`Warning: dependency "${dep}" does not exist in project "${projectSlug}" yet.`
|
|
@@ -10931,7 +12320,7 @@ Run 'syntaur create-project' first or use --one-off.`
|
|
|
10931
12320
|
}
|
|
10932
12321
|
}
|
|
10933
12322
|
folderName = assignmentSlug;
|
|
10934
|
-
assignmentDir =
|
|
12323
|
+
assignmentDir = resolve24(projectDir, "assignments", folderName);
|
|
10935
12324
|
}
|
|
10936
12325
|
if (await fileExists(assignmentDir)) {
|
|
10937
12326
|
throw new Error(
|
|
@@ -10943,7 +12332,7 @@ Use --slug to specify a different slug.`
|
|
|
10943
12332
|
const companionAssignmentRef = projectSlug === null ? id : assignmentSlug;
|
|
10944
12333
|
const files = [
|
|
10945
12334
|
[
|
|
10946
|
-
|
|
12335
|
+
resolve24(assignmentDir, "assignment.md"),
|
|
10947
12336
|
renderAssignment({
|
|
10948
12337
|
id,
|
|
10949
12338
|
slug: assignmentSlug,
|
|
@@ -10961,35 +12350,35 @@ Use --slug to specify a different slug.`
|
|
|
10961
12350
|
})
|
|
10962
12351
|
],
|
|
10963
12352
|
[
|
|
10964
|
-
|
|
12353
|
+
resolve24(assignmentDir, "scratchpad.md"),
|
|
10965
12354
|
renderScratchpad({
|
|
10966
12355
|
assignmentSlug: companionAssignmentRef,
|
|
10967
12356
|
timestamp
|
|
10968
12357
|
})
|
|
10969
12358
|
],
|
|
10970
12359
|
[
|
|
10971
|
-
|
|
12360
|
+
resolve24(assignmentDir, "handoff.md"),
|
|
10972
12361
|
renderHandoff({
|
|
10973
12362
|
assignmentSlug: companionAssignmentRef,
|
|
10974
12363
|
timestamp
|
|
10975
12364
|
})
|
|
10976
12365
|
],
|
|
10977
12366
|
[
|
|
10978
|
-
|
|
12367
|
+
resolve24(assignmentDir, "decision-record.md"),
|
|
10979
12368
|
renderDecisionRecord({
|
|
10980
12369
|
assignmentSlug: companionAssignmentRef,
|
|
10981
12370
|
timestamp
|
|
10982
12371
|
})
|
|
10983
12372
|
],
|
|
10984
12373
|
[
|
|
10985
|
-
|
|
12374
|
+
resolve24(assignmentDir, "progress.md"),
|
|
10986
12375
|
renderProgress({
|
|
10987
12376
|
assignment: companionAssignmentRef,
|
|
10988
12377
|
timestamp
|
|
10989
12378
|
})
|
|
10990
12379
|
],
|
|
10991
12380
|
[
|
|
10992
|
-
|
|
12381
|
+
resolve24(assignmentDir, "comments.md"),
|
|
10993
12382
|
renderComments({
|
|
10994
12383
|
assignment: companionAssignmentRef,
|
|
10995
12384
|
timestamp
|
|
@@ -11038,6 +12427,12 @@ Use --slug to specify a different slug.`
|
|
|
11038
12427
|
|
|
11039
12428
|
// src/utils/promote-todos.ts
|
|
11040
12429
|
init_slug();
|
|
12430
|
+
var BundlePromoteError = class extends Error {
|
|
12431
|
+
constructor(message) {
|
|
12432
|
+
super(message);
|
|
12433
|
+
this.name = "BundlePromoteError";
|
|
12434
|
+
}
|
|
12435
|
+
};
|
|
11041
12436
|
function parsePromoteTarget(target) {
|
|
11042
12437
|
if (!target || typeof target !== "object") {
|
|
11043
12438
|
return { ok: false, error: "target is required for new-assignment mode" };
|
|
@@ -11076,6 +12471,15 @@ async function promoteTodosToNewAssignment(groups, options) {
|
|
|
11076
12471
|
if (!options.title.trim()) {
|
|
11077
12472
|
throw new Error("Title is required.");
|
|
11078
12473
|
}
|
|
12474
|
+
for (const group of groups) {
|
|
12475
|
+
for (const it of group.items) {
|
|
12476
|
+
if (it.bundleId !== null) {
|
|
12477
|
+
throw new BundlePromoteError(
|
|
12478
|
+
`Todo [t:${it.id}] is part of bundle b:${it.bundleId}; run \`syntaur todo bundle remove b:${it.bundleId} ${it.id}\` first.`
|
|
12479
|
+
);
|
|
12480
|
+
}
|
|
12481
|
+
}
|
|
12482
|
+
}
|
|
11079
12483
|
const acceptanceCriteria = [];
|
|
11080
12484
|
for (const g of groups) {
|
|
11081
12485
|
for (const it of g.items) acceptanceCriteria.push(it.description);
|
|
@@ -11154,7 +12558,7 @@ function touchItem3(item) {
|
|
|
11154
12558
|
item.updatedAt = now;
|
|
11155
12559
|
}
|
|
11156
12560
|
function createTodosRouter(todosDir2, broadcast, projectsDir) {
|
|
11157
|
-
const router =
|
|
12561
|
+
const router = Router12();
|
|
11158
12562
|
function broadcastUpdate() {
|
|
11159
12563
|
broadcast({ type: "todos-updated", timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
11160
12564
|
}
|
|
@@ -11264,13 +12668,17 @@ function createTodosRouter(todosDir2, broadcast, projectsDir) {
|
|
|
11264
12668
|
broadcastUpdate();
|
|
11265
12669
|
res.json(out.result);
|
|
11266
12670
|
} catch (error) {
|
|
12671
|
+
if (error instanceof BundlePromoteError) {
|
|
12672
|
+
res.status(400).json({ error: error.message });
|
|
12673
|
+
return;
|
|
12674
|
+
}
|
|
11267
12675
|
res.status(500).json({ error: error instanceof Error ? error.message : "Failed to bulk-promote todos" });
|
|
11268
12676
|
}
|
|
11269
12677
|
});
|
|
11270
12678
|
router.get("/", async (_req, res) => {
|
|
11271
12679
|
try {
|
|
11272
12680
|
await ensureDir(todosDir2);
|
|
11273
|
-
const files = await
|
|
12681
|
+
const files = await readdir11(todosDir2).catch(() => []);
|
|
11274
12682
|
const workspaces = [];
|
|
11275
12683
|
for (const file of files) {
|
|
11276
12684
|
if (typeof file !== "string") continue;
|
|
@@ -11328,7 +12736,8 @@ function createTodosRouter(todosDir2, broadcast, projectsDir) {
|
|
|
11328
12736
|
updatedAt: now,
|
|
11329
12737
|
planDir: null,
|
|
11330
12738
|
linkedAssignmentId: null,
|
|
11331
|
-
linkedAssignmentRef: null
|
|
12739
|
+
linkedAssignmentRef: null,
|
|
12740
|
+
bundleId: null
|
|
11332
12741
|
};
|
|
11333
12742
|
checklist.items.push(newItem);
|
|
11334
12743
|
await writeChecklist(todosDir2, checklist);
|
|
@@ -11383,8 +12792,8 @@ function createTodosRouter(todosDir2, broadcast, projectsDir) {
|
|
|
11383
12792
|
router.post("/:workspace/archive", async (req, res) => {
|
|
11384
12793
|
try {
|
|
11385
12794
|
const { archivePath: archivePath2 } = await Promise.resolve().then(() => (init_parser2(), parser_exports));
|
|
11386
|
-
const { resolve:
|
|
11387
|
-
const { readFile:
|
|
12795
|
+
const { resolve: resolve29 } = await import("path");
|
|
12796
|
+
const { readFile: readFile21 } = await import("fs/promises");
|
|
11388
12797
|
const { writeFileForce: writeFileForce2 } = await Promise.resolve().then(() => (init_fs(), fs_exports));
|
|
11389
12798
|
const workspace = getWorkspaceParam(req.params.workspace);
|
|
11390
12799
|
const checklist = await readChecklist(todosDir2, workspace);
|
|
@@ -11400,10 +12809,10 @@ function createTodosRouter(todosDir2, broadcast, projectsDir) {
|
|
|
11400
12809
|
(e) => e.itemIds.every((id) => completedIds.has(id))
|
|
11401
12810
|
);
|
|
11402
12811
|
const archFile = archivePath2(todosDir2, workspace, checklist.archiveInterval);
|
|
11403
|
-
await ensureDir(
|
|
12812
|
+
await ensureDir(resolve29(todosDir2, "archive"));
|
|
11404
12813
|
let archContent = "";
|
|
11405
12814
|
if (await fileExists(archFile)) {
|
|
11406
|
-
archContent = await
|
|
12815
|
+
archContent = await readFile21(archFile, "utf-8");
|
|
11407
12816
|
archContent = archContent.trimEnd() + "\n\n";
|
|
11408
12817
|
} else {
|
|
11409
12818
|
archContent = `---
|
|
@@ -11655,6 +13064,9 @@ workspace: ${workspace}
|
|
|
11655
13064
|
const item = checklist.items.find((i) => i.id === id);
|
|
11656
13065
|
if (!item) return { error: `Todo "${id}" not found` };
|
|
11657
13066
|
if (item.status === "completed") return { error: `Todo "${id}" is already completed` };
|
|
13067
|
+
if (item.bundleId !== null) {
|
|
13068
|
+
return { error: `Todo [t:${id}] is part of bundle b:${item.bundleId}; run \`syntaur todo bundle remove b:${item.bundleId} ${id}\` first.` };
|
|
13069
|
+
}
|
|
11658
13070
|
items.push(item);
|
|
11659
13071
|
}
|
|
11660
13072
|
const scopeLabel = workspace === "_global" ? "_global" : `workspace:${workspace}`;
|
|
@@ -11682,7 +13094,7 @@ workspace: ${workspace}
|
|
|
11682
13094
|
const { readConfig: readConfig2 } = await Promise.resolve().then(() => (init_config2(), config_exports));
|
|
11683
13095
|
const { assignmentsDir: assignmentsDirFn } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
11684
13096
|
const { fileExists: fileExists2, writeFileForce: writeFileForce2 } = await Promise.resolve().then(() => (init_fs(), fs_exports));
|
|
11685
|
-
const { readFile:
|
|
13097
|
+
const { readFile: readFile21 } = await import("fs/promises");
|
|
11686
13098
|
const { appendTodosToAssignmentBody: appendTodosToAssignmentBody2, touchAssignmentUpdated: touchAssignmentUpdated2 } = await Promise.resolve().then(() => (init_assignment_todos(), assignment_todos_exports));
|
|
11687
13099
|
const { nowTimestamp: nowTimestamp3 } = await Promise.resolve().then(() => (init_timestamp(), timestamp_exports));
|
|
11688
13100
|
let assignmentRef;
|
|
@@ -11703,7 +13115,7 @@ workspace: ${workspace}
|
|
|
11703
13115
|
}
|
|
11704
13116
|
const assignmentMdPath = resolvePath2(assignmentDir, "assignment.md");
|
|
11705
13117
|
if (!await fileExists2(assignmentMdPath)) return { error: `Target assignment not found: ${assignmentMdPath}` };
|
|
11706
|
-
let content = await
|
|
13118
|
+
let content = await readFile21(assignmentMdPath, "utf-8");
|
|
11707
13119
|
content = appendTodosToAssignmentBody2(
|
|
11708
13120
|
content,
|
|
11709
13121
|
items.map((it) => ({
|
|
@@ -11743,6 +13155,10 @@ workspace: ${workspace}
|
|
|
11743
13155
|
broadcastUpdate();
|
|
11744
13156
|
res.json(result);
|
|
11745
13157
|
} catch (error) {
|
|
13158
|
+
if (error instanceof BundlePromoteError) {
|
|
13159
|
+
res.status(400).json({ error: error.message });
|
|
13160
|
+
return;
|
|
13161
|
+
}
|
|
11746
13162
|
res.status(500).json({ error: error instanceof Error ? error.message : "Failed to promote todos" });
|
|
11747
13163
|
}
|
|
11748
13164
|
});
|
|
@@ -11810,8 +13226,8 @@ workspace: ${workspace}
|
|
|
11810
13226
|
if (await fileExists(newPlanDir)) {
|
|
11811
13227
|
return { status: 409, error: "plan dir already exists in target" };
|
|
11812
13228
|
}
|
|
11813
|
-
await mkdir2(
|
|
11814
|
-
await
|
|
13229
|
+
await mkdir2(dirname5(newPlanDir), { recursive: true });
|
|
13230
|
+
await rename5(item.planDir, newPlanDir);
|
|
11815
13231
|
item.planDir = newPlanDir;
|
|
11816
13232
|
}
|
|
11817
13233
|
sourceChecklist.items.splice(idx, 1);
|
|
@@ -11886,9 +13302,9 @@ init_parser2();
|
|
|
11886
13302
|
init_fs();
|
|
11887
13303
|
init_paths();
|
|
11888
13304
|
init_slug();
|
|
11889
|
-
import { Router as
|
|
11890
|
-
import { mkdir as mkdir3, readFile as
|
|
11891
|
-
import { resolve as
|
|
13305
|
+
import { Router as Router13 } from "express";
|
|
13306
|
+
import { mkdir as mkdir3, readFile as readFile18, rename as rename6 } from "fs/promises";
|
|
13307
|
+
import { resolve as resolve25, dirname as dirname6 } from "path";
|
|
11892
13308
|
var WORKSPACE_REGEX2 = /^[a-z0-9_][a-z0-9-]*$/;
|
|
11893
13309
|
function touchItem4(item) {
|
|
11894
13310
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -11903,7 +13319,7 @@ function params(req) {
|
|
|
11903
13319
|
return req.params;
|
|
11904
13320
|
}
|
|
11905
13321
|
async function projectExists(projectsDir, slug) {
|
|
11906
|
-
return fileExists(
|
|
13322
|
+
return fileExists(resolve25(projectsDir, slug, "project.md"));
|
|
11907
13323
|
}
|
|
11908
13324
|
async function ensureProjectTodosDir(projectsDir, slug) {
|
|
11909
13325
|
const todosDir2 = projectTodosDir(projectsDir, slug);
|
|
@@ -11920,7 +13336,7 @@ async function ensureProjectTodosDir(projectsDir, slug) {
|
|
|
11920
13336
|
throw err;
|
|
11921
13337
|
}
|
|
11922
13338
|
try {
|
|
11923
|
-
await mkdir3(
|
|
13339
|
+
await mkdir3(resolve25(todosDir2, "archive"), { recursive: false });
|
|
11924
13340
|
} catch (err) {
|
|
11925
13341
|
const code = err.code;
|
|
11926
13342
|
if (code === "EEXIST") return;
|
|
@@ -11936,7 +13352,7 @@ function notFound(res, slug) {
|
|
|
11936
13352
|
res.status(404).json({ error: `Project "${slug}" not found` });
|
|
11937
13353
|
}
|
|
11938
13354
|
function createProjectTodosRouter(projectsDir, broadcast, workspaceTodosDir) {
|
|
11939
|
-
const router =
|
|
13355
|
+
const router = Router13({ mergeParams: true });
|
|
11940
13356
|
function broadcastUpdate(projectSlug) {
|
|
11941
13357
|
broadcast({ type: "todos-updated", projectSlug, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
11942
13358
|
}
|
|
@@ -12003,7 +13419,8 @@ function createProjectTodosRouter(projectsDir, broadcast, workspaceTodosDir) {
|
|
|
12003
13419
|
updatedAt: now,
|
|
12004
13420
|
planDir: null,
|
|
12005
13421
|
linkedAssignmentId: null,
|
|
12006
|
-
linkedAssignmentRef: null
|
|
13422
|
+
linkedAssignmentRef: null,
|
|
13423
|
+
bundleId: null
|
|
12007
13424
|
};
|
|
12008
13425
|
checklist.workspace = slug;
|
|
12009
13426
|
checklist.items.push(newItem);
|
|
@@ -12108,7 +13525,7 @@ function createProjectTodosRouter(projectsDir, broadcast, workspaceTodosDir) {
|
|
|
12108
13525
|
const archFile = archivePath(todosDir2, slug, checklist.archiveInterval);
|
|
12109
13526
|
let archContent = "";
|
|
12110
13527
|
if (await fileExists(archFile)) {
|
|
12111
|
-
archContent = await
|
|
13528
|
+
archContent = await readFile18(archFile, "utf-8");
|
|
12112
13529
|
archContent = archContent.trimEnd() + "\n\n";
|
|
12113
13530
|
} else {
|
|
12114
13531
|
archContent = `---
|
|
@@ -12518,6 +13935,9 @@ workspace: ${slug}
|
|
|
12518
13935
|
const item = checklist.items.find((i) => i.id === id);
|
|
12519
13936
|
if (!item) return { error: `Todo "${id}" not found` };
|
|
12520
13937
|
if (item.status === "completed") return { error: `Todo "${id}" is already completed` };
|
|
13938
|
+
if (item.bundleId !== null) {
|
|
13939
|
+
return { error: `Todo [t:${id}] is part of bundle b:${item.bundleId}; run \`syntaur todo bundle remove b:${item.bundleId} ${id}\` first.` };
|
|
13940
|
+
}
|
|
12521
13941
|
items.push(item);
|
|
12522
13942
|
}
|
|
12523
13943
|
const scopeLabel = `project:${slug}`;
|
|
@@ -12552,17 +13972,17 @@ workspace: ${slug}
|
|
|
12552
13972
|
if (tg.includes("/")) {
|
|
12553
13973
|
const parts = tg.split("/");
|
|
12554
13974
|
if (parts.length !== 2) return { error: `Invalid target.assignment "${tg}"` };
|
|
12555
|
-
assignmentDir =
|
|
13975
|
+
assignmentDir = resolve25(projectsDir, parts[0], "assignments", parts[1]);
|
|
12556
13976
|
assignmentRef = `${parts[0]}/${parts[1]}`;
|
|
12557
13977
|
} else if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(tg)) {
|
|
12558
|
-
assignmentDir =
|
|
13978
|
+
assignmentDir = resolve25(assignmentsDirFn(), tg);
|
|
12559
13979
|
assignmentRef = tg;
|
|
12560
13980
|
} else {
|
|
12561
13981
|
return { error: `Invalid target.assignment "${tg}"` };
|
|
12562
13982
|
}
|
|
12563
|
-
const assignmentMdPath =
|
|
13983
|
+
const assignmentMdPath = resolve25(assignmentDir, "assignment.md");
|
|
12564
13984
|
if (!await fileExists(assignmentMdPath)) return { error: `Target assignment not found: ${assignmentMdPath}` };
|
|
12565
|
-
let content = await
|
|
13985
|
+
let content = await readFile18(assignmentMdPath, "utf-8");
|
|
12566
13986
|
content = appendTodosToAssignmentBody2(
|
|
12567
13987
|
content,
|
|
12568
13988
|
items.map((it) => ({
|
|
@@ -12607,6 +14027,10 @@ workspace: ${slug}
|
|
|
12607
14027
|
broadcastUpdate(slug);
|
|
12608
14028
|
res.json(result);
|
|
12609
14029
|
} catch (error) {
|
|
14030
|
+
if (error instanceof BundlePromoteError) {
|
|
14031
|
+
res.status(400).json({ error: error.message });
|
|
14032
|
+
return;
|
|
14033
|
+
}
|
|
12610
14034
|
if (error.code === "PROJECT_GONE") {
|
|
12611
14035
|
notFound(res, getProjectIdParam(params(req).projectId));
|
|
12612
14036
|
return;
|
|
@@ -12691,8 +14115,8 @@ workspace: ${slug}
|
|
|
12691
14115
|
if (await fileExists(newPlanDir)) {
|
|
12692
14116
|
return { status: 409, error: "plan dir already exists in target" };
|
|
12693
14117
|
}
|
|
12694
|
-
await mkdir3(
|
|
12695
|
-
await
|
|
14118
|
+
await mkdir3(dirname6(newPlanDir), { recursive: true });
|
|
14119
|
+
await rename6(item.planDir, newPlanDir);
|
|
12696
14120
|
item.planDir = newPlanDir;
|
|
12697
14121
|
}
|
|
12698
14122
|
sourceChecklist.items.splice(idx, 1);
|
|
@@ -12752,9 +14176,247 @@ workspace: ${slug}
|
|
|
12752
14176
|
return router;
|
|
12753
14177
|
}
|
|
12754
14178
|
|
|
14179
|
+
// src/dashboard/api-bundles.ts
|
|
14180
|
+
import { Router as Router14 } from "express";
|
|
14181
|
+
import { readdir as readdir12 } from "fs/promises";
|
|
14182
|
+
|
|
14183
|
+
// src/todos/bundle-parser.ts
|
|
14184
|
+
init_parser();
|
|
14185
|
+
init_fs();
|
|
14186
|
+
init_paths();
|
|
14187
|
+
init_parser2();
|
|
14188
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
14189
|
+
import { readFile as readFile19 } from "fs/promises";
|
|
14190
|
+
var BUNDLE_ID_REGEX = /^[a-f0-9]{4}$/;
|
|
14191
|
+
var SCOPE_VALUES = /* @__PURE__ */ new Set(["workspace", "project", "global"]);
|
|
14192
|
+
var SCOPE_ID_REGEX = /^[a-z0-9_][a-z0-9_-]*$/;
|
|
14193
|
+
var BUNDLE_LINE_REGEX = /^- b:([a-f0-9]{4})\s+<([^>]*)>\s*$/;
|
|
14194
|
+
function parseScopeToken(raw) {
|
|
14195
|
+
const idx = raw.indexOf(":");
|
|
14196
|
+
if (idx < 0) return null;
|
|
14197
|
+
const scopeRaw = raw.slice(0, idx);
|
|
14198
|
+
const scopeId = raw.slice(idx + 1);
|
|
14199
|
+
if (!SCOPE_VALUES.has(scopeRaw)) return null;
|
|
14200
|
+
if (!scopeId) return null;
|
|
14201
|
+
if (!SCOPE_ID_REGEX.test(scopeId)) return null;
|
|
14202
|
+
return { scope: scopeRaw, scopeId };
|
|
14203
|
+
}
|
|
14204
|
+
function parseTodosToken(raw) {
|
|
14205
|
+
if (!raw) return [];
|
|
14206
|
+
return raw.split(",").map((s) => s.trim()).filter((s) => BUNDLE_ID_REGEX.test(s));
|
|
14207
|
+
}
|
|
14208
|
+
function parseBundleLine(line) {
|
|
14209
|
+
const match = line.match(BUNDLE_LINE_REGEX);
|
|
14210
|
+
if (!match) return null;
|
|
14211
|
+
const id = match[1];
|
|
14212
|
+
const body = match[2];
|
|
14213
|
+
const fields = {};
|
|
14214
|
+
for (const pair of body.split(";")) {
|
|
14215
|
+
const trimmed = pair.trim();
|
|
14216
|
+
if (!trimmed) continue;
|
|
14217
|
+
const eq = trimmed.indexOf("=");
|
|
14218
|
+
if (eq < 0) continue;
|
|
14219
|
+
const key = trimmed.slice(0, eq).trim();
|
|
14220
|
+
const value = decodeMetaValue(trimmed.slice(eq + 1));
|
|
14221
|
+
fields[key] = value;
|
|
14222
|
+
}
|
|
14223
|
+
const scopeParsed = parseScopeToken(fields.scope ?? "");
|
|
14224
|
+
if (!scopeParsed) return null;
|
|
14225
|
+
const todoIds = parseTodosToken(fields.todos ?? "");
|
|
14226
|
+
const createdAt = fields.created ?? "";
|
|
14227
|
+
const updatedAt = fields.updated ?? "";
|
|
14228
|
+
if (!createdAt || !updatedAt) return null;
|
|
14229
|
+
return {
|
|
14230
|
+
id,
|
|
14231
|
+
slug: fields.slug ? fields.slug : null,
|
|
14232
|
+
scope: scopeParsed.scope,
|
|
14233
|
+
scopeId: scopeParsed.scopeId,
|
|
14234
|
+
todoIds,
|
|
14235
|
+
planDir: fields.plan ? fields.plan : null,
|
|
14236
|
+
branch: fields.branch ? fields.branch : null,
|
|
14237
|
+
worktreePath: fields.worktree ? fields.worktree : null,
|
|
14238
|
+
repository: fields.repository ? fields.repository : null,
|
|
14239
|
+
createdAt,
|
|
14240
|
+
updatedAt
|
|
14241
|
+
};
|
|
14242
|
+
}
|
|
14243
|
+
function parseBundles(content) {
|
|
14244
|
+
const [fm, body] = extractFrontmatter2(content);
|
|
14245
|
+
const version = getField(fm, "version") ?? "1";
|
|
14246
|
+
const bundles = [];
|
|
14247
|
+
for (const line of body.split("\n")) {
|
|
14248
|
+
const b = parseBundleLine(line);
|
|
14249
|
+
if (b) bundles.push(b);
|
|
14250
|
+
}
|
|
14251
|
+
return { version, bundles };
|
|
14252
|
+
}
|
|
14253
|
+
async function readBundles(todosDir2) {
|
|
14254
|
+
const path = bundlesPath(todosDir2);
|
|
14255
|
+
if (!await fileExists(path)) return [];
|
|
14256
|
+
const content = await readFile19(path, "utf-8");
|
|
14257
|
+
return parseBundles(content).bundles;
|
|
14258
|
+
}
|
|
14259
|
+
|
|
14260
|
+
// src/dashboard/api-bundles.ts
|
|
14261
|
+
init_parser2();
|
|
14262
|
+
init_fs();
|
|
14263
|
+
var WORKSPACE_REGEX3 = /^[a-z0-9_][a-z0-9-]*$/;
|
|
14264
|
+
function getWorkspaceParam2(value) {
|
|
14265
|
+
if (Array.isArray(value)) return value[0] ?? "";
|
|
14266
|
+
return value ?? "";
|
|
14267
|
+
}
|
|
14268
|
+
function deriveStatus(bundle, items) {
|
|
14269
|
+
const members = bundle.todoIds.map((id) => items.find((i) => i.id === id)).filter((i) => i !== void 0);
|
|
14270
|
+
const counts = { open: 0, in_progress: 0, blocked: 0, completed: 0, total: members.length };
|
|
14271
|
+
for (const m of members) counts[m.status]++;
|
|
14272
|
+
let status;
|
|
14273
|
+
if (counts.total === 0) status = "open";
|
|
14274
|
+
else if (counts.completed === counts.total) status = "completed";
|
|
14275
|
+
else if (counts.completed > 0 && counts.completed < counts.total) status = "mixed";
|
|
14276
|
+
else if (counts.in_progress > 0) status = "in_progress";
|
|
14277
|
+
else if (counts.blocked > 0) status = "blocked";
|
|
14278
|
+
else status = "open";
|
|
14279
|
+
return { status, counts };
|
|
14280
|
+
}
|
|
14281
|
+
function bundlesInScope(bundles, scope, scopeId) {
|
|
14282
|
+
return bundles.filter((b) => b.scope === scope && b.scopeId === scopeId);
|
|
14283
|
+
}
|
|
14284
|
+
function annotate(bundle, items) {
|
|
14285
|
+
const memberMap = new Map(items.map((i) => [i.id, i]));
|
|
14286
|
+
const members = bundle.todoIds.map((id) => memberMap.get(id)).filter((i) => i !== void 0);
|
|
14287
|
+
return { ...bundle, members, derivedStatus: deriveStatus(bundle, items) };
|
|
14288
|
+
}
|
|
14289
|
+
function createBundlesRouter(todosDir2, broadcast) {
|
|
14290
|
+
void broadcast;
|
|
14291
|
+
const router = Router14();
|
|
14292
|
+
function validateWorkspace(req, res, next) {
|
|
14293
|
+
const workspace = getWorkspaceParam2(req.params.workspace);
|
|
14294
|
+
if (workspace && !WORKSPACE_REGEX3.test(workspace)) {
|
|
14295
|
+
res.status(400).json({ error: `Invalid workspace name: "${workspace}"` });
|
|
14296
|
+
return;
|
|
14297
|
+
}
|
|
14298
|
+
next();
|
|
14299
|
+
}
|
|
14300
|
+
router.param("workspace", validateWorkspace);
|
|
14301
|
+
router.get("/", async (_req, res) => {
|
|
14302
|
+
try {
|
|
14303
|
+
await ensureDir(todosDir2);
|
|
14304
|
+
const bundles = await readBundles(todosDir2);
|
|
14305
|
+
const workspaceFiles = await readdir12(todosDir2).catch(() => []);
|
|
14306
|
+
const itemsByKey = /* @__PURE__ */ new Map();
|
|
14307
|
+
for (const f of workspaceFiles) {
|
|
14308
|
+
if (typeof f !== "string") continue;
|
|
14309
|
+
if (!f.endsWith(".md") || f.endsWith("-log.md")) continue;
|
|
14310
|
+
const workspace = f.replace(/\.md$/, "");
|
|
14311
|
+
const checklist = await readChecklist(todosDir2, workspace);
|
|
14312
|
+
itemsByKey.set(workspace, checklist.items);
|
|
14313
|
+
}
|
|
14314
|
+
if (!itemsByKey.has("_global")) itemsByKey.set("_global", (await readChecklist(todosDir2, "_global")).items);
|
|
14315
|
+
const scopes = [];
|
|
14316
|
+
const workspaceKeys = [...new Set(bundles.filter((b) => b.scope === "workspace").map((b) => b.scopeId))];
|
|
14317
|
+
for (const key of workspaceKeys) {
|
|
14318
|
+
const items = itemsByKey.get(key) ?? (await readChecklist(todosDir2, key)).items;
|
|
14319
|
+
scopes.push({
|
|
14320
|
+
scope: "workspace",
|
|
14321
|
+
scopeId: key,
|
|
14322
|
+
bundles: bundlesInScope(bundles, "workspace", key).map((b) => annotate(b, items))
|
|
14323
|
+
});
|
|
14324
|
+
}
|
|
14325
|
+
const globalItems = itemsByKey.get("_global") ?? [];
|
|
14326
|
+
scopes.push({
|
|
14327
|
+
scope: "global",
|
|
14328
|
+
scopeId: "_global",
|
|
14329
|
+
bundles: bundlesInScope(bundles, "global", "_global").map((b) => annotate(b, globalItems))
|
|
14330
|
+
});
|
|
14331
|
+
res.json({ scopes });
|
|
14332
|
+
} catch (error) {
|
|
14333
|
+
res.status(500).json({ error: error instanceof Error ? error.message : "Failed to list bundles" });
|
|
14334
|
+
}
|
|
14335
|
+
});
|
|
14336
|
+
router.get("/:workspace", async (req, res) => {
|
|
14337
|
+
try {
|
|
14338
|
+
const workspace = getWorkspaceParam2(req.params.workspace);
|
|
14339
|
+
await ensureDir(todosDir2);
|
|
14340
|
+
const checklist = await readChecklist(todosDir2, workspace);
|
|
14341
|
+
const bundles = await readBundles(todosDir2);
|
|
14342
|
+
const scope = workspace === "_global" ? "global" : "workspace";
|
|
14343
|
+
const scopeId = workspace;
|
|
14344
|
+
const filtered = bundlesInScope(bundles, scope, scopeId).map((b) => annotate(b, checklist.items));
|
|
14345
|
+
res.json({ scope, scopeId, bundles: filtered });
|
|
14346
|
+
} catch (error) {
|
|
14347
|
+
res.status(500).json({ error: error instanceof Error ? error.message : "Failed to list bundles" });
|
|
14348
|
+
}
|
|
14349
|
+
});
|
|
14350
|
+
return router;
|
|
14351
|
+
}
|
|
14352
|
+
|
|
14353
|
+
// src/dashboard/api-project-bundles.ts
|
|
14354
|
+
init_fs();
|
|
14355
|
+
init_paths();
|
|
14356
|
+
init_slug();
|
|
14357
|
+
import { Router as Router15 } from "express";
|
|
14358
|
+
import { resolve as resolve26 } from "path";
|
|
14359
|
+
init_parser2();
|
|
14360
|
+
function deriveStatus2(bundle, items) {
|
|
14361
|
+
const members = bundle.todoIds.map((id) => items.find((i) => i.id === id)).filter((i) => i !== void 0);
|
|
14362
|
+
const counts = { open: 0, in_progress: 0, blocked: 0, completed: 0, total: members.length };
|
|
14363
|
+
for (const m of members) counts[m.status]++;
|
|
14364
|
+
let status;
|
|
14365
|
+
if (counts.total === 0) status = "open";
|
|
14366
|
+
else if (counts.completed === counts.total) status = "completed";
|
|
14367
|
+
else if (counts.completed > 0 && counts.completed < counts.total) status = "mixed";
|
|
14368
|
+
else if (counts.in_progress > 0) status = "in_progress";
|
|
14369
|
+
else if (counts.blocked > 0) status = "blocked";
|
|
14370
|
+
else status = "open";
|
|
14371
|
+
return { status, counts };
|
|
14372
|
+
}
|
|
14373
|
+
function annotate2(bundle, items) {
|
|
14374
|
+
const memberMap = new Map(items.map((i) => [i.id, i]));
|
|
14375
|
+
const members = bundle.todoIds.map((id) => memberMap.get(id)).filter((i) => i !== void 0);
|
|
14376
|
+
return { ...bundle, members, derivedStatus: deriveStatus2(bundle, items) };
|
|
14377
|
+
}
|
|
14378
|
+
function getProjectIdParam2(value) {
|
|
14379
|
+
if (Array.isArray(value)) return value[0] ?? "";
|
|
14380
|
+
return value ?? "";
|
|
14381
|
+
}
|
|
14382
|
+
function notFound2(res, slug) {
|
|
14383
|
+
res.status(404).json({ error: `Project "${slug}" not found` });
|
|
14384
|
+
}
|
|
14385
|
+
function createProjectBundlesRouter(projectsDir, broadcast) {
|
|
14386
|
+
void broadcast;
|
|
14387
|
+
const router = Router15({ mergeParams: true });
|
|
14388
|
+
function validateProjectId(req, res, next) {
|
|
14389
|
+
const slug = getProjectIdParam2(req.params.projectId);
|
|
14390
|
+
if (!slug || !isValidSlug(slug)) {
|
|
14391
|
+
res.status(400).json({ error: `Invalid project slug: "${slug}"` });
|
|
14392
|
+
return;
|
|
14393
|
+
}
|
|
14394
|
+
next();
|
|
14395
|
+
}
|
|
14396
|
+
router.use(validateProjectId);
|
|
14397
|
+
router.get("/", async (req, res) => {
|
|
14398
|
+
try {
|
|
14399
|
+
const slug = getProjectIdParam2(req.params.projectId);
|
|
14400
|
+
const projectMd = resolve26(projectsDir, slug, "project.md");
|
|
14401
|
+
if (!await fileExists(projectMd)) {
|
|
14402
|
+
notFound2(res, slug);
|
|
14403
|
+
return;
|
|
14404
|
+
}
|
|
14405
|
+
const todosDir2 = projectTodosDir(projectsDir, slug);
|
|
14406
|
+
const bundles = await readBundles(todosDir2);
|
|
14407
|
+
const checklist = await readChecklist(todosDir2, slug);
|
|
14408
|
+
const filtered = bundles.filter((b) => b.scope === "project" && b.scopeId === slug).map((b) => annotate2(b, checklist.items));
|
|
14409
|
+
res.json({ scope: "project", scopeId: slug, bundles: filtered });
|
|
14410
|
+
} catch (error) {
|
|
14411
|
+
res.status(500).json({ error: error instanceof Error ? error.message : "Failed to list bundles" });
|
|
14412
|
+
}
|
|
14413
|
+
});
|
|
14414
|
+
return router;
|
|
14415
|
+
}
|
|
14416
|
+
|
|
12755
14417
|
// src/dashboard/api-backup.ts
|
|
12756
14418
|
init_config2();
|
|
12757
|
-
import { Router as
|
|
14419
|
+
import { Router as Router16 } from "express";
|
|
12758
14420
|
|
|
12759
14421
|
// src/utils/github-backup.ts
|
|
12760
14422
|
init_paths();
|
|
@@ -12762,8 +14424,8 @@ init_fs();
|
|
|
12762
14424
|
init_config2();
|
|
12763
14425
|
import { execFile as execFile2 } from "child_process";
|
|
12764
14426
|
import { promisify as promisify2 } from "util";
|
|
12765
|
-
import { cp, mkdtemp, rm as
|
|
12766
|
-
import { resolve as
|
|
14427
|
+
import { cp, mkdtemp, rm as rm3, readFile as readFile20, writeFile as writeFile5, unlink as unlink5, stat, open as open2, rename as rename7 } from "fs/promises";
|
|
14428
|
+
import { resolve as resolve27, join as join2 } from "path";
|
|
12767
14429
|
import { tmpdir } from "os";
|
|
12768
14430
|
var exec2 = promisify2(execFile2);
|
|
12769
14431
|
var VALID_CATEGORIES = ["projects", "playbooks", "todos", "servers", "config"];
|
|
@@ -12803,7 +14465,7 @@ async function resolveCategoryPath(category) {
|
|
|
12803
14465
|
case "servers":
|
|
12804
14466
|
return { sourcePath: serversDir(), repoPath: "servers", isFile: false };
|
|
12805
14467
|
case "config":
|
|
12806
|
-
return { sourcePath:
|
|
14468
|
+
return { sourcePath: resolve27(syntaurRoot(), "config.md"), repoPath: "config.md", isFile: true };
|
|
12807
14469
|
}
|
|
12808
14470
|
}
|
|
12809
14471
|
async function checkGitInstalled() {
|
|
@@ -12814,7 +14476,7 @@ async function checkGitInstalled() {
|
|
|
12814
14476
|
}
|
|
12815
14477
|
}
|
|
12816
14478
|
async function acquireLock() {
|
|
12817
|
-
const lockPath =
|
|
14479
|
+
const lockPath = resolve27(syntaurRoot(), LOCK_FILE_NAME);
|
|
12818
14480
|
await ensureDir(syntaurRoot());
|
|
12819
14481
|
try {
|
|
12820
14482
|
const handle = await open2(lockPath, "wx");
|
|
@@ -12823,7 +14485,7 @@ async function acquireLock() {
|
|
|
12823
14485
|
return lockPath;
|
|
12824
14486
|
} catch (err) {
|
|
12825
14487
|
if (err.code === "EEXIST") {
|
|
12826
|
-
const pid = await
|
|
14488
|
+
const pid = await readFile20(lockPath, "utf-8").catch(() => "");
|
|
12827
14489
|
throw new Error(
|
|
12828
14490
|
`Backup operation already in progress (lock file at ${lockPath}, pid ${pid.trim() || "unknown"}). If stale, delete the file and retry.`
|
|
12829
14491
|
);
|
|
@@ -12833,7 +14495,7 @@ async function acquireLock() {
|
|
|
12833
14495
|
}
|
|
12834
14496
|
async function releaseLock(lockPath) {
|
|
12835
14497
|
try {
|
|
12836
|
-
await
|
|
14498
|
+
await unlink5(lockPath);
|
|
12837
14499
|
} catch {
|
|
12838
14500
|
}
|
|
12839
14501
|
}
|
|
@@ -12861,7 +14523,7 @@ async function copyRecursive(src, dest) {
|
|
|
12861
14523
|
await ensureDir(dest);
|
|
12862
14524
|
await cp(src, dest, { recursive: true, force: true });
|
|
12863
14525
|
} else {
|
|
12864
|
-
await ensureDir(
|
|
14526
|
+
await ensureDir(resolve27(dest, ".."));
|
|
12865
14527
|
await cp(src, dest, { force: true });
|
|
12866
14528
|
}
|
|
12867
14529
|
}
|
|
@@ -12870,7 +14532,7 @@ function resolveCategoriesStrict(csv) {
|
|
|
12870
14532
|
return parseCategoriesStrict(parts);
|
|
12871
14533
|
}
|
|
12872
14534
|
async function readSanitizedConfig(configPath) {
|
|
12873
|
-
const content = await
|
|
14535
|
+
const content = await readFile20(configPath, "utf-8");
|
|
12874
14536
|
return content.replace(/^(\s*lastBackup:\s*).*$/m, "$1null").replace(/^(\s*lastRestore:\s*).*$/m, "$1null");
|
|
12875
14537
|
}
|
|
12876
14538
|
async function backupToGithub(overrides) {
|
|
@@ -12899,9 +14561,9 @@ async function backupToGithub(overrides) {
|
|
|
12899
14561
|
const { sourcePath, repoPath, isFile } = await resolveCategoryPath(category);
|
|
12900
14562
|
const destPath = join2(tmpDir, repoPath);
|
|
12901
14563
|
if (isFile) {
|
|
12902
|
-
await
|
|
14564
|
+
await rm3(destPath, { force: true });
|
|
12903
14565
|
} else {
|
|
12904
|
-
await
|
|
14566
|
+
await rm3(destPath, { recursive: true, force: true });
|
|
12905
14567
|
}
|
|
12906
14568
|
if (!await fileExists(sourcePath)) {
|
|
12907
14569
|
console.warn(`Category "${category}": no local data at ${sourcePath}; backup will reflect deletion.`);
|
|
@@ -12909,8 +14571,8 @@ async function backupToGithub(overrides) {
|
|
|
12909
14571
|
}
|
|
12910
14572
|
if (category === "config") {
|
|
12911
14573
|
const sanitized = await readSanitizedConfig(sourcePath);
|
|
12912
|
-
await ensureDir(
|
|
12913
|
-
await
|
|
14574
|
+
await ensureDir(resolve27(destPath, ".."));
|
|
14575
|
+
await writeFile5(destPath, sanitized, "utf-8");
|
|
12914
14576
|
} else {
|
|
12915
14577
|
await copyRecursive(sourcePath, destPath);
|
|
12916
14578
|
}
|
|
@@ -12955,7 +14617,7 @@ async function backupToGithub(overrides) {
|
|
|
12955
14617
|
};
|
|
12956
14618
|
} finally {
|
|
12957
14619
|
if (tmpDir) {
|
|
12958
|
-
await
|
|
14620
|
+
await rm3(tmpDir, { recursive: true, force: true }).catch(() => {
|
|
12959
14621
|
});
|
|
12960
14622
|
}
|
|
12961
14623
|
await releaseLock(lockPath);
|
|
@@ -12963,18 +14625,18 @@ async function backupToGithub(overrides) {
|
|
|
12963
14625
|
}
|
|
12964
14626
|
async function safeRestoreCategory(localPath, repoSrcPath, isFile) {
|
|
12965
14627
|
if (isFile) {
|
|
12966
|
-
await ensureDir(
|
|
14628
|
+
await ensureDir(resolve27(localPath, ".."));
|
|
12967
14629
|
await cp(repoSrcPath, localPath, { force: true });
|
|
12968
14630
|
return;
|
|
12969
14631
|
}
|
|
12970
14632
|
const stagingPath = `${localPath}.syntaur-restore-staging`;
|
|
12971
14633
|
const backupPath = `${localPath}.syntaur-restore-backup`;
|
|
12972
|
-
await
|
|
14634
|
+
await rm3(stagingPath, { recursive: true, force: true });
|
|
12973
14635
|
const backupExistsBefore = await fileExists(backupPath);
|
|
12974
14636
|
const localExistsBefore = await fileExists(localPath);
|
|
12975
14637
|
if (backupExistsBefore) {
|
|
12976
14638
|
if (!localExistsBefore) {
|
|
12977
|
-
await
|
|
14639
|
+
await rename7(backupPath, localPath);
|
|
12978
14640
|
} else {
|
|
12979
14641
|
throw new Error(
|
|
12980
14642
|
`Cannot restore "${localPath}": a stale crash-recovery backup exists at ${backupPath} while the current path also exists. Inspect both and remove the one you don't need, then retry.`
|
|
@@ -12986,18 +14648,18 @@ async function safeRestoreCategory(localPath, repoSrcPath, isFile) {
|
|
|
12986
14648
|
await cp(repoSrcPath, stagingPath, { recursive: true, force: true });
|
|
12987
14649
|
const localExists = await fileExists(localPath);
|
|
12988
14650
|
if (localExists) {
|
|
12989
|
-
await
|
|
14651
|
+
await rename7(localPath, backupPath);
|
|
12990
14652
|
localMovedAside = true;
|
|
12991
14653
|
}
|
|
12992
|
-
await
|
|
12993
|
-
await
|
|
14654
|
+
await rename7(stagingPath, localPath);
|
|
14655
|
+
await rm3(backupPath, { recursive: true, force: true }).catch(() => {
|
|
12994
14656
|
});
|
|
12995
14657
|
} catch (err) {
|
|
12996
14658
|
if (localMovedAside && await fileExists(backupPath)) {
|
|
12997
|
-
await
|
|
14659
|
+
await rename7(backupPath, localPath).catch(() => {
|
|
12998
14660
|
});
|
|
12999
14661
|
}
|
|
13000
|
-
await
|
|
14662
|
+
await rm3(stagingPath, { recursive: true, force: true }).catch(() => {
|
|
13001
14663
|
});
|
|
13002
14664
|
throw err;
|
|
13003
14665
|
}
|
|
@@ -13056,7 +14718,7 @@ async function restoreFromGithub(overrides) {
|
|
|
13056
14718
|
};
|
|
13057
14719
|
} finally {
|
|
13058
14720
|
if (tmpDir) {
|
|
13059
|
-
await
|
|
14721
|
+
await rm3(tmpDir, { recursive: true, force: true }).catch(() => {
|
|
13060
14722
|
});
|
|
13061
14723
|
}
|
|
13062
14724
|
await releaseLock(lockPath);
|
|
@@ -13064,7 +14726,7 @@ async function restoreFromGithub(overrides) {
|
|
|
13064
14726
|
}
|
|
13065
14727
|
async function getBackupStatus() {
|
|
13066
14728
|
const config = await readConfig();
|
|
13067
|
-
const lockPath =
|
|
14729
|
+
const lockPath = resolve27(syntaurRoot(), LOCK_FILE_NAME);
|
|
13068
14730
|
const locked = await fileExists(lockPath);
|
|
13069
14731
|
return {
|
|
13070
14732
|
repo: config.backup?.repo ?? null,
|
|
@@ -13077,7 +14739,7 @@ async function getBackupStatus() {
|
|
|
13077
14739
|
|
|
13078
14740
|
// src/dashboard/api-backup.ts
|
|
13079
14741
|
function createBackupRouter() {
|
|
13080
|
-
const router =
|
|
14742
|
+
const router = Router16();
|
|
13081
14743
|
router.get("/", async (_req, res) => {
|
|
13082
14744
|
try {
|
|
13083
14745
|
const status = await getBackupStatus();
|
|
@@ -13402,10 +15064,11 @@ function createDashboardServer(options) {
|
|
|
13402
15064
|
console.error("Session migration from markdown failed:", err);
|
|
13403
15065
|
});
|
|
13404
15066
|
initLeasesDb();
|
|
15067
|
+
initUsageDb();
|
|
13405
15068
|
(async () => {
|
|
13406
15069
|
try {
|
|
13407
15070
|
const configResult = await migrateLegacyConfig(
|
|
13408
|
-
|
|
15071
|
+
resolve28(syntaurRoot(), "config.md")
|
|
13409
15072
|
);
|
|
13410
15073
|
const projectResult = await migrateLegacyProjectFiles(projectsDir);
|
|
13411
15074
|
const summary = summarizeMigration(projectResult, configResult);
|
|
@@ -13440,55 +15103,19 @@ function createDashboardServer(options) {
|
|
|
13440
15103
|
res.status(500).json({ error: "Failed to get help content" });
|
|
13441
15104
|
}
|
|
13442
15105
|
});
|
|
13443
|
-
app.
|
|
13444
|
-
|
|
13445
|
-
const config = await getStatusConfig();
|
|
13446
|
-
res.json({
|
|
13447
|
-
statuses: config.statuses,
|
|
13448
|
-
order: config.order,
|
|
13449
|
-
transitions: config.transitions,
|
|
13450
|
-
custom: config.custom
|
|
13451
|
-
});
|
|
13452
|
-
} catch (error) {
|
|
13453
|
-
console.error("Error getting status config:", error);
|
|
13454
|
-
res.status(500).json({ error: "Failed to get status config" });
|
|
13455
|
-
}
|
|
13456
|
-
});
|
|
13457
|
-
app.post("/api/config/statuses", async (req, res) => {
|
|
13458
|
-
try {
|
|
13459
|
-
const { statuses, order, transitions } = req.body;
|
|
13460
|
-
if (!Array.isArray(statuses) || !Array.isArray(order) || !Array.isArray(transitions)) {
|
|
13461
|
-
res.status(400).json({ error: "Request body must include statuses, order, and transitions arrays" });
|
|
13462
|
-
return;
|
|
13463
|
-
}
|
|
13464
|
-
await writeStatusConfig({ statuses, order, transitions });
|
|
13465
|
-
clearStatusConfigCache();
|
|
13466
|
-
const config = await getStatusConfig();
|
|
13467
|
-
res.json({
|
|
13468
|
-
statuses: config.statuses,
|
|
13469
|
-
order: config.order,
|
|
13470
|
-
transitions: config.transitions,
|
|
13471
|
-
custom: config.custom
|
|
13472
|
-
});
|
|
13473
|
-
} catch (error) {
|
|
13474
|
-
console.error("Error saving status config:", error);
|
|
13475
|
-
res.status(500).json({ error: "Failed to save status config" });
|
|
13476
|
-
}
|
|
13477
|
-
});
|
|
13478
|
-
app.delete("/api/config/statuses", async (_req, res) => {
|
|
15106
|
+
app.use("/api/config/statuses", createStatusConfigRouter(projectsDir, assignmentsDir2));
|
|
15107
|
+
app.get("/api/config/types", async (_req, res) => {
|
|
13479
15108
|
try {
|
|
13480
|
-
await
|
|
13481
|
-
|
|
13482
|
-
const config = await getStatusConfig();
|
|
15109
|
+
const config = await readConfig();
|
|
15110
|
+
const types = getAssignmentTypes(config);
|
|
13483
15111
|
res.json({
|
|
13484
|
-
|
|
13485
|
-
|
|
13486
|
-
|
|
13487
|
-
custom: config.custom
|
|
15112
|
+
definitions: types.definitions,
|
|
15113
|
+
default: types.default,
|
|
15114
|
+
custom: config.types !== null
|
|
13488
15115
|
});
|
|
13489
15116
|
} catch (error) {
|
|
13490
|
-
console.error("Error
|
|
13491
|
-
res.status(500).json({ error: "Failed to
|
|
15117
|
+
console.error("Error getting types config:", error);
|
|
15118
|
+
res.status(500).json({ error: "Failed to get types config" });
|
|
13492
15119
|
}
|
|
13493
15120
|
});
|
|
13494
15121
|
const THEME_PRESET_SLUGS = ["default", "ocean", "forest", "sunset"];
|
|
@@ -13602,7 +15229,7 @@ function createDashboardServer(options) {
|
|
|
13602
15229
|
}
|
|
13603
15230
|
});
|
|
13604
15231
|
const VIEW_PREFS_LOCK = "vp:global";
|
|
13605
|
-
const FILTER_KEYS = /* @__PURE__ */ new Set(["status", "priority", "assignee", "project", "activity"]);
|
|
15232
|
+
const FILTER_KEYS = /* @__PURE__ */ new Set(["status", "type", "priority", "assignee", "project", "activity"]);
|
|
13606
15233
|
const GLOBAL_KEYS = /* @__PURE__ */ new Set(["defaultView", "sortField", "sortDirection", "density", "grouping", "filters"]);
|
|
13607
15234
|
const SCOPE_KEYS = /* @__PURE__ */ new Set(["defaultView", "sortField", "sortDirection", "grouping", "filters"]);
|
|
13608
15235
|
const ROOT_KEYS = /* @__PURE__ */ new Set(["global", "projects"]);
|
|
@@ -13621,7 +15248,7 @@ function createDashboardServer(options) {
|
|
|
13621
15248
|
const unknown = unknownKey(obj, FILTER_KEYS, "filters");
|
|
13622
15249
|
if (unknown) return { ok: false, error: unknown };
|
|
13623
15250
|
const out = {};
|
|
13624
|
-
for (const key of ["status", "priority", "assignee", "project"]) {
|
|
15251
|
+
for (const key of ["status", "type", "priority", "assignee", "project"]) {
|
|
13625
15252
|
if (obj[key] !== void 0) {
|
|
13626
15253
|
if (!isFilterString(obj[key])) return { ok: false, error: `filters.${key} must be a non-empty string` };
|
|
13627
15254
|
out[key] = obj[key];
|
|
@@ -13764,6 +15391,8 @@ function createDashboardServer(options) {
|
|
|
13764
15391
|
res.status(500).json({ error: "Failed to reset view-prefs" });
|
|
13765
15392
|
}
|
|
13766
15393
|
});
|
|
15394
|
+
app.use("/api/saved-views", createSavedViewsRouter());
|
|
15395
|
+
app.use("/api/dashboard", createDashboardLayoutRouter());
|
|
13767
15396
|
app.get("/api/projects", async (req, res) => {
|
|
13768
15397
|
try {
|
|
13769
15398
|
let projects = await listProjects(projectsDir);
|
|
@@ -13912,6 +15541,7 @@ function createDashboardServer(options) {
|
|
|
13912
15541
|
app.use(createWriteRouter(projectsDir, assignmentsDir2, todosDir2));
|
|
13913
15542
|
app.use("/api/servers", createServersRouter(serversDir2, projectsDir, assignmentsDir2));
|
|
13914
15543
|
app.use("/api/leases", createLeasesRouter(broadcast));
|
|
15544
|
+
app.use("/api/usage", createUsageRouter());
|
|
13915
15545
|
app.use("/api/agent-sessions", createAgentSessionsRouter(projectsDir, broadcast, assignmentsDir2));
|
|
13916
15546
|
app.use("/api/config/agents", createAgentsRouter());
|
|
13917
15547
|
app.use("/api/launch", createLaunchPreflightRouter());
|
|
@@ -13936,17 +15566,19 @@ function createDashboardServer(options) {
|
|
|
13936
15566
|
});
|
|
13937
15567
|
app.use("/api/todos", createTodosRouter(todosDir2, broadcast, projectsDir));
|
|
13938
15568
|
app.use("/api/projects/:projectId/todos", createProjectTodosRouter(projectsDir, broadcast, todosDir2));
|
|
15569
|
+
app.use("/api/bundles", createBundlesRouter(todosDir2, broadcast));
|
|
15570
|
+
app.use("/api/projects/:projectId/bundles", createProjectBundlesRouter(projectsDir, broadcast));
|
|
13939
15571
|
app.use("/api/backup", createBackupRouter());
|
|
13940
15572
|
if (serveStaticUi && dashboardDistPath) {
|
|
13941
15573
|
const sendOpts = { dotfiles: "allow" };
|
|
13942
|
-
app.use("/assets", express.static(
|
|
15574
|
+
app.use("/assets", express.static(resolve28(dashboardDistPath, "assets"), sendOpts));
|
|
13943
15575
|
app.use(express.static(dashboardDistPath, { ...sendOpts, index: false, fallthrough: true }));
|
|
13944
15576
|
app.get("{*path}", async (req, res) => {
|
|
13945
15577
|
if (req.path.startsWith("/api") || req.path === "/ws" || req.path.startsWith("/assets")) {
|
|
13946
15578
|
res.status(404).json({ error: "Not Found" });
|
|
13947
15579
|
return;
|
|
13948
15580
|
}
|
|
13949
|
-
const indexPath =
|
|
15581
|
+
const indexPath = resolve28(dashboardDistPath, "index.html");
|
|
13950
15582
|
if (!await fileExists(indexPath)) {
|
|
13951
15583
|
res.status(503).send(
|
|
13952
15584
|
'Dashboard not built. Run "npm run build:dashboard" first.'
|
|
@@ -13970,7 +15602,7 @@ function createDashboardServer(options) {
|
|
|
13970
15602
|
serversDir: serversDir2,
|
|
13971
15603
|
playbooksDir: playbooksDir2,
|
|
13972
15604
|
todosDir: todosDir2,
|
|
13973
|
-
dbPath:
|
|
15605
|
+
dbPath: resolve28(syntaurRoot(), "syntaur.db"),
|
|
13974
15606
|
onMessage: broadcast
|
|
13975
15607
|
});
|
|
13976
15608
|
startAutodiscovery({ serversDir: serversDir2, projectsDir, assignmentsDir: assignmentsDir2, excludePids: /* @__PURE__ */ new Set([process.pid]) });
|
|
@@ -13985,8 +15617,8 @@ function createDashboardServer(options) {
|
|
|
13985
15617
|
}
|
|
13986
15618
|
});
|
|
13987
15619
|
server.listen(port, () => {
|
|
13988
|
-
const portFile =
|
|
13989
|
-
|
|
15620
|
+
const portFile = resolve28(syntaurRoot(), "dashboard-port");
|
|
15621
|
+
writeFile6(portFile, String(port), "utf-8").catch(() => {
|
|
13990
15622
|
});
|
|
13991
15623
|
resolvePromise();
|
|
13992
15624
|
});
|
|
@@ -13999,12 +15631,13 @@ function createDashboardServer(options) {
|
|
|
13999
15631
|
}
|
|
14000
15632
|
closeSessionDb();
|
|
14001
15633
|
closeLeasesDb();
|
|
15634
|
+
closeUsageDb();
|
|
14002
15635
|
for (const client of clients) {
|
|
14003
15636
|
client.terminate();
|
|
14004
15637
|
}
|
|
14005
15638
|
clients.clear();
|
|
14006
|
-
const portFile =
|
|
14007
|
-
await
|
|
15639
|
+
const portFile = resolve28(syntaurRoot(), "dashboard-port");
|
|
15640
|
+
await unlink6(portFile).catch(() => {
|
|
14008
15641
|
});
|
|
14009
15642
|
server.closeAllConnections?.();
|
|
14010
15643
|
return new Promise((resolvePromise) => {
|