triflux 10.9.11 → 10.9.13

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.
@@ -33,6 +33,34 @@ export function createSwarmLocks(opts = {}) {
33
33
  return Date.now();
34
34
  }
35
35
 
36
+ function normalizeLeaseType(leaseType) {
37
+ return leaseType === "shared-read" ? "shared-read" : "exclusive";
38
+ }
39
+
40
+ function normalizeSessionMeta(sessionMeta) {
41
+ return sessionMeta ?? null;
42
+ }
43
+
44
+ function normalizeEntry(entry) {
45
+ return {
46
+ workerId: entry.workerId,
47
+ acquiredAt: entry.acquiredAt,
48
+ leaseType: normalizeLeaseType(entry.leaseType),
49
+ sessionMeta: normalizeSessionMeta(entry.sessionMeta),
50
+ };
51
+ }
52
+
53
+ function hasConflict(existing, requesterId, requestedLeaseType) {
54
+ if (!existing || existing.workerId === requesterId) return false;
55
+ if (
56
+ existing.leaseType === "shared-read" &&
57
+ requestedLeaseType === "shared-read"
58
+ ) {
59
+ return false;
60
+ }
61
+ return true;
62
+ }
63
+
36
64
  function isExpired(entry) {
37
65
  return now() - entry.acquiredAt > ttlMs;
38
66
  }
