Add writeJsonFileAtomic to json-persistence.ts for atomic write-to-tmp-then-rename. Migrate 5 files from raw JSON.parse(readFileSync(...)) to loadJsonFileOrNull/writeJsonFileAtomic/saveJsonFile: - session-status-io.ts: all read/write/signal functions - auto-worktree-sync.ts: readResourceVersion - auto-recovery.ts: persistCompletedKey, removePersistedKey, loadPersistedKeys - commands-logs.ts: metrics summary read - queue-order.ts: loadQueueOrder, saveQueueOrder Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
230 lines
7.6 KiB
TypeScript
230 lines
7.6 KiB
TypeScript
/**
|
|
* 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 { join } from "node:path";
|
|
import { gsdRoot } from "./paths.js";
|
|
import { milestoneIdSort } from "./milestone-ids.js";
|
|
import { loadJsonFileOrNull, saveJsonFile } from "./json-persistence.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");
|
|
}
|
|
|
|
// ─── Type Guards ─────────────────────────────────────────────────────────────
|
|
|
|
function isQueueOrderFile(data: unknown): data is QueueOrderFile {
|
|
return data !== null && typeof data === "object" && "order" in data! && Array.isArray((data as QueueOrderFile).order);
|
|
}
|
|
|
|
// ─── 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 data = loadJsonFileOrNull(queueOrderPath(basePath), isQueueOrderFile);
|
|
return data?.order ?? null;
|
|
}
|
|
|
|
/**
|
|
* Save a custom queue order to disk.
|
|
*/
|
|
export function saveQueueOrder(basePath: string, order: string[]): void {
|
|
const data: QueueOrderFile = {
|
|
order,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
saveJsonFile(queueOrderPath(basePath), data);
|
|
}
|
|
|
|
// ─── 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,
|
|
};
|
|
}
|