feat: queue reorder — reorder milestone execution via /gsd queue (#460)
This commit is contained in:
parent
1d1b91f428
commit
0820b1196d
8 changed files with 1244 additions and 26 deletions
|
|
@ -93,6 +93,7 @@ import {
|
|||
getAutoWorktreeOriginalBase,
|
||||
mergeMilestoneToMain,
|
||||
} from "./auto-worktree.js";
|
||||
import { pruneQueueOrder } from "./queue-order.js";
|
||||
import { showNextAction } from "../shared/next-action-ui.js";
|
||||
import {
|
||||
resolveExpectedArtifactPath,
|
||||
|
|
@ -1251,6 +1252,11 @@ async function dispatchNextUnit(
|
|||
unitLifetimeDispatches.clear();
|
||||
// Capture integration branch for the new milestone and update git service
|
||||
captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
|
||||
// Prune completed milestone from queue order file
|
||||
const pendingIds = state.registry
|
||||
.filter(m => m.status !== "complete")
|
||||
.map(m => m.id);
|
||||
pruneQueueOrder(basePath, pendingIds);
|
||||
}
|
||||
if (mid) {
|
||||
currentMilestoneId = mid;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { readFileSync } from "node:fs";
|
|||
import { readdirSync } from "node:fs";
|
||||
import { resolveMilestoneFile, milestonesDir } from "./paths.js";
|
||||
import { parseRoadmapSlices } from "./roadmap-slices.js";
|
||||
import { extractMilestoneSeq, milestoneIdSort } from "./guided-flow.js";
|
||||
import { findMilestoneIds } from "./guided-flow.js";
|
||||
|
||||
const SLICE_DISPATCH_TYPES = new Set([
|
||||
"research-slice",
|
||||
|
|
@ -43,24 +43,12 @@ export function getPriorSliceCompletionBlocker(base: string, _mainBranch: string
|
|||
const [targetMid, targetSid] = unitId.split("/");
|
||||
if (!targetMid || !targetSid) return null;
|
||||
|
||||
const targetSeq = extractMilestoneSeq(targetMid);
|
||||
if (targetSeq === 0) return null;
|
||||
|
||||
// Scan actual milestone directories instead of iterating by number
|
||||
let milestoneIds: string[];
|
||||
try {
|
||||
milestoneIds = readdirSync(milestonesDir(base), { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => {
|
||||
const match = d.name.match(/^(M\d+(?:-[a-z0-9]{6})?)/);
|
||||
return match ? match[1] : null;
|
||||
})
|
||||
.filter((id): id is string => id !== null)
|
||||
.sort(milestoneIdSort)
|
||||
.filter(id => extractMilestoneSeq(id) <= targetSeq);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Use findMilestoneIds to respect custom queue order.
|
||||
// Only check milestones that come BEFORE the target in queue order.
|
||||
const allIds = findMilestoneIds(base);
|
||||
const targetIdx = allIds.indexOf(targetMid);
|
||||
if (targetIdx < 0) return null;
|
||||
const milestoneIds = allIds.slice(0, targetIdx + 1);
|
||||
|
||||
for (const mid of milestoneIds) {
|
||||
// Read from disk (working tree) — always has the latest state
|
||||
|
|
|
|||
|
|
@ -22,11 +22,12 @@ import {
|
|||
} from "./paths.js";
|
||||
import { randomInt } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs";
|
||||
import { nativeIsRepo, nativeInit, nativeAddPaths, nativeCommit } from "./native-git-bridge.js";
|
||||
import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js";
|
||||
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
||||
import { showConfirm } from "../shared/confirm-ui.js";
|
||||
import { loadQueueOrder, sortByQueueOrder, saveQueueOrder } from "./queue-order.js";
|
||||
|
||||
// ─── Auto-start after discuss ─────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -203,13 +204,16 @@ function buildDiscussPrompt(nextId: string, preamble: string, _basePath: string)
|
|||
export function findMilestoneIds(basePath: string): string[] {
|
||||
const dir = milestonesDir(basePath);
|
||||
try {
|
||||
return readdirSync(dir, { withFileTypes: true })
|
||||
const ids = readdirSync(dir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => {
|
||||
const match = d.name.match(/^(M\d+(?:-[a-z0-9]{6})?)/);
|
||||
return match ? match[1] : d.name;
|
||||
})
|
||||
.sort(milestoneIdSort);
|
||||
});
|
||||
|
||||
// Apply custom queue order if available, else fall back to numeric sort
|
||||
const customOrder = loadQueueOrder(basePath);
|
||||
return sortByQueueOrder(ids, customOrder);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -305,6 +309,235 @@ export async function showQueue(
|
|||
return;
|
||||
}
|
||||
|
||||
// ── Count pending milestones ────────────────────────────────────────
|
||||
const pendingMilestones = state.registry.filter(
|
||||
m => m.status === "pending" || m.status === "active",
|
||||
);
|
||||
const completeCount = state.registry.filter(m => m.status === "complete").length;
|
||||
|
||||
// ── If multiple pending milestones, show queue management hub ──────
|
||||
if (pendingMilestones.length > 1) {
|
||||
const choice = await showNextAction(ctx, {
|
||||
title: "GSD — Queue Management",
|
||||
summary: [
|
||||
`${completeCount} complete, ${pendingMilestones.length} pending.`,
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: "reorder",
|
||||
label: "Reorder queue",
|
||||
description: `Change execution order of ${pendingMilestones.length} pending milestones.`,
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "add",
|
||||
label: "Add new work",
|
||||
description: "Queue new milestones via discussion.",
|
||||
},
|
||||
],
|
||||
notYetMessage: "Run /gsd queue when ready.",
|
||||
});
|
||||
|
||||
if (choice === "reorder") {
|
||||
await handleQueueReorder(ctx, basePath, state);
|
||||
return;
|
||||
}
|
||||
if (choice === "not_yet") return;
|
||||
// "add" falls through to existing queue-add logic below
|
||||
}
|
||||
|
||||
// ── Existing queue-add flow ─────────────────────────────────────────
|
||||
await showQueueAdd(ctx, pi, basePath, state);
|
||||
}
|
||||
|
||||
async function handleQueueReorder(
|
||||
ctx: ExtensionCommandContext,
|
||||
basePath: string,
|
||||
state: Awaited<ReturnType<typeof deriveState>>,
|
||||
): Promise<void> {
|
||||
const { showQueueReorder: showReorderUI } = await import("./queue-reorder-ui.js");
|
||||
const { invalidateStateCache } = await import("./state.js");
|
||||
|
||||
const completed = state.registry
|
||||
.filter(m => m.status === "complete")
|
||||
.map(m => ({ id: m.id, title: m.title, dependsOn: m.dependsOn }));
|
||||
|
||||
const pending = state.registry
|
||||
.filter(m => m.status !== "complete")
|
||||
.map(m => ({ id: m.id, title: m.title, dependsOn: m.dependsOn }));
|
||||
|
||||
const result = await showReorderUI(ctx, completed, pending);
|
||||
if (!result) {
|
||||
ctx.ui.notify("Queue reorder cancelled.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the new order
|
||||
saveQueueOrder(basePath, result.order);
|
||||
invalidateStateCache();
|
||||
|
||||
// Remove conflicting depends_on entries from CONTEXT.md files
|
||||
if (result.depsToRemove.length > 0) {
|
||||
removeDependsOnFromContextFiles(basePath, result.depsToRemove);
|
||||
}
|
||||
|
||||
// Sync PROJECT.md milestone sequence table
|
||||
syncProjectMdSequence(basePath, state.registry, result.order);
|
||||
|
||||
// Commit the change
|
||||
const filesToAdd = [".gsd/QUEUE-ORDER.json", ".gsd/PROJECT.md"];
|
||||
for (const r of result.depsToRemove) {
|
||||
filesToAdd.push(`.gsd/milestones/${r.milestone}/${r.milestone}-CONTEXT.md`);
|
||||
}
|
||||
try {
|
||||
nativeAddPaths(basePath, filesToAdd);
|
||||
nativeCommit(basePath, "docs: reorder queue");
|
||||
} catch {
|
||||
// Commit may fail if nothing changed or git hooks block — non-fatal
|
||||
}
|
||||
|
||||
const depInfo = result.depsToRemove.length > 0
|
||||
? ` (removed ${result.depsToRemove.length} depends_on)`
|
||||
: "";
|
||||
ctx.ui.notify(`Queue reordered: ${result.order.join(" → ")}${depInfo}`, "info");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove specific depends_on entries from milestone CONTEXT.md frontmatter.
|
||||
*/
|
||||
function removeDependsOnFromContextFiles(
|
||||
basePath: string,
|
||||
depsToRemove: Array<{ milestone: string; dep: string }>,
|
||||
): void {
|
||||
// Group removals by milestone
|
||||
const byMilestone = new Map<string, string[]>();
|
||||
for (const { milestone, dep } of depsToRemove) {
|
||||
const existing = byMilestone.get(milestone) ?? [];
|
||||
existing.push(dep);
|
||||
byMilestone.set(milestone, existing);
|
||||
}
|
||||
|
||||
for (const [mid, depsToRemoveForMid] of byMilestone) {
|
||||
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
|
||||
if (!contextFile || !existsSync(contextFile)) continue;
|
||||
|
||||
const content = readFileSync(contextFile, "utf-8");
|
||||
|
||||
// Parse frontmatter
|
||||
const trimmed = content.trimStart();
|
||||
if (!trimmed.startsWith("---")) continue;
|
||||
const afterFirst = trimmed.indexOf("\n");
|
||||
if (afterFirst === -1) continue;
|
||||
const rest = trimmed.slice(afterFirst + 1);
|
||||
const endIdx = rest.indexOf("\n---");
|
||||
if (endIdx === -1) continue;
|
||||
|
||||
const fmText = rest.slice(0, endIdx);
|
||||
const body = rest.slice(endIdx + 4);
|
||||
|
||||
// Parse depends_on line(s)
|
||||
const fmLines = fmText.split("\n");
|
||||
const removeSet = new Set(depsToRemoveForMid.map(d => d.toUpperCase()));
|
||||
|
||||
// Handle inline format: depends_on: [M009, M010]
|
||||
const inlineMatch = fmLines.findIndex(l => /^depends_on:\s*\[/.test(l));
|
||||
if (inlineMatch >= 0) {
|
||||
const line = fmLines[inlineMatch];
|
||||
const inner = line.match(/\[([^\]]*)\]/);
|
||||
if (inner) {
|
||||
const remaining = inner[1]
|
||||
.split(",")
|
||||
.map(s => s.trim())
|
||||
.filter(s => s && !removeSet.has(s.toUpperCase()));
|
||||
if (remaining.length === 0) {
|
||||
fmLines.splice(inlineMatch, 1);
|
||||
} else {
|
||||
fmLines[inlineMatch] = `depends_on: [${remaining.join(", ")}]`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle multi-line format
|
||||
const keyIdx = fmLines.findIndex(l => /^depends_on:\s*$/.test(l));
|
||||
if (keyIdx >= 0) {
|
||||
let end = keyIdx + 1;
|
||||
while (end < fmLines.length && /^\s+-\s/.test(fmLines[end])) {
|
||||
const val = fmLines[end].replace(/^\s+-\s*/, "").trim().toUpperCase();
|
||||
if (removeSet.has(val)) {
|
||||
fmLines.splice(end, 1);
|
||||
} else {
|
||||
end++;
|
||||
}
|
||||
}
|
||||
if (end === keyIdx + 1 || (end <= fmLines.length && !/^\s+-\s/.test(fmLines[keyIdx + 1] ?? ""))) {
|
||||
fmLines.splice(keyIdx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild file
|
||||
const newFm = fmLines.filter(l => l !== undefined).join("\n");
|
||||
const newContent = newFm.trim()
|
||||
? `---\n${newFm}\n---${body}`
|
||||
: body.replace(/^\n+/, "");
|
||||
writeFileSync(contextFile, newContent, "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
function syncProjectMdSequence(
|
||||
basePath: string,
|
||||
registry: Array<{ id: string; title: string; status: string }>,
|
||||
newOrder: string[],
|
||||
): void {
|
||||
const projectPath = resolveGsdRootFile(basePath, "PROJECT");
|
||||
if (!projectPath || !existsSync(projectPath)) return;
|
||||
|
||||
const content = readFileSync(projectPath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
const headerIdx = lines.findIndex(l => /^##\s+Milestone Sequence/.test(l));
|
||||
if (headerIdx < 0) return;
|
||||
|
||||
let tableStart = headerIdx + 1;
|
||||
while (tableStart < lines.length && !lines[tableStart].startsWith("|")) tableStart++;
|
||||
if (tableStart >= lines.length) return;
|
||||
|
||||
let tableEnd = tableStart + 1;
|
||||
while (tableEnd < lines.length && lines[tableEnd].startsWith("|")) tableEnd++;
|
||||
|
||||
const registryMap = new Map(registry.map(m => [m.id, m]));
|
||||
const completedSet = new Set(registry.filter(m => m.status === "complete").map(m => m.id));
|
||||
|
||||
const newRows: string[] = [];
|
||||
for (const m of registry) {
|
||||
if (m.status === "complete") {
|
||||
newRows.push(`| ${m.id} | ${m.title} | ✅ Complete |`);
|
||||
}
|
||||
}
|
||||
let isFirst = true;
|
||||
for (const id of newOrder) {
|
||||
if (completedSet.has(id)) continue;
|
||||
const m = registryMap.get(id);
|
||||
if (!m) continue;
|
||||
const status = isFirst ? "📋 Next" : "📋 Queued";
|
||||
newRows.push(`| ${m.id} | ${m.title} | ${status} |`);
|
||||
isFirst = false;
|
||||
}
|
||||
|
||||
const headerLine = lines[tableStart];
|
||||
const separatorLine = lines[tableStart + 1];
|
||||
const newTable = [headerLine, separatorLine, ...newRows];
|
||||
lines.splice(tableStart, tableEnd - tableStart, ...newTable);
|
||||
writeFileSync(projectPath, lines.join("\n"), "utf-8");
|
||||
}
|
||||
|
||||
async function showQueueAdd(
|
||||
ctx: ExtensionCommandContext,
|
||||
pi: ExtensionAPI,
|
||||
basePath: string,
|
||||
state: Awaited<ReturnType<typeof deriveState>>,
|
||||
): Promise<void> {
|
||||
const milestoneIds = findMilestoneIds(basePath);
|
||||
|
||||
// ── Build existing milestones context for the prompt ────────────────
|
||||
const existingContext = await buildExistingMilestonesContext(basePath, milestoneIds, state);
|
||||
|
||||
|
|
|
|||
231
src/resources/extensions/gsd/queue-order.ts
Normal file
231
src/resources/extensions/gsd/queue-order.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* GSD Queue Order — Custom milestone execution ordering.
|
||||
*
|
||||
* Stores an explicit execution order in `.gsd/QUEUE-ORDER.json`.
|
||||
* When present, `findMilestoneIds()` uses this order instead of
|
||||
* the default numeric sort (milestoneIdSort).
|
||||
*
|
||||
* The file is committed to git (not gitignored) so ordering
|
||||
* survives branch switches and is shared across sessions.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { gsdRoot } from "./paths.js";
|
||||
import { milestoneIdSort } from "./guided-flow.js";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface QueueOrderFile {
|
||||
order: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DependencyViolation {
|
||||
milestone: string;
|
||||
dependsOn: string;
|
||||
type: 'would_block' | 'circular' | 'missing_dep';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DependencyRedundancy {
|
||||
milestone: string;
|
||||
dependsOn: string;
|
||||
}
|
||||
|
||||
export interface DependencyValidation {
|
||||
valid: boolean;
|
||||
violations: DependencyViolation[];
|
||||
redundant: DependencyRedundancy[];
|
||||
}
|
||||
|
||||
// ─── Path ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function queueOrderPath(basePath: string): string {
|
||||
return join(gsdRoot(basePath), "QUEUE-ORDER.json");
|
||||
}
|
||||
|
||||
// ─── Read / Write ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load the custom queue order. Returns null if no file exists or if
|
||||
* the file is corrupt/unreadable.
|
||||
*/
|
||||
export function loadQueueOrder(basePath: string): string[] | null {
|
||||
const p = queueOrderPath(basePath);
|
||||
if (!existsSync(p)) return null;
|
||||
try {
|
||||
const data: QueueOrderFile = JSON.parse(readFileSync(p, "utf-8"));
|
||||
if (!Array.isArray(data.order)) return null;
|
||||
return data.order;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a custom queue order to disk.
|
||||
*/
|
||||
export function saveQueueOrder(basePath: string, order: string[]): void {
|
||||
const data: QueueOrderFile = {
|
||||
order,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
writeFileSync(queueOrderPath(basePath), JSON.stringify(data, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
|
||||
// ─── Sorting ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sort milestone IDs respecting a custom order.
|
||||
*
|
||||
* - IDs present in `customOrder` appear in that exact sequence.
|
||||
* - IDs on disk but NOT in `customOrder` are appended at the end,
|
||||
* sorted by the default `milestoneIdSort` (numeric).
|
||||
* - IDs in `customOrder` but NOT on disk are silently skipped.
|
||||
* - When `customOrder` is null, falls back to `milestoneIdSort`.
|
||||
*/
|
||||
export function sortByQueueOrder(ids: string[], customOrder: string[] | null): string[] {
|
||||
if (!customOrder) return [...ids].sort(milestoneIdSort);
|
||||
|
||||
const idSet = new Set(ids);
|
||||
const ordered: string[] = [];
|
||||
|
||||
// First: IDs from customOrder that exist on disk
|
||||
for (const id of customOrder) {
|
||||
if (idSet.has(id)) {
|
||||
ordered.push(id);
|
||||
idSet.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Then: remaining IDs not in customOrder, in default sort order
|
||||
const remaining = [...idSet].sort(milestoneIdSort);
|
||||
return [...ordered, ...remaining];
|
||||
}
|
||||
|
||||
// ─── Pruning ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Remove IDs from the queue order file that are no longer valid
|
||||
* (completed or deleted milestones). No-op if file doesn't exist.
|
||||
*/
|
||||
export function pruneQueueOrder(basePath: string, validIds: string[]): void {
|
||||
const order = loadQueueOrder(basePath);
|
||||
if (!order) return;
|
||||
|
||||
const validSet = new Set(validIds);
|
||||
const pruned = order.filter(id => validSet.has(id));
|
||||
|
||||
if (pruned.length !== order.length) {
|
||||
saveQueueOrder(basePath, pruned);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Validation ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate a proposed queue order against dependency constraints.
|
||||
*
|
||||
* Checks:
|
||||
* - would_block: A milestone is placed before one of its dependencies
|
||||
* - circular: Two or more milestones form a dependency cycle
|
||||
* - missing_dep: A milestone depends on an ID that doesn't exist
|
||||
* - redundant: A dependency is satisfied by queue position (dep comes earlier)
|
||||
*/
|
||||
export function validateQueueOrder(
|
||||
order: string[],
|
||||
depsMap: Map<string, string[]>,
|
||||
completedIds: Set<string>,
|
||||
): DependencyValidation {
|
||||
const violations: DependencyViolation[] = [];
|
||||
const redundant: DependencyRedundancy[] = [];
|
||||
|
||||
const positionMap = new Map<string, number>();
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
positionMap.set(order[i], i);
|
||||
}
|
||||
|
||||
const allKnownIds = new Set([...order, ...completedIds]);
|
||||
|
||||
for (const [mid, deps] of depsMap) {
|
||||
const midPos = positionMap.get(mid);
|
||||
if (midPos === undefined) continue; // not in pending order
|
||||
|
||||
for (const dep of deps) {
|
||||
// Dep already completed — always satisfied
|
||||
if (completedIds.has(dep)) continue;
|
||||
|
||||
// Dep doesn't exist anywhere
|
||||
if (!allKnownIds.has(dep)) {
|
||||
violations.push({
|
||||
milestone: mid,
|
||||
dependsOn: dep,
|
||||
type: 'missing_dep',
|
||||
message: `${mid} depends on ${dep}, but ${dep} does not exist.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const depPos = positionMap.get(dep);
|
||||
if (depPos === undefined) continue; // dep not in pending order (edge case)
|
||||
|
||||
if (depPos > midPos) {
|
||||
// Dep comes AFTER this milestone in the order — violation
|
||||
violations.push({
|
||||
milestone: mid,
|
||||
dependsOn: dep,
|
||||
type: 'would_block',
|
||||
message: `${mid} cannot run before ${dep} — ${mid} depends_on: [${dep}].`,
|
||||
});
|
||||
} else {
|
||||
// Dep comes before — satisfied by position, redundant
|
||||
redundant.push({ milestone: mid, dependsOn: dep });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for circular dependencies
|
||||
const visited = new Set<string>();
|
||||
const inStack = new Set<string>();
|
||||
|
||||
function hasCycle(node: string, path: string[]): string[] | null {
|
||||
if (inStack.has(node)) return [...path, node];
|
||||
if (visited.has(node)) return null;
|
||||
|
||||
visited.add(node);
|
||||
inStack.add(node);
|
||||
|
||||
const deps = depsMap.get(node) ?? [];
|
||||
for (const dep of deps) {
|
||||
if (completedIds.has(dep)) continue;
|
||||
const cycle = hasCycle(dep, [...path, node]);
|
||||
if (cycle) return cycle;
|
||||
}
|
||||
|
||||
inStack.delete(node);
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const mid of order) {
|
||||
if (!visited.has(mid)) {
|
||||
const cycle = hasCycle(mid, []);
|
||||
if (cycle) {
|
||||
const cycleStr = cycle.join(' → ');
|
||||
violations.push({
|
||||
milestone: cycle[0],
|
||||
dependsOn: cycle[cycle.length - 2],
|
||||
type: 'circular',
|
||||
message: `Circular dependency: ${cycleStr}`,
|
||||
});
|
||||
break; // one cycle report is enough
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: violations.length === 0,
|
||||
violations,
|
||||
redundant,
|
||||
};
|
||||
}
|
||||
263
src/resources/extensions/gsd/queue-reorder-ui.ts
Normal file
263
src/resources/extensions/gsd/queue-reorder-ui.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
/**
|
||||
* GSD Queue Reorder UI
|
||||
*
|
||||
* Interactive TUI overlay for reordering pending milestones.
|
||||
* ↑/↓ navigates cursor. Space grabs/releases item for moving.
|
||||
* While grabbed, ↑/↓ swaps the item with its neighbor.
|
||||
* Enter confirms all changes. Esc cancels.
|
||||
* Conflicting depends_on entries are auto-removed on confirm.
|
||||
*/
|
||||
|
||||
import type { ExtensionContext } from "@gsd/pi-coding-agent";
|
||||
import { type Theme } from "@gsd/pi-coding-agent";
|
||||
import { Key, matchesKey, truncateToWidth, type TUI } from "@gsd/pi-tui";
|
||||
import { makeUI, GLYPH } from "../shared/ui.js";
|
||||
import { validateQueueOrder, type DependencyValidation } from "./queue-order.js";
|
||||
|
||||
export interface ReorderItem {
|
||||
id: string;
|
||||
title: string;
|
||||
dependsOn?: string[];
|
||||
}
|
||||
|
||||
export interface ReorderResult {
|
||||
order: string[];
|
||||
/** depends_on entries to remove from CONTEXT.md files */
|
||||
depsToRemove: Array<{ milestone: string; dep: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the queue reorder overlay.
|
||||
* Returns the new order + deps to remove, or null if cancelled.
|
||||
*/
|
||||
export async function showQueueReorder(
|
||||
ctx: ExtensionContext,
|
||||
completed: ReorderItem[],
|
||||
pending: ReorderItem[],
|
||||
): Promise<ReorderResult | null> {
|
||||
if (!ctx.hasUI) return null;
|
||||
if (pending.length < 2) return null;
|
||||
|
||||
return ctx.ui.custom<ReorderResult | null>((tui: TUI, theme: Theme, _kb, done) => {
|
||||
const items = [...pending];
|
||||
let cursor = 0;
|
||||
let grabbed = false;
|
||||
let cachedLines: string[] | undefined;
|
||||
let validation: DependencyValidation;
|
||||
|
||||
// Mutable deps map — tracks removals during this session
|
||||
const liveDeps = new Map<string, string[]>();
|
||||
for (const item of [...completed, ...pending]) {
|
||||
if (item.dependsOn && item.dependsOn.length > 0) {
|
||||
liveDeps.set(item.id, [...item.dependsOn]);
|
||||
}
|
||||
}
|
||||
|
||||
const removedDeps: Array<{ milestone: string; dep: string }> = [];
|
||||
const completedIds = new Set(completed.map(c => c.id));
|
||||
|
||||
function revalidate() {
|
||||
validation = validateQueueOrder(items.map(i => i.id), liveDeps, completedIds);
|
||||
}
|
||||
|
||||
revalidate();
|
||||
|
||||
function refresh() {
|
||||
cachedLines = undefined;
|
||||
tui.requestRender();
|
||||
}
|
||||
|
||||
function swapItems(fromIdx: number, toIdx: number) {
|
||||
if (toIdx < 0 || toIdx >= items.length) return;
|
||||
const [item] = items.splice(fromIdx, 1);
|
||||
items.splice(toIdx, 0, item);
|
||||
cursor = toIdx;
|
||||
revalidate();
|
||||
refresh();
|
||||
}
|
||||
|
||||
function removeDep(milestone: string, dep: string) {
|
||||
const deps = liveDeps.get(milestone);
|
||||
if (!deps) return;
|
||||
const idx = deps.indexOf(dep);
|
||||
if (idx >= 0) {
|
||||
deps.splice(idx, 1);
|
||||
if (deps.length === 0) liveDeps.delete(milestone);
|
||||
removedDeps.push({ milestone, dep });
|
||||
const item = items.find(i => i.id === milestone);
|
||||
if (item?.dependsOn) {
|
||||
item.dependsOn = item.dependsOn.filter(d => d !== dep);
|
||||
}
|
||||
revalidate();
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(data: string) {
|
||||
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
||||
done(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm — auto-resolve would_block violations
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
const wouldBlock = validation.violations.filter(v => v.type === 'would_block');
|
||||
for (const v of wouldBlock) {
|
||||
removeDep(v.milestone, v.dependsOn);
|
||||
}
|
||||
done({ order: items.map(i => i.id), depsToRemove: removedDeps });
|
||||
return;
|
||||
}
|
||||
|
||||
// Space — toggle grab mode
|
||||
if (data === " ") {
|
||||
grabbed = !grabbed;
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// ↑/↓ — move grabbed item OR navigate cursor
|
||||
if (matchesKey(data, Key.up)) {
|
||||
if (grabbed) {
|
||||
swapItems(cursor, cursor - 1);
|
||||
} else {
|
||||
cursor = Math.max(0, cursor - 1);
|
||||
refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.down)) {
|
||||
if (grabbed) {
|
||||
swapItems(cursor, cursor + 1);
|
||||
} else {
|
||||
cursor = Math.min(items.length - 1, cursor + 1);
|
||||
refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 'd' — manually remove a dep on the cursor item
|
||||
if (data === "d" || data === "D") {
|
||||
const item = items[cursor];
|
||||
const deps = liveDeps.get(item.id);
|
||||
if (deps) {
|
||||
const activeDep = deps.find(d => !completedIds.has(d));
|
||||
if (activeDep) removeDep(item.id, activeDep);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function render(width: number): string[] {
|
||||
if (cachedLines) return cachedLines;
|
||||
|
||||
const ui = makeUI(theme, width);
|
||||
const lines: string[] = [];
|
||||
const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); };
|
||||
const add = (s: string) => truncateToWidth(s, width);
|
||||
|
||||
const headerText = grabbed ? " Queue Reorder — Moving Item" : " Queue Reorder";
|
||||
push(ui.bar(), ui.blank(), ui.header(headerText), ui.blank());
|
||||
|
||||
// Completed milestones (dimmed)
|
||||
if (completed.length > 0) {
|
||||
lines.push(add(theme.fg("dim", " Completed:")));
|
||||
for (const m of completed) {
|
||||
const label = m.title && m.title !== m.id ? `${m.id} ${m.title}` : m.id;
|
||||
lines.push(add(` ${theme.fg("dim", `${GLYPH.statusDone} ${label}`)}`));
|
||||
}
|
||||
push(ui.blank());
|
||||
}
|
||||
|
||||
// Pending milestones
|
||||
const queueLabel = grabbed ? " Queue (space to release, ↑/↓ to move):" : " Queue (space to grab, ↑/↓ to navigate):";
|
||||
lines.push(add(theme.fg("text", queueLabel)));
|
||||
|
||||
const violatedPairs = new Set(
|
||||
validation.violations.filter(v => v.type === 'would_block').map(v => `${v.milestone}:${v.dependsOn}`),
|
||||
);
|
||||
const redundantPairs = new Set(
|
||||
validation.redundant.map(r => `${r.milestone}:${r.dependsOn}`),
|
||||
);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const isCursor = i === cursor;
|
||||
const num = i + 1;
|
||||
const label = item.title && item.title !== item.id ? `${item.id} ${item.title}` : item.id;
|
||||
|
||||
if (isCursor && grabbed) {
|
||||
lines.push(add(` ${theme.fg("warning", `▸▸ ${num}. ${label}`)}`));
|
||||
} else if (isCursor) {
|
||||
lines.push(add(` ${theme.fg("accent", `${GLYPH.cursor} ${num}. ${label}`)}`));
|
||||
} else {
|
||||
lines.push(add(` ${theme.fg("text", `${num}. ${label}`)}`));
|
||||
}
|
||||
|
||||
// depends_on annotations
|
||||
const deps = liveDeps.get(item.id) ?? [];
|
||||
for (const dep of deps) {
|
||||
if (completedIds.has(dep)) continue;
|
||||
const pairKey = `${item.id}:${dep}`;
|
||||
if (violatedPairs.has(pairKey)) {
|
||||
lines.push(add(` ${theme.fg("warning", `${GLYPH.statusWarning} depends_on: ${dep} — auto-removed on confirm`)}`));
|
||||
} else if (redundantPairs.has(pairKey)) {
|
||||
lines.push(add(` ${theme.fg("dim", `↳ depends_on: ${dep} (redundant)`)}`));
|
||||
} else {
|
||||
lines.push(add(` ${theme.fg("dim", `↳ depends_on: ${dep}`)}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Missing deps
|
||||
for (const v of validation.violations.filter(v => v.milestone === item.id && v.type === 'missing_dep')) {
|
||||
lines.push(add(` ${theme.fg("error", `${GLYPH.statusWarning} depends_on: ${v.dependsOn} (does not exist)`)}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Removed deps feedback
|
||||
if (removedDeps.length > 0) {
|
||||
push(ui.blank());
|
||||
for (const r of removedDeps) {
|
||||
lines.push(add(` ${theme.fg("success", `${GLYPH.statusDone} Removed: ${r.milestone} depends_on ${r.dep}`)}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Circular warning
|
||||
const circ = validation.violations.find(v => v.type === 'circular');
|
||||
if (circ) {
|
||||
push(ui.blank());
|
||||
lines.push(add(` ${theme.fg("error", `${GLYPH.statusWarning} ${circ.message}`)}`));
|
||||
}
|
||||
|
||||
push(ui.blank());
|
||||
|
||||
// Hints — context-sensitive based on grab state
|
||||
const hints: string[] = [];
|
||||
if (grabbed) {
|
||||
hints.push("↑/↓ move item", "space release");
|
||||
} else {
|
||||
hints.push("↑/↓ navigate", "space grab");
|
||||
}
|
||||
const hasDeps = liveDeps.get(items[cursor]?.id)?.some(d => !completedIds.has(d));
|
||||
if (hasDeps) hints.push("d del dep");
|
||||
|
||||
const wouldBlockCount = validation.violations.filter(v => v.type === 'would_block').length;
|
||||
if (wouldBlockCount > 0) {
|
||||
hints.push(`enter (fixes ${wouldBlockCount} dep)`);
|
||||
} else {
|
||||
hints.push("enter ok");
|
||||
}
|
||||
hints.push("esc");
|
||||
|
||||
push(ui.hints(hints), ui.bar());
|
||||
|
||||
cachedLines = lines;
|
||||
return lines;
|
||||
}
|
||||
|
||||
return { render, invalidate: () => { cachedLines = undefined; }, handleInput };
|
||||
}, {
|
||||
overlay: true,
|
||||
overlayOptions: { width: "70%", minWidth: 50, maxHeight: "80%", anchor: "center" },
|
||||
});
|
||||
}
|
||||
|
|
@ -224,9 +224,21 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
|
|||
const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT");
|
||||
if (draftFile) activeMilestoneHasDraft = true;
|
||||
}
|
||||
activeMilestone = { id: mid, title: mid };
|
||||
activeMilestoneFound = true;
|
||||
registry.push({ id: mid, title: mid, status: 'active' });
|
||||
|
||||
// Check milestone-level dependencies before promoting to active.
|
||||
// Without this, a queued milestone with depends_on in its CONTEXT
|
||||
// frontmatter would be promoted to active even when its deps are unmet
|
||||
// (the dep check only existed in the has-roadmap path previously).
|
||||
const contextContent = contextFile ? await cachedLoadFile(contextFile) : null;
|
||||
const deps = parseContextDependsOn(contextContent);
|
||||
const depsUnmet = deps.some(dep => !completeMilestoneIds.has(dep));
|
||||
if (depsUnmet) {
|
||||
registry.push({ id: mid, title: mid, status: 'pending', dependsOn: deps });
|
||||
} else {
|
||||
activeMilestone = { id: mid, title: mid };
|
||||
activeMilestoneFound = true;
|
||||
registry.push({ id: mid, title: mid, status: 'active', ...(deps.length > 0 ? { dependsOn: deps } : {}) });
|
||||
}
|
||||
} else {
|
||||
registry.push({ id: mid, title: mid, status: 'pending' });
|
||||
}
|
||||
|
|
|
|||
204
src/resources/extensions/gsd/tests/queue-order.test.ts
Normal file
204
src/resources/extensions/gsd/tests/queue-order.test.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import {
|
||||
loadQueueOrder,
|
||||
saveQueueOrder,
|
||||
sortByQueueOrder,
|
||||
pruneQueueOrder,
|
||||
validateQueueOrder,
|
||||
} from '../queue-order.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// ─── Fixture Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function createFixtureBase(): string {
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-queue-order-'));
|
||||
mkdirSync(join(base, '.gsd'), { recursive: true });
|
||||
return base;
|
||||
}
|
||||
|
||||
function cleanup(base: string): void {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// sortByQueueOrder
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== sortByQueueOrder ===');
|
||||
|
||||
// Null order → default milestoneIdSort
|
||||
{
|
||||
const result = sortByQueueOrder(['M003', 'M001', 'M002'], null);
|
||||
assertEq(result, ['M001', 'M002', 'M003'], 'null order falls back to numeric sort');
|
||||
}
|
||||
|
||||
// Custom order → exact sequence
|
||||
{
|
||||
const result = sortByQueueOrder(['M001', 'M002', 'M003'], ['M003', 'M001', 'M002']);
|
||||
assertEq(result, ['M003', 'M001', 'M002'], 'custom order produces exact sequence');
|
||||
}
|
||||
|
||||
// Custom order with new IDs → appended at end in numeric order
|
||||
{
|
||||
const result = sortByQueueOrder(['M001', 'M002', 'M003', 'M004'], ['M003', 'M001']);
|
||||
assertEq(result, ['M003', 'M001', 'M002', 'M004'], 'new IDs appended in numeric order');
|
||||
}
|
||||
|
||||
// Custom order with deleted IDs → silently skipped
|
||||
{
|
||||
const result = sortByQueueOrder(['M001', 'M003'], ['M003', 'M002', 'M001']);
|
||||
assertEq(result, ['M003', 'M001'], 'deleted IDs in order are skipped');
|
||||
}
|
||||
|
||||
// Empty custom order → all IDs in numeric order
|
||||
{
|
||||
const result = sortByQueueOrder(['M002', 'M001'], []);
|
||||
assertEq(result, ['M001', 'M002'], 'empty custom order falls back to numeric sort');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// loadQueueOrder / saveQueueOrder
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== loadQueueOrder / saveQueueOrder ===');
|
||||
|
||||
// Load returns null when file doesn't exist
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
assertEq(loadQueueOrder(base), null, 'returns null when file missing');
|
||||
cleanup(base);
|
||||
}
|
||||
|
||||
// Save then load round-trip
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
saveQueueOrder(base, ['M003', 'M001', 'M002']);
|
||||
const loaded = loadQueueOrder(base);
|
||||
assertEq(loaded, ['M003', 'M001', 'M002'], 'round-trip preserves order');
|
||||
|
||||
// Verify file contains updatedAt
|
||||
const raw = JSON.parse(readFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), 'utf-8'));
|
||||
assertTrue(typeof raw.updatedAt === 'string' && raw.updatedAt.length > 0, 'file contains updatedAt');
|
||||
|
||||
cleanup(base);
|
||||
}
|
||||
|
||||
// Load returns null on corrupt JSON
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
writeFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), 'not json');
|
||||
assertEq(loadQueueOrder(base), null, 'returns null on corrupt JSON');
|
||||
cleanup(base);
|
||||
}
|
||||
|
||||
// Load returns null when order field is not an array
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
writeFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), '{"order": "invalid"}');
|
||||
assertEq(loadQueueOrder(base), null, 'returns null when order is not array');
|
||||
cleanup(base);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// pruneQueueOrder
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== pruneQueueOrder ===');
|
||||
|
||||
// Prune removes invalid IDs
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
saveQueueOrder(base, ['M001', 'M002', 'M003']);
|
||||
pruneQueueOrder(base, ['M001', 'M003']);
|
||||
assertEq(loadQueueOrder(base), ['M001', 'M003'], 'prune removes invalid IDs');
|
||||
cleanup(base);
|
||||
}
|
||||
|
||||
// Prune no-ops when file doesn't exist
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
pruneQueueOrder(base, ['M001']); // should not throw
|
||||
assertTrue(!existsSync(join(base, '.gsd', 'QUEUE-ORDER.json')), 'prune does not create file');
|
||||
cleanup(base);
|
||||
}
|
||||
|
||||
// Prune no-ops when all IDs are valid
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
saveQueueOrder(base, ['M001', 'M002']);
|
||||
pruneQueueOrder(base, ['M001', 'M002', 'M003']);
|
||||
assertEq(loadQueueOrder(base), ['M001', 'M002'], 'prune is no-op when all valid');
|
||||
cleanup(base);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// validateQueueOrder
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== validateQueueOrder ===');
|
||||
|
||||
// Valid order with no dependencies
|
||||
{
|
||||
const depsMap = new Map<string, string[]>();
|
||||
const result = validateQueueOrder(['M001', 'M002'], depsMap, new Set());
|
||||
assertTrue(result.valid, 'valid when no dependencies');
|
||||
assertEq(result.violations.length, 0, 'no violations');
|
||||
assertEq(result.redundant.length, 0, 'no redundancies');
|
||||
}
|
||||
|
||||
// Dependency violation: M002 before M001, but M002 depends on M001
|
||||
{
|
||||
const depsMap = new Map<string, string[]>([['M002', ['M001']]]);
|
||||
const result = validateQueueOrder(['M002', 'M001'], depsMap, new Set());
|
||||
assertTrue(!result.valid, 'invalid when dep violated');
|
||||
assertEq(result.violations.length, 1, 'one violation');
|
||||
assertEq(result.violations[0].type, 'would_block', 'violation type is would_block');
|
||||
assertEq(result.violations[0].milestone, 'M002', 'violation milestone is M002');
|
||||
assertEq(result.violations[0].dependsOn, 'M001', 'violation dep is M001');
|
||||
}
|
||||
|
||||
// Redundant dependency: M002 depends on M001, M001 comes first in order
|
||||
{
|
||||
const depsMap = new Map<string, string[]>([['M002', ['M001']]]);
|
||||
const result = validateQueueOrder(['M001', 'M002'], depsMap, new Set());
|
||||
assertTrue(result.valid, 'valid when dep satisfied by position');
|
||||
assertEq(result.redundant.length, 1, 'one redundancy');
|
||||
assertEq(result.redundant[0].milestone, 'M002', 'redundant milestone is M002');
|
||||
}
|
||||
|
||||
// Completed dep is always satisfied
|
||||
{
|
||||
const depsMap = new Map<string, string[]>([['M002', ['M001']]]);
|
||||
const result = validateQueueOrder(['M002'], depsMap, new Set(['M001']));
|
||||
assertTrue(result.valid, 'valid when dep is already completed');
|
||||
assertEq(result.violations.length, 0, 'no violations for completed dep');
|
||||
}
|
||||
|
||||
// Missing dependency
|
||||
{
|
||||
const depsMap = new Map<string, string[]>([['M002', ['M099']]]);
|
||||
const result = validateQueueOrder(['M001', 'M002'], depsMap, new Set());
|
||||
assertTrue(!result.valid, 'invalid when dep does not exist');
|
||||
assertEq(result.violations[0].type, 'missing_dep', 'violation type is missing_dep');
|
||||
}
|
||||
|
||||
// Circular dependency
|
||||
{
|
||||
const depsMap = new Map<string, string[]>([
|
||||
['M001', ['M002']],
|
||||
['M002', ['M001']],
|
||||
]);
|
||||
const result = validateQueueOrder(['M001', 'M002'], depsMap, new Set());
|
||||
assertTrue(!result.valid, 'invalid on circular dependency');
|
||||
const circularViolation = result.violations.find(v => v.type === 'circular');
|
||||
assertTrue(!!circularViolation, 'circular violation detected');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
report();
|
||||
281
src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts
Normal file
281
src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
/**
|
||||
* End-to-end integration tests for the Queue Reorder feature.
|
||||
*
|
||||
* Verifies the full chain: QUEUE-ORDER.json + findMilestoneIds() + deriveState()
|
||||
* + depends_on removal from CONTEXT.md files.
|
||||
*
|
||||
* These tests simulate what happens when a user reorders milestones and confirms:
|
||||
* 1. QUEUE-ORDER.json is written with the new order
|
||||
* 2. depends_on is removed from CONTEXT.md frontmatter
|
||||
* 3. deriveState() picks the correct milestone as active
|
||||
* 4. A fresh deriveState() call (simulating new session) also works
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { deriveState, invalidateStateCache } from '../state.ts';
|
||||
import { findMilestoneIds } from '../guided-flow.ts';
|
||||
import { saveQueueOrder, loadQueueOrder } from '../queue-order.ts';
|
||||
import { parseContextDependsOn } from '../files.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// ─── Fixture Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function createFixtureBase(): string {
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-reorder-e2e-'));
|
||||
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
|
||||
return base;
|
||||
}
|
||||
|
||||
function cleanup(base: string): void {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeMilestoneDir(base: string, mid: string): void {
|
||||
mkdirSync(join(base, '.gsd', 'milestones', mid), { recursive: true });
|
||||
}
|
||||
|
||||
function writeContext(base: string, mid: string, frontmatter: string, body: string = ''): void {
|
||||
const dir = join(base, '.gsd', 'milestones', mid);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const fm = frontmatter ? `---\n${frontmatter}\n---\n\n` : '';
|
||||
writeFileSync(join(dir, `${mid}-CONTEXT.md`), `${fm}# ${mid}: Test\n\n${body}`);
|
||||
}
|
||||
|
||||
function writeCompleteMilestone(base: string, mid: string): void {
|
||||
const dir = join(base, '.gsd', 'milestones', mid);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${mid}-ROADMAP.md`), `# ${mid}: Complete
|
||||
|
||||
**Vision:** Done.
|
||||
|
||||
## Slices
|
||||
|
||||
- [x] **S01: Done** \`risk:low\` \`depends:[]\`
|
||||
> After this: Done.
|
||||
`);
|
||||
writeFileSync(join(dir, `${mid}-SUMMARY.md`), `# ${mid} Summary\n\nComplete.`);
|
||||
}
|
||||
|
||||
function readContextFile(base: string, mid: string): string {
|
||||
return readFileSync(join(base, '.gsd', 'milestones', mid, `${mid}-CONTEXT.md`), 'utf-8');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: Queue order changes milestone activation
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== E2E: queue-order changes active milestone ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// Setup: M007 complete, M008 and M009 pending (no context, no roadmap)
|
||||
writeCompleteMilestone(base, 'M007');
|
||||
writeMilestoneDir(base, 'M008');
|
||||
writeContext(base, 'M008', '', 'Multi-Session Parallel Orchestration');
|
||||
writeMilestoneDir(base, 'M009');
|
||||
writeContext(base, 'M009', '', 'Context-Budget Visibility');
|
||||
|
||||
// Without custom order: M008 comes first (numeric sort)
|
||||
invalidateStateCache();
|
||||
const stateBefore = await deriveState(base);
|
||||
assertEq(stateBefore.activeMilestone?.id, 'M008', 'before reorder: M008 is active');
|
||||
|
||||
// Save custom order: M009 before M008
|
||||
saveQueueOrder(base, ['M009', 'M008']);
|
||||
|
||||
// With custom order: M009 should be active
|
||||
invalidateStateCache();
|
||||
const stateAfter = await deriveState(base);
|
||||
assertEq(stateAfter.activeMilestone?.id, 'M009', 'after reorder: M009 is active');
|
||||
|
||||
// findMilestoneIds respects the order
|
||||
const ids = findMilestoneIds(base);
|
||||
const m008Idx = ids.indexOf('M008');
|
||||
const m009Idx = ids.indexOf('M009');
|
||||
assertTrue(m009Idx < m008Idx, 'findMilestoneIds: M009 comes before M008');
|
||||
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: Reorder + depends_on removal = correct state
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== E2E: reorder with depends_on removal ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// Setup: M007 complete, M008 depends_on M009, M009 no deps
|
||||
writeCompleteMilestone(base, 'M007');
|
||||
writeContext(base, 'M008', 'depends_on: [M009]', 'Multi-Session Parallel');
|
||||
writeContext(base, 'M009', '', 'Context-Budget Visibility');
|
||||
|
||||
// Before: M008 depends on M009, so deriveState skips M008, M009 is active
|
||||
invalidateStateCache();
|
||||
const stateBefore = await deriveState(base);
|
||||
assertEq(stateBefore.activeMilestone?.id, 'M009', 'before: M009 active (M008 dep-blocked)');
|
||||
|
||||
// Simulate reorder confirm: save order M009→M008, remove depends_on from M008
|
||||
saveQueueOrder(base, ['M009', 'M008']);
|
||||
|
||||
// Remove depends_on from M008-CONTEXT.md (simulating what handleQueueReorder does)
|
||||
const contextContent = readContextFile(base, 'M008');
|
||||
const newContent = contextContent.replace(/---\ndepends_on: \[M009\]\n---\n\n/, '');
|
||||
writeFileSync(join(base, '.gsd', 'milestones', 'M008', 'M008-CONTEXT.md'), newContent);
|
||||
|
||||
// Verify: depends_on is gone
|
||||
const updatedContent = readContextFile(base, 'M008');
|
||||
const deps = parseContextDependsOn(updatedContent);
|
||||
assertEq(deps.length, 0, 'depends_on removed from M008-CONTEXT.md');
|
||||
|
||||
// Verify: deriveState still picks M009 (it's first in queue order)
|
||||
invalidateStateCache();
|
||||
const stateAfter = await deriveState(base);
|
||||
assertEq(stateAfter.activeMilestone?.id, 'M009', 'after: M009 still active (first in queue)');
|
||||
|
||||
// Verify: M008 is now pending (not dep-blocked)
|
||||
const m008Entry = stateAfter.registry.find(m => m.id === 'M008');
|
||||
assertEq(m008Entry?.status, 'pending', 'M008 is pending (not dep-blocked)');
|
||||
assertTrue(!m008Entry?.dependsOn || m008Entry.dependsOn.length === 0, 'M008 has no dependsOn');
|
||||
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: Fresh deriveState (simulating new session) respects queue order
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== E2E: fresh session respects queue order ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeCompleteMilestone(base, 'M007');
|
||||
writeContext(base, 'M008', '', 'Parallel Orchestration');
|
||||
writeContext(base, 'M009', '', 'Budget Visibility');
|
||||
|
||||
// Save queue order
|
||||
saveQueueOrder(base, ['M009', 'M008']);
|
||||
|
||||
// Simulate fresh session — invalidate all caches
|
||||
invalidateStateCache();
|
||||
|
||||
// Derive state — should read QUEUE-ORDER.json from disk
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.activeMilestone?.id, 'M009', 'fresh session: M009 is active');
|
||||
|
||||
// Verify queue order persisted
|
||||
const order = loadQueueOrder(base);
|
||||
assertEq(order, ['M009', 'M008'], 'QUEUE-ORDER.json persisted correctly');
|
||||
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: Queue order with newly added milestones
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== E2E: new milestones appended to queue ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeCompleteMilestone(base, 'M007');
|
||||
writeContext(base, 'M008', '', 'Parallel');
|
||||
writeContext(base, 'M009', '', 'Visibility');
|
||||
|
||||
// Custom order only has M009, M008
|
||||
saveQueueOrder(base, ['M009', 'M008']);
|
||||
|
||||
// Add M010 (not in queue order)
|
||||
writeContext(base, 'M010', '', 'New feature');
|
||||
|
||||
invalidateStateCache();
|
||||
const ids = findMilestoneIds(base);
|
||||
|
||||
// M009 first, M008 second, M010 appended at end
|
||||
const m009Idx = ids.indexOf('M009');
|
||||
const m008Idx = ids.indexOf('M008');
|
||||
const m010Idx = ids.indexOf('M010');
|
||||
assertTrue(m009Idx < m008Idx, 'M009 before M008');
|
||||
assertTrue(m008Idx < m010Idx, 'M008 before M010 (new milestone appended)');
|
||||
|
||||
// M009 is still active (first non-complete in queue order)
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.activeMilestone?.id, 'M009', 'M009 still active after M010 added');
|
||||
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: No queue order file = default numeric sort (backward compat)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== E2E: backward compat without QUEUE-ORDER.json ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeCompleteMilestone(base, 'M007');
|
||||
writeContext(base, 'M008', '', 'Parallel');
|
||||
writeContext(base, 'M009', '', 'Visibility');
|
||||
|
||||
// No QUEUE-ORDER.json — default numeric sort
|
||||
invalidateStateCache();
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.activeMilestone?.id, 'M008', 'no queue order: M008 active (numeric)');
|
||||
|
||||
const ids = findMilestoneIds(base);
|
||||
assertTrue(ids.indexOf('M008') < ids.indexOf('M009'), 'default sort: M008 before M009');
|
||||
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: depends_on inline array format removal
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== E2E: depends_on inline format preserved after partial removal ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeCompleteMilestone(base, 'M007');
|
||||
// M008 depends on both M009 and M010
|
||||
writeContext(base, 'M008', 'depends_on: [M009, M010]', 'Parallel');
|
||||
writeContext(base, 'M009', '', 'Visibility');
|
||||
writeContext(base, 'M010', '', 'Other');
|
||||
|
||||
// Verify both deps are parsed
|
||||
const contentBefore = readContextFile(base, 'M008');
|
||||
const depsBefore = parseContextDependsOn(contentBefore);
|
||||
assertEq(depsBefore.length, 2, 'M008 has 2 deps before');
|
||||
|
||||
// Simulate removing only M009 dep (keep M010)
|
||||
const content = readContextFile(base, 'M008');
|
||||
const updated = content.replace('depends_on: [M009, M010]', 'depends_on: [M010]');
|
||||
writeFileSync(join(base, '.gsd', 'milestones', 'M008', 'M008-CONTEXT.md'), updated);
|
||||
|
||||
// Verify only M010 remains
|
||||
const contentAfter = readContextFile(base, 'M008');
|
||||
const depsAfter = parseContextDependsOn(contentAfter);
|
||||
assertEq(depsAfter.length, 1, 'M008 has 1 dep after removal');
|
||||
assertEq(depsAfter[0], 'M010', 'remaining dep is M010');
|
||||
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
Loading…
Add table
Reference in a new issue