@rubytech/create-realagent 1.0.869 → 1.0.871
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 +1 -1
- package/payload/platform/plugins/admin/mcp/dist/__tests__/public-hostname.test.js +83 -91
- package/payload/platform/plugins/admin/mcp/dist/__tests__/public-hostname.test.js.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/index.js +7 -10
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/public-hostname.d.ts +5 -11
- package/payload/platform/plugins/admin/mcp/dist/lib/public-hostname.d.ts.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/public-hostname.js +66 -47
- package/payload/platform/plugins/admin/mcp/dist/lib/public-hostname.js.map +1 -1
- package/payload/platform/plugins/admin/skills/publish-site/SKILL.md +2 -2
- package/payload/platform/plugins/docs/references/internals.md +1 -1
- package/payload/platform/scripts/__tests__/admin-persist-audit.test.ts +182 -0
- package/payload/platform/scripts/admin-persist-audit.ts +43 -17
- package/payload/server/chunk-5U36PKG4.js +11326 -0
- package/payload/server/chunk-NDEQBCVI.js +1160 -0
- package/payload/server/client-pool-XAEDMS5D.js +34 -0
- package/payload/server/maxy-edge.js +2 -2
- package/payload/server/public/assets/{Checkbox-B9hff9s8.js → Checkbox-CDffo5el.js} +1 -1
- package/payload/server/public/assets/{admin-Cpi6L_g7.js → admin-BSdV45P5.js} +2 -2
- package/payload/server/public/assets/data-vFVtOwuC.js +1 -0
- package/payload/server/public/assets/{graph-labels-ChinGFwI.js → graph-labels-C-KsUF_B.js} +1 -1
- package/payload/server/public/assets/graph-q802cxLY.js +1 -0
- package/payload/server/public/assets/{jsx-runtime-CVA1ZrPS.css → jsx-runtime-C1hGBzVx.css} +1 -1
- package/payload/server/public/assets/{page-OVrxtgOZ.js → page-B5b7tyz-.js} +1 -1
- package/payload/server/public/assets/{page-DqPf65sS.js → page-DsW7P98i.js} +1 -1
- package/payload/server/public/assets/{public-CJN5KAiK.js → public-BkNXx-3G.js} +1 -1
- package/payload/server/public/assets/{useVoiceRecorder-DyVx7e7a.js → useVoiceRecorder-DCVSlfUk.js} +1 -1
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +8 -8
- package/payload/server/public/public.html +5 -5
- package/payload/server/server.js +38 -18
- package/payload/server/public/assets/data-Da6iYRW1.js +0 -1
- package/payload/server/public/assets/graph-BHq-JYwV.js +0 -1
- /package/payload/server/public/assets/{jsx-runtime-nxP_2eNo.js → jsx-runtime-DFrHsKhm.js} +0 -0
|
@@ -1,21 +1,15 @@
|
|
|
1
|
-
export
|
|
2
|
-
run(cypher: string, params: Record<string, unknown>): Promise<{
|
|
3
|
-
records: Array<{
|
|
4
|
-
get: (k: string) => unknown;
|
|
5
|
-
}>;
|
|
6
|
-
}>;
|
|
7
|
-
}
|
|
1
|
+
export type HostnameSource = "cloudflared-config.yml" | "alias-domains.json";
|
|
8
2
|
export interface PublicHostnameHit {
|
|
9
3
|
hostname: string;
|
|
10
4
|
isApex: boolean;
|
|
11
|
-
|
|
5
|
+
source: HostnameSource;
|
|
12
6
|
}
|
|
13
7
|
export interface PublicHostnameMiss {
|
|
14
8
|
hostname: null;
|
|
15
9
|
isApex: null;
|
|
16
|
-
|
|
17
|
-
reason: "no-
|
|
10
|
+
source: null;
|
|
11
|
+
reason: "no-tunnel";
|
|
18
12
|
}
|
|
19
13
|
export type PublicHostnameResult = PublicHostnameHit | PublicHostnameMiss;
|
|
20
|
-
export declare function resolvePublicHostname(
|
|
14
|
+
export declare function resolvePublicHostname(configDir: string): PublicHostnameResult;
|
|
21
15
|
//# sourceMappingURL=public-hostname.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"public-hostname.d.ts","sourceRoot":"","sources":["../../src/lib/public-hostname.ts"],"names":[],"mappings":"AAmBA,MAAM,
|
|
1
|
+
{"version":3,"file":"public-hostname.d.ts","sourceRoot":"","sources":["../../src/lib/public-hostname.ts"],"names":[],"mappings":"AAmBA,MAAM,MAAM,cAAc,GAAG,wBAAwB,GAAG,oBAAoB,CAAC;AAE7E,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,cAAc,CAAC;CACxB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,IAAI,CAAC;IACf,MAAM,EAAE,IAAI,CAAC;IACb,MAAM,EAAE,IAAI,CAAC;IACb,MAAM,EAAE,WAAW,CAAC;CACrB;AAED,MAAM,MAAM,oBAAoB,GAAG,iBAAiB,GAAG,kBAAkB,CAAC;AA8C1E,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,oBAAoB,CAY7E"}
|
|
@@ -1,54 +1,73 @@
|
|
|
1
|
-
// Task
|
|
1
|
+
// Task 971 — public-hostname resolver reads filesystem (4th recurrence of
|
|
2
|
+
// llm-framing-deterministic).
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// Reads the same two files that already drive routing:
|
|
5
|
+
// - <configDir>/cloudflared/config.yml — the ingress map cloudflared itself
|
|
6
|
+
// reads to decide where to route a hostname.
|
|
7
|
+
// - <configDir>/alias-domains.json — the alias list platform/ui/server's
|
|
8
|
+
// isPublicHost() watches via watchFile.
|
|
9
|
+
// hasPublicEndpointConfigured() in admin/mcp/src/index.ts already parses both
|
|
10
|
+
// with the same regex; this resolver is the read-only twin that returns the
|
|
11
|
+
// chosen hostname instead of a boolean.
|
|
9
12
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return
|
|
13
|
+
// Graph queries are gone. The previous version (Task 970) returned (none) on
|
|
14
|
+
// any account that bootstrapped its tunnel without writing :CloudflareTunnel
|
|
15
|
+
// nodes — exactly the laptop Real Agent state that produced this recurrence.
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
const INGRESS_RE = /^\s*-\s*hostname:\s*(\S+)/gm;
|
|
19
|
+
function isExcluded(host) {
|
|
20
|
+
return host === "localhost" || host.endsWith(".local");
|
|
21
|
+
}
|
|
22
|
+
// Apex = single domain (e.g. "realagent.app"), with optional `www.` prefix
|
|
23
|
+
// stripped. Subdomain = anything else ("public.realagent.app").
|
|
24
|
+
function isApex(host) {
|
|
25
|
+
const stripped = host.startsWith("www.") ? host.slice(4) : host;
|
|
26
|
+
return stripped.split(".").length === 2;
|
|
27
|
+
}
|
|
28
|
+
function readIngressHostnames(configDir) {
|
|
29
|
+
const path = join(configDir, "cloudflared", "config.yml");
|
|
30
|
+
if (!existsSync(path))
|
|
31
|
+
return [];
|
|
32
|
+
try {
|
|
33
|
+
const yaml = readFileSync(path, "utf-8");
|
|
34
|
+
return [...yaml.matchAll(INGRESS_RE)].map((m) => m[1]).filter((h) => !isExcluded(h));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return [];
|
|
32
38
|
}
|
|
33
|
-
return Number(v);
|
|
34
39
|
}
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
function readAliasDomains(configDir) {
|
|
41
|
+
const path = join(configDir, "alias-domains.json");
|
|
42
|
+
if (!existsSync(path))
|
|
43
|
+
return [];
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
46
|
+
if (!Array.isArray(parsed))
|
|
47
|
+
return [];
|
|
48
|
+
return parsed.filter((h) => typeof h === "string" && h.length > 0 && !isExcluded(h));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Tiebreak: apex wins over subdomain; otherwise first-in-file.
|
|
55
|
+
function pickHostname(candidates) {
|
|
56
|
+
if (candidates.length === 0)
|
|
57
|
+
return null;
|
|
58
|
+
return candidates.find((h) => isApex(h)) ?? candidates[0];
|
|
59
|
+
}
|
|
60
|
+
export function resolvePublicHostname(configDir) {
|
|
61
|
+
const ingress = readIngressHostnames(configDir);
|
|
62
|
+
const chosen = pickHostname(ingress);
|
|
63
|
+
if (chosen !== null) {
|
|
64
|
+
return { hostname: chosen, isApex: isApex(chosen), source: "cloudflared-config.yml" };
|
|
65
|
+
}
|
|
66
|
+
const alias = readAliasDomains(configDir);
|
|
67
|
+
const aliasChosen = pickHostname(alias);
|
|
68
|
+
if (aliasChosen !== null) {
|
|
69
|
+
return { hostname: aliasChosen, isApex: isApex(aliasChosen), source: "alias-domains.json" };
|
|
44
70
|
}
|
|
45
|
-
|
|
46
|
-
const n = tunnelRes.records.length > 0 ? toNumber(tunnelRes.records[0].get("n")) : 0;
|
|
47
|
-
return {
|
|
48
|
-
hostname: null,
|
|
49
|
-
isApex: null,
|
|
50
|
-
tunnelId: null,
|
|
51
|
-
reason: n > 0 ? "no-hostname" : "no-tunnel",
|
|
52
|
-
};
|
|
71
|
+
return { hostname: null, isApex: null, source: null, reason: "no-tunnel" };
|
|
53
72
|
}
|
|
54
73
|
//# sourceMappingURL=public-hostname.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"public-hostname.js","sourceRoot":"","sources":["../../src/lib/public-hostname.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"public-hostname.js","sourceRoot":"","sources":["../../src/lib/public-hostname.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Publish Site
|
|
2
2
|
|
|
3
|
-
Move an already-extracted static-site tree into the per-account static-publish surface (`<accountDir>/sites/<slug>/`) and emit exactly one canonical path slug. Pair the slug with the deterministic public hostname returned by `mcp__admin__public-hostname`
|
|
3
|
+
Move an already-extracted static-site tree into the per-account static-publish surface (`<accountDir>/sites/<slug>/`) and emit exactly one canonical path slug. Pair the slug with the deterministic public hostname returned by `mcp__admin__public-hostname` to surface the full URL in a single turn.
|
|
4
4
|
|
|
5
5
|
**Invoked from `specialists:content-producer`** when the brief carries a host-website / publish-site / put-online intent (Task 966). Admin's IDENTITY.md routes those intents to that specialist on turn 1; running this skill inline on the main admin runner exhausts the 10-turn budget on per-turn ToolSearch + plugin-read discovery before publish-site executes.
|
|
6
6
|
|
|
@@ -54,7 +54,7 @@ The operator message for `ambiguous-html` names the candidate files and asks the
|
|
|
54
54
|
4. **Choose the canonical path.** `index.html` present at top level → path is `/sites/<slug>/`. Otherwise the single top-level HTML file → path is `/sites/<slug>/<file>.html`.
|
|
55
55
|
5. **Emit.** One log line:
|
|
56
56
|
`[publish-site] url emitted=<path-slug> kind=<index|file>`
|
|
57
|
-
6. **Resolve the public hostname and emit the full URL.** Call `mcp__admin__public-hostname`
|
|
57
|
+
6. **Resolve the public hostname and emit the full URL.** Call `mcp__admin__public-hostname` to fetch this account's hostname; the tool reads `cloudflared/config.yml` + `alias-domains.json` — the same files the platform server trusts to route. Concatenate `https://<hostname><path-slug>` and surface that one URL to the operator. If the tool returns `reason: no-tunnel`, relay the tool's remediation message verbatim — do not improvise the URL. The route at `/sites/*` (see [server/routes/sites.ts](../../../../ui/server/routes/sites.ts)) handles the trailing-slash redirect on the dir form, so the slug is correct as-emitted whether the operator's HTML uses an `index.html` entry-point or a publisher-named landing file.
|
|
58
58
|
|
|
59
59
|
## Log lines (grep targets)
|
|
60
60
|
|
|
@@ -328,7 +328,7 @@ Each row in the Conversations modal exposes a `View logs` row-action that opens
|
|
|
328
328
|
|
|
329
329
|
**Directory canonicalisation.** A request whose disk target is a directory is `301`'d to the trailing-slash form (query string preserved) before any body is served — RFC 3986 §5.3 base resolution requires the trailing slash so relative refs in the served HTML resolve under the directory, not its parent. After the redirect the route serves `<dir>/index.html` if it exists on disk; otherwise `404`. There is **no** implicit-`index.html` invention for missing paths — the publisher owns canonical URLs. A brochure shipped without `index.html` is reached at `/sites/<slug>/<file>.html`, and the admin skill `publish-site` is the sanctioned surface that moves the extracted tree under `<accountDir>/sites/<slug>/` and emits the canonical path slug. Operator-side: drop a brochure at `<accountDir>/sites/properties/<id>/brochure/output/` and it serves at `<public-host>/sites/properties/<id>/brochure/output/brochure.html` (or `<public-host>/sites/properties/<id>/brochure/output/` if that directory contains an `index.html`). See `.docs/web-chat.md` `/sites/*` route entry for the wire contract and `[sites]` log lines (`serve|redirect-trailing-slash|not-found|path-traversal-rejected|symlink-escape-rejected|no-account`).
|
|
330
330
|
|
|
331
|
-
**Deterministic public-hostname surface.** The `<public-host>` half of the URL the operator pastes is resolved by the `mcp__admin__public-hostname` MCP tool —
|
|
331
|
+
**Deterministic public-hostname surface.** The `<public-host>` half of the URL the operator pastes is resolved by the `mcp__admin__public-hostname` MCP tool. It reads `<configDir>/cloudflared/config.yml` (ingress list) then falls back to `<configDir>/alias-domains.json` — the same two files `cloudflared` and `platform/ui/server/index.ts`'s `isPublicHost()` already trust to route. Returns `{hostname, isApex, source}` on hit (`source` is `"cloudflared-config.yml"` or `"alias-domains.json"`), or `{hostname:null, source:null, reason:"no-tunnel"}` on miss. Tiebreak: apex wins over subdomain (single-label, or `www.<apex>` stripped). `publish-site` step 6 calls it after the move and emits the full URL (`https://<hostname><path-slug>`) in the same turn. Graph queries are no longer involved — any earlier graph-backed resolver returned `(none)` on accounts bootstrapped without `cloudflare-task-tracker.ts` writes (laptop Real Agent, manual `cloudflared` setup), the `llm-framing-deterministic` recurrence class. The graph-mcp shim additionally runs a sequential envelope-warning probe on every read response — when Neo4j emits `gql_status` codes matching `^0[12]N5\d$` (e.g. `01N52` "property does not exist"), the shim stitches them into a prefix content block on the response so property-name misses surface to the agent inline instead of returning silent `[]`. Probe failure is best-effort: the upstream response forwards unchanged with `[mcp:graph] probe-error`.
|
|
332
332
|
|
|
333
333
|
### Cross-tab session rotation
|
|
334
334
|
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 943 — admin-persist-audit.
|
|
3
|
+
*
|
|
4
|
+
* Pins Task 940 success criterion #7: the audit harness counts JSONL
|
|
5
|
+
* assistant turns + render-component blocks against Neo4j `:Message` +
|
|
6
|
+
* `:Component` rows for a `conversationId`, prints one
|
|
7
|
+
*
|
|
8
|
+
* [admin-persist-audit] convId=<conv> sdkTurnUuid=<uuid> expected=<message|component> missing reason=neo4j-row-absent
|
|
9
|
+
*
|
|
10
|
+
* line per divergence, and exits non-zero on mismatch.
|
|
11
|
+
*
|
|
12
|
+
* The test mocks the Neo4j surface (`getRecentMessages`, `getSession`) so
|
|
13
|
+
* `runAudit` is a pure function of (JSONL fixture, Neo4j seed). The
|
|
14
|
+
* harness reads the fixture with the real `replayJsonl`, so the matching
|
|
15
|
+
* key (role + content) is verified end-to-end without a graph connection.
|
|
16
|
+
*/
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
19
|
+
|
|
20
|
+
const FIXTURE_PATH = resolve(__dirname, "../../ui/app/lib/__tests__/fixtures/task-940-session.jsonl");
|
|
21
|
+
|
|
22
|
+
// Mock the Neo4j surface. The audit harness imports `getRecentMessages`
|
|
23
|
+
// and `getSession` from `platform/ui/app/lib/neo4j-store`. We replace
|
|
24
|
+
// both with vi-managed fakes; each test reseeds them as needed.
|
|
25
|
+
const getRecentMessagesMock = vi.fn<[string, number?], Promise<Array<{
|
|
26
|
+
role: "user" | "assistant";
|
|
27
|
+
content: string;
|
|
28
|
+
messageId: string;
|
|
29
|
+
components?: Array<{ componentId: string; submitted: boolean }>;
|
|
30
|
+
}>>>();
|
|
31
|
+
|
|
32
|
+
const sessionRunMock = vi.fn<[string, Record<string, unknown>], Promise<{
|
|
33
|
+
records: Array<{ get(key: string): unknown }>;
|
|
34
|
+
}>>();
|
|
35
|
+
const sessionCloseMock = vi.fn<[], Promise<void>>();
|
|
36
|
+
|
|
37
|
+
vi.mock("../../ui/app/lib/neo4j-store", () => ({
|
|
38
|
+
getRecentMessages: (...args: Parameters<typeof getRecentMessagesMock>) => getRecentMessagesMock(...args),
|
|
39
|
+
getSession: () => ({
|
|
40
|
+
run: (cypher: string, params: Record<string, unknown>) => sessionRunMock(cypher, params),
|
|
41
|
+
close: () => sessionCloseMock(),
|
|
42
|
+
}),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
vi.mock("../../ui/app/lib/claude-agent/account", () => ({
|
|
46
|
+
ACCOUNTS_DIR: "/tmp/maxy-accounts-fixture",
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
// PERSISTENT_COMPONENTS is also imported by the audit harness for the
|
|
50
|
+
// :KnowledgeDocument projection check. We mock the set so the test does
|
|
51
|
+
// not depend on the runtime persistent-component list — the fixture
|
|
52
|
+
// components (`document-editor`, `quick-chart`) are both included.
|
|
53
|
+
vi.mock("../../lib/persistent-components/src/index", () => ({
|
|
54
|
+
PERSISTENT_COMPONENTS: new Set<string>(["document-editor", "quick-chart"]),
|
|
55
|
+
isPersistentComponent: (name: string) => name === "document-editor" || name === "quick-chart",
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
import { runAudit, type AuditArgs } from "../admin-persist-audit";
|
|
59
|
+
|
|
60
|
+
const CONV_ID = "11111111-aaaa-bbbb-cccc-dddddddddddd";
|
|
61
|
+
const ACCOUNT_ID = "b3b638d9-9999-8888-7777-666666666666";
|
|
62
|
+
|
|
63
|
+
const buildArgs = (): AuditArgs => ({
|
|
64
|
+
conversationId: CONV_ID,
|
|
65
|
+
accountId: ACCOUNT_ID,
|
|
66
|
+
jsonlPath: FIXTURE_PATH,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const captureConsole = () => {
|
|
70
|
+
const out: string[] = [];
|
|
71
|
+
const err: string[] = [];
|
|
72
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation((...a) => { out.push(a.join(" ")); });
|
|
73
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation((...a) => { err.push(a.join(" ")); });
|
|
74
|
+
return { out, err, restore: () => { logSpy.mockRestore(); errSpy.mockRestore(); } };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
describe("Task 940 SC7 — admin-persist-audit divergence reporting", () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
getRecentMessagesMock.mockReset();
|
|
80
|
+
sessionRunMock.mockReset();
|
|
81
|
+
sessionCloseMock.mockReset();
|
|
82
|
+
sessionCloseMock.mockResolvedValue(undefined);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("Neo4j seed missing the assistant turn (and its components) → exit=1, one missing-message line + one missing-component line per component", async () => {
|
|
86
|
+
// Fixture has 1 user + 1 assistant (with 2 components). Seed Neo4j
|
|
87
|
+
// with the user only — the assistant turn is the silent persist
|
|
88
|
+
// failure the audit must surface.
|
|
89
|
+
getRecentMessagesMock.mockResolvedValue([
|
|
90
|
+
{ role: "user", content: "Please render the doc and a chart.", messageId: "neo4j-user", components: [] },
|
|
91
|
+
]);
|
|
92
|
+
// No persistent-component rows in Neo4j (since the assistant turn
|
|
93
|
+
// never persisted). The :KnowledgeDocument check finds nothing → no
|
|
94
|
+
// additional kd-absent divergences.
|
|
95
|
+
sessionRunMock.mockResolvedValue({ records: [] });
|
|
96
|
+
|
|
97
|
+
const cap = captureConsole();
|
|
98
|
+
try {
|
|
99
|
+
const code = await runAudit(buildArgs());
|
|
100
|
+
expect(code).toBe(1);
|
|
101
|
+
|
|
102
|
+
// One divergence line for the assistant message itself…
|
|
103
|
+
const messageMisses = cap.out.filter((l) => /expected=message missing reason=neo4j-row-absent/.test(l));
|
|
104
|
+
expect(messageMisses).toHaveLength(1);
|
|
105
|
+
expect(messageMisses[0]).toMatch(/sdkTurnUuid=22222222/); // matches fixture uuid prefix
|
|
106
|
+
|
|
107
|
+
// …plus one divergence line per component sibling that was queued
|
|
108
|
+
// by the assistant turn's render-component blocks.
|
|
109
|
+
const componentMisses = cap.out.filter((l) => /expected=component .* missing reason=neo4j-row-absent/.test(l));
|
|
110
|
+
expect(componentMisses).toHaveLength(2);
|
|
111
|
+
expect(componentMisses[0]).toMatch(/component_name=document-editor ordinal=0/);
|
|
112
|
+
expect(componentMisses[1]).toMatch(/component_name=quick-chart ordinal=1/);
|
|
113
|
+
|
|
114
|
+
// Status summary line ends with status=mismatch.
|
|
115
|
+
expect(cap.out.some((l) => /status=mismatch/.test(l))).toBe(true);
|
|
116
|
+
} finally {
|
|
117
|
+
cap.restore();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("Neo4j seed in lockstep with JSONL → exit=0, status=ok, zero divergence lines", async () => {
|
|
122
|
+
getRecentMessagesMock.mockResolvedValue([
|
|
123
|
+
{ role: "user", content: "Please render the doc and a chart.", messageId: "neo4j-user", components: [] },
|
|
124
|
+
{
|
|
125
|
+
role: "assistant",
|
|
126
|
+
content: "Here is the doc.\n\nAnd a chart follows.",
|
|
127
|
+
messageId: "neo4j-asst",
|
|
128
|
+
components: [
|
|
129
|
+
{ componentId: "comp-doc", submitted: false },
|
|
130
|
+
{ componentId: "comp-chart", submitted: false },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
]);
|
|
134
|
+
// Persistent-component KD projection: both rows have a sibling
|
|
135
|
+
// :KnowledgeDocument row → no kd-absent divergences.
|
|
136
|
+
sessionRunMock.mockResolvedValue({
|
|
137
|
+
records: [
|
|
138
|
+
{ get: (k: string) => ({ componentId: "comp-doc", componentName: "document-editor", accountId: ACCOUNT_ID, attachmentId: "att-doc", hasProjection: true })[k] },
|
|
139
|
+
{ get: (k: string) => ({ componentId: "comp-chart", componentName: "quick-chart", accountId: ACCOUNT_ID, attachmentId: "att-chart", hasProjection: true })[k] },
|
|
140
|
+
],
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const cap = captureConsole();
|
|
144
|
+
try {
|
|
145
|
+
const code = await runAudit(buildArgs());
|
|
146
|
+
expect(code).toBe(0);
|
|
147
|
+
expect(cap.out.some((l) => /divergences=0 status=ok/.test(l))).toBe(true);
|
|
148
|
+
expect(cap.out.some((l) => /missing reason=neo4j-row-absent/.test(l))).toBe(false);
|
|
149
|
+
} finally {
|
|
150
|
+
cap.restore();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("Neo4j throw on getRecentMessages → exit=2 (invocation error), no divergence lines", async () => {
|
|
155
|
+
getRecentMessagesMock.mockRejectedValue(new Error("bolt://neo4j unreachable"));
|
|
156
|
+
sessionRunMock.mockResolvedValue({ records: [] });
|
|
157
|
+
|
|
158
|
+
const cap = captureConsole();
|
|
159
|
+
try {
|
|
160
|
+
const code = await runAudit(buildArgs());
|
|
161
|
+
expect(code).toBe(2);
|
|
162
|
+
expect(cap.err.some((l) => /neo4j-read-failed/.test(l) && /bolt:\/\/neo4j unreachable/.test(l))).toBe(true);
|
|
163
|
+
} finally {
|
|
164
|
+
cap.restore();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("JSONL absent → exit=2 (invocation error)", async () => {
|
|
169
|
+
const cap = captureConsole();
|
|
170
|
+
try {
|
|
171
|
+
const code = await runAudit({
|
|
172
|
+
conversationId: CONV_ID,
|
|
173
|
+
accountId: ACCOUNT_ID,
|
|
174
|
+
jsonlPath: resolve(__dirname, "fixtures/does-not-exist.jsonl"),
|
|
175
|
+
});
|
|
176
|
+
expect(code).toBe(2);
|
|
177
|
+
expect(cap.err.some((l) => /jsonl absent path=/.test(l))).toBe(true);
|
|
178
|
+
} finally {
|
|
179
|
+
cap.restore();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -37,14 +37,19 @@ import { ACCOUNTS_DIR } from "../ui/app/lib/claude-agent/account";
|
|
|
37
37
|
import { getRecentMessages, getSession } from "../ui/app/lib/neo4j-store";
|
|
38
38
|
import { PERSISTENT_COMPONENTS } from "../lib/persistent-components/src/index";
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
// Task 943 — `AuditArgs` and `runAudit` are exported so the test
|
|
41
|
+
// (`platform/scripts/__tests__/admin-persist-audit.test.ts`) can mock the
|
|
42
|
+
// Neo4j surface and drive the audit with synthetic JSONL + Neo4j seeds,
|
|
43
|
+
// verifying the divergence-line shape and non-zero exit code without
|
|
44
|
+
// re-executing the live session or requiring a real graph connection.
|
|
45
|
+
export interface AuditArgs {
|
|
41
46
|
conversationId: string;
|
|
42
47
|
accountId: string;
|
|
43
48
|
jsonlPath: string;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
function parseArgs(argv: string[]):
|
|
47
|
-
const out: Partial<
|
|
51
|
+
function parseArgs(argv: string[]): AuditArgs | { error: string } {
|
|
52
|
+
const out: Partial<AuditArgs> = {};
|
|
48
53
|
let sessionId: string | undefined;
|
|
49
54
|
let jsonlOverride: string | undefined;
|
|
50
55
|
for (const a of argv) {
|
|
@@ -66,16 +71,11 @@ function parseArgs(argv: string[]): Args | { error: string } {
|
|
|
66
71
|
} else {
|
|
67
72
|
return { error: "either --jsonl or --session-id required" };
|
|
68
73
|
}
|
|
69
|
-
return out as
|
|
74
|
+
return out as AuditArgs;
|
|
70
75
|
}
|
|
71
76
|
|
|
72
|
-
async function
|
|
73
|
-
const
|
|
74
|
-
if ("error" in parsed) {
|
|
75
|
-
console.error(`[admin-persist-audit] usage error: ${parsed.error}`);
|
|
76
|
-
return 2;
|
|
77
|
-
}
|
|
78
|
-
const { conversationId, jsonlPath } = parsed;
|
|
77
|
+
export async function runAudit(args: AuditArgs): Promise<number> {
|
|
78
|
+
const { conversationId, jsonlPath } = args;
|
|
79
79
|
|
|
80
80
|
if (!existsSync(jsonlPath)) {
|
|
81
81
|
console.error(`[admin-persist-audit] jsonl absent path=${jsonlPath} convId=${conversationId.slice(0, 8)}`);
|
|
@@ -183,9 +183,35 @@ async function main(): Promise<number> {
|
|
|
183
183
|
return 1;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
main()
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
console.error(`[admin-persist-audit]
|
|
190
|
-
|
|
191
|
-
}
|
|
186
|
+
async function main(): Promise<number> {
|
|
187
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
188
|
+
if ("error" in parsed) {
|
|
189
|
+
console.error(`[admin-persist-audit] usage error: ${parsed.error}`);
|
|
190
|
+
return 2;
|
|
191
|
+
}
|
|
192
|
+
return runAudit(parsed);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Task 943 — only invoke main() when this script is executed directly.
|
|
196
|
+
// Importing the module (e.g. from `admin-persist-audit.test.ts`) reads
|
|
197
|
+
// `runAudit` and `AuditArgs` without firing the top-level argv parser or
|
|
198
|
+
// process.exit, which would otherwise kill the vitest worker before any
|
|
199
|
+
// assertion ran. `import.meta.url` resolves to the script path under tsx;
|
|
200
|
+
// the suffix check matches both the .ts source and any compiled .js output.
|
|
201
|
+
const invokedDirectly = (() => {
|
|
202
|
+
try {
|
|
203
|
+
const entry = process.argv[1];
|
|
204
|
+
return typeof entry === "string" && import.meta.url.endsWith(entry.replace(/\\/g, "/").split("/").pop() ?? "");
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
})();
|
|
209
|
+
|
|
210
|
+
if (invokedDirectly) {
|
|
211
|
+
main()
|
|
212
|
+
.then((code) => process.exit(code))
|
|
213
|
+
.catch((err) => {
|
|
214
|
+
console.error(`[admin-persist-audit] crashed: ${err instanceof Error ? err.stack : String(err)}`);
|
|
215
|
+
process.exit(2);
|
|
216
|
+
});
|
|
217
|
+
}
|