@tenonhq/dovetail-dashboard 0.0.43 → 0.0.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -37
- package/package.json +1 -1
- package/public/app.js +270 -66
- package/public/styles.css +22 -0
- package/server.js +613 -237
package/server.js
CHANGED
|
@@ -42,9 +42,15 @@ function resolveDovePath(doveName, sincName) {
|
|
|
42
42
|
return dovePath;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
const UPDATE_SET_CONFIG = resolveDovePath(
|
|
45
|
+
const UPDATE_SET_CONFIG = resolveDovePath(
|
|
46
|
+
".dove-update-sets.json",
|
|
47
|
+
".sinc-update-sets.json"
|
|
48
|
+
);
|
|
46
49
|
const DOVE_CONFIG_PATH = resolveDovePath("dove.config.js", "sinc.config.js");
|
|
47
|
-
const ACTIVE_TASK_FILE = resolveDovePath(
|
|
50
|
+
const ACTIVE_TASK_FILE = resolveDovePath(
|
|
51
|
+
".dove-active-task.json",
|
|
52
|
+
".sinc-active-task.json"
|
|
53
|
+
);
|
|
48
54
|
|
|
49
55
|
const CLICKUP_TOKEN = process.env.CLICKUP_API_TOKEN || "";
|
|
50
56
|
const CLICKUP_TEAM_ID = process.env.CLICKUP_TEAM_ID || "";
|
|
@@ -88,49 +94,80 @@ app.use(express.static(path.join(__dirname, "public")));
|
|
|
88
94
|
// Session-persistent ServiceNow client — cookie jar ensures scope changes
|
|
89
95
|
// (changeScope) persist across subsequent requests in the same session.
|
|
90
96
|
var snCookieJar = new CookieJar();
|
|
91
|
-
var snClient = wrapper(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
97
|
+
var snClient = wrapper(
|
|
98
|
+
axios.create({
|
|
99
|
+
baseURL: BASE_URL,
|
|
100
|
+
auth: { username: SN_USER, password: SN_PASSWORD },
|
|
101
|
+
headers: {
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
Accept: "application/json",
|
|
104
|
+
},
|
|
105
|
+
jar: snCookieJar,
|
|
106
|
+
withCredentials: true,
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Dovetail's core Scripted REST API now lives in the Dovetail scoped application
|
|
111
|
+
// at /api/cadso/dovetail_core/*. Older instances still expose the previous
|
|
112
|
+
// global-scope path /api/cadso/dovetail/*. Dashboard call sites still pass the
|
|
113
|
+
// original /api/cadso/claude/* paths; snApi maps them to dovetail_core first and,
|
|
114
|
+
// on a missing-endpoint error (404, or the 400 "Requested URI does not represent
|
|
115
|
+
// any resource" SN returns for an absent Scripted REST resource), latches to the
|
|
116
|
+
// legacy /api/cadso/dovetail/* path for the rest of the session and warns once.
|
|
117
|
+
// Mirrors packages/core/src/snClient.ts so the dashboard and the dove CLI agree.
|
|
118
|
+
var _dovetailApiUseLegacyPath = false;
|
|
119
|
+
var _SN_MISSING_ENDPOINT_BODY = "Requested URI does not represent any resource";
|
|
120
|
+
|
|
121
|
+
// True when an error means the Dovetail Scripted REST endpoint is absent on this
|
|
122
|
+
// instance: a 404, or the 400 body SN returns for an unknown scripted-REST URI.
|
|
123
|
+
function isMissingDovetailEndpoint(e) {
|
|
124
|
+
var status = e && e.response && e.response.status;
|
|
125
|
+
if (status === 404) return true;
|
|
126
|
+
if (status === 400) {
|
|
127
|
+
var data = e && e.response && e.response.data;
|
|
128
|
+
var body = "";
|
|
129
|
+
try {
|
|
130
|
+
body = typeof data === "string" ? data : JSON.stringify(data || "");
|
|
131
|
+
} catch (_e) {
|
|
132
|
+
body = "";
|
|
133
|
+
}
|
|
134
|
+
return body.indexOf(_SN_MISSING_ENDPOINT_BODY) !== -1;
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Map a dashboard call-site path (api/cadso/{claude,dovetail,dovetail_core}/<op>)
|
|
140
|
+
// to the active Dovetail scoped-API path. Returns null for non-scoped endpoints
|
|
141
|
+
// (e.g. api/now/table/*), which pass through unchanged.
|
|
142
|
+
function dovetailScopedPath(endpoint) {
|
|
143
|
+
var match = endpoint.match(
|
|
144
|
+
/^\/?api\/cadso\/(?:dovetail_core|dovetail|claude)\/(.*)$/
|
|
145
|
+
);
|
|
146
|
+
if (!match) return null;
|
|
147
|
+
var service = _dovetailApiUseLegacyPath ? "dovetail" : "dovetail_core";
|
|
148
|
+
return "api/cadso/" + service + "/" + match[1];
|
|
149
|
+
}
|
|
107
150
|
|
|
108
151
|
async function snApi(method, endpoint, data) {
|
|
109
152
|
await waitForRateLimit();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
var rewritten = endpoint;
|
|
113
|
-
var isDovetailScopedApi = endpoint.indexOf("api/cadso/claude/") === 0
|
|
114
|
-
|| endpoint.indexOf("/api/cadso/claude/") === 0
|
|
115
|
-
|| endpoint.indexOf("api/cadso/dovetail/") === 0
|
|
116
|
-
|| endpoint.indexOf("/api/cadso/dovetail/") === 0;
|
|
117
|
-
if (isDovetailScopedApi) {
|
|
118
|
-
rewritten = _dovetailApiUseLegacyClaudePath
|
|
119
|
-
? endpoint.replace("api/cadso/dovetail/", "api/cadso/claude/")
|
|
120
|
-
: endpoint.replace("api/cadso/claude/", "api/cadso/dovetail/");
|
|
121
|
-
}
|
|
153
|
+
var scopedPath = dovetailScopedPath(endpoint);
|
|
154
|
+
var url = scopedPath || endpoint;
|
|
122
155
|
try {
|
|
123
|
-
return await snClient({ method: method, url:
|
|
156
|
+
return await snClient({ method: method, url: url, data: data });
|
|
124
157
|
} catch (e) {
|
|
125
|
-
|
|
126
|
-
|
|
158
|
+
if (
|
|
159
|
+
scopedPath &&
|
|
160
|
+
!_dovetailApiUseLegacyPath &&
|
|
161
|
+
isMissingDovetailEndpoint(e)
|
|
162
|
+
) {
|
|
127
163
|
// eslint-disable-next-line no-console
|
|
128
164
|
console.warn(
|
|
129
|
-
"[deprecation] " +
|
|
130
|
-
|
|
165
|
+
"[deprecation] " +
|
|
166
|
+
url +
|
|
167
|
+
" not found. Falling back to legacy /api/cadso/dovetail/* path. Install the Dovetail application's Scripted REST APIs to silence this warning."
|
|
131
168
|
);
|
|
132
|
-
|
|
133
|
-
var legacyUrl =
|
|
169
|
+
_dovetailApiUseLegacyPath = true;
|
|
170
|
+
var legacyUrl = dovetailScopedPath(endpoint);
|
|
134
171
|
return await snClient({ method: method, url: legacyUrl, data: data });
|
|
135
172
|
}
|
|
136
173
|
throw e;
|
|
@@ -150,11 +187,50 @@ function clickupApi(method, endpoint, data) {
|
|
|
150
187
|
});
|
|
151
188
|
}
|
|
152
189
|
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
190
|
+
// Scope -> "App" label used in generated update-set names. Mirrors the
|
|
191
|
+
// override table in .claude/skills/sn-move-update-set so both tools agree.
|
|
192
|
+
var SCOPE_LABEL_OVERRIDES = {
|
|
193
|
+
x_cadso_journey: "Journey",
|
|
194
|
+
x_cadso_core: "Core",
|
|
195
|
+
x_cadso_automate: "Automate",
|
|
196
|
+
x_cadso_text_spoke: "Text",
|
|
197
|
+
x_cadso_email_spok: "Email",
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
function scopeLabel(scope) {
|
|
201
|
+
if (SCOPE_LABEL_OVERRIDES[scope]) return SCOPE_LABEL_OVERRIDES[scope];
|
|
202
|
+
var stripped = scope.replace(/^x_cadso_/, "");
|
|
203
|
+
return stripped
|
|
204
|
+
.split(/[_-]/)
|
|
205
|
+
.filter(Boolean)
|
|
206
|
+
.map(function (w) {
|
|
207
|
+
return w.charAt(0).toUpperCase() + w.slice(1);
|
|
208
|
+
})
|
|
209
|
+
.join(" ");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sanitizeTaskName(taskName) {
|
|
213
|
+
return taskName.replace(/[^a-zA-Z0-9\s\-_]/g, "").trim();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Task-level base name (no App segment yet — that's added per-scope by
|
|
217
|
+
// buildScopedUpdateSetName, since one task can span multiple scopes/apps).
|
|
218
|
+
function generateUpdateSetName(devInitials, taskId, shortDesc) {
|
|
219
|
+
var parts = [];
|
|
220
|
+
if (devInitials) parts.push(devInitials);
|
|
221
|
+
parts.push(taskId);
|
|
222
|
+
parts.push(shortDesc);
|
|
223
|
+
return parts.join(" | ").substring(0, 80);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Full per-scope update-set name: {DEVINITIALS} | {DEV-ID} | {App} | {Short Desc}
|
|
227
|
+
function buildScopedUpdateSetName(activeTask, appLabel) {
|
|
228
|
+
var parts = [];
|
|
229
|
+
if (activeTask.devInitials) parts.push(activeTask.devInitials);
|
|
230
|
+
parts.push(activeTask.customId || activeTask.taskId);
|
|
231
|
+
parts.push(appLabel);
|
|
232
|
+
parts.push(activeTask.shortDesc || activeTask.taskName);
|
|
233
|
+
return parts.join(" | ").substring(0, 80);
|
|
158
234
|
}
|
|
159
235
|
|
|
160
236
|
// Generate update set description from task
|
|
@@ -222,7 +298,9 @@ app.get("/api/scopes", async (req, res) => {
|
|
|
222
298
|
const scopeQuery = scopeKeys.map((s) => `scope=${s}`).join("^OR");
|
|
223
299
|
const resp = await snApi(
|
|
224
300
|
"get",
|
|
225
|
-
`api/now/table/sys_scope?sysparm_query=${encodeURIComponent(
|
|
301
|
+
`api/now/table/sys_scope?sysparm_query=${encodeURIComponent(
|
|
302
|
+
scopeQuery
|
|
303
|
+
)}&sysparm_fields=sys_id,scope,name&sysparm_limit=50`
|
|
226
304
|
);
|
|
227
305
|
|
|
228
306
|
const scopeRecords = resp.data.result || [];
|
|
@@ -251,7 +329,10 @@ app.get("/api/scopes", async (req, res) => {
|
|
|
251
329
|
});
|
|
252
330
|
|
|
253
331
|
// GET /api/recent-edits — read local recent edits file, enrich with live SN data
|
|
254
|
-
var RECENT_EDITS_FILE = resolveDovePath(
|
|
332
|
+
var RECENT_EDITS_FILE = resolveDovePath(
|
|
333
|
+
".dove-recent-edits.json",
|
|
334
|
+
".sinc-recent-edits.json"
|
|
335
|
+
);
|
|
255
336
|
|
|
256
337
|
app.get("/api/recent-edits", recentEditsLimiter, async function (req, res) {
|
|
257
338
|
try {
|
|
@@ -270,11 +351,17 @@ app.get("/api/recent-edits", recentEditsLimiter, async function (req, res) {
|
|
|
270
351
|
var edit = edits[i];
|
|
271
352
|
var updateSetName = "unknown";
|
|
272
353
|
try {
|
|
273
|
-
var query =
|
|
354
|
+
var query =
|
|
355
|
+
"name=" +
|
|
356
|
+
edit.tableName +
|
|
357
|
+
"_" +
|
|
358
|
+
edit.sys_id +
|
|
359
|
+
"^ORDERBYDESCsys_created_on";
|
|
274
360
|
var snResp = await snApi(
|
|
275
361
|
"get",
|
|
276
|
-
"api/now/table/sys_update_xml?sysparm_query=" +
|
|
277
|
-
|
|
362
|
+
"api/now/table/sys_update_xml?sysparm_query=" +
|
|
363
|
+
encodeURIComponent(query) +
|
|
364
|
+
"&sysparm_fields=update_set,update_set.name&sysparm_limit=1"
|
|
278
365
|
);
|
|
279
366
|
var results = snResp.data.result || [];
|
|
280
367
|
if (results.length > 0) {
|
|
@@ -329,7 +416,9 @@ app.get("/api/update-sets/:scope", async (req, res) => {
|
|
|
329
416
|
const query = `application.scope=${scope}^state=in progress^ORDERBYDESCsys_created_on`;
|
|
330
417
|
const resp = await snApi(
|
|
331
418
|
"get",
|
|
332
|
-
`api/now/table/sys_update_set?sysparm_query=${encodeURIComponent(
|
|
419
|
+
`api/now/table/sys_update_set?sysparm_query=${encodeURIComponent(
|
|
420
|
+
query
|
|
421
|
+
)}&sysparm_fields=sys_id,name,state,application,sys_created_on,description&sysparm_limit=50`
|
|
333
422
|
);
|
|
334
423
|
res.json({ update_sets: resp.data.result || [] });
|
|
335
424
|
} catch (e) {
|
|
@@ -350,7 +439,10 @@ app.post("/api/update-set", async (req, res) => {
|
|
|
350
439
|
|
|
351
440
|
// Switch to the target scope before creating — ServiceNow uses session scope
|
|
352
441
|
if (scope) {
|
|
353
|
-
await snApi(
|
|
442
|
+
await snApi(
|
|
443
|
+
"get",
|
|
444
|
+
"api/cadso/claude/changeScope?scope=" + encodeURIComponent(scope)
|
|
445
|
+
);
|
|
354
446
|
}
|
|
355
447
|
|
|
356
448
|
const data = {
|
|
@@ -371,11 +463,9 @@ app.post("/api/update-set", async (req, res) => {
|
|
|
371
463
|
app.patch("/api/update-set/:sysId/close", async (req, res) => {
|
|
372
464
|
try {
|
|
373
465
|
const { sysId } = req.params;
|
|
374
|
-
const resp = await snApi(
|
|
375
|
-
"
|
|
376
|
-
|
|
377
|
-
{ state: "complete" }
|
|
378
|
-
);
|
|
466
|
+
const resp = await snApi("patch", `api/now/table/sys_update_set/${sysId}`, {
|
|
467
|
+
state: "complete",
|
|
468
|
+
});
|
|
379
469
|
res.json({ update_set: resp.data.result });
|
|
380
470
|
} catch (e) {
|
|
381
471
|
res.status(500).json({ error: e.message });
|
|
@@ -441,7 +531,9 @@ app.get("/api/clickup/status", function (req, res) {
|
|
|
441
531
|
app.get("/api/clickup/me", async function (req, res) {
|
|
442
532
|
try {
|
|
443
533
|
if (!CLICKUP_TOKEN) {
|
|
444
|
-
return res
|
|
534
|
+
return res
|
|
535
|
+
.status(400)
|
|
536
|
+
.json({ error: "CLICKUP_API_TOKEN not configured" });
|
|
445
537
|
}
|
|
446
538
|
var resp = await clickupApi("get", "user");
|
|
447
539
|
var user = resp.data.user || {};
|
|
@@ -464,7 +556,9 @@ app.get("/api/clickup/me", async function (req, res) {
|
|
|
464
556
|
app.get("/api/clickup/tasks", async function (req, res) {
|
|
465
557
|
try {
|
|
466
558
|
if (!CLICKUP_TOKEN) {
|
|
467
|
-
return res
|
|
559
|
+
return res
|
|
560
|
+
.status(400)
|
|
561
|
+
.json({ error: "CLICKUP_API_TOKEN not configured" });
|
|
468
562
|
}
|
|
469
563
|
|
|
470
564
|
var teamId = CLICKUP_TEAM_ID;
|
|
@@ -492,7 +586,8 @@ app.get("/api/clickup/tasks", async function (req, res) {
|
|
|
492
586
|
var byStatus = {};
|
|
493
587
|
var allStatuses = [];
|
|
494
588
|
tasks.forEach(function (t) {
|
|
495
|
-
var statusName =
|
|
589
|
+
var statusName =
|
|
590
|
+
t.status && t.status.status ? t.status.status : "unknown";
|
|
496
591
|
if (!byStatus[statusName]) {
|
|
497
592
|
byStatus[statusName] = [];
|
|
498
593
|
allStatuses.push(statusName);
|
|
@@ -517,7 +612,11 @@ app.get("/api/clickup/tasks", async function (req, res) {
|
|
|
517
612
|
});
|
|
518
613
|
});
|
|
519
614
|
|
|
520
|
-
res.json({
|
|
615
|
+
res.json({
|
|
616
|
+
tasks: tasks.length,
|
|
617
|
+
byStatus: byStatus,
|
|
618
|
+
statuses: allStatuses,
|
|
619
|
+
});
|
|
521
620
|
} catch (e) {
|
|
522
621
|
var msg = e.message;
|
|
523
622
|
if (e.response && e.response.data) {
|
|
@@ -531,7 +630,9 @@ app.get("/api/clickup/tasks", async function (req, res) {
|
|
|
531
630
|
app.get("/api/clickup/task/:taskId", async function (req, res) {
|
|
532
631
|
try {
|
|
533
632
|
if (!CLICKUP_TOKEN) {
|
|
534
|
-
return res
|
|
633
|
+
return res
|
|
634
|
+
.status(400)
|
|
635
|
+
.json({ error: "CLICKUP_API_TOKEN not configured" });
|
|
535
636
|
}
|
|
536
637
|
var resp = await clickupApi("get", "task/" + req.params.taskId);
|
|
537
638
|
var t = resp.data;
|
|
@@ -557,14 +658,26 @@ app.get("/api/clickup/task/:taskId", async function (req, res) {
|
|
|
557
658
|
});
|
|
558
659
|
|
|
559
660
|
// POST /api/clickup/select-task — select a task as active
|
|
560
|
-
app.post("/api/clickup/select-task", function (req, res) {
|
|
661
|
+
app.post("/api/clickup/select-task", async function (req, res) {
|
|
561
662
|
try {
|
|
562
663
|
var body = req.body;
|
|
563
664
|
if (!body.taskId || !body.taskName) {
|
|
564
|
-
return res
|
|
665
|
+
return res
|
|
666
|
+
.status(400)
|
|
667
|
+
.json({ error: "taskId and taskName are required" });
|
|
565
668
|
}
|
|
566
669
|
|
|
567
|
-
var
|
|
670
|
+
var devInitials = "";
|
|
671
|
+
try {
|
|
672
|
+
var meResp = await clickupApi("get", "user");
|
|
673
|
+
devInitials = (meResp.data.user && meResp.data.user.initials) || "";
|
|
674
|
+
} catch (meErr) {
|
|
675
|
+
// Non-fatal — naming falls back to no-initials prefix
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
var customId = body.customId || body.taskId;
|
|
679
|
+
var shortDesc = sanitizeTaskName(body.taskName);
|
|
680
|
+
var updateSetName = generateUpdateSetName(devInitials, customId, shortDesc);
|
|
568
681
|
var description = generateUpdateSetDescription(
|
|
569
682
|
body.taskName,
|
|
570
683
|
body.taskDescription || ""
|
|
@@ -572,6 +685,9 @@ app.post("/api/clickup/select-task", function (req, res) {
|
|
|
572
685
|
|
|
573
686
|
var activeTask = {
|
|
574
687
|
taskId: body.taskId,
|
|
688
|
+
customId: customId,
|
|
689
|
+
devInitials: devInitials,
|
|
690
|
+
shortDesc: shortDesc,
|
|
575
691
|
taskName: body.taskName,
|
|
576
692
|
taskDescription: body.taskDescription || "",
|
|
577
693
|
updateSetName: updateSetName,
|
|
@@ -590,17 +706,24 @@ app.post("/api/clickup/select-task", function (req, res) {
|
|
|
590
706
|
// Core logic: find or create update set for a scope given an active task
|
|
591
707
|
// Returns { update_set, created }
|
|
592
708
|
async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
|
|
593
|
-
var
|
|
594
|
-
var
|
|
709
|
+
var appLabel = scopeLabel(scope);
|
|
710
|
+
var baseName = buildScopedUpdateSetName(activeTask, appLabel);
|
|
711
|
+
var lookupId = activeTask.customId || activeTask.taskId;
|
|
595
712
|
|
|
596
|
-
if (!
|
|
597
|
-
throw new Error(
|
|
713
|
+
if (!lookupId || typeof lookupId !== "string" || lookupId.trim() === "") {
|
|
714
|
+
throw new Error(
|
|
715
|
+
"Cannot search for update sets: activeTask has an empty or missing task id"
|
|
716
|
+
);
|
|
598
717
|
}
|
|
599
718
|
|
|
600
|
-
// Query ServiceNow for existing update sets matching this task in this scope
|
|
719
|
+
// Query ServiceNow for existing update sets matching this task in this scope.
|
|
720
|
+
// Lookup is on the task id substring only (not the full name) so a re-run
|
|
721
|
+
// still finds a set created before a naming-format change.
|
|
601
722
|
var query =
|
|
602
|
-
"application.scope=" +
|
|
603
|
-
|
|
723
|
+
"application.scope=" +
|
|
724
|
+
scope +
|
|
725
|
+
"^nameLIKE" +
|
|
726
|
+
lookupId +
|
|
604
727
|
"^state=in progress" +
|
|
605
728
|
"^ORDERBYDESCsys_created_on";
|
|
606
729
|
var searchResp = await snApi(
|
|
@@ -624,7 +747,10 @@ async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
|
|
|
624
747
|
if (!updateSet) {
|
|
625
748
|
// Switch to the target scope before creating — ServiceNow uses session scope,
|
|
626
749
|
// not the application field, when creating update sets via Table API
|
|
627
|
-
await snApi(
|
|
750
|
+
await snApi(
|
|
751
|
+
"get",
|
|
752
|
+
"api/cadso/claude/changeScope?scope=" + encodeURIComponent(scope)
|
|
753
|
+
);
|
|
628
754
|
|
|
629
755
|
var createData = {
|
|
630
756
|
name: baseName,
|
|
@@ -634,7 +760,11 @@ async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
|
|
|
634
760
|
if (activeTask.description) {
|
|
635
761
|
createData.description = activeTask.description;
|
|
636
762
|
}
|
|
637
|
-
var createResp = await snApi(
|
|
763
|
+
var createResp = await snApi(
|
|
764
|
+
"post",
|
|
765
|
+
"api/now/table/sys_update_set",
|
|
766
|
+
createData
|
|
767
|
+
);
|
|
638
768
|
updateSet = createResp.data.result;
|
|
639
769
|
created = true;
|
|
640
770
|
}
|
|
@@ -643,13 +773,15 @@ async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
|
|
|
643
773
|
try {
|
|
644
774
|
await snApi(
|
|
645
775
|
"get",
|
|
646
|
-
"api/cadso/claude/changeUpdateSet?sysId=" +
|
|
776
|
+
"api/cadso/claude/changeUpdateSet?sysId=" +
|
|
777
|
+
encodeURIComponent(updateSet.sys_id)
|
|
647
778
|
);
|
|
648
779
|
|
|
649
780
|
// Verify the switch was successful
|
|
650
781
|
var verifyResp = await snApi(
|
|
651
782
|
"get",
|
|
652
|
-
"api/cadso/claude/currentUpdateSet" +
|
|
783
|
+
"api/cadso/claude/currentUpdateSet" +
|
|
784
|
+
(scope ? "?scope=" + encodeURIComponent(scope) : "")
|
|
653
785
|
);
|
|
654
786
|
var verifyData = verifyResp.data;
|
|
655
787
|
if (verifyData && verifyData.result) {
|
|
@@ -661,11 +793,13 @@ async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
|
|
|
661
793
|
console.warn("Update set verification failed, retrying switch...");
|
|
662
794
|
await snApi(
|
|
663
795
|
"get",
|
|
664
|
-
"api/cadso/claude/changeUpdateSet?sysId=" +
|
|
796
|
+
"api/cadso/claude/changeUpdateSet?sysId=" +
|
|
797
|
+
encodeURIComponent(updateSet.sys_id)
|
|
665
798
|
);
|
|
666
799
|
var retryResp = await snApi(
|
|
667
800
|
"get",
|
|
668
|
-
"api/cadso/claude/currentUpdateSet" +
|
|
801
|
+
"api/cadso/claude/currentUpdateSet" +
|
|
802
|
+
(scope ? "?scope=" + encodeURIComponent(scope) : "")
|
|
669
803
|
);
|
|
670
804
|
var retryData = retryResp.data;
|
|
671
805
|
if (retryData && retryData.result) {
|
|
@@ -673,14 +807,22 @@ async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
|
|
|
673
807
|
}
|
|
674
808
|
var retrySysId = retryData && retryData.sysId ? retryData.sysId : null;
|
|
675
809
|
if (retrySysId !== updateSet.sys_id) {
|
|
676
|
-
var actualName =
|
|
810
|
+
var actualName =
|
|
811
|
+
retryData && retryData.name ? retryData.name : "unknown";
|
|
677
812
|
console.error(
|
|
678
|
-
"Update set " +
|
|
813
|
+
"Update set " +
|
|
814
|
+
updateSet.name +
|
|
815
|
+
" was created but could not be activated. Current update set is " +
|
|
816
|
+
actualName +
|
|
817
|
+
"."
|
|
679
818
|
);
|
|
680
819
|
}
|
|
681
820
|
}
|
|
682
821
|
} catch (changeErr) {
|
|
683
|
-
console.error(
|
|
822
|
+
console.error(
|
|
823
|
+
"Warning: Could not auto-switch update set on instance:",
|
|
824
|
+
changeErr.message
|
|
825
|
+
);
|
|
684
826
|
}
|
|
685
827
|
|
|
686
828
|
return { update_set: updateSet, created: created };
|
|
@@ -710,7 +852,9 @@ app.post("/api/clickup/activate-scope", async function (req, res) {
|
|
|
710
852
|
try {
|
|
711
853
|
var body = req.body;
|
|
712
854
|
if (!body.scope || !body.scope_sys_id) {
|
|
713
|
-
return res
|
|
855
|
+
return res
|
|
856
|
+
.status(400)
|
|
857
|
+
.json({ error: "scope and scope_sys_id are required" });
|
|
714
858
|
}
|
|
715
859
|
|
|
716
860
|
var activeTask = readActiveTask();
|
|
@@ -718,7 +862,11 @@ app.post("/api/clickup/activate-scope", async function (req, res) {
|
|
|
718
862
|
return res.status(400).json({ error: "No active task selected" });
|
|
719
863
|
}
|
|
720
864
|
|
|
721
|
-
var result = await findOrCreateUpdateSet(
|
|
865
|
+
var result = await findOrCreateUpdateSet(
|
|
866
|
+
body.scope,
|
|
867
|
+
body.scope_sys_id,
|
|
868
|
+
activeTask
|
|
869
|
+
);
|
|
722
870
|
persistScopeActivation(body.scope, result.update_set, activeTask);
|
|
723
871
|
|
|
724
872
|
res.json({
|
|
@@ -731,6 +879,61 @@ app.post("/api/clickup/activate-scope", async function (req, res) {
|
|
|
731
879
|
}
|
|
732
880
|
});
|
|
733
881
|
|
|
882
|
+
// Shared core: find-or-create an update set in every scope from dove.config.js
|
|
883
|
+
// for the given active task. Used by both /activate-all-scopes and /start-task.
|
|
884
|
+
// Returns { results, activeTask }.
|
|
885
|
+
async function activateAllConfiguredScopes(activeTask) {
|
|
886
|
+
// Read scopes from dove.config.js
|
|
887
|
+
delete require.cache[require.resolve(DOVE_CONFIG_PATH)];
|
|
888
|
+
var config = require(DOVE_CONFIG_PATH);
|
|
889
|
+
var scopeKeys = Object.keys(config.scopes || {});
|
|
890
|
+
|
|
891
|
+
// Resolve scope sys_ids
|
|
892
|
+
var scopeQuery = scopeKeys
|
|
893
|
+
.map(function (s) {
|
|
894
|
+
return "scope=" + s;
|
|
895
|
+
})
|
|
896
|
+
.join("^OR");
|
|
897
|
+
var scopeResp = await snApi(
|
|
898
|
+
"get",
|
|
899
|
+
"api/now/table/sys_scope?sysparm_query=" +
|
|
900
|
+
encodeURIComponent(scopeQuery) +
|
|
901
|
+
"&sysparm_fields=sys_id,scope,name&sysparm_limit=50"
|
|
902
|
+
);
|
|
903
|
+
var scopeRecords = scopeResp.data.result || [];
|
|
904
|
+
var scopeMap = {};
|
|
905
|
+
scopeRecords.forEach(function (r) {
|
|
906
|
+
scopeMap[r.scope] = r.sys_id;
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
// Activate each scope sequentially (respects rate limits)
|
|
910
|
+
var results = [];
|
|
911
|
+
for (var i = 0; i < scopeKeys.length; i++) {
|
|
912
|
+
var scope = scopeKeys[i];
|
|
913
|
+
var scopeSysId = scopeMap[scope];
|
|
914
|
+
if (!scopeSysId) {
|
|
915
|
+
results.push({ scope: scope, error: "scope not found on instance" });
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
try {
|
|
920
|
+
var result = await findOrCreateUpdateSet(scope, scopeSysId, activeTask);
|
|
921
|
+
persistScopeActivation(scope, result.update_set, activeTask);
|
|
922
|
+
// Re-read active task so subsequent iterations see updated scopes
|
|
923
|
+
activeTask = readActiveTask();
|
|
924
|
+
results.push({
|
|
925
|
+
scope: scope,
|
|
926
|
+
update_set: result.update_set,
|
|
927
|
+
created: result.created,
|
|
928
|
+
});
|
|
929
|
+
} catch (scopeErr) {
|
|
930
|
+
results.push({ scope: scope, error: scopeErr.message });
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
return { results: results, activeTask: readActiveTask() };
|
|
935
|
+
}
|
|
936
|
+
|
|
734
937
|
// POST /api/clickup/activate-all-scopes — find or create update sets for all configured scopes
|
|
735
938
|
app.post("/api/clickup/activate-all-scopes", async function (req, res) {
|
|
736
939
|
try {
|
|
@@ -739,51 +942,107 @@ app.post("/api/clickup/activate-all-scopes", async function (req, res) {
|
|
|
739
942
|
return res.status(400).json({ error: "No active task selected" });
|
|
740
943
|
}
|
|
741
944
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
945
|
+
var outcome = await activateAllConfiguredScopes(activeTask);
|
|
946
|
+
res.json({ results: outcome.results, activeTask: outcome.activeTask });
|
|
947
|
+
} catch (e) {
|
|
948
|
+
res.status(500).json({ error: e.message });
|
|
949
|
+
}
|
|
950
|
+
});
|
|
746
951
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
)
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
scopeRecords.forEach(function (r) {
|
|
758
|
-
scopeMap[r.scope] = r.sys_id;
|
|
759
|
-
});
|
|
952
|
+
// Slugify text for use in a branch name segment: lowercase, non-alphanumerics
|
|
953
|
+
// collapsed to single hyphens, trimmed, capped so the full branch name stays
|
|
954
|
+
// reasonable.
|
|
955
|
+
function slugify(text) {
|
|
956
|
+
return String(text)
|
|
957
|
+
.toLowerCase()
|
|
958
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
959
|
+
.replace(/^-+|-+$/g, "")
|
|
960
|
+
.substring(0, 50);
|
|
961
|
+
}
|
|
760
962
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
results.push({ scope: scope, error: "scope not found on instance" });
|
|
768
|
-
continue;
|
|
963
|
+
function execFilePromise(cmd, args, opts) {
|
|
964
|
+
return new Promise(function (resolve, reject) {
|
|
965
|
+
execFile(cmd, args, opts || {}, function (err, stdout, stderr) {
|
|
966
|
+
if (err) {
|
|
967
|
+
var detail = (stderr || err.message || "").toString().trim();
|
|
968
|
+
return reject(new Error(detail || err.message));
|
|
769
969
|
}
|
|
970
|
+
resolve(stdout.toString().trim());
|
|
971
|
+
});
|
|
972
|
+
});
|
|
973
|
+
}
|
|
770
974
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
975
|
+
// Cut (or switch to, if it already exists) the working branch for a task in
|
|
976
|
+
// the repo this dashboard process is running from: dev/{git-username}/{DEV-ID}/{short-desc}
|
|
977
|
+
async function createTaskBranch(activeTask) {
|
|
978
|
+
var username = "";
|
|
979
|
+
try {
|
|
980
|
+
username = await execFilePromise("git", ["config", "user.name"], {
|
|
981
|
+
cwd: PROJECT_ROOT,
|
|
982
|
+
});
|
|
983
|
+
} catch (userErr) {
|
|
984
|
+
// Fall through to the "dev" fallback below
|
|
985
|
+
}
|
|
986
|
+
var usernameSlug = slugify(username) || "dev";
|
|
987
|
+
var taskIdSlug = slugify(activeTask.customId || activeTask.taskId);
|
|
988
|
+
var descSlug = slugify(activeTask.shortDesc || activeTask.taskName);
|
|
989
|
+
var branchName = "dev/" + usernameSlug + "/" + taskIdSlug + "/" + descSlug;
|
|
990
|
+
|
|
991
|
+
try {
|
|
992
|
+
await execFilePromise("git", ["checkout", "-b", branchName], {
|
|
993
|
+
cwd: PROJECT_ROOT,
|
|
994
|
+
});
|
|
995
|
+
return { branch: branchName, cwd: PROJECT_ROOT, created: true };
|
|
996
|
+
} catch (createErr) {
|
|
997
|
+
// Branch may already exist from a prior Start Task click on this ticket.
|
|
998
|
+
await execFilePromise("git", ["checkout", branchName], {
|
|
999
|
+
cwd: PROJECT_ROOT,
|
|
1000
|
+
});
|
|
1001
|
+
return { branch: branchName, cwd: PROJECT_ROOT, created: false };
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// POST /api/clickup/start-task — mark the active task in-progress in ClickUp,
|
|
1006
|
+
// create/find the per-scope update sets, and cut the working branch in the
|
|
1007
|
+
// repo this dashboard is running from. One deliberate action, not tied to
|
|
1008
|
+
// mere task selection.
|
|
1009
|
+
app.post("/api/clickup/start-task", async function (req, res) {
|
|
1010
|
+
try {
|
|
1011
|
+
var activeTask = readActiveTask();
|
|
1012
|
+
if (!activeTask) {
|
|
1013
|
+
return res.status(400).json({ error: "No active task selected" });
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
try {
|
|
1017
|
+
await clickupApi("put", "task/" + activeTask.taskId, {
|
|
1018
|
+
status: "in progress",
|
|
1019
|
+
});
|
|
1020
|
+
} catch (statusErr) {
|
|
1021
|
+
var msg = statusErr.message;
|
|
1022
|
+
if (statusErr.response && statusErr.response.data) {
|
|
1023
|
+
msg =
|
|
1024
|
+
statusErr.response.data.err || statusErr.response.data.error || msg;
|
|
783
1025
|
}
|
|
1026
|
+
return res
|
|
1027
|
+
.status(502)
|
|
1028
|
+
.json({ error: "Failed to set ClickUp status: " + msg });
|
|
784
1029
|
}
|
|
785
1030
|
|
|
786
|
-
|
|
1031
|
+
var outcome = await activateAllConfiguredScopes(activeTask);
|
|
1032
|
+
|
|
1033
|
+
var branchResult = null;
|
|
1034
|
+
try {
|
|
1035
|
+
branchResult = await createTaskBranch(outcome.activeTask);
|
|
1036
|
+
} catch (branchErr) {
|
|
1037
|
+
branchResult = { error: branchErr.message };
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
res.json({
|
|
1041
|
+
status: "in progress",
|
|
1042
|
+
scopes: outcome.results,
|
|
1043
|
+
branch: branchResult,
|
|
1044
|
+
activeTask: outcome.activeTask,
|
|
1045
|
+
});
|
|
787
1046
|
} catch (e) {
|
|
788
1047
|
res.status(500).json({ error: e.message });
|
|
789
1048
|
}
|
|
@@ -943,21 +1202,45 @@ function classifyPath(filePath) {
|
|
|
943
1202
|
if (parts.length === 1 && parts[0] === ".focus") {
|
|
944
1203
|
return { kind: "focus" };
|
|
945
1204
|
}
|
|
946
|
-
if (
|
|
1205
|
+
if (
|
|
1206
|
+
parts.length === 2 &&
|
|
1207
|
+
parts[0] === "_lint-events" &&
|
|
1208
|
+
parts[1].endsWith(".json")
|
|
1209
|
+
) {
|
|
947
1210
|
return { kind: "lint", id: parts[1].slice(0, -5) };
|
|
948
1211
|
}
|
|
949
|
-
if (
|
|
1212
|
+
if (
|
|
1213
|
+
parts.length === 2 &&
|
|
1214
|
+
parts[0] === "_prompt-drafts" &&
|
|
1215
|
+
parts[1].endsWith(".json")
|
|
1216
|
+
) {
|
|
950
1217
|
if (parts[1] === "_active.json") return { kind: "draft-active" };
|
|
951
1218
|
return { kind: "draft", id: parts[1].slice(0, -5) };
|
|
952
1219
|
}
|
|
953
1220
|
if (parts.length === 1 && parts[0].endsWith(".json")) {
|
|
954
1221
|
return { kind: "plan", slug: parts[0].slice(0, -5) };
|
|
955
1222
|
}
|
|
956
|
-
if (
|
|
957
|
-
|
|
1223
|
+
if (
|
|
1224
|
+
parts.length === 3 &&
|
|
1225
|
+
parts[1] === "artifacts" &&
|
|
1226
|
+
parts[2].endsWith(".json")
|
|
1227
|
+
) {
|
|
1228
|
+
return {
|
|
1229
|
+
kind: "artifact",
|
|
1230
|
+
slug: parts[0],
|
|
1231
|
+
artifactSlug: parts[2].slice(0, -5),
|
|
1232
|
+
};
|
|
958
1233
|
}
|
|
959
|
-
if (
|
|
960
|
-
|
|
1234
|
+
if (
|
|
1235
|
+
parts.length === 3 &&
|
|
1236
|
+
parts[1] === "prompts" &&
|
|
1237
|
+
parts[2].endsWith(".json")
|
|
1238
|
+
) {
|
|
1239
|
+
return {
|
|
1240
|
+
kind: "prompt",
|
|
1241
|
+
slug: parts[0],
|
|
1242
|
+
promptSlug: parts[2].slice(0, -5),
|
|
1243
|
+
};
|
|
961
1244
|
}
|
|
962
1245
|
return null;
|
|
963
1246
|
}
|
|
@@ -1000,19 +1283,20 @@ function handleWatcherChange(event, filePath) {
|
|
|
1000
1283
|
if (event === "unlink") {
|
|
1001
1284
|
broadcastClaudePlanEvent("artifact:delete", {
|
|
1002
1285
|
plan_slug: info.slug,
|
|
1003
|
-
slug: info.artifactSlug
|
|
1286
|
+
slug: info.artifactSlug,
|
|
1004
1287
|
});
|
|
1005
1288
|
return;
|
|
1006
1289
|
}
|
|
1007
1290
|
const artifact = safeReadJson(filePath);
|
|
1008
|
-
if (artifact)
|
|
1291
|
+
if (artifact)
|
|
1292
|
+
broadcastClaudePlanEvent("artifact:upsert", { artifact: artifact });
|
|
1009
1293
|
return;
|
|
1010
1294
|
}
|
|
1011
1295
|
if (info.kind === "prompt") {
|
|
1012
1296
|
if (event === "unlink") {
|
|
1013
1297
|
broadcastClaudePlanEvent("prompt:delete", {
|
|
1014
1298
|
plan_slug: info.slug,
|
|
1015
|
-
slug: info.promptSlug
|
|
1299
|
+
slug: info.promptSlug,
|
|
1016
1300
|
});
|
|
1017
1301
|
return;
|
|
1018
1302
|
}
|
|
@@ -1033,7 +1317,9 @@ function handleWatcherChange(event, filePath) {
|
|
|
1033
1317
|
// The active-tab pointer changed (a draft was made active, or the active
|
|
1034
1318
|
// one was deleted and focus advanced). Tell editors which tab is active.
|
|
1035
1319
|
const ptr = safeReadJson(filePath);
|
|
1036
|
-
broadcastClaudePlanEvent("draft:active", {
|
|
1320
|
+
broadcastClaudePlanEvent("draft:active", {
|
|
1321
|
+
active_id: ptr ? ptr.active_id : null,
|
|
1322
|
+
});
|
|
1037
1323
|
return;
|
|
1038
1324
|
}
|
|
1039
1325
|
if (info.kind === "draft") {
|
|
@@ -1062,11 +1348,17 @@ function startClaudePlanWatcher() {
|
|
|
1062
1348
|
claudePlanWatcher = chokidar.watch(CLAUDE_PLANS_DIR, {
|
|
1063
1349
|
ignoreInitial: true,
|
|
1064
1350
|
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
|
|
1065
|
-
depth: 3
|
|
1351
|
+
depth: 3,
|
|
1352
|
+
});
|
|
1353
|
+
claudePlanWatcher.on("add", function (p) {
|
|
1354
|
+
handleWatcherChange("add", p);
|
|
1355
|
+
});
|
|
1356
|
+
claudePlanWatcher.on("change", function (p) {
|
|
1357
|
+
handleWatcherChange("change", p);
|
|
1358
|
+
});
|
|
1359
|
+
claudePlanWatcher.on("unlink", function (p) {
|
|
1360
|
+
handleWatcherChange("unlink", p);
|
|
1066
1361
|
});
|
|
1067
|
-
claudePlanWatcher.on("add", function (p) { handleWatcherChange("add", p); });
|
|
1068
|
-
claudePlanWatcher.on("change", function (p) { handleWatcherChange("change", p); });
|
|
1069
|
-
claudePlanWatcher.on("unlink", function (p) { handleWatcherChange("unlink", p); });
|
|
1070
1362
|
}
|
|
1071
1363
|
|
|
1072
1364
|
app.get("/claude-plans", function (req, res) {
|
|
@@ -1082,7 +1374,10 @@ app.get("/prompt-editor", claudePlansLimiter, function (req, res) {
|
|
|
1082
1374
|
// Legacy path-segment deep links (/claude-plans/:slug) redirect to the
|
|
1083
1375
|
// query-param form so relative assets resolve against /claude-plans.
|
|
1084
1376
|
app.get("/claude-plans/:slug", function (req, res) {
|
|
1085
|
-
res.redirect(
|
|
1377
|
+
res.redirect(
|
|
1378
|
+
301,
|
|
1379
|
+
"/claude-plans?plan=" + encodeURIComponent(req.params.slug)
|
|
1380
|
+
);
|
|
1086
1381
|
});
|
|
1087
1382
|
|
|
1088
1383
|
app.get("/prompt-lints", function (req, res) {
|
|
@@ -1092,13 +1387,18 @@ app.get("/prompt-lints", function (req, res) {
|
|
|
1092
1387
|
app.get("/api/prompt-lints", function (req, res) {
|
|
1093
1388
|
try {
|
|
1094
1389
|
const filters = {};
|
|
1095
|
-
if (typeof req.query.session_id === "string")
|
|
1096
|
-
|
|
1390
|
+
if (typeof req.query.session_id === "string")
|
|
1391
|
+
filters.session_id = req.query.session_id;
|
|
1392
|
+
if (typeof req.query.plan_slug === "string")
|
|
1393
|
+
filters.plan_slug = req.query.plan_slug;
|
|
1097
1394
|
if (typeof req.query.limit === "string") {
|
|
1098
1395
|
const n = parseInt(req.query.limit, 10);
|
|
1099
1396
|
if (!isNaN(n) && n > 0) filters.limit = Math.min(n, 1000); // mirror getLintEventsSchema cap
|
|
1100
1397
|
}
|
|
1101
|
-
res.json({
|
|
1398
|
+
res.json({
|
|
1399
|
+
events: listClaudeLintEvents(filters),
|
|
1400
|
+
storage: CLAUDE_PLANS_DIR,
|
|
1401
|
+
});
|
|
1102
1402
|
} catch (e) {
|
|
1103
1403
|
res.status(500).json({ error: e.message });
|
|
1104
1404
|
}
|
|
@@ -1150,7 +1450,7 @@ function resolvePrStatus(prUrl) {
|
|
|
1150
1450
|
prStatusCache.set(prUrl, {
|
|
1151
1451
|
state: result.state,
|
|
1152
1452
|
merged: result.merged,
|
|
1153
|
-
fetchedAt: Date.now()
|
|
1453
|
+
fetchedAt: Date.now(),
|
|
1154
1454
|
});
|
|
1155
1455
|
return result;
|
|
1156
1456
|
});
|
|
@@ -1168,8 +1468,12 @@ function mapWithConcurrency(items, limit, worker) {
|
|
|
1168
1468
|
if (index >= items.length) return;
|
|
1169
1469
|
const i = index++;
|
|
1170
1470
|
Promise.resolve(worker(items[i]))
|
|
1171
|
-
.then(function (r) {
|
|
1172
|
-
|
|
1471
|
+
.then(function (r) {
|
|
1472
|
+
results[i] = r;
|
|
1473
|
+
})
|
|
1474
|
+
.catch(function () {
|
|
1475
|
+
results[i] = null;
|
|
1476
|
+
})
|
|
1173
1477
|
.then(function () {
|
|
1174
1478
|
completed++;
|
|
1175
1479
|
if (completed === items.length) resolve(results);
|
|
@@ -1211,7 +1515,7 @@ app.get("/api/claude-plans/stream", function (req, res) {
|
|
|
1211
1515
|
"Content-Type": "text/event-stream",
|
|
1212
1516
|
"Cache-Control": "no-cache",
|
|
1213
1517
|
Connection: "keep-alive",
|
|
1214
|
-
"X-Accel-Buffering": "no"
|
|
1518
|
+
"X-Accel-Buffering": "no",
|
|
1215
1519
|
});
|
|
1216
1520
|
res.flushHeaders();
|
|
1217
1521
|
res.write("event: hello\ndata: {}\n\n");
|
|
@@ -1236,13 +1540,14 @@ app.get("/api/claude-plans/stream", function (req, res) {
|
|
|
1236
1540
|
app.get("/api/claude-plans/:slug", function (req, res) {
|
|
1237
1541
|
try {
|
|
1238
1542
|
const slug = req.params.slug;
|
|
1239
|
-
if (!isValidSlug(slug))
|
|
1543
|
+
if (!isValidSlug(slug))
|
|
1544
|
+
return res.status(400).json({ error: "invalid slug" });
|
|
1240
1545
|
const plan = safeReadJson(planFilePath(slug));
|
|
1241
1546
|
if (!plan) return res.status(404).json({ error: "plan not found" });
|
|
1242
1547
|
res.json({
|
|
1243
1548
|
plan: plan,
|
|
1244
1549
|
artifacts: listClaudeArtifacts(slug),
|
|
1245
|
-
prompts: listClaudePrompts(slug)
|
|
1550
|
+
prompts: listClaudePrompts(slug),
|
|
1246
1551
|
});
|
|
1247
1552
|
} catch (e) {
|
|
1248
1553
|
res.status(500).json({ error: e.message });
|
|
@@ -1261,7 +1566,9 @@ function sendTypedError(res, err) {
|
|
|
1261
1566
|
// a `code` (e.g. ILLEGAL_TRANSITION, MISSING_AGENT). Anything else is
|
|
1262
1567
|
// a 500 with the message.
|
|
1263
1568
|
if (err && err.name === "ZodError") {
|
|
1264
|
-
return res
|
|
1569
|
+
return res
|
|
1570
|
+
.status(400)
|
|
1571
|
+
.json({ error: "validation_failed", details: err.issues });
|
|
1265
1572
|
}
|
|
1266
1573
|
if (err && typeof err.code === "string") {
|
|
1267
1574
|
var status = 409;
|
|
@@ -1272,83 +1579,104 @@ function sendTypedError(res, err) {
|
|
|
1272
1579
|
return res.status(status).json({
|
|
1273
1580
|
error: err.code,
|
|
1274
1581
|
name: err.name,
|
|
1275
|
-
message: err.message
|
|
1582
|
+
message: err.message,
|
|
1276
1583
|
});
|
|
1277
1584
|
}
|
|
1278
1585
|
if (err && /not found/i.test(err.message || "")) {
|
|
1279
1586
|
return res.status(404).json({ error: "not_found", message: err.message });
|
|
1280
1587
|
}
|
|
1281
|
-
return res
|
|
1588
|
+
return res
|
|
1589
|
+
.status(500)
|
|
1590
|
+
.json({ error: "internal", message: (err && err.message) || String(err) });
|
|
1282
1591
|
}
|
|
1283
1592
|
|
|
1284
1593
|
// POST /api/claude-plans/:slug/answers — record an answer to a question.
|
|
1285
1594
|
// Body: { question_id, answer, answered_by? }
|
|
1286
|
-
app.post(
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1595
|
+
app.post(
|
|
1596
|
+
"/api/claude-plans/:slug/answers",
|
|
1597
|
+
claudePlansLimiter,
|
|
1598
|
+
express.json(),
|
|
1599
|
+
function (req, res) {
|
|
1600
|
+
try {
|
|
1601
|
+
var slug = req.params.slug;
|
|
1602
|
+
if (!isValidSlug(slug))
|
|
1603
|
+
return res.status(400).json({ error: "invalid slug" });
|
|
1604
|
+
var body = req.body || {};
|
|
1605
|
+
var result = claudePlansLib.recordAnswer({
|
|
1606
|
+
plan_slug: slug,
|
|
1607
|
+
question_id: body.question_id,
|
|
1608
|
+
answer: body.answer,
|
|
1609
|
+
answered_by: body.answered_by || "dashboard",
|
|
1610
|
+
});
|
|
1611
|
+
res.json(result);
|
|
1612
|
+
} catch (e) {
|
|
1613
|
+
sendTypedError(res, e);
|
|
1614
|
+
}
|
|
1300
1615
|
}
|
|
1301
|
-
|
|
1616
|
+
);
|
|
1302
1617
|
|
|
1303
1618
|
// POST /api/claude-plans/:slug/stage — move the plan to a new stage.
|
|
1304
1619
|
// Body: { to: PipelineStage, by? }
|
|
1305
1620
|
// Source is forced to 'dashboard' so the conflict-resolution rule
|
|
1306
1621
|
// (docs/v2-design.md §4) treats dashboard moves as authoritative.
|
|
1307
|
-
app.post(
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1622
|
+
app.post(
|
|
1623
|
+
"/api/claude-plans/:slug/stage",
|
|
1624
|
+
claudePlansLimiter,
|
|
1625
|
+
express.json(),
|
|
1626
|
+
function (req, res) {
|
|
1627
|
+
try {
|
|
1628
|
+
var slug = req.params.slug;
|
|
1629
|
+
if (!isValidSlug(slug))
|
|
1630
|
+
return res.status(400).json({ error: "invalid slug" });
|
|
1631
|
+
var body = req.body || {};
|
|
1632
|
+
var result = claudePlansLib.setStage({
|
|
1633
|
+
plan_slug: slug,
|
|
1634
|
+
to: body.to,
|
|
1635
|
+
by: body.by || "dashboard",
|
|
1636
|
+
source: "dashboard",
|
|
1637
|
+
});
|
|
1638
|
+
res.json(result);
|
|
1639
|
+
} catch (e) {
|
|
1640
|
+
sendTypedError(res, e);
|
|
1641
|
+
}
|
|
1321
1642
|
}
|
|
1322
|
-
|
|
1643
|
+
);
|
|
1323
1644
|
|
|
1324
1645
|
// POST /api/claude-plans/:slug/dispatch — dry-run or live dispatch.
|
|
1325
1646
|
// Body: { target_stage, confirm?, token?, by? }
|
|
1326
1647
|
// The dashboard's flow is: POST without confirm (dry-run) → show
|
|
1327
1648
|
// resolved command in the UI → POST with confirm:true + token from
|
|
1328
1649
|
// set_stage response → live spawn.
|
|
1329
|
-
app.post(
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1650
|
+
app.post(
|
|
1651
|
+
"/api/claude-plans/:slug/dispatch",
|
|
1652
|
+
claudePlansLimiter,
|
|
1653
|
+
express.json(),
|
|
1654
|
+
function (req, res) {
|
|
1655
|
+
try {
|
|
1656
|
+
var slug = req.params.slug;
|
|
1657
|
+
if (!isValidSlug(slug))
|
|
1658
|
+
return res.status(400).json({ error: "invalid slug" });
|
|
1659
|
+
var body = req.body || {};
|
|
1660
|
+
var result = claudePlansLib.dispatchStage({
|
|
1661
|
+
plan_slug: slug,
|
|
1662
|
+
target_stage: body.target_stage,
|
|
1663
|
+
confirm: body.confirm === true,
|
|
1664
|
+
token: body.token,
|
|
1665
|
+
by: body.by || "dashboard",
|
|
1666
|
+
});
|
|
1667
|
+
res.json(result);
|
|
1668
|
+
} catch (e) {
|
|
1669
|
+
sendTypedError(res, e);
|
|
1670
|
+
}
|
|
1344
1671
|
}
|
|
1345
|
-
|
|
1672
|
+
);
|
|
1346
1673
|
|
|
1347
1674
|
// GET /api/claude-plans/:slug/versions — list saved version snapshots (newest-first).
|
|
1348
1675
|
app.get("/api/claude-plans/:slug/versions", function (req, res) {
|
|
1349
1676
|
try {
|
|
1350
1677
|
var slug = req.params.slug;
|
|
1351
|
-
if (!isValidSlug(slug))
|
|
1678
|
+
if (!isValidSlug(slug))
|
|
1679
|
+
return res.status(400).json({ error: "invalid slug" });
|
|
1352
1680
|
res.json({ slug: slug, versions: claudePlansLib.listVersions(slug) });
|
|
1353
1681
|
} catch (e) {
|
|
1354
1682
|
sendTypedError(res, e);
|
|
@@ -1359,7 +1687,8 @@ app.get("/api/claude-plans/:slug/versions", function (req, res) {
|
|
|
1359
1687
|
app.get("/api/claude-plans/:slug/versions/:n", function (req, res) {
|
|
1360
1688
|
try {
|
|
1361
1689
|
var slug = req.params.slug;
|
|
1362
|
-
if (!isValidSlug(slug))
|
|
1690
|
+
if (!isValidSlug(slug))
|
|
1691
|
+
return res.status(400).json({ error: "invalid slug" });
|
|
1363
1692
|
var n = parseInt(req.params.n, 10);
|
|
1364
1693
|
if (!(n > 0)) return res.status(400).json({ error: "invalid version" });
|
|
1365
1694
|
var version = claudePlansLib.getVersion(slug, n);
|
|
@@ -1380,7 +1709,8 @@ app.post(
|
|
|
1380
1709
|
function (req, res) {
|
|
1381
1710
|
try {
|
|
1382
1711
|
var slug = req.params.slug;
|
|
1383
|
-
if (!isValidSlug(slug))
|
|
1712
|
+
if (!isValidSlug(slug))
|
|
1713
|
+
return res.status(400).json({ error: "invalid slug" });
|
|
1384
1714
|
var n = parseInt(req.params.n, 10);
|
|
1385
1715
|
if (!(n > 0)) return res.status(400).json({ error: "invalid version" });
|
|
1386
1716
|
var plan = claudePlansLib.restoreVersion(slug, n);
|
|
@@ -1394,14 +1724,20 @@ app.post(
|
|
|
1394
1724
|
app.delete("/api/claude-plans/:slug", claudePlansLimiter, function (req, res) {
|
|
1395
1725
|
try {
|
|
1396
1726
|
var slug = req.params.slug;
|
|
1397
|
-
if (!isValidSlug(slug))
|
|
1727
|
+
if (!isValidSlug(slug))
|
|
1728
|
+
return res.status(400).json({ error: "invalid slug" });
|
|
1398
1729
|
var baseDir = path.resolve(CLAUDE_PLANS_DIR);
|
|
1399
1730
|
var planFile = path.resolve(baseDir, slug + ".json");
|
|
1400
|
-
if (!planFile.startsWith(baseDir + path.sep))
|
|
1401
|
-
|
|
1731
|
+
if (!planFile.startsWith(baseDir + path.sep))
|
|
1732
|
+
return res.status(400).json({ error: "invalid slug" });
|
|
1733
|
+
if (!fs.existsSync(planFile))
|
|
1734
|
+
return res.status(404).json({ error: "plan not found" });
|
|
1402
1735
|
fs.unlinkSync(planFile);
|
|
1403
1736
|
var artifactsDir = path.resolve(baseDir, slug);
|
|
1404
|
-
if (
|
|
1737
|
+
if (
|
|
1738
|
+
artifactsDir.startsWith(baseDir + path.sep) &&
|
|
1739
|
+
fs.existsSync(artifactsDir)
|
|
1740
|
+
) {
|
|
1405
1741
|
fs.rmSync(artifactsDir, { recursive: true, force: true });
|
|
1406
1742
|
}
|
|
1407
1743
|
res.json({ deleted: true });
|
|
@@ -1427,37 +1763,49 @@ app.get("/api/prompt-drafts", claudePlansLimiter, function (req, res) {
|
|
|
1427
1763
|
});
|
|
1428
1764
|
|
|
1429
1765
|
// POST /api/prompt-drafts — create a new draft (tab). Body: { title?, content? }
|
|
1430
|
-
app.post(
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1766
|
+
app.post(
|
|
1767
|
+
"/api/prompt-drafts",
|
|
1768
|
+
claudePlansLimiter,
|
|
1769
|
+
express.json(),
|
|
1770
|
+
function (req, res) {
|
|
1771
|
+
try {
|
|
1772
|
+
var body = req.body || {};
|
|
1773
|
+
var draft = claudePlansLib.createPromptDraft({
|
|
1774
|
+
title: body.title,
|
|
1775
|
+
content: body.content,
|
|
1776
|
+
session_id: body.session_id,
|
|
1777
|
+
});
|
|
1778
|
+
res.json(draft);
|
|
1779
|
+
} catch (e) {
|
|
1780
|
+
sendTypedError(res, e);
|
|
1781
|
+
}
|
|
1441
1782
|
}
|
|
1442
|
-
|
|
1783
|
+
);
|
|
1443
1784
|
|
|
1444
1785
|
// POST /api/prompt-drafts/active — set the active tab. Body: { id }
|
|
1445
1786
|
// Declared before "/:id" so Express doesn't treat "active" as an id.
|
|
1446
|
-
app.post(
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1787
|
+
app.post(
|
|
1788
|
+
"/api/prompt-drafts/active",
|
|
1789
|
+
claudePlansLimiter,
|
|
1790
|
+
express.json(),
|
|
1791
|
+
function (req, res) {
|
|
1792
|
+
try {
|
|
1793
|
+
var body = req.body || {};
|
|
1794
|
+
if (!isValidDraftId(body.id))
|
|
1795
|
+
return res.status(400).json({ error: "invalid draft id" });
|
|
1796
|
+
res.json(claudePlansLib.setActivePromptDraft(body.id));
|
|
1797
|
+
} catch (e) {
|
|
1798
|
+
sendTypedError(res, e);
|
|
1799
|
+
}
|
|
1453
1800
|
}
|
|
1454
|
-
|
|
1801
|
+
);
|
|
1455
1802
|
|
|
1456
1803
|
// GET /api/prompt-drafts/:id — read one draft.
|
|
1457
1804
|
app.get("/api/prompt-drafts/:id", claudePlansLimiter, function (req, res) {
|
|
1458
1805
|
try {
|
|
1459
1806
|
var id = req.params.id;
|
|
1460
|
-
if (!isValidDraftId(id))
|
|
1807
|
+
if (!isValidDraftId(id))
|
|
1808
|
+
return res.status(400).json({ error: "invalid draft id" });
|
|
1461
1809
|
var draft = claudePlansLib.getPromptDraft(id);
|
|
1462
1810
|
if (!draft) return res.status(404).json({ error: "draft not found" });
|
|
1463
1811
|
res.json(draft);
|
|
@@ -1467,25 +1815,38 @@ app.get("/api/prompt-drafts/:id", claudePlansLimiter, function (req, res) {
|
|
|
1467
1815
|
});
|
|
1468
1816
|
|
|
1469
1817
|
// PUT /api/prompt-drafts/:id — autosave title/content. Body: { title?, content? }
|
|
1470
|
-
app.put(
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1818
|
+
app.put(
|
|
1819
|
+
"/api/prompt-drafts/:id",
|
|
1820
|
+
claudePlansLimiter,
|
|
1821
|
+
express.json(),
|
|
1822
|
+
function (req, res) {
|
|
1823
|
+
try {
|
|
1824
|
+
var id = req.params.id;
|
|
1825
|
+
if (!isValidDraftId(id))
|
|
1826
|
+
return res.status(400).json({ error: "invalid draft id" });
|
|
1827
|
+
var body = req.body || {};
|
|
1828
|
+
if (body.title === undefined && body.content === undefined) {
|
|
1829
|
+
return res.status(400).json({ error: "nothing to update" });
|
|
1830
|
+
}
|
|
1831
|
+
res.json(
|
|
1832
|
+
claudePlansLib.updatePromptDraft({
|
|
1833
|
+
id: id,
|
|
1834
|
+
title: body.title,
|
|
1835
|
+
content: body.content,
|
|
1836
|
+
})
|
|
1837
|
+
);
|
|
1838
|
+
} catch (e) {
|
|
1839
|
+
sendTypedError(res, e);
|
|
1477
1840
|
}
|
|
1478
|
-
res.json(claudePlansLib.updatePromptDraft({ id: id, title: body.title, content: body.content }));
|
|
1479
|
-
} catch (e) {
|
|
1480
|
-
sendTypedError(res, e);
|
|
1481
1841
|
}
|
|
1482
|
-
|
|
1842
|
+
);
|
|
1483
1843
|
|
|
1484
1844
|
// DELETE /api/prompt-drafts/:id — close a tab. Returns the new active id.
|
|
1485
1845
|
app.delete("/api/prompt-drafts/:id", claudePlansLimiter, function (req, res) {
|
|
1486
1846
|
try {
|
|
1487
1847
|
var id = req.params.id;
|
|
1488
|
-
if (!isValidDraftId(id))
|
|
1848
|
+
if (!isValidDraftId(id))
|
|
1849
|
+
return res.status(400).json({ error: "invalid draft id" });
|
|
1489
1850
|
res.json(claudePlansLib.deletePromptDraft(id));
|
|
1490
1851
|
} catch (e) {
|
|
1491
1852
|
sendTypedError(res, e);
|
|
@@ -1518,7 +1879,8 @@ const todoReadLimiter = RateLimit({
|
|
|
1518
1879
|
const todoSseClients = new Set();
|
|
1519
1880
|
|
|
1520
1881
|
function broadcastTodos(list) {
|
|
1521
|
-
const frame =
|
|
1882
|
+
const frame =
|
|
1883
|
+
"event: todos:update\ndata: " + JSON.stringify({ list: list }) + "\n\n";
|
|
1522
1884
|
for (const res of todoSseClients) {
|
|
1523
1885
|
try {
|
|
1524
1886
|
res.write(frame);
|
|
@@ -1550,7 +1912,9 @@ function startTodoWatcher() {
|
|
|
1550
1912
|
});
|
|
1551
1913
|
todoWatcher.on("add", emitTodoState);
|
|
1552
1914
|
todoWatcher.on("change", emitTodoState);
|
|
1553
|
-
todoWatcher.on("unlink", function () {
|
|
1915
|
+
todoWatcher.on("unlink", function () {
|
|
1916
|
+
broadcastTodos({ schema_version: 1, items: [] });
|
|
1917
|
+
});
|
|
1554
1918
|
}
|
|
1555
1919
|
|
|
1556
1920
|
app.get("/todos", todoReadLimiter, function (req, res) {
|
|
@@ -1559,7 +1923,10 @@ app.get("/todos", todoReadLimiter, function (req, res) {
|
|
|
1559
1923
|
|
|
1560
1924
|
app.get("/api/todos", todoReadLimiter, function (req, res) {
|
|
1561
1925
|
try {
|
|
1562
|
-
res.json({
|
|
1926
|
+
res.json({
|
|
1927
|
+
list: todoLib.loadList({ rootDir: TODO_DIR }),
|
|
1928
|
+
storage: TODO_DIR,
|
|
1929
|
+
});
|
|
1563
1930
|
} catch (e) {
|
|
1564
1931
|
res.status(500).json({ error: e.message });
|
|
1565
1932
|
}
|
|
@@ -1636,9 +2003,15 @@ app.patch("/api/todos/:id", todoLimiter, function (req, res) {
|
|
|
1636
2003
|
const body = req.body || {};
|
|
1637
2004
|
let result;
|
|
1638
2005
|
if (typeof body.text === "string") {
|
|
1639
|
-
result = todoLib.updateTodo(
|
|
2006
|
+
result = todoLib.updateTodo(
|
|
2007
|
+
{ id: id, text: body.text },
|
|
2008
|
+
{ rootDir: TODO_DIR }
|
|
2009
|
+
);
|
|
1640
2010
|
} else {
|
|
1641
|
-
result = todoLib.toggleTodo(
|
|
2011
|
+
result = todoLib.toggleTodo(
|
|
2012
|
+
{ id: id, done: body.done },
|
|
2013
|
+
{ rootDir: TODO_DIR }
|
|
2014
|
+
);
|
|
1642
2015
|
}
|
|
1643
2016
|
broadcastTodos(result.list);
|
|
1644
2017
|
res.json(result);
|
|
@@ -1650,7 +2023,10 @@ app.patch("/api/todos/:id", todoLimiter, function (req, res) {
|
|
|
1650
2023
|
// DELETE /api/todos/:id — remove one item.
|
|
1651
2024
|
app.delete("/api/todos/:id", todoLimiter, function (req, res) {
|
|
1652
2025
|
try {
|
|
1653
|
-
const result = todoLib.removeTodo(
|
|
2026
|
+
const result = todoLib.removeTodo(
|
|
2027
|
+
{ id: req.params.id },
|
|
2028
|
+
{ rootDir: TODO_DIR }
|
|
2029
|
+
);
|
|
1654
2030
|
broadcastTodos(result.list);
|
|
1655
2031
|
res.json(result);
|
|
1656
2032
|
} catch (e) {
|