@fenglimg/fabric-server 1.7.0 → 1.8.0-rc.2

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.
@@ -1,7 +1,5 @@
1
1
  import {
2
2
  AGENTS_MD_RESOURCE_URI,
3
- AgentsMetaFileMissingError,
4
- AgentsMetaInvalidError,
5
3
  EVENT_LEDGER_PATH,
6
4
  LEDGER_PATH,
7
5
  LEGACY_LEDGER_PATH,
@@ -11,12 +9,13 @@ import {
11
9
  getLedgerPath,
12
10
  getLegacyLedgerPath,
13
11
  getRules,
12
+ invalidateRuleSyncCooldown,
14
13
  isNodeError,
15
14
  readAgentsMeta,
16
15
  readEventLedger,
17
16
  runDoctorReport,
18
17
  sha256
19
- } from "./chunk-PTFSYO4Y.js";
18
+ } from "./chunk-EGGZFXMO.js";
20
19
 
21
20
  // src/http.ts
22
21
  import { randomUUID as randomUUID2 } from "crypto";
@@ -28,6 +27,7 @@ import {
28
27
  import chokidar2 from "chokidar";
29
28
 
30
29
  // src/api/_error.ts
30
+ import { FabricError } from "@fenglimg/fabric-shared/errors";
31
31
  function sendError(res, status, code, message, details) {
32
32
  const payload = {
33
33
  error: {
@@ -48,49 +48,15 @@ function sendUnknownError(res, error) {
48
48
  sendError(res, normalized.status, normalized.code, normalized.message, normalized.details);
49
49
  }
50
50
  function normalizeApiError(error) {
51
- if (error instanceof Error && "status" in error && "code" in error && typeof error.status === "number" && typeof error.code === "string") {
51
+ if (error instanceof FabricError) {
52
52
  return {
53
- status: error.status,
53
+ status: error.httpStatus,
54
54
  code: error.code,
55
- message: error.message
56
- };
57
- }
58
- if (error instanceof AgentsMetaFileMissingError) {
59
- return {
60
- status: 404,
61
- code: error.code,
62
- message: error.message
63
- };
64
- }
65
- if (error instanceof AgentsMetaInvalidError) {
66
- return {
67
- status: 500,
68
- code: error.code,
69
- message: error.message
55
+ message: error.message,
56
+ details: error.details
70
57
  };
71
58
  }
72
59
  if (error instanceof Error) {
73
- if (error.message.startsWith("Path escapes project root:")) {
74
- return {
75
- status: 403,
76
- code: "PATH_OUTSIDE_PROJECT_ROOT",
77
- message: error.message
78
- };
79
- }
80
- if (error.message.startsWith("Cannot find human lock entry:")) {
81
- return {
82
- status: 404,
83
- code: "HUMAN_LOCK_ENTRY_NOT_FOUND",
84
- message: error.message
85
- };
86
- }
87
- if (error.message.startsWith("Cannot find ledger entry:")) {
88
- return {
89
- status: 404,
90
- code: "LEDGER_ENTRY_NOT_FOUND",
91
- message: error.message
92
- };
93
- }
94
60
  return {
95
61
  status: 500,
96
62
  code: "INTERNAL_ERROR",
@@ -213,7 +179,7 @@ function parseLedgerLine(line, index) {
213
179
  }
214
180
  }
215
181
  async function readLedgerFromEventLedger(projectRoot) {
216
- const events = await readEventLedger(projectRoot);
182
+ const { events } = await readEventLedger(projectRoot);
217
183
  const grouped = /* @__PURE__ */ new Map();
218
184
  for (const event of events) {
219
185
  const entry = projectLedgerEvent(event);
@@ -687,17 +653,26 @@ import { historyStateQuerySchema } from "@fenglimg/fabric-shared";
687
653
  import { execFile } from "child_process";
688
654
  import { promisify } from "util";
689
655
  import { agentsMetaSchema as agentsMetaSchema2 } from "@fenglimg/fabric-shared";
656
+ import { IOFabricError, RuleError } from "@fenglimg/fabric-shared/errors";
690
657
  var execFileAsync = promisify(execFile);
691
658
  var AGENTS_META_GIT_PATH = ".fabric/agents.meta.json";
692
- var HistoryReplayError = class extends Error {
693
- constructor(message, code, status) {
694
- super(message);
695
- this.code = code;
696
- this.status = status;
697
- this.name = "HistoryReplayError";
698
- }
699
- code;
700
- status;
659
+ var HistoryStateNotFoundError = class extends IOFabricError {
660
+ code = "HISTORY_STATE_NOT_FOUND";
661
+ httpStatus = 404;
662
+ constructor(message, opts) {
663
+ super(message, {
664
+ actionHint: opts?.actionHint ?? "Ensure the ledger exists and the requested timestamp or entry ID is within its range"
665
+ });
666
+ }
667
+ };
668
+ var LedgerEntryNotFoundError = class extends RuleError {
669
+ code = "LEDGER_ENTRY_NOT_FOUND";
670
+ httpStatus = 404;
671
+ constructor(message, opts) {
672
+ super(message, {
673
+ actionHint: opts?.actionHint ?? "Verify the ledger entry ID exists in the current ledger"
674
+ });
675
+ }
701
676
  };
702
677
  async function rehydrateAgentsMetaAt(projectRoot, target) {
703
678
  const ledger = await readLedger(projectRoot);
@@ -705,10 +680,8 @@ async function rehydrateAgentsMetaAt(projectRoot, target) {
705
680
  const replayedEntries = ledger.slice(0, selectedIndex + 1);
706
681
  const selectedEntry = replayedEntries.at(-1);
707
682
  if (selectedEntry === void 0) {
708
- throw new HistoryReplayError(
709
- "Cannot rehydrate history state because the ledger is empty.",
710
- "HISTORY_STATE_NOT_FOUND",
711
- 404
683
+ throw new HistoryStateNotFoundError(
684
+ "Cannot rehydrate history state because the ledger is empty."
712
685
  );
713
686
  }
714
687
  const commitCandidates = collectCommitCandidates(replayedEntries);
@@ -743,10 +716,8 @@ function resolveTargetIndex(ledger, target) {
743
716
  if ("ledgerEntryId" in target) {
744
717
  const index = ledger.findIndex((entry) => entry.id === target.ledgerEntryId);
745
718
  if (index === -1) {
746
- throw new HistoryReplayError(
747
- `Cannot find ledger entry: ${target.ledgerEntryId}`,
748
- "LEDGER_ENTRY_NOT_FOUND",
749
- 404
719
+ throw new LedgerEntryNotFoundError(
720
+ `Cannot find ledger entry: ${target.ledgerEntryId}`
750
721
  );
751
722
  }
752
723
  return index;
@@ -756,10 +727,8 @@ function resolveTargetIndex(ledger, target) {
756
727
  return index;
757
728
  }
758
729
  }
759
- throw new HistoryReplayError(
760
- `Cannot find ledger entry at or before timestamp: ${new Date(target.timestamp).toISOString()}`,
761
- "HISTORY_STATE_NOT_FOUND",
762
- 404
730
+ throw new HistoryStateNotFoundError(
731
+ `Cannot find ledger entry at or before timestamp: ${new Date(target.timestamp).toISOString()}`
763
732
  );
764
733
  }
765
734
  function collectCommitCandidates(entries) {
@@ -869,7 +838,7 @@ async function annotateIntent(projectRoot, input) {
869
838
  const entries = await readLedger(projectRoot);
870
839
  const parentEntry = entries.find((entry2) => entry2.id === input.ledger_entry_id);
871
840
  if (parentEntry === void 0) {
872
- throw new Error(`Cannot find ledger entry: ${input.ledger_entry_id}`);
841
+ throw new LedgerEntryNotFoundError(`Cannot find ledger entry: ${input.ledger_entry_id}`);
873
842
  }
874
843
  const lastEntry = entries[entries.length - 1];
875
844
  if (lastEntry?.source === "human" && lastEntry.parent_ledger_entry_id === input.ledger_entry_id && lastEntry.annotation === input.annotation) {
@@ -1205,7 +1174,7 @@ var JsonlEventStore = class {
1205
1174
  return streamId;
1206
1175
  }
1207
1176
  async readEvents() {
1208
- const eventLedgerEvents = await readEventLedger(this.projectRoot);
1177
+ const { events: eventLedgerEvents } = await readEventLedger(this.projectRoot);
1209
1178
  const projectedEvents = eventLedgerEvents.flatMap((event) => {
1210
1179
  if (event.event_type !== "mcp_event") {
1211
1180
  return [];
@@ -1240,6 +1209,33 @@ var JsonlEventStore = class {
1240
1209
  return raw.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0).map((line) => parseStoredMcpEvent(line)).filter((event) => event !== null);
1241
1210
  }
1242
1211
  };
1212
+ function handleCacheWatcherEvent(relativePath, projectRoot, sessions, timers) {
1213
+ const normalized = relativePath.replaceAll("\\", "/");
1214
+ if (normalized === ".fabric/agents.meta.json") {
1215
+ contextCache.invalidate("file_watch", projectRoot);
1216
+ clearTimeout(timers.getToolListTimer());
1217
+ timers.setToolListTimer(
1218
+ setTimeout(() => {
1219
+ notifyAllSessions(sessions, "tools/list_changed");
1220
+ }, NOTIFY_DEBOUNCE_MS)
1221
+ );
1222
+ return;
1223
+ }
1224
+ if (normalized === ".fabric/bootstrap/README.md") {
1225
+ contextCache.invalidate("file_watch", projectRoot);
1226
+ clearTimeout(timers.getAgentsMdTimer());
1227
+ timers.setAgentsMdTimer(
1228
+ setTimeout(() => {
1229
+ notifyAllSessions(sessions, "resource_updated", AGENTS_MD_RESOURCE_URI);
1230
+ }, NOTIFY_DEBOUNCE_MS)
1231
+ );
1232
+ return;
1233
+ }
1234
+ if (normalized.startsWith(".fabric/rules/") && normalized.endsWith(".md")) {
1235
+ contextCache.invalidate("file_watch", projectRoot);
1236
+ invalidateRuleSyncCooldown(projectRoot);
1237
+ }
1238
+ }
1243
1239
  function createFabricHttpApp(options) {
1244
1240
  const { projectRoot, host = DEFAULT_HOST, authToken, dashboardDistPath, dev } = options;
1245
1241
  const app = createMcpExpressApp({ host });
@@ -1247,7 +1243,11 @@ function createFabricHttpApp(options) {
1247
1243
  const sessions = /* @__PURE__ */ new Map();
1248
1244
  process.env.FABRIC_PROJECT_ROOT = projectRoot;
1249
1245
  const cacheWatcher = chokidar2.watch(
1250
- [".fabric/agents.meta.json", ".fabric/bootstrap/README.md"],
1246
+ [
1247
+ ".fabric/agents.meta.json",
1248
+ ".fabric/bootstrap/README.md",
1249
+ ".fabric/rules/**/*.md"
1250
+ ],
1251
1251
  {
1252
1252
  cwd: projectRoot,
1253
1253
  ignoreInitial: true,
@@ -1259,22 +1259,21 @@ function createFabricHttpApp(options) {
1259
1259
  );
1260
1260
  let agentsMdNotifyTimer;
1261
1261
  let toolListNotifyTimer;
1262
- cacheWatcher.on("change", (relativePath) => {
1263
- const normalized = relativePath.replaceAll("\\", "/");
1264
- if (normalized === ".fabric/agents.meta.json") {
1265
- contextCache.invalidate("file_watch", projectRoot);
1266
- clearTimeout(toolListNotifyTimer);
1267
- toolListNotifyTimer = setTimeout(() => {
1268
- notifyAllSessions(sessions, "tools/list_changed");
1269
- }, NOTIFY_DEBOUNCE_MS);
1270
- } else if (normalized === ".fabric/bootstrap/README.md") {
1271
- contextCache.invalidate("file_watch", projectRoot);
1272
- clearTimeout(agentsMdNotifyTimer);
1273
- agentsMdNotifyTimer = setTimeout(() => {
1274
- notifyAllSessions(sessions, "resource_updated", AGENTS_MD_RESOURCE_URI);
1275
- }, NOTIFY_DEBOUNCE_MS);
1276
- }
1277
- });
1262
+ const onCacheWatcherEvent = (relativePath) => {
1263
+ handleCacheWatcherEvent(relativePath, projectRoot, sessions, {
1264
+ getAgentsMdTimer: () => agentsMdNotifyTimer,
1265
+ getToolListTimer: () => toolListNotifyTimer,
1266
+ setAgentsMdTimer: (t) => {
1267
+ agentsMdNotifyTimer = t;
1268
+ },
1269
+ setToolListTimer: (t) => {
1270
+ toolListNotifyTimer = t;
1271
+ }
1272
+ });
1273
+ };
1274
+ cacheWatcher.on("change", onCacheWatcherEvent);
1275
+ cacheWatcher.on("add", onCacheWatcherEvent);
1276
+ cacheWatcher.on("unlink", onCacheWatcherEvent);
1278
1277
  let disposed = false;
1279
1278
  app.dispose = async () => {
1280
1279
  if (disposed) {
@@ -1407,5 +1406,5 @@ function isNodeError3(error) {
1407
1406
  }
1408
1407
  export {
1409
1408
  createFabricHttpApp,
1410
- notifyAllSessions
1409
+ handleCacheWatcherEvent
1411
1410
  };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,18 @@
1
1
  import { Server } from 'node:http';
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { AgentsMeta, RuleTestIndex, AgentsLayer, AgentsTopologyType } from '@fenglimg/fabric-shared';
4
+ import { IOFabricError } from '@fenglimg/fabric-shared/errors';
5
+
6
+ interface InFlightTracker {
7
+ enter(requestId: string): void;
8
+ exit(requestId: string): void;
9
+ drain(deadlineMs: number): Promise<{
10
+ drained: number;
11
+ timed_out: number;
12
+ }>;
13
+ size(): number;
14
+ }
15
+ declare function createInFlightTracker(): InFlightTracker;
4
16
 
5
17
  declare const LEDGER_PATH = ".fabric/.intent-ledger.jsonl";
6
18
  declare const LEGACY_LEDGER_PATH = ".intent-ledger.jsonl";
@@ -10,7 +22,7 @@ declare function getLegacyLedgerPath(projectRoot: string): string;
10
22
  declare function getEventLedgerPath(projectRoot: string): string;
11
23
 
12
24
  type DoctorStatus = "ok" | "warn" | "error";
13
- type DoctorIssueKind = "fixable_error" | "manual_error" | "warning";
25
+ type DoctorIssueKind = "fixable_error" | "manual_error" | "warning" | "info";
14
26
  type DoctorCheck = {
15
27
  name: string;
16
28
  status: DoctorStatus;
@@ -18,6 +30,7 @@ type DoctorCheck = {
18
30
  kind?: DoctorIssueKind;
19
31
  code?: string;
20
32
  fixable?: boolean;
33
+ actionHint?: string;
21
34
  };
22
35
  type DoctorIssue = {
23
36
  code: string;
@@ -43,6 +56,7 @@ type DoctorSummary = {
43
56
  fixableErrorCount: number;
44
57
  manualErrorCount: number;
45
58
  warningCount: number;
59
+ infoCount: number;
46
60
  targetFiles: Record<string, boolean>;
47
61
  };
48
62
  type DoctorReport = {
@@ -51,6 +65,7 @@ type DoctorReport = {
51
65
  fixable_errors: DoctorIssue[];
52
66
  manual_errors: DoctorIssue[];
53
67
  warnings: DoctorIssue[];
68
+ infos: DoctorIssue[];
54
69
  summary: DoctorSummary;
55
70
  };
56
71
  type DoctorFixReport = {
@@ -88,8 +103,137 @@ declare function stableStringify(value: unknown): string;
88
103
  /** MCP resource URI for the project's bootstrap README (L0 rules) file. */
89
104
  declare const AGENTS_MD_RESOURCE_URI = "fabric://bootstrap-readme";
90
105
 
91
- declare function createFabricServer(): McpServer;
106
+ /**
107
+ * Synchronously fsync the event ledger file to ensure OS page-cache buffers are
108
+ * flushed to durable storage. Must be called AFTER in-flight drain but BEFORE
109
+ * server.close() — Gemini G1 ordering requirement.
110
+ *
111
+ * Uses sync APIs intentionally: we are inside a signal handler and need
112
+ * guaranteed completion before process.exit().
113
+ */
114
+ declare function flushAndSyncEventLedger(projectRoot: string): void;
115
+
116
+ /**
117
+ * rule-sync.ts — Rule-sync orchestrator framework (R28, TASK-011)
118
+ *
119
+ * Public surface: ensureRulesFresh, reconcileRules + exported types.
120
+ * Internal helpers are co-located in this file.
121
+ * Does NOT wire any consumers (MCP tools, doctor, watchers).
122
+ *
123
+ * Distinction between the two public entry points:
124
+ *
125
+ * - `ensureRulesFresh`: detects drift, emits ledger events, invalidates cache.
126
+ * Does NOT rewrite agents.meta.json. Optimised for hot-path consumers (MCP tools).
127
+ *
128
+ * - `reconcileRules`: full scan + rewrites agents.meta.json (via rule-meta-builder)
129
+ * + emits ledger events. Used by startup (TASK-022) and doctor repair (TASK-023).
130
+ */
131
+ interface RuleSyncOptions {
132
+ mode?: "incremental" | "full";
133
+ /** When true, invalid frontmatter throws RuleValidationError (default: false — collect as warning). */
134
+ throwOnInvalidFrontmatter?: boolean;
135
+ }
136
+ interface StructuredWarning {
137
+ code: string;
138
+ file: string;
139
+ line?: number;
140
+ action_hint: string;
141
+ }
142
+ /**
143
+ * Granular ledger event shape for rule-sync operations.
144
+ * These are returned in RuleSyncReport and also appended to the event ledger
145
+ * using the nearest available ledger event type (rule_drift_detected /
146
+ * baseline_synced). The shape below is what callers receive in `.events`.
147
+ */
148
+ interface RuleSyncLedgerEvent {
149
+ type: "rule_content_changed" | "rule_added" | "rule_removed";
150
+ stable_id: string;
151
+ path: string;
152
+ prev_hash: string | null;
153
+ new_hash: string | null;
154
+ changed_fields: string[];
155
+ source: "ensureRulesFresh" | "reconcileRules";
156
+ }
157
+ /** Alias so the public API says LedgerEvent (as documented). */
158
+ type LedgerEvent = RuleSyncLedgerEvent;
159
+ interface RuleSyncReport {
160
+ status: "fresh" | "reconciled" | "errors";
161
+ events: LedgerEvent[];
162
+ warnings: StructuredWarning[];
163
+ reconciled_files?: string[];
164
+ }
165
+ /**
166
+ * Detects drift between disk and agents.meta.json, emits ledger events, and
167
+ * invalidates the cache. Does NOT rewrite agents.meta.json. Optimised for
168
+ * hot-path consumers (MCP tools).
169
+ */
170
+ declare function ensureRulesFresh(projectRoot: string, opts?: RuleSyncOptions): Promise<RuleSyncReport>;
171
+ interface ReconcileRulesOptions {
172
+ /** Identifies who triggered the reconcile; controls which summary ledger event is written. */
173
+ trigger?: "startup" | "doctor" | "manual";
174
+ }
175
+ /**
176
+ * Full scan + rewrites agents.meta.json with ground-truth disk state + emits
177
+ * ledger events. Used by startup (TASK-022) and doctor repair (TASK-023).
178
+ * Returns reconciled_files listing all paths whose meta was updated.
179
+ *
180
+ * When `opts.trigger` is `'startup'`, a `meta_reconciled_on_startup` summary
181
+ * ledger event is appended after per-file drift events. Other trigger values
182
+ * append a `meta_reconciled` event. Omitting the trigger skips the summary.
183
+ */
184
+ declare function reconcileRules(projectRoot: string, opts?: ReconcileRulesOptions): Promise<RuleSyncReport>;
185
+
186
+ declare class ServeLockHeldError extends IOFabricError {
187
+ readonly code = "SERVE_LOCK_HELD";
188
+ readonly httpStatus = 423;
189
+ }
190
+ interface LockState {
191
+ pid: number;
192
+ acquiredAt: number;
193
+ host?: string;
194
+ }
195
+ interface AcquireOptions {
196
+ force?: boolean;
197
+ }
198
+ declare function acquireLock(projectRoot: string, opts?: AcquireOptions): void;
199
+ declare function releaseLock(projectRoot: string): void;
200
+ declare function readLockState(projectRoot: string): LockState | null;
201
+ declare function checkLockOrThrow(projectRoot: string, opts?: AcquireOptions): void;
202
+
203
+ /**
204
+ * Returns an info-level startup message when CLAUDE.md or AGENTS.md exist at
205
+ * the project root, or null when neither is present.
206
+ *
207
+ * Extracted as a pure helper so unit tests can exercise it without spawning
208
+ * a full server (TASK-034).
209
+ */
210
+ declare function formatPreexistingRootMessage(projectRoot: string): string | null;
211
+
212
+ declare function createFabricServer(tracker?: InFlightTracker): McpServer;
92
213
  declare function startStdioServer(): Promise<void>;
214
+ /**
215
+ * Dependencies for the shutdown handler factory. Tests inject `exit` to assert
216
+ * exit-code behavior without terminating the test process.
217
+ */
218
+ interface ShutdownHandlerDeps {
219
+ signal: NodeJS.Signals;
220
+ tracker: InFlightTracker;
221
+ projectRoot: string;
222
+ closeServer: () => Promise<void>;
223
+ /** Override for tests; defaults to `process.exit`. */
224
+ exit?: (code: number) => never;
225
+ /** Override for tests; defaults to 5000ms (Gemini G1). */
226
+ drainDeadlineMs?: number;
227
+ }
228
+ /**
229
+ * Builds a same-signal shutdown handler implementing server.md I1:
230
+ * - First invocation: drain in-flight (5s) → fsync ledger → close server → exit(0)
231
+ * - Second invocation of the same signal (while first is in flight): exit(1)
232
+ *
233
+ * Each call to this factory returns an independent handler with its own
234
+ * `invoked` flag, so per-signal dedup is isolated.
235
+ */
236
+ declare function createShutdownHandler(deps: ShutdownHandlerDeps): () => void;
93
237
  declare function startHttpServer(options: {
94
238
  port: number;
95
239
  projectRoot: string;
@@ -99,4 +243,4 @@ declare function startHttpServer(options: {
99
243
  dev?: boolean;
100
244
  }): Promise<Server>;
101
245
 
102
- export { AGENTS_MD_RESOURCE_URI, type DoctorFixReport, type DoctorIssue, type DoctorReport, EVENT_LEDGER_PATH, LEDGER_PATH, LEGACY_LEDGER_PATH, type RuleMetaBuildResult, type RuleMetaBuildSource, type WriteRuleMetaOptions, buildRuleMeta, computeRuleTestIndex, computeRulesBasedAgentsMeta, createFabricServer, deriveRuleMetaLayer, deriveRuleMetaTopologyType, getEventLedgerPath, getLedgerPath, getLegacyLedgerPath, isSameRuleTestIndex, runDoctorFix, runDoctorReport, stableStringify, startHttpServer, startStdioServer, writeRuleMeta };
246
+ export { AGENTS_MD_RESOURCE_URI, type AcquireOptions, type DoctorFixReport, type DoctorIssue, type DoctorReport, EVENT_LEDGER_PATH, type InFlightTracker, LEDGER_PATH, LEGACY_LEDGER_PATH, type LedgerEvent, type LockState, type ReconcileRulesOptions, type RuleMetaBuildResult, type RuleMetaBuildSource, type RuleSyncLedgerEvent, type RuleSyncOptions, type RuleSyncReport, ServeLockHeldError, type ShutdownHandlerDeps, type StructuredWarning, type WriteRuleMetaOptions, acquireLock, buildRuleMeta, checkLockOrThrow, computeRuleTestIndex, computeRulesBasedAgentsMeta, createFabricServer, createInFlightTracker, createShutdownHandler, deriveRuleMetaLayer, deriveRuleMetaTopologyType, ensureRulesFresh, flushAndSyncEventLedger, formatPreexistingRootMessage, getEventLedgerPath, getLedgerPath, getLegacyLedgerPath, isSameRuleTestIndex, readLockState, reconcileRules, releaseLock, runDoctorFix, runDoctorReport, stableStringify, startHttpServer, startStdioServer, writeRuleMeta };