archgraph-argo 0.27.0 → 0.28.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/README.md +126 -65
- package/argo/.env.example +12 -1
- package/argo/rules/archgraph.instructions.md +1 -1
- package/argo/schema/{argob.config.json → schema-bundle.config.json} +4 -2
- package/argo/schema/{argob-rules.json → schema-bundle.rules.json} +12229 -12229
- package/argo/scripts/agentSearchDiagnose.js +410 -407
- package/argo/scripts/argo-mcp-server.js +59 -12
- package/argo/scripts/ensureArgoHarnessEnvironment.js +1 -1
- package/argo/scripts/external-graph-query.js +162 -162
- package/argo/scripts/graph-rag/liveEmbeddingProviderConfig.js +3 -0
- package/argo/scripts/graph-semantics.js +100 -3
- package/argo/scripts/runArchitectureTests.js +591 -591
- package/argo/scripts/{argob-schema.js → schema-bundle.js} +386 -28
- package/argo/scripts/systemarchitecture-mcp-server.js +673 -68
- package/argo/scripts/validateSystemArchitecture.js +3 -1
- package/argo/scripts/validator-mcp-server.js +1 -1
- package/argo/scripts/workspace-write-guard.js +144 -0
- package/argo/skills/argo-init/SKILL.md +2 -2
- package/argo/skills/ea-human-reconcile/SKILL.md +40 -40
- package/cordis.patch.yml +15 -6
- package/dsh-argo-wakeup/index.js +17 -17
- package/dsh-argo-workspace/index.js +227 -142
- package/install-argo.ps1 +122 -21
- package/package.json +57 -56
|
@@ -14,11 +14,16 @@ const {
|
|
|
14
14
|
} = require('./repositoryArgoEnvironment.js');
|
|
15
15
|
const {
|
|
16
16
|
loadSchemaBundleAndOntology,
|
|
17
|
-
} = require('./
|
|
17
|
+
} = require('./schema-bundle.js');
|
|
18
18
|
const {
|
|
19
19
|
externalQueryRequested,
|
|
20
20
|
queryExternalRead,
|
|
21
21
|
} = require('./external-graph-query.js');
|
|
22
|
+
const {
|
|
23
|
+
authorizeWorkspaceWrite,
|
|
24
|
+
isWriteTool,
|
|
25
|
+
stripTrustedSessionField,
|
|
26
|
+
} = require('./workspace-write-guard.js');
|
|
22
27
|
const {
|
|
23
28
|
getWorkspaceRoot,
|
|
24
29
|
hasStaticWorkspace,
|
|
@@ -134,7 +139,7 @@ const TOOLS = [
|
|
|
134
139
|
},
|
|
135
140
|
{
|
|
136
141
|
name: 'getIntentElementContext',
|
|
137
|
-
description: 'read-only query that returns an intent subgraph context for one element. Uses ArchiMate semantic dependency traversal with dependencyDepth and dependentDepth, preserving native subgraph elements, relationships, and views.',
|
|
142
|
+
description: 'read-only query that returns an intent subgraph context for one element. Uses ArchiMate semantic dependency traversal with dependencyDepth and dependentDepth, preserving native subgraph elements, relationships, and views. Output is bounded by maxBytes (default ARGO_CONTEXT_MAX_BYTES or 32000): beyond it non-focus members degrade to identity (id/type/name) with a complete id manifest under truncation, so the host never silently cuts the payload. On a large/hub element leave includeAttributes/includeTestcases off and prefer queryNeo4jGraph to locate ids, then read them narrowly.',
|
|
138
143
|
inputSchema: intentElementContextInputSchema(),
|
|
139
144
|
},
|
|
140
145
|
{
|
|
@@ -149,12 +154,18 @@ const TOOLS = [
|
|
|
149
154
|
},
|
|
150
155
|
{
|
|
151
156
|
name: 'addArchitectureElement',
|
|
152
|
-
description: 'Use for one element. Creates a new element or adds an existing element to view_ids. view_ids is required so elements never exist outside views.',
|
|
157
|
+
description: 'Use for one element. Creates a new element or adds an existing element to view_ids. view_ids is required so elements never exist outside views. element.id is OPTIONAL: omit it and the server auto-allocates a unique id (returned in the result); provide it to pin a specific id (must not collide with a different element).',
|
|
153
158
|
inputSchema: {
|
|
154
159
|
type: 'object',
|
|
155
160
|
required: ['element', 'view_ids'],
|
|
156
161
|
properties: {
|
|
157
|
-
element: {
|
|
162
|
+
element: {
|
|
163
|
+
type: 'object',
|
|
164
|
+
properties: {
|
|
165
|
+
id: { type: 'string', description: 'Element id (OPTIONAL). Omit to let the server auto-allocate a unique id (a semantic slug from the name, e.g. "graph-wiki-federation-center", suffixed -002.. if taken). If provided it must not collide with a DIFFERENT element (a same (type,name) match is idempotent reuse).' },
|
|
166
|
+
},
|
|
167
|
+
description: 'The element to create/attach. `id` is optional — omit it and the server allocates one (returned in the result).',
|
|
168
|
+
},
|
|
158
169
|
view_ids: { type: 'array', minItems: 1, items: { type: 'string' } },
|
|
159
170
|
onConflict: { type: 'string', enum: ['reuse', 'allowDuplicate'], description: 'Dedup policy (default reuse). reuse: find-or-create — attach an existing exact (type, name) match; a same-type semantic near-duplicate also blocks creation. allowDuplicate: create anyway (even if a duplicate exists), requires a justification.' },
|
|
160
171
|
justification: { type: 'string', description: 'Required when onConflict is allowDuplicate.' },
|
|
@@ -193,7 +204,7 @@ const TOOLS = [
|
|
|
193
204
|
},
|
|
194
205
|
{
|
|
195
206
|
name: 'addArchitectureRelationship',
|
|
196
|
-
description: 'Use for one relationship. Creates a new relationship or adds an existing relationship to view_ids. relationship.type is the ArchiMate 3.2 relationship type and is validated against endpoint element types.',
|
|
207
|
+
description: 'Use for one relationship. Creates a new relationship or adds an existing relationship to view_ids. relationship.type is the ArchiMate 3.2 relationship type and is validated against endpoint element types. relationship.id is OPTIONAL: omit it and the server auto-allocates a unique id (returned in the result).',
|
|
197
208
|
inputSchema: {
|
|
198
209
|
type: 'object',
|
|
199
210
|
required: ['relationship', 'view_ids'],
|
|
@@ -237,7 +248,7 @@ const TOOLS = [
|
|
|
237
248
|
},
|
|
238
249
|
{
|
|
239
250
|
name: 'addArchitectureView',
|
|
240
|
-
description: 'Use for one view. The graph must have exactly one top-level view named SystemArchitecture; all sub-views must attach to an element with parent_element_id.',
|
|
251
|
+
description: 'Use for one view. The graph must have exactly one top-level view named SystemArchitecture; all sub-views must attach to an element with parent_element_id. view.view_id is OPTIONAL: omit it and the server auto-allocates a unique view_id (returned in the result).',
|
|
241
252
|
inputSchema: {
|
|
242
253
|
type: 'object',
|
|
243
254
|
required: ['view'],
|
|
@@ -337,8 +348,9 @@ function intentElementContextInputSchema() {
|
|
|
337
348
|
dependentDepth: { type: 'number', description: 'Default: 1. Semantic dependents that rely on the focus element.' },
|
|
338
349
|
associationDepth: { type: 'number', description: 'Default: 1. Association neighbors are expanded at least one layer.' },
|
|
339
350
|
associationNeighborDependencyDepth: { type: 'number', description: 'Default: 0. Optional dependency expansion from association neighbors.' },
|
|
340
|
-
includeAttributes: { type: 'boolean', description: 'Default: false. Include `attributes` (commit/session/release ledgers) verbatim; omitted by default from this structural read (the focus element always keeps its own).' },
|
|
341
|
-
includeTestcases: { type: 'boolean', description: 'Default: false. Include member `testcases` verbatim; omitted by default from this structural read.' },
|
|
351
|
+
includeAttributes: { type: 'boolean', description: 'Default: false. Include `attributes` (commit/session/release ledgers) verbatim; omitted by default from this structural read (the focus element always keeps its own). On a hub element this ledger can dominate the payload.' },
|
|
352
|
+
includeTestcases: { type: 'boolean', description: 'Default: false. Include member `testcases` verbatim; omitted by default from this structural read. On a hub element this can be large.' },
|
|
353
|
+
maxBytes: { type: 'number', description: 'Optional output budget in UTF-8 bytes. Default: ARGO_CONTEXT_MAX_BYTES env or 32000. 0 = unlimited. Above the budget, non-focus members degrade to identity (id/type/name) with a complete id manifest under `truncation`; the payload is never silently truncated by the host.' },
|
|
342
354
|
},
|
|
343
355
|
additionalProperties: false,
|
|
344
356
|
};
|
|
@@ -404,6 +416,33 @@ function resolveWorkspaceRoot(args) {
|
|
|
404
416
|
}
|
|
405
417
|
|
|
406
418
|
async function callTool(name, args = {}, progressToken = null, dependencies = undefined) {
|
|
419
|
+
// Cross-project WRITE guard (single choke point for every tool): a write tool
|
|
420
|
+
// may only target this server's HOME workspace, or a host-designated trusted
|
|
421
|
+
// session workspace when the process was launched by a trusted broker
|
|
422
|
+
// (ARGO_WORKSPACE_ROOT_TRUSTED=1). A foreign `workspaceRoot` is rejected
|
|
423
|
+
// fail-closed before any side effect; READ tools stay ungated so cross-project
|
|
424
|
+
// reading still works. See workspace-write-guard.js.
|
|
425
|
+
if (isWriteTool(name, args)) {
|
|
426
|
+
const decision = authorizeWorkspaceWrite({ tool: name, args, homeRoot: getWorkspaceRoot() });
|
|
427
|
+
if (!decision.allowed) {
|
|
428
|
+
return toolResult({
|
|
429
|
+
status: 'failed',
|
|
430
|
+
error: {
|
|
431
|
+
category: decision.category,
|
|
432
|
+
message: decision.message,
|
|
433
|
+
tool: decision.tool,
|
|
434
|
+
homeWorkspaceRoot: decision.homeWorkspaceRoot,
|
|
435
|
+
requestedWorkspaceRoot: decision.requestedWorkspaceRoot,
|
|
436
|
+
...(decision.trustedSessionWorkspaceRoot
|
|
437
|
+
? { trustedSessionWorkspaceRoot: decision.trustedSessionWorkspaceRoot }
|
|
438
|
+
: {}),
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// The broker-only field is not part of any tool's schema: honor it above, then
|
|
444
|
+
// drop it before dispatch so schema validation never sees it.
|
|
445
|
+
args = stripTrustedSessionField(args);
|
|
407
446
|
loadRepositoryArgoEnvironment(resolveWorkspaceRoot(args));
|
|
408
447
|
try {
|
|
409
448
|
const crash = require('./graph-rag/mcpCrashDiagnostics.js');
|
|
@@ -420,6 +459,13 @@ async function callTool(name, args = {}, progressToken = null, dependencies = un
|
|
|
420
459
|
tool: name,
|
|
421
460
|
args,
|
|
422
461
|
});
|
|
462
|
+
// getSystemArchitecture declares an outputSchema, so its result MUST carry
|
|
463
|
+
// matching structuredContent even on the cross-project path (which bypasses
|
|
464
|
+
// the systemarchitecture module's own toolResult). A missing
|
|
465
|
+
// structuredContent makes the MCP client reject the call with -32600.
|
|
466
|
+
if (name === 'getSystemArchitecture') {
|
|
467
|
+
return toolResult(result, systemArchitectureMcp.buildGetSystemArchitectureStructuredContent(result));
|
|
468
|
+
}
|
|
423
469
|
return toolResult(result);
|
|
424
470
|
}
|
|
425
471
|
if (name === 'initializeWorkspace') {
|
|
@@ -438,7 +484,7 @@ async function callTool(name, args = {}, progressToken = null, dependencies = un
|
|
|
438
484
|
return toolResult({
|
|
439
485
|
status: report.status,
|
|
440
486
|
workspaceRoot: workspace.workspaceRoot,
|
|
441
|
-
// The resolved modeling language for this workspace (default
|
|
487
|
+
// The resolved modeling language for this workspace (default ArchiMate 3.2 vs
|
|
442
488
|
// the repository's own .argo/schema bundle) so init names the active schema.
|
|
443
489
|
schema: workspace.schema,
|
|
444
490
|
schemaBundle: report.schemaBundle,
|
|
@@ -535,7 +581,7 @@ async function initializeWorkspace(workspaceRoot) {
|
|
|
535
581
|
const graphTargetPath = path.join(workspaceRoot, ...WORKSPACE_GRAPH_PATH_SEGMENTS);
|
|
536
582
|
const graphRelativePath = normalizeRelativePath(path.relative(workspaceRoot, graphTargetPath));
|
|
537
583
|
if (!fs.existsSync(graphTargetPath)) {
|
|
538
|
-
// Only the built-in
|
|
584
|
+
// Only the built-in ArchiMate 3.2 default schema auto-provides a graph (the
|
|
539
585
|
// packaged default). For any custom / replaced schema the graph is the user's
|
|
540
586
|
// own: never copy the packaged (mismatched) graph — fail closed if missing.
|
|
541
587
|
let ontology = null;
|
|
@@ -590,7 +636,7 @@ async function initializeWorkspace(workspaceRoot) {
|
|
|
590
636
|
} catch (error) {
|
|
591
637
|
qeaFullProjection = { status: 'failed', error: String(error && error.message ? error.message : error) };
|
|
592
638
|
}
|
|
593
|
-
// Report which schema this workspace resolves to (default
|
|
639
|
+
// Report which schema this workspace resolves to (default ArchiMate 3.2 vs a
|
|
594
640
|
// repository's own .argo/schema bundle) so the caller/human can see the active
|
|
595
641
|
// modeling language right after init.
|
|
596
642
|
let schema;
|
|
@@ -678,7 +724,7 @@ function normalizeRelativePath(value) {
|
|
|
678
724
|
return String(value).replace(/\\/g, '/');
|
|
679
725
|
}
|
|
680
726
|
|
|
681
|
-
function toolResult(payload) {
|
|
727
|
+
function toolResult(payload, structuredContent = undefined) {
|
|
682
728
|
return {
|
|
683
729
|
content: [
|
|
684
730
|
{
|
|
@@ -686,6 +732,7 @@ function toolResult(payload) {
|
|
|
686
732
|
text: JSON.stringify(payload, null, 2),
|
|
687
733
|
},
|
|
688
734
|
],
|
|
735
|
+
...(structuredContent === undefined ? {} : { structuredContent }),
|
|
689
736
|
isError: payload.status === 'failed',
|
|
690
737
|
};
|
|
691
738
|
}
|
|
@@ -57,7 +57,7 @@ async function runHarnessReport({ checkOnly, workspaceRoot, includeBootstrap })
|
|
|
57
57
|
try {
|
|
58
58
|
report.harnessEnvironment = loadRepositoryArgoEnvironment(workspaceRoot);
|
|
59
59
|
try {
|
|
60
|
-
const { bundle, ontology } = require('./
|
|
60
|
+
const { bundle, ontology } = require('./schema-bundle.js').loadSchemaBundleAndOntology(workspaceRoot);
|
|
61
61
|
report.schemaBundle = {
|
|
62
62
|
kind: bundle.kind,
|
|
63
63
|
language: ontology.language,
|
|
@@ -1,162 +1,162 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Cross-project graph query (federation client side).
|
|
4
|
-
//
|
|
5
|
-
// A read tool call may carry an optional `projectId`. Absent => the local
|
|
6
|
-
// workspace (unchanged). Present => the call is routed to the federation center
|
|
7
|
-
// (`POST <centerUrl>/graph/read`), which authorizes and forwards to the mirror
|
|
8
|
-
// engine; the native ARGO result is passed through with a `namespaceKey`. There
|
|
9
|
-
// is NO silent fallback to the local graph.
|
|
10
|
-
//
|
|
11
|
-
// The requester identity is the project's own `projectId`, read from
|
|
12
|
-
// `<workspace>/.argo/federation.json`. If it is missing, external queries fail
|
|
13
|
-
// with an explicit "register first" error — the id is never guessed.
|
|
14
|
-
|
|
15
|
-
const fs = require('node:fs');
|
|
16
|
-
const path = require('node:path');
|
|
17
|
-
|
|
18
|
-
const EXTERNAL_READ_TOOLS = new Set([
|
|
19
|
-
'getSystemArchitecture',
|
|
20
|
-
'getIntentElementContext',
|
|
21
|
-
'getArchitectureViewContext',
|
|
22
|
-
'queryNeo4jGraph',
|
|
23
|
-
'memory_search',
|
|
24
|
-
]);
|
|
25
|
-
|
|
26
|
-
const FEDERATION_FILE = path.join('.argo', 'federation.json');
|
|
27
|
-
const DEFAULT_CENTER_URL = 'https://argo.derekworkspacev5.com';
|
|
28
|
-
|
|
29
|
-
function loadFederationIdentity(workspaceRoot) {
|
|
30
|
-
const file = path.join(workspaceRoot || process.cwd(), FEDERATION_FILE);
|
|
31
|
-
if (!fs.existsSync(file)) {
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
try {
|
|
35
|
-
const identity = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
36
|
-
return identity && typeof identity === 'object' ? identity : null;
|
|
37
|
-
} catch {
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function isExternalQuery(args) {
|
|
43
|
-
return Boolean(args) && typeof args.projectId === 'string' && args.projectId.trim() !== '';
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// A tool call is external iff it is one of the readable tools AND carries projectId.
|
|
47
|
-
function externalQueryRequested(toolName, args) {
|
|
48
|
-
return EXTERNAL_READ_TOOLS.has(toolName) && isExternalQuery(args);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function forwardedArgs(args) {
|
|
52
|
-
const out = { ...(args || {}) };
|
|
53
|
-
// projectId is the router parameter; workspaceRoot/architecturePath are local-only.
|
|
54
|
-
delete out.projectId;
|
|
55
|
-
delete out.workspaceRoot;
|
|
56
|
-
delete out.architecturePath;
|
|
57
|
-
return out;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Route an external read. Returns the native ARGO result (with an added
|
|
62
|
-
* `namespaceKey`) on success, or `{ status:'failed', error:{...} }` — never the
|
|
63
|
-
* local result.
|
|
64
|
-
*
|
|
65
|
-
* @param {object} options
|
|
66
|
-
* @param {string} options.workspaceRoot
|
|
67
|
-
* @param {string} options.tool
|
|
68
|
-
* @param {object} options.args
|
|
69
|
-
* @param {Function} [options.fetchImpl] - injectable fetch (tests)
|
|
70
|
-
*/
|
|
71
|
-
async function queryExternalRead({ workspaceRoot, tool, args, fetchImpl }) {
|
|
72
|
-
const fetchFn = fetchImpl || globalThis.fetch;
|
|
73
|
-
if (typeof fetchFn !== 'function') {
|
|
74
|
-
return { status: 'failed', error: { category: 'EXTERNAL_QUERY_UNREACHABLE', message: 'global fetch is unavailable in this runtime' } };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const identity = loadFederationIdentity(workspaceRoot);
|
|
78
|
-
if (!identity || typeof identity.projectId !== 'string' || identity.projectId.trim() === '') {
|
|
79
|
-
return {
|
|
80
|
-
status: 'failed',
|
|
81
|
-
error: {
|
|
82
|
-
category: 'EXTERNAL_QUERY_NOT_REGISTERED',
|
|
83
|
-
message: `No federation identity at ${FEDERATION_FILE}. Register this project with the federation center first (registry_register) and write its projectId to ${FEDERATION_FILE}.`,
|
|
84
|
-
},
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (!EXTERNAL_READ_TOOLS.has(tool)) {
|
|
89
|
-
return {
|
|
90
|
-
status: 'failed',
|
|
91
|
-
error: { category: 'EXTERNAL_QUERY_TOOL_NOT_ALLOWED', message: `tool '${tool}' is not externally readable`, allowed: [...EXTERNAL_READ_TOOLS] },
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const centerUrl = String(identity.centerUrl || DEFAULT_CENTER_URL).replace(/\/+$/, '');
|
|
96
|
-
const url = `${centerUrl}/graph/read`;
|
|
97
|
-
const body = {
|
|
98
|
-
requester: identity.projectId,
|
|
99
|
-
projectId: args.projectId,
|
|
100
|
-
tool,
|
|
101
|
-
args: forwardedArgs(args),
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
let response;
|
|
105
|
-
try {
|
|
106
|
-
response = await fetchFn(url, {
|
|
107
|
-
method: 'POST',
|
|
108
|
-
headers: { 'content-type': 'application/json' },
|
|
109
|
-
body: JSON.stringify(body),
|
|
110
|
-
});
|
|
111
|
-
} catch (error) {
|
|
112
|
-
return {
|
|
113
|
-
status: 'failed',
|
|
114
|
-
error: {
|
|
115
|
-
category: 'EXTERNAL_QUERY_UNREACHABLE',
|
|
116
|
-
message: `federation center unreachable: ${error && error.message ? error.message : error}`,
|
|
117
|
-
centerUrl,
|
|
118
|
-
},
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
let payload = null;
|
|
123
|
-
try { payload = await response.json(); } catch { /* leave null */ }
|
|
124
|
-
|
|
125
|
-
if (response.status === 200 && payload && payload.status === 'ok') {
|
|
126
|
-
// The engine returns the ARGO tool result; unwrap a {content:[{text}]}
|
|
127
|
-
// envelope so callers get the native payload, then stamp the namespaceKey.
|
|
128
|
-
let result = payload.result;
|
|
129
|
-
if (result && Array.isArray(result.content) && result.content[0] && typeof result.content[0].text === 'string') {
|
|
130
|
-
try { result = JSON.parse(result.content[0].text); } catch { /* keep the envelope */ }
|
|
131
|
-
}
|
|
132
|
-
if (result && typeof result === 'object') {
|
|
133
|
-
result = { ...result, namespaceKey: payload.namespaceKey || `proj:${args.projectId}` };
|
|
134
|
-
}
|
|
135
|
-
return result;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const denied = Boolean(payload && payload.status === 'denied');
|
|
139
|
-
const reason = (payload && payload.reason)
|
|
140
|
-
|| (response.status === 403 ? 'not_authorized' : `http_${response.status}`);
|
|
141
|
-
return {
|
|
142
|
-
status: 'failed',
|
|
143
|
-
error: {
|
|
144
|
-
category: denied ? 'EXTERNAL_QUERY_DENIED' : 'EXTERNAL_QUERY_FAILED',
|
|
145
|
-
reason,
|
|
146
|
-
httpStatus: response.status,
|
|
147
|
-
requester: identity.projectId,
|
|
148
|
-
projectId: args.projectId,
|
|
149
|
-
namespaceKey: `proj:${args.projectId}`,
|
|
150
|
-
},
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
module.exports = {
|
|
155
|
-
EXTERNAL_READ_TOOLS,
|
|
156
|
-
FEDERATION_FILE,
|
|
157
|
-
DEFAULT_CENTER_URL,
|
|
158
|
-
loadFederationIdentity,
|
|
159
|
-
isExternalQuery,
|
|
160
|
-
externalQueryRequested,
|
|
161
|
-
queryExternalRead,
|
|
162
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Cross-project graph query (federation client side).
|
|
4
|
+
//
|
|
5
|
+
// A read tool call may carry an optional `projectId`. Absent => the local
|
|
6
|
+
// workspace (unchanged). Present => the call is routed to the federation center
|
|
7
|
+
// (`POST <centerUrl>/graph/read`), which authorizes and forwards to the mirror
|
|
8
|
+
// engine; the native ARGO result is passed through with a `namespaceKey`. There
|
|
9
|
+
// is NO silent fallback to the local graph.
|
|
10
|
+
//
|
|
11
|
+
// The requester identity is the project's own `projectId`, read from
|
|
12
|
+
// `<workspace>/.argo/federation.json`. If it is missing, external queries fail
|
|
13
|
+
// with an explicit "register first" error — the id is never guessed.
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
const EXTERNAL_READ_TOOLS = new Set([
|
|
19
|
+
'getSystemArchitecture',
|
|
20
|
+
'getIntentElementContext',
|
|
21
|
+
'getArchitectureViewContext',
|
|
22
|
+
'queryNeo4jGraph',
|
|
23
|
+
'memory_search',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const FEDERATION_FILE = path.join('.argo', 'federation.json');
|
|
27
|
+
const DEFAULT_CENTER_URL = 'https://argo.derekworkspacev5.com';
|
|
28
|
+
|
|
29
|
+
function loadFederationIdentity(workspaceRoot) {
|
|
30
|
+
const file = path.join(workspaceRoot || process.cwd(), FEDERATION_FILE);
|
|
31
|
+
if (!fs.existsSync(file)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const identity = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
36
|
+
return identity && typeof identity === 'object' ? identity : null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isExternalQuery(args) {
|
|
43
|
+
return Boolean(args) && typeof args.projectId === 'string' && args.projectId.trim() !== '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A tool call is external iff it is one of the readable tools AND carries projectId.
|
|
47
|
+
function externalQueryRequested(toolName, args) {
|
|
48
|
+
return EXTERNAL_READ_TOOLS.has(toolName) && isExternalQuery(args);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function forwardedArgs(args) {
|
|
52
|
+
const out = { ...(args || {}) };
|
|
53
|
+
// projectId is the router parameter; workspaceRoot/architecturePath are local-only.
|
|
54
|
+
delete out.projectId;
|
|
55
|
+
delete out.workspaceRoot;
|
|
56
|
+
delete out.architecturePath;
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Route an external read. Returns the native ARGO result (with an added
|
|
62
|
+
* `namespaceKey`) on success, or `{ status:'failed', error:{...} }` — never the
|
|
63
|
+
* local result.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} options
|
|
66
|
+
* @param {string} options.workspaceRoot
|
|
67
|
+
* @param {string} options.tool
|
|
68
|
+
* @param {object} options.args
|
|
69
|
+
* @param {Function} [options.fetchImpl] - injectable fetch (tests)
|
|
70
|
+
*/
|
|
71
|
+
async function queryExternalRead({ workspaceRoot, tool, args, fetchImpl }) {
|
|
72
|
+
const fetchFn = fetchImpl || globalThis.fetch;
|
|
73
|
+
if (typeof fetchFn !== 'function') {
|
|
74
|
+
return { status: 'failed', error: { category: 'EXTERNAL_QUERY_UNREACHABLE', message: 'global fetch is unavailable in this runtime' } };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const identity = loadFederationIdentity(workspaceRoot);
|
|
78
|
+
if (!identity || typeof identity.projectId !== 'string' || identity.projectId.trim() === '') {
|
|
79
|
+
return {
|
|
80
|
+
status: 'failed',
|
|
81
|
+
error: {
|
|
82
|
+
category: 'EXTERNAL_QUERY_NOT_REGISTERED',
|
|
83
|
+
message: `No federation identity at ${FEDERATION_FILE}. Register this project with the federation center first (registry_register) and write its projectId to ${FEDERATION_FILE}.`,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!EXTERNAL_READ_TOOLS.has(tool)) {
|
|
89
|
+
return {
|
|
90
|
+
status: 'failed',
|
|
91
|
+
error: { category: 'EXTERNAL_QUERY_TOOL_NOT_ALLOWED', message: `tool '${tool}' is not externally readable`, allowed: [...EXTERNAL_READ_TOOLS] },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const centerUrl = String(identity.centerUrl || DEFAULT_CENTER_URL).replace(/\/+$/, '');
|
|
96
|
+
const url = `${centerUrl}/graph/read`;
|
|
97
|
+
const body = {
|
|
98
|
+
requester: identity.projectId,
|
|
99
|
+
projectId: args.projectId,
|
|
100
|
+
tool,
|
|
101
|
+
args: forwardedArgs(args),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
let response;
|
|
105
|
+
try {
|
|
106
|
+
response = await fetchFn(url, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'content-type': 'application/json' },
|
|
109
|
+
body: JSON.stringify(body),
|
|
110
|
+
});
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return {
|
|
113
|
+
status: 'failed',
|
|
114
|
+
error: {
|
|
115
|
+
category: 'EXTERNAL_QUERY_UNREACHABLE',
|
|
116
|
+
message: `federation center unreachable: ${error && error.message ? error.message : error}`,
|
|
117
|
+
centerUrl,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let payload = null;
|
|
123
|
+
try { payload = await response.json(); } catch { /* leave null */ }
|
|
124
|
+
|
|
125
|
+
if (response.status === 200 && payload && payload.status === 'ok') {
|
|
126
|
+
// The engine returns the ARGO tool result; unwrap a {content:[{text}]}
|
|
127
|
+
// envelope so callers get the native payload, then stamp the namespaceKey.
|
|
128
|
+
let result = payload.result;
|
|
129
|
+
if (result && Array.isArray(result.content) && result.content[0] && typeof result.content[0].text === 'string') {
|
|
130
|
+
try { result = JSON.parse(result.content[0].text); } catch { /* keep the envelope */ }
|
|
131
|
+
}
|
|
132
|
+
if (result && typeof result === 'object') {
|
|
133
|
+
result = { ...result, namespaceKey: payload.namespaceKey || `proj:${args.projectId}` };
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const denied = Boolean(payload && payload.status === 'denied');
|
|
139
|
+
const reason = (payload && payload.reason)
|
|
140
|
+
|| (response.status === 403 ? 'not_authorized' : `http_${response.status}`);
|
|
141
|
+
return {
|
|
142
|
+
status: 'failed',
|
|
143
|
+
error: {
|
|
144
|
+
category: denied ? 'EXTERNAL_QUERY_DENIED' : 'EXTERNAL_QUERY_FAILED',
|
|
145
|
+
reason,
|
|
146
|
+
httpStatus: response.status,
|
|
147
|
+
requester: identity.projectId,
|
|
148
|
+
projectId: args.projectId,
|
|
149
|
+
namespaceKey: `proj:${args.projectId}`,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = {
|
|
155
|
+
EXTERNAL_READ_TOOLS,
|
|
156
|
+
FEDERATION_FILE,
|
|
157
|
+
DEFAULT_CENTER_URL,
|
|
158
|
+
loadFederationIdentity,
|
|
159
|
+
isExternalQuery,
|
|
160
|
+
externalQueryRequested,
|
|
161
|
+
queryExternalRead,
|
|
162
|
+
};
|
|
@@ -99,8 +99,11 @@ const HOST_ONLY_ENV_KEYS = Object.freeze([
|
|
|
99
99
|
'ARGO_MCP_SEMANTIC_DEDUP',
|
|
100
100
|
'ARGO_MCP_SEMANTIC_DEDUP_THRESHOLD',
|
|
101
101
|
'ARGO_SEMANTIC_DEDUP_THRESHOLD',
|
|
102
|
+
'ARGO_WORKSPACE_WRITE_GUARD',
|
|
103
|
+
'ARGO_WORKSPACE_ROOT_TRUSTED',
|
|
102
104
|
'ARGO_COST_PROFILER',
|
|
103
105
|
'ARGO_COST_TRACE_MAX_BYTES',
|
|
106
|
+
'ARGO_CONTEXT_MAX_BYTES',
|
|
104
107
|
]);
|
|
105
108
|
const PROHIBITED_RUNTIME_FIELD_KEYS = Object.freeze(['neo4jUri', 'embeddingCredential']);
|
|
106
109
|
const SECRET_KEYS = new Set(['ARGO_NEO4J_DATABASE_PASSWORD', 'QWEN_KEY', 'ARGO_RERANK_API_KEY', EMBEDDING_API_KEY_KEY]);
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
// view element limit) is supplied by an *ontology* so a repository that ships
|
|
9
9
|
// its own schema under .argo/schema is validated against its own language.
|
|
10
10
|
//
|
|
11
|
-
// The ontology argument is optional: when omitted, the default
|
|
11
|
+
// The ontology argument is optional: when omitted, the default ArchiMate 3.2
|
|
12
12
|
// (ArchiMate 3.2 + ARGO) ontology is used, preserving historical behaviour.
|
|
13
13
|
|
|
14
|
-
const { loadSchemaBundleAndOntology } = require('./
|
|
14
|
+
const { loadSchemaBundleAndOntology } = require('./schema-bundle.js');
|
|
15
15
|
|
|
16
16
|
let defaultOntology = null;
|
|
17
17
|
|
|
@@ -30,7 +30,7 @@ function resolveOntology(ontology) {
|
|
|
30
30
|
*
|
|
31
31
|
* @param {object} document - parsed SystemArchitecture JSON
|
|
32
32
|
* @param {string[]} errors - error accumulator
|
|
33
|
-
* @param {object} [ontology] - resolved modeling language (defaults to
|
|
33
|
+
* @param {object} [ontology] - resolved modeling language (defaults to ArchiMate 3.2)
|
|
34
34
|
*/
|
|
35
35
|
function validateGraphSemantics(document, errors, ontology) {
|
|
36
36
|
if (!document || typeof document !== 'object') {
|
|
@@ -259,8 +259,105 @@ function validateViewElementLimits(document, errors, options = {}) {
|
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
// Per-element-type attribute contract (issue #3): required attributes, a
|
|
263
|
+
// controlled vocabulary per attribute (enumByAttr) and uniqueness per attribute.
|
|
264
|
+
// Supplied by the active schema bundle's `attributesByElementType`; absent => no-op
|
|
265
|
+
// (fully backward compatible).
|
|
266
|
+
function attributeEntriesByName(element) {
|
|
267
|
+
const map = new Map();
|
|
268
|
+
const entries = Array.isArray(element && element.attributes) ? element.attributes : [];
|
|
269
|
+
for (const entry of entries) {
|
|
270
|
+
if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string' || entry.name === '') {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!map.has(entry.name)) {
|
|
274
|
+
map.set(entry.name, []);
|
|
275
|
+
}
|
|
276
|
+
map.get(entry.name).push(entry);
|
|
277
|
+
}
|
|
278
|
+
return map;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function attributeValueOf(entry) {
|
|
282
|
+
if (entry.value !== undefined && entry.value !== null) {
|
|
283
|
+
return entry.value;
|
|
284
|
+
}
|
|
285
|
+
if (entry.content !== undefined && entry.content !== null) {
|
|
286
|
+
return entry.content;
|
|
287
|
+
}
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function validateAttributeContracts(document, errors, ontology) {
|
|
292
|
+
const language = resolveOntology(ontology);
|
|
293
|
+
const contracts = language.attributesByElementType;
|
|
294
|
+
if (!contracts || typeof contracts !== 'object') {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const uniqueTrackers = new Map();
|
|
298
|
+
for (const type of Object.keys(contracts)) {
|
|
299
|
+
uniqueTrackers.set(type, new Map());
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for (const element of document.elements || []) {
|
|
303
|
+
if (!element || typeof element !== 'object') {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const contract = contracts[element.type];
|
|
307
|
+
if (!contract || typeof contract !== 'object') {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const byName = attributeEntriesByName(element);
|
|
311
|
+
|
|
312
|
+
for (const name of Array.isArray(contract.required) ? contract.required : []) {
|
|
313
|
+
const entries = byName.get(name);
|
|
314
|
+
const present = Array.isArray(entries) && entries.some((entry) => {
|
|
315
|
+
const value = attributeValueOf(entry);
|
|
316
|
+
return value !== undefined && value !== '';
|
|
317
|
+
});
|
|
318
|
+
if (!present) {
|
|
319
|
+
errors.push(`element '${element.id}' (type '${element.type}') is missing required attribute '${name}'`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const enumByAttr = contract.enumByAttr && typeof contract.enumByAttr === 'object' ? contract.enumByAttr : {};
|
|
324
|
+
for (const [attr, allowed] of Object.entries(enumByAttr)) {
|
|
325
|
+
if (!Array.isArray(allowed)) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
for (const entry of byName.get(attr) || []) {
|
|
329
|
+
const value = attributeValueOf(entry);
|
|
330
|
+
if (value === undefined) {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const matches = allowed.some((option) => option === value || JSON.stringify(option) === JSON.stringify(value));
|
|
334
|
+
if (!matches) {
|
|
335
|
+
errors.push(`element '${element.id}' (type '${element.type}') attribute '${attr}' value ${JSON.stringify(value)} is not one of: ${allowed.map((option) => JSON.stringify(option)).join(', ')}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
for (const attr of Array.isArray(contract.unique) ? contract.unique : []) {
|
|
341
|
+
const tracker = uniqueTrackers.get(element.type);
|
|
342
|
+
for (const entry of byName.get(attr) || []) {
|
|
343
|
+
const value = attributeValueOf(entry);
|
|
344
|
+
if (value === undefined) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const key = JSON.stringify(value);
|
|
348
|
+
if (tracker.has(key) && tracker.get(key) !== element.id) {
|
|
349
|
+
errors.push(`element '${element.id}' (type '${element.type}') attribute '${attr}' value ${key} duplicates element '${tracker.get(key)}'`);
|
|
350
|
+
} else if (!tracker.has(key)) {
|
|
351
|
+
tracker.set(key, element.id);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
262
358
|
module.exports = {
|
|
263
359
|
validateGraphSemantics,
|
|
264
360
|
validateArchiMateEndpointMatrix,
|
|
265
361
|
validateViewElementLimits,
|
|
362
|
+
validateAttributeContracts,
|
|
266
363
|
};
|