@intent-driven/runtime-local 0.2.0 → 0.4.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 +33 -11
- package/package.json +3 -2
- package/src/buildEffect.js +82 -0
- package/src/createRuntime.js +100 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Standalone Fold-runtime для локального quickstart'а. Принимает ontology.js (сгенерированный `idf full-bootstrap`), поднимает HTTP-сервер с эндпоинтами, которые ожидает `@intent-driven/mcp-server` для подключения к Claude Desktop / Cursor / Zed.
|
|
4
4
|
|
|
5
|
-
> **Статус:** v0.
|
|
5
|
+
> **Статус:** v0.3 — stateful Φ через `@intent-driven/engine`. Поддерживает `/api/health`, `/api/typemap`, `/api/agent/:domain/schema` (discovery), `/api/state/current`, `/api/agent/:domain/world` (fold снимок). agent/exec + approvals lifecycle — в следующих релизах (см. roadmap fold-15min).
|
|
6
6
|
|
|
7
7
|
## Зачем
|
|
8
8
|
|
|
@@ -12,7 +12,10 @@ Standalone Fold-runtime для локального quickstart'а. Приним
|
|
|
12
12
|
idf serve --ontology ./ontology.js # планируется в @intent-driven/cli
|
|
13
13
|
# или программно:
|
|
14
14
|
import { createRuntime } from '@intent-driven/runtime-local';
|
|
15
|
-
const runtime = createRuntime({
|
|
15
|
+
const runtime = createRuntime({
|
|
16
|
+
ontology: require('./ontology.js'),
|
|
17
|
+
seed: require('./seed.json'), // опц. — массив эффектов
|
|
18
|
+
});
|
|
16
19
|
await runtime.start();
|
|
17
20
|
```
|
|
18
21
|
|
|
@@ -22,7 +25,7 @@ await runtime.start();
|
|
|
22
25
|
npm install @intent-driven/runtime-local
|
|
23
26
|
```
|
|
24
27
|
|
|
25
|
-
Peer-зависимость: `@intent-driven/core@>=0.49.0` (
|
|
28
|
+
Peer-зависимость: `@intent-driven/core@>=0.49.0` (через `@intent-driven/engine`).
|
|
26
29
|
|
|
27
30
|
## API
|
|
28
31
|
|
|
@@ -33,33 +36,52 @@ Peer-зависимость: `@intent-driven/core@>=0.49.0` (нужен для
|
|
|
33
36
|
| Опция | Тип | Default | Описание |
|
|
34
37
|
|---|---|---|---|
|
|
35
38
|
| `ontology` | object | **required** | IDF ontology — `{ entities, intents, roles, projections, ... }`. |
|
|
39
|
+
| `seed` | array | `[]` | Pre-confirmed эффекты для начального состояния Φ. Каждый — `{id, intent_id, alpha, target, context, value?, status: 'confirmed', created_at}`. Primitive `value` (строки/числа) нормализуются в JSON-encoded автоматически. |
|
|
36
40
|
| `port` | number | `3001` | Порт. `0` = random (для тестов). |
|
|
37
41
|
| `host` | string | `'127.0.0.1'` | Bind-адрес. |
|
|
38
42
|
|
|
39
|
-
Возвращает `{ app, ontology, domain, start(), stop() }`.
|
|
43
|
+
Возвращает `{ app, ontology, domain, engine, persistence, start(), stop() }`. `engine` — instance `@intent-driven/engine`, для прямого использования (e.g., тесты).
|
|
40
44
|
|
|
41
45
|
### `runtime.start()` → `Promise<{url, port, host}>`
|
|
42
46
|
|
|
43
|
-
Поднимает Express-сервер. Возвращает actual URL/port (полезно при `port: 0`).
|
|
47
|
+
Поднимает Express-сервер. Загружает `seed` в persistence перед listen. Возвращает actual URL/port (полезно при `port: 0`).
|
|
44
48
|
|
|
45
49
|
### `runtime.stop()` → `Promise<void>`
|
|
46
50
|
|
|
47
51
|
Закрывает сервер.
|
|
48
52
|
|
|
49
|
-
## Эндпоинты
|
|
53
|
+
## Эндпоинты
|
|
50
54
|
|
|
51
55
|
| Метод | Путь | Назначение |
|
|
52
56
|
|---|---|---|
|
|
53
57
|
| `GET` | `/api/health` | Liveness check для `idf doctor` и MCP-server. Возвращает `{status, domain, runtime, version}`. |
|
|
54
58
|
| `GET` | `/api/typemap` | Сводка ontology: entities/intents/roles names. |
|
|
55
|
-
| `GET` | `/api/agent/:domain/schema` | Full ontology snapshot для MCP discovery. 503 если domain не
|
|
59
|
+
| `GET` | `/api/agent/:domain/schema` | Full ontology snapshot для MCP discovery. 503 если domain не совпадает. |
|
|
60
|
+
| `GET` | `/api/state/current` | Folded world snapshot — `{domain, world: {products: [...], orders: [...]}}`. |
|
|
61
|
+
| `GET` | `/api/agent/:domain/world` | Тот же world, что и `/api/state/current`. Этот endpoint вызывает `@intent-driven/mcp-server` для resource-чтения. 503 при mismatch domain. |
|
|
62
|
+
| `POST` | `/api/agent/:domain/exec/:intentId` | Ingest intent через `engine.submit`. Generic builder для add/replace/remove. Возвращает `200 {status:'confirmed', effect}` или `409 {status:'rejected', error:'invariant_violation', reason, cascaded}`. `400` для read-intents, `404` для unknown intent, `503` для wrong domain. |
|
|
63
|
+
|
|
64
|
+
### POST /api/agent/:domain/exec/:intentId
|
|
65
|
+
|
|
66
|
+
Тело запроса: `{ params: { ... } }`.
|
|
67
|
+
|
|
68
|
+
`buildEffect()` строит effect по generic-семантике (идентично host'овскому `genericBuildEffects`):
|
|
69
|
+
|
|
70
|
+
- `alpha=add` → `context = {...params, id: params.id || uuid}`, `value = null`
|
|
71
|
+
- `alpha=replace` target=`Entity.field` → `context = {id: params.id}`, `value = JSON.stringify(params[field])`
|
|
72
|
+
- `alpha=replace` target=`Entity` → `context = {...params}`, `value = null`
|
|
73
|
+
- `alpha=remove` → `context = {id: params.id || params['<entity>Id']}`, `value = null`
|
|
74
|
+
- `alpha=read` → 400 (читай через GET `/api/state/current`)
|
|
75
|
+
|
|
76
|
+
Лежит на `engine.submit`, поэтому invariants всех 6 kinds автоматически проверяются (role-capability / referential / transition / cardinality / aggregate / expression).
|
|
77
|
+
|
|
78
|
+
> **A1.3 (следующий PR):** intents с `lifecycle.requiresApproval` сейчас confirm'ятся напрямую. После добавления `lifecycleAugmenter` они будут создавать `ApprovalRequest` вместо ingest'а оригинального effect'а.
|
|
56
79
|
|
|
57
80
|
## Roadmap (следующие PR)
|
|
58
81
|
|
|
59
|
-
- v0.
|
|
60
|
-
- v0.
|
|
61
|
-
- v0.
|
|
62
|
-
- v0.5: SSE-стрим `/api/approvals/stream` для `idf approvals --watch`
|
|
82
|
+
- v0.5: `GET /api/approvals/pending` + `POST /api/approvals/:id/approve|reject` + ApprovalRequest auto-injection (lifecycleAugmenter)
|
|
83
|
+
- v0.6: `GET /api/state/at?t=ISO` + `/api/state/diff?from=&to=` — time-travel
|
|
84
|
+
- v0.7: SSE-стрим `/api/approvals/stream` для `idf approvals --watch`
|
|
63
85
|
|
|
64
86
|
## Лицензия
|
|
65
87
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intent-driven/runtime-local",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Standalone Fold-runtime для local quickstart: stateful Φ + agent/exec + approvals + state — то, что делает host idf/server, но как npm-пакет",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
],
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"cors": "^2.8.5",
|
|
16
|
-
"express": "^4.21.2"
|
|
16
|
+
"express": "^4.21.2",
|
|
17
|
+
"@intent-driven/engine": "0.4.0"
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
19
20
|
"vitest": "^4.1.0",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generic effect-builder для intents без custom builder'а — закрывает
|
|
5
|
+
* 80%+ кейсов после `idf full-bootstrap`. Идентичен host'овскому
|
|
6
|
+
* `genericBuildEffects` (server/schema/effectBuildersRegistry.cjs):
|
|
7
|
+
*
|
|
8
|
+
* alpha=add → ctx={...params, id: params.id || uuid}
|
|
9
|
+
* alpha=replace target='Entity.field' → ctx={id}, value=params[field] || params.value
|
|
10
|
+
* alpha=replace target='Entity' → ctx={...params}, value=null
|
|
11
|
+
* alpha=remove → ctx={id: params.id || params['<entity>Id']}
|
|
12
|
+
*
|
|
13
|
+
* Возвращает unsubmitted effect (без status/created_at — engine.submit
|
|
14
|
+
* проставит их сам).
|
|
15
|
+
*/
|
|
16
|
+
export function buildEffect({ intentId, intent, params = {}, viewerId = null }) {
|
|
17
|
+
if (!intent) {
|
|
18
|
+
throw new Error(`buildEffect: intent '${intentId}' not provided`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const alpha = intent.alpha;
|
|
22
|
+
if (alpha === "read") {
|
|
23
|
+
throw new Error(`buildEffect: read intent '${intentId}' is not executable`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const target = intent.target;
|
|
27
|
+
if (!target) {
|
|
28
|
+
throw new Error(`buildEffect: intent '${intentId}' has no target`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const id = randomUUID();
|
|
32
|
+
const baseEffect = {
|
|
33
|
+
id,
|
|
34
|
+
intent_id: intentId,
|
|
35
|
+
alpha,
|
|
36
|
+
target,
|
|
37
|
+
scope: intent.scope || "account",
|
|
38
|
+
};
|
|
39
|
+
if (viewerId) baseEffect.user_id = viewerId;
|
|
40
|
+
|
|
41
|
+
switch (alpha) {
|
|
42
|
+
case "add": {
|
|
43
|
+
return {
|
|
44
|
+
...baseEffect,
|
|
45
|
+
context: { ...params, id: params.id || id },
|
|
46
|
+
value: null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
case "replace": {
|
|
50
|
+
const segments = target.split(".");
|
|
51
|
+
if (segments.length > 1) {
|
|
52
|
+
const field = segments[segments.length - 1];
|
|
53
|
+
const raw = params[field] !== undefined ? params[field] : params.value;
|
|
54
|
+
// Engine validator JSON-парсит string values (host-convention для
|
|
55
|
+
// SQLite JSONB columns). Любое native JS value JSON-encode'ится.
|
|
56
|
+
const value = raw === undefined || raw === null ? null : JSON.stringify(raw);
|
|
57
|
+
return {
|
|
58
|
+
...baseEffect,
|
|
59
|
+
context: { id: params.id },
|
|
60
|
+
value,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
...baseEffect,
|
|
65
|
+
context: { ...params },
|
|
66
|
+
value: null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
case "remove": {
|
|
70
|
+
const entityName = target.split(".")[0];
|
|
71
|
+
const idKey = `${entityName.charAt(0).toLowerCase()}${entityName.slice(1)}Id`;
|
|
72
|
+
const entityId = params.id || params[idKey];
|
|
73
|
+
return {
|
|
74
|
+
...baseEffect,
|
|
75
|
+
context: { id: entityId },
|
|
76
|
+
value: null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
throw new Error(`buildEffect: unsupported alpha '${alpha}'`);
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/createRuntime.js
CHANGED
|
@@ -3,6 +3,21 @@ import cors from "cors";
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
|
+
import { createEngine, createInMemoryPersistence } from "@intent-driven/engine";
|
|
7
|
+
import { buildEffect } from "./buildEffect.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Engine validator JSON-парсит ef.value если оно string (host-convention для
|
|
11
|
+
* SQLite JSONB columns). Seed-эффекты от user-кода обычно содержат native
|
|
12
|
+
* JS values — нормализуем primitive strings обратно в JSON-encoded.
|
|
13
|
+
*/
|
|
14
|
+
function normalizeSeedEffect(ef) {
|
|
15
|
+
if (ef.value === undefined || ef.value === null) return { ...ef };
|
|
16
|
+
if (typeof ef.value === "string") {
|
|
17
|
+
return { ...ef, value: JSON.stringify(ef.value) };
|
|
18
|
+
}
|
|
19
|
+
return { ...ef };
|
|
20
|
+
}
|
|
6
21
|
|
|
7
22
|
const PKG_VERSION = (() => {
|
|
8
23
|
try {
|
|
@@ -15,7 +30,7 @@ const PKG_VERSION = (() => {
|
|
|
15
30
|
})();
|
|
16
31
|
|
|
17
32
|
export function createRuntime(opts = {}) {
|
|
18
|
-
const { ontology, port = 3001, host = "127.0.0.1" } = opts;
|
|
33
|
+
const { ontology, seed = [], port = 3001, host = "127.0.0.1" } = opts;
|
|
19
34
|
|
|
20
35
|
if (!ontology || typeof ontology !== "object") {
|
|
21
36
|
throw new Error("createRuntime: ontology is required");
|
|
@@ -23,6 +38,12 @@ export function createRuntime(opts = {}) {
|
|
|
23
38
|
|
|
24
39
|
const domain = ontology.name || "default";
|
|
25
40
|
|
|
41
|
+
const persistence = createInMemoryPersistence();
|
|
42
|
+
const engine = createEngine({
|
|
43
|
+
domain: { INTENTS: ontology.intents || {}, ONTOLOGY: ontology },
|
|
44
|
+
persistence,
|
|
45
|
+
});
|
|
46
|
+
|
|
26
47
|
const app = express();
|
|
27
48
|
app.use(cors());
|
|
28
49
|
app.use(express.json({ limit: "10mb" }));
|
|
@@ -61,17 +82,95 @@ export function createRuntime(opts = {}) {
|
|
|
61
82
|
});
|
|
62
83
|
});
|
|
63
84
|
|
|
85
|
+
app.get("/api/state/current", async (_req, res, next) => {
|
|
86
|
+
try {
|
|
87
|
+
const world = await engine.foldWorld();
|
|
88
|
+
res.json({ domain, world });
|
|
89
|
+
} catch (err) {
|
|
90
|
+
next(err);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
app.get("/api/agent/:domain/world", async (req, res, next) => {
|
|
95
|
+
if (req.params.domain !== domain) {
|
|
96
|
+
return res.status(503).json({
|
|
97
|
+
error: "ontology_unavailable",
|
|
98
|
+
domain: req.params.domain,
|
|
99
|
+
message: `Ontology for '${req.params.domain}' is not registered. Runtime serves '${domain}'.`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const world = await engine.foldWorld();
|
|
104
|
+
res.json({ domain, world });
|
|
105
|
+
} catch (err) {
|
|
106
|
+
next(err);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
app.post("/api/agent/:domain/exec/:intentId", async (req, res, next) => {
|
|
111
|
+
if (req.params.domain !== domain) {
|
|
112
|
+
return res.status(503).json({
|
|
113
|
+
error: "ontology_unavailable",
|
|
114
|
+
domain: req.params.domain,
|
|
115
|
+
message: `Ontology for '${req.params.domain}' is not registered. Runtime serves '${domain}'.`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const intentId = req.params.intentId;
|
|
119
|
+
const intent = (ontology.intents || {})[intentId];
|
|
120
|
+
if (!intent) {
|
|
121
|
+
return res.status(404).json({
|
|
122
|
+
error: "intent_not_found",
|
|
123
|
+
intentId,
|
|
124
|
+
domain,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (intent.alpha === "read") {
|
|
128
|
+
return res.status(400).json({
|
|
129
|
+
error: "read_intent_not_executable",
|
|
130
|
+
intentId,
|
|
131
|
+
message: `Read-intents must be executed via GET /api/state/current or /api/agent/${domain}/world.`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const params = (req.body && typeof req.body === "object" ? req.body.params : null) || {};
|
|
136
|
+
const effect = buildEffect({ intentId, intent, params });
|
|
137
|
+
const result = await engine.submit(effect);
|
|
138
|
+
if (result.status === "confirmed") {
|
|
139
|
+
return res.json({ status: "confirmed", effect });
|
|
140
|
+
}
|
|
141
|
+
return res.status(409).json({
|
|
142
|
+
status: "rejected",
|
|
143
|
+
error: "invariant_violation",
|
|
144
|
+
reason: result.reason,
|
|
145
|
+
cascaded: result.cascaded || [],
|
|
146
|
+
effect,
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
next(err);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
64
153
|
let server = null;
|
|
65
154
|
|
|
155
|
+
async function loadSeed() {
|
|
156
|
+
if (!Array.isArray(seed) || seed.length === 0) return;
|
|
157
|
+
for (const ef of seed) {
|
|
158
|
+
await persistence.appendEffect(normalizeSeedEffect(ef));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
66
162
|
return {
|
|
67
163
|
app,
|
|
68
164
|
ontology,
|
|
69
165
|
domain,
|
|
166
|
+
engine,
|
|
167
|
+
persistence,
|
|
70
168
|
|
|
71
169
|
async start() {
|
|
72
170
|
if (server) {
|
|
73
171
|
throw new Error("runtime already started");
|
|
74
172
|
}
|
|
173
|
+
await loadSeed();
|
|
75
174
|
return await new Promise((resolve, reject) => {
|
|
76
175
|
const s = app.listen(port, host, () => {
|
|
77
176
|
server = s;
|