@rubytech/create-realagent 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
|
@@ -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
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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)
|
|
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
|
-
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
|
|
454
|
-
let
|
|
455
|
-
let
|
|
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=${
|
|
514
|
+
`phase=dom-scrape-poll n=${polls} count=${count} domains=[${formatDomains(outcome.domains)}]`,
|
|
473
515
|
);
|
|
474
516
|
|
|
475
|
-
if (outcome.reason === "ok" &&
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
488
|
-
|
|
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
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
558
|
+
// Deadline reached. Two branches:
|
|
507
559
|
//
|
|
508
|
-
// (i) We
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
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)
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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=${
|
|
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
|
|
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
|
|
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
|
}
|
package/payload/server/server.js
CHANGED
|
@@ -20254,8 +20254,236 @@ function resolveDataPath(raw2) {
|
|
|
20254
20254
|
return { ok: true, absolute: resolvedReal, dataRootReal, relative: relPath };
|
|
20255
20255
|
}
|
|
20256
20256
|
|
|
20257
|
-
//
|
|
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 (!
|
|
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" &&
|
|
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 (
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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) => {
|