@@ -65,7 +93,7 @@ export function createSwarmLocks(opts = {}) {
65
93
  const ts = now();
66
94
  for (const [path, entry] of Object.entries(data)) {
67
95
  if (ts - entry.acquiredAt <= ttlMs) {
68
- locks.set(path, entry);
96
+ locks.set(path, normalizeEntry(entry));
69
97
  }
70
98
  }
71
99
  } catch {
@@ -82,23 +110,42 @@ export function createSwarmLocks(opts = {}) {
82
110
  * Acquire file leases for a worker.
83
111
  * @param {string} workerId — worker/shard identifier
84
112
  * @param {string[]} files — file paths to lock
113
+ * @param {{ leaseType?: "exclusive" | "shared-read", sessionMeta?: { sessionId: string, host: string, taskSummary: string } }} [opts]
85
114
  * @returns {{ ok: boolean, acquired: string[], conflicts: Array<{ file: string, holder: string }> }}
86
115
  */
87
- function acquire(workerId, files) {
116
+ function acquire(workerId, files, opts = {}) {
88
117
  pruneExpired();
89
118
 
119
+ const leaseType = normalizeLeaseType(opts.leaseType);
120
+ const sessionMeta = normalizeSessionMeta(opts.sessionMeta);
90
121
  const normalized = files.map((f) => normalizePath(f));
91
122
  const conflicts = [];
92
123
  const toAcquire = [];
124
+ const allowedSharedRead = [];
93
125
 
94
126
  for (let i = 0; i < normalized.length; i++) {
95
127
  const path = normalized[i];
96
128
  const existing = locks.get(path);
97
129
 
98
- if (existing && existing.workerId !== workerId && !isExpired(existing)) {
130
+ if (!existing || isExpired(existing)) {
131
+ toAcquire.push(path);
132
+ continue;
133
+ }
134
+
135
+ if (hasConflict(existing, workerId, leaseType)) {
99
136
  conflicts.push({ file: files[i], holder: existing.workerId });
100
137
  } else {
101
- toAcquire.push(path);
138
+ if (
139
+ !(
140
+ existing.workerId !== workerId &&
141
+ existing.leaseType === "shared-read" &&
142
+ leaseType === "shared-read"
143
+ )
144
+ ) {
145
+ toAcquire.push(path);
146
+ } else {
147
+ allowedSharedRead.push(path);
148
+ }
102
149
  }
103
150
  }
104
151
 
@@ -108,11 +155,15 @@ export function createSwarmLocks(opts = {}) {
108
155
 
109
156
  const ts = now();
110
157
  for (const path of toAcquire) {
111
- locks.set(path, { workerId, acquiredAt: ts });
158
+ locks.set(path, { workerId, acquiredAt: ts, leaseType, sessionMeta });
112
159
  }
113
160
 
114
161
  persist();
115
- return { ok: true, acquired: toAcquire, conflicts: [] };
162
+ return {
163
+ ok: true,
164
+ acquired: [...toAcquire, ...allowedSharedRead],
165
+ conflicts: [],
166
+ };
116
167
  }
117
168
 
118
169
  /**
@@ -136,15 +187,18 @@ export function createSwarmLocks(opts = {}) {
136
187
  * Check if a file write would violate any lease.
137
188
  * @param {string} workerId — the worker attempting the write
138
189
  * @param {string} filePath — the file being written
190
+ * @param {"exclusive" | "shared-read"} [leaseType="exclusive"] — requested lease type
139
191
  * @returns {{ allowed: boolean, holder?: string }}
140
192
  */
141
- function check(workerId, filePath) {
193
+ function check(workerId, filePath, leaseType = "exclusive") {
142
194
  pruneExpired();
143
195
  const path = normalizePath(filePath);
144
196
  const entry = locks.get(path);
197
+ const normalizedLeaseType = normalizeLeaseType(leaseType);
145
198
 
146
199
  if (!entry || isExpired(entry)) return { allowed: true };
147
- if (entry.workerId === workerId) return { allowed: true };
200
+ if (!hasConflict(entry, workerId, normalizedLeaseType))
201
+ return { allowed: true };
148
202
  return { allowed: false, holder: entry.workerId };
149
203
  }
150
204
 
@@ -173,7 +227,7 @@ export function createSwarmLocks(opts = {}) {
173
227
 
174
228
  /**
175
229
  * Get snapshot of all active locks.
176
- * @returns {Array<{ file: string, workerId: string, acquiredAt: number }>}
230
+ * @returns {Array<{ file: string, workerId: string, acquiredAt: number, leaseType: "exclusive" | "shared-read", sessionMeta: { sessionId: string, host: string, taskSummary: string } | null }>}
177
231
  */
178
232
  function snapshot() {
179
233
  pruneExpired();
@@ -181,6 +235,8 @@ export function createSwarmLocks(opts = {}) {
181
235
  file,
182
236
  workerId: entry.workerId,
183
237
  acquiredAt: entry.acquiredAt,
238
+ leaseType: normalizeLeaseType(entry.leaseType),
239
+ sessionMeta: normalizeSessionMeta(entry.sessionMeta),
184
240
  }));
185
241
  }
186
242
 
@@ -1,9 +1,13 @@
1
1
  // hub/team/swarm-reconciler.mjs — Redundant execution + result reconciliation
2
2
  // For critical shards: launches primary + verifier sessions, compares results,
3
3
  // applies conservative adoption (fewer changes wins) or HITL fallback.
4
+ // v2 (Synapse): parses X-Intent trailers to route complementary/contradictory
5
+ // commits through intent-aware paths before falling back to conservative logic.
4
6
 
5
7
  import { execFile } from "node:child_process";
6
8
 
9
+ import { classifyIntentPair, parseIntentTrailer } from "./swarm-intent.mjs";
10
+
7
11
  /**
8
12
  * Compare two shard results and decide which to accept.
9
13
  * Strategy: conservative adoption — the result with fewer changed files wins.
@@ -49,6 +53,66 @@ export async function reconcile(primaryResult, verifierResult, opts = {}) {
49
53
  return decision("primary", "identical", primaryResult);
50
54
  }
51
55
 
56
+ // Intent-aware classification: parse X-Intent trailers from both commits.
57
+ // When both commits carry an intent, route contradictory pairs to HITL and
58
+ // complementary (non-overlapping) pairs to a merge-friendly path. Missing or
59
+ // malformed trailers fall through to the existing divergence/conservative
60
+ // adoption logic below.
61
+ const [primaryMsg, verifierMsg] = await Promise.all([
62
+ getCommitMessage(primaryResult.branchName, rootDir),
63
+ getCommitMessage(verifierResult.branchName, rootDir),
64
+ ]);
65
+
66
+ const primaryIntent = parseIntentTrailer(primaryMsg);
67
+ const verifierIntent = parseIntentTrailer(verifierMsg);
68
+
69
+ if (primaryIntent && verifierIntent) {
70
+ const classification = classifyIntentPair(primaryIntent, verifierIntent);
71
+
72
+ if (classification.relation === "contradictory") {
73
+ return {
74
+ selected: "hitl",
75
+ reason: `intent_contradictory: ${classification.reason}`,
76
+ result: null,
77
+ requiresManualReview: true,
78
+ intentClassification: classification,
79
+ primaryIntent,
80
+ verifierIntent,
81
+ primary: {
82
+ filesChanged: primaryDiff.filesChanged,
83
+ linesChanged: primaryDiff.linesChanged,
84
+ },
85
+ verifier: {
86
+ filesChanged: verifierDiff.filesChanged,
87
+ linesChanged: verifierDiff.linesChanged,
88
+ },
89
+ };
90
+ }
91
+
92
+ if (classification.relation === "complementary") {
93
+ return {
94
+ selected: "complementary",
95
+ reason: `intent_complementary: ${classification.reason}`,
96
+ result: { primary: primaryResult, verifier: verifierResult },
97
+ requiresManualReview: false,
98
+ intentClassification: classification,
99
+ primaryIntent,
100
+ verifierIntent,
101
+ shouldAttemptMerge: true,
102
+ primary: {
103
+ filesChanged: primaryDiff.filesChanged,
104
+ linesChanged: primaryDiff.linesChanged,
105
+ },
106
+ verifier: {
107
+ filesChanged: verifierDiff.filesChanged,
108
+ linesChanged: verifierDiff.linesChanged,
109
+ },
110
+ };
111
+ }
112
+
113
+ // complementary-risky / independent → fall through to existing logic.
114
+ }
115
+
52
116
  // Compute divergence
53
117
  const divergence = Math.abs(
54
118
  primaryDiff.filesChanged - verifierDiff.filesChanged,
@@ -90,6 +154,22 @@ function decision(selected, reason, result) {
90
154
  };
91
155
  }
92
156
 
157
+ /**
158
+ * Read the HEAD commit message of a branch.
159
+ *
160
+ * @param {string} branch
161
+ * @param {string} cwd
162
+ * @returns {Promise<string>}
163
+ */
164
+ async function getCommitMessage(branch, cwd) {
165
+ try {
166
+ const msg = await gitExec(["log", "-1", "--format=%B", branch], cwd);
167
+ return msg;
168
+ } catch {
169
+ return "";
170
+ }
171
+ }
172
+
93
173
  /**
94
174
  * Get diff statistics for a branch relative to its merge-base.
95
175
  *
@@ -0,0 +1,207 @@
1
+ // hub/team/synapse-cli.mjs — CLI surface for Synapse v1.
2
+ // Reads persisted registry state + git log for the "status" and "why" commands.
3
+ // Hub integration comes later; for v1 we go straight to the JSON persist files
4
+ // so the CLI works even when Hub is offline.
5
+
6
+ import { execFile } from "node:child_process";
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join, resolve } from "node:path";
10
+
11
+ import { parseIntentTrailer } from "./swarm-intent.mjs";
12
+
13
+ const DEFAULT_REGISTRY_CANDIDATES = [
14
+ ".triflux/synapse-registry.json",
15
+ ".triflux/synapse/registry.json",
16
+ join(homedir(), ".claude", "cache", "tfx-hub", "synapse-registry.json"),
17
+ ];
18
+
19
+ function gitExec(args, cwd) {
20
+ return new Promise((res, rej) => {
21
+ execFile(
22
+ "git",
23
+ args,
24
+ { cwd, windowsHide: true, timeout: 15_000 },
25
+ (err, stdout) => {
26
+ if (err) rej(err);
27
+ else res(stdout);
28
+ },
29
+ );
30
+ });
31
+ }
32
+
33
+ function locateRegistryPath(explicit) {
34
+ if (explicit && existsSync(explicit)) return explicit;
35
+ for (const candidate of DEFAULT_REGISTRY_CANDIDATES) {
36
+ if (existsSync(candidate)) return candidate;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ function loadRegistrySnapshot(path) {
42
+ if (!path) return [];
43
+ try {
44
+ const raw = readFileSync(path, "utf8");
45
+ const parsed = JSON.parse(raw);
46
+ if (Array.isArray(parsed)) return parsed;
47
+ if (parsed && typeof parsed === "object") {
48
+ // Accept both array-form and object-form persist layouts.
49
+ if (Array.isArray(parsed.sessions)) return parsed.sessions;
50
+ return Object.values(parsed);
51
+ }
52
+ return [];
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ function formatStatus(sessions) {
59
+ if (!sessions.length) {
60
+ return "no active sessions (synapse-registry empty)";
61
+ }
62
+ const rows = [];
63
+ rows.push("SESSION HOST BRANCH DIRTY STATE TASK");
64
+ rows.push("───────────────────── ────── ──────── ────── ─────── ─────────────");
65
+ for (const s of sessions) {
66
+ const id = (s.sessionId || "?").padEnd(21).slice(0, 21);
67
+ const host = (s.host || "?").padEnd(6).slice(0, 6);
68
+ const branch = (s.branch || "?").padEnd(8).slice(0, 8);
69
+ const dirty = String((s.dirtyFiles || []).length).padEnd(6).slice(0, 6);
70
+ const state = (s.status || "active").padEnd(7).slice(0, 7);
71
+ const task = (s.taskSummary || "").slice(0, 40);
72
+ rows.push(`${id} ${host} ${branch} ${dirty} ${state} ${task}`);
73
+ }
74
+ return rows.join("\n");
75
+ }
76
+
77
+ /**
78
+ * `tfx synapse status` — list active swarm sessions from persisted registry.
79
+ */
80
+ export async function cmdSynapseStatus(args = [], opts = {}) {
81
+ const jsonOut = args.includes("--json") || opts.json;
82
+ const explicit = extractFlag(args, "--registry");
83
+ const path = locateRegistryPath(explicit);
84
+ const sessions = loadRegistrySnapshot(path);
85
+
86
+ if (jsonOut) {
87
+ process.stdout.write(
88
+ JSON.stringify({ registry: path, count: sessions.length, sessions }, null, 2) + "\n",
89
+ );
90
+ return;
91
+ }
92
+
93
+ if (!path) {
94
+ process.stdout.write(
95
+ "no registry file found (looked for .triflux/synapse-registry.json)\n",
96
+ );
97
+ return;
98
+ }
99
+ process.stdout.write(`registry: ${path}\n`);
100
+ process.stdout.write(`${formatStatus(sessions)}\n`);
101
+ }
102
+
103
+ function extractFlag(args, name) {
104
+ const idx = args.indexOf(name);
105
+ if (idx < 0) return null;
106
+ return args[idx + 1] || null;
107
+ }
108
+
109
+ async function resolveCommitForPath(path, cwd) {
110
+ try {
111
+ const out = await gitExec(["log", "-1", "--format=%H", "--", path], cwd);
112
+ return out.trim();
113
+ } catch {
114
+ return "";
115
+ }
116
+ }
117
+
118
+ async function readCommitMessage(sha, cwd) {
119
+ try {
120
+ return await gitExec(["log", "-1", "--format=%B", sha], cwd);
121
+ } catch {
122
+ return "";
123
+ }
124
+ }
125
+
126
+ async function readCommitMeta(sha, cwd) {
127
+ try {
128
+ const out = await gitExec(
129
+ ["log", "-1", "--format=%h%x1f%an%x1f%ad%x1f%s", "--date=iso", sha],
130
+ cwd,
131
+ );
132
+ const [short, author, date, subject] = out.trim().split("\x1f");
133
+ return { short, author, date, subject };
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * `tfx why <path>` — show the X-Intent of the last commit touching <path>.
141
+ */
142
+ export async function cmdSynapseWhy(args = [], opts = {}) {
143
+ const jsonOut = args.includes("--json") || opts.json;
144
+ const cwd = process.cwd();
145
+ const positional = args.filter((a) => !a.startsWith("--"));
146
+ const target = positional[0];
147
+
148
+ if (!target) {
149
+ const err = { error: "path required", usage: "tfx why <path>" };
150
+ if (jsonOut) process.stdout.write(JSON.stringify(err) + "\n");
151
+ else process.stderr.write("tfx why <path> — path 인자 필요\n");
152
+ process.exitCode = 1;
153
+ return;
154
+ }
155
+
156
+ const resolved = resolve(cwd, target);
157
+ if (!existsSync(resolved)) {
158
+ const err = { error: "path not found", path: target };
159
+ if (jsonOut) process.stdout.write(JSON.stringify(err) + "\n");
160
+ else process.stderr.write(`tfx why: 파일 없음: ${target}\n`);
161
+ process.exitCode = 1;
162
+ return;
163
+ }
164
+
165
+ const sha = await resolveCommitForPath(target, cwd);
166
+ if (!sha) {
167
+ const result = { path: target, commit: null, intent: null };
168
+ if (jsonOut) process.stdout.write(JSON.stringify(result) + "\n");
169
+ else process.stdout.write(`no commit touches ${target}\n`);
170
+ return;
171
+ }
172
+
173
+ const [message, meta] = await Promise.all([
174
+ readCommitMessage(sha, cwd),
175
+ readCommitMeta(sha, cwd),
176
+ ]);
177
+ const intent = parseIntentTrailer(message);
178
+
179
+ if (jsonOut) {
180
+ process.stdout.write(
181
+ JSON.stringify({ path: target, commit: { sha, ...meta }, intent }, null, 2) + "\n",
182
+ );
183
+ return;
184
+ }
185
+
186
+ const header = meta
187
+ ? `${meta.short} ${meta.author} ${meta.date} ${meta.subject}`
188
+ : sha;
189
+ process.stdout.write(`${target}\n`);
190
+ process.stdout.write(` last commit: ${header}\n`);
191
+ if (intent) {
192
+ process.stdout.write(` intent.scope: ${intent.scope || "-"}\n`);
193
+ process.stdout.write(` intent.action: ${intent.action || "-"}\n`);
194
+ process.stdout.write(` intent.reason: ${intent.reason || "-"}\n`);
195
+ if (Array.isArray(intent.touches) && intent.touches.length) {
196
+ process.stdout.write(` intent.touches: ${intent.touches.join(", ")}\n`);
197
+ }
198
+ if (intent.invariant) {
199
+ process.stdout.write(` intent.invariant: ${intent.invariant}\n`);
200
+ }
201
+ if (intent.conflictsWith) {
202
+ process.stdout.write(` intent.conflicts: ${intent.conflictsWith}\n`);
203
+ }
204
+ } else {
205
+ process.stdout.write(" intent: (no X-Intent trailer)\n");
206
+ }
207
+ }