@intent-driven/runtime-local 0.3.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 CHANGED
@@ -59,11 +59,27 @@ Peer-зависимость: `@intent-driven/core@>=0.49.0` (через `@intent
59
59
  | `GET` | `/api/agent/:domain/schema` | Full ontology snapshot для MCP discovery. 503 если domain не совпадает. |
60
60
  | `GET` | `/api/state/current` | Folded world snapshot — `{domain, world: {products: [...], orders: [...]}}`. |
61
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'а.
62
79
 
63
80
  ## Roadmap (следующие PR)
64
81
 
65
- - v0.4: `POST /api/agent/:domain/exec/:intentId` ingest через `engine.submit` с invariant validation, ApprovalRequest auto-injection через `lifecycleAugmenter`
66
- - v0.5: `GET /api/approvals/pending` + `POST /api/approvals/:id/approve|reject` + timer-driven expiry
82
+ - v0.5: `GET /api/approvals/pending` + `POST /api/approvals/:id/approve|reject` + ApprovalRequest auto-injection (lifecycleAugmenter)
67
83
  - v0.6: `GET /api/state/at?t=ISO` + `/api/state/diff?from=&to=` — time-travel
68
84
  - v0.7: SSE-стрим `/api/approvals/stream` для `idf approvals --watch`
69
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intent-driven/runtime-local",
3
- "version": "0.3.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": {
@@ -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
+ }
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { dirname, join } from "node:path";
6
6
  import { createEngine, createInMemoryPersistence } from "@intent-driven/engine";
7
+ import { buildEffect } from "./buildEffect.js";
7
8
 
8
9
  /**
9
10
  * Engine validator JSON-парсит ef.value если оно string (host-convention для
@@ -106,6 +107,49 @@ export function createRuntime(opts = {}) {
106
107
  }
107
108
  });
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
+
109
153
  let server = null;
110
154
 
111
155
  async function loadSeed() {