@kontourai/flow-agents 2.4.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/CODEOWNERS +8 -0
- package/.github/workflows/ci.yml +20 -0
- package/.github/workflows/trust-reconcile.yml +62 -4
- package/CHANGELOG.md +38 -0
- package/CONTEXT.md +88 -1
- package/README.md +4 -0
- package/build/src/cli/assignment-provider.d.ts +144 -0
- package/build/src/cli/assignment-provider.js +805 -0
- package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
- package/build/src/cli/effective-assignment-provider-settings.js +125 -0
- package/build/src/cli/validate-workflow-artifacts.js +5 -1
- package/build/src/cli/workflow-sidecar.d.ts +2 -2
- package/build/src/cli/workflow-sidecar.js +549 -145
- package/build/src/cli.js +6 -0
- package/build/src/lib/flow-resolver.d.ts +12 -6
- package/build/src/lib/flow-resolver.js +30 -14
- package/build/src/tools/validate-source-tree.js +2 -0
- package/context/contracts/artifact-contract.md +2 -0
- package/context/contracts/assignment-provider-contract.md +248 -0
- package/context/contracts/builder-kit-workflow-state-contract.md +2 -0
- package/context/contracts/decision-registry-contract.md +2 -0
- package/context/contracts/delivery-contract.md +2 -0
- package/context/contracts/execution-contract.md +25 -0
- package/context/contracts/governance-adapter-contract.md +2 -0
- package/context/contracts/knowledge-store-contract.md +197 -0
- package/context/contracts/planning-contract.md +2 -0
- package/context/contracts/probe-docs-write-contract.md +187 -0
- package/context/contracts/review-contract.md +2 -0
- package/context/contracts/sandbox-policy.md +2 -0
- package/context/contracts/standing-directives.md +13 -0
- package/context/contracts/verification-contract.md +2 -0
- package/context/contracts/work-item-contract.md +2 -0
- package/context/scripts/hooks/config-protection.js +14 -1
- package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/context/scripts/hooks/stop-goal-fit.js +4 -2
- package/context/scripts/hooks/workflow-steering.js +42 -0
- package/context/settings/assignment-provider-settings.json +33 -0
- package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
- package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
- package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
- package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
- package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
- package/docs/adr/0006-typescript-first-source-policy.md +2 -0
- package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
- package/docs/adr/0007-skill-audit.md +2 -0
- package/docs/adr/0008-kit-operation-boundary.md +2 -0
- package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
- package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
- package/docs/adr/0011-mcp-posture.md +2 -0
- package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
- package/docs/adr/0013-context-lifecycle.md +2 -0
- package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
- package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
- package/docs/adr/0016-three-hard-boundary-model.md +2 -0
- package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
- package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
- package/docs/adr/0019-kit-dependency-ownership.md +2 -0
- package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
- package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +182 -0
- package/docs/adr/README.md +49 -0
- package/docs/adr/index.md +34 -0
- package/docs/context-map.md +1 -0
- package/docs/decisions/agent-coordination.md +20 -0
- package/docs/decisions/anti-gaming-trust-security.md +20 -0
- package/docs/decisions/context-lifecycle.md +18 -0
- package/docs/decisions/core-domain-kit-boundary.md +18 -0
- package/docs/decisions/flow-flow-agents-boundary.md +18 -0
- package/docs/decisions/flow-kit.md +20 -0
- package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
- package/docs/decisions/graph-knowledge-provider.md +63 -0
- package/docs/decisions/hook-core-kit-boundary.md +18 -0
- package/docs/decisions/index.md +22 -0
- package/docs/decisions/kit-dependency-ownership.md +18 -0
- package/docs/decisions/kit-operation-boundary.md +18 -0
- package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
- package/docs/decisions/knowledge-store-provider.md +51 -0
- package/docs/decisions/kontour-resource-contract.md +18 -0
- package/docs/decisions/mcp-posture.md +18 -0
- package/docs/decisions/model-routing.md +63 -0
- package/docs/decisions/standing-directives.md +66 -0
- package/docs/decisions/three-hard-boundary-model.md +18 -0
- package/docs/decisions/trust-reconcile.md +20 -0
- package/docs/decisions/typescript-source-policy.md +48 -0
- package/docs/decisions/workflow-enforcement.md +18 -0
- package/docs/decisions/workflow-trust-state.md +20 -0
- package/docs/fixture-ownership.md +1 -0
- package/docs/workflow-shared-contracts.md +2 -1
- package/docs/workflow-usage-guide.md +7 -1
- package/evals/ci/run-baseline.sh +10 -0
- package/evals/fixtures/assignment-provider/actor-a.json +6 -0
- package/evals/fixtures/assignment-provider/actor-b.json +6 -0
- package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
- package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
- package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
- package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
- package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
- package/evals/integration/test_assignment_provider_github.sh +318 -0
- package/evals/integration/test_assignment_provider_local_file.sh +222 -0
- package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
- package/evals/integration/test_current_json_per_actor.sh +516 -0
- package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_gate_lockdown.sh +10 -0
- package/evals/integration/test_publish_delivery.sh +21 -4
- package/evals/integration/test_pull_work_assignment_join.sh +132 -0
- package/evals/integration/test_pull_work_liveness_preflight.sh +20 -7
- package/evals/integration/test_reconcile_soundness.sh +33 -9
- package/evals/integration/test_trust_reconcile.sh +9 -8
- package/evals/integration/test_trust_reconcile_negatives.sh +608 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +91 -3
- package/evals/run.sh +14 -0
- package/evals/static/test_flowdef_codeowners_coverage.sh +6 -0
- package/evals/static/test_knowledge_providers.sh +32 -0
- package/evals/static/test_workflow_skills.sh +15 -2
- package/kits/builder/skills/deliver/SKILL.md +36 -0
- package/kits/builder/skills/design-probe/SKILL.md +37 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
- package/kits/builder/skills/pull-work/SKILL.md +78 -10
- package/kits/knowledge/adapters/default-store/index.js +92 -4
- package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
- package/kits/knowledge/adapters/shared/codec.js +141 -0
- package/kits/knowledge/docs/README.md +121 -2
- package/kits/knowledge/docs/store-contract.md +112 -4
- package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
- package/kits/knowledge/flows/promote.flow.json +84 -0
- package/kits/knowledge/kit.json +50 -0
- package/kits/knowledge/promote/distill.js +96 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
- package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
- package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
- package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
- package/kits/knowledge/promote/fixtures/session/state.json +19 -0
- package/kits/knowledge/promote/health.js +137 -0
- package/kits/knowledge/promote/index.js +176 -0
- package/kits/knowledge/promote/ingest.js +103 -0
- package/kits/knowledge/promote/lib.js +132 -0
- package/kits/knowledge/promote/link.js +84 -0
- package/kits/knowledge/promote/promote.test.js +174 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
- package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
- package/kits/knowledge/providers/conformance/suite.test.js +143 -0
- package/kits/knowledge/providers/git-repo/index.js +236 -0
- package/kits/knowledge/providers/health/health-pass.test.js +99 -0
- package/kits/knowledge/providers/health/index.js +153 -0
- package/kits/knowledge/providers/index.js +25 -0
- package/kits/knowledge/providers/lib/model.js +91 -0
- package/kits/knowledge/providers/lib/schema-validate.js +119 -0
- package/kits/knowledge/providers/markdown-vault/index.js +169 -0
- package/kits/knowledge/providers/neo4j/connection.js +121 -0
- package/kits/knowledge/providers/neo4j/cypher.js +190 -0
- package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
- package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
- package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
- package/kits/knowledge/providers/neo4j/index.js +280 -0
- package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
- package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
- package/kits/knowledge/providers/neo4j/sync.js +235 -0
- package/kits/knowledge/providers/work-item/index.js +204 -0
- package/package.json +7 -3
- package/schemas/assignment-provider-settings.schema.json +125 -0
- package/schemas/knowledge/edge.schema.json +54 -0
- package/schemas/knowledge/health-report.schema.json +45 -0
- package/schemas/knowledge/node.schema.json +49 -0
- package/schemas/knowledge/proposal.schema.json +53 -0
- package/scripts/README.md +1 -0
- package/scripts/ci/trust-reconcile.js +521 -24
- package/scripts/freeze-adrs.mjs +364 -0
- package/scripts/hooks/config-protection.js +14 -1
- package/scripts/hooks/evidence-capture.js +4 -1
- package/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/scripts/hooks/lib/current-pointer.js +123 -0
- package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
- package/scripts/hooks/stop-goal-fit.js +4 -2
- package/scripts/hooks/workflow-steering.js +42 -0
- package/scripts/statusline/flow-agents-statusline.js +3 -1
- package/src/cli/assignment-provider.ts +927 -0
- package/src/cli/effective-assignment-provider-settings.ts +112 -0
- package/src/cli/validate-workflow-artifacts.ts +5 -1
- package/src/cli/workflow-sidecar.ts +577 -139
- package/src/cli.ts +6 -0
- package/src/lib/flow-resolver.ts +35 -14
- package/src/tools/validate-source-tree.ts +2 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* neo4j provider — LIVE integration test (gated).
|
|
3
|
+
*
|
|
4
|
+
* Runs against a real Neo4j Community instance ONLY when NEO4J_URI is set and
|
|
5
|
+
* reachable; otherwise it SKIPS LOUDLY (CI has no Neo4j — the CI proof is the
|
|
6
|
+
* unit tests + conformance with the injected fake driver). To run locally:
|
|
7
|
+
*
|
|
8
|
+
* docker run -d --name kg-neo4j -p 7474:7474 -p 7687:7687 \
|
|
9
|
+
* -e NEO4J_AUTH=neo4j/testpassword neo4j:5-community
|
|
10
|
+
* NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=testpassword \
|
|
11
|
+
* node --test kits/knowledge/providers/neo4j/integration.test.js
|
|
12
|
+
*
|
|
13
|
+
* Proves end-to-end what the fake driver can only approximate: real MERGE
|
|
14
|
+
* idempotency counters (AC1) and the five canonical queries executing as LIVE
|
|
15
|
+
* Cypher (AC3), asserted to agree with the portable JS backend (parity).
|
|
16
|
+
*
|
|
17
|
+
* @module providers/neo4j/integration.test
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { test, describe, before, after } from "node:test";
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
import { Neo4jProvider } from "./index.js";
|
|
27
|
+
import { syncToNeo4j } from "./sync.js";
|
|
28
|
+
import { resolveNeo4jConfig, createDriver, isReachable } from "./connection.js";
|
|
29
|
+
import * as jsq from "./graph-queries.js";
|
|
30
|
+
|
|
31
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const GRAPH = JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", "spike-ground-truth.json"), "utf8"));
|
|
33
|
+
|
|
34
|
+
const config = resolveNeo4jConfig();
|
|
35
|
+
let driver = null;
|
|
36
|
+
let live = false;
|
|
37
|
+
|
|
38
|
+
before(async () => {
|
|
39
|
+
if (!config) return;
|
|
40
|
+
driver = await createDriver(config);
|
|
41
|
+
live = await isReachable(driver);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
after(async () => {
|
|
45
|
+
if (driver) await driver.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("neo4j LIVE integration", () => {
|
|
49
|
+
test("live Neo4j round-trip, idempotent sync (AC1) and canonical Cypher queries (AC3)", async (t) => {
|
|
50
|
+
if (!config) {
|
|
51
|
+
t.skip("SKIP (loud): NEO4J_URI unset — live Neo4j integration not run. See file header to run locally.");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!live) {
|
|
55
|
+
t.skip(`SKIP (loud): Neo4j configured (${config.uri}) but not reachable — live integration not run.`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const graph = { nodes: JSON.parse(JSON.stringify(GRAPH.nodes)), edges: JSON.parse(JSON.stringify(GRAPH.edges)) };
|
|
60
|
+
|
|
61
|
+
// Clean slate.
|
|
62
|
+
{
|
|
63
|
+
const s = driver.session({ database: config.database });
|
|
64
|
+
try {
|
|
65
|
+
await s.run("MATCH (n) DETACH DELETE n");
|
|
66
|
+
} finally {
|
|
67
|
+
await s.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// AC1: first sync writes; second reports zero; forced re-sync also zero.
|
|
72
|
+
const first = await syncToNeo4j({ driver, database: config.database, graph });
|
|
73
|
+
assert.ok(first.writes > 0, "first live sync must write");
|
|
74
|
+
const second = await syncToNeo4j({ driver, database: config.database, graph });
|
|
75
|
+
assert.equal(second.writes, 0, "second live sync must report zero writes (MERGE idempotency)");
|
|
76
|
+
const forced = await syncToNeo4j({ driver, database: config.database, graph, force: true });
|
|
77
|
+
assert.equal(forced.writes, 0, "forced live re-sync of identical data must produce zero writes");
|
|
78
|
+
|
|
79
|
+
const provider = new Neo4jProvider({ driver, database: config.database, agent: "integration" });
|
|
80
|
+
assert.equal(provider._cypherAnalytics, true, "live driver must use the Cypher analytics backend");
|
|
81
|
+
|
|
82
|
+
// Read side round-trips.
|
|
83
|
+
const nodes = await provider.readNodes();
|
|
84
|
+
assert.ok(nodes.length >= GRAPH.nodes.length);
|
|
85
|
+
|
|
86
|
+
// AC3 via LIVE Cypher, asserted equal to the JS oracle over the same graph.
|
|
87
|
+
const blockers = await provider.transitiveBlockers("issue:313");
|
|
88
|
+
assert.deepEqual(
|
|
89
|
+
blockers.map((b) => b.id).sort(),
|
|
90
|
+
["issue:310", "issue:312"],
|
|
91
|
+
"Q1 live Cypher: #313 blockers = {#310,#312}, not #317",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const contradictions = await provider.contradictionCandidates();
|
|
95
|
+
assert.ok(
|
|
96
|
+
contradictions.some((c) => c.a_id === "decision:adr-0007" && c.b_id === "decision:adr-0007b"),
|
|
97
|
+
"Q2 live Cypher: ADR-0007 collision flagged",
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const orphans = await provider.orphans();
|
|
101
|
+
const orphanIds = orphans.map((o) => o.id);
|
|
102
|
+
assert.ok(orphanIds.includes("decision:orphan-adr"), "Q3 live Cypher: stale decision orphan");
|
|
103
|
+
assert.ok(!orphanIds.includes("session:s1"), "Q3 live Cypher: leaf types excluded");
|
|
104
|
+
|
|
105
|
+
const dups = await provider.duplicateCandidates();
|
|
106
|
+
assert.ok(
|
|
107
|
+
dups.some(
|
|
108
|
+
(d) =>
|
|
109
|
+
(d.a_id === "issue:traverse-8" && d.b_id === "issue:traverse-14") ||
|
|
110
|
+
(d.a_id === "issue:traverse-14" && d.b_id === "issue:traverse-8"),
|
|
111
|
+
),
|
|
112
|
+
"Q4 live Cypher: stemmed dup catches the traverse pair",
|
|
113
|
+
);
|
|
114
|
+
// Parity: the same stemmed dup is (not) found by exact tokens in JS.
|
|
115
|
+
const jsDups = jsq.duplicateCandidates(graph, { threshold: 0.7 });
|
|
116
|
+
const jsHit = jsDups.find((d) => d.a_id.startsWith("issue:traverse") && d.b_id.startsWith("issue:traverse"));
|
|
117
|
+
assert.ok(jsHit && jsHit.caught_only_by_stemming, "JS oracle agrees: caught only by stemming");
|
|
118
|
+
|
|
119
|
+
const sp = await provider.shortestPath("decision:adr-0011", "decision:adr-0021");
|
|
120
|
+
assert.equal(sp.length, 2, "Q5 live Cypher: shortest path length 2");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* neo4j provider — unit tests (CI-safe, no Docker, injected fake driver).
|
|
3
|
+
*
|
|
4
|
+
* Covers the parts of issue #327 that do NOT need a live server:
|
|
5
|
+
* - sync idempotency (AC1): writes>0 on first sync, 0 on re-sync, 0 on a
|
|
6
|
+
* FORCED re-sync (hash-guard) — proven against the fake driver's counters.
|
|
7
|
+
* - canonical queries (AC3): the five spike queries over the ground-truth
|
|
8
|
+
* fixture reproduce the spike's results, incl. the stemmed-dup case.
|
|
9
|
+
* - marker tolerance: a throwing provider does not kill the sync.
|
|
10
|
+
* - graceful degradation (AC4): selection falls back to file providers with a
|
|
11
|
+
* clear one-line message when no Neo4j is reachable / configured.
|
|
12
|
+
* - proposals-only write.
|
|
13
|
+
*
|
|
14
|
+
* Run: node --test kits/knowledge/providers/neo4j/neo4j.test.js
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { test, describe } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
import { Neo4jProvider } from "./index.js";
|
|
24
|
+
import { syncToNeo4j, materializeGraph, planSync, computeDigest } from "./sync.js";
|
|
25
|
+
import { selectKnowledgeProvider } from "./connection.js";
|
|
26
|
+
import { makeFakeDriver } from "./fake-driver.js";
|
|
27
|
+
import * as jsq from "./graph-queries.js";
|
|
28
|
+
import { loadSchemas } from "../lib/model.js";
|
|
29
|
+
import { validate } from "../lib/schema-validate.js";
|
|
30
|
+
|
|
31
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const GRAPH = JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", "spike-ground-truth.json"), "utf8"));
|
|
33
|
+
const schemas = loadSchemas();
|
|
34
|
+
|
|
35
|
+
function fixtureGraph() {
|
|
36
|
+
return { nodes: JSON.parse(JSON.stringify(GRAPH.nodes)), edges: JSON.parse(JSON.stringify(GRAPH.edges)) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("neo4j sync — idempotency (AC1)", () => {
|
|
40
|
+
test("first sync writes; second sync reports zero writes; forced re-sync also zero (hash-guard)", async () => {
|
|
41
|
+
const driver = makeFakeDriver();
|
|
42
|
+
const graph = fixtureGraph();
|
|
43
|
+
|
|
44
|
+
const first = await syncToNeo4j({ driver, graph });
|
|
45
|
+
assert.ok(first.writes > 0, `expected writes on first sync, got ${first.writes}`);
|
|
46
|
+
assert.equal(first.unchanged, false);
|
|
47
|
+
assert.ok(first.digest && first.snapshot.synced_at);
|
|
48
|
+
|
|
49
|
+
const second = await syncToNeo4j({ driver, graph });
|
|
50
|
+
assert.equal(second.writes, 0, "second sync must report zero writes");
|
|
51
|
+
assert.equal(second.unchanged, true);
|
|
52
|
+
|
|
53
|
+
// Force past the digest short-circuit: the hash-guarded MERGE must STILL
|
|
54
|
+
// produce zero created nodes/rels and zero property sets on identical data.
|
|
55
|
+
const forced = await syncToNeo4j({ driver, graph, force: true });
|
|
56
|
+
assert.equal(forced.writes, 0, "forced re-sync of identical data must produce zero writes");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("planSync is a pure no-op when the digest is unchanged", () => {
|
|
60
|
+
const graph = fixtureGraph();
|
|
61
|
+
const digest = computeDigest(graph);
|
|
62
|
+
const plan = planSync(graph, digest);
|
|
63
|
+
assert.equal(plan.unchanged, true);
|
|
64
|
+
assert.equal(plan.statements.length, 0);
|
|
65
|
+
const changed = planSync(graph, "different-digest");
|
|
66
|
+
assert.equal(changed.unchanged, false);
|
|
67
|
+
assert.ok(changed.statements.length > 0);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("neo4j read side round-trips through the (fake) driver", () => {
|
|
72
|
+
test("synced graph reads back as schema-valid nodes/edges", async () => {
|
|
73
|
+
const driver = makeFakeDriver();
|
|
74
|
+
await syncToNeo4j({ driver, graph: fixtureGraph() });
|
|
75
|
+
const provider = new Neo4jProvider({ driver });
|
|
76
|
+
|
|
77
|
+
const nodes = await provider.readNodes();
|
|
78
|
+
assert.ok(nodes.length > 0);
|
|
79
|
+
for (const n of nodes) {
|
|
80
|
+
const { valid, errors } = validate(n, schemas.node);
|
|
81
|
+
assert.ok(valid, `node ${n.id} invalid: ${errors.join(", ")}`);
|
|
82
|
+
}
|
|
83
|
+
const edges = await provider.readEdges();
|
|
84
|
+
for (const e of edges) {
|
|
85
|
+
const { valid, errors } = validate(e, schemas.edge);
|
|
86
|
+
assert.ok(valid, `edge ${e.id} invalid: ${errors.join(", ")}`);
|
|
87
|
+
}
|
|
88
|
+
// Edges to nodes present in the fixture materialize; all 8 fixture edges have
|
|
89
|
+
// resolvable endpoints, so the view keeps them.
|
|
90
|
+
assert.equal(edges.length, GRAPH.edges.length);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("neo4j canonical queries reproduce the spike ground truth (AC3)", () => {
|
|
95
|
+
const provider = new Neo4jProvider({ sourceGraph: fixtureGraph() });
|
|
96
|
+
|
|
97
|
+
test("Q1 transitive blockers of #313 = {#312 OPEN, #310 CLOSED}, NOT #317", async () => {
|
|
98
|
+
const blockers = await provider.transitiveBlockers("issue:313");
|
|
99
|
+
const ids = blockers.map((b) => b.id).sort();
|
|
100
|
+
assert.deepEqual(ids, ["issue:310", "issue:312"]);
|
|
101
|
+
assert.equal(blockers.find((b) => b.id === "issue:310").state, "CLOSED");
|
|
102
|
+
assert.equal(blockers.find((b) => b.id === "issue:312").state, "OPEN");
|
|
103
|
+
assert.ok(!ids.includes("issue:317"), "the spike headline: no #313 -> #317 edge exists");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("Q2 contradiction candidates catch the ADR-0007 collision", async () => {
|
|
107
|
+
const cands = await provider.contradictionCandidates();
|
|
108
|
+
const hit = cands.find((c) => c.a_id === "decision:adr-0007" && c.b_id === "decision:adr-0007b");
|
|
109
|
+
assert.ok(hit, "ADR-0007 vs ADR-0007b (same subject, divergent status) must be flagged");
|
|
110
|
+
// A different-subject decision pair must NOT be a contradiction.
|
|
111
|
+
assert.ok(!cands.some((c) => c.a_id === "decision:adr-0011" && c.b_id === "decision:adr-0021"));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("Q3 orphans are node-type aware: the stale decision surfaces, leaf types do not", async () => {
|
|
115
|
+
const orphans = await provider.orphans();
|
|
116
|
+
const ids = orphans.map((o) => o.id);
|
|
117
|
+
assert.ok(ids.includes("decision:orphan-adr"), "the undiscoverable decision is a meaningful orphan");
|
|
118
|
+
assert.ok(!ids.includes("session:s1"), "sessions are source-only leaves — never flagged");
|
|
119
|
+
assert.ok(!ids.includes("learning:l1"), "learnings are source-only leaves — never flagged");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("Q4 duplicate detection needs stemming: traverse#14 ~ #8 caught only by stemming", async () => {
|
|
123
|
+
const dups = await provider.duplicateCandidates();
|
|
124
|
+
const hit = dups.find(
|
|
125
|
+
(d) =>
|
|
126
|
+
(d.a_id === "issue:traverse-8" && d.b_id === "issue:traverse-14") ||
|
|
127
|
+
(d.a_id === "issue:traverse-14" && d.b_id === "issue:traverse-8"),
|
|
128
|
+
);
|
|
129
|
+
assert.ok(hit, "the known traverse duplicate must be detected");
|
|
130
|
+
assert.equal(hit.caught_only_by_stemming, true, "exact tokens miss it; stemming catches it");
|
|
131
|
+
assert.ok(hit.exact_similarity < 0.7 && hit.stemmed_similarity >= 0.7);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("Q5 shortest path between two decisions is length 2 through a shared node", async () => {
|
|
135
|
+
const sp = await provider.shortestPath("decision:adr-0011", "decision:adr-0021");
|
|
136
|
+
assert.ok(sp, "a path must exist");
|
|
137
|
+
assert.equal(sp.length, 2);
|
|
138
|
+
assert.equal(sp.path[0], "decision:adr-0011");
|
|
139
|
+
assert.equal(sp.path[sp.path.length - 1], "decision:adr-0021");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("naive exact-token duplicate detection MISSES the traverse dup (the spike's finding)", () => {
|
|
143
|
+
// Direct evidence of the finding: exact tokens produce no cross-threshold hit
|
|
144
|
+
// for the traverse pair, stemming does.
|
|
145
|
+
const g = { nodes: fixtureGraph().nodes.filter((n) => n.id.startsWith("issue:traverse")), edges: [] };
|
|
146
|
+
const dups = jsq.duplicateCandidates(g, { threshold: 0.7 });
|
|
147
|
+
assert.equal(dups.length, 1);
|
|
148
|
+
assert.equal(dups[0].exact_similarity, 0);
|
|
149
|
+
assert.ok(dups[0].stemmed_similarity >= 0.7);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("marker tolerance (spike finding 3)", () => {
|
|
154
|
+
test("a provider that throws while reading does not kill the sync", async () => {
|
|
155
|
+
const good = new Neo4jProvider({ sourceGraph: fixtureGraph() });
|
|
156
|
+
const exploding = {
|
|
157
|
+
id: "work-item",
|
|
158
|
+
async readGraph() {
|
|
159
|
+
throw new Error("simulated malformed metadata marker: 3/19 failed JSON.parse");
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
const mat = await materializeGraph([good, exploding]);
|
|
163
|
+
assert.ok(mat.nodes.length > 0, "the healthy provider's graph still materialized");
|
|
164
|
+
assert.equal(mat.errors.length, 1);
|
|
165
|
+
assert.match(mat.errors[0].error, /malformed metadata marker/);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("graceful degradation (AC4)", () => {
|
|
170
|
+
test("neo4j selected but NEO4J_URI unset -> file providers with a clear message", async () => {
|
|
171
|
+
const msgs = [];
|
|
172
|
+
const result = await selectKnowledgeProvider({
|
|
173
|
+
preference: "neo4j",
|
|
174
|
+
env: {},
|
|
175
|
+
fileProviders: async () => [{ id: "git-repo" }],
|
|
176
|
+
log: (m) => msgs.push(m),
|
|
177
|
+
});
|
|
178
|
+
assert.equal(result.provider, "file");
|
|
179
|
+
assert.equal(result.providers[0].id, "git-repo");
|
|
180
|
+
assert.match(msgs[0], /NEO4J_URI is unset/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("neo4j reachable path selects the neo4j provider", async () => {
|
|
184
|
+
const driver = makeFakeDriver();
|
|
185
|
+
const result = await selectKnowledgeProvider({
|
|
186
|
+
preference: "neo4j",
|
|
187
|
+
env: { NEO4J_URI: "bolt://localhost:7687" },
|
|
188
|
+
driver,
|
|
189
|
+
fileProviders: async () => [{ id: "git-repo" }],
|
|
190
|
+
neo4jFactory: async ({ driver: d }) => new Neo4jProvider({ driver: d }),
|
|
191
|
+
});
|
|
192
|
+
assert.equal(result.provider, "neo4j");
|
|
193
|
+
assert.ok(result.graph instanceof Neo4jProvider);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("default preference is file (graph is opt-in only)", async () => {
|
|
197
|
+
const result = await selectKnowledgeProvider({
|
|
198
|
+
env: {},
|
|
199
|
+
fileProviders: async () => [{ id: "git-repo" }],
|
|
200
|
+
});
|
|
201
|
+
assert.equal(result.provider, "file");
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("proposals-only write", () => {
|
|
206
|
+
test("proposeWrite returns a schema-valid 'proposed' proposal and mutates nothing", async () => {
|
|
207
|
+
const driver = makeFakeDriver();
|
|
208
|
+
await syncToNeo4j({ driver, graph: fixtureGraph() });
|
|
209
|
+
const provider = new Neo4jProvider({ driver });
|
|
210
|
+
const before = await provider.readNodes();
|
|
211
|
+
const p = await provider.proposeWrite({ kind: "create-node", type: "note", title: "Proposed" });
|
|
212
|
+
const { valid, errors } = validate(p, schemas.proposal);
|
|
213
|
+
assert.ok(valid, `proposal invalid: ${errors.join(", ")}`);
|
|
214
|
+
assert.equal(p.status, "proposed");
|
|
215
|
+
assert.equal(p.provider, "neo4j");
|
|
216
|
+
const after = await provider.readNodes();
|
|
217
|
+
assert.equal(after.length, before.length, "proposeWrite must not mutate the store");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync / materialize: import the file/work-item providers into Neo4j.
|
|
3
|
+
*
|
|
4
|
+
* The file stores remain the SOURCE OF TRUTH; the graph is a queryable
|
|
5
|
+
* materialized view. Sync reuses the #317 providers as READERS (no forked
|
|
6
|
+
* extractors) and MERGEs their combined graph into Neo4j with MERGE semantics,
|
|
7
|
+
* so a re-sync of unchanged data reports zero writes (AC1). A snapshot digest +
|
|
8
|
+
* timestamp is recorded on the graph so the second run can short-circuit and so
|
|
9
|
+
* the graph is point-in-time attributable.
|
|
10
|
+
*
|
|
11
|
+
* Marker tolerance (spike finding 3: 3/19 metadata markers failed JSON.parse in
|
|
12
|
+
* the wild): a provider that throws while reading MUST NOT kill the whole sync.
|
|
13
|
+
* Each provider is read behind a guard that falls back to an empty contribution
|
|
14
|
+
* and records the error; the work-item provider already falls back from a
|
|
15
|
+
* malformed metadata marker to prose refs, and this guard covers the rest.
|
|
16
|
+
*
|
|
17
|
+
* Pure planning (planSync/computeDigest) is separated from IO (syncToNeo4j) so
|
|
18
|
+
* idempotency is unit-testable with no driver and no Docker.
|
|
19
|
+
*
|
|
20
|
+
* @module providers/neo4j/sync
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import {
|
|
25
|
+
typeToLabel,
|
|
26
|
+
edgeTypeToRel,
|
|
27
|
+
nodeToProps,
|
|
28
|
+
edgeToProps,
|
|
29
|
+
SNAPSHOT_LABEL,
|
|
30
|
+
SNAPSHOT_ID,
|
|
31
|
+
READ_SNAPSHOT_CYPHER,
|
|
32
|
+
} from "./cypher.js";
|
|
33
|
+
|
|
34
|
+
/** Deterministic JSON (sorted keys) for hashing. */
|
|
35
|
+
function canonical(value) {
|
|
36
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
37
|
+
if (value && typeof value === "object") {
|
|
38
|
+
return `{${Object.keys(value)
|
|
39
|
+
.sort()
|
|
40
|
+
.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
|
|
41
|
+
.join(",")}}`;
|
|
42
|
+
}
|
|
43
|
+
return JSON.stringify(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The graph is a materialized view keyed on CONTENT, not on when it was read.
|
|
48
|
+
* Provenance carries volatile fields (retrieved_at, agent) that change every
|
|
49
|
+
* read — including them would defeat idempotency (a re-sync of unchanged stores
|
|
50
|
+
* would rewrite every element). Strip them for identity; the snapshot's
|
|
51
|
+
* synced_at records the point-in-time separately.
|
|
52
|
+
*/
|
|
53
|
+
function contentIdentity(el) {
|
|
54
|
+
const clone = JSON.parse(JSON.stringify(el));
|
|
55
|
+
if (clone.provenance) {
|
|
56
|
+
delete clone.provenance.retrieved_at;
|
|
57
|
+
delete clone.provenance.agent;
|
|
58
|
+
}
|
|
59
|
+
return clone;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Content hash of a single node/edge (drives the idempotent hash-guarded SET). */
|
|
63
|
+
export function hashElement(el) {
|
|
64
|
+
return createHash("sha256").update(canonical(contentIdentity(el))).digest("hex").slice(0, 16);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Digest of the whole materialized graph (drives the zero-write short-circuit). */
|
|
68
|
+
export function computeDigest(graph) {
|
|
69
|
+
const nodes = [...(graph.nodes || [])].map(contentIdentity).sort((a, b) => a.id.localeCompare(b.id));
|
|
70
|
+
const edges = [...(graph.edges || [])].map(contentIdentity).sort((a, b) => a.id.localeCompare(b.id));
|
|
71
|
+
return createHash("sha256").update(canonical({ nodes, edges })).digest("hex");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Read one provider's graph behind a guard; never let one provider kill sync. */
|
|
75
|
+
async function safeReadGraph(provider, errors) {
|
|
76
|
+
try {
|
|
77
|
+
const g = await provider.readGraph();
|
|
78
|
+
return { nodes: g.nodes || [], edges: g.edges || [] };
|
|
79
|
+
} catch (err) {
|
|
80
|
+
errors.push({ provider: provider?.id || provider?.constructor?.name || "unknown", error: String(err && err.message || err) });
|
|
81
|
+
return { nodes: [], edges: [] };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Materialize the combined graph from the provider readers. Dedupes nodes and
|
|
87
|
+
* edges by id (last write wins on id collision across providers).
|
|
88
|
+
* @param {object[]} providers #317 provider instances (vault, git-repo, work-item, …)
|
|
89
|
+
* @returns {Promise<{ nodes:object[], edges:object[], errors:object[] }>}
|
|
90
|
+
*/
|
|
91
|
+
export async function materializeGraph(providers) {
|
|
92
|
+
const errors = [];
|
|
93
|
+
const nodeById = new Map();
|
|
94
|
+
const edgeById = new Map();
|
|
95
|
+
for (const provider of providers) {
|
|
96
|
+
const { nodes, edges } = await safeReadGraph(provider, errors);
|
|
97
|
+
for (const n of nodes) nodeById.set(n.id, n);
|
|
98
|
+
for (const e of edges) edgeById.set(e.id, e);
|
|
99
|
+
}
|
|
100
|
+
return { nodes: [...nodeById.values()], edges: [...edgeById.values()], errors };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Pure sync plan. Given the materialized graph and the prior snapshot digest,
|
|
105
|
+
* decide whether anything changed and produce the parameterized MERGE
|
|
106
|
+
* statements. When the digest is unchanged, the plan is a no-op (AC1: second run
|
|
107
|
+
* reports zero writes) unless `force` is set.
|
|
108
|
+
*
|
|
109
|
+
* @param {{nodes:object[],edges:object[]}} graph
|
|
110
|
+
* @param {string|null} priorDigest
|
|
111
|
+
* @param {object} [options]
|
|
112
|
+
* @param {boolean} [options.force=false] emit statements even when unchanged
|
|
113
|
+
* @returns {{ digest:string, unchanged:boolean, statements:{cypher:string,params:object}[] }}
|
|
114
|
+
*/
|
|
115
|
+
export function planSync(graph, priorDigest, options = {}) {
|
|
116
|
+
const digest = computeDigest(graph);
|
|
117
|
+
const unchanged = digest === priorDigest;
|
|
118
|
+
if (unchanged && !options.force) {
|
|
119
|
+
return { digest, unchanged, statements: [] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const statements = [];
|
|
123
|
+
|
|
124
|
+
// Nodes grouped by contract type -> one MERGE per Neo4j label. Hash-guarded
|
|
125
|
+
// SET so an unchanged node produces zero propertiesSet on re-run.
|
|
126
|
+
const nodesByLabel = new Map();
|
|
127
|
+
for (const n of graph.nodes || []) {
|
|
128
|
+
const label = typeToLabel(n.type);
|
|
129
|
+
if (!nodesByLabel.has(label)) nodesByLabel.set(label, []);
|
|
130
|
+
nodesByLabel.get(label).push(nodeToProps(n, hashElement(n)));
|
|
131
|
+
}
|
|
132
|
+
for (const [label, rows] of nodesByLabel) {
|
|
133
|
+
statements.push({
|
|
134
|
+
cypher: `UNWIND $rows AS r MERGE (n:${label} {id:r.id}) WITH n, r WHERE coalesce(n._kg_hash,'') <> r._kg_hash SET n += r`,
|
|
135
|
+
params: { rows },
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Edges grouped by rel type. Endpoints matched by id regardless of label
|
|
140
|
+
// (spike: polyglot property bags "just work"); edges to a not-yet-synced node
|
|
141
|
+
// are skipped by the MATCH (external/dangling refs stay out of the view).
|
|
142
|
+
const edgesByRel = new Map();
|
|
143
|
+
for (const e of graph.edges || []) {
|
|
144
|
+
const rel = edgeTypeToRel(e.type);
|
|
145
|
+
if (!edgesByRel.has(rel)) edgesByRel.set(rel, []);
|
|
146
|
+
edgesByRel.get(rel).push(edgeToProps(e, hashElement(e)));
|
|
147
|
+
}
|
|
148
|
+
for (const [rel, rows] of edgesByRel) {
|
|
149
|
+
statements.push({
|
|
150
|
+
cypher: `UNWIND $rows AS r MATCH (a {id:r.from}) MATCH (b {id:r.to}) MERGE (a)-[x:${rel} {id:r.id}]->(b) WITH x, r WHERE coalesce(x._kg_hash,'') <> r._kg_hash SET x += r`,
|
|
151
|
+
params: { rows },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { digest, unchanged, statements };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Read the prior snapshot digest from Neo4j (null if never synced). */
|
|
159
|
+
async function readPriorDigest(session) {
|
|
160
|
+
const res = await session.run(READ_SNAPSHOT_CYPHER, { id: SNAPSHOT_ID });
|
|
161
|
+
const rec = res.records && res.records[0];
|
|
162
|
+
if (!rec) return null;
|
|
163
|
+
const props = rec.get("props");
|
|
164
|
+
return (props && props.digest) || null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function sumWrites(counters) {
|
|
168
|
+
const u = typeof counters.updates === "function" ? counters.updates() : counters;
|
|
169
|
+
return (u.nodesCreated || 0) + (u.relationshipsCreated || 0) + (u.propertiesSet || 0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Materialize the providers and MERGE them into Neo4j idempotently.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} options
|
|
176
|
+
* @param {object} options.driver neo4j driver (or the injected fake)
|
|
177
|
+
* @param {string} [options.database]
|
|
178
|
+
* @param {object[]} [options.providers] #317 providers to read (or pass graph)
|
|
179
|
+
* @param {{nodes,edges}} [options.graph] a pre-materialized graph (skips readers)
|
|
180
|
+
* @param {boolean} [options.force=false] re-run MERGEs even if the digest matches
|
|
181
|
+
* @returns {Promise<{ writes:number, unchanged:boolean, digest:string, nodes:number, edges:number, snapshot:object, errors:object[] }>}
|
|
182
|
+
*/
|
|
183
|
+
export async function syncToNeo4j(options = {}) {
|
|
184
|
+
const { driver, database, force = false } = options;
|
|
185
|
+
let errors = [];
|
|
186
|
+
let graph = options.graph;
|
|
187
|
+
if (!graph) {
|
|
188
|
+
const mat = await materializeGraph(options.providers || []);
|
|
189
|
+
graph = { nodes: mat.nodes, edges: mat.edges };
|
|
190
|
+
errors = mat.errors;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const session = driver.session(database ? { database } : undefined);
|
|
194
|
+
try {
|
|
195
|
+
const priorDigest = await readPriorDigest(session);
|
|
196
|
+
const plan = planSync(graph, priorDigest, { force });
|
|
197
|
+
|
|
198
|
+
if (plan.unchanged && !force) {
|
|
199
|
+
return {
|
|
200
|
+
writes: 0,
|
|
201
|
+
unchanged: true,
|
|
202
|
+
digest: plan.digest,
|
|
203
|
+
nodes: graph.nodes.length,
|
|
204
|
+
edges: graph.edges.length,
|
|
205
|
+
snapshot: { digest: plan.digest },
|
|
206
|
+
errors,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let writes = 0;
|
|
211
|
+
for (const stmt of plan.statements) {
|
|
212
|
+
const res = await session.run(stmt.cypher, stmt.params);
|
|
213
|
+
writes += sumWrites(res.summary.counters);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const syncedAt = new Date().toISOString();
|
|
217
|
+
const snapRes = await session.run(
|
|
218
|
+
`MERGE (s:${SNAPSHOT_LABEL} {id:$id}) WITH s, $digest AS d WHERE coalesce(s.digest,'') <> d SET s.digest = d, s.synced_at = $syncedAt, s.node_count = $nc, s.edge_count = $ec`,
|
|
219
|
+
{ id: SNAPSHOT_ID, digest: plan.digest, syncedAt, nc: graph.nodes.length, ec: graph.edges.length },
|
|
220
|
+
);
|
|
221
|
+
writes += sumWrites(snapRes.summary.counters);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
writes,
|
|
225
|
+
unchanged: plan.unchanged,
|
|
226
|
+
digest: plan.digest,
|
|
227
|
+
nodes: graph.nodes.length,
|
|
228
|
+
edges: graph.edges.length,
|
|
229
|
+
snapshot: { digest: plan.digest, synced_at: syncedAt },
|
|
230
|
+
errors,
|
|
231
|
+
};
|
|
232
|
+
} finally {
|
|
233
|
+
await session.close();
|
|
234
|
+
}
|
|
235
|
+
}
|