p-backlog 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/cli.js +363 -103
- package/dist/web/assets/{ChartTooltip-DwQKHXkN.js → ChartTooltip-DWaDSRan.js} +3 -3
- package/dist/web/assets/CodeTab-C87LaJFD.js +1 -0
- package/dist/web/assets/{CostTab-B21EB-7P.js → CostTab-D4ByOnHX.js} +1 -1
- package/dist/web/assets/EffectTab-Dq06ghtZ.js +1 -0
- package/dist/web/assets/Figure-BC1eGh3_.js +1 -0
- package/dist/web/assets/OverviewTab-BBmohHJR.js +1 -0
- package/dist/web/assets/QualityTab-Rb7zRcGH.js +1 -0
- package/dist/web/assets/StatsPage-Dtyc1gAc.js +1 -0
- package/dist/web/assets/StatsTabState-Br-8FgKd.js +1 -0
- package/dist/web/assets/{StatsTable-Bd3vAxKU.js → StatsTable-BXG0IS8p.js} +1 -1
- package/dist/web/assets/UnavailableRepos-KiTvsm1X.js +1 -0
- package/dist/web/assets/cx-CNNf0q6V.js +58 -0
- package/dist/web/assets/index-AJ1Qzly1.js +2 -0
- package/dist/web/assets/start-BGXTwsB0.js +38 -0
- package/dist/web/assets/{index-CAKUAPJF.css → start-Cy19NMke.css} +1 -1
- package/dist/web/assets/{value-dot-kiMizZCu.js → value-dot-W-8PsGpd.js} +1 -1
- package/dist/web/index.html +1 -5
- package/package.json +1 -1
- package/skill/backlog/SKILL.md +3 -2
- package/skill/backlog-en/SKILL.md +3 -2
- package/dist/web/assets/CodeTab-BGxsIZMH.js +0 -1
- package/dist/web/assets/EffectTab-97OFZkWY.js +0 -1
- package/dist/web/assets/Figure-CiIqHKnb.js +0 -1
- package/dist/web/assets/OverviewTab-X_g8WfUl.js +0 -1
- package/dist/web/assets/QualityTab-DYnFz37G.js +0 -1
- package/dist/web/assets/StatsPage-D9gJHHQg.js +0 -1
- package/dist/web/assets/StatsTabState-DbuUIt-D.js +0 -1
- package/dist/web/assets/UnavailableRepos-D2iehY7u.js +0 -1
- package/dist/web/assets/cx-BZTW4xgb.js +0 -58
- package/dist/web/assets/index-BrkoyuWi.js +0 -38
- package/dist/web/assets/paths-ZO6w2vYU.js +0 -1
package/dist/cli.js
CHANGED
|
@@ -170,6 +170,17 @@ function completedEpicChildren(task, index) {
|
|
|
170
170
|
const complete = children.length > 0 && children.every((child) => isClosed(child.status));
|
|
171
171
|
return complete ? children.map((child) => child.id) : null;
|
|
172
172
|
}
|
|
173
|
+
function isAutoClosedEpic(task) {
|
|
174
|
+
return task.type === "epic" && isClosed(task.status) && task.resolution === "epic-done";
|
|
175
|
+
}
|
|
176
|
+
function planEpicReopening(tasks) {
|
|
177
|
+
const index = buildIndex(tasks);
|
|
178
|
+
return tasks.flatMap((epic) => {
|
|
179
|
+
if (!isAutoClosedEpic(epic)) return [];
|
|
180
|
+
const openChildIds = epicChildren(epic, index).flatMap((child) => isClosed(child.status) ? [] : [child.id]);
|
|
181
|
+
return openChildIds.length === 0 ? [] : [{ epic, childIds: openChildIds }];
|
|
182
|
+
});
|
|
183
|
+
}
|
|
173
184
|
function epicDoneClosure(childIds, messages) {
|
|
174
185
|
return { resolution: "epic-done", reason: messages.epicDoneReason(childIds) };
|
|
175
186
|
}
|
|
@@ -936,18 +947,18 @@ function compareIds(a, b) {
|
|
|
936
947
|
if (!left || !right) return a.localeCompare(b);
|
|
937
948
|
return left.prefix === right.prefix ? left.number - right.number : left.prefix.localeCompare(right.prefix);
|
|
938
949
|
}
|
|
939
|
-
function derivePrefix(
|
|
940
|
-
const base = prefixBase(
|
|
950
|
+
function derivePrefix(basename11, taken) {
|
|
951
|
+
const base = prefixBase(basename11);
|
|
941
952
|
return firstFree(base, taken, (n) => `${base}${n}`);
|
|
942
953
|
}
|
|
943
|
-
function deriveProjectId(
|
|
944
|
-
const slug =
|
|
954
|
+
function deriveProjectId(basename11, taken) {
|
|
955
|
+
const slug = basename11.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
945
956
|
const base = slug === "" ? "project" : slug;
|
|
946
957
|
return firstFree(base, taken, (n) => `${base}-${n}`);
|
|
947
958
|
}
|
|
948
959
|
var PREFIX_BASE_LENGTH = 4;
|
|
949
|
-
function prefixBase(
|
|
950
|
-
const words =
|
|
960
|
+
function prefixBase(basename11) {
|
|
961
|
+
const words = basename11.split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
951
962
|
const letters = words.length > 1 ? words.map((word) => word.charAt(0)).join("") : words[0] ?? "";
|
|
952
963
|
const prefix = letters.slice(0, PREFIX_BASE_LENGTH).toUpperCase();
|
|
953
964
|
if (prefix === "") return "PROJ";
|
|
@@ -1040,7 +1051,7 @@ function recordedMethodOf({ method, bySymbol, byAnchor }) {
|
|
|
1040
1051
|
if (method !== void 0) return method;
|
|
1041
1052
|
return bySymbol === true || byAnchor === true ? checkMethodOf({ bySymbol, byAnchor }) : "unknown";
|
|
1042
1053
|
}
|
|
1043
|
-
var eventBase = { at: z4.iso.datetime({ offset: true }), task: z4.string().min(1), via: recordedEnum(CHANGE_SOURCES) };
|
|
1054
|
+
var eventBase = { at: z4.iso.datetime({ offset: true }), task: z4.string().min(1), via: recordedEnum(CHANGE_SOURCES), undo: z4.literal(true).optional().catch(void 0) };
|
|
1044
1055
|
var taskSnapshotSchema = taskFrontmatterSchema.extend({
|
|
1045
1056
|
priority: recordedEnum(PRIORITIES),
|
|
1046
1057
|
category: recordedEnum(TASK_CATEGORIES).optional(),
|
|
@@ -1129,6 +1140,10 @@ function changeEvents(before, after, now, via) {
|
|
|
1129
1140
|
}
|
|
1130
1141
|
return events;
|
|
1131
1142
|
}
|
|
1143
|
+
function statusBeforeAutoClose(journal, epicId) {
|
|
1144
|
+
const autoClose = journal.findLast((event) => event.task === epicId && event.kind === "status" && event.resolution === "epic-done");
|
|
1145
|
+
return autoClose?.kind === "status" && !isClosed(autoClose.from) ? autoClose.from : "backlog";
|
|
1146
|
+
}
|
|
1132
1147
|
function deletedEvent(task, now, via) {
|
|
1133
1148
|
return { at: formatLocalIso(now), task: task.id, via, kind: "deleted", snapshot: snapshotOf(task) };
|
|
1134
1149
|
}
|
|
@@ -1164,13 +1179,26 @@ function candidateGoneEvents(sightings, tasks, states, now, checked = CANDIDATE_
|
|
|
1164
1179
|
}
|
|
1165
1180
|
function episodeStates(journal) {
|
|
1166
1181
|
const states = /* @__PURE__ */ new Map();
|
|
1182
|
+
const beforeClosing = /* @__PURE__ */ new Map();
|
|
1167
1183
|
for (const event of journal) {
|
|
1168
1184
|
if (event.kind === "candidate") states.set(episodeKey(event.task, event.evidence), "open");
|
|
1169
1185
|
else if (event.kind === "candidate-gone") states.set(episodeKey(event.task, event.evidence), "ended");
|
|
1170
|
-
else if (
|
|
1186
|
+
else if (undoesClosing(event)) {
|
|
1187
|
+
for (const [key, state] of beforeClosing.get(event.task) ?? []) {
|
|
1188
|
+
if (state === void 0) states.delete(key);
|
|
1189
|
+
else states.set(key, state);
|
|
1190
|
+
}
|
|
1191
|
+
} else if (endsEpisodes(event)) {
|
|
1192
|
+
const keys = CANDIDATE_EVIDENCE.map((evidence2) => episodeKey(event.task, evidence2));
|
|
1193
|
+
if (event.kind === "status") beforeClosing.set(event.task, keys.map((key) => [key, states.get(key)]));
|
|
1194
|
+
for (const key of keys) states.set(key, "ended");
|
|
1195
|
+
}
|
|
1171
1196
|
}
|
|
1172
1197
|
return states;
|
|
1173
1198
|
}
|
|
1199
|
+
function undoesClosing(event) {
|
|
1200
|
+
return event.kind === "status" && event.undo === true && isClosed(event.from) && !isClosed(event.to);
|
|
1201
|
+
}
|
|
1174
1202
|
function endsEpisodes(event) {
|
|
1175
1203
|
if (event.kind === "verified" || event.kind === "deleted") return true;
|
|
1176
1204
|
return event.kind === "status" && isClosed(event.to);
|
|
@@ -1203,7 +1231,7 @@ function taskHistories(tasks, journals, unparsedIds = /* @__PURE__ */ new Set())
|
|
|
1203
1231
|
if (event.kind === "category") item.categoryEvents.push({ at: Date.parse(event.at), to: event.to });
|
|
1204
1232
|
if (event.kind === "priority") item.priorityEvents.push({ at: Date.parse(event.at), to: event.to });
|
|
1205
1233
|
if (event.kind === "status") {
|
|
1206
|
-
item.transitions.push({ at: Date.parse(event.at), from: event.from, to: event.to, resolution: event.resolution, via: event.via });
|
|
1234
|
+
item.transitions.push({ at: Date.parse(event.at), from: event.from, to: event.to, resolution: event.resolution, via: event.via, undo: event.undo });
|
|
1207
1235
|
}
|
|
1208
1236
|
if (event.kind === "candidate") item.candidates.push({ at: Date.parse(event.at), evidence: event.evidence, method: recordedMethodOf(event), match: event.match ?? "unknown" });
|
|
1209
1237
|
if (event.kind === "candidate-filtered") item.filtered.push(Date.parse(event.at));
|
|
@@ -1235,7 +1263,7 @@ function historyOf(id, { projectId, final, created, categoryEvents, priorityEven
|
|
|
1235
1263
|
const createdIso = final?.created ?? created?.at;
|
|
1236
1264
|
const type = final?.type ?? created?.type;
|
|
1237
1265
|
if (createdIso === void 0 || type === void 0) return [];
|
|
1238
|
-
const ordered = [...transitions].sort((a, b) => a.at - b.at);
|
|
1266
|
+
const ordered = withoutUndoneClosings([...transitions].sort((a, b) => a.at - b.at));
|
|
1239
1267
|
const fateUnknown = final === void 0 && unparsed;
|
|
1240
1268
|
return [
|
|
1241
1269
|
{
|
|
@@ -1257,6 +1285,15 @@ function historyOf(id, { projectId, final, created, categoryEvents, priorityEven
|
|
|
1257
1285
|
}
|
|
1258
1286
|
];
|
|
1259
1287
|
}
|
|
1288
|
+
function withoutUndoneClosings(ordered) {
|
|
1289
|
+
const kept = [];
|
|
1290
|
+
for (const transition of ordered) {
|
|
1291
|
+
const lastKept = kept.at(-1);
|
|
1292
|
+
if (transition.undo === true && lastKept !== void 0 && isClosing(lastKept) && transition.from === lastKept.to) kept.pop();
|
|
1293
|
+
else kept.push(transition);
|
|
1294
|
+
}
|
|
1295
|
+
return kept;
|
|
1296
|
+
}
|
|
1260
1297
|
function categoryOf(final, created, categoryEvents) {
|
|
1261
1298
|
if (final !== void 0) return final.category;
|
|
1262
1299
|
const lastCategoryEvent = [...categoryEvents].sort((a, b) => a.at - b.at).at(-1);
|
|
@@ -1462,6 +1499,8 @@ function problem(p) {
|
|
|
1462
1499
|
return `epic ${p.epic} not found`;
|
|
1463
1500
|
case "epic-not-epic":
|
|
1464
1501
|
return `${p.epic} is not an epic`;
|
|
1502
|
+
case "epic-foreign-project":
|
|
1503
|
+
return `epic ${p.epic} belongs to another project \u2014 clear the epic or pick one from this project`;
|
|
1465
1504
|
case "epic-in-epic":
|
|
1466
1505
|
return "an epic cannot belong to another epic";
|
|
1467
1506
|
case "reference-missing":
|
|
@@ -1494,6 +1533,8 @@ function checkFix(fix) {
|
|
|
1494
1533
|
return `${fix.taskId}: removed references to missing tasks: ${fix.ids.join(", ")}`;
|
|
1495
1534
|
case "epic-closed":
|
|
1496
1535
|
return `${fix.taskId}: epic closed \u2014 ${epicDoneReason(fix.childIds)}`;
|
|
1536
|
+
case "epic-reopened":
|
|
1537
|
+
return `${fix.taskId}: epic reopened \u2014 it has open tasks: ${fix.childIds.join(", ")}`;
|
|
1497
1538
|
case "source-moved":
|
|
1498
1539
|
return `${fix.taskId}: source moved ${lineSuffix(fix.from)} \u2192 ${lineSuffix(fix.to)}`;
|
|
1499
1540
|
}
|
|
@@ -1691,6 +1732,8 @@ function problem2(p) {
|
|
|
1691
1732
|
return `\u044D\u043F\u0438\u043A ${p.epic} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D`;
|
|
1692
1733
|
case "epic-not-epic":
|
|
1693
1734
|
return `${p.epic} \u043D\u0435 \u044F\u0432\u043B\u044F\u0435\u0442\u0441\u044F \u044D\u043F\u0438\u043A\u043E\u043C`;
|
|
1735
|
+
case "epic-foreign-project":
|
|
1736
|
+
return `\u044D\u043F\u0438\u043A ${p.epic} \u0438\u0437 \u0434\u0440\u0443\u0433\u043E\u0433\u043E \u043F\u0440\u043E\u0435\u043A\u0442\u0430 \u2014 \u0441\u043D\u0438\u043C\u0438\u0442\u0435 \u044D\u043F\u0438\u043A \u0438\u043B\u0438 \u0432\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u044D\u043F\u0438\u043A \u044D\u0442\u043E\u0433\u043E \u043F\u0440\u043E\u0435\u043A\u0442\u0430`;
|
|
1694
1737
|
case "epic-in-epic":
|
|
1695
1738
|
return "\u044D\u043F\u0438\u043A \u043D\u0435 \u043C\u043E\u0436\u0435\u0442 \u0432\u0445\u043E\u0434\u0438\u0442\u044C \u0432 \u0434\u0440\u0443\u0433\u043E\u0439 \u044D\u043F\u0438\u043A";
|
|
1696
1739
|
case "reference-missing":
|
|
@@ -1723,6 +1766,8 @@ function checkFix2(fix) {
|
|
|
1723
1766
|
return `${fix.taskId}: \u0443\u0431\u0440\u0430\u043D\u044B \u0441\u0441\u044B\u043B\u043A\u0438 \u043D\u0430 \u043D\u0435\u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044E\u0449\u0438\u0435 \u0437\u0430\u0434\u0430\u0447\u0438: ${fix.ids.join(", ")}`;
|
|
1724
1767
|
case "epic-closed":
|
|
1725
1768
|
return `${fix.taskId}: \u044D\u043F\u0438\u043A \u0437\u0430\u043A\u0440\u044B\u0442 \u2014 ${epicDoneReason2(fix.childIds)}`;
|
|
1769
|
+
case "epic-reopened":
|
|
1770
|
+
return `${fix.taskId}: \u044D\u043F\u0438\u043A \u0441\u043D\u043E\u0432\u0430 \u043E\u0442\u043A\u0440\u044B\u0442 \u2014 \u0432 \u043D\u0451\u043C \u043E\u0442\u043A\u0440\u044B\u0442\u044B\u0435 \u0437\u0430\u0434\u0430\u0447\u0438: ${fix.childIds.join(", ")}`;
|
|
1726
1771
|
case "source-moved":
|
|
1727
1772
|
return `${fix.taskId}: source \u0441\u0434\u0432\u0438\u043D\u0443\u043B\u0441\u044F ${lineSuffix(fix.from)} \u2192 ${lineSuffix(fix.to)}`;
|
|
1728
1773
|
}
|
|
@@ -2001,7 +2046,7 @@ function isTaskFileName(name) {
|
|
|
2001
2046
|
}
|
|
2002
2047
|
|
|
2003
2048
|
// src/cli/lookups.ts
|
|
2004
|
-
import { basename as basename6, dirname as
|
|
2049
|
+
import { basename as basename6, dirname as dirname4, relative as relative2, sep as sep2 } from "node:path";
|
|
2005
2050
|
|
|
2006
2051
|
// src/core/store/create.ts
|
|
2007
2052
|
import { mkdir as mkdir3 } from "node:fs/promises";
|
|
@@ -2035,6 +2080,7 @@ function epicProblems(candidate, resolve7) {
|
|
|
2035
2080
|
const epic = resolve7(candidate.epic);
|
|
2036
2081
|
if (!epic) problems3.push({ code: "epic-missing", epic: candidate.epic });
|
|
2037
2082
|
else if (epic.type !== "epic") problems3.push({ code: "epic-not-epic", epic: candidate.epic });
|
|
2083
|
+
else if (epic.projectId !== candidate.projectId) problems3.push({ code: "epic-foreign-project", epic: candidate.epic });
|
|
2038
2084
|
}
|
|
2039
2085
|
if (candidate.type === "epic") problems3.push({ code: "epic-in-epic" });
|
|
2040
2086
|
return problems3;
|
|
@@ -2134,11 +2180,93 @@ function taskText(draft) {
|
|
|
2134
2180
|
return { ok: true, value: { text, task: { ...parsed.value, version: contentVersion(text) } } };
|
|
2135
2181
|
}
|
|
2136
2182
|
|
|
2183
|
+
// src/core/store/update.ts
|
|
2184
|
+
import { dirname as dirname3 } from "node:path";
|
|
2185
|
+
|
|
2137
2186
|
// src/core/store/write-result.ts
|
|
2138
2187
|
function invalid(errors) {
|
|
2139
2188
|
return { ok: false, reason: "invalid", errors };
|
|
2140
2189
|
}
|
|
2141
2190
|
|
|
2191
|
+
// src/core/store/update.ts
|
|
2192
|
+
var CHANGE_FIELDS = ["title", "type", "priority", "tags", "blockedBy", "related", "body", "source", "verified"];
|
|
2193
|
+
async function updateTaskInIndex(index, request) {
|
|
2194
|
+
const before = index.byId.get(request.id);
|
|
2195
|
+
const result = await writeChanges(index, request);
|
|
2196
|
+
if (result.ok) await reopenEpicOfOpenedTask(index, { before, after: result.task, now: request.now, via: request.via, undo: request.undo });
|
|
2197
|
+
return result;
|
|
2198
|
+
}
|
|
2199
|
+
async function statusToReopen(epic) {
|
|
2200
|
+
const { events } = await readJournal(dirname3(epic.path), epic.projectId);
|
|
2201
|
+
return statusBeforeAutoClose(events, epic.id);
|
|
2202
|
+
}
|
|
2203
|
+
async function reopenEpicOfOpenedTask(index, { before, after, now, via, undo }) {
|
|
2204
|
+
if (after.epic === void 0 || isClosed(after.status)) return;
|
|
2205
|
+
const becameOpenInEpic = before === void 0 || isClosed(before.status) || before.epic !== after.epic;
|
|
2206
|
+
const epic = index.byId.get(after.epic);
|
|
2207
|
+
if (!becameOpenInEpic || epic === void 0 || !isAutoClosedEpic(epic)) return;
|
|
2208
|
+
try {
|
|
2209
|
+
if (await diskChange(epic, epic.version) !== null) return;
|
|
2210
|
+
await writeChanges(index, { id: epic.id, changes: { status: await statusToReopen(epic) }, expectedVersion: epic.version, now, via, undo });
|
|
2211
|
+
} catch (error) {
|
|
2212
|
+
if (!(error instanceof FileBusyError)) console.error(`${epic.path}: ${errorText(error)}`);
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
async function writeChanges(index, { id, changes, expectedVersion, now, closure, via, undo = false }) {
|
|
2216
|
+
const current = index.byId.get(id);
|
|
2217
|
+
if (!current) return { ok: false, reason: "not-found" };
|
|
2218
|
+
if (expectedVersion !== current.version) return { ok: false, reason: "conflict", current };
|
|
2219
|
+
const normalized = taskText(applyChanges(current, changes, now, closure));
|
|
2220
|
+
if (!normalized.ok) return invalid(normalized.problems);
|
|
2221
|
+
const { text, task } = normalized.value;
|
|
2222
|
+
const errors = integrityErrors(task, index);
|
|
2223
|
+
if (errors.length > 0) return invalid(errors);
|
|
2224
|
+
return withFileLock(current.path, async () => {
|
|
2225
|
+
const changedOnDisk = await diskChange(current, expectedVersion);
|
|
2226
|
+
if (changedOnDisk !== null) return changedOnDisk;
|
|
2227
|
+
await writeFileAtomic(current.path, text);
|
|
2228
|
+
const events = changeEvents(current, task, now, via);
|
|
2229
|
+
await appendJournal(dirname3(current.path), undo ? events.map((event) => ({ ...event, undo: true })) : events);
|
|
2230
|
+
return { ok: true, task };
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
async function diskChange(snapshot, expectedVersion) {
|
|
2234
|
+
const text = await readTextOrNull(snapshot.path);
|
|
2235
|
+
if (text === null) return { ok: false, reason: "not-found" };
|
|
2236
|
+
const version = contentVersion(text);
|
|
2237
|
+
if (version === expectedVersion) return null;
|
|
2238
|
+
const fresh = parseTaskFile(text, { projectId: snapshot.projectId, path: snapshot.path, version });
|
|
2239
|
+
return { ok: false, reason: "conflict", current: fresh.ok ? fresh.value : snapshot };
|
|
2240
|
+
}
|
|
2241
|
+
function applyChanges(task, changes, now, closure) {
|
|
2242
|
+
const edited = {
|
|
2243
|
+
...task,
|
|
2244
|
+
...pickDefined(changes, CHANGE_FIELDS),
|
|
2245
|
+
epic: nextOptional(task.epic, changes.epic),
|
|
2246
|
+
category: nextOptional(task.category, changes.category),
|
|
2247
|
+
anchor: nextAnchor(task, changes)
|
|
2248
|
+
};
|
|
2249
|
+
const moved = changes.status === void 0 ? edited : changeStatus(edited, changes.status, now, closure);
|
|
2250
|
+
return settleLifecycle(moved, now);
|
|
2251
|
+
}
|
|
2252
|
+
function nextAnchor(task, changes) {
|
|
2253
|
+
if (changes.anchor !== void 0) return changes.anchor ?? void 0;
|
|
2254
|
+
const sourceMoved = changes.source !== void 0 && changes.source !== task.source;
|
|
2255
|
+
return sourceMoved ? void 0 : task.anchor;
|
|
2256
|
+
}
|
|
2257
|
+
function pickDefined(source, keys) {
|
|
2258
|
+
const picked = {};
|
|
2259
|
+
for (const key of keys) {
|
|
2260
|
+
const value = source[key];
|
|
2261
|
+
if (value !== void 0) picked[key] = value;
|
|
2262
|
+
}
|
|
2263
|
+
return picked;
|
|
2264
|
+
}
|
|
2265
|
+
function nextOptional(current, change) {
|
|
2266
|
+
if (change === null) return void 0;
|
|
2267
|
+
return change ?? current;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2142
2270
|
// src/core/store/create.ts
|
|
2143
2271
|
var MAX_ID_ATTEMPTS = 20;
|
|
2144
2272
|
async function createTask(root, request) {
|
|
@@ -2156,6 +2284,7 @@ async function createTask(root, request) {
|
|
|
2156
2284
|
try {
|
|
2157
2285
|
await createFileAtomic(path, text);
|
|
2158
2286
|
await appendJournal(dir, [createdEvent(task, request.now, request.via, request.provenance)]);
|
|
2287
|
+
await reopenEpicOfOpenedTask(index, { before: void 0, after: task, now: request.now, via: request.via });
|
|
2159
2288
|
return { ok: true, task };
|
|
2160
2289
|
} catch (error) {
|
|
2161
2290
|
if (!hasErrorCode(error, "EEXIST")) throw error;
|
|
@@ -2166,7 +2295,8 @@ async function createTask(root, request) {
|
|
|
2166
2295
|
async function createProject(root, repoRoot, existingProjects) {
|
|
2167
2296
|
const name = basename5(repoRoot);
|
|
2168
2297
|
const entries = await listDir(root);
|
|
2169
|
-
const
|
|
2298
|
+
const joinable = await Promise.all(entries.map((entry) => entry.isDirectory() && isJoinableProjectDir(join9(root, entry.name), repoRoot)));
|
|
2299
|
+
const id = deriveProjectId(name, new Set(entries.filter((_, index) => !joinable[index]).map((entry) => entry.name)));
|
|
2170
2300
|
const prefixesOnDisk = await Promise.all(entries.filter((entry) => entry.isDirectory()).map((entry) => takenPrefixes(join9(root, entry.name))));
|
|
2171
2301
|
const prefix = derivePrefix(name, /* @__PURE__ */ new Set([...existingProjects.map((project) => project.prefix), ...prefixesOnDisk.flat()]));
|
|
2172
2302
|
const dir = join9(root, id);
|
|
@@ -2184,6 +2314,11 @@ async function createProject(root, repoRoot, existingProjects) {
|
|
|
2184
2314
|
if (!parsed.ok) throw new Error(`${path}: ${parsed.problems.map((problem3) => problem3.code).join(", ")}`);
|
|
2185
2315
|
return parsed.value;
|
|
2186
2316
|
}
|
|
2317
|
+
async function isJoinableProjectDir(dir, repoRoot) {
|
|
2318
|
+
const path = join9(dir, PROJECT_FILE);
|
|
2319
|
+
if (await readTextOrNull(path) !== null) return await projectOfRepo(basename5(dir), path, repoRoot) !== null;
|
|
2320
|
+
return (await listDir(dir)).every((entry) => !entry.name.endsWith(".md"));
|
|
2321
|
+
}
|
|
2187
2322
|
async function projectOfRepo(id, path, repoRoot) {
|
|
2188
2323
|
const parsed = await readProjectFile({ id, path });
|
|
2189
2324
|
return parsed?.ok === true && parsed.value.repos.includes(repoRoot) ? parsed.value : null;
|
|
@@ -2424,7 +2559,7 @@ async function brokenProjectFilesOf(loaded, roots, home2) {
|
|
|
2424
2559
|
const ownId = deriveProjectId(basename6(roots.main), /* @__PURE__ */ new Set());
|
|
2425
2560
|
const broken = loaded.errors.filter((error) => basename6(error.path) === PROJECT_FILE);
|
|
2426
2561
|
const texts = await Promise.all(broken.map(async (error) => await readTextOrNull(error.path) ?? ""));
|
|
2427
|
-
return broken.filter((error, position) => basename6(
|
|
2562
|
+
return broken.filter((error, position) => basename6(dirname4(error.path)) === ownId || repoPaths.some((path) => mentionsPath(texts[position] ?? "", path)));
|
|
2428
2563
|
}
|
|
2429
2564
|
var PATH_BOUNDARY = /[\s"',[\]]/;
|
|
2430
2565
|
var TRAILING_SEPARATORS = /^[\\/]*/;
|
|
@@ -2446,63 +2581,6 @@ function findProject(loaded, io, explicitId) {
|
|
|
2446
2581
|
return explicitId === void 0 ? findProjectForDir(loaded.projects, io.cwd, io.home) : loaded.projects.find((project) => project.id === explicitId);
|
|
2447
2582
|
}
|
|
2448
2583
|
|
|
2449
|
-
// src/core/store/update.ts
|
|
2450
|
-
import { dirname as dirname4 } from "node:path";
|
|
2451
|
-
var CHANGE_FIELDS = ["title", "type", "priority", "tags", "blockedBy", "related", "body", "source", "verified"];
|
|
2452
|
-
async function updateTaskInIndex(index, { id, changes, expectedVersion, now, closure, via }) {
|
|
2453
|
-
const current = index.byId.get(id);
|
|
2454
|
-
if (!current) return { ok: false, reason: "not-found" };
|
|
2455
|
-
if (expectedVersion !== current.version) return { ok: false, reason: "conflict", current };
|
|
2456
|
-
const normalized = taskText(applyChanges(current, changes, now, closure));
|
|
2457
|
-
if (!normalized.ok) return invalid(normalized.problems);
|
|
2458
|
-
const { text, task } = normalized.value;
|
|
2459
|
-
const errors = integrityErrors(task, index);
|
|
2460
|
-
if (errors.length > 0) return invalid(errors);
|
|
2461
|
-
return withFileLock(current.path, async () => {
|
|
2462
|
-
const changedOnDisk = await diskChange(current, expectedVersion);
|
|
2463
|
-
if (changedOnDisk !== null) return changedOnDisk;
|
|
2464
|
-
await writeFileAtomic(current.path, text);
|
|
2465
|
-
await appendJournal(dirname4(current.path), changeEvents(current, task, now, via));
|
|
2466
|
-
return { ok: true, task };
|
|
2467
|
-
});
|
|
2468
|
-
}
|
|
2469
|
-
async function diskChange(snapshot, expectedVersion) {
|
|
2470
|
-
const text = await readTextOrNull(snapshot.path);
|
|
2471
|
-
if (text === null) return { ok: false, reason: "not-found" };
|
|
2472
|
-
const version = contentVersion(text);
|
|
2473
|
-
if (version === expectedVersion) return null;
|
|
2474
|
-
const fresh = parseTaskFile(text, { projectId: snapshot.projectId, path: snapshot.path, version });
|
|
2475
|
-
return { ok: false, reason: "conflict", current: fresh.ok ? fresh.value : snapshot };
|
|
2476
|
-
}
|
|
2477
|
-
function applyChanges(task, changes, now, closure) {
|
|
2478
|
-
const edited = {
|
|
2479
|
-
...task,
|
|
2480
|
-
...pickDefined(changes, CHANGE_FIELDS),
|
|
2481
|
-
epic: nextOptional(task.epic, changes.epic),
|
|
2482
|
-
category: nextOptional(task.category, changes.category),
|
|
2483
|
-
anchor: nextAnchor(task, changes)
|
|
2484
|
-
};
|
|
2485
|
-
const moved = changes.status === void 0 ? edited : changeStatus(edited, changes.status, now, closure);
|
|
2486
|
-
return settleLifecycle(moved, now);
|
|
2487
|
-
}
|
|
2488
|
-
function nextAnchor(task, changes) {
|
|
2489
|
-
if (changes.anchor !== void 0) return changes.anchor ?? void 0;
|
|
2490
|
-
const sourceMoved = changes.source !== void 0 && changes.source !== task.source;
|
|
2491
|
-
return sourceMoved ? void 0 : task.anchor;
|
|
2492
|
-
}
|
|
2493
|
-
function pickDefined(source, keys) {
|
|
2494
|
-
const picked = {};
|
|
2495
|
-
for (const key of keys) {
|
|
2496
|
-
const value = source[key];
|
|
2497
|
-
if (value !== void 0) picked[key] = value;
|
|
2498
|
-
}
|
|
2499
|
-
return picked;
|
|
2500
|
-
}
|
|
2501
|
-
function nextOptional(current, change) {
|
|
2502
|
-
if (change === null) return void 0;
|
|
2503
|
-
return change ?? current;
|
|
2504
|
-
}
|
|
2505
|
-
|
|
2506
2584
|
// src/cli/update-failure.ts
|
|
2507
2585
|
function reportUpdateFailure(io, id, result) {
|
|
2508
2586
|
switch (result.reason) {
|
|
@@ -3384,14 +3462,12 @@ function sightingOf(candidate) {
|
|
|
3384
3462
|
}
|
|
3385
3463
|
async function applyFixes(loaded, inScope, { now, messages }) {
|
|
3386
3464
|
const isGone = goneTaskCheck(loaded);
|
|
3387
|
-
const
|
|
3388
|
-
planEpicClosing(loaded.tasks, loaded.errors).close.map(({ epic, childIds }) => [epic.id, { closure: epicDoneClosure(childIds, messages), childIds }])
|
|
3389
|
-
);
|
|
3465
|
+
const epicFixes = await epicStatusFixes(loaded, inScope, messages);
|
|
3390
3466
|
const index = buildIndex(loaded.tasks);
|
|
3391
3467
|
const fixed = [];
|
|
3392
3468
|
const failed = [];
|
|
3393
3469
|
for (const task of loaded.tasks.filter((candidate) => inScope(candidate.projectId))) {
|
|
3394
|
-
const fix = planFix(task, isGone,
|
|
3470
|
+
const fix = planFix(task, isGone, epicFixes.get(task.id));
|
|
3395
3471
|
if (fix === null) continue;
|
|
3396
3472
|
const result = await updateTaskInIndex(index, { id: task.id, changes: fix.changes, expectedVersion: task.version, now, closure: fix.closure, via: "check" });
|
|
3397
3473
|
if (result.ok) fixed.push(...fix.done);
|
|
@@ -3409,14 +3485,24 @@ function fixFailure(taskId, failure) {
|
|
|
3409
3485
|
return { kind: "fix-failed", taskId, cause: "gone-during-check" };
|
|
3410
3486
|
}
|
|
3411
3487
|
}
|
|
3412
|
-
function
|
|
3488
|
+
async function epicStatusFixes(loaded, inScope, messages) {
|
|
3489
|
+
const closing = planEpicClosing(loaded.tasks, loaded.errors).close.map(({ epic, childIds }) => [
|
|
3490
|
+
epic.id,
|
|
3491
|
+
{ status: "done", closure: epicDoneClosure(childIds, messages), done: { kind: "epic-closed", taskId: epic.id, childIds } }
|
|
3492
|
+
]);
|
|
3493
|
+
const reopenable = planEpicReopening(loaded.tasks).filter(({ epic }) => inScope(epic.projectId));
|
|
3494
|
+
const reopening = await Promise.all(
|
|
3495
|
+
reopenable.map(async ({ epic, childIds }) => [epic.id, { status: await statusToReopen(epic), done: { kind: "epic-reopened", taskId: epic.id, childIds } }])
|
|
3496
|
+
);
|
|
3497
|
+
return new Map([...closing, ...reopening]);
|
|
3498
|
+
}
|
|
3499
|
+
function planFix(task, isGone, epicFix) {
|
|
3413
3500
|
const cleanup = referenceCleanup(task, isGone);
|
|
3414
|
-
if (cleanup === null &&
|
|
3501
|
+
if (cleanup === null && epicFix === void 0) return null;
|
|
3415
3502
|
const done = [];
|
|
3416
3503
|
if (cleanup !== null) done.push({ kind: "references-removed", taskId: task.id, ids: goneReferences(task, isGone) });
|
|
3417
|
-
if (
|
|
3418
|
-
|
|
3419
|
-
return { changes: { ...cleanup, status: "done" }, closure: epicClosing.closure, done };
|
|
3504
|
+
if (epicFix === void 0) return { changes: cleanup ?? {}, done };
|
|
3505
|
+
return { changes: { ...cleanup, status: epicFix.status }, closure: epicFix.closure, done: [...done, epicFix.done] };
|
|
3420
3506
|
}
|
|
3421
3507
|
function goneReferences(task, isGone) {
|
|
3422
3508
|
const references = [...task.blockedBy, ...task.related, ...task.epic === void 0 ? [] : [task.epic]];
|
|
@@ -4603,6 +4689,13 @@ var serverEn = {
|
|
|
4603
4689
|
projectNotFound: (id) => `Project ${id} not found`,
|
|
4604
4690
|
confirmMismatch: "Confirmation does not match the project id",
|
|
4605
4691
|
bodyNotParsed: "Request body could not be parsed: JSON expected",
|
|
4692
|
+
batchSkipped: {
|
|
4693
|
+
changed: (id) => `${id} changed on disk`,
|
|
4694
|
+
"not-found": (id) => `${id} not found`,
|
|
4695
|
+
"already-closed": (id) => `${id} is already closed`,
|
|
4696
|
+
invalid: (id) => `${id}: the action doesn't apply to it`,
|
|
4697
|
+
busy: (id) => `${id} is busy in another process`
|
|
4698
|
+
},
|
|
4606
4699
|
unknownRoute: (path) => `Unknown API route: ${path}`,
|
|
4607
4700
|
hostRejected: (host) => `Requests from host ${host} are not accepted`,
|
|
4608
4701
|
jsonContentTypeExpected: "Content-Type: application/json is expected",
|
|
@@ -4613,6 +4706,7 @@ Backlog directory: ${root}
|
|
|
4613
4706
|
settingsFileInvalid: (path) => `${path} could not be parsed, the language for this run was detected automatically. The file was not changed \u2014 fix it manually.`,
|
|
4614
4707
|
sweepFailed: (detail) => `Could not delete closed tasks: ${detail}`,
|
|
4615
4708
|
closedEpics: (ids) => `Closed completed epics: ${ids}`,
|
|
4709
|
+
reopenedEpics: (ids) => `Reopened epics that got an open task again: ${ids}`,
|
|
4616
4710
|
epicsBlockedByFiles: (paths) => `Epics will not close until these files are fixed: ${paths}`,
|
|
4617
4711
|
deletedClosedTasks: (ids) => `Deleted closed tasks: ${ids}`,
|
|
4618
4712
|
conflictedDuringSweep: (ids) => `Tasks changed during the sweep, will retry next time: ${ids}`,
|
|
@@ -4633,6 +4727,13 @@ var serverRu = {
|
|
|
4633
4727
|
projectNotFound: (id) => `\u041F\u0440\u043E\u0435\u043A\u0442 ${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D`,
|
|
4634
4728
|
confirmMismatch: "\u041F\u043E\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043D\u0438\u0435 \u043D\u0435 \u0441\u043E\u0432\u043F\u0430\u0434\u0430\u0435\u0442 \u0441 id \u043F\u0440\u043E\u0435\u043A\u0442\u0430",
|
|
4635
4729
|
bodyNotParsed: "\u0422\u0435\u043B\u043E \u0437\u0430\u043F\u0440\u043E\u0441\u0430 \u043D\u0435 \u0440\u0430\u0437\u043E\u0431\u0440\u0430\u043D\u043E: \u043E\u0436\u0438\u0434\u0430\u0435\u0442\u0441\u044F JSON",
|
|
4730
|
+
batchSkipped: {
|
|
4731
|
+
changed: (id) => `${id} \u0438\u0437\u043C\u0435\u043D\u0438\u043B\u0430\u0441\u044C \u043D\u0430 \u0434\u0438\u0441\u043A\u0435`,
|
|
4732
|
+
"not-found": (id) => `${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430`,
|
|
4733
|
+
"already-closed": (id) => `${id} \u0443\u0436\u0435 \u0437\u0430\u043A\u0440\u044B\u0442\u0430`,
|
|
4734
|
+
invalid: (id) => `${id}: \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435 \u043A \u043D\u0435\u0439 \u043D\u0435 \u043F\u043E\u0434\u0445\u043E\u0434\u0438\u0442`,
|
|
4735
|
+
busy: (id) => `${id} \u0437\u0430\u043D\u044F\u0442\u0430 \u0434\u0440\u0443\u0433\u0438\u043C \u043F\u0440\u043E\u0446\u0435\u0441\u0441\u043E\u043C`
|
|
4736
|
+
},
|
|
4636
4737
|
unknownRoute: (path) => `\u041D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u044B\u0439 \u0430\u0434\u0440\u0435\u0441 API: ${path}`,
|
|
4637
4738
|
hostRejected: (host) => `\u0417\u0430\u043F\u0440\u043E\u0441\u044B \u0441 \u0445\u043E\u0441\u0442\u0430 ${host} \u043D\u0435 \u043F\u0440\u0438\u043D\u0438\u043C\u0430\u044E\u0442\u0441\u044F`,
|
|
4638
4739
|
jsonContentTypeExpected: "\u041E\u0436\u0438\u0434\u0430\u0435\u0442\u0441\u044F Content-Type: application/json",
|
|
@@ -4643,6 +4744,7 @@ var serverRu = {
|
|
|
4643
4744
|
settingsFileInvalid: (path) => `${path} \u043D\u0435 \u0440\u0430\u0437\u043E\u0431\u0440\u0430\u043D, \u044F\u0437\u044B\u043A \u0434\u043B\u044F \u044D\u0442\u043E\u0433\u043E \u0437\u0430\u043F\u0443\u0441\u043A\u0430 \u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0451\u043D \u0430\u0432\u0442\u043E\u043C\u0430\u0442\u0438\u0447\u0435\u0441\u043A\u0438. \u0424\u0430\u0439\u043B \u043D\u0435 \u0438\u0437\u043C\u0435\u043D\u0451\u043D \u2014 \u043F\u043E\u043F\u0440\u0430\u0432\u044C\u0442\u0435 \u0435\u0433\u043E \u0432\u0440\u0443\u0447\u043D\u0443\u044E.`,
|
|
4644
4745
|
sweepFailed: (detail) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0443\u0434\u0430\u043B\u0438\u0442\u044C \u0437\u0430\u043A\u0440\u044B\u0442\u044B\u0435 \u0437\u0430\u0434\u0430\u0447\u0438: ${detail}`,
|
|
4645
4746
|
closedEpics: (ids) => `\u0417\u0430\u043A\u0440\u044B\u0442\u044B \u0437\u0430\u0432\u0435\u0440\u0448\u0451\u043D\u043D\u044B\u0435 \u044D\u043F\u0438\u043A\u0438: ${ids}`,
|
|
4747
|
+
reopenedEpics: (ids) => `\u0421\u043D\u043E\u0432\u0430 \u043E\u0442\u043A\u0440\u044B\u0442\u044B \u044D\u043F\u0438\u043A\u0438, \u0432 \u043A\u043E\u0442\u043E\u0440\u044B\u0445 \u043E\u0442\u043A\u0440\u044B\u043B\u0438 \u0437\u0430\u0434\u0430\u0447\u0443: ${ids}`,
|
|
4646
4748
|
epicsBlockedByFiles: (paths) => `\u042D\u043F\u0438\u043A\u0438 \u043D\u0435 \u0437\u0430\u043A\u0440\u044B\u0432\u0430\u044E\u0442\u0441\u044F, \u043F\u043E\u043A\u0430 \u043D\u0435 \u0440\u0430\u0437\u043E\u0431\u0440\u0430\u043D\u044B \u0444\u0430\u0439\u043B\u044B: ${paths}`,
|
|
4647
4749
|
deletedClosedTasks: (ids) => `\u0423\u0434\u0430\u043B\u0435\u043D\u044B \u0437\u0430\u043A\u0440\u044B\u0442\u044B\u0435 \u0437\u0430\u0434\u0430\u0447\u0438: ${ids}`,
|
|
4648
4750
|
conflictedDuringSweep: (ids) => `\u0417\u0430\u0434\u0430\u0447\u0438 \u043C\u0435\u043D\u044F\u043B\u0438\u0441\u044C \u0432\u043E \u0432\u0440\u0435\u043C\u044F \u043F\u0440\u043E\u0445\u043E\u0434\u0430, \u043F\u043E\u0432\u0442\u043E\u0440\u044E \u043F\u0440\u0438 \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0435\u043C: ${ids}`,
|
|
@@ -4692,19 +4794,22 @@ import { join as join25 } from "node:path";
|
|
|
4692
4794
|
import { dirname as dirname7, join as join20 } from "node:path";
|
|
4693
4795
|
async function sweepClosed(root, now, messages) {
|
|
4694
4796
|
const initial = await loadBacklog(root);
|
|
4797
|
+
const reopening = await reopenEpicsWithOpenTasks(initial, now);
|
|
4695
4798
|
const epics = await closeCompletedEpics(initial, now, messages);
|
|
4696
|
-
const { projects, tasks } = epics.closed.length > 0 ? await loadBacklog(root) : initial;
|
|
4799
|
+
const { projects, tasks } = epics.closed.length > 0 || reopening.reopened.length > 0 ? await loadBacklog(root) : initial;
|
|
4697
4800
|
const waitsForEpic = (task) => task.epic !== void 0 && epics.leftOpen.has(task.epic);
|
|
4698
|
-
const
|
|
4801
|
+
const stillHoldsOpenTasks = new Set(planEpicReopening(tasks).map(({ epic }) => epic.id));
|
|
4802
|
+
const expired = tasks.filter((task) => isExpired(task, now) && !waitsForEpic(task) && !stillHoldsOpenTasks.has(task.id));
|
|
4699
4803
|
const reserved = await reserveNumbers(projects, expired);
|
|
4700
4804
|
const removable = expired.filter((task) => reserved.has(task.projectId));
|
|
4701
4805
|
const removal = await removeWithReferences(tasks, removable, now);
|
|
4702
4806
|
await removeAbandonedTemporaries(root, now);
|
|
4703
4807
|
return {
|
|
4704
4808
|
closedEpics: epics.closed,
|
|
4809
|
+
reopenedEpics: reopening.reopened,
|
|
4705
4810
|
blockingFiles: epics.blockingFiles,
|
|
4706
4811
|
deleted: removal.deleted,
|
|
4707
|
-
...failureLists([...epics.failures, ...removal.failures], messages)
|
|
4812
|
+
...failureLists([...reopening.failures, ...epics.failures, ...removal.failures], messages)
|
|
4708
4813
|
};
|
|
4709
4814
|
}
|
|
4710
4815
|
async function removeAbandonedTemporaries(root, now) {
|
|
@@ -4735,6 +4840,17 @@ async function unchangedOnDisk(tasks) {
|
|
|
4735
4840
|
);
|
|
4736
4841
|
return tasks.filter((_, position) => checked[position]);
|
|
4737
4842
|
}
|
|
4843
|
+
async function reopenEpicsWithOpenTasks(loaded, now) {
|
|
4844
|
+
const index = buildIndex(loaded.tasks);
|
|
4845
|
+
const reopened = [];
|
|
4846
|
+
const failures = [];
|
|
4847
|
+
for (const { epic } of planEpicReopening(loaded.tasks)) {
|
|
4848
|
+
const failure = await repairFailure(index, epic, { status: await statusToReopen(epic) }, now);
|
|
4849
|
+
if (failure === null) reopened.push(epic.id);
|
|
4850
|
+
else failures.push(failure);
|
|
4851
|
+
}
|
|
4852
|
+
return { reopened, failures };
|
|
4853
|
+
}
|
|
4738
4854
|
async function closeCompletedEpics(loaded, now, messages) {
|
|
4739
4855
|
const plan = planEpicClosing(loaded.tasks, loaded.errors);
|
|
4740
4856
|
const index = buildIndex(loaded.tasks);
|
|
@@ -4850,6 +4966,23 @@ var updateTaskRequestSchema = z12.strictObject({
|
|
|
4850
4966
|
version: z12.string().min(1),
|
|
4851
4967
|
changes: taskChangesSchema
|
|
4852
4968
|
});
|
|
4969
|
+
var batchPreviousSchema = z12.strictObject({
|
|
4970
|
+
status: z12.enum(TASK_STATUSES),
|
|
4971
|
+
priority: z12.enum(PRIORITIES),
|
|
4972
|
+
epic: taskIdSchema.nullable(),
|
|
4973
|
+
resolution: z12.enum(RESOLUTIONS).nullable(),
|
|
4974
|
+
reason: z12.string().nullable()
|
|
4975
|
+
});
|
|
4976
|
+
var BATCH_TASKS_LIMIT = 500;
|
|
4977
|
+
var batchRequestSchema = z12.strictObject({
|
|
4978
|
+
tasks: z12.array(z12.strictObject({ id: taskIdSchema, version: z12.string().min(1) })).min(1).max(BATCH_TASKS_LIMIT).refine((tasks) => new Set(tasks.map((task) => task.id)).size === tasks.length),
|
|
4979
|
+
action: z12.discriminatedUnion("kind", [
|
|
4980
|
+
z12.strictObject({ kind: z12.literal("close"), reason: z12.string().transform((reason) => reason.replace(/\s*\n\s*/g, " ").trim()).pipe(z12.string().min(1)) }),
|
|
4981
|
+
z12.strictObject({ kind: z12.literal("priority"), priority: z12.enum(PRIORITIES) }),
|
|
4982
|
+
z12.strictObject({ kind: z12.literal("epic"), epic: taskIdSchema.nullable() }),
|
|
4983
|
+
z12.strictObject({ kind: z12.literal("restore"), changes: z12.record(taskIdSchema, batchPreviousSchema) })
|
|
4984
|
+
])
|
|
4985
|
+
});
|
|
4853
4986
|
|
|
4854
4987
|
// src/core/check/graph-health.ts
|
|
4855
4988
|
async function projectGraphHealth(project, tasks, home2) {
|
|
@@ -4879,6 +5012,58 @@ function isStale(graph, pinned, hashOf) {
|
|
|
4879
5012
|
return count3("changed") > count3("fresh");
|
|
4880
5013
|
}
|
|
4881
5014
|
|
|
5015
|
+
// src/core/store/batch.ts
|
|
5016
|
+
async function applyBatch(index, { tasks, action, now }) {
|
|
5017
|
+
const ordered = [...tasks].sort((left, right) => compareIds(left.id, right.id));
|
|
5018
|
+
const outcomes = [];
|
|
5019
|
+
for (const task of ordered) outcomes.push(await applyOne(index, task, action, now));
|
|
5020
|
+
return outcomes;
|
|
5021
|
+
}
|
|
5022
|
+
async function applyOne(index, { id, version }, action, now) {
|
|
5023
|
+
const current = index.byId.get(id);
|
|
5024
|
+
if (!current) return { id, outcome: "skipped", reason: "not-found" };
|
|
5025
|
+
const plan = planFor(current, action);
|
|
5026
|
+
if ("skip" in plan) return { id, outcome: "skipped", reason: plan.skip };
|
|
5027
|
+
const result = await updateUnlessBusy(index, { id, changes: plan.changes, closure: plan.closure, expectedVersion: version, now, via: "web", undo: action.kind === "restore" });
|
|
5028
|
+
if (result === "busy") return { id, outcome: "skipped", reason: "busy" };
|
|
5029
|
+
if (result.ok) return { id, outcome: "done", task: result.task, previous: previousOf(current) };
|
|
5030
|
+
if (result.reason === "conflict") return { id, outcome: "skipped", reason: "changed" };
|
|
5031
|
+
if (result.reason === "not-found") return { id, outcome: "skipped", reason: "not-found" };
|
|
5032
|
+
return { id, outcome: "skipped", reason: "invalid", problems: result.errors };
|
|
5033
|
+
}
|
|
5034
|
+
async function updateUnlessBusy(index, request) {
|
|
5035
|
+
try {
|
|
5036
|
+
return await updateTaskInIndex(index, request);
|
|
5037
|
+
} catch (error) {
|
|
5038
|
+
if (error instanceof FileBusyError) return "busy";
|
|
5039
|
+
throw error;
|
|
5040
|
+
}
|
|
5041
|
+
}
|
|
5042
|
+
function planFor(current, action) {
|
|
5043
|
+
switch (action.kind) {
|
|
5044
|
+
case "close":
|
|
5045
|
+
return isClosed(current.status) ? { skip: "already-closed" } : { changes: { status: "cancelled" }, closure: { resolution: "obsolete", reason: action.reason } };
|
|
5046
|
+
case "priority":
|
|
5047
|
+
return { changes: { priority: action.priority } };
|
|
5048
|
+
case "epic":
|
|
5049
|
+
return epicPlan(current, action.epic);
|
|
5050
|
+
case "restore":
|
|
5051
|
+
return restorePlan(current, action.changes[current.id]);
|
|
5052
|
+
}
|
|
5053
|
+
}
|
|
5054
|
+
function epicPlan(current, epic) {
|
|
5055
|
+
return current.type === "epic" ? { skip: "invalid" } : { changes: { epic } };
|
|
5056
|
+
}
|
|
5057
|
+
function restorePlan(current, previous) {
|
|
5058
|
+
if (!previous) return { skip: "invalid" };
|
|
5059
|
+
const wouldClose = previous.status !== current.status && isClosed(previous.status);
|
|
5060
|
+
if (wouldClose) return { skip: "invalid" };
|
|
5061
|
+
return { changes: { status: previous.status, priority: previous.priority, epic: previous.epic } };
|
|
5062
|
+
}
|
|
5063
|
+
function previousOf(task) {
|
|
5064
|
+
return { status: task.status, priority: task.priority, epic: task.epic ?? null, resolution: task.resolution ?? null, reason: task.reason ?? null };
|
|
5065
|
+
}
|
|
5066
|
+
|
|
4882
5067
|
// src/server/report-cache.ts
|
|
4883
5068
|
function createReportCache({ ttlMs, now }) {
|
|
4884
5069
|
let entries = /* @__PURE__ */ new Map();
|
|
@@ -4898,6 +5083,44 @@ function createReportCache({ ttlMs, now }) {
|
|
|
4898
5083
|
};
|
|
4899
5084
|
}
|
|
4900
5085
|
|
|
5086
|
+
// src/server/revisions.ts
|
|
5087
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
5088
|
+
import { stat as stat4 } from "node:fs/promises";
|
|
5089
|
+
import { basename as basename9 } from "node:path";
|
|
5090
|
+
function createRevisions(boot = randomUUID3()) {
|
|
5091
|
+
let seq = 0;
|
|
5092
|
+
const ownMarks = /* @__PURE__ */ new Map();
|
|
5093
|
+
const isOwn = async (path) => {
|
|
5094
|
+
if (basename9(path) === JOURNAL_FILE) return true;
|
|
5095
|
+
const expected = ownMarks.get(path);
|
|
5096
|
+
ownMarks.delete(path);
|
|
5097
|
+
if (expected === void 0) return false;
|
|
5098
|
+
const actual = await markOf(path);
|
|
5099
|
+
return actual !== null && actual.version === expected.version && actual.mtimeMs === expected.mtimeMs;
|
|
5100
|
+
};
|
|
5101
|
+
return {
|
|
5102
|
+
current: () => ({ boot, seq }),
|
|
5103
|
+
recordOwnWrites: async (writes) => {
|
|
5104
|
+
const marks = await Promise.all(writes.map(async ({ path }) => ({ path, mark: await markOf(path) })));
|
|
5105
|
+
seq += 1;
|
|
5106
|
+
marks.forEach(({ path, mark }, index) => {
|
|
5107
|
+
if (mark !== null && mark.version === writes[index]?.version) ownMarks.set(path, mark);
|
|
5108
|
+
else ownMarks.delete(path);
|
|
5109
|
+
});
|
|
5110
|
+
},
|
|
5111
|
+
settle: async (paths) => {
|
|
5112
|
+
const own = paths.length > 0 && (await Promise.all(paths.map((path) => isOwn(path).catch(() => false)))).every(Boolean);
|
|
5113
|
+
if (own) return "own";
|
|
5114
|
+
seq += 1;
|
|
5115
|
+
return "foreign";
|
|
5116
|
+
}
|
|
5117
|
+
};
|
|
5118
|
+
}
|
|
5119
|
+
async function markOf(path) {
|
|
5120
|
+
const [text, stats] = await Promise.all([readTextOrNull(path), stat4(path).catch(() => null)]);
|
|
5121
|
+
return text === null || stats === null ? null : { version: contentVersion(text), mtimeMs: stats.mtimeMs };
|
|
5122
|
+
}
|
|
5123
|
+
|
|
4901
5124
|
// src/server/stats-api.ts
|
|
4902
5125
|
import { Hono } from "hono";
|
|
4903
5126
|
|
|
@@ -5880,9 +6103,10 @@ async function resolveRepoRoots(lookupRepoRoot, cwds) {
|
|
|
5880
6103
|
var GRAPH_STATE_TTL_MS = 60 * 1e3;
|
|
5881
6104
|
function createApi({ root, readLanguage: readLanguage2, changes, now, home: home2, usage, memory, warn: warn2 }) {
|
|
5882
6105
|
const api = new Hono2();
|
|
6106
|
+
const revisions = createRevisions();
|
|
5883
6107
|
let snapshot = null;
|
|
5884
6108
|
const backlog = () => {
|
|
5885
|
-
snapshot ??= loadSnapshot(root).catch((error) => {
|
|
6109
|
+
snapshot ??= loadSnapshot(root, revisions.current()).catch((error) => {
|
|
5886
6110
|
snapshot = null;
|
|
5887
6111
|
throw error;
|
|
5888
6112
|
});
|
|
@@ -5895,19 +6119,29 @@ function createApi({ root, readLanguage: readLanguage2, changes, now, home: home
|
|
|
5895
6119
|
stats.forget();
|
|
5896
6120
|
graphStates.clear();
|
|
5897
6121
|
};
|
|
5898
|
-
|
|
6122
|
+
const recordOwnWrites = async (writes) => {
|
|
6123
|
+
if (writes.length > 0) await revisions.recordOwnWrites(writes);
|
|
6124
|
+
forgetBacklog();
|
|
6125
|
+
};
|
|
6126
|
+
const streams = /* @__PURE__ */ new Set();
|
|
6127
|
+
changes.subscribe(async (paths) => {
|
|
6128
|
+
if (await revisions.settle(paths) === "foreign") forgetBacklog();
|
|
6129
|
+
else stats.forget();
|
|
6130
|
+
const revision = revisions.current();
|
|
6131
|
+
for (const send of streams) send(revision);
|
|
6132
|
+
});
|
|
5899
6133
|
api.get("/projects", async (c) => {
|
|
5900
|
-
const { projects, tasks } = await backlog();
|
|
6134
|
+
const { projects, tasks, revision } = await backlog();
|
|
5901
6135
|
const withGraph = async (project) => {
|
|
5902
6136
|
const codeGraph2 = await graphStates.get(project.id, async () => (await projectGraphHealth(project, tasks, home2)).state);
|
|
5903
6137
|
return { ...project, codeGraph: codeGraph2 };
|
|
5904
6138
|
};
|
|
5905
|
-
return c.json(await Promise.all(projects.map(withGraph)));
|
|
6139
|
+
return c.json({ projects: await Promise.all(projects.map(withGraph)), revision });
|
|
5906
6140
|
});
|
|
5907
6141
|
api.get("/tasks", async (c) => {
|
|
5908
|
-
const [{ tasks, errors }, language] = await Promise.all([backlog(), readLanguage2()]);
|
|
6142
|
+
const [{ tasks, errors, revision }, language] = await Promise.all([backlog(), readLanguage2()]);
|
|
5909
6143
|
const messages = coreMessages(language);
|
|
5910
|
-
return c.json({ tasks, errors: errors.map(({ problems: problems3, ...error }) => ({ ...error, message: messages.problems(problems3) })) });
|
|
6144
|
+
return c.json({ tasks, errors: errors.map(({ problems: problems3, ...error }) => ({ ...error, message: messages.problems(problems3) })), revision });
|
|
5911
6145
|
});
|
|
5912
6146
|
api.route("/", stats.routes);
|
|
5913
6147
|
api.get("/settings", async (c) => c.json({ language: await readLanguage2() }));
|
|
@@ -5923,13 +6157,26 @@ function createApi({ root, readLanguage: readLanguage2, changes, now, home: home
|
|
|
5923
6157
|
const id = c.req.param("id");
|
|
5924
6158
|
const { index } = await backlog();
|
|
5925
6159
|
const result = await updateTaskInIndex(index, { id, changes: body.data.changes, expectedVersion: body.data.version, now: now(), via: "web" });
|
|
5926
|
-
|
|
6160
|
+
await recordOwnWrites(result.ok ? [result.task] : []);
|
|
5927
6161
|
if (result.ok) return c.json(result.task);
|
|
5928
6162
|
const messages = serverMessages(body.language);
|
|
5929
6163
|
if (result.reason === "not-found") return c.json({ errors: [messages.taskNotFound(id)] }, 404);
|
|
5930
6164
|
if (result.reason === "conflict") return c.json({ errors: [messages.taskChangedOnDisk], current: result.current }, 409);
|
|
5931
6165
|
return invalidResponse(c, result, coreMessages(body.language));
|
|
5932
6166
|
});
|
|
6167
|
+
api.post("/tasks/batch", async (c) => {
|
|
6168
|
+
const body = await readBody(c, batchRequestSchema, readLanguage2);
|
|
6169
|
+
if (!body.ok) return body.response;
|
|
6170
|
+
const { index } = await backlog();
|
|
6171
|
+
const outcomes = await applyBatch(index, { ...body.data, now: now() }).catch((error) => {
|
|
6172
|
+
forgetBacklog();
|
|
6173
|
+
throw error;
|
|
6174
|
+
});
|
|
6175
|
+
await recordOwnWrites(outcomes.flatMap((outcome) => outcome.outcome === "done" ? [outcome.task] : []));
|
|
6176
|
+
const messages = serverMessages(body.language);
|
|
6177
|
+
const core = coreMessages(body.language);
|
|
6178
|
+
return c.json({ results: outcomes.map((outcome) => viewOf(outcome, messages, core)) });
|
|
6179
|
+
});
|
|
5933
6180
|
api.patch("/projects/:id", async (c) => {
|
|
5934
6181
|
const body = await readBody(c, projectActiveSchema, readLanguage2);
|
|
5935
6182
|
if (!body.ok) return body.response;
|
|
@@ -5953,21 +6200,27 @@ function createApi({ root, readLanguage: readLanguage2, changes, now, home: home
|
|
|
5953
6200
|
api.get(
|
|
5954
6201
|
"/events",
|
|
5955
6202
|
(c) => streamSSE(c, async (stream) => {
|
|
5956
|
-
const
|
|
6203
|
+
const send = (revision) => void stream.writeSSE({ event: "change", data: JSON.stringify(revision) });
|
|
6204
|
+
streams.add(send);
|
|
5957
6205
|
const clientGone = new Promise((resolve7) => stream.onAbort(resolve7));
|
|
5958
6206
|
await Promise.race([clientGone, changes.closed]);
|
|
5959
|
-
|
|
6207
|
+
streams.delete(send);
|
|
5960
6208
|
})
|
|
5961
6209
|
);
|
|
5962
6210
|
return api;
|
|
5963
6211
|
}
|
|
5964
|
-
async function loadSnapshot(root) {
|
|
6212
|
+
async function loadSnapshot(root, revision) {
|
|
5965
6213
|
const loaded = await loadBacklog(root);
|
|
5966
|
-
return { ...loaded, index: buildIndex(loaded.tasks) };
|
|
6214
|
+
return { ...loaded, index: buildIndex(loaded.tasks), revision };
|
|
5967
6215
|
}
|
|
5968
6216
|
function invalidResponse(c, result, messages) {
|
|
5969
6217
|
return c.json({ errors: result.errors.map(messages.problem) }, 422);
|
|
5970
6218
|
}
|
|
6219
|
+
function viewOf(outcome, messages, core) {
|
|
6220
|
+
if (outcome.outcome === "done") return { id: outcome.id, outcome: "done", version: outcome.task.version, previous: outcome.previous };
|
|
6221
|
+
const message = outcome.reason === "invalid" && outcome.problems ? core.problems(outcome.problems) : messages.batchSkipped[outcome.reason](outcome.id);
|
|
6222
|
+
return { id: outcome.id, outcome: "skipped", reason: outcome.reason, message };
|
|
6223
|
+
}
|
|
5971
6224
|
async function readBody(c, schema, readLanguage2) {
|
|
5972
6225
|
const [body, language] = await Promise.all([readJson(c), readLanguage2()]);
|
|
5973
6226
|
if (body.ok === false) return { ok: false, response: c.json({ errors: [serverMessages(language).bodyNotParsed] }, 400) };
|
|
@@ -6061,11 +6314,17 @@ function isHiddenPath(root, path) {
|
|
|
6061
6314
|
function createChangeFeed({ root, debounceMs, messages, warn: warn2 }) {
|
|
6062
6315
|
const listeners = /* @__PURE__ */ new Set();
|
|
6063
6316
|
const { promise: closed, resolve: markClosed } = Promise.withResolvers();
|
|
6317
|
+
const changedPaths = /* @__PURE__ */ new Set();
|
|
6064
6318
|
const debouncer = createDebouncer(debounceMs, () => {
|
|
6065
|
-
|
|
6319
|
+
const paths = [...changedPaths];
|
|
6320
|
+
changedPaths.clear();
|
|
6321
|
+
for (const listener of listeners) void listener(paths);
|
|
6066
6322
|
});
|
|
6067
6323
|
const watcher = watch(root, { ignoreInitial: true, ignored: (path) => isHiddenPath(root, path) });
|
|
6068
|
-
watcher.on("all", () =>
|
|
6324
|
+
watcher.on("all", (_event, path) => {
|
|
6325
|
+
changedPaths.add(path);
|
|
6326
|
+
debouncer.schedule();
|
|
6327
|
+
});
|
|
6069
6328
|
watcher.on("error", (error) => {
|
|
6070
6329
|
void messages().then((texts) => warn2(texts.watcherError(root, errorText(error))));
|
|
6071
6330
|
});
|
|
@@ -6130,8 +6389,9 @@ function startSweeper({ sweep, intervalMs, log: log2, warn: warn2, messages }) {
|
|
|
6130
6389
|
return current;
|
|
6131
6390
|
};
|
|
6132
6391
|
}
|
|
6133
|
-
function logReport({ closedEpics, blockingFiles, deleted, conflicts, invalid: invalid2 }, messages, log2) {
|
|
6392
|
+
function logReport({ closedEpics, reopenedEpics, blockingFiles, deleted, conflicts, invalid: invalid2 }, messages, log2) {
|
|
6134
6393
|
if (closedEpics.length > 0) log2(messages.closedEpics(closedEpics.join(", ")));
|
|
6394
|
+
if (reopenedEpics.length > 0) log2(messages.reopenedEpics(reopenedEpics.join(", ")));
|
|
6135
6395
|
if (blockingFiles.length > 0) log2(messages.epicsBlockedByFiles(blockingFiles.join(", ")));
|
|
6136
6396
|
if (deleted.length > 0) log2(messages.deletedClosedTasks(deleted.join(", ")));
|
|
6137
6397
|
if (conflicts.length > 0) log2(messages.conflictedDuringSweep(conflicts.join(", ")));
|
|
@@ -6142,8 +6402,8 @@ function logReport({ closedEpics, blockingFiles, deleted, conflicts, invalid: in
|
|
|
6142
6402
|
|
|
6143
6403
|
// src/core/usage/transcripts.ts
|
|
6144
6404
|
import { createHash as createHash4 } from "node:crypto";
|
|
6145
|
-
import { open as open2, stat as
|
|
6146
|
-
import { basename as
|
|
6405
|
+
import { open as open2, stat as stat5 } from "node:fs/promises";
|
|
6406
|
+
import { basename as basename10, join as join24 } from "node:path";
|
|
6147
6407
|
|
|
6148
6408
|
// src/core/stats/cost/attribute.ts
|
|
6149
6409
|
import { z as z16 } from "zod";
|
|
@@ -6531,7 +6791,7 @@ async function listTranscripts(claudeProjectsDir2) {
|
|
|
6531
6791
|
}
|
|
6532
6792
|
async function listedFile(path) {
|
|
6533
6793
|
try {
|
|
6534
|
-
const { size, mtimeMs } = await
|
|
6794
|
+
const { size, mtimeMs } = await stat5(path);
|
|
6535
6795
|
return [{ path, size, mtimeMs }];
|
|
6536
6796
|
} catch {
|
|
6537
6797
|
return [];
|
|
@@ -6560,9 +6820,9 @@ async function scanTranscripts({ files, cache, byteBudget, now }) {
|
|
|
6560
6820
|
}
|
|
6561
6821
|
function deletedStillReported(cache, listedFiles, now) {
|
|
6562
6822
|
const reportStart = now.getTime() - COST_REPORT_DAYS * DAY_MS;
|
|
6563
|
-
const listedSessions = new Set(Object.keys(listedFiles).map((path) =>
|
|
6823
|
+
const listedSessions = new Set(Object.keys(listedFiles).map((path) => basename10(path)));
|
|
6564
6824
|
return Object.fromEntries(
|
|
6565
|
-
Object.entries(cache.files).filter(([path, entry]) => !listedSessions.has(
|
|
6825
|
+
Object.entries(cache.files).filter(([path, entry]) => !listedSessions.has(basename10(path)) && entry.buckets.some((bucket) => Date.parse(bucket.slot) >= reportStart))
|
|
6566
6826
|
);
|
|
6567
6827
|
}
|
|
6568
6828
|
async function scanChunk(file, start, chunkSize, { longestReadableLine, now }) {
|
|
@@ -7173,7 +7433,7 @@ function reportFailure({ failed, code, output }, io) {
|
|
|
7173
7433
|
import { join as join29 } from "node:path";
|
|
7174
7434
|
|
|
7175
7435
|
// src/cli/stop-hook.ts
|
|
7176
|
-
import { mkdir as mkdir10, readFile as readFile5, readlink as readlink2, realpath as realpath2, stat as
|
|
7436
|
+
import { mkdir as mkdir10, readFile as readFile5, readlink as readlink2, realpath as realpath2, stat as stat6 } from "node:fs/promises";
|
|
7177
7437
|
import { dirname as dirname11, resolve as resolve4 } from "node:path";
|
|
7178
7438
|
import { z as z18 } from "zod";
|
|
7179
7439
|
var POSIX_COMMAND = "command -v backlog >/dev/null && backlog hook stop || true";
|
|
@@ -7201,7 +7461,7 @@ async function addStopHook(settingsPath, platform) {
|
|
|
7201
7461
|
stopGroups.push({ hooks: [stopHookFor(platform)] });
|
|
7202
7462
|
await mkdir10(dirname11(settingsPath), { recursive: true });
|
|
7203
7463
|
const target = await writeTargetOf(settingsPath);
|
|
7204
|
-
const mode = await
|
|
7464
|
+
const mode = await stat6(target).then(({ mode: mode2 }) => mode2 & 511, () => void 0);
|
|
7205
7465
|
await writeFileAtomic(target, `${JSON.stringify(settings, null, 2)}
|
|
7206
7466
|
`, mode);
|
|
7207
7467
|
return "added";
|