@indigoai-us/hq-cloud 6.14.17 → 6.14.19
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/.github/workflows/ci.yml +35 -0
- package/dist/active-company.d.ts +43 -0
- package/dist/active-company.d.ts.map +1 -0
- package/dist/active-company.js +132 -0
- package/dist/active-company.js.map +1 -0
- package/dist/active-company.test.d.ts +2 -0
- package/dist/active-company.test.d.ts.map +1 -0
- package/dist/active-company.test.js +149 -0
- package/dist/active-company.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +7 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +21 -1
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts +27 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +33 -4
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-planning.test.d.ts +2 -0
- package/dist/bin/sync-runner-planning.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-planning.test.js +115 -0
- package/dist/bin/sync-runner-planning.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts +10 -2
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +201 -25
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +14 -2
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +39 -9
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +114 -20
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +852 -8
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/rescue-core.js +27 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-drop-dir-symlink.test.d.ts +2 -0
- package/dist/cli/rescue-drop-dir-symlink.test.d.ts.map +1 -0
- package/dist/cli/rescue-drop-dir-symlink.test.js +206 -0
- package/dist/cli/rescue-drop-dir-symlink.test.js.map +1 -0
- package/dist/cli/share.d.ts +15 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +100 -55
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +119 -6
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +19 -25
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +121 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +3 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +13 -2
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +26 -1
- package/dist/journal.test.js.map +1 -1
- package/dist/manifest-reconcile.d.ts +118 -5
- package/dist/manifest-reconcile.d.ts.map +1 -1
- package/dist/manifest-reconcile.js +319 -62
- package/dist/manifest-reconcile.js.map +1 -1
- package/dist/manifest-reconcile.test.js +824 -2
- package/dist/manifest-reconcile.test.js.map +1 -1
- package/dist/personal-vault.d.ts +12 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +20 -0
- package/dist/personal-vault.js.map +1 -1
- package/dist/qmd-reindex.d.ts +88 -11
- package/dist/qmd-reindex.d.ts.map +1 -1
- package/dist/qmd-reindex.js +354 -65
- package/dist/qmd-reindex.js.map +1 -1
- package/dist/qmd-reindex.test.d.ts +4 -0
- package/dist/qmd-reindex.test.d.ts.map +1 -1
- package/dist/qmd-reindex.test.js +285 -3
- package/dist/qmd-reindex.test.js.map +1 -1
- package/dist/s3.d.ts +13 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +34 -1
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +60 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/telemetry.d.ts +22 -0
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +79 -0
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +117 -1
- package/dist/telemetry.test.js.map +1 -1
- package/dist/watcher.d.ts +26 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +75 -12
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +1 -1
- package/src/active-company.test.ts +188 -0
- package/src/active-company.ts +168 -0
- package/src/bin/sync-runner-company.ts +29 -1
- package/src/bin/sync-runner-planning.test.ts +131 -0
- package/src/bin/sync-runner-planning.ts +60 -7
- package/src/bin/sync-runner-watch-loop.ts +322 -32
- package/src/bin/sync-runner-watch-routes.ts +14 -1
- package/src/bin/sync-runner.test.ts +1021 -16
- package/src/bin/sync-runner.ts +179 -28
- package/src/cli/rescue-core.ts +26 -1
- package/src/cli/rescue-drop-dir-symlink.test.ts +224 -0
- package/src/cli/share.test.ts +150 -6
- package/src/cli/share.ts +143 -48
- package/src/cli/sync.test.ts +150 -1
- package/src/cli/sync.ts +25 -29
- package/src/journal.test.ts +45 -0
- package/src/journal.ts +18 -1
- package/src/manifest-reconcile.test.ts +1019 -3
- package/src/manifest-reconcile.ts +424 -66
- package/src/personal-vault.ts +23 -0
- package/src/qmd-reindex.test.ts +324 -4
- package/src/qmd-reindex.ts +414 -76
- package/src/s3.test.ts +79 -0
- package/src/s3.ts +48 -1
- package/src/telemetry.test.ts +128 -0
- package/src/telemetry.ts +80 -0
- package/src/watcher.ts +111 -14
- package/test/e2e/watcher-real-chokidar.test.ts +62 -2
- package/test/e2e/watcher-recursive-backend.test.ts +67 -1
- package/test/joiner-manifest-reconcile.integration.test.ts +283 -0
package/src/s3.ts
CHANGED
|
@@ -321,6 +321,53 @@ function removeTempPath(tempPath: string): void {
|
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
export interface CreateStagedSymlinkOps {
|
|
325
|
+
platform: NodeJS.Platform;
|
|
326
|
+
symlink(target: string, linkPath: string, type?: fs.symlink.Type): void;
|
|
327
|
+
statIsDirectory(absPath: string): boolean | undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const DEFAULT_CREATE_STAGED_SYMLINK_OPS: CreateStagedSymlinkOps = {
|
|
331
|
+
platform: process.platform,
|
|
332
|
+
symlink: (target, linkPath, type) =>
|
|
333
|
+
fs.symlinkSync(target, linkPath, type),
|
|
334
|
+
statIsDirectory: (absPath) => {
|
|
335
|
+
try {
|
|
336
|
+
return fs.statSync(absPath).isDirectory();
|
|
337
|
+
} catch {
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Create a fully-staged symlink without requiring Windows Developer Mode.
|
|
345
|
+
* NTFS symlinks fail with EPERM without SeCreateSymbolicLinkPrivilege, while
|
|
346
|
+
* directory junctions need no privilege but require an absolute target. HQ
|
|
347
|
+
* vault links are directory overlays, so missing targets default to junctions.
|
|
348
|
+
* See Sentry HQ-DESKTOP-3M.
|
|
349
|
+
*/
|
|
350
|
+
export function createStagedSymlink(
|
|
351
|
+
target: string,
|
|
352
|
+
linkPath: string,
|
|
353
|
+
ops: CreateStagedSymlinkOps = DEFAULT_CREATE_STAGED_SYMLINK_OPS,
|
|
354
|
+
): void {
|
|
355
|
+
if (ops.platform !== "win32") {
|
|
356
|
+
ops.symlink(target, linkPath);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const absTarget = path.isAbsolute(target)
|
|
361
|
+
? target
|
|
362
|
+
: path.resolve(path.dirname(linkPath), target);
|
|
363
|
+
if (ops.statIsDirectory(absTarget) === false) {
|
|
364
|
+
ops.symlink(target, linkPath, "file");
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
ops.symlink(absTarget, linkPath, "junction");
|
|
369
|
+
}
|
|
370
|
+
|
|
324
371
|
export interface ReplaceStagedPathOps {
|
|
325
372
|
lstat(path: string): fs.Stats;
|
|
326
373
|
rename(from: string, to: string): void;
|
|
@@ -1053,7 +1100,7 @@ export async function downloadFile(
|
|
|
1053
1100
|
|
|
1054
1101
|
const tempPath = downloadTempPath(localPath);
|
|
1055
1102
|
try {
|
|
1056
|
-
|
|
1103
|
+
createStagedSymlink(symlinkTarget, tempPath);
|
|
1057
1104
|
options.beforeReplace?.();
|
|
1058
1105
|
replaceStagedPath(tempPath, localPath);
|
|
1059
1106
|
} catch (err) {
|
package/src/telemetry.test.ts
CHANGED
|
@@ -16,6 +16,7 @@ import * as path from "path";
|
|
|
16
16
|
import {
|
|
17
17
|
collectAndSendTelemetry,
|
|
18
18
|
sanitizeRow,
|
|
19
|
+
extractServices,
|
|
19
20
|
type CollectTelemetryOptions,
|
|
20
21
|
type TelemetryClientSurface,
|
|
21
22
|
} from "./telemetry.js";
|
|
@@ -203,6 +204,133 @@ describe("sanitizeRow", () => {
|
|
|
203
204
|
});
|
|
204
205
|
});
|
|
205
206
|
|
|
207
|
+
// ── extractServices + sanitizeRow services emission (US-007) ─────────────────
|
|
208
|
+
|
|
209
|
+
// An assistant row that invoked the Slack + Figma MCP servers. The tool_use
|
|
210
|
+
// block `input` deliberately carries a URL + secret so the redaction assertions
|
|
211
|
+
// have something dangerous to prove is dropped.
|
|
212
|
+
const MCP_ROW = JSON.stringify({
|
|
213
|
+
type: "assistant",
|
|
214
|
+
timestamp: "2026-04-25T10:00:02Z",
|
|
215
|
+
sessionId: "s1",
|
|
216
|
+
uuid: "u3",
|
|
217
|
+
message: {
|
|
218
|
+
role: "assistant",
|
|
219
|
+
model: "claude-opus",
|
|
220
|
+
usage: { input_tokens: 10, output_tokens: 5 },
|
|
221
|
+
content: [
|
|
222
|
+
{ type: "text", text: "calling tools" },
|
|
223
|
+
{
|
|
224
|
+
type: "tool_use",
|
|
225
|
+
id: "t1",
|
|
226
|
+
name: "mcp__slack__slack_send_message",
|
|
227
|
+
input: { channel: "#secret-ops", token: "xoxb-SECRET-TOKEN", text: "hi" },
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
type: "tool_use",
|
|
231
|
+
id: "t2",
|
|
232
|
+
name: "mcp__slack__slack_read_channel",
|
|
233
|
+
input: { url: "https://hooks.slack.com/leak", channel: "C123" },
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
type: "tool_use",
|
|
237
|
+
id: "t3",
|
|
238
|
+
name: "mcp__figma__get_screenshot",
|
|
239
|
+
input: { fileKey: "https://figma.com/file/PRIVATE" },
|
|
240
|
+
},
|
|
241
|
+
// A non-MCP builtin tool must NOT count as a service.
|
|
242
|
+
{ type: "tool_use", id: "t4", name: "Bash", input: { command: "rm -rf /" } },
|
|
243
|
+
],
|
|
244
|
+
id: "msg_3",
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("extractServices (US-007)", () => {
|
|
249
|
+
it("counts MCP servers by name from tool_use block names", () => {
|
|
250
|
+
const services = extractServices(JSON.parse(MCP_ROW).message);
|
|
251
|
+
// Slack invoked twice, Figma once; Bash (builtin) excluded.
|
|
252
|
+
expect(services).toEqual({ slack: 2, figma: 1 });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("returns undefined when the row touched no MCP server", () => {
|
|
256
|
+
expect(extractServices(JSON.parse(ASST_ROW).message)).toBeUndefined();
|
|
257
|
+
expect(extractServices(JSON.parse(USER_ROW).message)).toBeUndefined();
|
|
258
|
+
expect(extractServices(undefined)).toBeUndefined();
|
|
259
|
+
expect(extractServices({ content: "not an array" })).toBeUndefined();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("preserves multi-underscore server names (mcp__claude_ai_Slack__…)", () => {
|
|
263
|
+
const msg = {
|
|
264
|
+
content: [
|
|
265
|
+
{ type: "tool_use", id: "a", name: "mcp__claude_ai_Slack__slack_send_message", input: {} },
|
|
266
|
+
{ type: "tool_use", id: "b", name: "mcp__claude_ai_Figma__get_screenshot", input: {} },
|
|
267
|
+
],
|
|
268
|
+
};
|
|
269
|
+
// The server segment is everything between the first and the LAST `__`.
|
|
270
|
+
expect(extractServices(msg)).toEqual({
|
|
271
|
+
claude_ai_Slack: 1,
|
|
272
|
+
claude_ai_Figma: 1,
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("preserves a double-underscore in the server name (mcp__foo__bar__run)", () => {
|
|
277
|
+
const msg = {
|
|
278
|
+
content: [
|
|
279
|
+
{ type: "tool_use", id: "a", name: "mcp__foo__bar__run", input: {} },
|
|
280
|
+
],
|
|
281
|
+
};
|
|
282
|
+
// Delimiter is the LAST `__`, so server = `foo__bar`, tool = `run`.
|
|
283
|
+
expect(extractServices(msg)).toEqual({ "foo__bar": 1 });
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("counts servers whose names collide with Object.prototype members", () => {
|
|
287
|
+
const msg = {
|
|
288
|
+
content: [
|
|
289
|
+
{ type: "tool_use", id: "a", name: "mcp__constructor__x", input: {} },
|
|
290
|
+
{ type: "tool_use", id: "b", name: "mcp__constructor__y", input: {} },
|
|
291
|
+
{ type: "tool_use", id: "c", name: "mcp__toString__z", input: {} },
|
|
292
|
+
],
|
|
293
|
+
};
|
|
294
|
+
// A plain-object counter would read these off the prototype and produce a
|
|
295
|
+
// non-numeric count; the null-prototype map keeps them honest integers.
|
|
296
|
+
const out = extractServices(msg)!;
|
|
297
|
+
expect(out).toEqual({ constructor: 2, toString: 1 });
|
|
298
|
+
expect(typeof out.constructor).toBe("number");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// SECURITY-CRITICAL: names + counts ONLY. No args / URLs / payloads / secrets.
|
|
302
|
+
it("emits NAMES + COUNTS only — no args, URLs, payloads, or secrets", () => {
|
|
303
|
+
const out = sanitizeRow(JSON.parse(MCP_ROW))!;
|
|
304
|
+
expect(out.services).toEqual({ slack: 2, figma: 1 });
|
|
305
|
+
|
|
306
|
+
// The full serialized wire row must contain NONE of the dangerous input data.
|
|
307
|
+
const serialized = JSON.stringify(out);
|
|
308
|
+
expect(serialized).not.toContain("xoxb-SECRET-TOKEN");
|
|
309
|
+
expect(serialized).not.toContain("hooks.slack.com");
|
|
310
|
+
expect(serialized).not.toContain("figma.com/file/PRIVATE");
|
|
311
|
+
expect(serialized).not.toContain("#secret-ops");
|
|
312
|
+
expect(serialized).not.toContain("rm -rf");
|
|
313
|
+
// No tool names or input keys leaked — only the server names + counts.
|
|
314
|
+
expect(serialized).not.toContain("slack_send_message");
|
|
315
|
+
expect(serialized).not.toContain("get_screenshot");
|
|
316
|
+
expect(serialized).not.toContain("channel");
|
|
317
|
+
expect(serialized).not.toContain("token");
|
|
318
|
+
expect(serialized).not.toContain("url");
|
|
319
|
+
|
|
320
|
+
// The services VALUE is a map of string → number (counts) — never objects
|
|
321
|
+
// or strings that could smuggle structured data.
|
|
322
|
+
for (const [name, count] of Object.entries(out.services as Record<string, unknown>)) {
|
|
323
|
+
expect(typeof name).toBe("string");
|
|
324
|
+
expect(typeof count).toBe("number");
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("omits services entirely for a row with no MCP calls (backward-compatible)", () => {
|
|
329
|
+
const out = sanitizeRow(JSON.parse(ASST_ROW))!;
|
|
330
|
+
expect("services" in out).toBe(false);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
206
334
|
// ── collectAndSendTelemetry ───────────────────────────────────────────────────
|
|
207
335
|
|
|
208
336
|
describe("collectAndSendTelemetry", () => {
|
package/src/telemetry.ts
CHANGED
|
@@ -152,6 +152,73 @@ const KEEP_TOP_LEVEL = [
|
|
|
152
152
|
"userType",
|
|
153
153
|
] as const;
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* MCP tool_use blocks name their tool `mcp__<server>__<tool>` in a Claude Code
|
|
157
|
+
* session row's `message.content[]`. This recovers the SERVER segment only —
|
|
158
|
+
* the server/connector name (e.g. `slack`, `figma`). The tool segment and the
|
|
159
|
+
* block's `input` (args/URLs/payloads) are deliberately NEVER read.
|
|
160
|
+
*
|
|
161
|
+
* The `__` (double underscore) is the server↔tool delimiter, so the server is
|
|
162
|
+
* everything between the `mcp__` prefix and the LAST `__`. Using `lastIndexOf`
|
|
163
|
+
* (rather than a first-`__` split or a single-underscore-only regex) preserves
|
|
164
|
+
* legal underscores in server names — `_foo`, `foo_`, and `foo__bar` all parse
|
|
165
|
+
* correctly instead of being truncated or dropped. Returns `undefined` for a
|
|
166
|
+
* name that is not an `mcp__<server>__<tool>` with a non-empty server segment.
|
|
167
|
+
*/
|
|
168
|
+
const MCP_PREFIX = "mcp__";
|
|
169
|
+
function mcpServerName(toolName: string): string | undefined {
|
|
170
|
+
if (!toolName.startsWith(MCP_PREFIX)) return undefined;
|
|
171
|
+
const rest = toolName.slice(MCP_PREFIX.length);
|
|
172
|
+
const delim = rest.lastIndexOf("__");
|
|
173
|
+
if (delim <= 0) return undefined; // no tool segment, or empty server
|
|
174
|
+
return rest.slice(0, delim);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* SECURITY-CRITICAL (owner-telemetry-v2 US-007). Extract the MCP servers /
|
|
179
|
+
* connectors / integrations a session row touched as a NAMES-AND-COUNTS-ONLY map
|
|
180
|
+
* `{ <serverName>: <count> }` — e.g. `{ slack: 2, figma: 1 }`.
|
|
181
|
+
*
|
|
182
|
+
* This mirrors the `hasArgs` redaction precedent in skill-telemetry.ts: we look
|
|
183
|
+
* ONLY at the tool-use block's `name` (to recover the server segment of
|
|
184
|
+
* `mcp__<server>__<tool>`) and count occurrences. We NEVER read the block's
|
|
185
|
+
* `input` (which carries the args/URLs/payloads), never emit the tool name, and
|
|
186
|
+
* never emit any argument text. So no args, URLs, payloads, or secrets can reach
|
|
187
|
+
* the wire — only a service name and how many times it was invoked in this row.
|
|
188
|
+
*
|
|
189
|
+
* Returns `undefined` when the row touched no MCP server, so the caller omits the
|
|
190
|
+
* `services` field entirely (backward-compatible: old-shaped rows carry nothing
|
|
191
|
+
* new, and the server treats absence as "no services").
|
|
192
|
+
*/
|
|
193
|
+
export function extractServices(
|
|
194
|
+
message: unknown,
|
|
195
|
+
): Record<string, number> | undefined {
|
|
196
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
const content = (message as Record<string, unknown>).content;
|
|
200
|
+
if (!Array.isArray(content)) return undefined;
|
|
201
|
+
|
|
202
|
+
// Null-prototype map: server names come from untrusted session content and can
|
|
203
|
+
// legally be `constructor`, `toString`, etc. A plain `{}` would read those off
|
|
204
|
+
// Object.prototype, so `counts[server] ?? 0` would yield a function and the
|
|
205
|
+
// increment would produce a non-numeric count — poisoning the whole row (the
|
|
206
|
+
// backend then drops it as `invalid-services`, losing its token/session data).
|
|
207
|
+
const counts: Record<string, number> = Object.create(null);
|
|
208
|
+
for (const blk of content as unknown[]) {
|
|
209
|
+
if (!blk || typeof blk !== "object" || Array.isArray(blk)) continue;
|
|
210
|
+
const b = blk as Record<string, unknown>;
|
|
211
|
+
// Only tool_use blocks name an MCP tool. We read `name` and NOTHING ELSE —
|
|
212
|
+
// the block's `input` (args/URLs/payloads) is never touched.
|
|
213
|
+
if (b.type !== "tool_use" || typeof b.name !== "string") continue;
|
|
214
|
+
const server = mcpServerName(b.name);
|
|
215
|
+
if (!server) continue;
|
|
216
|
+
counts[server] = (counts[server] ?? 0) + 1;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return Object.keys(counts).length > 0 ? counts : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
155
222
|
/**
|
|
156
223
|
* Build an outgoing event row matching the server's KEEP allowlist.
|
|
157
224
|
*
|
|
@@ -162,6 +229,11 @@ const KEEP_TOP_LEVEL = [
|
|
|
162
229
|
* camelCase top-level fields. The original `message` object — which
|
|
163
230
|
* carries prompt/response text, thinking, and tool data — is dropped.
|
|
164
231
|
*
|
|
232
|
+
* `services` (US-007): the MCP servers / connectors / integrations this row
|
|
233
|
+
* touched, as a names-and-counts-only map (see `extractServices`). NAMES + COUNTS
|
|
234
|
+
* ONLY — never args/URLs/payloads/secrets, matching the `hasArgs` precedent.
|
|
235
|
+
* Omitted when the row touched no MCP server (the server treats absence as none).
|
|
236
|
+
*
|
|
165
237
|
* `companyUid` (US-002): when the caller has resolved the row's `cwd` to an
|
|
166
238
|
* owning company (`resolveCompanyForCwd`), it passes that `cmp_*` uid here and
|
|
167
239
|
* it is stamped on the wire row. It is on the server's KEEP allowlist
|
|
@@ -213,6 +285,14 @@ export function sanitizeRow(
|
|
|
213
285
|
}
|
|
214
286
|
}
|
|
215
287
|
|
|
288
|
+
// services/integrations touched (US-007). Names + counts only — extracted from
|
|
289
|
+
// the message's tool_use block NAMES, never their inputs. Omitted when none, so
|
|
290
|
+
// the wire row is unchanged for rows that touched no MCP server.
|
|
291
|
+
const services = extractServices(message);
|
|
292
|
+
if (services !== undefined) {
|
|
293
|
+
out.services = services;
|
|
294
|
+
}
|
|
295
|
+
|
|
216
296
|
return out;
|
|
217
297
|
}
|
|
218
298
|
|
package/src/watcher.ts
CHANGED
|
@@ -263,6 +263,32 @@ interface WatchBackend {
|
|
|
263
263
|
close(): void;
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
export type TreeChangeKind =
|
|
267
|
+
| "add"
|
|
268
|
+
| "change"
|
|
269
|
+
| "unlink"
|
|
270
|
+
| "addDir"
|
|
271
|
+
| "unlinkDir";
|
|
272
|
+
|
|
273
|
+
/** Exact journal revision observed when a live watcher reports a deletion. */
|
|
274
|
+
export interface LocalDeleteSnapshot {
|
|
275
|
+
journalSlug: string;
|
|
276
|
+
journalPath: string;
|
|
277
|
+
/** Exact vault-relative root authorized by the originating watcher event. */
|
|
278
|
+
deleteScopeRoot?: string;
|
|
279
|
+
absolutePath: string;
|
|
280
|
+
remoteEtag: string;
|
|
281
|
+
localHash: string;
|
|
282
|
+
localKind: "file" | "symlink";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface TreeChange {
|
|
286
|
+
kind: TreeChangeKind;
|
|
287
|
+
deleteSnapshots?: LocalDeleteSnapshot[];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
type BackendChangeKind = TreeChangeKind | "rename";
|
|
291
|
+
|
|
266
292
|
/**
|
|
267
293
|
* Platforms where Node's `fs.watch(dir, { recursive: true })` is natively
|
|
268
294
|
* supported by a SINGLE OS handle: macOS (FSEvents) and Windows
|
|
@@ -277,7 +303,7 @@ function supportsRecursiveWatch(): boolean {
|
|
|
277
303
|
function startChokidarTreeWatch(
|
|
278
304
|
hqRoot: string,
|
|
279
305
|
shouldEmit: WatchPathFilter,
|
|
280
|
-
onEvent: (absolutePath: string) => void,
|
|
306
|
+
onEvent: (absolutePath: string, kind: BackendChangeKind) => void,
|
|
281
307
|
onError: (err: unknown) => void,
|
|
282
308
|
): WatchBackend {
|
|
283
309
|
const cw = watch(hqRoot, {
|
|
@@ -291,11 +317,11 @@ function startChokidarTreeWatch(
|
|
|
291
317
|
pollInterval: 100,
|
|
292
318
|
},
|
|
293
319
|
});
|
|
294
|
-
cw.on("add", onEvent)
|
|
295
|
-
.on("change", onEvent)
|
|
296
|
-
.on("unlink", onEvent)
|
|
297
|
-
.on("addDir", onEvent)
|
|
298
|
-
.on("unlinkDir", onEvent)
|
|
320
|
+
cw.on("add", (p) => onEvent(p, "add"))
|
|
321
|
+
.on("change", (p) => onEvent(p, "change"))
|
|
322
|
+
.on("unlink", (p) => onEvent(p, "unlink"))
|
|
323
|
+
.on("addDir", (p) => onEvent(p, "addDir"))
|
|
324
|
+
.on("unlinkDir", (p) => onEvent(p, "unlinkDir"))
|
|
299
325
|
.on("error", onError);
|
|
300
326
|
return {
|
|
301
327
|
close: () => {
|
|
@@ -327,7 +353,7 @@ function startChokidarTreeWatch(
|
|
|
327
353
|
function startTreeWatch(
|
|
328
354
|
hqRoot: string,
|
|
329
355
|
shouldEmit: WatchPathFilter,
|
|
330
|
-
onEvent: (absolutePath: string) => void,
|
|
356
|
+
onEvent: (absolutePath: string, kind: BackendChangeKind) => void,
|
|
331
357
|
onError: (err: unknown) => void,
|
|
332
358
|
): WatchBackend {
|
|
333
359
|
if (supportsRecursiveWatch()) {
|
|
@@ -335,14 +361,17 @@ function startTreeWatch(
|
|
|
335
361
|
const native = fs.watch(
|
|
336
362
|
hqRoot,
|
|
337
363
|
{ recursive: true, persistent: true },
|
|
338
|
-
(
|
|
364
|
+
(eventType, filename) => {
|
|
339
365
|
// `filename` is relative to hqRoot (or null/empty if the platform
|
|
340
366
|
// couldn't provide it — nothing actionable then). `String()` coerces
|
|
341
367
|
// both the string and (older @types/node) Buffer shapes.
|
|
342
368
|
if (filename == null) return;
|
|
343
369
|
const rel = String(filename);
|
|
344
370
|
if (rel === "") return;
|
|
345
|
-
onEvent(
|
|
371
|
+
onEvent(
|
|
372
|
+
path.resolve(hqRoot, rel),
|
|
373
|
+
eventType === "change" ? "change" : "rename",
|
|
374
|
+
);
|
|
346
375
|
},
|
|
347
376
|
);
|
|
348
377
|
let closed = false;
|
|
@@ -534,6 +563,10 @@ export interface TreeWatcherOptions {
|
|
|
534
563
|
maxPendingBytes?: number;
|
|
535
564
|
/** Backlog overflow signal. Defaults to a console warning. */
|
|
536
565
|
onBacklogOverflow?: (info: TreeWatcherBacklogOverflow) => void;
|
|
566
|
+
captureLocalDeleteSnapshots?: (
|
|
567
|
+
relativePath: string,
|
|
568
|
+
kind: "unlink" | "unlinkDir",
|
|
569
|
+
) => LocalDeleteSnapshot[];
|
|
537
570
|
}
|
|
538
571
|
|
|
539
572
|
/**
|
|
@@ -556,6 +589,8 @@ export interface TreeWatcherOptions {
|
|
|
556
589
|
export interface TreeChangeBatch {
|
|
557
590
|
/** Map of absolutePath → relativePath for every path in the settled burst. */
|
|
558
591
|
paths: Map<string, string>;
|
|
592
|
+
/** Per-path watcher event and the captured pre-delete journal revision. */
|
|
593
|
+
changes?: Map<string, TreeChange>;
|
|
559
594
|
/** True when path detail was dropped after the watcher backlog cap was hit. */
|
|
560
595
|
overflowed?: boolean;
|
|
561
596
|
/** Count of paths dropped from the detailed batch after overflow. */
|
|
@@ -587,11 +622,14 @@ export class TreeWatcher {
|
|
|
587
622
|
private readonly maxPendingPaths: number;
|
|
588
623
|
private readonly maxPendingBytes: number;
|
|
589
624
|
private readonly onBacklogOverflow: (info: TreeWatcherBacklogOverflow) => void;
|
|
625
|
+
private readonly captureLocalDeleteSnapshots?: TreeWatcherOptions["captureLocalDeleteSnapshots"];
|
|
590
626
|
private backend: WatchBackend | null = null;
|
|
591
627
|
private timer: unknown = null;
|
|
592
628
|
private listeners = new Set<TreeChangeListener>();
|
|
593
629
|
/** Paths accumulated for the current (in-flight) debounce window. */
|
|
594
630
|
private pending = new Map<string, string>();
|
|
631
|
+
private pendingChanges = new Map<string, TreeChange>();
|
|
632
|
+
private knownKinds = new Map<string, "file" | "directory">();
|
|
595
633
|
private pendingBytes = 0;
|
|
596
634
|
private overflowed = false;
|
|
597
635
|
private overflowLogged = false;
|
|
@@ -620,6 +658,7 @@ export class TreeWatcher {
|
|
|
620
658
|
`TreeWatcher backlog cap exceeded; dropping ${info.droppedPaths} path(s) until the next resync`,
|
|
621
659
|
);
|
|
622
660
|
});
|
|
661
|
+
this.captureLocalDeleteSnapshots = opts.captureLocalDeleteSnapshots;
|
|
623
662
|
}
|
|
624
663
|
|
|
625
664
|
/**
|
|
@@ -648,12 +687,13 @@ export class TreeWatcher {
|
|
|
648
687
|
this.backend = startTreeWatch(
|
|
649
688
|
this.hqRoot,
|
|
650
689
|
this.shouldEmit,
|
|
651
|
-
(absolutePath) => this.handleEvent(absolutePath),
|
|
690
|
+
(absolutePath, kind) => this.handleEvent(absolutePath, kind),
|
|
652
691
|
(err) => {
|
|
653
692
|
console.error("TreeWatcher error:", err);
|
|
654
693
|
this.signalBackendResync();
|
|
655
694
|
},
|
|
656
695
|
);
|
|
696
|
+
this.seedKnownKinds();
|
|
657
697
|
}
|
|
658
698
|
|
|
659
699
|
private signalBackendResync(): void {
|
|
@@ -668,13 +708,32 @@ export class TreeWatcher {
|
|
|
668
708
|
* events route through here too — and for the recursive backend this is the
|
|
669
709
|
* ONLY place filtering happens, so out-of-scope paths are dropped here.
|
|
670
710
|
*/
|
|
671
|
-
handleEvent(
|
|
711
|
+
handleEvent(
|
|
712
|
+
absolutePath: string,
|
|
713
|
+
backendKind: BackendChangeKind = "change",
|
|
714
|
+
): void {
|
|
672
715
|
if (this.disposed) return;
|
|
716
|
+
const abs = path.resolve(absolutePath);
|
|
717
|
+
let kind: TreeChangeKind;
|
|
718
|
+
if (backendKind === "rename") {
|
|
719
|
+
try {
|
|
720
|
+
kind = fs.lstatSync(abs).isDirectory() ? "addDir" : "add";
|
|
721
|
+
} catch (err: unknown) {
|
|
722
|
+
const code =
|
|
723
|
+
err && typeof err === "object" && "code" in err
|
|
724
|
+
? (err as { code?: string }).code
|
|
725
|
+
: undefined;
|
|
726
|
+
if (code !== "ENOENT") return;
|
|
727
|
+
kind = this.knownKinds.get(abs) === "directory" ? "unlinkDir" : "unlink";
|
|
728
|
+
}
|
|
729
|
+
} else {
|
|
730
|
+
kind = backendKind;
|
|
731
|
+
}
|
|
732
|
+
const isDir = kind === "addDir" || kind === "unlinkDir";
|
|
673
733
|
// The recursive backend does no descent-time filtering, so this is the
|
|
674
734
|
// authoritative emit gate; the chokidar backend's `ignored` pre-filters
|
|
675
735
|
// but a slipped-through (or synthetic test) path is re-checked here too.
|
|
676
|
-
if (!this.shouldEmit(
|
|
677
|
-
const abs = path.resolve(absolutePath);
|
|
736
|
+
if (!this.shouldEmit(abs, isDir)) return;
|
|
678
737
|
const rel = path.relative(this.hqRoot, abs).split(path.sep).join("/");
|
|
679
738
|
if (!this.pending.has(abs)) {
|
|
680
739
|
const entryBytes = estimatePendingEntryBytes(abs, rel);
|
|
@@ -689,9 +748,43 @@ export class TreeWatcher {
|
|
|
689
748
|
this.pendingBytes += entryBytes;
|
|
690
749
|
}
|
|
691
750
|
this.pending.set(abs, rel);
|
|
751
|
+
if (kind === "unlink" || kind === "unlinkDir") {
|
|
752
|
+
const deleteSnapshots =
|
|
753
|
+
this.captureLocalDeleteSnapshots?.(rel, kind) ?? [];
|
|
754
|
+
this.pendingChanges.set(abs, {
|
|
755
|
+
kind,
|
|
756
|
+
...(deleteSnapshots.length > 0 ? { deleteSnapshots } : {}),
|
|
757
|
+
});
|
|
758
|
+
this.knownKinds.delete(abs);
|
|
759
|
+
} else {
|
|
760
|
+
this.pendingChanges.set(abs, { kind });
|
|
761
|
+
this.knownKinds.set(abs, isDir ? "directory" : "file");
|
|
762
|
+
}
|
|
692
763
|
this.arm();
|
|
693
764
|
}
|
|
694
765
|
|
|
766
|
+
private seedKnownKinds(): void {
|
|
767
|
+
const visit = (dir: string): void => {
|
|
768
|
+
let entries: fs.Dirent[];
|
|
769
|
+
try {
|
|
770
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
771
|
+
} catch {
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
for (const entry of entries) {
|
|
775
|
+
const absolutePath = path.join(dir, entry.name);
|
|
776
|
+
if (entry.isDirectory()) {
|
|
777
|
+
if (!this.shouldEmit(absolutePath, true)) continue;
|
|
778
|
+
this.knownKinds.set(path.resolve(absolutePath), "directory");
|
|
779
|
+
visit(absolutePath);
|
|
780
|
+
} else if (this.shouldEmit(absolutePath, false)) {
|
|
781
|
+
this.knownKinds.set(path.resolve(absolutePath), "file");
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
visit(this.hqRoot);
|
|
786
|
+
}
|
|
787
|
+
|
|
695
788
|
private recordBacklogOverflow(entryBytes: number): void {
|
|
696
789
|
this.overflowed = true;
|
|
697
790
|
this.droppedPaths += 1;
|
|
@@ -727,7 +820,10 @@ export class TreeWatcher {
|
|
|
727
820
|
if (this.disposed) return;
|
|
728
821
|
// Snapshot + clear the accumulated paths so the next window starts fresh
|
|
729
822
|
// even if a listener re-enters synchronously.
|
|
730
|
-
const batch: TreeChangeBatch = {
|
|
823
|
+
const batch: TreeChangeBatch = {
|
|
824
|
+
paths: new Map(this.pending),
|
|
825
|
+
changes: new Map(this.pendingChanges),
|
|
826
|
+
};
|
|
731
827
|
if (this.overflowed) {
|
|
732
828
|
batch.overflowed = true;
|
|
733
829
|
batch.droppedPaths = this.droppedPaths;
|
|
@@ -749,6 +845,7 @@ export class TreeWatcher {
|
|
|
749
845
|
|
|
750
846
|
private clearPending(): void {
|
|
751
847
|
this.pending.clear();
|
|
848
|
+
this.pendingChanges.clear();
|
|
752
849
|
this.pendingBytes = 0;
|
|
753
850
|
this.overflowed = false;
|
|
754
851
|
this.overflowLogged = false;
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
* pinning the exact regression so it can't silently return.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
21
|
+
import { mkdtemp, mkdir, rm, unlink, writeFile } from "node:fs/promises";
|
|
22
22
|
import { tmpdir } from "node:os";
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
|
|
25
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
25
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
26
26
|
|
|
27
27
|
import { TreeWatcher } from "../../src/watcher.js";
|
|
28
28
|
|
|
@@ -66,6 +66,66 @@ function waitForEmit(w: TreeWatcher, ms: number): Promise<boolean> {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
describe("event-push watcher -- real chokidar scope (regression for 5.26.0)", () => {
|
|
69
|
+
it(
|
|
70
|
+
"preserves unlink classification and the captured pre-delete revision",
|
|
71
|
+
async () => {
|
|
72
|
+
const localPath = path.join(
|
|
73
|
+
hqRoot,
|
|
74
|
+
"companies",
|
|
75
|
+
"acme",
|
|
76
|
+
"knowledge",
|
|
77
|
+
"delete-me.md",
|
|
78
|
+
);
|
|
79
|
+
await writeFile(localPath, "# synced\n");
|
|
80
|
+
const capture = vi.fn(() => [
|
|
81
|
+
{
|
|
82
|
+
journalSlug: "acme",
|
|
83
|
+
journalPath: "knowledge/delete-me.md",
|
|
84
|
+
deleteScopeRoot: "knowledge/delete-me.md",
|
|
85
|
+
absolutePath: localPath,
|
|
86
|
+
remoteEtag: "etag-before-delete",
|
|
87
|
+
localHash: "hash-before-delete",
|
|
88
|
+
localKind: "file" as const,
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
watcher = new TreeWatcher({
|
|
92
|
+
hqRoot,
|
|
93
|
+
debounceMs: 250,
|
|
94
|
+
personalMode: false,
|
|
95
|
+
captureLocalDeleteSnapshots: capture,
|
|
96
|
+
});
|
|
97
|
+
watcher.start();
|
|
98
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
99
|
+
|
|
100
|
+
const emitted = new Promise<Parameters<Parameters<TreeWatcher["onChange"]>[0]>[1]>(
|
|
101
|
+
(resolve) => {
|
|
102
|
+
const off = watcher!.onChange((_rel, batch) => {
|
|
103
|
+
off();
|
|
104
|
+
resolve(batch);
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
await unlink(localPath);
|
|
109
|
+
const batch = await emitted;
|
|
110
|
+
const change = batch?.changes?.get(localPath);
|
|
111
|
+
|
|
112
|
+
expect(capture).toHaveBeenCalledWith(
|
|
113
|
+
"companies/acme/knowledge/delete-me.md",
|
|
114
|
+
"unlink",
|
|
115
|
+
);
|
|
116
|
+
expect(change).toMatchObject({
|
|
117
|
+
kind: "unlink",
|
|
118
|
+
deleteSnapshots: [
|
|
119
|
+
expect.objectContaining({
|
|
120
|
+
remoteEtag: "etag-before-delete",
|
|
121
|
+
localHash: "hash-before-delete",
|
|
122
|
+
}),
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
8000,
|
|
127
|
+
);
|
|
128
|
+
|
|
69
129
|
it(
|
|
70
130
|
"FIRES on a companies/<slug>/knowledge/ edit when personalMode=false (the --companies scope)",
|
|
71
131
|
async () => {
|