@triflux/remote 10.0.1 → 10.25.1
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/hub/pipe.mjs +239 -121
- package/hub/public/dashboard.html +68 -7
- package/hub/server.mjs +2181 -701
- package/hub/store-adapter.mjs +101 -37
- package/hub/store.mjs +356 -153
- package/hub/team/agent-map.json +26 -7
- package/hub/team/ansi.mjs +245 -146
- package/hub/team/backend.mjs +84 -17
- package/hub/team/build-worker-prompt.mjs +42 -0
- package/hub/team/check-mcp-hub.mjs +49 -0
- package/hub/team/claude-daemon-control.mjs +219 -0
- package/hub/team/claude-native-bridge.mjs +784 -0
- package/hub/team/claude-session-projection.mjs +65 -0
- package/hub/team/cli/commands/attach.mjs +57 -37
- package/hub/team/cli/commands/control.mjs +29 -8
- package/hub/team/cli/commands/debug.mjs +95 -74
- package/hub/team/cli/commands/focus.mjs +80 -53
- package/hub/team/cli/commands/interrupt.mjs +18 -6
- package/hub/team/cli/commands/kill.mjs +16 -5
- package/hub/team/cli/commands/list.mjs +31 -24
- package/hub/team/cli/commands/send.mjs +19 -6
- package/hub/team/cli/commands/start/index.mjs +154 -33
- package/hub/team/cli/commands/start/parse-args.mjs +89 -12
- package/hub/team/cli/commands/start/start-headless.mjs +127 -37
- package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
- package/hub/team/cli/commands/start/start-mux.mjs +122 -73
- package/hub/team/cli/commands/start/start-wt.mjs +86 -69
- package/hub/team/cli/commands/status.mjs +43 -14
- package/hub/team/cli/commands/stop.mjs +11 -4
- package/hub/team/cli/commands/task.mjs +8 -3
- package/hub/team/cli/commands/tasks.mjs +13 -13
- package/hub/team/cli/index.mjs +2 -2
- package/hub/team/cli/manifest.mjs +38 -8
- package/hub/team/cli/render.mjs +52 -30
- package/hub/team/cli/services/attach-fallback.mjs +74 -54
- package/hub/team/cli/services/hub-client.mjs +45 -17
- package/hub/team/cli/services/member-selector.mjs +37 -30
- package/hub/team/cli/services/native-control.mjs +144 -116
- package/hub/team/cli/services/runtime-mode.mjs +55 -1
- package/hub/team/cli/services/state-store.mjs +66 -9
- package/hub/team/cli/services/task-model.mjs +40 -30
- package/hub/team/codex-review.mjs +521 -0
- package/hub/team/conductor-mesh-bridge.mjs +25 -24
- package/hub/team/conductor-registry.mjs +121 -0
- package/hub/team/conductor.mjs +1379 -671
- package/hub/team/consensus-meta.mjs +176 -0
- package/hub/team/dashboard-anchor.mjs +4 -5
- package/hub/team/dashboard-layout.mjs +3 -1
- package/hub/team/dashboard-open.mjs +21 -119
- package/hub/team/dashboard.mjs +76 -28
- package/hub/team/event-log.mjs +18 -10
- package/hub/team/execution-mode.mjs +241 -0
- package/hub/team/extract-completion-payload.mjs +110 -0
- package/hub/team/git-preflight.mjs +286 -0
- package/hub/team/handoff.mjs +49 -18
- package/hub/team/headless-bridge-session.mjs +17 -0
- package/hub/team/headless.mjs +2065 -1156
- package/hub/team/health-probe.mjs +232 -69
- package/hub/team/launcher-template.mjs +23 -13
- package/hub/team/lead-control.mjs +91 -4
- package/hub/team/mcp-selector.mjs +145 -0
- package/hub/team/native-supervisor.mjs +90 -26
- package/hub/team/native.mjs +74 -35
- package/hub/team/nativeProxy.mjs +184 -113
- package/hub/team/notify.mjs +147 -78
- package/hub/team/orchestrator.mjs +173 -161
- package/hub/team/pane.mjs +43 -15
- package/hub/team/process-cleanup.mjs +69 -25
- package/hub/team/psmux.mjs +1067 -264
- package/hub/team/recovery-store.mjs +149 -0
- package/hub/team/remote-probe.mjs +69 -53
- package/hub/team/remote-session.mjs +131 -59
- package/hub/team/remote-watcher.mjs +62 -34
- package/hub/team/retry-state-machine.mjs +267 -0
- package/hub/team/routing.mjs +51 -25
- package/hub/team/runtime-strategy.mjs +200 -0
- package/hub/team/schemas/worker-completion.json +39 -0
- package/hub/team/sentinel-capture.mjs +127 -0
- package/hub/team/session-sync.mjs +153 -6
- package/hub/team/session.mjs +605 -611
- package/hub/team/shared.mjs +13 -13
- package/hub/team/staleState.mjs +72 -30
- package/hub/team/swarm-cli.mjs +390 -0
- package/hub/team/swarm-hypervisor.mjs +1526 -198
- package/hub/team/swarm-intent.mjs +216 -0
- package/hub/team/swarm-locks.mjs +140 -25
- package/hub/team/swarm-planner.mjs +121 -21
- package/hub/team/swarm-preflight.mjs +317 -0
- package/hub/team/swarm-reconciler.mjs +128 -23
- package/hub/team/synapse-cli.mjs +458 -0
- package/hub/team/synapse-http.mjs +60 -0
- package/hub/team/synapse-registry.mjs +329 -0
- package/hub/team/terminal-opener.mjs +216 -0
- package/hub/team/tui-core.mjs +288 -0
- package/hub/team/tui-lite.mjs +234 -146
- package/hub/team/tui-remote-adapter.mjs +21 -37
- package/hub/team/tui-synapse.mjs +223 -0
- package/hub/team/tui-viewer.mjs +104 -45
- package/hub/team/tui-widgets.mjs +262 -0
- package/hub/team/tui.mjs +936 -449
- package/hub/team/worker-completion-validator.mjs +84 -0
- package/hub/team/worker-sandbox.mjs +93 -0
- package/hub/team/worker-signal.mjs +263 -0
- package/hub/team/worker-signal.types.d.ts +52 -0
- package/hub/team/worktree-lifecycle.mjs +518 -64
- package/hub/team/wt-manager.mjs +297 -26
- package/hub/tools.mjs +474 -252
- package/hub/tray.mjs +411 -368
- package/hub/workers/claude-worker.mjs +162 -118
- package/hub/workers/codex-app-server-worker.mjs +1015 -0
- package/hub/workers/codex-mcp.mjs +724 -507
- package/hub/workers/delegator-mcp.mjs +663 -337
- package/hub/workers/factory.mjs +100 -21
- package/hub/workers/gemini-worker.mjs +179 -86
- package/hub/workers/interface.mjs +23 -2
- package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
- package/hub/workers/worker-utils.mjs +22 -16
- package/package.json +4 -3
- package/scripts/lib/agent-json.mjs +27 -0
- package/scripts/lib/async.mjs +174 -0
- package/scripts/lib/claudemd-scanner.mjs +223 -0
- package/scripts/lib/cli-agy.mjs +62 -0
- package/scripts/lib/cli-claude.mjs +78 -0
- package/scripts/lib/cli-codex.mjs +199 -0
- package/scripts/lib/cli-gemini.mjs +67 -0
- package/scripts/lib/codex-recovery.sh +50 -0
- package/scripts/lib/context.mjs +67 -0
- package/scripts/lib/cross-review-utils.mjs +59 -0
- package/scripts/lib/doctor-env-checks.mjs +121 -0
- package/scripts/lib/dynamic-route-cli.mjs +107 -0
- package/scripts/lib/env-probe.mjs +269 -0
- package/scripts/lib/env.mjs +14 -0
- package/scripts/lib/gemini-profiles.mjs +119 -0
- package/scripts/lib/handoff.mjs +187 -0
- package/scripts/lib/hook-utils.mjs +16 -0
- package/scripts/lib/hub.mjs +48 -0
- package/scripts/lib/keyword-rules.mjs +195 -0
- package/scripts/lib/logger.mjs +105 -0
- package/scripts/lib/mcp-filter.mjs +883 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-guard-engine.mjs +2120 -0
- package/scripts/lib/mcp-health.mjs +773 -0
- package/scripts/lib/mcp-manifest.mjs +89 -0
- package/scripts/lib/mcp-server-catalog.mjs +355 -0
- package/scripts/lib/pid.mjs +63 -0
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/lib/psmux-info.mjs +179 -0
- package/scripts/lib/quota.mjs +47 -0
- package/scripts/lib/remote-spawn-transfer.mjs +226 -0
- package/scripts/lib/skill-state.mjs +220 -0
- package/scripts/lib/skill-template.mjs +375 -0
- package/scripts/lib/team.mjs +75 -0
- package/scripts/lib/timeout.mjs +66 -0
- package/scripts/lib/tmp.mjs +26 -0
- package/scripts/lib/toml.mjs +113 -0
|
@@ -0,0 +1,216 @@
|
|
|
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
CHANGED
|
@@ -3,11 +3,36 @@
|
|
|
3
3
|
// Lock state is kept in-memory (single-process hypervisor) with optional
|
|
4
4
|
// JSON persistence to .triflux/swarm-locks.json for crash recovery.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import { dirname,
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, relative, resolve } from "node:path";
|
|
8
8
|
|
|
9
9
|
const LOCK_TTL_MS = 10 * 60_000; // 10 minutes default TTL
|
|
10
10
|
|
|
11
|
+
// Distribution-critical paths. A swarm worker that modifies these without
|
|
12
|
+
// having them explicitly listed in its shard lease is almost certainly a
|
|
13
|
+
// recovery-patch fallout (worker exited before commit, hypervisor saved
|
|
14
|
+
// patch with destructive `+++ /dev/null` deletions). Block by default;
|
|
15
|
+
// override via validateChanges options.sensitiveDeny.
|
|
16
|
+
//
|
|
17
|
+
// Paths use POSIX separators (matches normalizePath output).
|
|
18
|
+
export const SENSITIVE_PATH_PREFIXES = [
|
|
19
|
+
".claude-plugin/",
|
|
20
|
+
"bin/",
|
|
21
|
+
".github/workflows/",
|
|
22
|
+
];
|
|
23
|
+
export const SENSITIVE_PATH_FILES = [
|
|
24
|
+
".gitignore",
|
|
25
|
+
".npmignore",
|
|
26
|
+
"package.json",
|
|
27
|
+
"package-lock.json",
|
|
28
|
+
"biome.json",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_SENSITIVE_DENY = Object.freeze({
|
|
32
|
+
prefixes: Object.freeze([...SENSITIVE_PATH_PREFIXES]),
|
|
33
|
+
files: Object.freeze([...SENSITIVE_PATH_FILES]),
|
|
34
|
+
});
|
|
35
|
+
|
|
11
36
|
/**
|
|
12
37
|
* Swarm lock manager factory.
|
|
13
38
|
* @param {object} [opts]
|
|
@@ -17,11 +42,7 @@ const LOCK_TTL_MS = 10 * 60_000; // 10 minutes default TTL
|
|
|
17
42
|
* @returns {SwarmLockManager}
|
|
18
43
|
*/
|
|
19
44
|
export function createSwarmLocks(opts = {}) {
|
|
20
|
-
const {
|
|
21
|
-
repoRoot = process.cwd(),
|
|
22
|
-
persistPath,
|
|
23
|
-
ttlMs = LOCK_TTL_MS,
|
|
24
|
-
} = opts;
|
|
45
|
+
const { repoRoot = process.cwd(), persistPath, ttlMs = LOCK_TTL_MS } = opts;
|
|
25
46
|
|
|
26
47
|
/** @type {Map<string, LockEntry>} normalized relative path → lock */
|
|
27
48
|
const locks = new Map();
|
|
@@ -30,13 +51,41 @@ export function createSwarmLocks(opts = {}) {
|
|
|
30
51
|
|
|
31
52
|
function normalizePath(filePath) {
|
|
32
53
|
const abs = resolve(repoRoot, filePath);
|
|
33
|
-
return relative(repoRoot, abs).replace(/\\/g,
|
|
54
|
+
return relative(repoRoot, abs).replace(/\\/g, "/");
|
|
34
55
|
}
|
|
35
56
|
|
|
36
57
|
function now() {
|
|
37
58
|
return Date.now();
|
|
38
59
|
}
|
|
39
60
|
|
|
61
|
+
function normalizeLeaseType(leaseType) {
|
|
62
|
+
return leaseType === "shared-read" ? "shared-read" : "exclusive";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeSessionMeta(sessionMeta) {
|
|
66
|
+
return sessionMeta ?? null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeEntry(entry) {
|
|
70
|
+
return {
|
|
71
|
+
workerId: entry.workerId,
|
|
72
|
+
acquiredAt: entry.acquiredAt,
|
|
73
|
+
leaseType: normalizeLeaseType(entry.leaseType),
|
|
74
|
+
sessionMeta: normalizeSessionMeta(entry.sessionMeta),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hasConflict(existing, requesterId, requestedLeaseType) {
|
|
79
|
+
if (!existing || existing.workerId === requesterId) return false;
|
|
80
|
+
if (
|
|
81
|
+
existing.leaseType === "shared-read" &&
|
|
82
|
+
requestedLeaseType === "shared-read"
|
|
83
|
+
) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
40
89
|
function isExpired(entry) {
|
|
41
90
|
return now() - entry.acquiredAt > ttlMs;
|
|
42
91
|
}
|
|
@@ -56,21 +105,25 @@ export function createSwarmLocks(opts = {}) {
|
|
|
56
105
|
const data = Object.fromEntries(
|
|
57
106
|
[...locks].map(([k, v]) => [k, { ...v }]),
|
|
58
107
|
);
|
|
59
|
-
writeFileSync(persistPath, JSON.stringify(data, null, 2),
|
|
60
|
-
} catch {
|
|
108
|
+
writeFileSync(persistPath, JSON.stringify(data, null, 2), "utf8");
|
|
109
|
+
} catch {
|
|
110
|
+
/* best-effort */
|
|
111
|
+
}
|
|
61
112
|
}
|
|
62
113
|
|
|
63
114
|
function restore() {
|
|
64
115
|
if (!persistPath || !existsSync(persistPath)) return;
|
|
65
116
|
try {
|
|
66
|
-
const data = JSON.parse(readFileSync(persistPath,
|
|
117
|
+
const data = JSON.parse(readFileSync(persistPath, "utf8"));
|
|
67
118
|
const ts = now();
|
|
68
119
|
for (const [path, entry] of Object.entries(data)) {
|
|
69
120
|
if (ts - entry.acquiredAt <= ttlMs) {
|
|
70
|
-
locks.set(path, entry);
|
|
121
|
+
locks.set(path, normalizeEntry(entry));
|
|
71
122
|
}
|
|
72
123
|
}
|
|
73
|
-
} catch {
|
|
124
|
+
} catch {
|
|
125
|
+
/* corrupted file — start fresh */
|
|
126
|
+
}
|
|
74
127
|
}
|
|
75
128
|
|
|
76
129
|
// restore on creation
|
|
@@ -82,23 +135,42 @@ export function createSwarmLocks(opts = {}) {
|
|
|
82
135
|
* Acquire file leases for a worker.
|
|
83
136
|
* @param {string} workerId — worker/shard identifier
|
|
84
137
|
* @param {string[]} files — file paths to lock
|
|
138
|
+
* @param {{ leaseType?: "exclusive" | "shared-read", sessionMeta?: { sessionId: string, host: string, taskSummary: string } }} [opts]
|
|
85
139
|
* @returns {{ ok: boolean, acquired: string[], conflicts: Array<{ file: string, holder: string }> }}
|
|
86
140
|
*/
|
|
87
|
-
function acquire(workerId, files) {
|
|
141
|
+
function acquire(workerId, files, opts = {}) {
|
|
88
142
|
pruneExpired();
|
|
89
143
|
|
|
144
|
+
const leaseType = normalizeLeaseType(opts.leaseType);
|
|
145
|
+
const sessionMeta = normalizeSessionMeta(opts.sessionMeta);
|
|
90
146
|
const normalized = files.map((f) => normalizePath(f));
|
|
91
147
|
const conflicts = [];
|
|
92
148
|
const toAcquire = [];
|
|
149
|
+
const allowedSharedRead = [];
|
|
93
150
|
|
|
94
151
|
for (let i = 0; i < normalized.length; i++) {
|
|
95
152
|
const path = normalized[i];
|
|
96
153
|
const existing = locks.get(path);
|
|
97
154
|
|
|
98
|
-
if (existing
|
|
155
|
+
if (!existing || isExpired(existing)) {
|
|
156
|
+
toAcquire.push(path);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (hasConflict(existing, workerId, leaseType)) {
|
|
99
161
|
conflicts.push({ file: files[i], holder: existing.workerId });
|
|
100
162
|
} else {
|
|
101
|
-
|
|
163
|
+
if (
|
|
164
|
+
!(
|
|
165
|
+
existing.workerId !== workerId &&
|
|
166
|
+
existing.leaseType === "shared-read" &&
|
|
167
|
+
leaseType === "shared-read"
|
|
168
|
+
)
|
|
169
|
+
) {
|
|
170
|
+
toAcquire.push(path);
|
|
171
|
+
} else {
|
|
172
|
+
allowedSharedRead.push(path);
|
|
173
|
+
}
|
|
102
174
|
}
|
|
103
175
|
}
|
|
104
176
|
|
|
@@ -108,11 +180,15 @@ export function createSwarmLocks(opts = {}) {
|
|
|
108
180
|
|
|
109
181
|
const ts = now();
|
|
110
182
|
for (const path of toAcquire) {
|
|
111
|
-
locks.set(path, { workerId, acquiredAt: ts });
|
|
183
|
+
locks.set(path, { workerId, acquiredAt: ts, leaseType, sessionMeta });
|
|
112
184
|
}
|
|
113
185
|
|
|
114
186
|
persist();
|
|
115
|
-
return {
|
|
187
|
+
return {
|
|
188
|
+
ok: true,
|
|
189
|
+
acquired: [...toAcquire, ...allowedSharedRead],
|
|
190
|
+
conflicts: [],
|
|
191
|
+
};
|
|
116
192
|
}
|
|
117
193
|
|
|
118
194
|
/**
|
|
@@ -136,35 +212,70 @@ export function createSwarmLocks(opts = {}) {
|
|
|
136
212
|
* Check if a file write would violate any lease.
|
|
137
213
|
* @param {string} workerId — the worker attempting the write
|
|
138
214
|
* @param {string} filePath — the file being written
|
|
215
|
+
* @param {"exclusive" | "shared-read"} [leaseType="exclusive"] — requested lease type
|
|
139
216
|
* @returns {{ allowed: boolean, holder?: string }}
|
|
140
217
|
*/
|
|
141
|
-
function check(workerId, filePath) {
|
|
218
|
+
function check(workerId, filePath, leaseType = "exclusive") {
|
|
142
219
|
pruneExpired();
|
|
143
220
|
const path = normalizePath(filePath);
|
|
144
221
|
const entry = locks.get(path);
|
|
222
|
+
const normalizedLeaseType = normalizeLeaseType(leaseType);
|
|
145
223
|
|
|
146
224
|
if (!entry || isExpired(entry)) return { allowed: true };
|
|
147
|
-
if (entry
|
|
225
|
+
if (!hasConflict(entry, workerId, normalizedLeaseType))
|
|
226
|
+
return { allowed: true };
|
|
148
227
|
return { allowed: false, holder: entry.workerId };
|
|
149
228
|
}
|
|
150
229
|
|
|
151
230
|
/**
|
|
152
231
|
* Validate a set of changed files against the lease map.
|
|
153
232
|
* Returns all violations found.
|
|
233
|
+
*
|
|
234
|
+
* Beyond the basic cross-lease check, when `options.ownLease` is supplied
|
|
235
|
+
* (the caller's known lease set), this also flags out-of-lease writes to
|
|
236
|
+
* distribution-critical paths — e.g. recovery-patch fallouts where a
|
|
237
|
+
* worker's recorded diff carries `+++ /dev/null` deletions of files like
|
|
238
|
+
* `.claude-plugin/marketplace.json` (regression of #115 / #34).
|
|
239
|
+
*
|
|
240
|
+
* `options.ownLease` is opt-in to preserve existing callers; when omitted,
|
|
241
|
+
* legacy behavior (cross-lease check only) is kept.
|
|
242
|
+
*
|
|
154
243
|
* @param {string} workerId
|
|
155
244
|
* @param {string[]} changedFiles
|
|
156
|
-
* @
|
|
245
|
+
* @param {{ ownLease?: string[], sensitiveDeny?: { prefixes?: string[], files?: string[] } }} [options]
|
|
246
|
+
* @returns {Array<{ file: string, holder?: string, kind: "other-lease" | "sensitive-out-of-lease" }>}
|
|
157
247
|
*/
|
|
158
|
-
function validateChanges(workerId, changedFiles) {
|
|
248
|
+
function validateChanges(workerId, changedFiles, options = {}) {
|
|
159
249
|
pruneExpired();
|
|
160
250
|
const violations = [];
|
|
251
|
+
const ownLeaseSet = Array.isArray(options.ownLease)
|
|
252
|
+
? new Set(options.ownLease.map((path) => normalizePath(path)))
|
|
253
|
+
: null;
|
|
254
|
+
const sensitivePrefixes =
|
|
255
|
+
options.sensitiveDeny?.prefixes ?? SENSITIVE_PATH_PREFIXES;
|
|
256
|
+
const sensitiveFiles = new Set(
|
|
257
|
+
options.sensitiveDeny?.files ?? SENSITIVE_PATH_FILES,
|
|
258
|
+
);
|
|
161
259
|
|
|
162
260
|
for (const file of changedFiles) {
|
|
163
261
|
const path = normalizePath(file);
|
|
164
262
|
const entry = locks.get(path);
|
|
165
263
|
|
|
166
264
|
if (entry && entry.workerId !== workerId && !isExpired(entry)) {
|
|
167
|
-
violations.push({ file, holder: entry.workerId });
|
|
265
|
+
violations.push({ file, holder: entry.workerId, kind: "other-lease" });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Sensitive-path guard only runs when caller supplied ownLease.
|
|
270
|
+
// We only flag when the worker did NOT explicitly lease the file —
|
|
271
|
+
// an explicit lease means the shard intentionally owns it.
|
|
272
|
+
if (ownLeaseSet && !ownLeaseSet.has(path)) {
|
|
273
|
+
const isSensitive =
|
|
274
|
+
sensitiveFiles.has(path) ||
|
|
275
|
+
sensitivePrefixes.some((prefix) => path.startsWith(prefix));
|
|
276
|
+
if (isSensitive) {
|
|
277
|
+
violations.push({ file, kind: "sensitive-out-of-lease" });
|
|
278
|
+
}
|
|
168
279
|
}
|
|
169
280
|
}
|
|
170
281
|
|
|
@@ -173,7 +284,7 @@ export function createSwarmLocks(opts = {}) {
|
|
|
173
284
|
|
|
174
285
|
/**
|
|
175
286
|
* Get snapshot of all active locks.
|
|
176
|
-
* @returns {Array<{ file: string, workerId: string, acquiredAt: number }>}
|
|
287
|
+
* @returns {Array<{ file: string, workerId: string, acquiredAt: number, leaseType: "exclusive" | "shared-read", sessionMeta: { sessionId: string, host: string, taskSummary: string } | null }>}
|
|
177
288
|
*/
|
|
178
289
|
function snapshot() {
|
|
179
290
|
pruneExpired();
|
|
@@ -181,6 +292,8 @@ export function createSwarmLocks(opts = {}) {
|
|
|
181
292
|
file,
|
|
182
293
|
workerId: entry.workerId,
|
|
183
294
|
acquiredAt: entry.acquiredAt,
|
|
295
|
+
leaseType: normalizeLeaseType(entry.leaseType),
|
|
296
|
+
sessionMeta: normalizeSessionMeta(entry.sessionMeta),
|
|
184
297
|
}));
|
|
185
298
|
}
|
|
186
299
|
|
|
@@ -199,6 +312,8 @@ export function createSwarmLocks(opts = {}) {
|
|
|
199
312
|
validateChanges,
|
|
200
313
|
snapshot,
|
|
201
314
|
releaseAll,
|
|
202
|
-
get size() {
|
|
315
|
+
get size() {
|
|
316
|
+
return locks.size;
|
|
317
|
+
},
|
|
203
318
|
});
|
|
204
319
|
}
|