@unson/brainbase-mcp 0.15.0 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/canonical-task-service.d.ts +11 -7
- package/dist/canonical-task-service.js +49 -21
- package/dist/cli.js +39 -3
- package/dist/graph-web-http.d.ts +7 -0
- package/dist/graph-web-http.js +14 -0
- package/dist/graph-web.d.ts +36 -6
- package/dist/graph-web.js +45 -4
- package/dist/judgment-value-proof-review.d.ts +76 -0
- package/dist/judgment-value-proof-review.js +294 -10
- package/dist/knowledge-continuation.js +18 -5
- package/dist/knowledge-lookup.js +7 -0
- package/dist/local-web-host.d.ts +23 -0
- package/dist/local-web-host.js +26 -7
- package/dist/organization-graph-web.d.ts +74 -0
- package/dist/organization-graph-web.js +275 -0
- package/dist/value-proof-review-http.d.ts +4 -1
- package/dist/value-proof-review-http.js +76 -12
- package/dist/world-extension.d.ts +148 -0
- package/dist/world-extension.js +277 -0
- package/package.json +5 -3
- package/ui/graph-projects-view.js +6 -3
- package/ui/graph-registry-view.js +6 -3
- package/ui/graph-view-shared.css +2 -0
- package/ui/graph-view-shared.js +9 -3
- package/ui/local-web-shell.js +82 -11
- package/ui/value-proof-review.css +24 -0
- package/ui/value-proof-review.js +241 -12
- package/ui/world/world-placement.js +45 -0
- package/ui/world/world-vendor.LICENSE.txt +23 -0
- package/ui/world/world-vendor.js +4220 -0
- package/ui/world/world-view.css +234 -0
- package/ui/world/world-view.js +1033 -0
package/dist/local-web-host.js
CHANGED
|
@@ -8,7 +8,7 @@ import { foundationCatalogOrEmpty, foundationKey } from './foundation-catalog.js
|
|
|
8
8
|
import { createFoundationHttpRouter, createObjectiveFoundationRoute, FOUNDATION_HTTP_PREFIX, FOUNDATION_OBJECTIVE_ROUTE_PREFIX } from './foundation-http.js';
|
|
9
9
|
import { createFoundationRevisionStore, FoundationStoreError } from './foundation-store.js';
|
|
10
10
|
import { createGraphWebHttpHandler } from './graph-web-http.js';
|
|
11
|
-
import { defaultJudgmentJournalRoot } from './judgment-value-proof-review.js';
|
|
11
|
+
import { defaultJudgmentJournalRoot, JudgmentValueProofJournalCache } from './judgment-value-proof-review.js';
|
|
12
12
|
import { nodeRequestToFetch, writeFetchResponse } from './local-web-fetch-bridge.js';
|
|
13
13
|
import { assertSameOrigin, isLoopbackHost, LOCAL_WEB_CONTENT_SECURITY_POLICY, LocalWebHttpError, readJsonObjectBody, requestUrl, tokenMatches, writeHttpError, writeJson } from './local-web-security.js';
|
|
14
14
|
import { resolveDataDir } from './paths.js';
|
|
@@ -138,7 +138,9 @@ export function createLocalStatusModule(context) {
|
|
|
138
138
|
writeJson(response, 405, { error: { code: 'method_not_allowed', message: 'Use GET' } });
|
|
139
139
|
return true;
|
|
140
140
|
}
|
|
141
|
-
const
|
|
141
|
+
const local = await readLocalGraphState(context.dataDir);
|
|
142
|
+
// Under C1 the Graph screens read the organization Graph, so they are not gated on the local file.
|
|
143
|
+
const graph = context.organizationGraph ? { ...local, status: 'ready', format: 'v2', message: undefined } : local;
|
|
142
144
|
writeJson(response, 200, {
|
|
143
145
|
version: LOCAL_WEB_HOST_VERSION,
|
|
144
146
|
data_dir: context.dataDir,
|
|
@@ -149,8 +151,14 @@ export function createLocalStatusModule(context) {
|
|
|
149
151
|
...(graph.message ? { message: graph.message } : {}),
|
|
150
152
|
commands: graphCommands(graph, context.dataDir)
|
|
151
153
|
},
|
|
152
|
-
|
|
153
|
-
|
|
154
|
+
organization_graph: context.organizationGraph
|
|
155
|
+
? {
|
|
156
|
+
status: 'connected',
|
|
157
|
+
server: context.organizationGraph.server,
|
|
158
|
+
mode: 'read_only',
|
|
159
|
+
...(context.organizationGraph.webUrl ? { web_url: context.organizationGraph.webUrl } : {})
|
|
160
|
+
}
|
|
161
|
+
: { status: 'not_connected' }
|
|
154
162
|
});
|
|
155
163
|
return true;
|
|
156
164
|
}
|
|
@@ -161,7 +169,8 @@ export function createValueProofModule(context) {
|
|
|
161
169
|
journalRoot: context.journalRoot,
|
|
162
170
|
dataDir: context.dataDir,
|
|
163
171
|
token: context.token,
|
|
164
|
-
now: context.now
|
|
172
|
+
now: context.now,
|
|
173
|
+
journalCache: context.journalCache
|
|
165
174
|
});
|
|
166
175
|
return {
|
|
167
176
|
id: 'value-proofs',
|
|
@@ -674,6 +683,7 @@ export function createGraphWebModule(context) {
|
|
|
674
683
|
dataDir: context.dataDir,
|
|
675
684
|
basePath: LOCAL_WEB_GRAPH_PREFIX,
|
|
676
685
|
now: context.now,
|
|
686
|
+
...(context.organizationGraph ? { readGraph: context.organizationGraph.read } : {}),
|
|
677
687
|
assertWriteAllowed(request) {
|
|
678
688
|
if (isTrustedLocalWrite(request, context.token))
|
|
679
689
|
return;
|
|
@@ -732,6 +742,8 @@ ${stylesheets.map((path) => `<link rel="stylesheet" href="${escapeAttribute(path
|
|
|
732
742
|
}
|
|
733
743
|
function bootstrapJs(extensions) {
|
|
734
744
|
const imports = extensions.map((extension, index) => `import { screen as extensionScreen${index} } from '/ui/extensions/${extension.id}/${extension.screenEntry}';`).join('\n');
|
|
745
|
+
const leading = extensions.flatMap((extension, index) => (extension.navPosition === 'first' ? [`extensionScreen${index}`] : [])).join(', ');
|
|
746
|
+
const trailing = extensions.flatMap((extension, index) => (extension.navPosition === 'first' ? [] : [`extensionScreen${index}`])).join(', ');
|
|
735
747
|
const screens = extensions.map((_, index) => `extensionScreen${index}`).join(', ');
|
|
736
748
|
const ids = JSON.stringify(extensions.map((extension) => extension.id));
|
|
737
749
|
return `import { createLocalWebShell, LOCAL_WEB_SCREENS } from '/ui/local-web-shell.js';
|
|
@@ -749,7 +761,7 @@ const shell = createLocalWebShell({
|
|
|
749
761
|
root: document.getElementById('brainbase-local-web'),
|
|
750
762
|
token: meta('brainbase-web-token'),
|
|
751
763
|
initialScreen: screenFromHash(),
|
|
752
|
-
screens: [...LOCAL_WEB_SCREENS, ...extensionScreens]
|
|
764
|
+
screens: ${leading ? `[${leading}, ...LOCAL_WEB_SCREENS${trailing ? `, ${trailing}` : ''}]` : '[...LOCAL_WEB_SCREENS, ...extensionScreens]'}
|
|
753
765
|
});
|
|
754
766
|
addEventListener('hashchange', () => shell.show(screenFromHash()));
|
|
755
767
|
`;
|
|
@@ -765,7 +777,14 @@ export function createLocalWebHost(options = {}) {
|
|
|
765
777
|
const dataDir = resolveDataDir(options.dataDir);
|
|
766
778
|
const journalRoot = options.journalRoot ?? defaultJudgmentJournalRoot(dataDir);
|
|
767
779
|
const uiDir = options.uiDir ?? fileURLToPath(new URL('../ui/', import.meta.url));
|
|
768
|
-
const context = {
|
|
780
|
+
const context = {
|
|
781
|
+
dataDir,
|
|
782
|
+
journalRoot,
|
|
783
|
+
token,
|
|
784
|
+
now: options.now ?? (() => new Date()),
|
|
785
|
+
journalCache: new JudgmentValueProofJournalCache(),
|
|
786
|
+
...(options.organizationGraph ? { organizationGraph: options.organizationGraph } : {})
|
|
787
|
+
};
|
|
769
788
|
const modules = defaultLocalWebModules(context);
|
|
770
789
|
const extensions = [...options.extensions ?? []];
|
|
771
790
|
const extensionModules = [];
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { LocalWebOrganizationGraph } from './local-web-host.js';
|
|
2
|
+
import type { CanonicalEntityKind, GraphFileV2 } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Ledger C1 (adopted 2026-09-24): while the owner validates the Web, the host
|
|
5
|
+
* reads the owner's organization Graph read only. The records are projected
|
|
6
|
+
* into an in-memory Graph v2 so the existing Graph screens read them through
|
|
7
|
+
* the same views as a local Graph. Nothing is written locally or remotely.
|
|
8
|
+
*
|
|
9
|
+
* Relations come only from what the organization Graph states:
|
|
10
|
+
* - `member_of` person → project → `participates_in` (role = role_code)
|
|
11
|
+
* - RACI `assigned_to` raci → person, with the RACI's project:
|
|
12
|
+
* accountable / A / lane decider / decision:* → `accountable_for`
|
|
13
|
+
* any other role → `participates_in`
|
|
14
|
+
* Retired or superseded records are counted, not drawn.
|
|
15
|
+
*/
|
|
16
|
+
export declare const ORGANIZATION_GRAPH_WEB_VERSION: "organization-graph-web.v0";
|
|
17
|
+
interface OrganizationRecord {
|
|
18
|
+
id: string;
|
|
19
|
+
entity_type?: string;
|
|
20
|
+
project_id?: string | null;
|
|
21
|
+
project_code?: string | null;
|
|
22
|
+
lifecycle_status?: string;
|
|
23
|
+
payload?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
interface OrganizationEdge {
|
|
26
|
+
id: string;
|
|
27
|
+
from_id: string;
|
|
28
|
+
to_id: string;
|
|
29
|
+
rel_type: string;
|
|
30
|
+
project_id?: string | null;
|
|
31
|
+
lifecycle_status?: string;
|
|
32
|
+
payload?: Record<string, unknown> | null;
|
|
33
|
+
}
|
|
34
|
+
export interface OrganizationGraphRecords {
|
|
35
|
+
readonly entities: Readonly<Record<CanonicalEntityKind | 'raci_assignment', readonly OrganizationRecord[]>>;
|
|
36
|
+
readonly memberOf: readonly OrganizationEdge[];
|
|
37
|
+
readonly assignedTo: readonly OrganizationEdge[];
|
|
38
|
+
}
|
|
39
|
+
export interface OrganizationGraphProjection {
|
|
40
|
+
readonly graph: GraphFileV2;
|
|
41
|
+
readonly excluded: {
|
|
42
|
+
readonly inactive: number;
|
|
43
|
+
readonly unnamed: number;
|
|
44
|
+
readonly danglingRelations: number;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Projects organization Graph records into a canonical Graph v2 (pure; tested). */
|
|
48
|
+
export declare function projectOrganizationGraph(records: OrganizationGraphRecords, owner?: {
|
|
49
|
+
id: string;
|
|
50
|
+
name?: string;
|
|
51
|
+
}): OrganizationGraphProjection;
|
|
52
|
+
interface OrganizationAccess {
|
|
53
|
+
readonly server: string;
|
|
54
|
+
readonly token: string;
|
|
55
|
+
}
|
|
56
|
+
/** The owner's Brainbase server and token, read at request time (tokens rotate hourly). Never logged or returned. */
|
|
57
|
+
export declare function readOrganizationAccess(home?: string): Promise<OrganizationAccess | {
|
|
58
|
+
readonly reason: string;
|
|
59
|
+
}>;
|
|
60
|
+
export interface OrganizationGraphSourceOptions {
|
|
61
|
+
readonly home?: string;
|
|
62
|
+
readonly fetch?: typeof fetch;
|
|
63
|
+
readonly now?: () => Date;
|
|
64
|
+
readonly cacheMs?: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The organization Graph for the local host (C1). Reads are cached briefly and
|
|
68
|
+
* shared while in flight; a failure is reported as `unavailable`, never as an
|
|
69
|
+
* empty Graph.
|
|
70
|
+
*/
|
|
71
|
+
export declare function createOrganizationGraphSource(options?: OrganizationGraphSourceOptions): Promise<LocalWebOrganizationGraph | {
|
|
72
|
+
readonly reason: string;
|
|
73
|
+
}>;
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { GraphWebError, openInMemoryGraph } from './graph-web.js';
|
|
5
|
+
import { canonicalEdgeId } from './canonical-graph.js';
|
|
6
|
+
import { canonicalGraphOntologyRelease } from './templates.js';
|
|
7
|
+
/**
|
|
8
|
+
* Ledger C1 (adopted 2026-09-24): while the owner validates the Web, the host
|
|
9
|
+
* reads the owner's organization Graph read only. The records are projected
|
|
10
|
+
* into an in-memory Graph v2 so the existing Graph screens read them through
|
|
11
|
+
* the same views as a local Graph. Nothing is written locally or remotely.
|
|
12
|
+
*
|
|
13
|
+
* Relations come only from what the organization Graph states:
|
|
14
|
+
* - `member_of` person → project → `participates_in` (role = role_code)
|
|
15
|
+
* - RACI `assigned_to` raci → person, with the RACI's project:
|
|
16
|
+
* accountable / A / lane decider / decision:* → `accountable_for`
|
|
17
|
+
* any other role → `participates_in`
|
|
18
|
+
* Retired or superseded records are counted, not drawn.
|
|
19
|
+
*/
|
|
20
|
+
export const ORGANIZATION_GRAPH_WEB_VERSION = 'organization-graph-web.v0';
|
|
21
|
+
const ENTITY_TYPES = ['project', 'person', 'org', 'decision'];
|
|
22
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
23
|
+
const CACHE_MS = 60_000;
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
function text(value) {
|
|
28
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
29
|
+
}
|
|
30
|
+
function nameOf(type, payload) {
|
|
31
|
+
return type === 'decision'
|
|
32
|
+
? text(payload.title) ?? text(payload.name) ?? text(payload.decision)?.slice(0, 80) ?? null
|
|
33
|
+
: text(payload.display_name) ?? text(payload.name);
|
|
34
|
+
}
|
|
35
|
+
function isAccountableRole(payload) {
|
|
36
|
+
const role = text(payload.role_code) ?? text(payload.role) ?? '';
|
|
37
|
+
const lane = text(payload.lane) ?? '';
|
|
38
|
+
return role === 'accountable' || role === 'A' || lane === 'decider' || role.startsWith('decision:');
|
|
39
|
+
}
|
|
40
|
+
/** The record that is the project of its scope: its code, or else its id, is the scope's code. */
|
|
41
|
+
function isCatalogProject(record, payload) {
|
|
42
|
+
const scope = text(record.project_code);
|
|
43
|
+
return scope !== null && (text(payload.code) ?? record.id) === scope;
|
|
44
|
+
}
|
|
45
|
+
/** A project's code, parent and repositories as the organization Graph states them. */
|
|
46
|
+
function projectPlacement(record, payload, catalogByCode) {
|
|
47
|
+
const scope = text(record.project_code);
|
|
48
|
+
const isCatalog = isCatalogProject(record, payload);
|
|
49
|
+
const code = text(payload.code) ?? (isCatalog ? scope : null);
|
|
50
|
+
const repositories = Array.isArray(payload.repository_roots)
|
|
51
|
+
? payload.repository_roots.filter(isRecord).map((root) => text(root.repository)).filter((name) => name !== null)
|
|
52
|
+
: [];
|
|
53
|
+
const parentId = scope ? catalogByCode.get(scope) : undefined;
|
|
54
|
+
return {
|
|
55
|
+
...(code ? { code } : {}),
|
|
56
|
+
...(!isCatalog && scope ? { parent_project_code: scope } : {}),
|
|
57
|
+
...(!isCatalog && parentId && parentId !== record.id ? { parent_project_id: parentId } : {}),
|
|
58
|
+
...(repositories.length ? { repositories } : {})
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Projects organization Graph records into a canonical Graph v2 (pure; tested). */
|
|
62
|
+
export function projectOrganizationGraph(records, owner) {
|
|
63
|
+
const entities = [];
|
|
64
|
+
const ids = new Map();
|
|
65
|
+
let inactive = 0;
|
|
66
|
+
let unnamed = 0;
|
|
67
|
+
// The organization's catalog project for each project code (its own code or id equals its scope);
|
|
68
|
+
// other projects in that scope hang under it (the world draws them as its districts).
|
|
69
|
+
const catalogByCode = new Map();
|
|
70
|
+
for (const record of records.entities.project ?? []) {
|
|
71
|
+
if (!isRecord(record) || typeof record.id !== 'string' || (record.lifecycle_status && record.lifecycle_status !== 'active'))
|
|
72
|
+
continue;
|
|
73
|
+
const scope = text(record.project_code);
|
|
74
|
+
if (scope && isCatalogProject(record, isRecord(record.payload) ? record.payload : {}) && !catalogByCode.has(scope))
|
|
75
|
+
catalogByCode.set(scope, record.id);
|
|
76
|
+
}
|
|
77
|
+
for (const type of ENTITY_TYPES) {
|
|
78
|
+
for (const record of records.entities[type] ?? []) {
|
|
79
|
+
if (!isRecord(record) || typeof record.id !== 'string' || ids.has(record.id))
|
|
80
|
+
continue;
|
|
81
|
+
if (record.lifecycle_status && record.lifecycle_status !== 'active') {
|
|
82
|
+
inactive += 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const payload = isRecord(record.payload) ? record.payload : {};
|
|
86
|
+
const name = nameOf(type, payload);
|
|
87
|
+
if (!name) {
|
|
88
|
+
unnamed += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const aliases = Array.isArray(payload.aliases) ? payload.aliases.filter((alias) => typeof alias === 'string' && alias.trim() !== '' && alias !== name) : [];
|
|
92
|
+
const summary = text(payload.purpose) ?? text(payload.summary) ?? text(payload.description);
|
|
93
|
+
const status = text(payload.operational_status) ?? text(payload.status);
|
|
94
|
+
entities.push({
|
|
95
|
+
id: record.id,
|
|
96
|
+
type,
|
|
97
|
+
name,
|
|
98
|
+
...(aliases.length ? { aliases: [...new Set(aliases)] } : {}),
|
|
99
|
+
...(summary ? { summary } : {}),
|
|
100
|
+
metadata: {
|
|
101
|
+
source: 'organization_graph',
|
|
102
|
+
...(record.project_code ? { project_code: record.project_code } : {}),
|
|
103
|
+
...(type === 'project' && summary ? { goal: summary } : {}),
|
|
104
|
+
...(type === 'project' && status ? { status } : {}),
|
|
105
|
+
...(type === 'project' && text(payload.kind) ? { kind: text(payload.kind) } : {}),
|
|
106
|
+
...(type === 'project' ? projectPlacement(record, payload, catalogByCode) : {})
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
ids.set(record.id, type);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// One canonical relation per person, relation and project (the edge id is derived from them);
|
|
113
|
+
// the organization's role names for that pair are kept together.
|
|
114
|
+
const byKey = new Map();
|
|
115
|
+
let danglingRelations = 0;
|
|
116
|
+
const addEdge = (sourceId, fromId, relation, toId, role) => {
|
|
117
|
+
if (ids.get(fromId) !== 'person' || ids.get(toId) !== 'project') {
|
|
118
|
+
danglingRelations += 1;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const key = `${fromId}|${relation}|${toId}`;
|
|
122
|
+
const existing = byKey.get(key);
|
|
123
|
+
if (existing) {
|
|
124
|
+
if (role && !existing.roles.includes(role))
|
|
125
|
+
existing.roles.push(role);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
byKey.set(key, {
|
|
129
|
+
edge: { id: canonicalEdgeId({ fromId, relation, toId }), fromId, relation, toId, provenance: { sourceKind: 'import', sourceId } },
|
|
130
|
+
roles: role ? [role] : []
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
for (const edge of records.memberOf) {
|
|
134
|
+
if (edge.lifecycle_status && edge.lifecycle_status !== 'active')
|
|
135
|
+
continue;
|
|
136
|
+
addEdge(edge.id, edge.from_id, 'participates_in', edge.to_id, text(edge.payload?.role_code));
|
|
137
|
+
}
|
|
138
|
+
const raci = new Map((records.entities.raci_assignment ?? []).filter((record) => !record.lifecycle_status || record.lifecycle_status === 'active').map((record) => [record.id, record]));
|
|
139
|
+
for (const edge of records.assignedTo) {
|
|
140
|
+
if (edge.lifecycle_status && edge.lifecycle_status !== 'active')
|
|
141
|
+
continue;
|
|
142
|
+
const assignment = raci.get(edge.from_id);
|
|
143
|
+
const projectId = assignment?.project_id ?? edge.project_id ?? null;
|
|
144
|
+
if (!assignment || !projectId) {
|
|
145
|
+
danglingRelations += 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const payload = isRecord(assignment.payload) ? assignment.payload : {};
|
|
149
|
+
const role = text(payload.role_code) ?? text(payload.role) ?? text(edge.payload?.role_code);
|
|
150
|
+
addEdge(edge.id, edge.to_id, isAccountableRole(payload) ? 'accountable_for' : 'participates_in', projectId, role);
|
|
151
|
+
}
|
|
152
|
+
const edges = [...byKey.values()].map(({ edge, roles }) => (roles.length ? { ...edge, role: roles.join('、') } : edge));
|
|
153
|
+
const ownerEntity = owner && ids.get(owner.id) === 'person' ? owner : undefined;
|
|
154
|
+
return {
|
|
155
|
+
graph: {
|
|
156
|
+
version: 2,
|
|
157
|
+
ontology: { ...canonicalGraphOntologyRelease.binding },
|
|
158
|
+
owner: ownerEntity ? { id: ownerEntity.id, ...(ownerEntity.name ? { name: ownerEntity.name } : {}) } : {},
|
|
159
|
+
entities,
|
|
160
|
+
edges
|
|
161
|
+
},
|
|
162
|
+
excluded: { inactive, unnamed, danglingRelations }
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/** The owner's Brainbase server and token, read at request time (tokens rotate hourly). Never logged or returned. */
|
|
166
|
+
export async function readOrganizationAccess(home = homedir()) {
|
|
167
|
+
try {
|
|
168
|
+
const config = JSON.parse(await readFile(join(home, '.brainbase', 'config.json'), 'utf8'));
|
|
169
|
+
const tokens = JSON.parse(await readFile(join(home, '.brainbase', 'tokens.json'), 'utf8'));
|
|
170
|
+
const server = isRecord(config) ? text(config.server_url) : null;
|
|
171
|
+
const token = isRecord(tokens) ? text(tokens.access_token) : null;
|
|
172
|
+
if (!server || !/^https:\/\/[^/?#@]+$/u.test(server))
|
|
173
|
+
return { reason: 'server_url_missing_or_invalid' };
|
|
174
|
+
if (!token)
|
|
175
|
+
return { reason: 'access_token_missing' };
|
|
176
|
+
return { server, token };
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return { reason: 'brainbase_auth_files_unreadable' };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function getRecords(request, access, path) {
|
|
183
|
+
const controller = new AbortController();
|
|
184
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
185
|
+
try {
|
|
186
|
+
const response = await request(`${access.server}/api/info/graph/${path}`, {
|
|
187
|
+
method: 'GET',
|
|
188
|
+
headers: { Authorization: `Bearer ${access.token}`, Accept: 'application/json' },
|
|
189
|
+
redirect: 'error',
|
|
190
|
+
signal: controller.signal
|
|
191
|
+
});
|
|
192
|
+
if (response.status === 401 || response.status === 403) {
|
|
193
|
+
throw new GraphWebError('unavailable', 'organization_graph_auth_failed', `The organization Graph refused the owner's token (HTTP ${response.status}); sign in again`);
|
|
194
|
+
}
|
|
195
|
+
if (!response.ok)
|
|
196
|
+
throw new GraphWebError('unavailable', 'organization_graph_unavailable', `The organization Graph answered HTTP ${response.status} for ${path.split('?')[0]}`);
|
|
197
|
+
const body = await response.json();
|
|
198
|
+
if (!isRecord(body) || !Array.isArray(body.records))
|
|
199
|
+
throw new GraphWebError('unavailable', 'organization_graph_unavailable', 'The organization Graph response has no records');
|
|
200
|
+
return body.records;
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (error instanceof GraphWebError)
|
|
204
|
+
throw error;
|
|
205
|
+
const reason = error instanceof Error && error.name === 'AbortError' ? 'timed out' : 'request failed';
|
|
206
|
+
throw new GraphWebError('unavailable', 'organization_graph_unavailable', `The organization Graph ${reason}`);
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The organization Graph for the local host (C1). Reads are cached briefly and
|
|
214
|
+
* shared while in flight; a failure is reported as `unavailable`, never as an
|
|
215
|
+
* empty Graph.
|
|
216
|
+
*/
|
|
217
|
+
export async function createOrganizationGraphSource(options = {}) {
|
|
218
|
+
const home = options.home ?? homedir();
|
|
219
|
+
const initial = await readOrganizationAccess(home);
|
|
220
|
+
if ('reason' in initial)
|
|
221
|
+
return initial;
|
|
222
|
+
const request = options.fetch ?? fetch;
|
|
223
|
+
const now = options.now ?? (() => new Date());
|
|
224
|
+
const cacheMs = options.cacheMs ?? CACHE_MS;
|
|
225
|
+
let cached = null;
|
|
226
|
+
let inFlight = null;
|
|
227
|
+
async function load() {
|
|
228
|
+
const access = await readOrganizationAccess(home);
|
|
229
|
+
if ('reason' in access)
|
|
230
|
+
throw new GraphWebError('unavailable', 'organization_graph_not_connected', `The organization Graph cannot be read (${access.reason})`);
|
|
231
|
+
const [project, person, org, decision, raci, memberOf, assignedTo] = await Promise.all([
|
|
232
|
+
getRecords(request, access, 'entities?type=project&limit=500'),
|
|
233
|
+
getRecords(request, access, 'entities?type=person&limit=500'),
|
|
234
|
+
getRecords(request, access, 'entities?type=org&limit=500'),
|
|
235
|
+
getRecords(request, access, 'entities?type=decision&limit=500'),
|
|
236
|
+
getRecords(request, access, 'entities?type=raci_assignment&limit=500'),
|
|
237
|
+
getRecords(request, access, 'edges?type=member_of'),
|
|
238
|
+
getRecords(request, access, 'edges?type=assigned_to')
|
|
239
|
+
]);
|
|
240
|
+
const projection = projectOrganizationGraph({
|
|
241
|
+
entities: {
|
|
242
|
+
project: project,
|
|
243
|
+
person: person,
|
|
244
|
+
org: org,
|
|
245
|
+
decision: decision,
|
|
246
|
+
raci_assignment: raci
|
|
247
|
+
},
|
|
248
|
+
memberOf: memberOf,
|
|
249
|
+
assignedTo: assignedTo
|
|
250
|
+
});
|
|
251
|
+
const source = { dataDir: null, graphFormat: 2, authority: 'organization_graph', server: access.server, readAt: now().toISOString() };
|
|
252
|
+
return { reader: openInMemoryGraph(projection.graph, source), graph: projection.graph };
|
|
253
|
+
}
|
|
254
|
+
async function current() {
|
|
255
|
+
const at = now().getTime();
|
|
256
|
+
if (cached && at - cached.at < cacheMs)
|
|
257
|
+
return cached.loaded;
|
|
258
|
+
inFlight ??= load().then((loaded) => {
|
|
259
|
+
cached = { at: now().getTime(), loaded };
|
|
260
|
+
return loaded;
|
|
261
|
+
}).finally(() => {
|
|
262
|
+
inFlight = null;
|
|
263
|
+
});
|
|
264
|
+
return inFlight;
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
server: initial.server,
|
|
268
|
+
async read() {
|
|
269
|
+
return (await current()).reader;
|
|
270
|
+
},
|
|
271
|
+
async readGraphFile() {
|
|
272
|
+
return (await current()).graph;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
2
|
+
import { JudgmentValueProofJournalCache } from './judgment-value-proof-review.js';
|
|
2
3
|
export declare const VALUE_PROOF_REVIEW_HTTP_VERSION: "value-proof-review-http.v1";
|
|
3
4
|
export declare const VALUE_PROOF_REVIEW_TOKEN_HEADER = "x-brainbase-review-token";
|
|
4
5
|
export interface ValueProofReviewHttpOptions {
|
|
5
6
|
/** Judgment journal root. Defaults to `~/.brainbase/personal-os/judgment-journal`. */
|
|
6
7
|
readonly journalRoot?: string;
|
|
7
|
-
/** Personal OS data directory that stores owner feedback. */
|
|
8
|
+
/** Personal OS data directory that stores owner feedback and answers. */
|
|
8
9
|
readonly dataDir?: string;
|
|
9
10
|
/** Required on mutating requests. */
|
|
10
11
|
readonly token: string;
|
|
11
12
|
readonly basePath?: string;
|
|
12
13
|
readonly now?: () => Date;
|
|
14
|
+
/** One cache per journal; a host passes the one it shares with other readers of the same journal. */
|
|
15
|
+
readonly journalCache?: JudgmentValueProofJournalCache;
|
|
13
16
|
}
|
|
14
17
|
export type ValueProofReviewHttpHandler = (request: IncomingMessage, response: ServerResponse) => Promise<boolean>;
|
|
15
18
|
export declare function createValueProofReviewHttpHandler(options: ValueProofReviewHttpOptions): ValueProofReviewHttpHandler;
|
|
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { createServer } from 'node:http';
|
|
4
4
|
import { join, relative } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { buildJudgmentValueProofReviewHome, readJudgmentValueProofFeedback, readJudgmentValueProofJournal, recordJudgmentValueProofFeedback } from './judgment-value-proof-review.js';
|
|
6
|
+
import { applyJudgmentValueProofFeedback, buildJudgmentValueProofReviewHome, classifyJudgmentValueProof, JudgmentValueProofAnswerConflictError, JudgmentValueProofJournalCache, readJudgmentValueProofAnswers, readJudgmentValueProofFeedback, readJudgmentValueProofJournal, recordJudgmentValueProofAnswer, recordJudgmentValueProofFeedback } from './judgment-value-proof-review.js';
|
|
7
7
|
import { assertSameOrigin, isLoopbackHost, LOCAL_WEB_CONTENT_SECURITY_POLICY, LocalWebHttpError as HttpError, readJsonObjectBody, requestUrl, tokenMatches, writeHttpError as writeError, writeJson } from './local-web-security.js';
|
|
8
8
|
export const VALUE_PROOF_REVIEW_HTTP_VERSION = 'value-proof-review-http.v1';
|
|
9
9
|
export const VALUE_PROOF_REVIEW_TOKEN_HEADER = 'x-brainbase-review-token';
|
|
@@ -22,13 +22,21 @@ function publicHome(home) {
|
|
|
22
22
|
return home;
|
|
23
23
|
const strip = (items) => Object.fromEntries(Object.entries(items).map(([section, list]) => [
|
|
24
24
|
section,
|
|
25
|
-
list.map(({ section: itemSection, proof, feedback_history }) => ({
|
|
25
|
+
list.map(({ section: itemSection, proof, feedback_history, answer, conversation_moved_on_at }) => ({
|
|
26
|
+
section: itemSection,
|
|
27
|
+
proof,
|
|
28
|
+
feedback_history,
|
|
29
|
+
answer: answer ?? null,
|
|
30
|
+
conversation_moved_on_at: conversation_moved_on_at ?? null
|
|
31
|
+
}))
|
|
26
32
|
]));
|
|
27
33
|
return {
|
|
28
34
|
status: home.status,
|
|
29
35
|
root: home.root,
|
|
30
36
|
coverage: home.coverage,
|
|
31
37
|
sections: strip(home.sections),
|
|
38
|
+
// The screen offers answering only when the host says it records answers (and never resumes work).
|
|
39
|
+
capabilities: { answer: 'record_only' },
|
|
32
40
|
// Holds only decision IDs, counts and states; no local paths.
|
|
33
41
|
delegation_map: home.delegation_map,
|
|
34
42
|
rejected: home.rejected.map((entry) => ({ file: relative(home.root, entry.file), reason: entry.reason }))
|
|
@@ -40,22 +48,78 @@ export function createValueProofReviewHttpHandler(options) {
|
|
|
40
48
|
}
|
|
41
49
|
const basePath = (options.basePath ?? DEFAULT_BASE_PATH).replace(/\/+$/u, '');
|
|
42
50
|
const now = options.now ?? (() => new Date());
|
|
51
|
+
// A large journal has thousands of conversation folders; only the ones that changed are listed again.
|
|
52
|
+
const journalCache = options.journalCache ?? new JudgmentValueProofJournalCache();
|
|
53
|
+
const readJournal = () => readJudgmentValueProofJournal({ root: options.journalRoot, cache: journalCache });
|
|
43
54
|
async function readHome() {
|
|
44
|
-
const journal = await
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
const journal = await readJournal();
|
|
56
|
+
if (journal.status !== 'available')
|
|
57
|
+
return buildJudgmentValueProofReviewHome(journal, [], { now: now() });
|
|
58
|
+
const [feedback, answers] = await Promise.all([
|
|
59
|
+
readJudgmentValueProofFeedback({ dataDir: options.dataDir }),
|
|
60
|
+
readJudgmentValueProofAnswers({ dataDir: options.dataDir })
|
|
61
|
+
]);
|
|
62
|
+
return buildJudgmentValueProofReviewHome(journal, feedback, { now: now(), answers });
|
|
49
63
|
}
|
|
50
|
-
async function
|
|
64
|
+
async function authorizedBody(request) {
|
|
51
65
|
assertSameOrigin(request);
|
|
52
66
|
if (!tokenMatches(options.token, request.headers[VALUE_PROOF_REVIEW_TOKEN_HEADER])) {
|
|
53
67
|
throw new HttpError(403, 'review_token_required', 'A valid review token is required');
|
|
54
68
|
}
|
|
55
|
-
|
|
69
|
+
return readJsonObjectBody(request, MAX_BODY_BYTES);
|
|
70
|
+
}
|
|
71
|
+
async function recordAnswer(request) {
|
|
72
|
+
const body = await authorizedBody(request);
|
|
73
|
+
const intentId = typeof body.intent_id === 'string' ? body.intent_id : '';
|
|
74
|
+
const attemptId = typeof body.decision_attempt_id === 'string' ? body.decision_attempt_id : '';
|
|
75
|
+
const journal = await readJournal();
|
|
76
|
+
if (journal.status === 'unavailable') {
|
|
77
|
+
throw new HttpError(503, 'judgment_journal_unavailable', journal.reason);
|
|
78
|
+
}
|
|
79
|
+
const entry = journal.entries.find((candidate) => candidate.proof.intent_id === intentId
|
|
80
|
+
&& candidate.proof.decision_attempt_id === attemptId);
|
|
81
|
+
if (!entry)
|
|
82
|
+
throw new HttpError(404, 'value_proof_not_found', 'No saved value proof matches this decision');
|
|
83
|
+
// Classify the judgment as the home shows it, with the owner's evaluation applied.
|
|
84
|
+
const { proof } = applyJudgmentValueProofFeedback(entry.proof, await readJudgmentValueProofFeedback({ dataDir: options.dataDir }));
|
|
85
|
+
if (classifyJudgmentValueProof(proof) !== 'needs_human' || !proof.human_decision) {
|
|
86
|
+
throw new HttpError(422, 'not_answerable', 'This judgment is not waiting for the owner');
|
|
87
|
+
}
|
|
88
|
+
if (entry.conversation_moved_on_at) {
|
|
89
|
+
// The owner answered in that conversation or moved on; answering here could replay a settled question.
|
|
90
|
+
throw new HttpError(422, 'conversation_moved_on', 'The conversation moved on after this question; continue it there');
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const result = await recordJudgmentValueProofAnswer({
|
|
94
|
+
dataDir: options.dataDir,
|
|
95
|
+
proof,
|
|
96
|
+
kind: body.kind,
|
|
97
|
+
selected_option_id: typeof body.selected_option_id === 'string' ? body.selected_option_id : null,
|
|
98
|
+
confirmed: body.confirmed === true,
|
|
99
|
+
now: now()
|
|
100
|
+
});
|
|
101
|
+
return { statusCode: result.created ? 201 : 200, body: result };
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error instanceof JudgmentValueProofAnswerConflictError) {
|
|
105
|
+
return {
|
|
106
|
+
statusCode: 409,
|
|
107
|
+
body: {
|
|
108
|
+
error: { code: 'answer_conflict', message: 'A different answer is already recorded for this judgment' },
|
|
109
|
+
existing: error.existing
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (error instanceof TypeError)
|
|
114
|
+
throw new HttpError(400, 'invalid_answer', error.message);
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function recordFeedback(request) {
|
|
119
|
+
const body = await authorizedBody(request);
|
|
56
120
|
const intentId = typeof body.intent_id === 'string' ? body.intent_id : '';
|
|
57
121
|
const attemptId = typeof body.decision_attempt_id === 'string' ? body.decision_attempt_id : '';
|
|
58
|
-
const journal = await
|
|
122
|
+
const journal = await readJournal();
|
|
59
123
|
if (journal.status === 'unavailable') {
|
|
60
124
|
throw new HttpError(503, 'judgment_journal_unavailable', journal.reason);
|
|
61
125
|
}
|
|
@@ -83,7 +147,7 @@ export function createValueProofReviewHttpHandler(options) {
|
|
|
83
147
|
}
|
|
84
148
|
return async (request, response) => {
|
|
85
149
|
const path = requestUrl(request).pathname;
|
|
86
|
-
if (path !== `${basePath}/home` && path !== `${basePath}/feedback`)
|
|
150
|
+
if (path !== `${basePath}/home` && path !== `${basePath}/feedback` && path !== `${basePath}/answers`)
|
|
87
151
|
return false;
|
|
88
152
|
try {
|
|
89
153
|
if (path === `${basePath}/home`) {
|
|
@@ -94,7 +158,7 @@ export function createValueProofReviewHttpHandler(options) {
|
|
|
94
158
|
}
|
|
95
159
|
if (request.method !== 'POST')
|
|
96
160
|
throw new HttpError(405, 'method_not_allowed', 'Use POST');
|
|
97
|
-
const result = await recordFeedback(request);
|
|
161
|
+
const result = path === `${basePath}/answers` ? await recordAnswer(request) : await recordFeedback(request);
|
|
98
162
|
writeJson(response, result.statusCode, result.body);
|
|
99
163
|
}
|
|
100
164
|
catch (error) {
|