@rubytech/create-maxy 1.0.658 → 1.0.659

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy",
3
- "version": "1.0.658",
3
+ "version": "1.0.659",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy": "./dist/index.js"
@@ -363,14 +363,24 @@ export const SCRAPE_EXPRESSION = `(${scrapeCurrentPage.toString()})(document, lo
363
363
  // constructing a full CdpClient + WebSocket.
364
364
  export type CdpEvaluator = (expression: string) => Promise<unknown>;
365
365
 
366
- // Number of consecutive polls returning an identical non-empty count that
367
- // proves the SPA zone-list has finished hydrating. Two iterations at 500 ms
368
- // each = 1 s of observed stability, which is the empirical ceiling on the
369
- // CF dashboard's lazy zone-list fetch post-document-ready. Shorter thresholds
370
- // risk returning a partial list; longer thresholds add user-visible latency
371
- // without proportional robustness.
366
+ // Trailing-run length (consecutive polls at `max_count`) the complete-line
367
+ // asserts before it declares `unstable=false`. Two iterations at 500 ms each
368
+ // = 1 s of continuous observation at the high-water mark. Task 615 removed
369
+ // the early-exit on this threshold (it shortcuts multi-zone accounts where
370
+ // the first zone hydrates one poll ahead of the second — the plateau at the
371
+ // intermediate count trips the threshold before the final zone arrives);
372
+ // the threshold now only labels the exit, it does not gate it.
372
373
  const STABLE_POLL_THRESHOLD = 2;
373
374
 
375
+ // Cap on `document.documentElement.outerHTML` captured into the dump file.
376
+ // The observed Cloudflare dashboard outerHTML is ~1 MB; 5 MB is 5× headroom
377
+ // and still fits comfortably in one `writeFile` syscall on the Pi. Pre-Task
378
+ // 615 this was 100 KB — the dump ended mid-`<head>` CSS on every scrape,
379
+ // rendering the forensic artifact premise of Task 608 silently broken: the
380
+ // zone table lived past the cutoff and `grep <zone-host> <dump>` always
381
+ // returned nothing, forcing re-scrapes to answer post-hoc questions.
382
+ const OUTER_HTML_CAPTURE_MAX_CHARS = 5_000_000;
383
+
374
384
  // Cap the `domains=[…]` payload on per-poll and complete phase lines. The
375
385
  // extractor already implicitly bounds the list via the FQDN regex + Set
376
386
  // dedupe, but a pathological CF redesign could emit thousands of href
@@ -386,7 +396,7 @@ function formatDomains(domains: string[]): string {
386
396
  }
387
397
 
388
398
  type DumpMode = "stable" | "unstable" | "empty-or-drift";
389
- type DumpResult = { path: string } | { err: string };
399
+ type DumpResult = { path: string; truncated: boolean } | { err: string };
390
400
 
391
401
  // Snapshot the operator's current dashboard HTML so post-hoc diagnosis of a
392
402
  // partial, unstable, or empty scrape has the exact DOM the scrape observed
@@ -404,7 +414,7 @@ async function dumpHtml(
404
414
  ): Promise<DumpResult> {
405
415
  try {
406
416
  const html = (await evaluator(
407
- "document.documentElement.outerHTML.slice(0, 100000)",
417
+ `document.documentElement.outerHTML.slice(0, ${OUTER_HTML_CAPTURE_MAX_CHARS})`,
408
418
  )) as string;
409
419
  // CONFIG_DIR is set by list-cf-domains.sh before the spawn. A silent
410
420
  // fallback would dump logs into the wrong brand's directory on a Real
@@ -423,8 +433,19 @@ async function dumpHtml(
423
433
  logDir,
424
434
  `list-cf-domains-${ts}-count${count}-${mode}-pid${process.pid}.html`,
425
435
  );
426
- await writeFile(dumpPath, typeof html === "string" ? html : String(html), "utf-8");
427
- return { path: dumpPath };
436
+ const htmlStr = typeof html === "string" ? html : String(html);
437
+ await writeFile(dumpPath, htmlStr, "utf-8");
438
+ // Heuristic: if the slice returned exactly OUTER_HTML_CAPTURE_MAX_CHARS,
439
+ // the source outerHTML almost certainly exceeded the cap and we
440
+ // truncated mid-document. The false positive (outerHTML exactly at the
441
+ // ceiling) is a rounding coincidence on the order of 1-in-5M and is
442
+ // acceptable — a loud truncation signal with a ~10^-7 false-positive
443
+ // rate is strictly better than silent truncation (the pre-Task-615
444
+ // failure mode at the 100 KB ceiling, which silently broke every scrape
445
+ // for five months). A downstream investigator seeing `truncated=true`
446
+ // can re-scrape with a larger cap or `outerHTML` unsliced.
447
+ const truncated = htmlStr.length >= OUTER_HTML_CAPTURE_MAX_CHARS;
448
+ return { path: dumpPath, truncated };
428
449
  } catch (err) {
429
450
  return {
430
451
  err: (err instanceof Error ? err.message : String(err)).slice(0, 120),
@@ -437,7 +458,17 @@ async function dumpHtml(
437
458
  // complete line terse and routes every failure through the same observation
438
459
  // primitive so investigators have one grep pattern for all dump write errors.
439
460
  function dumpField(result: DumpResult, mode: DumpMode): string {
440
- if ("path" in result) return `dump=${result.path}`;
461
+ if ("path" in result) {
462
+ if (result.truncated) {
463
+ // Loud separate phase line — an investigator grepping for truncation
464
+ // incidents across the stream log has one exact pattern to match.
465
+ logPhase(
466
+ `phase=dump-truncated mode=${mode} path=${result.path} captured_bytes=${OUTER_HTML_CAPTURE_MAX_CHARS}`,
467
+ );
468
+ return `dump=${result.path} dump_truncated=true`;
469
+ }
470
+ return `dump=${result.path}`;
471
+ }
441
472
  logPhase(
442
473
  `phase=dump-write-failed mode=${mode} detail="${result.err.replace(/"/g, "'")}"`,
