agentlas 1.0.27 → 1.0.29
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 +33 -0
- package/README.md +5 -2
- package/engine/agentlas-i18n.cjs +4 -4
- package/engine/agentlas-input.cjs +0 -2
- package/engine/agentlas-onboard.cjs +20 -0
- package/engine/agentlas-workforce.cjs +41 -8
- package/engine/agentlas.cjs +7 -1
- package/engine/commands/billing.cjs +3 -0
- package/engine/commands/creds.cjs +49 -1
- package/engine/commands/doctor.cjs +46 -3
- package/engine/commands/graph.cjs +1150 -0
- package/engine/commands/help.cjs +82 -6
- package/engine/commands/hep-cloud.cjs +9 -23
- package/engine/commands/hep-hub.cjs +9 -22
- package/engine/commands/hep-local.cjs +9 -24
- package/engine/commands/hep-network.cjs +9 -35
- package/engine/commands/index.cjs +49 -25
- package/engine/commands/mcp.cjs +6 -2
- package/engine/commands/native.cjs +18 -2
- package/engine/commands/plugin.cjs +22 -0
- package/engine/commands/roles.cjs +202 -0
- package/engine/commands/workforce.cjs +63 -12
- package/engine/graph/ask-model.cjs +131 -0
- package/engine/graph/interview.cjs +875 -0
- package/engine/graph/layout.cjs +137 -0
- package/engine/graph/package.cjs +223 -0
- package/engine/graph/vocabulary.generated.cjs +30 -0
- package/engine/hephaestus/local-core.cjs +159 -0
- package/engine/hephaestus/runtime.cjs +43 -9
- package/engine/runtimes/auth-evidence.cjs +78 -0
- package/engine/sessions/prompt.cjs +16 -0
- package/engine/sessions/session.cjs +9 -0
- package/engine/tools/access-notice.cjs +86 -0
- package/engine/ui/palette.cjs +6 -3
- package/engine/ui/repl.cjs +8 -2
- package/engine/workforce/deps.cjs +13 -0
- package/engine/workforce/local-core-transport.cjs +298 -0
- package/package.json +4 -3
- package/engine/commands/legacy-network.cjs +0 -29
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// 결정적 좌→우 그래프 배치. **데스크탑 `shared/graph-layout.ts`의 미러다.**
|
|
2
|
+
//
|
|
3
|
+
// 왜 사본이 있나: 터미널은 독립 패키지라 데스크탑 소스를 임포트할 수 없다. 이 저장소는
|
|
4
|
+
// 같은 이유로 `interview.cjs`(청사진 컴파일러)도 이미 미러로 두고, **패리티 게이트**가
|
|
5
|
+
// 두 벌이 같은 판단을 하는지 대조한다(`test/graph-interview-parity.cjs`). 배치도 같은 규율:
|
|
6
|
+
// 여기서 바꾸면 데스크탑도 바꾸고, 게이트가 그 둘을 붙잡는다.
|
|
7
|
+
//
|
|
8
|
+
// ★규칙이 갈라지면 같은 그래프가 표면마다 다르게 그려지고, 사용자는 자기 자동화를
|
|
9
|
+
// 어느 쪽이 맞는지 알 수 없게 된다.
|
|
10
|
+
"use strict";
|
|
11
|
+
|
|
12
|
+
const COL_W = 280;
|
|
13
|
+
const ROW_H = 120;
|
|
14
|
+
const NODE_ORIGIN_X = 0;
|
|
15
|
+
const NODE_ORIGIN_Y = 120;
|
|
16
|
+
|
|
17
|
+
/** 노드 카드의 실측 크기 — 이보다 가까우면 화면에서 겹쳐 글자를 못 읽는다. */
|
|
18
|
+
const NODE_W = 230;
|
|
19
|
+
const NODE_H = 90;
|
|
20
|
+
|
|
21
|
+
/** 위상 순서로 각 노드에 컬럼 depth를 매긴다(진입 엣지 없는 노드 = depth 0). */
|
|
22
|
+
function computeDepths(graph) {
|
|
23
|
+
const indeg = new Map();
|
|
24
|
+
const adj = new Map();
|
|
25
|
+
for (const n of graph.nodes) {
|
|
26
|
+
indeg.set(n.id, 0);
|
|
27
|
+
adj.set(n.id, []);
|
|
28
|
+
}
|
|
29
|
+
for (const e of graph.edges || []) {
|
|
30
|
+
if (!indeg.has(e.target) || !adj.has(e.source)) continue; // dangling edge 방어
|
|
31
|
+
indeg.set(e.target, (indeg.get(e.target) || 0) + 1);
|
|
32
|
+
adj.get(e.source).push(e.target);
|
|
33
|
+
}
|
|
34
|
+
const depth = new Map();
|
|
35
|
+
const frontier = graph.nodes.filter((n) => (indeg.get(n.id) || 0) === 0).map((n) => n.id);
|
|
36
|
+
for (const id of frontier) depth.set(id, 0);
|
|
37
|
+
const remaining = new Map(indeg);
|
|
38
|
+
const queue = [...frontier];
|
|
39
|
+
while (queue.length) {
|
|
40
|
+
const id = queue.shift();
|
|
41
|
+
const d = depth.get(id) || 0;
|
|
42
|
+
for (const next of adj.get(id) || []) {
|
|
43
|
+
depth.set(next, Math.max(depth.get(next) || 0, d + 1));
|
|
44
|
+
const left = (remaining.get(next) == null ? 1 : remaining.get(next)) - 1;
|
|
45
|
+
remaining.set(next, left);
|
|
46
|
+
if (left <= 0) queue.push(next);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// 사이클(반복 그래프) 등으로 depth 미할당된 노드는 순서 인덱스로 폴백.
|
|
50
|
+
graph.nodes.forEach((n, i) => {
|
|
51
|
+
if (!depth.has(n.id)) depth.set(n.id, i);
|
|
52
|
+
});
|
|
53
|
+
return depth;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 한 띠(band)에 넣을 컬럼 수 — 데스크탑 columnsPerBand와 같은 식. */
|
|
57
|
+
function columnsPerBand(totalCols) {
|
|
58
|
+
if (totalCols <= 4) return totalCols;
|
|
59
|
+
return Math.max(4, Math.ceil(Math.sqrt(totalCols * 2)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 그래프를 결정적 사행(蛇行) 배치로 재배치한 새 노드 배열.
|
|
64
|
+
* 긴 사슬은 띠로 접어 좌→우 / 우→좌로 번갈아 흐르고(뱀 모양), 같은 컬럼은 세로 분산.
|
|
65
|
+
* (일직선은 14단계에서 폭 4,000px가 되어 아무도 못 읽었다 — 실측 항목 3.)
|
|
66
|
+
*/
|
|
67
|
+
function layoutGraph(graph) {
|
|
68
|
+
const depth = computeDepths(graph);
|
|
69
|
+
const byCol = new Map();
|
|
70
|
+
for (const n of graph.nodes) {
|
|
71
|
+
const d = depth.get(n.id) || 0;
|
|
72
|
+
if (!byCol.has(d)) byCol.set(d, []);
|
|
73
|
+
byCol.get(d).push(n);
|
|
74
|
+
}
|
|
75
|
+
const cols = [...byCol.keys()].sort((a, b) => a - b);
|
|
76
|
+
const colOrder = new Map(cols.map((c, i) => [c, i]));
|
|
77
|
+
const totalCols = cols.length;
|
|
78
|
+
const perBand = columnsPerBand(totalCols);
|
|
79
|
+
|
|
80
|
+
const bandCount = Math.ceil(totalCols / perBand);
|
|
81
|
+
const bandHeight = [];
|
|
82
|
+
for (let b = 0; b < bandCount; b += 1) {
|
|
83
|
+
let maxRows = 1;
|
|
84
|
+
for (const [c, i] of colOrder) {
|
|
85
|
+
if (Math.floor(i / perBand) === b) maxRows = Math.max(maxRows, byCol.get(c).length);
|
|
86
|
+
}
|
|
87
|
+
bandHeight.push(maxRows * ROW_H + ROW_H);
|
|
88
|
+
}
|
|
89
|
+
const bandTop = [];
|
|
90
|
+
let acc = 0;
|
|
91
|
+
for (let b = 0; b < bandCount; b += 1) { bandTop.push(acc); acc += bandHeight[b]; }
|
|
92
|
+
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const [col, nodes] of byCol) {
|
|
95
|
+
const i = colOrder.get(col) || 0;
|
|
96
|
+
const band = Math.floor(i / perBand);
|
|
97
|
+
let c = i % perBand;
|
|
98
|
+
if (band % 2 === 1) c = perBand - 1 - c;
|
|
99
|
+
const count = nodes.length;
|
|
100
|
+
nodes.forEach((n, row) => {
|
|
101
|
+
const offset = (row - (count - 1) / 2) * ROW_H;
|
|
102
|
+
out.push({
|
|
103
|
+
...n,
|
|
104
|
+
position: {
|
|
105
|
+
x: NODE_ORIGIN_X + c * COL_W,
|
|
106
|
+
y: NODE_ORIGIN_Y + bandTop[band] + (bandHeight[band] - ROW_H) / 2 + offset,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
// 원래 순서 보존(렌더러 key 안정).
|
|
112
|
+
const orderIndex = new Map(graph.nodes.map((n, i) => [n.id, i]));
|
|
113
|
+
out.sort((a, b) => (orderIndex.get(a.id) || 0) - (orderIndex.get(b.id) || 0));
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 재배치가 필요한가 — **실제로 겹치는가**로 판단한다.
|
|
119
|
+
* ★예전에는 좌표가 완전히 같을 때만 재배치해서, 검증을 +70·갈림길을 +140만 띄운
|
|
120
|
+
* 그래프(노드 폭 230)가 "다른 좌표"라 통과했고 카드가 서로 가렸다(실측 2026-08-05).
|
|
121
|
+
*/
|
|
122
|
+
function needsLayout(graph) {
|
|
123
|
+
if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length <= 1) return false;
|
|
124
|
+
const placed = graph.nodes.map((n) => ({
|
|
125
|
+
x: Math.round((n.position && n.position.x) || 0),
|
|
126
|
+
y: Math.round((n.position && n.position.y) || 0),
|
|
127
|
+
}));
|
|
128
|
+
for (let i = 0; i < placed.length; i += 1) {
|
|
129
|
+
for (let j = i + 1; j < placed.length; j += 1) {
|
|
130
|
+
if (Math.abs(placed[i].x - placed[j].x) < NODE_W
|
|
131
|
+
&& Math.abs(placed[i].y - placed[j].y) < NODE_H) return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { layoutGraph, needsLayout, columnsPerBand, COL_W, ROW_H, NODE_W, NODE_H };
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* .agentgraph 패키징 — 그래프를 남에게 줄 수 있는 형태로 만든다.
|
|
4
|
+
*
|
|
5
|
+
* 핵심 계약 두 줄:
|
|
6
|
+
* 1) 지울 수 없는 비밀이 하나라도 남으면 **내보내지 않는다**. 몰래 지우고 통과시키면
|
|
7
|
+
* 사용자는 자기 키가 빠진 줄 알고 공유하게 된다.
|
|
8
|
+
* 2) 모델 고정은 유통될 수 없다. 받는 사람의 기본 모델로 돌아야 하므로 등급 힌트로 바꾼다.
|
|
9
|
+
*
|
|
10
|
+
* 여기서 만드는 건 파일 하나(JSON)다. 허브 업로드는 별도 표면이며, 이 모듈은
|
|
11
|
+
* "무엇을 지웠고 무엇을 채워야 하는지"까지 파일 안에 적어 둔다.
|
|
12
|
+
*/
|
|
13
|
+
const crypto = require("node:crypto");
|
|
14
|
+
|
|
15
|
+
const SCHEMA_VERSION = "agentgraph/1.0";
|
|
16
|
+
|
|
17
|
+
/** 값이 자격증명처럼 보이는가 — 형태 판정만 한다(의미 판정 아님). */
|
|
18
|
+
const SECRET_VALUE_PATTERNS = [
|
|
19
|
+
/\bsk-[A-Za-z0-9_-]{16,}/,
|
|
20
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
21
|
+
/\bghp_[A-Za-z0-9]{20,}/,
|
|
22
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}/,
|
|
23
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
24
|
+
/\bBearer\s+[A-Za-z0-9._-]{20,}/i,
|
|
25
|
+
/\beyJ[A-Za-z0-9._-]{30,}/,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/** 키 이름이 비밀을 담기로 되어 있는가. 값이 비어 있어도 템플릿 대상이다. */
|
|
29
|
+
const SECRET_KEY_RE = /(token|secret|password|passwd|apikey|api_key|credential|private_key|access_key)/i;
|
|
30
|
+
|
|
31
|
+
/** 로컬 사용자 경로 — 남의 기계에서 의미가 없고, 계정명이 그대로 드러난다. */
|
|
32
|
+
const PERSONAL_PATH_RE = /(\/Users\/[^/\s"']+|\/home\/[^/\s"']+|C:\\Users\\[^\\\s"']+)/g;
|
|
33
|
+
|
|
34
|
+
function vaultKeyFor(nodeId, key) {
|
|
35
|
+
return `${String(key).replace(/[^A-Za-z0-9]+/g, "_").toUpperCase()}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function looksSecretValue(value) {
|
|
39
|
+
return typeof value === "string" && SECRET_VALUE_PATTERNS.some((re) => re.test(value));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 노드 설정을 훑어 비밀·모델핀·개인 경로를 처리한다.
|
|
44
|
+
* 반환: { config, findings, vaultTemplate, blockers }
|
|
45
|
+
*/
|
|
46
|
+
function scrubNodeConfig(nodeId, config) {
|
|
47
|
+
const out = {};
|
|
48
|
+
const findings = [];
|
|
49
|
+
const vaultTemplate = [];
|
|
50
|
+
const blockers = [];
|
|
51
|
+
for (const [key, value] of Object.entries(config || {})) {
|
|
52
|
+
// 1) 비밀로 선언된 칸 — 값 유무와 무관하게 금고 변수로 바꾼다.
|
|
53
|
+
if (SECRET_KEY_RE.test(key)) {
|
|
54
|
+
const vaultKey = vaultKeyFor(nodeId, key);
|
|
55
|
+
out[key] = `$\{vault.${vaultKey}}`;
|
|
56
|
+
vaultTemplate.push({ key: vaultKey, kind: "secret", requiredBy: [nodeId], sourceField: key });
|
|
57
|
+
findings.push({ rule: "secret-field", nodeId, field: key, action: `templated:${vaultKey}` });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// 2) 비밀처럼 생긴 값이 엉뚱한 칸에 있으면 — 자동 치환하지 않고 막는다.
|
|
61
|
+
// 이름 없는 칸의 비밀은 무엇을 채워야 하는지 우리가 알 수 없다.
|
|
62
|
+
if (looksSecretValue(value)) {
|
|
63
|
+
blockers.push({
|
|
64
|
+
nodeId,
|
|
65
|
+
field: key,
|
|
66
|
+
reason: `"${key}" 값이 자격증명처럼 보입니다. 어떤 키인지 알 수 없어 자동으로 빈칸 처리할 수 없습니다.`,
|
|
67
|
+
nextAction: `이 값을 금고 변수로 바꾼 뒤(예: $\{vault.MY_TOKEN}) 다시 내보내세요.`,
|
|
68
|
+
});
|
|
69
|
+
out[key] = value;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// 3) 모델 고정 — 받는 사람 기계엔 그 모델이 없다. 등급 힌트로 바꾼다.
|
|
73
|
+
if (key === "model" && typeof value === "string" && value) {
|
|
74
|
+
out.tierHint = "standard";
|
|
75
|
+
findings.push({ rule: "model-pin", nodeId, field: key, action: "replaced:runner-primary" });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// 4) 개인 경로 — 계정명이 그대로 드러난다.
|
|
79
|
+
if (typeof value === "string" && PERSONAL_PATH_RE.test(value)) {
|
|
80
|
+
out[key] = value.replace(PERSONAL_PATH_RE, "<사용자 폴더>");
|
|
81
|
+
findings.push({ rule: "personal-path", nodeId, field: key, action: "removed" });
|
|
82
|
+
PERSONAL_PATH_RE.lastIndex = 0;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
out[key] = value;
|
|
86
|
+
}
|
|
87
|
+
return { config: out, findings, vaultTemplate, blockers };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function digestOf(value) {
|
|
91
|
+
return `sha256:${crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 그래프 하나를 .agentgraph 패키지로 만든다.
|
|
96
|
+
* 막을 사유가 있으면 { blocked: true, blockers } 를 돌려주고 패키지를 만들지 않는다.
|
|
97
|
+
*/
|
|
98
|
+
function buildPackage(input) {
|
|
99
|
+
const { automation, graph } = input;
|
|
100
|
+
const findings = [];
|
|
101
|
+
const vaultTemplate = [];
|
|
102
|
+
const blockers = [];
|
|
103
|
+
const nodes = [];
|
|
104
|
+
const dependencies = { agents: [], mcp: [], subGraphs: [] };
|
|
105
|
+
|
|
106
|
+
for (const node of graph.nodes || []) {
|
|
107
|
+
const scrubbed = scrubNodeConfig(node.id, node.config);
|
|
108
|
+
findings.push(...scrubbed.findings);
|
|
109
|
+
vaultTemplate.push(...scrubbed.vaultTemplate);
|
|
110
|
+
blockers.push(...scrubbed.blockers);
|
|
111
|
+
nodes.push({ ...node, config: scrubbed.config });
|
|
112
|
+
|
|
113
|
+
// 에이전트 참조는 핀으로 남긴다 — 받는 사람이 무엇을 빌려야 하는지 알아야 한다.
|
|
114
|
+
// 노드가 ref를 선언하지 않으면 자동화의 대상 에이전트를 상속한다(제품의 실제 동작).
|
|
115
|
+
// 그 경우를 빼면 패키지가 "채울 것 없음"이라고 거짓말한다.
|
|
116
|
+
const isAgentish = node.type === "agent" || node.type === "action" || node.type === "output";
|
|
117
|
+
const ref = typeof node.config?.ref === "string" && node.config.ref ? node.config.ref : null;
|
|
118
|
+
const inheritedSlug = automation.target_id || null;
|
|
119
|
+
const slug = ref || (isAgentish && node.type === "agent" ? inheritedSlug : null);
|
|
120
|
+
if (isAgentish && slug) {
|
|
121
|
+
const source = (ref ? node.config?.targetType : automation.target_type) === "hub" ? "hub" : "local";
|
|
122
|
+
if (!dependencies.agents.some((dep) => dep.slug === slug)) {
|
|
123
|
+
dependencies.agents.push({
|
|
124
|
+
nodeId: node.id,
|
|
125
|
+
slug,
|
|
126
|
+
source,
|
|
127
|
+
...(ref ? {} : { inheritedFromAutomation: true }),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const server = node.config?.mcpServer;
|
|
132
|
+
if (typeof server === "string" && server && !dependencies.mcp.some((m) => m.serverSlug === server)) {
|
|
133
|
+
dependencies.mcp.push({ serverSlug: server, requiredBy: [node.id] });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (blockers.length > 0) {
|
|
138
|
+
return { blocked: true, blockers, findings };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const mutationNodes = nodes
|
|
142
|
+
.filter((n) => n.config?.effect === "mutation")
|
|
143
|
+
.map((n) => ({ nodeId: n.id, label: n.label || n.id }));
|
|
144
|
+
|
|
145
|
+
const scrubbedGraph = { version: graph.version ?? 1, nodes, edges: graph.edges || [] };
|
|
146
|
+
if (graph.budget) scrubbedGraph.budget = graph.budget;
|
|
147
|
+
|
|
148
|
+
const manifest = {
|
|
149
|
+
schemaVersion: SCHEMA_VERSION,
|
|
150
|
+
slug: String(automation.name || "graph").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
|
|
151
|
+
name: automation.name,
|
|
152
|
+
version: input.version || "1.0.0",
|
|
153
|
+
exportedAt: new Date().toISOString(),
|
|
154
|
+
trigger: {
|
|
155
|
+
kind: automation.trigger_type && automation.trigger_type !== "schedule" ? "input" : "cron",
|
|
156
|
+
schedule: automation.schedule ?? null,
|
|
157
|
+
},
|
|
158
|
+
dependencies,
|
|
159
|
+
vaultTemplate,
|
|
160
|
+
modelPolicy: { binding: "runner-primary" },
|
|
161
|
+
// 받는 사람이 설치 전에 알아야 하는 것 — 무엇이 바깥으로 나가는가.
|
|
162
|
+
permissionsSummary: {
|
|
163
|
+
mutationNodes,
|
|
164
|
+
leasedAgents: dependencies.agents.filter((d) => d.source === "hub").map((d) => d.slug),
|
|
165
|
+
},
|
|
166
|
+
scrubReport: { rulesVersion: "scrub/1.0", scrubbedAt: new Date().toISOString(), findings },
|
|
167
|
+
};
|
|
168
|
+
manifest.integrity = { graphDigest: digestOf(scrubbedGraph), manifestDigest: null };
|
|
169
|
+
manifest.integrity.manifestDigest = digestOf({ ...manifest, integrity: { graphDigest: manifest.integrity.graphDigest, manifestDigest: null } });
|
|
170
|
+
|
|
171
|
+
return { blocked: false, blockers: [], findings, package: { manifest, graph: scrubbedGraph } };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 패키지를 받았을 때 실행 전에 채워야 하는 것들.
|
|
176
|
+
* "설치했으니 이제 돌아간다"가 아니라 "무엇이 비어 있는가"를 먼저 말한다.
|
|
177
|
+
*/
|
|
178
|
+
function bindingChecklist(pkg) {
|
|
179
|
+
const manifest = pkg?.manifest || {};
|
|
180
|
+
const items = [];
|
|
181
|
+
for (const entry of manifest.vaultTemplate || []) {
|
|
182
|
+
items.push({
|
|
183
|
+
kind: "vault-key",
|
|
184
|
+
key: entry.key,
|
|
185
|
+
requiredBy: entry.requiredBy || [],
|
|
186
|
+
done: false,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
for (const dep of manifest.dependencies?.agents || []) {
|
|
190
|
+
items.push({ kind: "agent", slug: dep.slug, source: dep.source, nodeId: dep.nodeId, done: false });
|
|
191
|
+
}
|
|
192
|
+
for (const dep of manifest.dependencies?.mcp || []) {
|
|
193
|
+
items.push({ kind: "mcp-server", serverSlug: dep.serverSlug, done: false });
|
|
194
|
+
}
|
|
195
|
+
return items;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function verifyPackage(pkg) {
|
|
199
|
+
const problems = [];
|
|
200
|
+
if (!pkg || typeof pkg !== "object") return ["패키지 형식이 아닙니다."];
|
|
201
|
+
const { manifest, graph } = pkg;
|
|
202
|
+
if (!manifest || manifest.schemaVersion !== SCHEMA_VERSION) {
|
|
203
|
+
problems.push(`이 버전이 읽을 수 없는 패키지 형식입니다(${manifest?.schemaVersion ?? "형식 없음"}).`);
|
|
204
|
+
}
|
|
205
|
+
if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) {
|
|
206
|
+
problems.push("그래프에 단계가 없습니다.");
|
|
207
|
+
}
|
|
208
|
+
if (manifest?.integrity?.graphDigest && graph) {
|
|
209
|
+
if (digestOf(graph) !== manifest.integrity.graphDigest) {
|
|
210
|
+
problems.push("그래프 내용이 매니페스트 지문과 다릅니다(전송 중 변형되었을 수 있습니다).");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return problems;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
SCHEMA_VERSION,
|
|
218
|
+
buildPackage,
|
|
219
|
+
bindingChecklist,
|
|
220
|
+
verifyPackage,
|
|
221
|
+
scrubNodeConfig,
|
|
222
|
+
digestOf,
|
|
223
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// ⚠️ 생성된 파일입니다. 손으로 고치지 마세요.
|
|
2
|
+
// 정본: agentlas_desktop/shared/graph-registry/*.json
|
|
3
|
+
// 생성: (agentlas_desktop) node scripts/gen-graph-registry.cjs
|
|
4
|
+
//
|
|
5
|
+
// 터미널은 데스크탑과 같은 DB를 읽지만 스키마 판이 뒤따라온다. 모르는 값을 만나는 것은
|
|
6
|
+
// 고장이 아니라 정상이며, 그때 **그 항목만** 강등하고 나머지는 정상 처리한다.
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const GRAPH_WIRE = "graph/1";
|
|
10
|
+
const GRAPH_ERROR_CODES = ["APPROVAL_REJECTED","APPROVAL_REQUIRED","APPROVAL_TIMED_OUT","ARCHITECT_NO_CHANGE","ARCHITECT_NO_REQUEST","ARCHITECT_OUTPUT_MALFORMED","ARCHITECT_OUTPUT_TOO_LARGE","ARCHITECT_OUTPUT_UNREADABLE","ARCHITECT_UNAVAILABLE","AUTOMATION_NOT_CONNECTED","BUDGET_EXHAUSTED","CODE_DEPENDENCY_MISSING","CODE_NODE_EMPTY","CODE_STEP_FAILED","CREATE_INPUT_INVALID","EDGE_CONDITION_UNRESOLVED","EVAL_INCOMPLETE","EVAL_STUCK","EVAL_UNAVAILABLE","INTERVIEW_MODEL_UNAVAILABLE","INTERVIEW_OUTPUT_UNREADABLE","INTERVIEW_REPEATED_QUESTIONS","INTERVIEW_SELF_CORRECTION_EXHAUSTED","INTERVIEW_STATE_INVALID","LOOP_BOUND_INVALID","LOOP_BOUND_UNDECLARED","LOOP_LIMIT_REACHED","LOOP_WITHOUT_EXIT","MUTATION_UNVERIFIED","NODE_FAILED","NODE_INPUT_MISSING","NODE_NEVER_REACHED","NODE_NO_RESULT","NODE_TIMEOUT","NODE_TYPE_UNSUPPORTED","NO_MATCHING_EDGE","OUTPUT_NODE_EMPTY","PATCH_CODE_EMPTY","PATCH_EDGE_CONFLICT","PATCH_EDGE_DANGLING","PATCH_EDGE_HANDLE_MISSING","PATCH_EDGE_MISSING","PATCH_EMPTY","PATCH_LOOP_BOUND_MISSING","PATCH_NODE_CONFLICT","PATCH_NODE_MISSING","PATCH_NO_GRAPH","PATCH_OP_UNKNOWN","REDUCER_MERGE_CONFLICT","REDUCER_WRITE_CONFLICT","RESUME_CONFLICT","RUN_REQUEST_DISABLED","RUN_REQUEST_INPUT_REQUIRED","RUN_REQUEST_NOT_FOUND","RUN_REQUEST_QUEUE_UNAVAILABLE","RUN_REQUEST_REF_AMBIGUOUS","RUN_REQUEST_REF_MISSING","SUBGRAPH_DEPTH_EXCEEDED","SUBGRAPH_FAILED","SUBGRAPH_NOT_FOUND","SUBGRAPH_NO_RESULT","SUBGRAPH_SELF_CALL","SWAP_CAPABILITY_MISMATCH","SWAP_HUB_RELEASE_UNPINNED","SWAP_NODE_NOT_FOUND","SWAP_NOT_AGENT_NODE","SWAP_NO_MATCH","SWAP_UNKNOWN_PROVIDER","TOOL_BROKER_CALL_UNREADABLE","TOOL_BROKER_MUTATION_IN_SIMULATION","TOOL_BROKER_PLAN_UNREADABLE","TOOL_BROKER_TOOL_NOT_DECLARED","TOOL_NODE_UNATTACHED","TOOL_NODE_UNCONFIGURED","TRANSFORM_MODE_UNKNOWN","TRANSFORM_NODE_UNCONFIGURED"];
|
|
11
|
+
const GRAPH_JOURNAL_KINDS = ["blob_externalized","node_failed","node_intent","node_reserved","node_retry","node_routed","node_settled","resumed","run_completed","run_created","run_failed","run_validated","suspended"];
|
|
12
|
+
const GRAPH_NODE_KINDS = ["action","agent","code","condition","eval","output","subgraph","tool","transform","trigger"];
|
|
13
|
+
const GRAPH_BLOCK_UI = {"trigger":{"section":"none","placeable":false,"placeReason":"그래프마다 하나뿐이고 처음 만들 때 함께 지어진다"},"agent":{"section":"inventory","placeable":true},"eval":{"section":"flow","placeable":true},"condition":{"section":"flow","placeable":true},"transform":{"section":"flow","placeable":true},"code":{"section":"flow","placeable":true},"tool":{"section":"inventory","placeable":true},"action":{"section":"actions","placeable":true},"output":{"section":"flow","placeable":true},"loop":{"section":"none","placeable":false,"placeReason":"노드가 아니라 되돌아가는 연결의 성질이다 — 엣지를 이어서 만든다"},"subgraph":{"section":"flow","placeable":true}};
|
|
14
|
+
|
|
15
|
+
/** 모르는 값은 원문을 보존한 채 항목 단위로 강등한다. 집합 폐기 금지. */
|
|
16
|
+
function readEnum(value, allowed) {
|
|
17
|
+
const text = typeof value === "string" ? value : String(value == null ? "" : value);
|
|
18
|
+
return allowed.includes(text) ? { known: text } : { unknown: text };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function degradedLabel(value, lang) {
|
|
22
|
+
if (value && typeof value === "object" && "known" in value) return value.known;
|
|
23
|
+
const raw = value && value.unknown ? value.unknown : "";
|
|
24
|
+
return lang === "en" ? `unknown (raw: ${raw})` : `알 수 없음 (원문: ${raw})`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
GRAPH_WIRE, GRAPH_ERROR_CODES, GRAPH_JOURNAL_KINDS, GRAPH_NODE_KINDS, GRAPH_BLOCK_UI,
|
|
29
|
+
readEnum, degradedLabel,
|
|
30
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* hephaestus/local-core — 로컬 Agentlas Core(`hephaestus mcp serve`)의 stdio MCP 클라이언트.
|
|
4
|
+
*
|
|
5
|
+
* 배경(2026-08-05, 감사 결함 E): 터미널의 편성 루프(agentlas-workforce.cjs)는 원격
|
|
6
|
+
* agentlas.cloud MCP만 쳤다. 그 서버는 공개 Hub 메뉴만 주므로 로컬·오너 Cloud를
|
|
7
|
+
* 포함한 연합(sourceScope network/local/cloud)은 물리적으로 불가능했고, hep-*
|
|
8
|
+
* 명령들은 외부 CLI 스텁(exit 3 host_llm_required)에 배선돼 있었다. 연합을
|
|
9
|
+
* 소유한 것은 로컬 Core다 — 실측(2026-08-05): `hephaestus mcp serve`가
|
|
10
|
+
* workforce.search_candidates/validate_selection/prepare_execution을 전부 노출하고,
|
|
11
|
+
* sourceScope:"local" 검색이 CandidateSet v1(reference-first)을 반환했다.
|
|
12
|
+
*
|
|
13
|
+
* 계약:
|
|
14
|
+
* - 프로세스 수명 = 클라이언트 수명. 명령이 끝나면 반드시 close(). 데몬화 금지.
|
|
15
|
+
* - 폴백 금지: Core 바이너리가 없으면 code="local_core_unavailable"로 정직하게
|
|
16
|
+
* 던진다 — 원격 Hub로 조용히 내려가면 스코프가 거짓이 된다.
|
|
17
|
+
* - 응답은 MCP content[0].text의 JSON. Core가 {status:"rejected", error:…}를 주면
|
|
18
|
+
* 그 코드를 그대로 던진다(거절 원문 보존 — 재작성 금지).
|
|
19
|
+
*/
|
|
20
|
+
const fs = require("node:fs");
|
|
21
|
+
const os = require("node:os");
|
|
22
|
+
const path = require("node:path");
|
|
23
|
+
const { spawn } = require("node:child_process");
|
|
24
|
+
|
|
25
|
+
function localCoreBin() {
|
|
26
|
+
const candidates = [
|
|
27
|
+
process.env.HEPHAESTUS_BIN,
|
|
28
|
+
path.join(os.homedir(), ".agentlas", "runtime", "current", "bin", "hephaestus"),
|
|
29
|
+
];
|
|
30
|
+
if (process.platform === "win32") return null;
|
|
31
|
+
for (const candidate of candidates) {
|
|
32
|
+
try {
|
|
33
|
+
if (candidate && fs.existsSync(candidate)) return candidate;
|
|
34
|
+
} catch { /* keep looking */ }
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function coreError(code, message) {
|
|
40
|
+
const error = new Error(message);
|
|
41
|
+
error.code = code;
|
|
42
|
+
return error;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* createLocalCoreClient({cwd, timeoutMs}) → { call(name,args), close() }
|
|
47
|
+
* call은 initialize를 게으르게 1회 수행한 뒤 tools/call을 보낸다.
|
|
48
|
+
*/
|
|
49
|
+
function createLocalCoreClient({ cwd, timeoutMs = 60_000 } = {}) {
|
|
50
|
+
const bin = localCoreBin();
|
|
51
|
+
if (!bin) {
|
|
52
|
+
throw coreError(
|
|
53
|
+
"local_core_unavailable",
|
|
54
|
+
"Agentlas-OS local core (hephaestus) is not installed — federated staffing needs it. Install Agentlas-OS or set HEPHAESTUS_BIN.",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const child = spawn(bin, ["mcp", "serve"], {
|
|
58
|
+
cwd: cwd || process.cwd(),
|
|
59
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
60
|
+
});
|
|
61
|
+
// 프로세스 수명 = 클라이언트 수명 계약의 백스톱. close() 를 못 부르고 부모가
|
|
62
|
+
// 죽는 경로(오류 → process.exit)에서 Core 가 고아로 남는다 — 실측: 프로브
|
|
63
|
+
// 세션에서 `hephaestus mcp serve` 17개 누적. exit 훅으로 반드시 걷는다.
|
|
64
|
+
const reap = () => { try { child.kill(); } catch { /* already gone */ } };
|
|
65
|
+
process.once("exit", reap);
|
|
66
|
+
let buffer = "";
|
|
67
|
+
let nextId = 0;
|
|
68
|
+
let initialized = null;
|
|
69
|
+
const pending = new Map();
|
|
70
|
+
let exited = false;
|
|
71
|
+
|
|
72
|
+
child.on("exit", () => {
|
|
73
|
+
exited = true;
|
|
74
|
+
for (const [, entry] of pending) {
|
|
75
|
+
entry.reject(coreError("local_core_exited", "the local core process exited before responding"));
|
|
76
|
+
}
|
|
77
|
+
pending.clear();
|
|
78
|
+
});
|
|
79
|
+
child.stdout.on("data", (chunk) => {
|
|
80
|
+
buffer += chunk;
|
|
81
|
+
let index;
|
|
82
|
+
while ((index = buffer.indexOf("\n")) >= 0) {
|
|
83
|
+
const line = buffer.slice(0, index);
|
|
84
|
+
buffer = buffer.slice(index + 1);
|
|
85
|
+
if (!line.trim()) continue;
|
|
86
|
+
let message;
|
|
87
|
+
try { message = JSON.parse(line); } catch { continue; }
|
|
88
|
+
const entry = message && message.id != null ? pending.get(message.id) : null;
|
|
89
|
+
if (entry) {
|
|
90
|
+
pending.delete(message.id);
|
|
91
|
+
entry.resolve(message);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const rpc = (method, params) => new Promise((resolve, reject) => {
|
|
97
|
+
if (exited) { reject(coreError("local_core_exited", "the local core process already exited")); return; }
|
|
98
|
+
const id = ++nextId;
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
pending.delete(id);
|
|
101
|
+
reject(coreError("local_core_timeout", `${method} did not respond within ${timeoutMs}ms`));
|
|
102
|
+
}, timeoutMs);
|
|
103
|
+
pending.set(id, {
|
|
104
|
+
resolve: (message) => { clearTimeout(timer); resolve(message); },
|
|
105
|
+
reject: (error) => { clearTimeout(timer); reject(error); },
|
|
106
|
+
});
|
|
107
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
async function ensureInitialized() {
|
|
111
|
+
if (!initialized) {
|
|
112
|
+
initialized = rpc("initialize", {
|
|
113
|
+
protocolVersion: "2024-11-05",
|
|
114
|
+
capabilities: {},
|
|
115
|
+
clientInfo: { name: "agentlas-terminal", version: "2" },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return initialized;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function call(name, args) {
|
|
122
|
+
await ensureInitialized();
|
|
123
|
+
const response = await rpc("tools/call", { name, arguments: args });
|
|
124
|
+
if (response.error) {
|
|
125
|
+
throw coreError("local_core_rpc_error", `${name}: ${response.error.message || JSON.stringify(response.error)}`);
|
|
126
|
+
}
|
|
127
|
+
const text = response?.result?.content?.[0]?.text;
|
|
128
|
+
if (typeof text !== "string") {
|
|
129
|
+
throw coreError("local_core_invalid_response", `${name} returned no text content`);
|
|
130
|
+
}
|
|
131
|
+
let parsed;
|
|
132
|
+
try { parsed = JSON.parse(text); } catch {
|
|
133
|
+
throw coreError("local_core_invalid_response", `${name} returned non-JSON content`);
|
|
134
|
+
}
|
|
135
|
+
// Core의 거절/오류는 원문 코드로 전파한다 — 사람 문장으로 바꾸면 기계 표식이
|
|
136
|
+
// 죽는다. (실측: 경계 거절은 status:"rejected", 계약 오류는 status:"error".)
|
|
137
|
+
if (parsed && (parsed.status === "rejected" || parsed.status === "error")) {
|
|
138
|
+
const code = typeof parsed.error === "string" ? parsed.error : `local_core_${parsed.status}`;
|
|
139
|
+
// 경계 거절의 issues(무엇이 어느 path에서 걸렸나)는 유일한 진단 근거다 —
|
|
140
|
+
// 코드만 전파하면 다음 사람이 다시 프로브부터 시작한다(2026-08-05 실측 2회).
|
|
141
|
+
const issues = Array.isArray(parsed?.boundary?.issues)
|
|
142
|
+
? parsed.boundary.issues.map((issue) => `${issue.code}@${issue.path}`).join(", ")
|
|
143
|
+
: "";
|
|
144
|
+
const error = coreError(code, `${name} ${parsed.status}: ${code}${issues ? ` [${issues}]` : ""}`);
|
|
145
|
+
error.detail = parsed;
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
return parsed;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function close() {
|
|
152
|
+
process.removeListener("exit", reap);
|
|
153
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { call, close };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { createLocalCoreClient, localCoreBin };
|
|
@@ -40,16 +40,12 @@ const USAGE = Object.freeze({
|
|
|
40
40
|
call: 'usage: agentlas call "<agent-slugs>" "<context>"',
|
|
41
41
|
connect: "usage: agentlas connect [status|telegram|help]",
|
|
42
42
|
hep: "usage: agentlas hep <subcommand> [args]",
|
|
43
|
-
// 소스
|
|
44
|
-
//
|
|
45
|
-
// usage
|
|
46
|
-
|
|
47
|
-
"hep-local": 'usage: agentlas hep-local "<request>" # registered Local agents only',
|
|
48
|
-
"hep-cloud": 'usage: agentlas hep-cloud "<request>" # owner Agent Cloud agents only',
|
|
49
|
-
"hep-hub": 'usage: agentlas hep-hub "<request>" # public Agentlas Hub agents only',
|
|
43
|
+
// 소스 스코프 스태핑(hep-network/hep-local/hep-cloud/hep-hub)과 legacy-network 의
|
|
44
|
+
// usage 는 2026-08-05 에 삭제했다. 네이티브가 편성을 수행하지 않고 exit 3 +
|
|
45
|
+
// host_llm_required 만 반환하므로, 여기에 usage 를 두면 "쓸 수 있는 명령"으로
|
|
46
|
+
// 읽힌다. 차단과 안내는 commands/index.cjs 의 HOST_LLM_ONLY_SURFACES 가 한다.
|
|
50
47
|
hephaestus: "usage: agentlas hephaestus <subcommand> [args]",
|
|
51
48
|
journal: "usage: agentlas journal <status|verify|repair|gate> --run-id <id> | --journal <path>",
|
|
52
|
-
"legacy-network": 'usage: agentlas legacy-network "<request>"',
|
|
53
49
|
netadmin: "usage: agentlas netadmin <init|status|reindex|bench|add-source> [args]",
|
|
54
50
|
research: "usage: agentlas research <status|gather|search|read|plan> [args]",
|
|
55
51
|
route: 'usage: agentlas route "<request>" [--json]',
|
|
@@ -644,15 +640,53 @@ function create(ctx, deps = {}) {
|
|
|
644
640
|
process.stderr.write("Hephaestus failed: Python 3.9+ was not found.\n");
|
|
645
641
|
return Promise.resolve(1);
|
|
646
642
|
}
|
|
643
|
+
// Core writes nothing until its final payload. `hep search` measured 29.7s
|
|
644
|
+
// of complete silence on every stream — indistinguishable from a hang, and
|
|
645
|
+
// the command that takes longest is the one a newcomer tries first.
|
|
646
|
+
// stdout carries machine-readable JSON, so the heartbeat goes to stderr
|
|
647
|
+
// only, and only when stderr is a terminal: piping or redirecting must stay
|
|
648
|
+
// byte-identical for anything parsing this.
|
|
649
|
+
const stopHeartbeat = helpOnly ? null : startQuietChildHeartbeat(args);
|
|
647
650
|
return new Promise((resolve) => {
|
|
648
651
|
child.on("error", (e) => {
|
|
652
|
+
if (stopHeartbeat) stopHeartbeat();
|
|
649
653
|
process.stderr.write(`Hephaestus failed: ${e.message}\n`);
|
|
650
654
|
resolve(1);
|
|
651
655
|
});
|
|
652
|
-
child.on("close", (code) =>
|
|
656
|
+
child.on("close", (code) => {
|
|
657
|
+
if (stopHeartbeat) stopHeartbeat();
|
|
658
|
+
resolve(code == null ? 0 : code);
|
|
659
|
+
});
|
|
653
660
|
});
|
|
654
661
|
}
|
|
655
662
|
|
|
663
|
+
/**
|
|
664
|
+
* Report elapsed time on stderr while a passthrough child stays quiet.
|
|
665
|
+
* Returns a stop function; returns null when there is nothing safe to write to.
|
|
666
|
+
*/
|
|
667
|
+
function startQuietChildHeartbeat(args) {
|
|
668
|
+
if (!process.stderr.isTTY) return null;
|
|
669
|
+
const label = args.filter((a) => !String(a).startsWith("-")).slice(0, 2).join(" ") || "hephaestus";
|
|
670
|
+
const started = Date.now();
|
|
671
|
+
let painted = false;
|
|
672
|
+
const paint = () => {
|
|
673
|
+
const secs = Math.round((Date.now() - started) / 1000);
|
|
674
|
+
process.stderr.write(`\r[2K${label} … ${secs}s`);
|
|
675
|
+
painted = true;
|
|
676
|
+
};
|
|
677
|
+
// Stay silent through the common fast case; only speak once it is slow
|
|
678
|
+
// enough that a user would start wondering.
|
|
679
|
+
const first = setTimeout(() => { paint(); }, 1500);
|
|
680
|
+
const timer = setInterval(paint, 1000);
|
|
681
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
682
|
+
if (typeof first.unref === "function") first.unref();
|
|
683
|
+
return () => {
|
|
684
|
+
clearTimeout(first);
|
|
685
|
+
clearInterval(timer);
|
|
686
|
+
if (painted) process.stderr.write("\r[2K");
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
656
690
|
const HEP_USAGE = [
|
|
657
691
|
"agentlas hep <hephaestus 서브커맨드…> — 엔진 전 기능 네이티브 패스스루",
|
|
658
692
|
"",
|