@rubytech/create-realagent 1.0.853 → 1.0.855
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/dist/index.js +9 -3
- package/package.json +1 -1
- package/payload/platform/lib/persistent-components/dist/index.d.ts +21 -0
- package/payload/platform/lib/persistent-components/dist/index.d.ts.map +1 -0
- package/payload/platform/lib/persistent-components/dist/index.js +32 -0
- package/payload/platform/lib/persistent-components/dist/index.js.map +1 -0
- package/payload/platform/lib/persistent-components/src/index.ts +28 -0
- package/payload/platform/lib/persistent-components/tsconfig.json +8 -0
- package/payload/platform/package.json +2 -2
- package/payload/platform/plugins/admin/PLUGIN.md +1 -1
- package/payload/platform/plugins/admin/hooks/__tests__/playwright-file-guard.test.sh +278 -0
- package/payload/platform/plugins/admin/hooks/playwright-file-guard.sh +204 -20
- package/payload/platform/plugins/admin/mcp/dist/index.js +40 -1
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/cloudflare/references/manual-setup.md +2 -0
- package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.sh +39 -10
- package/payload/platform/plugins/cloudflare/scripts/list-cf-domains.ts +112 -20
- package/payload/platform/plugins/docs/references/cloudflare.md +1 -0
- package/payload/platform/plugins/docs/references/deployment.md +2 -0
- package/payload/platform/plugins/docs/references/getting-started.md +2 -0
- package/payload/platform/plugins/docs/references/platform.md +1 -1
- package/payload/platform/plugins/docs/references/troubleshooting.md +10 -0
- package/payload/platform/scripts/admin-persist-audit.ts +191 -0
- package/payload/platform/scripts/component-knowledgedoc-backfill.ts +214 -0
- package/payload/platform/scripts/installer-device-verify.sh +17 -4
- package/payload/platform/templates/specialists/agents/content-producer.md +2 -2
- package/payload/server/chunk-CFNSKDGA.js +667 -0
- package/payload/server/chunk-DC6DWYZJ.js +1603 -0
- package/payload/server/chunk-LTB5SSQW.js +10889 -0
- package/payload/server/chunk-MN2LGNUB.js +2143 -0
- package/payload/server/client-pool-AMT2W3II.js +34 -0
- package/payload/server/cloudflare-task-tracker-LJ4SMK2D.js +20 -0
- package/payload/server/maxy-edge.js +3 -3
- package/payload/server/public/assets/admin-Cpk5cT4I.js +352 -0
- package/payload/server/public/assets/public-DApUXgoq.js +5 -0
- package/payload/server/public/assets/useVoiceRecorder-CI8GpxfU.js +36 -0
- package/payload/server/public/index.html +2 -2
- package/payload/server/public/public.html +2 -2
- package/payload/server/server.js +543 -354
- package/payload/server/public/assets/admin-Dyl8uNxX.js +0 -352
- package/payload/server/public/assets/public-B_PNZUph.js +0 -5
- package/payload/server/public/assets/useVoiceRecorder-fD0IWzJj.js +0 -36
|
@@ -5,11 +5,19 @@
|
|
|
5
5
|
# token on stderr tagged `[list-cf-domains]`.
|
|
6
6
|
#
|
|
7
7
|
# Usage:
|
|
8
|
-
# list-cf-domains.sh
|
|
8
|
+
# list-cf-domains.sh <brand>
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# Task 954: brand arg is REQUIRED — the prior silent default (string
|
|
11
|
+
# fallback to "maxy" when $1 was empty) made every non-Maxy brand's CF
|
|
12
|
+
# setup form fail on first use because the wrapper's child node helper
|
|
13
|
+
# read the Maxy CDP port from a hardcoded fallback. Missing arg now exits
|
|
14
|
+
# 1 with `phase=error reason=brand-arg-missing`.
|
|
15
|
+
#
|
|
16
|
+
# The wrapper also resolves and exports MAXY_PLATFORM_ROOT from its own
|
|
17
|
+
# script location so the .ts helper can read `<root>/config/brand.json` for
|
|
18
|
+
# the brand's `cdpPort` (Task 924's source of truth). Direct-SSH invocation
|
|
19
|
+
# works because the script lives at `<install>/platform/plugins/cloudflare/
|
|
20
|
+
# scripts/`, making platform root three dirs up from the resolved script.
|
|
13
21
|
#
|
|
14
22
|
# Runtime: Node 22's `--experimental-strip-types` runs the .ts helper
|
|
15
23
|
# directly, so no tsx / playwright / ws dependency exists.
|
|
@@ -24,16 +32,29 @@ set -euo pipefail
|
|
|
24
32
|
# Resolve symlinks before dirname — ~/list-cf-domains.sh is installed as a
|
|
25
33
|
# symlink into $HOME, so the raw BASH_SOURCE[0] points at $HOME, not the
|
|
26
34
|
# scripts directory where _stream-log.sh lives.
|
|
27
|
-
|
|
35
|
+
SCRIPT_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
|
|
36
|
+
source "${SCRIPT_DIR}/_stream-log.sh"
|
|
28
37
|
require_stream_log_path list-cf-domains
|
|
29
38
|
|
|
30
|
-
|
|
39
|
+
if [ "$#" -lt 1 ] || [ -z "${1:-}" ]; then
|
|
40
|
+
phase_line list-cf-domains phase=error reason=brand-arg-missing
|
|
41
|
+
exit 1
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
BRAND="${1}"
|
|
31
45
|
CONFIG_DIR=".${BRAND}"
|
|
32
46
|
mkdir -p "${HOME}/${CONFIG_DIR}/logs"
|
|
33
47
|
|
|
48
|
+
# MAXY_PLATFORM_ROOT is set by the systemd service when the admin server
|
|
49
|
+
# spawns this script via runFormSpawn. Direct-SSH invocation has no such env
|
|
50
|
+
# so derive it from the resolved script location: scripts/ → cloudflare/ →
|
|
51
|
+
# plugins/ → platform → install root (three dirs up).
|
|
52
|
+
if [ -z "${MAXY_PLATFORM_ROOT:-}" ]; then
|
|
53
|
+
MAXY_PLATFORM_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
|
|
54
|
+
fi
|
|
55
|
+
|
|
34
56
|
phase_line list-cf-domains phase=script-start brand="${BRAND}" config_dir="${CONFIG_DIR}"
|
|
35
57
|
|
|
36
|
-
SCRIPT_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
|
|
37
58
|
TS_ENTRY="${SCRIPT_DIR}/list-cf-domains.ts"
|
|
38
59
|
|
|
39
60
|
if [ ! -f "${TS_ENTRY}" ]; then
|
|
@@ -47,15 +68,23 @@ fi
|
|
|
47
68
|
# (124) from a helper-reported failure (1).
|
|
48
69
|
HARD_TIMEOUT_SECS=30
|
|
49
70
|
|
|
50
|
-
#
|
|
51
|
-
#
|
|
71
|
+
# Env passed through:
|
|
72
|
+
# - CONFIG_DIR — consumed by the node helper when writing selector-drift
|
|
73
|
+
# dumps to ~/.${BRAND}/logs/list-cf-domains-<ts>.html.
|
|
74
|
+
# - BRAND — names the brand for log lines naming the brand on
|
|
75
|
+
# config-class failures (helper does not derive it from
|
|
76
|
+
# CONFIG_DIR to keep the two concerns independent).
|
|
77
|
+
# - MAXY_PLATFORM_ROOT — install root the helper reads `config/brand.json`
|
|
78
|
+
# from for `cdpPort`. Resolved above so direct-SSH
|
|
79
|
+
# invocation matches the systemd-spawned path.
|
|
52
80
|
#
|
|
53
81
|
# `node --experimental-strip-types` (stable in Node 22.22) runs the .ts file
|
|
54
82
|
# natively: type annotations are stripped, no enums / decorators are used, so
|
|
55
83
|
# no TS type-transform is needed. `--no-warnings` suppresses the experimental
|
|
56
84
|
# banner which would otherwise leak into stderr and confuse the route parser.
|
|
57
85
|
set +e
|
|
58
|
-
CONFIG_DIR="${CONFIG_DIR}"
|
|
86
|
+
CONFIG_DIR="${CONFIG_DIR}" BRAND="${BRAND}" MAXY_PLATFORM_ROOT="${MAXY_PLATFORM_ROOT}" \
|
|
87
|
+
timeout --preserve-status --signal=TERM "${HARD_TIMEOUT_SECS}" \
|
|
59
88
|
node --experimental-strip-types --no-warnings "${TS_ENTRY}"
|
|
60
89
|
EXIT_CODE=$?
|
|
61
90
|
set -e
|
|
@@ -42,19 +42,29 @@
|
|
|
42
42
|
// "selector drift" from "empty account" — both shapes arrive via
|
|
43
43
|
// stdout).
|
|
44
44
|
|
|
45
|
-
import { appendFileSync } from "node:fs";
|
|
45
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
46
46
|
import { writeFile } from "node:fs/promises";
|
|
47
47
|
import { resolve } from "node:path";
|
|
48
48
|
import { homedir } from "node:os";
|
|
49
49
|
import { fileURLToPath } from "node:url";
|
|
50
50
|
|
|
51
|
-
// CDP
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
// CDP endpoint is resolved lazily inside `main()` so importing this module
|
|
52
|
+
// for in-process tests (`scrapeCurrentPage` / `scrapeDomains` under JSDOM)
|
|
53
|
+
// does not require an installed brand on disk.
|
|
54
|
+
//
|
|
55
|
+
// Task 954 source-of-truth contract:
|
|
56
|
+
// Runtime: brand.json `cdpPort` at `${MAXY_PLATFORM_ROOT}/config/brand.json`
|
|
57
|
+
// is authoritative. Wrapper exports MAXY_PLATFORM_ROOT and BRAND. Missing
|
|
58
|
+
// env / file / field → loud-fail with one of three named reasons. The
|
|
59
|
+
// pre-Task-954 silent CDP-port default made every non-Maxy brand fail
|
|
60
|
+
// `cdp-unreachable` because the helper hit Maxy's port instead of the
|
|
61
|
+
// brand's; Task 787 / NEO4J_URI sets the precedent that runtime config
|
|
62
|
+
// never falls back silently.
|
|
63
|
+
//
|
|
64
|
+
// Test overrides: when BOTH `LIST_CF_DOMAINS_CDP_HOST` and
|
|
65
|
+
// `LIST_CF_DOMAINS_CDP_PORT` are set, they win over brand.json. The
|
|
66
|
+
// existing ECONNREFUSED vitest forces `127.0.0.1:1` (RFC 6335-reserved)
|
|
67
|
+
// this way without an installed brand. Production never sets these.
|
|
58
68
|
|
|
59
69
|
// Phase budgets total 30s (enforced by the shell wrapper's `timeout`).
|
|
60
70
|
// Individual steps get sub-budgets so an early stall does not eat the
|
|
@@ -72,7 +82,14 @@ type Reason =
|
|
|
72
82
|
| "not-signed-in"
|
|
73
83
|
| "navigate-failed"
|
|
74
84
|
| "table-wait-timeout"
|
|
75
|
-
| "runtime-error"
|
|
85
|
+
| "runtime-error"
|
|
86
|
+
// Task 954 — config-class failures. The first three short-circuit before
|
|
87
|
+
// any CDP work begins; the route maps them to `field='config'` so the
|
|
88
|
+
// form renders a config-card naming the brand + path instead of a Retry
|
|
89
|
+
// button (retrying re-fires the same wrongly-resolved spawn — Task 787
|
|
90
|
+
// pattern).
|
|
91
|
+
| "brand-config-missing"
|
|
92
|
+
| "cdp-port-unresolved";
|
|
76
93
|
|
|
77
94
|
// Sticky flag: once a stream-log write fails (EACCES, ENOENT, …), skip
|
|
78
95
|
// subsequent file writes for the rest of this process. Repeated appends to an
|
|
@@ -120,11 +137,85 @@ function die(reason: Reason, detail = ""): never {
|
|
|
120
137
|
process.exit(1);
|
|
121
138
|
}
|
|
122
139
|
|
|
123
|
-
|
|
140
|
+
interface CdpEndpoint {
|
|
141
|
+
host: string;
|
|
142
|
+
port: number;
|
|
143
|
+
source: "env-override" | "brand.json";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Task 954 — single source of truth for the CDP endpoint. Called once from
|
|
147
|
+
// `main()` so the JSDOM scrape test (which imports `scrapeCurrentPage`
|
|
148
|
+
// directly without invoking `main`) does not need an installed brand.
|
|
149
|
+
function resolveCdpEndpoint(): CdpEndpoint {
|
|
150
|
+
const envHost = process.env.LIST_CF_DOMAINS_CDP_HOST;
|
|
151
|
+
const envPortRaw = process.env.LIST_CF_DOMAINS_CDP_PORT;
|
|
152
|
+
if (envHost !== undefined && envPortRaw !== undefined) {
|
|
153
|
+
const envPort = Number(envPortRaw);
|
|
154
|
+
if (!Number.isInteger(envPort) || envPort <= 0 || envPort > 65535) {
|
|
155
|
+
die(
|
|
156
|
+
"cdp-port-unresolved",
|
|
157
|
+
`LIST_CF_DOMAINS_CDP_PORT="${envPortRaw}" is not a valid integer port`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return { host: envHost, port: envPort, source: "env-override" };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const brand = process.env.BRAND;
|
|
164
|
+
if (!brand) {
|
|
165
|
+
die(
|
|
166
|
+
"brand-config-missing",
|
|
167
|
+
"BRAND env var not set by wrapper — refusing to guess brand identity",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const platformRoot = process.env.MAXY_PLATFORM_ROOT;
|
|
171
|
+
if (!platformRoot) {
|
|
172
|
+
die(
|
|
173
|
+
"brand-config-missing",
|
|
174
|
+
"MAXY_PLATFORM_ROOT env var not set — wrapper must export it before spawn",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const brandPath = resolve(platformRoot, "config", "brand.json");
|
|
178
|
+
let raw: string;
|
|
179
|
+
try {
|
|
180
|
+
raw = readFileSync(brandPath, "utf-8");
|
|
181
|
+
} catch (err) {
|
|
182
|
+
// Distinct emission (not via `die`) so the path is named on the same line
|
|
183
|
+
// — config-card rendering on the route side keys off it.
|
|
184
|
+
logPhase(
|
|
185
|
+
`phase=error reason=brand-config-missing path=${brandPath} detail="${(err instanceof Error ? err.message : String(err)).replace(/"/g, "'").slice(0, 200)}"`,
|
|
186
|
+
);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
let parsed: Record<string, unknown>;
|
|
190
|
+
try {
|
|
191
|
+
parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
logPhase(
|
|
194
|
+
`phase=error reason=brand-config-missing path=${brandPath} detail="parse failed: ${(err instanceof Error ? err.message : String(err)).replace(/"/g, "'").slice(0, 160)}"`,
|
|
195
|
+
);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
const cdpPort = parsed.cdpPort;
|
|
199
|
+
if (typeof cdpPort !== "number" || !Number.isInteger(cdpPort) || cdpPort <= 0 || cdpPort > 65535) {
|
|
200
|
+
// Names the keys actually present so a schema-drift incident surfaces
|
|
201
|
+
// immediately ("oh, brand.json renamed `cdpPort` to `cdp_port` in r123").
|
|
202
|
+
const keys = Object.keys(parsed).join(",");
|
|
203
|
+
logPhase(
|
|
204
|
+
`phase=error reason=cdp-port-unresolved brand=${brand} json_keys=${keys}`,
|
|
205
|
+
);
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
// Host stays 127.0.0.1 on the runtime path — vnc.sh binds Chromium's CDP
|
|
209
|
+
// server to localhost only, by construction. brand.json carries the port,
|
|
210
|
+
// not the host, because the host has never varied across brands.
|
|
211
|
+
return { host: "127.0.0.1", port: cdpPort, source: "brand.json" };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function cdpVersion(host: string, port: number): Promise<void> {
|
|
124
215
|
const controller = new AbortController();
|
|
125
216
|
const timer = setTimeout(() => controller.abort(), CONNECT_TIMEOUT_MS);
|
|
126
217
|
try {
|
|
127
|
-
const res = await fetch(`http://${
|
|
218
|
+
const res = await fetch(`http://${host}:${port}/json/version`, { signal: controller.signal });
|
|
128
219
|
if (!res.ok) die("cdp-unreachable", `HTTP ${res.status}`);
|
|
129
220
|
} catch (err) {
|
|
130
221
|
die("cdp-unreachable", err instanceof Error ? err.message : String(err));
|
|
@@ -138,12 +229,12 @@ interface CdpTarget {
|
|
|
138
229
|
webSocketDebuggerUrl: string;
|
|
139
230
|
}
|
|
140
231
|
|
|
141
|
-
async function createTarget(): Promise<CdpTarget> {
|
|
232
|
+
async function createTarget(host: string, port: number): Promise<CdpTarget> {
|
|
142
233
|
// `PUT /json/new?<url>` returns JSON describing the target. The URL goes
|
|
143
234
|
// as a query-string argument (not a ?url= key), matching Chromium's CDP
|
|
144
235
|
// server. about:blank avoids a wasted navigation — the real navigate
|
|
145
236
|
// happens over WS after we attach.
|
|
146
|
-
const endpoint = `http://${
|
|
237
|
+
const endpoint = `http://${host}:${port}/json/new?about:blank`;
|
|
147
238
|
let res: Response;
|
|
148
239
|
try {
|
|
149
240
|
res = await fetch(endpoint, { method: "PUT", signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) });
|
|
@@ -158,9 +249,9 @@ async function createTarget(): Promise<CdpTarget> {
|
|
|
158
249
|
return target as CdpTarget;
|
|
159
250
|
}
|
|
160
251
|
|
|
161
|
-
async function closeTarget(id: string): Promise<void> {
|
|
252
|
+
async function closeTarget(host: string, port: number, id: string): Promise<void> {
|
|
162
253
|
try {
|
|
163
|
-
await fetch(`http://${
|
|
254
|
+
await fetch(`http://${host}:${port}/json/close/${id}`, {
|
|
164
255
|
signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS),
|
|
165
256
|
});
|
|
166
257
|
} catch {
|
|
@@ -602,19 +693,20 @@ async function waitForDocumentReady(cdp: CdpClient): Promise<void> {
|
|
|
602
693
|
}
|
|
603
694
|
|
|
604
695
|
async function main(): Promise<void> {
|
|
605
|
-
|
|
696
|
+
const endpoint = resolveCdpEndpoint();
|
|
697
|
+
logPhase(`phase=script-start cdp=${endpoint.host}:${endpoint.port} source=${endpoint.source}`);
|
|
606
698
|
|
|
607
|
-
await cdpVersion();
|
|
699
|
+
await cdpVersion(endpoint.host, endpoint.port);
|
|
608
700
|
logPhase(`phase=cdp-connect result=ok`);
|
|
609
701
|
|
|
610
|
-
const target = await createTarget();
|
|
702
|
+
const target = await createTarget(endpoint.host, endpoint.port);
|
|
611
703
|
logPhase(`phase=target-created targetId=${target.id.slice(0, 8)}…`);
|
|
612
704
|
|
|
613
705
|
let cdp: CdpClient;
|
|
614
706
|
try {
|
|
615
707
|
cdp = await CdpClient.connect(target.webSocketDebuggerUrl);
|
|
616
708
|
} catch (err) {
|
|
617
|
-
await closeTarget(target.id);
|
|
709
|
+
await closeTarget(endpoint.host, endpoint.port, target.id);
|
|
618
710
|
die("ws-connect-failed", err instanceof Error ? err.message : String(err));
|
|
619
711
|
}
|
|
620
712
|
|
|
@@ -643,7 +735,7 @@ async function main(): Promise<void> {
|
|
|
643
735
|
die("runtime-error", err instanceof Error ? err.message : String(err));
|
|
644
736
|
} finally {
|
|
645
737
|
cdp.close();
|
|
646
|
-
await closeTarget(target.id);
|
|
738
|
+
await closeTarget(endpoint.host, endpoint.port, target.id);
|
|
647
739
|
}
|
|
648
740
|
}
|
|
649
741
|
|
|
@@ -9,6 +9,7 @@ Each installation has its own Cloudflare account. Sign-in is OAuth in the device
|
|
|
9
9
|
| **Product identity** (Maxy vs Real Agent) | `brand.json` (`productName`, `configDir`) — known at install. |
|
|
10
10
|
| **Cloudflare account identity** | `cert.pem` from OAuth. One account per brand per device. |
|
|
11
11
|
| **Domain scope** (which zones the operator can route) | Live Cloudflare dashboard at form-render time via `list-cf-domains.sh`, not `brand.json`. Brand identity has no authority over which domains the operator's CF account holds. When the scrape returns an unexpected count (e.g. 1 on a two-zone account), the stream log's per-poll `phase=dom-scrape-poll n=<k> count=<n> domains=[…]` trajectory + the on-disk HTML dump at `~/{configDir}/logs/list-cf-domains-<ts>-count<n>-<mode>-pid<pid>.html` (earlier platform fixes — written on every scrape outcome, not just empty ones) give the operator everything they need to triage the cause without re-running. |
|
|
12
|
+
| **CDP port the scrape attaches to** | `brand.json` (`cdpPort`, stamped at install time) — the same brand-scoped port `vnc.sh` binds Chromium to. `list-cf-domains.sh <brand>` requires the brand arg; the helper reads `${MAXY_PLATFORM_ROOT}/config/brand.json` (no silent default). Missing brand arg, missing brand.json, or missing `cdpPort` field each exit 1 with one of three named reasons (`brand-arg-missing`, `brand-config-missing`, `cdp-port-unresolved`); the route maps all three to `field=config` so the form renders a config card naming the brand instead of a Retry button — retrying would re-fire the same wrongly-resolved spawn. |
|
|
12
13
|
| **Local tunnel state** | `~/{configDir}/cloudflared/` — `cert.pem`, `<UUID>.json`, `config.yml`, `tunnel.state`, `alias-domains.json`. |
|
|
13
14
|
|
|
14
15
|
There is no token-based auth for the operator-owned path (Mode A). To switch Cloudflare accounts, run `reset-tunnel.sh` (which deletes the cert and every tunnel on the current account), then run `setup-tunnel.sh` again — `cloudflared tunnel login` inside the setup script will pick a fresh account when you sign in.
|
|
@@ -115,6 +115,8 @@ The installer detects this case during system-dependency setup and replaces the
|
|
|
115
115
|
|
|
116
116
|
The post-install acceptance gate at `platform/scripts/test-laptop-vnc-boot.sh` runs four checks: the configured Chromium realpath is non-snap, the path is absolute and executable, the per-brand CDP port returns Chromium version JSON, and the VNC boot log ends with `VNC + browser stack running` with no preceding `Chromium failed to start`. The gate runs automatically at the end of every install on Linux; manual invocation is `MAXY_PLATFORM_ROOT=<install-dir>/platform <install-dir>/platform/scripts/test-laptop-vnc-boot.sh`.
|
|
117
117
|
|
|
118
|
+
A separate operator-side harness at `platform/scripts/installer-device-verify.sh <published-version>` runs after every npm publish to confirm the installer reaches a terminal-success marker on each device in the operator's manifest. Two markers are accepted because the installer's CDP probe behaves differently per `DISPLAY_MODE`: `Browser automation ready (CDP connected)` on Pi (virtual display, persistent Chromium) and `[cdp-check] skipped reason=native-display` on laptop (native display, on-demand Chromium). Either is a pass. The harness is operator-only — end users do not run it.
|
|
119
|
+
|
|
118
120
|
## Running multiple brands on one device
|
|
119
121
|
|
|
120
122
|
A single Pi or laptop can host more than one brand (for example Maxy and Real Agent) side by side. Each brand runs as its own service on its own port, with its own install directory and its own data. Installing one brand does not touch the other.
|
|
@@ -60,6 +60,8 @@ When the text field is empty, a microphone button appears in place of the send b
|
|
|
60
60
|
|
|
61
61
|
Voice recording requires a secure connection (HTTPS). When accessing {{productName}} over the local network via HTTP, use the tunnel URL for voice notes.
|
|
62
62
|
|
|
63
|
+
You can also drop, paste, or pick an audio file (`.opus`, `.ogg`, `.m4a`, `.mp3`, `.wav`, `.webm`) into the chat composer — for example a voice note forwarded from WhatsApp. The file is transcribed the same way the in-browser recording is, and only the transcript reaches {{productName}}; the audio itself is discarded after transcription.
|
|
64
|
+
|
|
63
65
|
## What {{productName}} Remembers
|
|
64
66
|
|
|
65
67
|
{{productName}} maintains a memory graph of everything important: contacts, conversations, preferences, relationships, and context. When you tell {{productName}} something, it stores it. When you ask about something later, it retrieves it.
|
|
@@ -65,7 +65,7 @@ There is no dashboard, no settings panel, no menus. Everything is done through c
|
|
|
65
65
|
|
|
66
66
|
The chat input auto-grows as you type — it expands to fit your message and shrinks back when you delete text. You can also drag the resize handle above the input to set a custom height.
|
|
67
67
|
|
|
68
|
-
The admin interface is a three-pane layout: a sidebar on the left with your brand mark, navigation (Chat, People, Agents, Projects, Tasks, Artefacts), and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu — holding the surface side-by-side with the conversation so the chat stays live while you work in it. The sidebar's nav rows swap the list view in place — Chat shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). The People, Agents, and Tasks rows are graph shortcuts: clicking each opens the artefact-pane Graph filtered to every Person, every public Agent, or every Task in your account respectively, with no side-list — the graph itself is the result. Public agents become first-class graph entities the moment you create them, with edges to their IDENTITY/SOUL/KNOWLEDGE files, edges to every knowledge document they have access to, and edges from every conversation they have handled, so a single Agents click reveals the whole shape of who knows what and who has been talking to whom. Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable — type in the document and changes save automatically; specialist agent templates are read-only because they ship with Maxy and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood — clicking a second project swaps the focus rather than stacking on top. The chat / artefact divider is drag-resizable — drag the line between the columns to make either side wider; double-click it to reset to half of the available width (viewport minus sidebar), clamped to the chat / artefact min-width floors. Your chosen width is remembered across reloads. On wider screens (>1280px) all three panes are visible. The sidebar narrows at 1280px, the artefact pane hides at 1080px (Browser, Data, and Graph then open as full-window pages instead), and the sidebar collapses to a 56px icon rail at 820px. On phones (<720px) the sidebar slides in as a drawer from the left when you tap the menu icon in the chat header.
|
|
68
|
+
The admin interface is a three-pane layout: a sidebar on the left with your brand mark, navigation (Chat, People, Agents, Projects, Tasks, Artefacts), and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu — holding the surface side-by-side with the conversation so the chat stays live while you work in it. The sidebar's nav rows swap the list view in place — Chat shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). The People, Agents, and Tasks rows are graph shortcuts: clicking each opens the artefact-pane Graph filtered to every Person, every public Agent, or every Task in your account respectively, with no side-list — the graph itself is the result. Public agents become first-class graph entities the moment you create them, with edges to their IDENTITY/SOUL/KNOWLEDGE files, edges to every knowledge document they have access to, and edges from every conversation they have handled, so a single Agents click reveals the whole shape of who knows what and who has been talking to whom. Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable — type in the document and changes save automatically; specialist agent templates are read-only because they ship with Maxy and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood — clicking a second project swaps the focus rather than stacking on top. The chat / artefact divider is drag-resizable — drag the line between the columns to make either side wider; double-click it to reset to half of the available width (viewport minus sidebar), clamped to the chat / artefact min-width floors. Your chosen width is remembered across reloads. On wider screens (>1280px) all three panes are visible. The sidebar narrows at 1280px, the artefact pane hides at 1080px (Browser, Data, and Graph then open as full-window pages instead), and the sidebar collapses to a 56px icon rail at 820px. On phones (<720px) the sidebar slides in as a drawer from the left when you tap the menu icon in the chat header. When the sidebar is collapsed to the 56px icon rail, clicking the Artefacts icon expands the rail back open so the artefact list is visible — the row was previously a silent no-op in collapsed state.
|
|
69
69
|
|
|
70
70
|
Page titles are brand-aware: the browser tab shows your product name (e.g. `Real Agent` instead of `Maxy`) on every shell — chat, graph, and data — so a non-default brand never leaks the default name in tab strips or browser history.
|
|
71
71
|
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Troubleshooting
|
|
2
2
|
|
|
3
|
+
## Browser navigation to a local file (`file://`) used to time out for two minutes
|
|
4
|
+
|
|
5
|
+
**Symptom:** Older versions of the platform's admin agent would attempt `browser_navigate file:///path/to.html`, hit Playwright's silent two-minute timeout, then guess fixed ports (8080 / 3000 / 8000 / 9000) and report `ERR_CONNECTION_REFUSED` for each before someone manually started a local HTTP server.
|
|
6
|
+
|
|
7
|
+
**Resolution shipped:** The `playwright-file-guard` PreToolUse hook (admin plugin) intercepts `file://` URLs, picks a free loopback port, backgrounds `python3 -m http.server` rooted at the file's parent directory, connect-verifies the server within one second, and rewrites the tool call's URL to `http://127.0.0.1:<port>/<basename>` before Playwright sees it. The agent never sees the rewrite. Stale server processes are reaped opportunistically on every hook invocation (1 h threshold, gated by a `ps` cmdline check that won't kill a reused PID).
|
|
8
|
+
|
|
9
|
+
**Diagnose if it ever recurs:** grep the per-conversation stream log for `[playwright-file-guard] action=`. One `action=rewrite original=file://… port=<n> pid=<m>` line per file:// navigate is the healthy signal. `action=fail reason=<r>` indicates the hook tried to rewrite but failed open (Playwright handled the original URL); the reason field names the cause (`python3-missing`, `port-pick-failed`, `server-not-ready`, `file-not-found`, `spawn-failed`). The `cleanup` argv on the hook script can be invoked manually to sweep `/tmp/playwright-file-guard.*.pid`; the suite at `platform/plugins/admin/hooks/__tests__/playwright-file-guard.test.sh` exercises every path.
|
|
10
|
+
|
|
3
11
|
## First user-domain write rejected by `[graph-write-gate] reject reason=no-admin-user`
|
|
4
12
|
|
|
5
13
|
**Symptom:** Admin chat reports "couldn't save that — set up your business profile first" or `[graph-write-gate] reject reason=no-admin-user` appears in `server.log` on the operator's first non-bootstrap write (a website, service, opening hours, etc.). Reproduces on Minimal-onboarded installs from before the seed-stamping fix shipped.
|
|
@@ -64,6 +72,8 @@ tail -200 ~/.maxy/logs/maxy-ui.log | rg '\[remote-auth\].*resolvedKind='
|
|
|
64
72
|
|
|
65
73
|
**Agent searches the filesystem after uploading a zip.** If you uploaded a zip and the agent burns several turns running `find` / `Glob` instead of unzipping, that is the symptom of the recovery-retry attachment-context regression (now closed by the recovery context preservation contract in `.docs/agents.md`). Greppable confirmation is the `[context-overflow-recovery] retry … attachmentsCarried=<n>` line in the conversation stream log. If you see `[context-overflow-recovery] WARN attachment-context-lost`, the regression has returned — surface to support.
|
|
66
74
|
|
|
75
|
+
|
|
76
|
+
**A turn rendered in chat is missing on next page-refresh.** Pre-the 2026-05-07 mandate this was a class of silent failure — Neo4j persists were wrapped in a no-op error catch and a write that threw left the artefact "rendered then disappeared on resume". The 2026-05-07 mandate makes JSONL canonical: the resume route reads the SDK transcript file at `~/.claude/projects/<project-key>/<sessionId>.jsonl` first, supplements from Neo4j, and triggers async heal-on-resume writes for any turn the JSONL has but Neo4j does not. So a refreshed conversation always renders what the SDK saw, regardless of write outcome. If a heal write itself fails, the chat shows a top-of-conversation banner naming the count; if every heal succeeds the resume is silent and the missing rows are quietly restored to Neo4j. Greppable post-deploy invariants in the per-conversation stream log (`logs/claude-agent-stream-<conversationId>.log`): `[admin-resume] reason=<…> source=<jsonl|jsonl-missing|neo4j-only>` (one per resume), `[admin-persist] convId=<8> writer=<…> outcome=<ok|fail|skip>` (per persist site), `[admin-persist-heal] convId=<8> turnIndex=<n> outcome=<ok|fail>` (per heal write). To force-audit a specific conversation against its Neo4j projection without re-executing it, run `tsx platform/scripts/admin-persist-audit.ts --conversation-id=<uuid> --account-id=<uuid> --session-id=<uuid>` — non-zero exit + per-divergence `[admin-persist-audit] expected=<message|component> missing reason=neo4j-row-absent` lines name what would have been silently lost pre-mandate.
|
|
67
77
|
**Wrong Claude account answering on a multi-brand device.** On a host running both Maxy and Real Agent, each brand's admin agent reads its own `~/${brand.configDir}/.claude/.credentials.json`; there is no longer a shared `~/.claude/` thrashing them against one another. If a brand reports auth failures or appears to be operating against the wrong subscription, check three things:
|
|
68
78
|
1. `grep "\[claude-auth\] init" ~/.${brand}/logs/server.log | tail -1` — the resolved path must end with `~/.${brand}/.claude/.credentials.json`. If a `[claude-auth] WARN cross-brand-path-detected` line is present, the runtime is still pointing at `~/.claude/`; the brand main service did not pick up the `Environment=CLAUDE_CONFIG_DIR=` setting (re-run the brand installer to refresh the unit file).
|
|
69
79
|
2. `diff <(jq .claudeAiOauth.accessToken ~/.maxy/.claude/.credentials.json) <(jq .claudeAiOauth.accessToken ~/.realagent/.claude/.credentials.json)` — must be non-empty after each brand's operator has run `claude /login` against distinct Anthropic accounts; if it's empty, both brands are still logged in to the same account (operator action, not a code bug).
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --loader tsx
|
|
2
|
+
/**
|
|
3
|
+
* Task 940 — admin persist audit harness.
|
|
4
|
+
*
|
|
5
|
+
* Compares JSONL canonical state against Neo4j projection for a given
|
|
6
|
+
* conversationId. Prints one [admin-persist-audit] divergence line per
|
|
7
|
+
* (sdkTurnUuid, expected) gap; non-zero exit on any mismatch. Designed to
|
|
8
|
+
* run against an operator-supplied stream log fixture WITHOUT re-executing
|
|
9
|
+
* the live session — JSONL is the only ground truth this script consults.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* tsx platform/scripts/admin-persist-audit.ts \
|
|
13
|
+
* --conversation-id=<uuid> \
|
|
14
|
+
* --account-id=<uuid> \
|
|
15
|
+
* --jsonl=<path> # optional override; otherwise resolved from accountId+sessionId
|
|
16
|
+
* --session-id=<uuid> # required if --jsonl not provided
|
|
17
|
+
*
|
|
18
|
+
* Exit codes:
|
|
19
|
+
* 0 = no divergences
|
|
20
|
+
* 1 = at least one Message or Component absent from Neo4j
|
|
21
|
+
* 2 = invocation error (missing args, file unreadable, Neo4j unreachable)
|
|
22
|
+
*
|
|
23
|
+
* Why audit-only and not also auto-heal: the heal-on-resume writer at
|
|
24
|
+
* server/routes/admin/sessions.ts handles the live path. This harness exists
|
|
25
|
+
* for forensic investigation against operator-supplied JSONLs (e.g. the
|
|
26
|
+
* 2026-05-07 stream log that motivated this task) where the live session
|
|
27
|
+
* has long since terminated.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { resolve } from "node:path";
|
|
33
|
+
import process from "node:process";
|
|
34
|
+
|
|
35
|
+
import { replayJsonl, resolveJsonlPath } from "../ui/app/lib/claude-agent/jsonl-replay";
|
|
36
|
+
import { ACCOUNTS_DIR } from "../ui/app/lib/claude-agent/account";
|
|
37
|
+
import { getRecentMessages, getSession } from "../ui/app/lib/neo4j-store";
|
|
38
|
+
import { PERSISTENT_COMPONENTS } from "../lib/persistent-components/src/index";
|
|
39
|
+
|
|
40
|
+
interface Args {
|
|
41
|
+
conversationId: string;
|
|
42
|
+
accountId: string;
|
|
43
|
+
jsonlPath: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseArgs(argv: string[]): Args | { error: string } {
|
|
47
|
+
const out: Partial<Args> = {};
|
|
48
|
+
let sessionId: string | undefined;
|
|
49
|
+
let jsonlOverride: string | undefined;
|
|
50
|
+
for (const a of argv) {
|
|
51
|
+
const m = a.match(/^--([a-z-]+)=(.+)$/);
|
|
52
|
+
if (!m) continue;
|
|
53
|
+
const [, key, val] = m;
|
|
54
|
+
if (key === "conversation-id") out.conversationId = val;
|
|
55
|
+
else if (key === "account-id") out.accountId = val;
|
|
56
|
+
else if (key === "session-id") sessionId = val;
|
|
57
|
+
else if (key === "jsonl") jsonlOverride = val;
|
|
58
|
+
}
|
|
59
|
+
if (!out.conversationId) return { error: "--conversation-id required" };
|
|
60
|
+
if (!out.accountId) return { error: "--account-id required" };
|
|
61
|
+
if (jsonlOverride) {
|
|
62
|
+
out.jsonlPath = resolve(jsonlOverride);
|
|
63
|
+
} else if (sessionId) {
|
|
64
|
+
const accountDir = resolve(ACCOUNTS_DIR, out.accountId);
|
|
65
|
+
out.jsonlPath = resolveJsonlPath(homedir(), accountDir, sessionId);
|
|
66
|
+
} else {
|
|
67
|
+
return { error: "either --jsonl or --session-id required" };
|
|
68
|
+
}
|
|
69
|
+
return out as Args;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function main(): Promise<number> {
|
|
73
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
74
|
+
if ("error" in parsed) {
|
|
75
|
+
console.error(`[admin-persist-audit] usage error: ${parsed.error}`);
|
|
76
|
+
return 2;
|
|
77
|
+
}
|
|
78
|
+
const { conversationId, jsonlPath } = parsed;
|
|
79
|
+
|
|
80
|
+
if (!existsSync(jsonlPath)) {
|
|
81
|
+
console.error(`[admin-persist-audit] jsonl absent path=${jsonlPath} convId=${conversationId.slice(0, 8)}`);
|
|
82
|
+
return 2;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// JSONL replay — derives the canonical message stream and the expected
|
|
86
|
+
// component side-effects.
|
|
87
|
+
const replay = replayJsonl(jsonlPath);
|
|
88
|
+
if (replay.malformedLines > 0) {
|
|
89
|
+
console.error(`[admin-persist-audit] jsonl-malformed-lines convId=${conversationId.slice(0, 8)} count=${replay.malformedLines}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Neo4j projection — what the resume route would have rendered pre-940.
|
|
93
|
+
let neo4j: Awaited<ReturnType<typeof getRecentMessages>>;
|
|
94
|
+
try {
|
|
95
|
+
neo4j = await getRecentMessages(conversationId, 1000);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error(`[admin-persist-audit] neo4j-read-failed convId=${conversationId.slice(0, 8)} reason=${err instanceof Error ? err.message : String(err)}`);
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Build a set of (role, content) keys present in Neo4j for fast lookup.
|
|
102
|
+
// Same matching key the resume route uses, for parity.
|
|
103
|
+
const neo4jByKey = new Map<string, typeof neo4j[number]>();
|
|
104
|
+
for (const n of neo4j) neo4jByKey.set(`${n.role}\x1f${n.content}`, n);
|
|
105
|
+
|
|
106
|
+
let divergences = 0;
|
|
107
|
+
for (const j of replay.messages) {
|
|
108
|
+
const key = `${j.role}\x1f${j.content}`;
|
|
109
|
+
const match = neo4jByKey.get(key);
|
|
110
|
+
if (!match) {
|
|
111
|
+
// Whole message absent from Neo4j.
|
|
112
|
+
console.log(`[admin-persist-audit] convId=${conversationId.slice(0, 8)} sdkTurnUuid=${j.messageId.slice(0, 8)} expected=message missing reason=neo4j-row-absent`);
|
|
113
|
+
divergences += 1;
|
|
114
|
+
// Each missing message implies its components are also missing — emit
|
|
115
|
+
// one divergence line per absent component for forensic completeness.
|
|
116
|
+
for (const c of j.components) {
|
|
117
|
+
console.log(`[admin-persist-audit] convId=${conversationId.slice(0, 8)} sdkTurnUuid=${j.messageId.slice(0, 8)} expected=component component_name=${c.name} ordinal=${c.ordinal} missing reason=neo4j-row-absent`);
|
|
118
|
+
divergences += 1;
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
// Message exists; cross-check component count (Neo4j carries components
|
|
123
|
+
// as siblings of :Message via :HAS_COMPONENT).
|
|
124
|
+
const neoComps = match.components ?? [];
|
|
125
|
+
if (neoComps.length < j.components.length) {
|
|
126
|
+
for (let i = neoComps.length; i < j.components.length; i++) {
|
|
127
|
+
const c = j.components[i];
|
|
128
|
+
console.log(`[admin-persist-audit] convId=${conversationId.slice(0, 8)} sdkTurnUuid=${j.messageId.slice(0, 8)} expected=component component_name=${c.name} ordinal=${c.ordinal} missing reason=neo4j-row-absent`);
|
|
129
|
+
divergences += 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Task 942 — every PERSISTENT_COMPONENTS :Component row must have a
|
|
135
|
+
// sibling :KnowledgeDocument with a matching attachmentId. Two failure
|
|
136
|
+
// modes: (a) live writer succeeded the :Component CREATE but the
|
|
137
|
+
// sibling :KnowledgeDocument MERGE didn't fire (theoretical — they're
|
|
138
|
+
// in the same tx, so this is only possible if the row is pre-942 or if
|
|
139
|
+
// the FOREACH ran on a null attachmentId); (b) the disk-write failed
|
|
140
|
+
// mid-render and `c.attachmentId` is null but the artefact bytes might
|
|
141
|
+
// be recoverable from `c.data`. Both cases are surfaced as `kd-row-absent`
|
|
142
|
+
// — the operator runs component-knowledgedoc-backfill.ts to materialise
|
|
143
|
+
// the projection.
|
|
144
|
+
const projectionSession = getSession();
|
|
145
|
+
try {
|
|
146
|
+
const componentRowsResult = await projectionSession.run(
|
|
147
|
+
`MATCH (m:Message {conversationId: $conversationId})-[:HAS_COMPONENT]->(c:Component)
|
|
148
|
+
WHERE c.name IN $names
|
|
149
|
+
OPTIONAL MATCH (k:KnowledgeDocument {accountId: c.accountId, attachmentId: c.attachmentId})
|
|
150
|
+
WHERE c.attachmentId IS NOT NULL
|
|
151
|
+
RETURN c.componentId AS componentId,
|
|
152
|
+
c.name AS componentName,
|
|
153
|
+
c.accountId AS accountId,
|
|
154
|
+
c.attachmentId AS attachmentId,
|
|
155
|
+
k IS NOT NULL AS hasProjection`,
|
|
156
|
+
{ conversationId, names: Array.from(PERSISTENT_COMPONENTS) },
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
for (const record of componentRowsResult.records) {
|
|
160
|
+
const componentId = record.get("componentId") as string;
|
|
161
|
+
const componentName = record.get("componentName") as string;
|
|
162
|
+
const attachmentId = record.get("attachmentId") as string | null;
|
|
163
|
+
const hasProjection = record.get("hasProjection") === true;
|
|
164
|
+
if (!attachmentId) {
|
|
165
|
+
console.log(`[admin-persist-audit] convId=${conversationId.slice(0, 8)} componentId=${componentId.slice(0, 8)} expected=knowledgedoc component_name=${componentName} missing reason=empty-attachment-id`);
|
|
166
|
+
divergences += 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (!hasProjection) {
|
|
170
|
+
console.log(`[admin-persist-audit] convId=${conversationId.slice(0, 8)} componentId=${componentId.slice(0, 8)} expected=knowledgedoc component_name=${componentName} missing reason=kd-row-absent attachmentId=${attachmentId.slice(0, 8)}`);
|
|
171
|
+
divergences += 1;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
await projectionSession.close();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (divergences === 0) {
|
|
179
|
+
console.log(`[admin-persist-audit] convId=${conversationId.slice(0, 8)} jsonlMessages=${replay.messages.length} neo4jMessages=${neo4j.length} divergences=0 status=ok`);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
console.log(`[admin-persist-audit] convId=${conversationId.slice(0, 8)} jsonlMessages=${replay.messages.length} neo4jMessages=${neo4j.length} divergences=${divergences} status=mismatch`);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
main()
|
|
187
|
+
.then((code) => process.exit(code))
|
|
188
|
+
.catch((err) => {
|
|
189
|
+
console.error(`[admin-persist-audit] crashed: ${err instanceof Error ? err.stack : String(err)}`);
|
|
190
|
+
process.exit(2);
|
|
191
|
+
});
|