152 lines
4.3 KiB
JavaScript
152 lines
4.3 KiB
JavaScript
/**
|
|
* Parallel Worker Intent/Claim Registry
|
|
*
|
|
* Purpose: before editing files, parallel workers declare intent so the
|
|
* coordinator can detect conflicts without waiting for git merge failures.
|
|
* This is SF's implementation of Wit-style symbol-level coordination for
|
|
* parallel milestone workers.
|
|
*
|
|
* Consumer: parallel-orchestrator.js before dispatching overlapping milestones.
|
|
*/
|
|
import { mkdirSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { getDatabase, getDbPath, openDatabase } from "./sf-db.js";
|
|
import { UokCoordinationStore } from "./uok/coordination-store.js";
|
|
import { logWarning } from "./workflow-logger.js";
|
|
|
|
const INTENT_KEY_PREFIX = "parallel:intent:";
|
|
const INTENT_STREAM = "parallel:intents";
|
|
|
|
function projectDbPath(basePath) {
|
|
const sfDir = join(basePath, ".sf");
|
|
mkdirSync(sfDir, { recursive: true });
|
|
return join(sfDir, "sf.db");
|
|
}
|
|
|
|
function getStore(basePath) {
|
|
const dbPath = projectDbPath(basePath);
|
|
if (!getDatabase() || getDbPath() !== dbPath) {
|
|
openDatabase(dbPath);
|
|
}
|
|
const db = getDatabase();
|
|
if (!db) throw new Error("SF database is not open");
|
|
return new UokCoordinationStore(db);
|
|
}
|
|
|
|
function intentKey(milestoneId) {
|
|
return `${INTENT_KEY_PREFIX}${milestoneId}`;
|
|
}
|
|
|
|
function normalizeFiles(files) {
|
|
return files.map((f) => f.replace(/^\/+/, ""));
|
|
}
|
|
|
|
/**
|
|
* Declare editing intent before making changes.
|
|
*
|
|
* Purpose: let other workers know which files this worker plans to modify,
|
|
* so conflicts can be detected proactively.
|
|
*
|
|
* @param {string} basePath — project root
|
|
* @param {string} milestoneId — worker's milestone
|
|
* @param {string[]} files — relative file paths worker intends to edit
|
|
* @param {object} opts — optional: symbolRanges (for fine-grained claims)
|
|
*/
|
|
export function declareIntent(basePath, milestoneId, files, opts = {}) {
|
|
try {
|
|
const store = getStore(basePath);
|
|
const record = {
|
|
milestoneId,
|
|
files: normalizeFiles(files),
|
|
symbolRanges: opts.symbolRanges ?? [],
|
|
declaredAt: new Date().toISOString(),
|
|
status: "claimed",
|
|
};
|
|
store.set(intentKey(milestoneId), record);
|
|
store.xadd(INTENT_STREAM, "intent-claimed", record);
|
|
return { ok: true };
|
|
} catch (err) {
|
|
logWarning(
|
|
"parallel-intent",
|
|
`declareIntent failed for ${milestoneId}: ${err.message}`,
|
|
);
|
|
return { ok: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Release intent claim when editing is complete or abandoned.
|
|
*/
|
|
export function releaseIntent(basePath, milestoneId) {
|
|
try {
|
|
const store = getStore(basePath);
|
|
const record = store.get(intentKey(milestoneId));
|
|
if (record) {
|
|
record.status = "released";
|
|
record.releasedAt = new Date().toISOString();
|
|
store.set(intentKey(milestoneId), record);
|
|
store.xadd(INTENT_STREAM, "intent-released", record);
|
|
}
|
|
return { ok: true };
|
|
} catch (err) {
|
|
logWarning(
|
|
"parallel-intent",
|
|
`releaseIntent failed for ${milestoneId}: ${err.message}`,
|
|
);
|
|
return { ok: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if any active worker has claimed overlapping files.
|
|
*
|
|
* @returns {Array<{milestoneId: string, files: string[]}>} conflicts
|
|
*/
|
|
export function checkIntentConflicts(basePath, milestoneId, files) {
|
|
const conflicts = [];
|
|
const normalized = normalizeFiles(files);
|
|
try {
|
|
for (const record of getActiveIntents(basePath)) {
|
|
if (record.milestoneId === milestoneId) continue;
|
|
const overlap = normalized.filter((f) => record.files.includes(f));
|
|
if (overlap.length > 0) {
|
|
conflicts.push({ milestoneId: record.milestoneId, files: overlap });
|
|
}
|
|
}
|
|
} catch (err) {
|
|
logWarning(
|
|
"parallel-intent",
|
|
`checkIntentConflicts failed: ${err.message}`,
|
|
);
|
|
}
|
|
return conflicts;
|
|
}
|
|
|
|
/**
|
|
* Get all active intent claims.
|
|
*/
|
|
export function getActiveIntents(basePath) {
|
|
try {
|
|
return getStore(basePath)
|
|
.entries(INTENT_KEY_PREFIX)
|
|
.map((entry) => entry.value)
|
|
.filter((record) => record?.status === "claimed");
|
|
} catch (err) {
|
|
logWarning("parallel-intent", `getActiveIntents failed: ${err.message}`);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all intent records (used on orchestrator shutdown).
|
|
*/
|
|
export function clearAllIntents(basePath) {
|
|
try {
|
|
const store = getStore(basePath);
|
|
for (const entry of store.entries(INTENT_KEY_PREFIX)) {
|
|
store.delete(entry.key);
|
|
}
|
|
} catch (err) {
|
|
logWarning("parallel-intent", `clearAllIntents failed: ${err.message}`);
|
|
}
|
|
}
|