@triflux/remote 10.50.2 → 10.52.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/cto/brief.mjs +0 -20
- package/cto/collect.mjs +1 -200
- package/cto/current.schema.json +2 -23
- package/cto/hygiene.mjs +2 -61
- package/cto/lake-root.mjs +0 -56
- package/cto/status.mjs +5 -187
- package/hub/index.mjs +0 -17
- package/hub/team/agent-map.json +0 -1
- package/hub/team/agents-row-attach.py +120 -0
- package/hub/team/agents-row.mjs +200 -0
- package/hub/team/ansi.mjs +1 -35
- package/hub/team/backend.mjs +9 -145
- package/hub/team/claude-daemon-control.mjs +1 -2
- package/hub/team/claude-session-projection.mjs +5 -73
- package/hub/team/cli/commands/attach.mjs +1 -17
- package/hub/team/cli/commands/control.mjs +12 -38
- package/hub/team/cli/commands/debug.mjs +2 -39
- package/hub/team/cli/commands/focus.mjs +5 -31
- package/hub/team/cli/commands/interrupt.mjs +6 -25
- package/hub/team/cli/commands/kill.mjs +1 -30
- package/hub/team/cli/commands/list.mjs +0 -16
- package/hub/team/cli/commands/send.mjs +6 -26
- package/hub/team/cli/commands/start/index.mjs +42 -133
- package/hub/team/cli/commands/start/parse-args.mjs +15 -40
- package/hub/team/cli/commands/start/start-headless.mjs +138 -105
- package/hub/team/cli/commands/start/start-mux.mjs +2 -7
- package/hub/team/cli/commands/status.mjs +2 -57
- package/hub/team/cli/commands/stop.mjs +14 -19
- package/hub/team/cli/help.mjs +4 -5
- package/hub/team/cli/manifest.mjs +5 -1
- package/hub/team/cli/render.mjs +0 -12
- package/hub/team/cli/services/runtime-mode.mjs +27 -98
- package/hub/team/cli/services/state-store.mjs +5 -20
- package/hub/team/handoff.mjs +3 -34
- package/hub/team/headless.mjs +108 -1296
- package/hub/team/orchestrator.mjs +4 -30
- package/hub/team/pane.mjs +6 -28
- package/hub/team/psmux.mjs +3 -34
- package/hub/team/remote-session.mjs +1 -2
- package/hub/team/session-context.mjs +1 -1
- package/hub/team/session.mjs +7 -91
- package/hub/team/tui-core.mjs +1 -1
- package/hub/team/tui-viewer.mjs +11 -58
- package/hub/team/tui.mjs +8 -306
- package/hub/team/uds-orchestrator.mjs +2 -580
- package/hub/team/wt-manager.mjs +2 -2
- package/hub/workers/factory.mjs +6 -84
- package/hub/workers/interface.mjs +1 -7
- package/hub/workers/lib/jsonrpc-core.mjs +11 -0
- package/hub/workers/lib/jsonrpc-ws-uds.mjs +5 -8
- package/package.json +3 -8
- package/scripts/lib/agent-route-policy.mjs +143 -62
- package/scripts/lib/cli-claude.mjs +0 -14
- package/scripts/lib/env-probe.mjs +2 -131
- package/scripts/lib/gemini-profiles.mjs +2 -69
- package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
- package/scripts/lib/machine-profile.mjs +3 -17
- package/scripts/lib/mcp-filter.mjs +109 -53
- package/scripts/lib/mcp-guard-engine.mjs +40 -98
- package/scripts/lib/mcp-health.mjs +2 -2
- package/scripts/lib/mcp-server-catalog.mjs +2 -2
- package/scripts/lib/psmux-info.mjs +11 -59
- package/scripts/lib/skill-template.mjs +0 -245
- package/hub/hub-lifecycle.mjs +0 -248
- package/hub/pipe.mjs +0 -825
- package/hub/promote-penalties.mjs +0 -138
- package/hub/public/dashboard.html +0 -416
- package/hub/schema.sql +0 -209
- package/hub/server.mjs +0 -3048
- package/hub/store-adapter.mjs +0 -182
- package/hub/store.mjs +0 -1736
- package/hub/team/build-worker-prompt.mjs +0 -92
- package/hub/team/check-mcp-hub.mjs +0 -49
- package/hub/team/claude-native-bridge.mjs +0 -987
- package/hub/team/cli/commands/start/start-in-process.mjs +0 -50
- package/hub/team/cli/commands/start/start-wt.mjs +0 -86
- package/hub/team/cli/services/hub-client.mjs +0 -264
- package/hub/team/cli/services/native-control.mjs +0 -145
- package/hub/team/codex-review.mjs +0 -520
- package/hub/team/conductor-mesh-bridge.mjs +0 -122
- package/hub/team/conductor-registry.mjs +0 -121
- package/hub/team/conductor.mjs +0 -1529
- package/hub/team/consensus-meta.mjs +0 -176
- package/hub/team/cto-auto-collect.mjs +0 -115
- package/hub/team/cto-pull.mjs +0 -31
- package/hub/team/daemon-pty-tmux-bridge.mjs +0 -146
- package/hub/team/dashboard.mjs +0 -322
- package/hub/team/event-log.mjs +0 -84
- package/hub/team/execution-mode.mjs +0 -324
- package/hub/team/extract-completion-payload.mjs +0 -110
- package/hub/team/headless-bridge-session.mjs +0 -17
- package/hub/team/health-probe.mjs +0 -435
- package/hub/team/interactive-native-launcher.mjs +0 -119
- package/hub/team/interactive-native-worker.mjs +0 -80
- package/hub/team/interactive-tui-transport.mjs +0 -479
- package/hub/team/launcher-template.mjs +0 -144
- package/hub/team/lead-control.mjs +0 -191
- package/hub/team/mcp-selector.mjs +0 -145
- package/hub/team/native-supervisor.mjs +0 -460
- package/hub/team/native.mjs +0 -727
- package/hub/team/recovery-store.mjs +0 -149
- package/hub/team/remote-probe.mjs +0 -292
- package/hub/team/remote-watcher.mjs +0 -506
- package/hub/team/runtime-strategy.mjs +0 -200
- package/hub/team/schemas/worker-completion.json +0 -39
- package/hub/team/sentinel-capture.mjs +0 -127
- package/hub/team/session-sync.mjs +0 -316
- package/hub/team/swarm-cli.mjs +0 -390
- package/hub/team/swarm-hypervisor.mjs +0 -2206
- package/hub/team/swarm-intent.mjs +0 -216
- package/hub/team/swarm-locks.mjs +0 -335
- package/hub/team/swarm-planner.mjs +0 -371
- package/hub/team/swarm-preflight.mjs +0 -366
- package/hub/team/swarm-reconciler.mjs +0 -242
- package/hub/team/synapse-cli.mjs +0 -458
- package/hub/team/synapse-http.mjs +0 -311
- package/hub/team/synapse-registry.mjs +0 -618
- package/hub/team/tui-remote-adapter.mjs +0 -377
- package/hub/team/tui-synapse.mjs +0 -223
- package/hub/team/worker-completion-validator.mjs +0 -79
- package/hub/team/worker-sandbox.mjs +0 -125
- package/hub/team/worktree-lifecycle.mjs +0 -639
- package/hub/team/wt-templates.json +0 -43
- package/hub/tools.mjs +0 -900
- package/hub/workers/antigravity-route-worker.mjs +0 -179
- package/hub/workers/codex-app-server-worker.mjs +0 -1067
- package/hub/workers/delegator-mcp.mjs +0 -1098
- package/hub/workers/lib/jsonrpc-stdio.mjs +0 -204
- package/scripts/lib/claudemd-scanner.mjs +0 -223
- package/scripts/lib/context.mjs +0 -67
- package/scripts/lib/dynamic-route-cli.mjs +0 -107
- package/scripts/lib/handoff.mjs +0 -187
- package/scripts/lib/logger.mjs +0 -122
- package/scripts/lib/stealth-fetch.mjs +0 -176
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
// hub/team/swarm-intent.mjs — X-Intent helper utilities for semantic merge awareness
|
|
2
|
-
|
|
3
|
-
const INTENT_TRAILER_REGEX = /^X-Intent:\s*(.+)$/m;
|
|
4
|
-
|
|
5
|
-
function normalizeText(value) {
|
|
6
|
-
return typeof value === "string" ? value.trim() : "";
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function normalizeTouches(touches) {
|
|
10
|
-
if (!Array.isArray(touches)) return null;
|
|
11
|
-
return touches
|
|
12
|
-
.filter((item) => typeof item === "string")
|
|
13
|
-
.map((item) => item.trim().replace(/\\/g, "/"))
|
|
14
|
-
.filter(Boolean);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function normalizeIntent(intentObj) {
|
|
18
|
-
if (!intentObj || typeof intentObj !== "object" || Array.isArray(intentObj)) {
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const scope = normalizeText(intentObj.scope);
|
|
23
|
-
const action = normalizeText(intentObj.action);
|
|
24
|
-
const reason = normalizeText(intentObj.reason);
|
|
25
|
-
const touches = normalizeTouches(intentObj.touches);
|
|
26
|
-
|
|
27
|
-
if (!scope || !action || !reason || !touches) {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return {
|
|
32
|
-
scope,
|
|
33
|
-
action,
|
|
34
|
-
reason,
|
|
35
|
-
touches,
|
|
36
|
-
invariant: normalizeText(intentObj.invariant),
|
|
37
|
-
conflictsWith: normalizeText(intentObj.conflictsWith),
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function fallbackIntent(llmResponse) {
|
|
42
|
-
const responseText = String(llmResponse ?? "");
|
|
43
|
-
return {
|
|
44
|
-
scope: "unknown",
|
|
45
|
-
action: "unknown",
|
|
46
|
-
reason: responseText.slice(0, 100),
|
|
47
|
-
touches: [],
|
|
48
|
-
invariant: "",
|
|
49
|
-
conflictsWith: "",
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function includesEitherWay(a, b) {
|
|
54
|
-
if (!a || !b) return false;
|
|
55
|
-
return a.includes(b) || b.includes(a);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function hasConflict(intentA, intentB) {
|
|
59
|
-
const conflictsWith = normalizeText(intentA?.conflictsWith).toLowerCase();
|
|
60
|
-
const scope = normalizeText(intentB?.scope).toLowerCase();
|
|
61
|
-
const action = normalizeText(intentB?.action).toLowerCase();
|
|
62
|
-
|
|
63
|
-
if (!conflictsWith) return false;
|
|
64
|
-
return (
|
|
65
|
-
includesEitherWay(conflictsWith, scope) ||
|
|
66
|
-
includesEitherWay(conflictsWith, action)
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function getTouchesOverlap(intentA, intentB) {
|
|
71
|
-
const touchesA = new Set(
|
|
72
|
-
(normalizeTouches(intentA?.touches) ?? []).map((item) =>
|
|
73
|
-
item.toLowerCase(),
|
|
74
|
-
),
|
|
75
|
-
);
|
|
76
|
-
const touchesB = (normalizeTouches(intentB?.touches) ?? []).map((item) =>
|
|
77
|
-
item.toLowerCase(),
|
|
78
|
-
);
|
|
79
|
-
return touchesB.filter((item) => touchesA.has(item));
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Build prompts for an LLM to generate an X-Intent JSON object.
|
|
84
|
-
* @param {string[]} changedFiles
|
|
85
|
-
* @param {string} commitDiff
|
|
86
|
-
* @param {string} taskContext
|
|
87
|
-
* @returns {{ systemPrompt: string, userPrompt: string }}
|
|
88
|
-
*/
|
|
89
|
-
export function generateIntentPrompt(changedFiles, commitDiff, taskContext) {
|
|
90
|
-
const files = Array.isArray(changedFiles)
|
|
91
|
-
? changedFiles.filter((filePath) => typeof filePath === "string")
|
|
92
|
-
: [];
|
|
93
|
-
const diffSnippet = String(commitDiff ?? "").slice(0, 4000);
|
|
94
|
-
const context = String(taskContext ?? "").trim();
|
|
95
|
-
|
|
96
|
-
return {
|
|
97
|
-
systemPrompt:
|
|
98
|
-
"You generate commit intent metadata for semantic merge awareness. Return only valid JSON with keys: scope, action, reason, touches, invariant, conflictsWith.",
|
|
99
|
-
userPrompt: [
|
|
100
|
-
"Create an intent object from the following commit context.",
|
|
101
|
-
"- scope/action/reason must be concise strings.",
|
|
102
|
-
"- touches must be an array of touched file paths.",
|
|
103
|
-
"- invariant/conflictsWith should be strings (empty if unknown).",
|
|
104
|
-
"- Return JSON only (no markdown).",
|
|
105
|
-
"",
|
|
106
|
-
`Task context:\n${context || "(none provided)"}`,
|
|
107
|
-
"",
|
|
108
|
-
`Changed files (${files.length}):\n${files.join("\n") || "(none)"}`,
|
|
109
|
-
"",
|
|
110
|
-
"Commit diff (first 4000 chars):",
|
|
111
|
-
diffSnippet || "(empty)",
|
|
112
|
-
].join("\n"),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Format an intent object into an X-Intent commit trailer.
|
|
118
|
-
* @param {object} intentObj
|
|
119
|
-
* @returns {string}
|
|
120
|
-
*/
|
|
121
|
-
export function formatIntentTrailer(intentObj) {
|
|
122
|
-
return `X-Intent: ${JSON.stringify(intentObj)}`;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Parse an X-Intent trailer from a commit message.
|
|
127
|
-
* @param {string} commitMessage
|
|
128
|
-
* @returns {object | null}
|
|
129
|
-
*/
|
|
130
|
-
export function parseIntentTrailer(commitMessage) {
|
|
131
|
-
const message = String(commitMessage ?? "");
|
|
132
|
-
const match = message.match(INTENT_TRAILER_REGEX);
|
|
133
|
-
if (!match) return null;
|
|
134
|
-
|
|
135
|
-
try {
|
|
136
|
-
return JSON.parse(match[1]);
|
|
137
|
-
} catch {
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Classify the relation between two intents.
|
|
144
|
-
* @param {object} intentA
|
|
145
|
-
* @param {object} intentB
|
|
146
|
-
* @returns {{ relation: "complementary" | "complementary-risky" | "contradictory" | "independent", reason: string }}
|
|
147
|
-
*/
|
|
148
|
-
export function classifyIntentPair(intentA, intentB) {
|
|
149
|
-
if (hasConflict(intentA, intentB) || hasConflict(intentB, intentA)) {
|
|
150
|
-
return {
|
|
151
|
-
relation: "contradictory",
|
|
152
|
-
reason:
|
|
153
|
-
"conflictsWith on one intent matches the other intent scope/action",
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const scopeA = normalizeText(intentA?.scope).toLowerCase();
|
|
158
|
-
const scopeB = normalizeText(intentB?.scope).toLowerCase();
|
|
159
|
-
|
|
160
|
-
if (scopeA && scopeB && scopeA === scopeB) {
|
|
161
|
-
const overlap = getTouchesOverlap(intentA, intentB);
|
|
162
|
-
if (overlap.length > 0) {
|
|
163
|
-
return {
|
|
164
|
-
relation: "complementary-risky",
|
|
165
|
-
reason: `same scope with overlapping touched files: ${overlap.join(", ")}`,
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return {
|
|
170
|
-
relation: "complementary",
|
|
171
|
-
reason:
|
|
172
|
-
"same scope with no conflicting intent and no touched-file overlap",
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return {
|
|
177
|
-
relation: "independent",
|
|
178
|
-
reason: "different scopes and no conflictsWith match detected",
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Build a validated intent object from raw LLM response text.
|
|
184
|
-
* @param {string} llmResponse
|
|
185
|
-
* @returns {{ scope: string, action: string, reason: string, touches: string[], invariant: string, conflictsWith: string }}
|
|
186
|
-
*/
|
|
187
|
-
export function buildIntentFromLLMResponse(llmResponse) {
|
|
188
|
-
const responseText = String(llmResponse ?? "").trim();
|
|
189
|
-
const candidates = [];
|
|
190
|
-
|
|
191
|
-
const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)```/gi;
|
|
192
|
-
for (const match of responseText.matchAll(codeBlockRegex)) {
|
|
193
|
-
if (match[1]) candidates.push(match[1].trim());
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
candidates.push(responseText);
|
|
197
|
-
|
|
198
|
-
const firstBrace = responseText.indexOf("{");
|
|
199
|
-
const lastBrace = responseText.lastIndexOf("}");
|
|
200
|
-
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
|
201
|
-
candidates.push(responseText.slice(firstBrace, lastBrace + 1));
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
for (const candidate of candidates) {
|
|
205
|
-
if (!candidate) continue;
|
|
206
|
-
try {
|
|
207
|
-
const parsed = JSON.parse(candidate);
|
|
208
|
-
const normalized = normalizeIntent(parsed);
|
|
209
|
-
if (normalized) return normalized;
|
|
210
|
-
} catch {
|
|
211
|
-
// ignore parse failures and continue trying other candidates
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
return fallbackIntent(responseText);
|
|
216
|
-
}
|
package/hub/team/swarm-locks.mjs
DELETED
|
@@ -1,335 +0,0 @@
|
|
|
1
|
-
// hub/team/swarm-locks.mjs — File-level lease lock manager for swarm execution
|
|
2
|
-
// Prevents multiple workers from writing to the same file simultaneously.
|
|
3
|
-
// Lock state is kept in-memory (single-process hypervisor) with optional
|
|
4
|
-
// JSON persistence to .triflux/swarm-locks.json for crash recovery.
|
|
5
|
-
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
-
import { dirname, relative, resolve } from "node:path";
|
|
8
|
-
import { DEFAULT_SWARM_LOCK_TTL_MS } from "@triflux/core/hub/lib/timeout-defaults.mjs";
|
|
9
|
-
|
|
10
|
-
const LOCK_TTL_MS = DEFAULT_SWARM_LOCK_TTL_MS;
|
|
11
|
-
|
|
12
|
-
// Distribution-critical paths. A swarm worker that modifies these without
|
|
13
|
-
// having them explicitly listed in its shard lease is almost certainly a
|
|
14
|
-
// recovery-patch fallout (worker exited before commit, hypervisor saved
|
|
15
|
-
// patch with destructive `+++ /dev/null` deletions). Block by default;
|
|
16
|
-
// override via validateChanges options.sensitiveDeny.
|
|
17
|
-
//
|
|
18
|
-
// Paths use POSIX separators (matches normalizePath output).
|
|
19
|
-
export const SENSITIVE_PATH_PREFIXES = [
|
|
20
|
-
".claude-plugin/",
|
|
21
|
-
"bin/",
|
|
22
|
-
".github/workflows/",
|
|
23
|
-
];
|
|
24
|
-
export const SENSITIVE_PATH_FILES = [
|
|
25
|
-
".gitignore",
|
|
26
|
-
".npmignore",
|
|
27
|
-
"package.json",
|
|
28
|
-
"package-lock.json",
|
|
29
|
-
"biome.json",
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
export const DEFAULT_SENSITIVE_DENY = Object.freeze({
|
|
33
|
-
prefixes: Object.freeze([...SENSITIVE_PATH_PREFIXES]),
|
|
34
|
-
files: Object.freeze([...SENSITIVE_PATH_FILES]),
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Swarm lock manager factory.
|
|
39
|
-
* @param {object} [opts]
|
|
40
|
-
* @param {string} [opts.repoRoot] — repository root for relative path normalization
|
|
41
|
-
* @param {string} [opts.persistPath] — JSON file path for crash recovery persistence
|
|
42
|
-
* @param {number} [opts.ttlMs=600000] — lock TTL in ms (auto-expire stale locks)
|
|
43
|
-
* @returns {SwarmLockManager}
|
|
44
|
-
*/
|
|
45
|
-
export function createSwarmLocks(opts = {}) {
|
|
46
|
-
const { repoRoot = process.cwd(), persistPath, ttlMs = LOCK_TTL_MS } = opts;
|
|
47
|
-
|
|
48
|
-
/** @type {Map<string, LockEntry>} normalized relative path → lock */
|
|
49
|
-
const locks = new Map();
|
|
50
|
-
|
|
51
|
-
// ── helpers ──────────────────────────────────────────────────
|
|
52
|
-
|
|
53
|
-
function normalizePath(filePath) {
|
|
54
|
-
const abs = resolve(repoRoot, filePath);
|
|
55
|
-
return relative(repoRoot, abs).replace(/\\/g, "/");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function now() {
|
|
59
|
-
return Date.now();
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function normalizeLeaseType(leaseType) {
|
|
63
|
-
return leaseType === "shared-read" ? "shared-read" : "exclusive";
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function normalizeSessionMeta(sessionMeta) {
|
|
67
|
-
return sessionMeta ?? null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function normalizeEntry(entry) {
|
|
71
|
-
return {
|
|
72
|
-
workerId: entry.workerId,
|
|
73
|
-
acquiredAt: entry.acquiredAt,
|
|
74
|
-
leaseType: normalizeLeaseType(entry.leaseType),
|
|
75
|
-
sessionMeta: normalizeSessionMeta(entry.sessionMeta),
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function hasConflict(existing, requesterId, requestedLeaseType) {
|
|
80
|
-
if (!existing || existing.workerId === requesterId) return false;
|
|
81
|
-
if (
|
|
82
|
-
existing.leaseType === "shared-read" &&
|
|
83
|
-
requestedLeaseType === "shared-read"
|
|
84
|
-
) {
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
return true;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function isExpired(entry) {
|
|
91
|
-
return now() - entry.acquiredAt > ttlMs;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function pruneExpired() {
|
|
95
|
-
for (const [path, entry] of locks) {
|
|
96
|
-
if (isExpired(entry)) locks.delete(path);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ── persistence ─────────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
function persist() {
|
|
103
|
-
if (!persistPath) return;
|
|
104
|
-
try {
|
|
105
|
-
mkdirSync(dirname(persistPath), { recursive: true });
|
|
106
|
-
const data = Object.fromEntries(
|
|
107
|
-
[...locks].map(([k, v]) => [k, { ...v }]),
|
|
108
|
-
);
|
|
109
|
-
writeFileSync(persistPath, JSON.stringify(data, null, 2), "utf8");
|
|
110
|
-
} catch {
|
|
111
|
-
/* best-effort */
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function restore() {
|
|
116
|
-
if (!persistPath || !existsSync(persistPath)) return;
|
|
117
|
-
try {
|
|
118
|
-
const data = JSON.parse(readFileSync(persistPath, "utf8"));
|
|
119
|
-
const ts = now();
|
|
120
|
-
for (const [path, entry] of Object.entries(data)) {
|
|
121
|
-
if (ts - entry.acquiredAt <= ttlMs) {
|
|
122
|
-
locks.set(path, normalizeEntry(entry));
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
} catch {
|
|
126
|
-
/* corrupted file — start fresh */
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// restore on creation
|
|
131
|
-
restore();
|
|
132
|
-
|
|
133
|
-
// ── public API ──────────────────────────────────────────────
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Acquire file leases for a worker.
|
|
137
|
-
* @param {string} workerId — worker/shard identifier
|
|
138
|
-
* @param {string[]} files — file paths to lock
|
|
139
|
-
* @param {{ leaseType?: "exclusive" | "shared-read", sessionMeta?: { sessionId: string, host: string, taskSummary: string } }} [opts]
|
|
140
|
-
* @returns {{ ok: boolean, acquired: string[], conflicts: Array<{ file: string, holder: string }> }}
|
|
141
|
-
*/
|
|
142
|
-
function acquire(workerId, files, opts = {}) {
|
|
143
|
-
pruneExpired();
|
|
144
|
-
|
|
145
|
-
const leaseType = normalizeLeaseType(opts.leaseType);
|
|
146
|
-
const sessionMeta = normalizeSessionMeta(opts.sessionMeta);
|
|
147
|
-
const normalized = files.map((f) => normalizePath(f));
|
|
148
|
-
const conflicts = [];
|
|
149
|
-
const toAcquire = [];
|
|
150
|
-
const allowedSharedRead = [];
|
|
151
|
-
|
|
152
|
-
for (let i = 0; i < normalized.length; i++) {
|
|
153
|
-
const path = normalized[i];
|
|
154
|
-
const existing = locks.get(path);
|
|
155
|
-
|
|
156
|
-
if (!existing || isExpired(existing)) {
|
|
157
|
-
toAcquire.push(path);
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (hasConflict(existing, workerId, leaseType)) {
|
|
162
|
-
conflicts.push({ file: files[i], holder: existing.workerId });
|
|
163
|
-
} else {
|
|
164
|
-
if (
|
|
165
|
-
!(
|
|
166
|
-
existing.workerId !== workerId &&
|
|
167
|
-
existing.leaseType === "shared-read" &&
|
|
168
|
-
leaseType === "shared-read"
|
|
169
|
-
)
|
|
170
|
-
) {
|
|
171
|
-
toAcquire.push(path);
|
|
172
|
-
} else {
|
|
173
|
-
allowedSharedRead.push(path);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (conflicts.length > 0) {
|
|
179
|
-
return { ok: false, acquired: [], conflicts };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const ts = now();
|
|
183
|
-
for (const path of toAcquire) {
|
|
184
|
-
locks.set(path, { workerId, acquiredAt: ts, leaseType, sessionMeta });
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
persist();
|
|
188
|
-
return {
|
|
189
|
-
ok: true,
|
|
190
|
-
acquired: [...toAcquire, ...allowedSharedRead],
|
|
191
|
-
conflicts: [],
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Release all locks held by a worker.
|
|
197
|
-
* @param {string} workerId
|
|
198
|
-
* @returns {number} number of locks released
|
|
199
|
-
*/
|
|
200
|
-
function release(workerId) {
|
|
201
|
-
let count = 0;
|
|
202
|
-
for (const [path, entry] of locks) {
|
|
203
|
-
if (entry.workerId === workerId) {
|
|
204
|
-
locks.delete(path);
|
|
205
|
-
count += 1;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
if (count > 0) persist();
|
|
209
|
-
return count;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function renew(workerId) {
|
|
213
|
-
pruneExpired();
|
|
214
|
-
const ts = now();
|
|
215
|
-
let count = 0;
|
|
216
|
-
for (const entry of locks.values()) {
|
|
217
|
-
if (entry.workerId === workerId) {
|
|
218
|
-
entry.acquiredAt = ts;
|
|
219
|
-
count += 1;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
if (count > 0) persist();
|
|
223
|
-
return count;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Check if a file write would violate any lease.
|
|
228
|
-
* @param {string} workerId — the worker attempting the write
|
|
229
|
-
* @param {string} filePath — the file being written
|
|
230
|
-
* @param {"exclusive" | "shared-read"} [leaseType="exclusive"] — requested lease type
|
|
231
|
-
* @returns {{ allowed: boolean, holder?: string }}
|
|
232
|
-
*/
|
|
233
|
-
function check(workerId, filePath, leaseType = "exclusive") {
|
|
234
|
-
pruneExpired();
|
|
235
|
-
const path = normalizePath(filePath);
|
|
236
|
-
const entry = locks.get(path);
|
|
237
|
-
const normalizedLeaseType = normalizeLeaseType(leaseType);
|
|
238
|
-
|
|
239
|
-
if (!entry || isExpired(entry)) return { allowed: true };
|
|
240
|
-
if (!hasConflict(entry, workerId, normalizedLeaseType))
|
|
241
|
-
return { allowed: true };
|
|
242
|
-
return { allowed: false, holder: entry.workerId };
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Validate a set of changed files against the lease map.
|
|
247
|
-
* Returns all violations found.
|
|
248
|
-
*
|
|
249
|
-
* Beyond the basic cross-lease check, when `options.ownLease` is supplied
|
|
250
|
-
* (the caller's known lease set), this also flags out-of-lease writes to
|
|
251
|
-
* distribution-critical paths — e.g. recovery-patch fallouts where a
|
|
252
|
-
* worker's recorded diff carries `+++ /dev/null` deletions of files like
|
|
253
|
-
* `.claude-plugin/marketplace.json` (regression of #115 / #34).
|
|
254
|
-
*
|
|
255
|
-
* `options.ownLease` is opt-in to preserve existing callers; when omitted,
|
|
256
|
-
* legacy behavior (cross-lease check only) is kept.
|
|
257
|
-
*
|
|
258
|
-
* @param {string} workerId
|
|
259
|
-
* @param {string[]} changedFiles
|
|
260
|
-
* @param {{ ownLease?: string[], sensitiveDeny?: { prefixes?: string[], files?: string[] } }} [options]
|
|
261
|
-
* @returns {Array<{ file: string, holder?: string, kind: "other-lease" | "sensitive-out-of-lease" }>}
|
|
262
|
-
*/
|
|
263
|
-
function validateChanges(workerId, changedFiles, options = {}) {
|
|
264
|
-
pruneExpired();
|
|
265
|
-
const violations = [];
|
|
266
|
-
const ownLeaseSet = Array.isArray(options.ownLease)
|
|
267
|
-
? new Set(options.ownLease.map((path) => normalizePath(path)))
|
|
268
|
-
: null;
|
|
269
|
-
const sensitivePrefixes =
|
|
270
|
-
options.sensitiveDeny?.prefixes ?? SENSITIVE_PATH_PREFIXES;
|
|
271
|
-
const sensitiveFiles = new Set(
|
|
272
|
-
options.sensitiveDeny?.files ?? SENSITIVE_PATH_FILES,
|
|
273
|
-
);
|
|
274
|
-
|
|
275
|
-
for (const file of changedFiles) {
|
|
276
|
-
const path = normalizePath(file);
|
|
277
|
-
const entry = locks.get(path);
|
|
278
|
-
|
|
279
|
-
if (entry && entry.workerId !== workerId && !isExpired(entry)) {
|
|
280
|
-
violations.push({ file, holder: entry.workerId, kind: "other-lease" });
|
|
281
|
-
continue;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Sensitive-path guard only runs when caller supplied ownLease.
|
|
285
|
-
// We only flag when the worker did NOT explicitly lease the file —
|
|
286
|
-
// an explicit lease means the shard intentionally owns it.
|
|
287
|
-
if (ownLeaseSet && !ownLeaseSet.has(path)) {
|
|
288
|
-
const isSensitive =
|
|
289
|
-
sensitiveFiles.has(path) ||
|
|
290
|
-
sensitivePrefixes.some((prefix) => path.startsWith(prefix));
|
|
291
|
-
if (isSensitive) {
|
|
292
|
-
violations.push({ file, kind: "sensitive-out-of-lease" });
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
return violations;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Get snapshot of all active locks.
|
|
302
|
-
* @returns {Array<{ file: string, workerId: string, acquiredAt: number, leaseType: "exclusive" | "shared-read", sessionMeta: { sessionId: string, host: string, taskSummary: string } | null }>}
|
|
303
|
-
*/
|
|
304
|
-
function snapshot() {
|
|
305
|
-
pruneExpired();
|
|
306
|
-
return [...locks].map(([file, entry]) => ({
|
|
307
|
-
file,
|
|
308
|
-
workerId: entry.workerId,
|
|
309
|
-
acquiredAt: entry.acquiredAt,
|
|
310
|
-
leaseType: normalizeLeaseType(entry.leaseType),
|
|
311
|
-
sessionMeta: normalizeSessionMeta(entry.sessionMeta),
|
|
312
|
-
}));
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Release all locks.
|
|
317
|
-
*/
|
|
318
|
-
function releaseAll() {
|
|
319
|
-
locks.clear();
|
|
320
|
-
persist();
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
return Object.freeze({
|
|
324
|
-
acquire,
|
|
325
|
-
release,
|
|
326
|
-
renew,
|
|
327
|
-
check,
|
|
328
|
-
validateChanges,
|
|
329
|
-
snapshot,
|
|
330
|
-
releaseAll,
|
|
331
|
-
get size() {
|
|
332
|
-
return locks.size;
|
|
333
|
-
},
|
|
334
|
-
});
|
|
335
|
-
}
|