443
474
  );
@@ -446,13 +477,30 @@ function dumpField(result: DumpResult, mode: DumpMode): string {
446
477
 
447
478
  export async function scrapeDomains(evaluator: CdpEvaluator): Promise<string[]> {
448
479
  const deadline = Date.now() + SCRAPE_POLL_MS;
480
+ // Task 615: pure poll-to-deadline. The earlier "first N stable polls wins"
481
+ // heuristic short-circuited on the first plateau a multi-zone dashboard
482
+ // rendered — a 2-zone account whose second zone hydrates one poll later
483
+ // than the first would lock `count=1` before the second zone arrived.
484
+ // Removing early-exit entirely is the only formulation that eliminates the
485
+ // class: any "N-consec-at-max wins" variant still fires on the intermediate
486
+ // plateau (`[] [] [a] [a] [a,b] …` → threshold met at poll 4 before zone B
487
+ // observed). The cost is ~9 s of happy-path latency under the 10 s budget
488
+ // (still within onboarding tolerance). `max_count` tracks the high-water
489
+ // count; `consecutiveAtMax` tracks the trailing run at that water-mark;
490
+ // `seenDecrease` tracks any mid-trajectory count drop. `unstable=false`
491
+ // requires reaching max, ending at max, at least STABLE_POLL_THRESHOLD
492
+ // consecutive trailing polls at max, and no mid-trajectory decrease — so a
493
+ // trajectory like `[a][a][a][a,b]{never reaches 2 consecutive}` correctly
494
+ // reports `unstable=true` even though `final_count === max_count`.
449
495
  let lastOutcome: ScrapeOutcome | null = null;
450
- // Track the most recent non-empty observation so deadline-reached without
451
- // stability returns the caller's best evidence rather than falling through
452
- // to the drift-dump (which is reserved for the always-empty case).
453
- let lastNonEmptyDomains: string[] = [];
454
- let stableCount = -1;
455
- let stableIterations = 0;
496
+ let maxCount = 0;
497
+ // Snapshot of the domains array at the most-recent observation of
498
+ // `maxCount`. Updated on every `>=` poll so it always reflects the latest
499
+ // capture at the high-water mark; returned at deadline when `maxCount > 0`.
500
+ let maxDomains: string[] = [];
501
+ let finalCount = 0;
502
+ let consecutiveAtMax = 0;
503
+ let seenDecrease = false;
456
504
  let polls = 0;
457
505
 
458
506
  while (Date.now() < deadline) {
@@ -460,40 +508,44 @@ export async function scrapeDomains(evaluator: CdpEvaluator): Promise<string[]>
460
508
  try {
461
509
  const outcome = (await evaluator(SCRAPE_EXPRESSION)) as ScrapeOutcome;
462
510
  lastOutcome = outcome;
511
+ const count = outcome.domains.length;
463
512
 
464
- // Per-poll trajectory line (Task 608). Names the exact list captured at
465
- // this iteration so a partial-capture scenario (e.g. 2-zone account
466
- // rendering count=1 on poll K because zone B has not hydrated yet) is
467
- // legible from the stream log alone. Emitted only on successful
468
- // evaluator returns — the catch branch emits `phase=scrape-retry`
469
- // instead, because "zero observed" and "failed to observe" are
470
- // semantically distinct signals for a downstream reader.
471
513
  logPhase(
472
- `phase=dom-scrape-poll n=${polls} count=${outcome.domains.length} domains=[${formatDomains(outcome.domains)}]`,
514
+ `phase=dom-scrape-poll n=${polls} count=${count} domains=[${formatDomains(outcome.domains)}]`,
473
515
  );
474
516
 
475
- if (outcome.reason === "ok" && outcome.domains.length > 0) {
476
- lastNonEmptyDomains = outcome.domains;
477
- if (outcome.domains.length === stableCount) {
478
- stableIterations += 1;
479
- if (stableIterations >= STABLE_POLL_THRESHOLD) {
480
- const dump = await dumpHtml(evaluator, outcome.domains.length, "stable");
481
- logPhase(
482
- `phase=dom-scrape-complete result=ok count=${outcome.domains.length} polls=${polls} stable_polls=${stableIterations} unstable=false domains=[${formatDomains(outcome.domains)}] ${dumpField(dump, "stable")}`,
483
- );
484
- return outcome.domains;
485
- }
517
+ if (outcome.reason === "ok" && count > 0) {
518
+ if (count > maxCount) {
519
+ // New high-water mark. Post-stable increases reset the run counter —
520
+ // the new stable window must also satisfy the threshold to qualify
521
+ // as `unstable=false`.
522
+ maxCount = count;
523
+ maxDomains = outcome.domains;
524
+ consecutiveAtMax = 1;
525
+ } else if (count === maxCount) {
526
+ maxDomains = outcome.domains;
527
+ consecutiveAtMax += 1;
486
528
  } else {
487
- stableCount = outcome.domains.length;
488
- stableIterations = 1;
529
+ // count > 0 && count < maxCount — the page shrunk mid-poll
530
+ // (virtual-scroll pagination, zone deletion concurrent with scrape,
531
+ // SPA re-render collapsing rows). Preserve the high-water snapshot;
532
+ // flag the trajectory as unstable.
533
+ seenDecrease = true;
534
+ consecutiveAtMax = 0;
489
535
  }
536
+ finalCount = count;
490
537
  } else {
491
- // `ok` with zero domains OR a non-ok reason: treat as still-hydrating.
492
- // Keep polling; the final iteration's zero result is the empty-account
493
- // signal. Reset stability tracking so a 0 N → 0 sequence doesn't
494
- // falsely satisfy the threshold.
495
- stableCount = -1;
496
- stableIterations = 0;
538
+ // Empty poll or non-ok reason (no-account-id SPA mid-navigation):
539
+ // the page momentarily showed no zones, breaking any trailing at-max
540
+ // run. Don't flip `seenDecrease` treating a hydration gap as a
541
+ // "shrink" would be a false positive on the leading empty polls of
542
+ // every scrape. `finalCount` reflects the last poll's observed count
543
+ // (always 0 in this branch, since `scrapeCurrentPage` returns empty
544
+ // domains on any non-ok reason), so the stream log's `final_count`
545
+ // field accurately names what the last poll saw, not a stale value
546
+ // from an earlier successful observation.
547
+ consecutiveAtMax = 0;
548
+ finalCount = count;
497
549
  }
498
550
  } catch (err) {
499
551
  logPhase(
@@ -503,31 +555,33 @@ export async function scrapeDomains(evaluator: CdpEvaluator): Promise<string[]>
503
555
  await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
504
556
  }
505
557
 
506
- // Deadline reached. Two distinct branches:
558
+ // Deadline reached. Two branches:
507
559
  //
508
- // (i) We saw non-empty results but they never stabilised across two
509
- // consecutive polls the page is either paginating or re-rendering
510
- // the zone list. Return the last non-empty observation, flag
511
- // `unstable=true` on the phase line, and snapshot the DOM so
512
- // partial-capture-that-also-races is diagnosable (Task 608 extends
513
- // the dump to this path — pre-task, only empty-or-drift dumped).
560
+ // (i) We observed at least one non-empty poll. Return the max-count
561
+ // snapshot (which, when `finalCount === maxCount`, is also the last
562
+ // non-empty observation). `unstable=true` whenever any signal suggests
563
+ // the page wasn't settled: shrinkage, end-below-max, or fewer than
564
+ // STABLE_POLL_THRESHOLD consecutive trailing polls at max.
514
565
  //
515
- // (ii) We never saw non-empty results this is either a genuinely empty
516
- // account OR CF has drifted the href URL shape so Source A yields
517
- // nothing. The `empty-or-drift` body-dump branch snapshots the page
518
- // HTML so the operator can distinguish the two by inspecting the
519
- // dump file.
520
- if (lastNonEmptyDomains.length > 0) {
521
- const dump = await dumpHtml(evaluator, lastNonEmptyDomains.length, "unstable");
566
+ // (ii) Every poll was empty — either a genuinely empty account OR CF
567
+ // drifted the href shape. The `empty-or-drift` dump is the operator's
568
+ // single-artifact signal for distinguishing the two.
569
+ if (maxCount > 0) {
570
+ const unstable =
571
+ seenDecrease ||
572
+ finalCount !== maxCount ||
573
+ consecutiveAtMax < STABLE_POLL_THRESHOLD;
574
+ const mode: DumpMode = unstable ? "unstable" : "stable";
575
+ const dump = await dumpHtml(evaluator, maxCount, mode);
522
576
  logPhase(
523
- `phase=dom-scrape-complete result=ok count=${lastNonEmptyDomains.length} polls=${polls} stable_polls=${stableIterations} unstable=true domains=[${formatDomains(lastNonEmptyDomains)}] ${dumpField(dump, "unstable")}`,
577
+ `phase=dom-scrape-complete result=ok count=${maxCount} total_polls=${polls} max_count=${maxCount} final_count=${finalCount} stable_polls=${consecutiveAtMax} unstable=${unstable} domains=[${formatDomains(maxDomains)}] ${dumpField(dump, mode)}`,
524
578
  );
525
- return lastNonEmptyDomains;
579
+ return maxDomains;
526
580
  }
527
581
 
528
582
  const dump = await dumpHtml(evaluator, 0, "empty-or-drift");
529
583
  logPhase(
530
- `phase=dom-scrape-complete result=empty-or-drift count=0 polls=${polls} lastReason=${lastOutcome?.reason ?? "unknown"} ${dumpField(dump, "empty-or-drift")}`,
584
+ `phase=dom-scrape-complete result=empty-or-drift count=0 total_polls=${polls} max_count=0 final_count=0 stable_polls=0 lastReason=${lastOutcome?.reason ?? "unknown"} ${dumpField(dump, "empty-or-drift")}`,
531
585
  );
532
586
  return [];
533
587
  }
@@ -20254,8 +20254,236 @@ function resolveDataPath(raw2) {
20254
20254
  return { ok: true, absolute: resolvedReal, dataRootReal, relative: relPath };
20255
20255
  }
20256
20256
 
20257
- // server/routes/admin/files.ts
20257
+ // ../lib/graph-trash/src/index.ts
20258
+ var UNIQUE_KEYS_BY_LABEL = {
20259
+ Person: ["email", "telephone"],
20260
+ Service: ["serviceId"],
20261
+ LocalBusiness: ["accountId"],
20262
+ Task: ["taskId"],
20263
+ Event: ["eventId"],
20264
+ KnowledgeDocument: ["attachmentId"],
20265
+ DigitalDocument: ["attachmentId"],
20266
+ Conversation: ["conversationId", "sessionKey"],
20267
+ Message: ["messageId"],
20268
+ OnboardingState: ["accountId"],
20269
+ Workflow: ["workflowId"],
20270
+ WorkflowStep: ["stepId"],
20271
+ WorkflowRun: ["runId"],
20272
+ Preference: ["preferenceId"],
20273
+ Email: ["emailId", "messageId"],
20274
+ AdminUser: ["userId"],
20275
+ ToolCall: ["callId"],
20276
+ // Composite component nulls — frees the composite constraint:
20277
+ AccessGrant: ["contactValue"],
20278
+ // composite (contactValue, agentSlug, accountId)
20279
+ UserProfile: ["userId"]
20280
+ // composite (accountId, userId)
20281
+ };
20282
+ async function trashNode(params) {
20283
+ const { session, accountId, elementId, by, reason } = params;
20284
+ const lookup = await session.run(
20285
+ `MATCH (n) WHERE elementId(n) = $eid AND n.accountId = $accountId
20286
+ RETURN labels(n) AS labels, properties(n) AS props`,
20287
+ { eid: elementId, accountId }
20288
+ );
20289
+ if (lookup.records.length === 0) {
20290
+ throw new Error(
20291
+ `trashNode: node not found (elementId=${elementId} accountId=${accountId.slice(0, 8)}\u2026)`
20292
+ );
20293
+ }
20294
+ const allLabels = lookup.records[0].get("labels");
20295
+ const props = lookup.records[0].get("props");
20296
+ const baseLabels = allLabels.filter((l) => l !== "Trashed");
20297
+ if (allLabels.includes("Trashed")) {
20298
+ return {
20299
+ trashed: false,
20300
+ alreadyTrashed: true,
20301
+ nodeId: elementId,
20302
+ labels: baseLabels,
20303
+ trashedAt: String(props.trashedAt ?? ""),
20304
+ originalKeys: {}
20305
+ };
20306
+ }
20307
+ const uniqueKeys = /* @__PURE__ */ new Set();
20308
+ for (const label of baseLabels) {
20309
+ for (const key of UNIQUE_KEYS_BY_LABEL[label] ?? []) uniqueKeys.add(key);
20310
+ }
20311
+ const originalKeys = {};
20312
+ for (const k of uniqueKeys) {
20313
+ if (props[k] !== void 0 && props[k] !== null) originalKeys[k] = props[k];
20314
+ }
20315
+ const setNullClauses = Object.keys(originalKeys).map((k) => `n.\`${k}\` = null`).join(", ");
20316
+ const setNullSuffix = setNullClauses ? `, ${setNullClauses}` : "";
20317
+ const trashedAt = (/* @__PURE__ */ new Date()).toISOString();
20318
+ await session.run(
20319
+ `MATCH (n) WHERE elementId(n) = $eid
20320
+ SET n:Trashed,
20321
+ n.trashedAt = datetime($trashedAt),
20322
+ n.trashedBy = $by,
20323
+ n.trashReason = $reason,
20324
+ n._trashedKeys = $trashedKeysJson${setNullSuffix}`,
20325
+ {
20326
+ eid: elementId,
20327
+ trashedAt,
20328
+ by,
20329
+ reason: reason ?? null,
20330
+ trashedKeysJson: JSON.stringify(originalKeys)
20331
+ }
20332
+ );
20333
+ process.stderr.write(
20334
+ `[trash:marked] accountId=${accountId} elementId=${elementId} labels=${baseLabels.join(",")} by=${by} reason=${reason ?? "null"}
20335
+ `
20336
+ );
20337
+ return {
20338
+ trashed: true,
20339
+ alreadyTrashed: false,
20340
+ nodeId: elementId,
20341
+ labels: baseLabels,
20342
+ trashedAt,
20343
+ originalKeys
20344
+ };
20345
+ }
20346
+ async function restoreNode(params) {
20347
+ const { session, accountId, elementId } = params;
20348
+ const lookup = await session.run(
20349
+ `MATCH (n:Trashed) WHERE elementId(n) = $eid
20350
+ RETURN labels(n) AS labels, n._trashedKeys AS keysJson`,
20351
+ { eid: elementId }
20352
+ );
20353
+ if (lookup.records.length === 0) {
20354
+ throw new Error(
20355
+ `restoreNode: trashed node not found (elementId=${elementId})`
20356
+ );
20357
+ }
20358
+ const allLabels = lookup.records[0].get("labels");
20359
+ const baseLabels = allLabels.filter((l) => l !== "Trashed");
20360
+ const keysJson = lookup.records[0].get("keysJson");
20361
+ const originalKeys = keysJson ? JSON.parse(keysJson) : {};
20362
+ for (const label of baseLabels) {
20363
+ const uniqueKeys = UNIQUE_KEYS_BY_LABEL[label] ?? [];
20364
+ for (const k of uniqueKeys) {
20365
+ const v = originalKeys[k];
20366
+ if (v === void 0 || v === null) continue;
20367
+ const conflict = await session.run(
20368
+ `MATCH (other:\`${label}\`)
20369
+ WHERE elementId(other) <> $eid
20370
+ AND NOT other:Trashed
20371
+ AND other.\`${k}\` = $val
20372
+ RETURN elementId(other) AS otherId LIMIT 1`,
20373
+ { eid: elementId, val: v }
20374
+ );
20375
+ if (conflict.records.length > 0) {
20376
+ const otherId = conflict.records[0].get("otherId");
20377
+ throw new Error(
20378
+ `restoreNode: cannot restore ${label} elementId=${elementId} \u2014 active node elementId=${otherId} already holds ${k}=${JSON.stringify(v)}`
20379
+ );
20380
+ }
20381
+ }
20382
+ }
20383
+ const setClauses = Object.keys(originalKeys).map((k) => `n.\`${k}\` = $val_${k}`).join(", ");
20384
+ const setSuffix = setClauses ? `, ${setClauses}` : "";
20385
+ const setParams = { eid: elementId };
20386
+ for (const [k, v] of Object.entries(originalKeys)) setParams[`val_${k}`] = v;
20387
+ await session.run(
20388
+ `MATCH (n:Trashed) WHERE elementId(n) = $eid
20389
+ REMOVE n:Trashed, n.trashedAt, n.trashedBy, n.trashReason, n._trashedKeys
20390
+ SET n.restoredAt = datetime()${setSuffix}`,
20391
+ setParams
20392
+ );
20393
+ process.stderr.write(
20394
+ `[trash:restored] accountId=${accountId} elementId=${elementId} labels=${baseLabels.join(",")}
20395
+ `
20396
+ );
20397
+ return {
20398
+ restored: true,
20399
+ nodeId: elementId,
20400
+ labels: baseLabels,
20401
+ restoredKeys: originalKeys
20402
+ };
20403
+ }
20404
+
20405
+ // app/lib/file-delete-cascade.ts
20258
20406
  var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20407
+ function parseAttachmentPath(relPath) {
20408
+ const segments = relPath.split("/").filter(Boolean);
20409
+ if (segments.length !== 4) return null;
20410
+ if (segments[0] !== "uploads") return null;
20411
+ const accountId = segments[1];
20412
+ const attachmentId = segments[2];
20413
+ const filename = segments[3];
20414
+ if (!UUID_RE2.test(accountId) || !UUID_RE2.test(attachmentId)) return null;
20415
+ const dot = filename.lastIndexOf(".");
20416
+ if (dot === -1) return null;
20417
+ const stem = filename.slice(0, dot);
20418
+ const ext = filename.slice(dot + 1);
20419
+ if (stem !== attachmentId) return null;
20420
+ if (ext === "meta" || filename.endsWith(".meta.json")) return null;
20421
+ return { accountId, attachmentId };
20422
+ }
20423
+ async function cascadeDeleteDocument(params) {
20424
+ const { accountId, attachmentId } = params;
20425
+ const session = getSession();
20426
+ try {
20427
+ const lookup = await session.run(
20428
+ `MATCH (d:KnowledgeDocument { accountId: $accountId, attachmentId: $attachmentId })
20429
+ WHERE NOT d:Trashed
20430
+ RETURN elementId(d) AS eid LIMIT 1`,
20431
+ { accountId, attachmentId }
20432
+ );
20433
+ if (lookup.records.length === 0) return { nodes: 0 };
20434
+ const docElementId = lookup.records[0].get("eid");
20435
+ const childResult = await session.run(
20436
+ `MATCH (d) WHERE elementId(d) = $eid
20437
+ OPTIONAL MATCH (d)-[:HAS_SECTION]->(s:Section)
20438
+ OPTIONAL MATCH (s)-[:HAS_CHUNK]->(c:Chunk)
20439
+ WITH collect(DISTINCT s) AS sections, collect(DISTINCT c) AS chunks
20440
+ RETURN [s IN sections WHERE s IS NOT NULL | elementId(s)] AS sectionIds,
20441
+ [c IN chunks WHERE c IS NOT NULL | elementId(c)] AS chunkIds`,
20442
+ { eid: docElementId }
20443
+ );
20444
+ if (childResult.records.length === 0) {
20445
+ throw new Error(
20446
+ `cascadeDeleteDocument: child lookup returned zero rows for elementId=${docElementId} \u2014 concurrent trash or connection drop`
20447
+ );
20448
+ }
20449
+ const sectionIds = childResult.records[0].get("sectionIds") ?? [];
20450
+ const chunkIds = childResult.records[0].get("chunkIds") ?? [];
20451
+ await trashNode({
20452
+ session,
20453
+ accountId,
20454
+ elementId: docElementId,
20455
+ by: "file-delete-cascade",
20456
+ reason: "file deleted via /data"
20457
+ });
20458
+ for (const sid of sectionIds) {
20459
+ await trashNode({
20460
+ session,
20461
+ accountId,
20462
+ elementId: sid,
20463
+ by: `file-delete-cascade:from-${docElementId}`,
20464
+ reason: `cascade from KnowledgeDocument ${docElementId}`
20465
+ });
20466
+ }
20467
+ for (const cid of chunkIds) {
20468
+ await trashNode({
20469
+ session,
20470
+ accountId,
20471
+ elementId: cid,
20472
+ by: `file-delete-cascade:from-${docElementId}`,
20473
+ reason: `cascade from KnowledgeDocument ${docElementId}`
20474
+ });
20475
+ }
20476
+ return { nodes: 1 };
20477
+ } finally {
20478
+ try {
20479
+ await session.close();
20480
+ } catch {
20481
+ }
20482
+ }
20483
+ }
20484
+
20485
+ // server/routes/admin/files.ts
20486
+ var UUID_RE3 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20259
20487
  async function readMeta(absDir, baseName) {
20260
20488
  try {
20261
20489
  const raw2 = await readFile4(join12(absDir, `${baseName}.meta.json`), "utf8");
@@ -20277,7 +20505,7 @@ async function readAccountNames() {
20277
20505
  return map;
20278
20506
  }
20279
20507
  for (const name of names) {
20280
- if (!UUID_RE2.test(name)) continue;
20508
+ if (!UUID_RE3.test(name)) continue;
20281
20509
  const configPath2 = resolve29(accountsDir, name, "account.json");
20282
20510
  try {
20283
20511
  const raw2 = await readFile4(configPath2, "utf8");
@@ -20295,7 +20523,7 @@ async function readAccountNames() {
20295
20523
  return map;
20296
20524
  }
20297
20525
  async function enrich(absolute, entry, accountNames) {
20298
- if (entry.kind === "directory" && UUID_RE2.test(entry.name)) {
20526
+ if (entry.kind === "directory" && UUID_RE3.test(entry.name)) {
20299
20527
  const meta = await readMeta(join12(absolute, entry.name), entry.name);
20300
20528
  if (meta?.filename) {
20301
20529
  entry.displayName = meta.filename;
@@ -20311,7 +20539,7 @@ async function enrich(absolute, entry, accountNames) {
20311
20539
  if (entry.kind === "file") {
20312
20540
  const dot = entry.name.lastIndexOf(".");
20313
20541
  const base = dot === -1 ? entry.name : entry.name.slice(0, dot);
20314
- if (UUID_RE2.test(base)) {
20542
+ if (UUID_RE3.test(base)) {
20315
20543
  const meta = await readMeta(absolute, base);
20316
20544
  if (meta?.filename) {
20317
20545
  entry.displayName = meta.filename;
@@ -20323,7 +20551,7 @@ async function enrich(absolute, entry, accountNames) {
20323
20551
  function buildDisplayPath(relPath, accountNames) {
20324
20552
  if (relPath === "." || relPath === "") return [];
20325
20553
  return relPath.split("/").filter(Boolean).map((seg) => {
20326
- const dn = UUID_RE2.test(seg) ? accountNames.get(seg) : void 0;
20554
+ const dn = UUID_RE3.test(seg) ? accountNames.get(seg) : void 0;
20327
20555
  return dn ? { name: seg, displayName: dn } : { name: seg };
20328
20556
  });
20329
20557
  }
@@ -20351,7 +20579,7 @@ app24.get("/", requireAdminSession, async (c) => {
20351
20579
  const names = await readdir2(absolute);
20352
20580
  const entries = [];
20353
20581
  for (const name of names) {
20354
- if (UUID_RE2.test(name.replace(/\.meta\.json$/, "")) && name.endsWith(".meta.json")) {
20582
+ if (UUID_RE3.test(name.replace(/\.meta\.json$/, "")) && name.endsWith(".meta.json")) {
20355
20583
  continue;
20356
20584
  }
20357
20585
  try {
@@ -20497,7 +20725,8 @@ app24.post("/upload", requireAdminSession, async (c) => {
20497
20725
  });
20498
20726
  app24.delete("/", requireAdminSession, async (c) => {
20499
20727
  const sessionKey = c.var.sessionKey;
20500
- if (!getAccountIdForSession(sessionKey)) {
20728
+ const accountId = getAccountIdForSession(sessionKey);
20729
+ if (!accountId) {
20501
20730
  console.error(`[data] auth-rejected endpoint="DELETE /api/admin/files" reason="no account for session"`);
20502
20731
  return c.json({ error: "Account not found for session" }, 401);
20503
20732
  }
@@ -20527,7 +20756,7 @@ app24.delete("/", requireAdminSession, async (c) => {
20527
20756
  }
20528
20757
  const dot = base.lastIndexOf(".");
20529
20758
  const stem = dot === -1 ? base : base.slice(0, dot);
20530
- const sidecarPath = UUID_RE2.test(stem) && base !== `${stem}.meta.json` ? join12(dirname10(absolute), `${stem}.meta.json`) : null;
20759
+ const sidecarPath = UUID_RE3.test(stem) && base !== `${stem}.meta.json` ? join12(dirname10(absolute), `${stem}.meta.json`) : null;
20531
20760
  await unlink2(absolute);
20532
20761
  if (sidecarPath) {
20533
20762
  try {
@@ -20536,6 +20765,19 @@ app24.delete("/", requireAdminSession, async (c) => {
20536
20765
  }
20537
20766
  }
20538
20767
  console.error(`[data] file-delete path="${relPath}" bytes=${info.size}`);
20768
+ const parsed = parseAttachmentPath(relPath);
20769
+ if (parsed) {
20770
+ try {
20771
+ const { nodes } = await cascadeDeleteDocument({
20772
+ accountId,
20773
+ attachmentId: parsed.attachmentId
20774
+ });
20775
+ console.error(`[data] file-delete graph-cascade path="${relPath}" nodes=${nodes}`);
20776
+ } catch (err) {
20777
+ const message = err instanceof Error ? err.message : String(err);
20778
+ console.error(`[data] file-delete graph-cascade-failed path="${relPath}" err="${message}"`);
20779
+ }
20780
+ }
20539
20781
  return c.json({ ok: true });
20540
20782
  } catch (err) {
20541
20783
  const code = err.code;
@@ -20884,154 +21126,6 @@ function pruneNode(node) {
20884
21126
  }
20885
21127
  var graph_subgraph_default = app26;
20886
21128
 
20887
- // ../lib/graph-trash/src/index.ts
20888
- var UNIQUE_KEYS_BY_LABEL = {
20889
- Person: ["email", "telephone"],
20890
- Service: ["serviceId"],
20891
- LocalBusiness: ["accountId"],
20892
- Task: ["taskId"],
20893
- Event: ["eventId"],
20894
- KnowledgeDocument: ["attachmentId"],
20895
- DigitalDocument: ["attachmentId"],
20896
- Conversation: ["conversationId", "sessionKey"],
20897
- Message: ["messageId"],
20898
- OnboardingState: ["accountId"],
20899
- Workflow: ["workflowId"],
20900
- WorkflowStep: ["stepId"],
20901
- WorkflowRun: ["runId"],
20902
- Preference: ["preferenceId"],
20903
- Email: ["emailId", "messageId"],
20904
- AdminUser: ["userId"],
20905
- ToolCall: ["callId"],
20906
- // Composite component nulls — frees the composite constraint:
20907
- AccessGrant: ["contactValue"],
20908
- // composite (contactValue, agentSlug, accountId)
20909
- UserProfile: ["userId"]
20910
- // composite (accountId, userId)
20911
- };
20912
- async function trashNode(params) {
20913
- const { session, accountId, elementId, by, reason } = params;
20914
- const lookup = await session.run(
20915
- `MATCH (n) WHERE elementId(n) = $eid AND n.accountId = $accountId
20916
- RETURN labels(n) AS labels, properties(n) AS props`,
20917
- { eid: elementId, accountId }
20918
- );
20919
- if (lookup.records.length === 0) {
20920
- throw new Error(
20921
- `trashNode: node not found (elementId=${elementId} accountId=${accountId.slice(0, 8)}\u2026)`
20922
- );
20923
- }
20924
- const allLabels = lookup.records[0].get("labels");
20925
- const props = lookup.records[0].get("props");
20926
- const baseLabels = allLabels.filter((l) => l !== "Trashed");
20927
- if (allLabels.includes("Trashed")) {
20928
- return {
20929
- trashed: false,
20930
- alreadyTrashed: true,
20931
- nodeId: elementId,
20932
- labels: baseLabels,
20933
- trashedAt: String(props.trashedAt ?? ""),
20934
- originalKeys: {}
20935
- };
20936
- }
20937
- const uniqueKeys = /* @__PURE__ */ new Set();
20938
- for (const label of baseLabels) {
20939
- for (const key of UNIQUE_KEYS_BY_LABEL[label] ?? []) uniqueKeys.add(key);
20940
- }
20941
- const originalKeys = {};
20942
- for (const k of uniqueKeys) {
20943
- if (props[k] !== void 0 && props[k] !== null) originalKeys[k] = props[k];
20944
- }
20945
- const setNullClauses = Object.keys(originalKeys).map((k) => `n.\`${k}\` = null`).join(", ");
20946
- const setNullSuffix = setNullClauses ? `, ${setNullClauses}` : "";
20947
- const trashedAt = (/* @__PURE__ */ new Date()).toISOString();
20948
- await session.run(
20949
- `MATCH (n) WHERE elementId(n) = $eid
20950
- SET n:Trashed,
20951
- n.trashedAt = datetime($trashedAt),
20952
- n.trashedBy = $by,
20953
- n.trashReason = $reason,
20954
- n._trashedKeys = $trashedKeysJson${setNullSuffix}`,
20955
- {
20956
- eid: elementId,
20957
- trashedAt,
20958
- by,
20959
- reason: reason ?? null,
20960
- trashedKeysJson: JSON.stringify(originalKeys)
20961
- }
20962
- );
20963
- process.stderr.write(
20964
- `[trash:marked] accountId=${accountId} elementId=${elementId} labels=${baseLabels.join(",")} by=${by} reason=${reason ?? "null"}
20965
- `
20966
- );
20967
- return {
20968
- trashed: true,
20969
- alreadyTrashed: false,
20970
- nodeId: elementId,
20971
- labels: baseLabels,
20972
- trashedAt,
20973
- originalKeys
20974
- };
20975
- }
20976
- async function restoreNode(params) {
20977
- const { session, accountId, elementId } = params;
20978
- const lookup = await session.run(
20979
- `MATCH (n:Trashed) WHERE elementId(n) = $eid
20980
- RETURN labels(n) AS labels, n._trashedKeys AS keysJson`,
20981
- { eid: elementId }
20982
- );
20983
- if (lookup.records.length === 0) {
20984
- throw new Error(
20985
- `restoreNode: trashed node not found (elementId=${elementId})`
20986
- );
20987
- }
20988
- const allLabels = lookup.records[0].get("labels");
20989
- const baseLabels = allLabels.filter((l) => l !== "Trashed");
20990
- const keysJson = lookup.records[0].get("keysJson");
20991
- const originalKeys = keysJson ? JSON.parse(keysJson) : {};
20992
- for (const label of baseLabels) {
20993
- const uniqueKeys = UNIQUE_KEYS_BY_LABEL[label] ?? [];
20994
- for (const k of uniqueKeys) {
20995
- const v = originalKeys[k];
20996
- if (v === void 0 || v === null) continue;
20997
- const conflict = await session.run(
20998
- `MATCH (other:\`${label}\`)
20999
- WHERE elementId(other) <> $eid
21000
- AND NOT other:Trashed
21001
- AND other.\`${k}\` = $val
21002
- RETURN elementId(other) AS otherId LIMIT 1`,
21003
- { eid: elementId, val: v }
21004
- );
21005
- if (conflict.records.length > 0) {
21006
- const otherId = conflict.records[0].get("otherId");
21007
- throw new Error(
21008
- `restoreNode: cannot restore ${label} elementId=${elementId} \u2014 active node elementId=${otherId} already holds ${k}=${JSON.stringify(v)}`
21009
- );
21010
- }
21011
- }
21012
- }
21013
- const setClauses = Object.keys(originalKeys).map((k) => `n.\`${k}\` = $val_${k}`).join(", ");
21014
- const setSuffix = setClauses ? `, ${setClauses}` : "";
21015
- const setParams = { eid: elementId };
21016
- for (const [k, v] of Object.entries(originalKeys)) setParams[`val_${k}`] = v;
21017
- await session.run(
21018
- `MATCH (n:Trashed) WHERE elementId(n) = $eid
21019
- REMOVE n:Trashed, n.trashedAt, n.trashedBy, n.trashReason, n._trashedKeys
21020
- SET n.restoredAt = datetime()${setSuffix}`,
21021
- setParams
21022
- );
21023
- process.stderr.write(
21024
- `[trash:restored] accountId=${accountId} elementId=${elementId} labels=${baseLabels.join(",")}
21025
- `
21026
- );
21027
- return {
21028
- restored: true,
21029
- nodeId: elementId,
21030
- labels: baseLabels,
21031
- restoredKeys: originalKeys
21032
- };
21033
- }
21034
-
21035
21129
  // server/routes/admin/graph-delete.ts
21036
21130
  var app27 = new Hono2();
21037
21131
  app27.post("/", requireAdminSession, async (c) => {