@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.15
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/CHANGELOG.md +283 -0
- package/README.md +102 -52
- package/SECURITY.md +1 -1
- package/TRADEMARKS.md +2 -2
- package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
- package/contracts/atelier-graph-view.v1.schema.json +1085 -0
- package/contracts/public-api-baseline.json +298 -19
- package/docs/README.md +90 -0
- package/docs/blocks/capability-stewardship.md +1 -1
- package/docs/blocks/claims.md +2 -2
- package/docs/blocks/conformance-admission.md +1 -1
- package/docs/blocks/learning-harnesses.md +1 -1
- package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
- package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
- package/docs/guides/co-authoring.md +113 -0
- package/docs/{distributions.md → guides/distributions.md} +13 -8
- package/docs/guides/extensions.md +189 -0
- package/docs/guides/graph-refresh.md +152 -0
- package/docs/guides/graph-workspace.md +64 -0
- package/docs/{ingestion.md → guides/ingestion.md} +16 -15
- package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
- package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
- package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
- package/docs/{learning.md → guides/learning.md} +12 -5
- package/docs/{obsidian.md → guides/obsidian.md} +370 -23
- package/docs/guides/ontology-guide.md +111 -0
- package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
- package/docs/{research-starter.md → guides/research-starter.md} +1 -1
- package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
- package/docs/{upgrade.md → guides/upgrade.md} +197 -11
- package/docs/knowledge-health/assessment/capabilities.md +6 -15
- package/docs/knowledge-health/facilitator.md +15 -4
- package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
- package/docs/knowledge-health/quickstart.md +97 -75
- package/docs/knowledge-health/readme.md +13 -5
- package/docs/maintainers/describing-the-atelier.md +173 -0
- package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
- package/docs/{announcements.md → reference/announcements.md} +1 -1
- package/docs/{architecture.md → reference/architecture.md} +31 -5
- package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
- package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
- package/docs/{coordination.md → reference/coordination.md} +1 -1
- package/docs/{decisions.md → reference/decisions.md} +3 -3
- package/docs/{design.md → reference/design.md} +1 -1
- package/docs/reference/edge-policy.md +151 -0
- package/docs/reference/graph-view-structured.md +119 -0
- package/docs/reference/graph-view.md +139 -0
- package/docs/reference/graph-workspace.md +37 -0
- package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
- package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
- package/docs/{local-services.md → reference/local-services.md} +43 -4
- package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
- package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
- package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
- package/docs/reference/portability.md +92 -0
- package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
- package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
- package/docs/{project-options.md → reference/project-options.md} +17 -0
- package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
- package/docs/{review-portability.md → reference/review-portability.md} +4 -0
- package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
- package/docs/{template-profile.md → reference/template-profile.md} +1 -1
- package/docs/{trackables.md → reference/trackables.md} +2 -2
- package/docs/start/independent-start.md +89 -0
- package/docs/{install.md → start/install.md} +9 -9
- package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
- package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
- package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
- package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
- package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
- package/fixtures/graph-view/synthetic-view.v1.json +1 -0
- package/fixtures/knowledge-health/workshop/README.md +1 -1
- package/fixtures/templates/local-library.v1.json +3 -1
- package/package.json +22 -8
- package/plugins/obsidian/main.js +57 -8
- package/plugins/obsidian/manifest.json +1 -1
- package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
- package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
- package/skills/claude/atelier-local-service/SKILL.md +1 -1
- package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
- package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
- package/skills/claude/mnstry-readiness/SKILL.md +2 -2
- package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
- package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
- package/skills/codex/atelier-local-service/SKILL.md +1 -1
- package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
- package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
- package/skills/codex/mnstry-readiness/SKILL.md +2 -2
- package/src/architecture/catalog.mjs +2 -2
- package/src/attestation/sign.mjs +1 -1
- package/src/boundary/content-rules.mjs +534 -68
- package/src/boundary/policy.mjs +187 -60
- package/src/cli/run.mjs +26 -9
- package/src/commands/distribution.mjs +1 -1
- package/src/commands/extension.mjs +27 -0
- package/src/commands/ingest.mjs +1 -1
- package/src/commands/learn.mjs +1 -1
- package/src/commands/obsidian.mjs +49 -21
- package/src/commands/review.mjs +3 -0
- package/src/edge-policy/contract.mjs +69 -0
- package/src/edge-policy/policy.mjs +142 -0
- package/src/edge-policy/source-citation-adapter.mjs +43 -0
- package/src/extension-packs/loader.mjs +4 -3
- package/src/extensions/admission.mjs +83 -0
- package/src/extensions/client.d.mts +23 -0
- package/src/extensions/client.mjs +99 -0
- package/src/extensions/contracts.d.mts +29 -0
- package/src/extensions/contracts.mjs +163 -0
- package/src/extensions/convex.d.mts +24 -0
- package/src/extensions/convex.mjs +189 -0
- package/src/extensions/delegation.mjs +54 -0
- package/src/extensions/drizzle.d.mts +11 -0
- package/src/extensions/drizzle.mjs +33 -0
- package/src/extensions/postgres.d.mts +7 -0
- package/src/extensions/postgres.mjs +66 -0
- package/src/extensions/prisma.d.mts +13 -0
- package/src/extensions/prisma.mjs +63 -0
- package/src/extensions/project.mjs +432 -0
- package/src/extensions/scaffold-convex.mjs +130 -0
- package/src/extensions/scaffold-sql.mjs +144 -0
- package/src/extensions/server.d.mts +20 -0
- package/src/extensions/server.mjs +102 -0
- package/src/extensions/sql-scope.mjs +22 -0
- package/src/graph/graph.mjs +13 -5
- package/src/graph/knowledge-graph.mjs +20 -2
- package/src/graph/repo-contribution.mjs +405 -0
- package/src/graph/repo-contribution.types.mjs +32 -0
- package/src/graph-access/edge-decision-adapter.mjs +41 -0
- package/src/graph-access/edge-record-adapter.mjs +73 -0
- package/src/graph-access/index.mjs +253 -0
- package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
- package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
- package/src/graph-access/source-adapter.mjs +81 -0
- package/src/graph-refresh/compare.mjs +110 -0
- package/src/graph-refresh/controller.mjs +324 -0
- package/src/graph-refresh/index.mjs +3 -0
- package/src/graph-refresh/local.mjs +78 -0
- package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
- package/src/graph-view/babylon/index.mjs +2 -0
- package/src/graph-view/babylon/navigator.mjs +312 -0
- package/src/graph-view/babylon/provenance.mjs +36 -0
- package/src/graph-view/babylon/scene-controller.mjs +131 -0
- package/src/graph-view/babylon/semantic-picking.mjs +35 -0
- package/src/graph-view/babylon/web-host.mjs +188 -0
- package/src/graph-view/cli.mjs +3 -0
- package/src/graph-view/command.mjs +34 -0
- package/src/graph-view/contracts.mjs +79 -0
- package/src/graph-view/errors.mjs +5 -0
- package/src/graph-view/index.mjs +4 -0
- package/src/graph-view/intents.mjs +30 -0
- package/src/graph-view/query.mjs +213 -0
- package/src/graph-view/refs.mjs +2 -0
- package/src/graph-view/structured/browser.mjs +262 -0
- package/src/graph-view/structured/styles.mjs +8 -0
- package/src/graph-workspace/authoring.mjs +185 -0
- package/src/graph-workspace/browser/constellation.mjs +369 -0
- package/src/graph-workspace/browser/dom.mjs +23 -0
- package/src/graph-workspace/browser/index.mjs +6 -0
- package/src/graph-workspace/browser/review.mjs +55 -0
- package/src/graph-workspace/browser/styles.mjs +11 -0
- package/src/graph-workspace/browser/workbench.mjs +351 -0
- package/src/graph-workspace/browser/workspace.mjs +431 -0
- package/src/graph-workspace/disclosure.mjs +39 -0
- package/src/graph-workspace/geometry.mjs +78 -0
- package/src/graph-workspace/index.mjs +11 -0
- package/src/graph-workspace/labels.mjs +50 -0
- package/src/graph-workspace/model.mjs +109 -0
- package/src/graph-workspace/recipes.mjs +88 -0
- package/src/graph-workspace/scale.mjs +95 -0
- package/src/graph-workspace/theme.mjs +156 -0
- package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
- package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
- package/src/ingestion/optional/graphify/native-pins.json +306 -0
- package/src/knowledge/ontology-guide.mjs +152 -0
- package/src/knowledge-health/proof/installed-check.mjs +2 -2
- package/src/knowledge-health/rule-identity.mjs +14 -9
- package/src/knowledge-health/workshop.mjs +22 -2
- package/src/personal-workspace/index.mjs +1 -1
- package/src/personal-workspace/selection.mjs +34 -3
- package/src/project/config.mjs +1 -1
- package/src/project/path-match.mjs +2 -1
- package/src/project/providers/github.mjs +24 -0
- package/src/project/repo-identity.mjs +3 -22
- package/src/projection/obsidian/contracts.mjs +1 -1
- package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
- package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
- package/src/projection/obsidian/materialize/redaction.mjs +1 -1
- package/src/projection/obsidian/personal-workspace.mjs +84 -5
- package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
- package/src/projection/obsidian/proposals/adapter.mjs +46 -23
- package/src/projection/obsidian/proposals/observation.mjs +40 -2
- package/src/projection/obsidian/publication/publisher.mjs +96 -8
- package/src/projection/project.mjs +21 -16
- package/src/readiness/readiness.mjs +20 -11
- package/src/readiness-protocols/bundled-pack.mjs +14 -766
- package/src/readiness-protocols/bundled-selection.mjs +32 -0
- package/src/readiness-protocols/evidence.mjs +2 -1
- package/src/readiness-protocols/packs/mnstry.mjs +766 -0
- package/src/readiness-protocols/runtime.mjs +43 -6
- package/src/runtime/obsidian/engine.mjs +387 -76
- package/src/runtime/obsidian/lifecycle.mjs +20 -15
- package/src/runtime/obsidian/login-item.mjs +6 -3
- package/src/runtime/obsidian/machine-settings.mjs +52 -8
- package/src/runtime/obsidian/observation.mjs +36 -4
- package/src/runtime/obsidian/opening.mjs +4 -3
- package/src/runtime/obsidian/personal-selection.mjs +187 -0
- package/src/runtime/obsidian/pipeline.mjs +115 -6
- package/src/runtime/obsidian/private-lock.mjs +50 -6
- package/src/runtime/obsidian/selection-continuity.mjs +124 -0
- package/src/runtime/obsidian/service-main.mjs +68 -16
- package/src/runtime/obsidian/service.mjs +80 -6
- package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
- package/src/runtime/obsidian/view-permission.mjs +335 -0
- package/src/ui/control-system.mjs +201 -0
- package/src/ui/html-primitives.mjs +3 -0
- package/src/upgrade/closure-diagnostics.mjs +135 -0
- package/src/upgrade/closure.mjs +649 -0
- package/src/upgrade/upgrade.mjs +42 -11
- package/templates/distribution-workspace/README.md +2 -2
- package/templates/external-project-workspace/atelier.project.json +3 -0
- package/templates/external-project-workspace/pack-lifecycle.json +3 -1
- package/templates/knowledge-workspace/README.md +2 -2
- package/docs/integration-and-review-plan.md +0 -409
- package/docs/integration-contract-decisions.md +0 -44
- package/docs/knowledge-health/devday/facilitator.md +0 -16
- package/docs/knowledge-health/devday/public-default.md +0 -107
- package/docs/knowledge-health/devday/quickstart.md +0 -18
- package/docs/knowledge-health/devday/support.md +0 -22
- package/docs/knowledge-health/exercise.md +0 -17
- package/docs/knowledge-health/support.md +0 -34
- package/docs/presentation-review-disposition.md +0 -47
- package/docs/progressive-ingestion-acceptance.md +0 -89
- package/docs/progressive-ingestion.md +0 -235
- /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
- /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
- /package/docs/{local-review.md → guides/local-review.md} +0 -0
- /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
- /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
- /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
- /package/docs/{attestation.md → reference/attestation.md} +0 -0
- /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
- /package/docs/{continuity.md → reference/continuity.md} +0 -0
- /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
- /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
- /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
- /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
- /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
- /package/docs/{local-state.md → reference/local-state.md} +0 -0
- /package/docs/{ontology.md → reference/ontology.md} +0 -0
- /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
- /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
- /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
- /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createBabylonPortFactory } from './babylon-scene-port.mjs'
|
|
2
|
+
import { createGraphNavigator } from './navigator.mjs'
|
|
3
|
+
|
|
4
|
+
const activeCanvases = new WeakMap()
|
|
5
|
+
function unavailableHost(reason, dispose = () => {}) {
|
|
6
|
+
const unavailable = () => ({ status: 'unavailable' })
|
|
7
|
+
return Object.freeze({ status: 'unavailable', reason,
|
|
8
|
+
select: unavailable, selectEdge: unavailable, receiveSelection: unavailable,
|
|
9
|
+
focus: unavailable, expand: unavailable, openEvidence: unavailable, setQuestion: unavailable,
|
|
10
|
+
setLens: unavailable, update: unavailable, fit() {}, reset() {}, zoom() {}, pan() {},
|
|
11
|
+
snapshot: () => null, dispose, clear: dispose })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// The browser owns scheduling, input and engine lifetime; the reused port owns
|
|
15
|
+
// scene geometry. Graphics availability never controls the companion document.
|
|
16
|
+
export function mountGraphCanvas(canvas, { B, view, onIntent, onAvailability = () => {},
|
|
17
|
+
createEngine = element => new B.Engine(element, true, { preserveDrawingBuffer: false, stencil: true }),
|
|
18
|
+
} = {}) {
|
|
19
|
+
if (!canvas?.ownerDocument?.defaultView) throw new TypeError('Browser canvas required')
|
|
20
|
+
if (activeCanvases.has(canvas)) return unavailableHost('canvas-in-use')
|
|
21
|
+
const lease = {}
|
|
22
|
+
activeCanvases.set(canvas, lease)
|
|
23
|
+
const document = canvas.ownerDocument, window = document.defaultView
|
|
24
|
+
const listeners = [], cameras = new Set()
|
|
25
|
+
let engine, navigator, camera, frame = null, down = null, disposed = false, lost = false
|
|
26
|
+
let motion = window.matchMedia?.('(prefers-reduced-motion: reduce)')
|
|
27
|
+
const listen = (target, event, handler, options) => {
|
|
28
|
+
target.addEventListener(event, handler, options)
|
|
29
|
+
listeners.push(() => target.removeEventListener(event, handler, options))
|
|
30
|
+
}
|
|
31
|
+
const available = state => { try { onAvailability(state) } catch { /* Consumer notification. */ } }
|
|
32
|
+
function clearBuffer() {
|
|
33
|
+
// Force reallocation: assigning the same width can retain composited pixels.
|
|
34
|
+
const width = canvas.width
|
|
35
|
+
canvas.width = width === 0 ? 1 : 0
|
|
36
|
+
canvas.width = width
|
|
37
|
+
}
|
|
38
|
+
function present(result) {
|
|
39
|
+
if (result.status === 'empty') clearBuffer()
|
|
40
|
+
fit()
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
function dispose() {
|
|
44
|
+
if (disposed) return
|
|
45
|
+
disposed = true
|
|
46
|
+
if (frame !== null) window.cancelAnimationFrame(frame)
|
|
47
|
+
frame = null
|
|
48
|
+
down = null
|
|
49
|
+
listeners.splice(0).reverse().forEach(remove => remove())
|
|
50
|
+
try { navigator?.dispose() } finally {
|
|
51
|
+
for (const item of cameras) item.detachControl()
|
|
52
|
+
cameras.clear()
|
|
53
|
+
try { engine?.dispose() } finally {
|
|
54
|
+
engine = camera = navigator = null
|
|
55
|
+
if (activeCanvases.get(canvas) === lease) {
|
|
56
|
+
activeCanvases.delete(canvas)
|
|
57
|
+
// Reset the owned drawing buffer synchronously, including the last
|
|
58
|
+
// composited frame, before a narrower view may be delivered.
|
|
59
|
+
clearBuffer()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function applyMotion() {
|
|
65
|
+
if (!camera) return
|
|
66
|
+
camera.inertia = motion?.matches ? 0 : .85
|
|
67
|
+
camera.panningInertia = motion?.matches ? 0 : .85
|
|
68
|
+
if (motion?.matches) stopInertia()
|
|
69
|
+
}
|
|
70
|
+
function stopInertia() {
|
|
71
|
+
if (!camera) return
|
|
72
|
+
for (const name of ['inertialAlphaOffset', 'inertialBetaOffset', 'inertialRadiusOffset', 'inertialPanningX', 'inertialPanningY']) camera[name] = 0
|
|
73
|
+
}
|
|
74
|
+
function fit() {
|
|
75
|
+
if (!camera || !navigator) return
|
|
76
|
+
const positions = camera.getScene().meshes.filter(mesh => mesh.isPickable && mesh.isEnabled()).map(mesh => mesh.position)
|
|
77
|
+
if (!positions.length) return
|
|
78
|
+
const min = new B.Vector3(Infinity, Infinity, Infinity), max = new B.Vector3(-Infinity, -Infinity, -Infinity)
|
|
79
|
+
for (const point of positions) { min.minimizeInPlace(point); max.maximizeInPlace(point) }
|
|
80
|
+
camera.setTarget(min.add(max).scale(.5), false, false, true)
|
|
81
|
+
const aspect = Math.max(.1, engine.getRenderWidth() / Math.max(1, engine.getRenderHeight()))
|
|
82
|
+
const fov = Math.min(camera.fov, 2 * Math.atan(Math.tan(camera.fov / 2) * aspect))
|
|
83
|
+
camera.upperRadiusLimit = 400
|
|
84
|
+
camera.radius = Math.max(camera.lowerRadiusLimit, max.subtract(min).length() / (2 * Math.sin(fov / 2)) + 2)
|
|
85
|
+
stopInertia()
|
|
86
|
+
}
|
|
87
|
+
function loop() {
|
|
88
|
+
frame = null
|
|
89
|
+
if (disposed || lost || document.hidden) return
|
|
90
|
+
navigator.frame()
|
|
91
|
+
frame = window.requestAnimationFrame(loop)
|
|
92
|
+
}
|
|
93
|
+
function active() { return !disposed && !lost && !document.hidden }
|
|
94
|
+
try {
|
|
95
|
+
engine = createEngine(canvas)
|
|
96
|
+
const baseFactory = createBabylonPortFactory(B, engine, { onSceneCreated(created) {
|
|
97
|
+
camera = created.camera
|
|
98
|
+
cameras.add(camera)
|
|
99
|
+
camera.lowerBetaLimit = .15
|
|
100
|
+
camera.upperBetaLimit = Math.PI / 2 - .05
|
|
101
|
+
camera.panningDistanceLimit = 150
|
|
102
|
+
camera.attachControl(canvas, true)
|
|
103
|
+
applyMotion()
|
|
104
|
+
} })
|
|
105
|
+
navigator = createGraphNavigator({ onIntent, createPort(profile, bind) {
|
|
106
|
+
const port = baseFactory(profile, bind)
|
|
107
|
+
const ownCamera = camera
|
|
108
|
+
// Preserve the retained controller and geometry; apply CR-4's measured
|
|
109
|
+
// orbit-preserving focus behavior at this presentation port boundary.
|
|
110
|
+
return { ...port, focus(position) {
|
|
111
|
+
ownCamera.setTarget(B.Vector3.FromArray(position), false, false, true)
|
|
112
|
+
for (const name of ['inertialAlphaOffset', 'inertialBetaOffset', 'inertialRadiusOffset', 'inertialPanningX', 'inertialPanningY']) ownCamera[name] = 0
|
|
113
|
+
}, dispose() {
|
|
114
|
+
ownCamera.detachControl()
|
|
115
|
+
cameras.delete(ownCamera)
|
|
116
|
+
if (camera === ownCamera) camera = null
|
|
117
|
+
port.dispose()
|
|
118
|
+
} }
|
|
119
|
+
} })
|
|
120
|
+
const mounted = navigator.mount(view)
|
|
121
|
+
engine.resize()
|
|
122
|
+
present(mounted)
|
|
123
|
+
listen(canvas, 'pointerdown', event => {
|
|
124
|
+
if (event.button === 0 && active()) down = { id: event.pointerId, x: event.clientX, y: event.clientY, dragged: false }
|
|
125
|
+
})
|
|
126
|
+
listen(canvas, 'pointermove', event => {
|
|
127
|
+
if (down?.id === event.pointerId && Math.hypot(event.clientX - down.x, event.clientY - down.y) > 6) down.dragged = true
|
|
128
|
+
})
|
|
129
|
+
listen(canvas, 'pointerup', event => {
|
|
130
|
+
const start = down
|
|
131
|
+
down = null
|
|
132
|
+
if (!active() || !start || start.dragged || start.id !== event.pointerId || Math.hypot(event.clientX - start.x, event.clientY - start.y) > 6) return
|
|
133
|
+
const rect = canvas.getBoundingClientRect()
|
|
134
|
+
if (!rect.width || !rect.height) return
|
|
135
|
+
const scale = engine.getHardwareScalingLevel()
|
|
136
|
+
const x = (event.clientX - rect.left) * engine.getRenderWidth() * scale / rect.width
|
|
137
|
+
const y = (event.clientY - rect.top) * engine.getRenderHeight() * scale / rect.height
|
|
138
|
+
navigator.pickAt(x, y)
|
|
139
|
+
})
|
|
140
|
+
for (const event of ['pointercancel', 'pointerleave']) listen(canvas, event, () => { down = null })
|
|
141
|
+
listen(window, 'resize', () => { if (active()) engine.resize() })
|
|
142
|
+
listen(document, 'visibilitychange', () => {
|
|
143
|
+
if (disposed || lost) return
|
|
144
|
+
if (document.hidden) { navigator.pause(); if (frame !== null) window.cancelAnimationFrame(frame); frame = null }
|
|
145
|
+
else { engine.resize(); navigator.resume(); if (frame === null) frame = window.requestAnimationFrame(loop) }
|
|
146
|
+
})
|
|
147
|
+
listen(canvas, 'webglcontextlost', event => {
|
|
148
|
+
event.preventDefault()
|
|
149
|
+
lost = true
|
|
150
|
+
navigator.pause()
|
|
151
|
+
if (frame !== null) window.cancelAnimationFrame(frame)
|
|
152
|
+
frame = null
|
|
153
|
+
available({ status: 'unavailable', reason: 'graphics-context-lost' })
|
|
154
|
+
})
|
|
155
|
+
if (motion) listen(motion, 'change', applyMotion)
|
|
156
|
+
if (document.hidden) navigator.pause()
|
|
157
|
+
else frame = window.requestAnimationFrame(loop)
|
|
158
|
+
available({ status: mounted.status, reducedMotion: Boolean(motion?.matches) })
|
|
159
|
+
return Object.freeze({
|
|
160
|
+
get status() { return disposed || lost ? 'unavailable' : navigator.snapshot().sceneNodes ? 'ready' : 'empty' },
|
|
161
|
+
select(ref, options) { return active() ? navigator.select(ref, options) : { status: 'unavailable' } },
|
|
162
|
+
selectEdge(edgeId, options) { return active() ? navigator.selectEdge(edgeId, options) : { status: 'unavailable' } },
|
|
163
|
+
receiveSelection(selection) { return active() ? navigator.receiveSelection(selection) : { status: 'unavailable' } },
|
|
164
|
+
focus(ref) { return active() ? navigator.focus(ref) : { status: 'unavailable' } },
|
|
165
|
+
expand(ref, direction) { return active() ? navigator.expand(ref, direction) : { status: 'unavailable' } },
|
|
166
|
+
openEvidence(evidenceId) { return active() ? navigator.openEvidence(evidenceId) : { status: 'unavailable' } },
|
|
167
|
+
setQuestion(questionId) { return active() ? navigator.setQuestion(questionId) : { status: 'unavailable' } },
|
|
168
|
+
setLens(lens) { if (!active()) return { status: 'unavailable' }; return present(navigator.setLens(lens)) },
|
|
169
|
+
update(next) { if (!active()) return { status: 'unavailable' }; return present(navigator.update(next)) },
|
|
170
|
+
fit() { if (active()) fit() },
|
|
171
|
+
reset() {
|
|
172
|
+
if (!active()) return
|
|
173
|
+
const result = navigator.setLens('all')
|
|
174
|
+
if (camera) { camera.alpha = Math.PI / 2; camera.beta = .7 }
|
|
175
|
+
present(result)
|
|
176
|
+
},
|
|
177
|
+
zoom(factor) { if (active() && camera && Number.isFinite(factor) && factor > 0) { camera.radius = Math.max(camera.lowerRadiusLimit, Math.min(camera.upperRadiusLimit, camera.radius * factor)); stopInertia() } },
|
|
178
|
+
pan(dx, dz) { if (active() && camera && Number.isFinite(dx) && Number.isFinite(dz)) { const target = camera.target; camera.setTarget(new B.Vector3(Math.max(-150, Math.min(150, target.x + dx)), target.y, Math.max(-150, Math.min(150, target.z + dz))), false, false, true); stopInertia() } },
|
|
179
|
+
snapshot() { return navigator?.snapshot() ?? null },
|
|
180
|
+
dispose,
|
|
181
|
+
clear: dispose,
|
|
182
|
+
})
|
|
183
|
+
} catch (error) {
|
|
184
|
+
dispose()
|
|
185
|
+
available({ status: 'unavailable', reason: 'graphics-unavailable' })
|
|
186
|
+
return unavailableHost('graphics-unavailable', dispose)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import { parseGraphView, refuse } from './contracts.mjs'
|
|
3
|
+
|
|
4
|
+
// A consuming CLI binds its authenticated reader through this port. The
|
|
5
|
+
// standalone validator reads fixture bytes only; a file is never an access grant.
|
|
6
|
+
export async function runGraphViewCommand(argv, { reader, stdout = text => process.stdout.write(text), stderr = text => process.stderr.write(text) } = {}) {
|
|
7
|
+
try {
|
|
8
|
+
const flags = new Map()
|
|
9
|
+
for (let index = 0; index < argv.length; index += 2) {
|
|
10
|
+
const key = argv[index], value = argv[index + 1]
|
|
11
|
+
if (!['--validate', '--request', '--out'].includes(key) || !value || value.startsWith('--') || flags.has(key)) refuse('invalid_arguments')
|
|
12
|
+
flags.set(key, value)
|
|
13
|
+
}
|
|
14
|
+
let bytes
|
|
15
|
+
if (flags.has('--validate')) {
|
|
16
|
+
if (flags.has('--request') || flags.has('--out') || reader) refuse('invalid_arguments')
|
|
17
|
+
parseGraphView(await fs.readFile(flags.get('--validate'), 'utf8'))
|
|
18
|
+
stdout('graph-view fixture valid\n')
|
|
19
|
+
return 0
|
|
20
|
+
} else {
|
|
21
|
+
if (!reader || typeof reader.exportGraphView !== 'function') refuse('host_binding_required')
|
|
22
|
+
const request = flags.has('--request') ? JSON.parse(await fs.readFile(flags.get('--request'), 'utf8')) : {}
|
|
23
|
+
bytes = await reader.exportGraphView(request)
|
|
24
|
+
}
|
|
25
|
+
if (flags.has('--out')) await fs.writeFile(flags.get('--out'), bytes, { flag: 'wx', mode: 0o600 })
|
|
26
|
+
else stdout(bytes)
|
|
27
|
+
return 0
|
|
28
|
+
} catch (error) {
|
|
29
|
+
const known = ['invalid_arguments', 'host_binding_required', 'policy_refusal', 'stale_source', 'stale_cursor', 'unavailable', 'budget_exceeded', 'contract_mismatch', 'digest_mismatch']
|
|
30
|
+
const code = known.includes(error?.code) ? error.code : 'operation_failed'
|
|
31
|
+
stderr(`graph-view: ${code}\n`)
|
|
32
|
+
return 2
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import Ajv2020 from 'ajv/dist/2020.js'
|
|
3
|
+
import addFormats from 'ajv-formats'
|
|
4
|
+
import { canonicalize } from '../attestation/jcs.mjs'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { refKey } from './refs.mjs'
|
|
7
|
+
import { refuse } from './errors.mjs'
|
|
8
|
+
export { refuse } from './errors.mjs'
|
|
9
|
+
export { refKey } from './refs.mjs'
|
|
10
|
+
|
|
11
|
+
export const GRAPH_VIEW_SCHEMA = 'atelier.graph-view@v1'
|
|
12
|
+
export const MAX_VIEW_BYTES = 2 * 1024 * 1024
|
|
13
|
+
const schema = JSON.parse(fs.readFileSync(new URL('../../contracts/atelier-graph-view.v1.schema.json', import.meta.url), 'utf8'))
|
|
14
|
+
const ajv = new Ajv2020({ allErrors: false, strict: true })
|
|
15
|
+
addFormats(ajv)
|
|
16
|
+
ajv.addSchema(schema)
|
|
17
|
+
const validators = new Map()
|
|
18
|
+
export const canonicalJson = canonicalize
|
|
19
|
+
export const digestJson = value => `sha256:${createHash('sha256').update(canonicalize(value)).digest('hex')}`
|
|
20
|
+
export const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0
|
|
21
|
+
export const sortedUnique = values => [...new Set(values)].sort(compareText)
|
|
22
|
+
|
|
23
|
+
export function assertShape(name, value) {
|
|
24
|
+
const key = name ? `${schema.$id}#/$defs/${name}` : schema.$id
|
|
25
|
+
if (!validators.has(key)) validators.set(key, ajv.getSchema(key))
|
|
26
|
+
if (!validators.get(key)?.(value)) refuse('contract_mismatch')
|
|
27
|
+
return value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Explicit schema properties are the only data boundary. No source body,
|
|
31
|
+
// relation map, host binding, error detail, or arbitrary ext object passes.
|
|
32
|
+
export function projectShape(name, value) {
|
|
33
|
+
const visit = (shape, input) => {
|
|
34
|
+
if (shape.$ref) return visit(schema.$defs[shape.$ref.split('/').at(-1)], input)
|
|
35
|
+
if (shape.type === 'object' && input && typeof input === 'object' && !Array.isArray(input)) {
|
|
36
|
+
const result = {}
|
|
37
|
+
for (const [key, child] of Object.entries(shape.properties ?? {})) {
|
|
38
|
+
if (Object.hasOwn(input, key)) result[key] = visit(child, input[key])
|
|
39
|
+
}
|
|
40
|
+
return result
|
|
41
|
+
}
|
|
42
|
+
if (shape.type === 'array' && Array.isArray(input)) return input.map(item => visit(shape.items, item))
|
|
43
|
+
return input
|
|
44
|
+
}
|
|
45
|
+
return assertShape(name, visit(schema.$defs[name], value))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function assertRelativePath(value) {
|
|
49
|
+
if (typeof value !== 'string' || !value || value.includes('\\') || value.startsWith('/') || /^[A-Za-z]:/.test(value) || value.split('/').some(part => !part || part === '.' || part === '..') || /[\u0000-\u001f\u007f]/.test(value)) refuse('contract_mismatch')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function serializeGraphView(view) {
|
|
53
|
+
assertShape(null, view)
|
|
54
|
+
const nodeIds = new Set(view.nodes.map(node => refKey(node.ref)))
|
|
55
|
+
const edgeIds = new Set(view.edges.map(edge => edge.edgeId))
|
|
56
|
+
const evidenceIds = new Set(view.evidence.map(item => item.evidenceId))
|
|
57
|
+
if (nodeIds.size !== view.nodes.length || edgeIds.size !== view.edges.length || evidenceIds.size !== view.evidence.length) refuse('contract_mismatch')
|
|
58
|
+
for (const edge of view.edges) {
|
|
59
|
+
if (!nodeIds.has(refKey(edge.source)) || !nodeIds.has(refKey(edge.target)) || edge.supportRefs.length === 0 || !edge.supportRefs.includes(edge.sourceRef) || edge.supportRefs.some(id => !evidenceIds.has(id))) refuse('contract_mismatch')
|
|
60
|
+
}
|
|
61
|
+
for (const item of [...view.questions, ...view.diagnostics]) {
|
|
62
|
+
if (item.nodeRefs.some(ref => !nodeIds.has(refKey(ref))) || [...item.evidenceRefs, ...(item.contradictingRefs ?? [])].some(id => !evidenceIds.has(id))) refuse('contract_mismatch')
|
|
63
|
+
}
|
|
64
|
+
for (const table of ['nodes', 'edges', 'evidence', 'questions', 'diagnostics']) if (view.page.returned[table] !== view[table].length) refuse('contract_mismatch')
|
|
65
|
+
if (view.page.truncated !== Object.values(view.page.omitted).some(value => value > 0)) refuse('contract_mismatch')
|
|
66
|
+
const { projectionDigest, ...payload } = view
|
|
67
|
+
if (digestJson(payload) !== projectionDigest) refuse('digest_mismatch')
|
|
68
|
+
const bytes = `${canonicalize(view)}\n`
|
|
69
|
+
if (Buffer.byteLength(bytes) > MAX_VIEW_BYTES) refuse('budget_exceeded')
|
|
70
|
+
return bytes
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseGraphView(bytes) {
|
|
74
|
+
if (typeof bytes !== 'string' || Buffer.byteLength(bytes) > MAX_VIEW_BYTES) refuse('budget_exceeded')
|
|
75
|
+
let view
|
|
76
|
+
try { view = JSON.parse(bytes) } catch { refuse('contract_mismatch') }
|
|
77
|
+
serializeGraphView(view)
|
|
78
|
+
return view
|
|
79
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { deepFreeze } from '../composition/wire.mjs'
|
|
2
|
+
import { refKey } from './refs.mjs'
|
|
3
|
+
import { refuse } from './errors.mjs'
|
|
4
|
+
|
|
5
|
+
// Validation emits no action. A host must separately revalidate current grants
|
|
6
|
+
// and source before accepting any stale-sensitive operation.
|
|
7
|
+
export function assertGraphViewIntent(view, intent) {
|
|
8
|
+
if (!intent || intent.projectionDigest !== view.projectionDigest) refuse('stale_source')
|
|
9
|
+
const specifications = {
|
|
10
|
+
select: ['type', 'projectionDigest', ...(Object.hasOwn(intent, 'ref') ? ['ref'] : ['edgeId'])],
|
|
11
|
+
expand: ['type', 'projectionDigest', 'ref', 'direction'],
|
|
12
|
+
openEvidence: ['type', 'projectionDigest', 'evidenceId'],
|
|
13
|
+
setQuestion: ['type', 'projectionDigest', 'questionId'],
|
|
14
|
+
compareSnapshots: ['type', 'projectionDigest', 'a', 'b'],
|
|
15
|
+
proposeModelChange: ['type', 'projectionDigest', 'draftRef'],
|
|
16
|
+
}
|
|
17
|
+
const allowed = Object.hasOwn(specifications, intent.type) ? specifications[intent.type] : null
|
|
18
|
+
if (!allowed || Object.keys(intent).sort().join(',') !== allowed.sort().join(',')) refuse('contract_mismatch')
|
|
19
|
+
if (intent.ref) {
|
|
20
|
+
if (Object.keys(intent.ref).sort().join(',') !== 'localId,repoId' || !view.nodes.some(node => refKey(node.ref) === refKey(intent.ref))) refuse('unavailable')
|
|
21
|
+
}
|
|
22
|
+
if (intent.edgeId && !view.edges.some(edge => edge.edgeId === intent.edgeId)) refuse('unavailable')
|
|
23
|
+
if (intent.type === 'select' && !intent.ref && !intent.edgeId) refuse('contract_mismatch')
|
|
24
|
+
if (intent.type === 'expand' && (!intent.ref || !['in', 'out', 'both'].includes(intent.direction) || !view.capabilities.queries.includes('neighborhood'))) refuse('contract_mismatch')
|
|
25
|
+
if (intent.type === 'openEvidence' && !view.evidence.some(item => item.evidenceId === intent.evidenceId)) refuse('unavailable')
|
|
26
|
+
if (intent.type === 'setQuestion' && !view.questions.some(item => item.questionId === intent.questionId)) refuse('unavailable')
|
|
27
|
+
if (intent.type === 'compareSnapshots' && ![intent.a, intent.b].every(value => typeof value === 'string' && /^sha256:[0-9a-f]{64}$/.test(value))) refuse('contract_mismatch')
|
|
28
|
+
if (intent.type === 'proposeModelChange' && (!view.capabilities.proposalIntents.includes('proposeModelChange') || typeof intent.draftRef !== 'string' || !intent.draftRef || intent.draftRef.length > 512)) refuse('policy_refusal')
|
|
29
|
+
return deepFreeze(JSON.parse(JSON.stringify(intent)))
|
|
30
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { VALID_RELATIONS } from '../graph/graph.mjs'
|
|
2
|
+
import { GRAPH_VIEW_SCHEMA, assertShape, assertRelativePath, projectShape, digestJson, canonicalJson, refKey, compareText, sortedUnique, refuse, serializeGraphView } from './contracts.mjs'
|
|
3
|
+
|
|
4
|
+
const TABLES = ['nodes', 'edges', 'evidence', 'questions', 'diagnostics']
|
|
5
|
+
const keys = { nodes: item => refKey(item.ref), edges: item => item.edgeId, evidence: item => item.evidenceId, questions: item => item.questionId, diagnostics: item => item.diagnosticId }
|
|
6
|
+
const shapes = { nodes: 'node', edges: 'edge', evidence: 'evidence', questions: 'question', diagnostics: 'diagnostic' }
|
|
7
|
+
const sourceKey = pin => canonicalJson([pin.repoId, pin.path, pin.revision, pin.digest])
|
|
8
|
+
const sortRefs = refs => refs.sort((a, b) => compareText(refKey(a), refKey(b)))
|
|
9
|
+
const count = tables => Object.fromEntries(TABLES.map(key => [key, tables[key].length]))
|
|
10
|
+
|
|
11
|
+
function indexUnique(items, key) {
|
|
12
|
+
const result = new Map()
|
|
13
|
+
for (const item of items) {
|
|
14
|
+
const id = key(item)
|
|
15
|
+
if (result.has(id)) refuse('contract_mismatch')
|
|
16
|
+
result.set(id, item)
|
|
17
|
+
}
|
|
18
|
+
return result
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalize(raw) {
|
|
22
|
+
if (!raw || typeof raw.producerVersion !== 'string' || !raw.producerVersion || raw.producerVersion.length > 128) refuse('contract_mismatch')
|
|
23
|
+
if (!['complete', 'incomplete'].includes(raw.completeness)) refuse('contract_mismatch')
|
|
24
|
+
const graph = { producerVersion: raw.producerVersion, completeness: raw.completeness }
|
|
25
|
+
graph.sourceSnapshot = projectShape('sourceSnapshot', {
|
|
26
|
+
...raw.sourceSnapshot,
|
|
27
|
+
repositories: raw.sourceSnapshot?.repositories?.map(repo => ({ ...repo, omittedFiles: 0 })),
|
|
28
|
+
})
|
|
29
|
+
const expiry = raw.accessContext?.expiresAt
|
|
30
|
+
graph.accessContext = projectShape('accessContext', { ...raw.accessContext, expiresAt: typeof expiry === 'number' && Number.isFinite(expiry) ? new Date(expiry).toISOString() : expiry })
|
|
31
|
+
graph.capabilities = projectShape('capabilities', { maxSlice: 500, maxDepth: 2, sourceReads: false, proposalIntents: [], queries: ['metadata-search', 'neighborhood', 'question', 'source-impact'], ...raw.capabilities })
|
|
32
|
+
graph.accessContext.authorizedRepoIds = sortedUnique(graph.accessContext.authorizedRepoIds)
|
|
33
|
+
graph.capabilities.queries = sortedUnique(graph.capabilities.queries)
|
|
34
|
+
graph.capabilities.proposalIntents = sortedUnique(graph.capabilities.proposalIntents)
|
|
35
|
+
const repositories = indexUnique(graph.sourceSnapshot.repositories, repo => repo.repoId)
|
|
36
|
+
if ([...repositories.keys()].some(id => !graph.accessContext.authorizedRepoIds.includes(id))) refuse('contract_mismatch')
|
|
37
|
+
for (const repo of repositories.values()) {
|
|
38
|
+
indexUnique(repo.files, file => file.path)
|
|
39
|
+
for (const file of repo.files) assertRelativePath(file.path)
|
|
40
|
+
repo.files.sort((a, b) => compareText(a.path, b.path))
|
|
41
|
+
}
|
|
42
|
+
graph.sourceSnapshot.repositories.sort((a, b) => compareText(a.repoId, b.repoId))
|
|
43
|
+
for (const table of TABLES) {
|
|
44
|
+
if (!Array.isArray(raw[table]) || raw[table].length > 20000) refuse('budget_exceeded')
|
|
45
|
+
graph[table] = raw[table].map(item => projectShape(shapes[table], table === 'diagnostics' && item.code === 'authorized-source-unavailable' ? { ...item, diagnosticId: digestJson({ code: item.code, repoId: item.repoId }) } : item))
|
|
46
|
+
indexUnique(graph[table], keys[table])
|
|
47
|
+
graph[table].sort((a, b) => compareText(keys[table](a), keys[table](b)))
|
|
48
|
+
}
|
|
49
|
+
const missingRepositories = graph.accessContext.authorizedRepoIds.filter(id => !repositories.has(id))
|
|
50
|
+
const unavailable = graph.diagnostics.filter(item => item.code === 'authorized-source-unavailable').map(item => item.repoId).sort(compareText)
|
|
51
|
+
if (canonicalJson(missingRepositories) !== canonicalJson(unavailable) || (missingRepositories.length > 0 && graph.completeness !== 'incomplete') || graph.diagnostics.some(item => item.repoId && !graph.accessContext.authorizedRepoIds.includes(item.repoId))) refuse('contract_mismatch')
|
|
52
|
+
for (const node of graph.nodes) node.tags = sortedUnique(node.tags)
|
|
53
|
+
if (raw.edges.some(edge => edge.eligible === false)) refuse('contract_mismatch')
|
|
54
|
+
for (const edge of graph.edges) {
|
|
55
|
+
if (edge.predicate !== 'links_to' && !VALID_RELATIONS.has(edge.predicate)) refuse('contract_mismatch')
|
|
56
|
+
edge.supportRefs = sortedUnique(edge.supportRefs)
|
|
57
|
+
edge.reviewRefs = sortedUnique(edge.reviewRefs)
|
|
58
|
+
}
|
|
59
|
+
for (const item of [...graph.questions, ...graph.diagnostics]) {
|
|
60
|
+
sortRefs(item.nodeRefs)
|
|
61
|
+
item.evidenceRefs = sortedUnique(item.evidenceRefs)
|
|
62
|
+
if (item.contradictingRefs) item.contradictingRefs = sortedUnique(item.contradictingRefs)
|
|
63
|
+
if (item.gaps) item.gaps = sortedUnique(item.gaps)
|
|
64
|
+
}
|
|
65
|
+
const nodes = new Map(graph.nodes.map(node => [refKey(node.ref), node]))
|
|
66
|
+
const evidence = new Map(graph.evidence.map(item => [item.evidenceId, item]))
|
|
67
|
+
const checkSource = (source, verified = true) => {
|
|
68
|
+
assertRelativePath(source.path)
|
|
69
|
+
if (source.locator?.rangeDigest && !source.locator.range) refuse('contract_mismatch')
|
|
70
|
+
for (const range of [source.locator?.range, source.locator?.targetRange].filter(Boolean)) {
|
|
71
|
+
if (range.end < range.start || (range.byteStart === undefined) !== (range.byteEnd === undefined) || (range.byteStart !== undefined && range.byteEnd < range.byteStart)) refuse('contract_mismatch')
|
|
72
|
+
}
|
|
73
|
+
const repo = repositories.get(source.repoId)
|
|
74
|
+
if (!repo) refuse('contract_mismatch')
|
|
75
|
+
if (verified && (source.revision !== repo.digest || !repo.files.some(file => file.path === source.path && file.rawDigest === source.digest))) refuse('contract_mismatch')
|
|
76
|
+
}
|
|
77
|
+
for (const node of graph.nodes) {
|
|
78
|
+
if (node.ref.repoId !== node.sourceRef.repoId) refuse('contract_mismatch')
|
|
79
|
+
checkSource(node.sourceRef)
|
|
80
|
+
}
|
|
81
|
+
for (const item of graph.evidence) checkSource(item.sourceRef, item.verification === 'verified-source')
|
|
82
|
+
for (const repo of repositories.values()) for (const field of ['config', 'plan']) if (repo.inputs[field]) {
|
|
83
|
+
if (repo.inputs[field].repoId !== repo.repoId) refuse('contract_mismatch')
|
|
84
|
+
checkSource(repo.inputs[field])
|
|
85
|
+
}
|
|
86
|
+
for (const edge of graph.edges) {
|
|
87
|
+
if (!nodes.has(refKey(edge.source)) || !nodes.has(refKey(edge.target)) || !evidence.has(edge.sourceRef) || !edge.supportRefs.includes(edge.sourceRef) || edge.supportRefs.length === 0 || edge.supportRefs.some(id => !evidence.has(id))) refuse('contract_mismatch')
|
|
88
|
+
}
|
|
89
|
+
for (const item of [...graph.questions, ...graph.diagnostics]) {
|
|
90
|
+
if (item.nodeRefs.some(ref => !nodes.has(refKey(ref))) || [...item.evidenceRefs, ...(item.contradictingRefs ?? [])].some(id => !evidence.has(id))) refuse('contract_mismatch')
|
|
91
|
+
}
|
|
92
|
+
return graph
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizedRequest(request, graph) {
|
|
96
|
+
assertShape('request', request)
|
|
97
|
+
if (request.limit && request.limit > graph.capabilities.maxSlice) refuse('budget_exceeded')
|
|
98
|
+
if ((request.depth ?? 0) > graph.capabilities.maxDepth) refuse('budget_exceeded')
|
|
99
|
+
if (request.search && !graph.capabilities.queries.includes('metadata-search') || request.questionId && !graph.capabilities.queries.includes('question') || request.focusRef && !graph.capabilities.queries.includes('neighborhood')) refuse('policy_refusal')
|
|
100
|
+
const query = {
|
|
101
|
+
questionId: request.questionId ?? null,
|
|
102
|
+
focusRef: request.focusRef ?? null,
|
|
103
|
+
relationFilter: sortedUnique(request.relationFilter ?? []),
|
|
104
|
+
direction: request.direction ?? 'both',
|
|
105
|
+
depth: request.depth ?? 0,
|
|
106
|
+
limit: request.limit ?? Math.min(100, graph.capabilities.maxSlice),
|
|
107
|
+
search: (request.search ?? '').normalize('NFC').trim().toLowerCase(),
|
|
108
|
+
}
|
|
109
|
+
if (query.depth > 0 && !query.focusRef) refuse('contract_mismatch')
|
|
110
|
+
return query
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function slice(graph, query, request, snapshot) {
|
|
114
|
+
let candidates = graph.nodes
|
|
115
|
+
let question = null
|
|
116
|
+
if (query.questionId) {
|
|
117
|
+
question = graph.questions.find(item => item.questionId === query.questionId)
|
|
118
|
+
if (!question) refuse('unavailable')
|
|
119
|
+
const allowed = new Set(question.nodeRefs.map(refKey))
|
|
120
|
+
candidates = candidates.filter(node => allowed.has(refKey(node.ref)))
|
|
121
|
+
}
|
|
122
|
+
if (query.focusRef) {
|
|
123
|
+
const focus = refKey(query.focusRef)
|
|
124
|
+
if (!graph.nodes.some(node => refKey(node.ref) === focus)) refuse('unavailable')
|
|
125
|
+
const visited = new Set([focus])
|
|
126
|
+
let frontier = new Set([focus])
|
|
127
|
+
for (let depth = 0; depth < query.depth; depth += 1) {
|
|
128
|
+
const next = new Set()
|
|
129
|
+
for (const edge of graph.edges) {
|
|
130
|
+
if (query.relationFilter.length && !query.relationFilter.includes(edge.predicate)) continue
|
|
131
|
+
const from = refKey(edge.source), to = refKey(edge.target)
|
|
132
|
+
if (query.direction !== 'in' && frontier.has(from)) next.add(to)
|
|
133
|
+
if (query.direction !== 'out' && frontier.has(to)) next.add(from)
|
|
134
|
+
}
|
|
135
|
+
frontier = new Set([...next].filter(key => !visited.has(key)))
|
|
136
|
+
for (const key of frontier) visited.add(key)
|
|
137
|
+
}
|
|
138
|
+
candidates = candidates.filter(node => visited.has(refKey(node.ref)))
|
|
139
|
+
}
|
|
140
|
+
if (query.search) candidates = candidates.filter(node => [node.title, node.summary, ...node.tags].some(value => value.toLowerCase().includes(query.search)))
|
|
141
|
+
const queryDigest = digestJson({ snapshot, query })
|
|
142
|
+
let offset = 0
|
|
143
|
+
if (request.cursor) {
|
|
144
|
+
let cursor
|
|
145
|
+
try { cursor = JSON.parse(Buffer.from(request.cursor, 'base64url').toString('utf8')) } catch { refuse('stale_cursor') }
|
|
146
|
+
if (!cursor || Object.keys(cursor).sort().join(',') !== 'offset,queryDigest' || cursor.queryDigest !== queryDigest || !Number.isSafeInteger(cursor.offset) || cursor.offset < 0 || cursor.offset >= candidates.length || cursor.offset % query.limit !== 0) refuse('stale_cursor')
|
|
147
|
+
offset = cursor.offset
|
|
148
|
+
}
|
|
149
|
+
const nodes = candidates.slice(offset, offset + query.limit)
|
|
150
|
+
const candidateRefs = new Set(candidates.map(node => refKey(node.ref)))
|
|
151
|
+
const visibleRefs = new Set(nodes.map(node => refKey(node.ref)))
|
|
152
|
+
const matchingEdges = graph.edges.filter(edge => candidateRefs.has(refKey(edge.source)) && candidateRefs.has(refKey(edge.target)) && (!query.relationFilter.length || query.relationFilter.includes(edge.predicate)))
|
|
153
|
+
const edges = matchingEdges.filter(edge => visibleRefs.has(refKey(edge.source)) && visibleRefs.has(refKey(edge.target))).slice(0, Math.min(2000, query.limit * 4))
|
|
154
|
+
const needed = new Set(edges.flatMap(edge => [edge.sourceRef, ...edge.supportRefs]))
|
|
155
|
+
const nodeSources = new Set(nodes.map(node => sourceKey(node.sourceRef)))
|
|
156
|
+
for (const item of graph.evidence) if (nodeSources.has(sourceKey(item.sourceRef))) needed.add(item.evidenceId)
|
|
157
|
+
const selectedQuestions = (question ? [question] : graph.questions.filter(item => (!query.focusRef && !query.search) || item.nodeRefs.some(ref => visibleRefs.has(refKey(ref))))).slice(0, 100)
|
|
158
|
+
for (const item of selectedQuestions) for (const id of [...item.evidenceRefs, ...item.contradictingRefs]) needed.add(id)
|
|
159
|
+
if (needed.size > 4000) refuse('budget_exceeded')
|
|
160
|
+
const evidence = graph.evidence.filter(item => needed.has(item.evidenceId))
|
|
161
|
+
const evidenceIds = new Set(evidence.map(item => item.evidenceId))
|
|
162
|
+
const questions = selectedQuestions.map(item => ({ ...item, nodeRefs: item.nodeRefs.filter(ref => visibleRefs.has(refKey(ref))), omissionState: item.nodeRefs.some(ref => !visibleRefs.has(refKey(ref))) ? 'bounded' : item.omissionState }))
|
|
163
|
+
const diagnostics = graph.diagnostics.filter(item => item.nodeRefs.every(ref => visibleRefs.has(refKey(ref))) && item.evidenceRefs.every(id => evidenceIds.has(id))).slice(0, query.limit)
|
|
164
|
+
const selected = { nodes, edges, evidence, questions, diagnostics }
|
|
165
|
+
const total = { nodes: candidates, edges: matchingEdges, evidence: graph.evidence, questions: question ? [question] : graph.questions, diagnostics: graph.diagnostics }
|
|
166
|
+
const returned = count(selected), totals = count(total)
|
|
167
|
+
const omitted = Object.fromEntries(TABLES.map(key => [key, totals[key] - returned[key]]))
|
|
168
|
+
const nextCursor = offset + nodes.length < candidates.length ? Buffer.from(canonicalJson({ offset: offset + nodes.length, queryDigest })).toString('base64url') : null
|
|
169
|
+
const sourcePins = [...nodes.map(node => node.sourceRef), ...evidence.map(item => item.sourceRef), ...graph.sourceSnapshot.repositories.flatMap(repo => [repo.inputs.config, repo.inputs.plan]).filter(Boolean)]
|
|
170
|
+
const sourceSnapshot = { ...graph.sourceSnapshot, repositories: graph.sourceSnapshot.repositories.map(repo => {
|
|
171
|
+
const selectedPaths = new Set(sourcePins.filter(pin => pin.repoId === repo.repoId).map(pin => pin.path))
|
|
172
|
+
const files = repo.files.filter(file => selectedPaths.has(file.path))
|
|
173
|
+
return { ...repo, files, omittedFiles: repo.files.length - files.length }
|
|
174
|
+
}) }
|
|
175
|
+
const payload = { schema: GRAPH_VIEW_SCHEMA, producerVersion: graph.producerVersion, snapshot, completeness: graph.completeness, sourceSnapshot, accessContext: graph.accessContext, ...selected, capabilities: graph.capabilities, query, page: { offset, limit: query.limit, returned, omitted, truncated: Object.values(omitted).some(value => value > 0), nextCursor } }
|
|
176
|
+
return { ...payload, projectionDigest: digestJson(payload) }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Host-only ports close over authenticated context. No caller-supplied graph,
|
|
180
|
+
// identity, scope, or grant enters the request. Nothing is cached here.
|
|
181
|
+
export function createGraphViewReader({ loadAuthorizedGraph, revalidate, now = () => Date.now() }) {
|
|
182
|
+
if (typeof loadAuthorizedGraph !== 'function' || typeof revalidate !== 'function' || typeof now !== 'function') throw new TypeError('graph-view host ports required')
|
|
183
|
+
const run = async (request, operation) => {
|
|
184
|
+
assertShape('request', request)
|
|
185
|
+
let loaded
|
|
186
|
+
try { loaded = await loadAuthorizedGraph({ operation }) } catch { refuse('policy_refusal') }
|
|
187
|
+
if (!loaded?.binding || !loaded.graph) refuse('policy_refusal')
|
|
188
|
+
let view, bytes, pendingError
|
|
189
|
+
const expiresAt = typeof loaded.graph?.accessContext?.expiresAt === 'number' ? loaded.graph.accessContext.expiresAt : Date.parse(loaded.graph?.accessContext?.expiresAt)
|
|
190
|
+
try {
|
|
191
|
+
const graph = normalize(loaded.graph)
|
|
192
|
+
const snapshot = digestJson(graph)
|
|
193
|
+
if (request.snapshot && request.snapshot !== snapshot) refuse('stale_source')
|
|
194
|
+
const query = normalizedRequest(request, graph)
|
|
195
|
+
if (operation === 'export' && graph.capabilities.export !== true) refuse('policy_refusal')
|
|
196
|
+
view = slice(graph, query, request, snapshot)
|
|
197
|
+
bytes = serializeGraphView(view)
|
|
198
|
+
} catch (error) { pendingError = error }
|
|
199
|
+
let current = false
|
|
200
|
+
try { current = await revalidate(loaded.binding) } catch { refuse('policy_refusal') }
|
|
201
|
+
const deliveredAt = now()
|
|
202
|
+
if (current !== true || !Number.isFinite(expiresAt) || !Number.isFinite(deliveredAt) || deliveredAt >= expiresAt) refuse('policy_refusal')
|
|
203
|
+
if (pendingError) {
|
|
204
|
+
if (!['contract_mismatch', 'budget_exceeded', 'policy_refusal', 'stale_source', 'stale_cursor', 'unavailable'].includes(pendingError.code)) refuse('contract_mismatch')
|
|
205
|
+
throw pendingError
|
|
206
|
+
}
|
|
207
|
+
return { view, bytes }
|
|
208
|
+
}
|
|
209
|
+
return Object.freeze({
|
|
210
|
+
readGraphView: async (request = {}) => (await run(request, 'read')).view,
|
|
211
|
+
exportGraphView: async (request = {}) => (await run(request, 'export')).bytes,
|
|
212
|
+
})
|
|
213
|
+
}
|