@anchrd/intel-api 0.6.7 → 0.9.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 +63 -3
- package/dist/adapters/cloudflare/cloudflare.js +102 -37
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +20 -0
- package/dist/adapters/content/content.d.ts +1 -1
- package/dist/adapters/db/db-flows.js +148 -20
- package/dist/adapters/db/db-grants.d.ts +13 -2
- package/dist/adapters/db/db-grants.js +25 -8
- package/dist/adapters/db/db-indexing.d.ts +2 -2
- package/dist/adapters/db/db-indexing.js +26 -19
- package/dist/adapters/db/db.d.ts +3 -3
- package/dist/adapters/db/db.js +442 -118
- package/dist/adapters/gate-applications/gate-applications.d.ts +23 -0
- package/dist/adapters/gate-applications/gate-applications.js +88 -0
- package/dist/adapters/index-queue/index-queue.d.ts +1 -1
- package/dist/adapters/index-queue/index-queue.js +2 -2
- package/dist/adapters/semantic-index/semantic-index.types.d.ts +2 -2
- package/dist/adapters/tool-delegation/tool-delegation.d.ts +22 -0
- package/dist/adapters/tool-delegation/tool-delegation.js +90 -0
- package/dist/agent-runtime/agent-runtime.d.ts +16 -0
- package/dist/agent-runtime/agent-runtime.js +150 -0
- package/dist/agent-runtime/agent-runtime.types.d.ts +122 -0
- package/dist/bundle/bundle.d.ts +4 -0
- package/dist/bundle/bundle.js +1048 -0
- package/dist/bundle/bundle.types.d.ts +33 -0
- package/dist/bundle/bundle.types.js +1 -0
- package/dist/cli/cli.js +10 -1
- package/dist/flows/flows.d.ts +8 -8
- package/dist/flows/flows.js +158 -42
- package/dist/flows/flows.types.d.ts +40 -7
- package/dist/http/http.d.ts +1 -0
- package/dist/http/http.js +348 -61
- package/dist/http/http.types.d.ts +6 -2
- package/dist/indexing/indexing.js +14 -2
- package/dist/indexing/indexing.types.d.ts +2 -2
- package/dist/intel/intel.js +12 -3
- package/dist/intel/intel.types.d.ts +6 -2
- package/dist/mcp/mcp.js +519 -124
- package/dist/mcp/mcp.types.d.ts +11 -2
- package/dist/nodes/nodes.d.ts +2 -0
- package/dist/nodes/nodes.js +1466 -0
- package/dist/nodes/nodes.types.d.ts +402 -0
- package/dist/nodes/nodes.types.js +1 -0
- package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
- package/dist/tools/tool-servers/tool-servers.js +114 -0
- package/dist/tools/tools.js +190 -31
- package/dist/tools/tools.types.d.ts +23 -1
- package/migrations/0011_one_name_for_the_tree.sql +53 -0
- package/migrations/0012_table_snapshots.sql +29 -0
- package/migrations/0013_agents_in_the_tree.sql +76 -0
- package/migrations/0014_agent_applications.sql +25 -0
- package/migrations/0015_tools_delegated_from_a_connection.sql +15 -0
- package/package.json +3 -2
- package/dist/knowledge/knowledge.d.ts +0 -2
- package/dist/knowledge/knowledge.js +0 -761
- package/dist/knowledge/knowledge.types.d.ts +0 -198
- /package/dist/{knowledge/knowledge.types.js → agent-runtime/agent-runtime.types.js} +0 -0
- /package/dist/{knowledge → nodes}/document-links/document-links.d.ts +0 -0
- /package/dist/{knowledge → nodes}/document-links/document-links.js +0 -0
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# @anchrd/intel-api
|
|
2
2
|
|
|
3
|
-
Intel is a customer-deployed, model-agnostic operating layer for company
|
|
3
|
+
Intel is a customer-deployed, model-agnostic operating layer for company intelligence and processes. It
|
|
4
4
|
gives people and MCP-capable AI clients the same governed access to three surfaces:
|
|
5
5
|
|
|
6
|
-
- **
|
|
7
|
-
- **Flows** — versioned, durable processes that reference
|
|
6
|
+
- **Intelligence** — company content as nodes, with sharing, immutable versions, retrieval, citations and a graph
|
|
7
|
+
- **Flows** — versioned, durable processes that reference nodes and Tools
|
|
8
8
|
- **Tools** — MCP capabilities discovered through a Cloudflare MCP Portal
|
|
9
9
|
|
|
10
10
|
The UI, the HTTP API and the Intel MCP surface call the same application services, so authorization,
|
|
@@ -88,6 +88,66 @@ derived from `INTEL_SESSION_SECRET` and kept in the `portal_tokens` table of you
|
|
|
88
88
|
credentials stay with the portal and never reach Intel. The Intel audience token is never forwarded
|
|
89
89
|
to another OAuth resource.
|
|
90
90
|
|
|
91
|
+
Where agents are deployed, bind the agent runtime Worker as the service `AGENT` — the reference
|
|
92
|
+
deployment does, as `{ "binding": "AGENT", "service": "intel-agent" }`. The browser never talks to
|
|
93
|
+
that Worker: it asks Intel under `/api/v1/agents/:agentId/*`, and Intel forwards the call over the
|
|
94
|
+
binding with the Gate token of the person who asked, after checking `agents/run` and the agent's own
|
|
95
|
+
resource ACL. So the runtime needs no second hostname, no CORS and no token in the browser bundle,
|
|
96
|
+
and the page stays on one origin with one session cookie. The binding is optional: without it those
|
|
97
|
+
routes answer `503 agent_runtime_not_configured` and nothing else changes. Deploy `@anchrd/intel-agent`
|
|
98
|
+
before an Intel deployment that declares it — Wrangler refuses a binding to a service that does not
|
|
99
|
+
exist yet.
|
|
100
|
+
|
|
101
|
+
### Creating an agent creates its Gate application
|
|
102
|
+
|
|
103
|
+
An agent acts as its own machine principal, so creating an agent node also creates the Gate
|
|
104
|
+
Application it runs as, and archiving the node switches that Application off (`disabledAt`) rather
|
|
105
|
+
than deleting it. Restoring the node switches it back on — the same principal, with the same ID and
|
|
106
|
+
the same grants, which is the whole reason it is disabled rather than deleted.
|
|
107
|
+
|
|
108
|
+
Both calls are made with **the bearer of the person asking**, not with `GATE_SERVICE_KEY`. Creating
|
|
109
|
+
and switching a machine principal is administrative work in Gate: the caller needs the applications
|
|
110
|
+
permission there on top of `knowledge:create` in Intel, and Gate audits the act under their name. A
|
|
111
|
+
caller without it is refused with `agent_application_forbidden`, and a Gate that does not answer
|
|
112
|
+
with `agent_application_unavailable` — in which case **no agent node is created at all**. There is
|
|
113
|
+
deliberately no half-created agent to repair later; the request is simply repeated.
|
|
114
|
+
|
|
115
|
+
**No response in Intel carries a credential.** The Application key Gate issues once, in plain text,
|
|
116
|
+
is handed to the agent runtime over the `AGENT` service binding inside the same request, and the
|
|
117
|
+
runtime encrypts it into that agent's Durable Object. Intel writes it to no table, no R2 object, no
|
|
118
|
+
audit event, no log and no response body; the browser never sees it, and neither does an MCP client.
|
|
119
|
+
The agent can run the moment it exists — there is no secret to set and no terminal to open.
|
|
120
|
+
|
|
121
|
+
The handover carries the caller's own bearer plus `AGENT_HANDOVER_SECRET`, the one value Intel and
|
|
122
|
+
the agent runtime share. The runtime cannot check Intel's resource ACLs, so that secret is what says
|
|
123
|
+
the key came through Intel rather than off the open internet; without it on both Workers, creating an
|
|
124
|
+
agent refuses by name.
|
|
125
|
+
|
|
126
|
+
The handover happens **before** the node row, for the same reason the Gate call does: a runtime that
|
|
127
|
+
will not take the key leaves no agent behind, and the freshly minted Application is switched off
|
|
128
|
+
again. There is deliberately no half-created agent to repair later; the request is simply repeated.
|
|
129
|
+
|
|
130
|
+
What still stays manual is the reach: grant the application its Gate roles and the resource grants
|
|
131
|
+
on the Intel nodes it works with. That is what makes the agent's reach its own.
|
|
132
|
+
|
|
133
|
+
`POST /api/v1/nodes/agents/<id>/rotate-key` (and the `agent_rotate_key` MCP tool) replaces the key:
|
|
134
|
+
Intel asks Gate for a new one and hands it to the runtime the same way, the previous key stops
|
|
135
|
+
working at once, and the answer names the principal and the moment rather than the key. It needs
|
|
136
|
+
`knowledge:write` in Intel plus write access to the agent node, and the applications permission in
|
|
137
|
+
Gate. It is the repair for a run that fails with `agent_key_missing`.
|
|
138
|
+
|
|
139
|
+
**A written schedule is an armed one.** The agent runtime sets its alarm only when something tells
|
|
140
|
+
it to look, so Intel calls `POST /agents/<id>/schedules/sync` over the same service binding after
|
|
141
|
+
every definition write — creating an agent with schedules, and every save. That call goes down the
|
|
142
|
+
handover route with the caller's own bearer beside `AGENT_HANDOVER_SECRET`, because arming an agent
|
|
143
|
+
belongs to editing one and not to driving one. A save whose arming fails keeps the definition it
|
|
144
|
+
wrote and answers `agent_schedules_not_armed`: the version is current, and saving again arms it.
|
|
145
|
+
|
|
146
|
+
Intel keeps `applicationId` beside the node and returns it on every agent read. It names the
|
|
147
|
+
principal without authenticating it, which is why it may be stored and shown while the key may not.
|
|
148
|
+
An agent whose `applicationId` is `null` has no principal — imported, restored from a bundle, or
|
|
149
|
+
created before this was automatic — and archiving it touches Gate not at all.
|
|
150
|
+
|
|
91
151
|
The reference Wrangler deployment binds `DB`, `CONTENT`, `INDEXING`, `AI`, `SEARCH`, `FLOWS`, and
|
|
92
152
|
`ASSETS`. Create `SEARCH` as a 1024-dimension cosine Vectorize index for the default multilingual
|
|
93
153
|
Workers AI `@cf/baai/bge-m3` embedding adapter. `FLOWS` targets the exported
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import { createGateClient } from "@anchrd/gate-sdk";
|
|
2
2
|
import { ulid } from "ulid";
|
|
3
|
+
import { createAgentRuntimeService } from "../../agent-runtime/agent-runtime.js";
|
|
3
4
|
import { createBrowserAuth } from "../../auth/auth.js";
|
|
5
|
+
import { createBundle } from "../../bundle/bundle.js";
|
|
4
6
|
import { createFlows } from "../../flows/flows.js";
|
|
5
7
|
import { createIndexing, PermanentIndexingError } from "../../indexing/indexing.js";
|
|
6
8
|
import { createIntel } from "../../intel/intel.js";
|
|
7
|
-
import {
|
|
9
|
+
import { createNodes } from "../../nodes/nodes.js";
|
|
8
10
|
import { IntelError } from "../../shared/intel-error/intel-error.js";
|
|
9
11
|
import { sha256Hex } from "../../shared/sha256/sha256.js";
|
|
10
12
|
import { createTools } from "../../tools/tools.js";
|
|
11
13
|
import { createContentStore } from "../content/content.js";
|
|
12
|
-
import {
|
|
14
|
+
import { createNodeRepository } from "../db/db.js";
|
|
13
15
|
import { createFlowRepository } from "../db/db-flows.js";
|
|
14
|
-
import {
|
|
16
|
+
import { createNodeIndexRepository } from "../db/db-indexing.js";
|
|
15
17
|
import { createOAuthClientStore } from "../db/db-oauth.js";
|
|
16
18
|
import { createDocumentConverter } from "../document-converter/document-converter.js";
|
|
17
19
|
import { createFlowRuntime } from "../flow-runtime/flow-runtime.js";
|
|
20
|
+
import { createGateApplications } from "../gate-applications/gate-applications.js";
|
|
18
21
|
import { createIndexQueue, IndexMessage } from "../index-queue/index-queue.js";
|
|
19
22
|
import { createJsonSchemaValidator } from "../json-schema/json-schema.js";
|
|
20
23
|
import { createOpenId } from "../openid/openid.js";
|
|
@@ -22,6 +25,7 @@ import { createPortalTokenStore } from "../portal-tokens/portal-tokens.js";
|
|
|
22
25
|
import { createRemoteTools } from "../remote-tools/remote-tools.js";
|
|
23
26
|
import { createSemanticIndex } from "../semantic-index/semantic-index.js";
|
|
24
27
|
import { createSessionCodec } from "../session-cookie/session-cookie.js";
|
|
28
|
+
import { createToolDelegation } from "../tool-delegation/tool-delegation.js";
|
|
25
29
|
import { createToolSourcePolicy } from "../tool-source-policy/tool-source-policy.js";
|
|
26
30
|
// biome-ignore lint/performance/noBarrelFile: The package entry must expose Wrangler's named Workflow class.
|
|
27
31
|
export { IntelFlowWorkflow } from "./cloudflare-flow-workflow.js";
|
|
@@ -57,25 +61,11 @@ export default {
|
|
|
57
61
|
assertConfigured(env);
|
|
58
62
|
const now = () => new Date();
|
|
59
63
|
const semantic = env.AI && env.SEARCH ? createSemanticIndex({ ai: env.AI, index: env.SEARCH }) : undefined;
|
|
60
|
-
// Built before
|
|
64
|
+
// Built before the tree because the tree has to ask it one question: who calls into a folder
|
|
61
65
|
// from outside it. The repository knows no service, so this stays one direction of dependency.
|
|
62
66
|
const flowRepository = createFlowRepository({ db: env.DB, now });
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
content: createContentStore(env.CONTENT),
|
|
66
|
-
id: ulid,
|
|
67
|
-
now,
|
|
68
|
-
indexing: createIndexQueue(env.INDEXING),
|
|
69
|
-
semantic,
|
|
70
|
-
externalFlowCallers: async (who, folderId) => await flowRepository.externalCallers(who, folderId),
|
|
71
|
-
flowKnowledgeReferences: async (who, folderId) => await flowRepository.knowledgeReferences(who, folderId),
|
|
72
|
-
hash: async (content) => {
|
|
73
|
-
const source = typeof content === "string"
|
|
74
|
-
? new TextEncoder().encode(content)
|
|
75
|
-
: Uint8Array.from(content).buffer;
|
|
76
|
-
return await sha256Hex(crypto, source);
|
|
77
|
-
},
|
|
78
|
-
});
|
|
67
|
+
const nodeRepository = createNodeRepository({ db: env.DB, now });
|
|
68
|
+
const contentStore = createContentStore(env.CONTENT);
|
|
79
69
|
const gate = createGateClient({ url: env.GATE_URL, serviceKey: env.GATE_SERVICE_KEY });
|
|
80
70
|
const oauth = createOpenId({
|
|
81
71
|
fetch: globalThis.fetch.bind(globalThis),
|
|
@@ -89,21 +79,22 @@ export default {
|
|
|
89
79
|
crypto,
|
|
90
80
|
now,
|
|
91
81
|
});
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
portalTokens,
|
|
82
|
+
// Built before the tree since D30: saving an agent has to ask the portal, live, whether the
|
|
83
|
+
// person saving reaches every server they are delegating. The dependency runs one way — the
|
|
84
|
+
// tool service knows nothing about the tree, and the delegation lookup below reads D1 directly
|
|
85
|
+
// rather than through the node service.
|
|
86
|
+
const toolDelegation = createToolDelegation({
|
|
87
|
+
db: env.DB,
|
|
88
|
+
content: contentStore,
|
|
89
|
+
id: ulid,
|
|
101
90
|
now,
|
|
102
91
|
});
|
|
103
92
|
const tools = createTools({
|
|
104
93
|
portalUrl,
|
|
105
94
|
remote: createRemoteTools({ fetch: globalThis.fetch.bind(globalThis) }),
|
|
106
95
|
tokens: portalTokens,
|
|
96
|
+
delegation: toolDelegation.resolve,
|
|
97
|
+
audit: toolDelegation.audit,
|
|
107
98
|
refresh: async (token) => {
|
|
108
99
|
try {
|
|
109
100
|
const refreshed = await oauth.refresh({
|
|
@@ -132,17 +123,76 @@ export default {
|
|
|
132
123
|
return await sha256Hex(crypto, JSON.stringify(value));
|
|
133
124
|
},
|
|
134
125
|
});
|
|
126
|
+
// ⚠️ Built before `nodes` and not after, because creating an agent has to be able to hand the
|
|
127
|
+
// runtime its key inside the same call (D29). Its `visibleNode` closes over `nodes` and is only
|
|
128
|
+
// ever called later, which is what keeps the two from being a cycle.
|
|
129
|
+
const agents = createAgentRuntimeService({
|
|
130
|
+
...(env.AGENT ? { runtime: env.AGENT } : {}),
|
|
131
|
+
...(env.AGENT_HANDOVER_SECRET ? { handoverSecret: env.AGENT_HANDOVER_SECRET } : {}),
|
|
132
|
+
// The tree's own visibility lookup, unchanged: whether somebody may reach an agent is the
|
|
133
|
+
// same question as whether they may see the node, and no second answer is invented here.
|
|
134
|
+
visibleNode: async (actor, nodeId) => await nodes.visibleNode(actor, nodeId),
|
|
135
|
+
});
|
|
136
|
+
const nodes = createNodes({
|
|
137
|
+
repository: nodeRepository,
|
|
138
|
+
content: contentStore,
|
|
139
|
+
// ⚠️ Built from `GATE_URL` alone — no service key is handed to it, and none would help. The
|
|
140
|
+
// Applications surface is admin-gated on a real principal, so every call carries the bearer
|
|
141
|
+
// of the person making it, which the tree passes in per operation (#182).
|
|
142
|
+
applications: createGateApplications({
|
|
143
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
144
|
+
gateUrl: env.GATE_URL,
|
|
145
|
+
}),
|
|
146
|
+
// The binding's presence IS the signal (#190) — no separate configuration option exists.
|
|
147
|
+
agentRuntimeAvailable: () => env.AGENT !== undefined,
|
|
148
|
+
// Where the Application key goes, and the only place it goes (D29). The same service the
|
|
149
|
+
// browser paths are proxied through, so there is one door to the runtime and not two.
|
|
150
|
+
agentKeys: { store: async (input) => await agents.storeKey(input) },
|
|
151
|
+
// The same door again (#214): a definition that was written is a schedule that has to be
|
|
152
|
+
// armed, and the runtime only looks when it is told to.
|
|
153
|
+
agentSchedules: { sync: async (input) => await agents.syncSchedules(input) },
|
|
154
|
+
id: ulid,
|
|
155
|
+
now,
|
|
156
|
+
indexing: createIndexQueue(env.INDEXING),
|
|
157
|
+
semantic,
|
|
158
|
+
externalFlowCallers: async (who, folderId) => await flowRepository.externalCallers(who, folderId),
|
|
159
|
+
flowNodeReferences: async (who, folderId) => await flowRepository.nodeReferences(who, folderId),
|
|
160
|
+
// ⚠️ `canExecute: true` is not a permission being granted here — the servers list touches
|
|
161
|
+
// nothing and executes nothing. It exists because `ToolActor` carries the flag for the one
|
|
162
|
+
// method that needs it, and a `false` would read as if listing were gated on execution.
|
|
163
|
+
toolServers: async (who) => {
|
|
164
|
+
const catalog = await tools.servers({ id: who.id, email: who.email, canExecute: true });
|
|
165
|
+
return catalog.items.map((server) => server.handle);
|
|
166
|
+
},
|
|
167
|
+
hash: async (content) => {
|
|
168
|
+
const source = typeof content === "string"
|
|
169
|
+
? new TextEncoder().encode(content)
|
|
170
|
+
: Uint8Array.from(content).buffer;
|
|
171
|
+
return await sha256Hex(crypto, source);
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
const auth = createBrowserAuth({
|
|
175
|
+
baseUrl: env.INTEL_URL,
|
|
176
|
+
gateUrl: env.GATE_URL,
|
|
177
|
+
resource: intelResource,
|
|
178
|
+
oauth,
|
|
179
|
+
clients: createOAuthClientStore(env.DB),
|
|
180
|
+
sessions: createSessionCodec({ secret: env.INTEL_SESSION_SECRET, crypto }),
|
|
181
|
+
portalUrl,
|
|
182
|
+
portalTokens,
|
|
183
|
+
now,
|
|
184
|
+
});
|
|
135
185
|
const flows = createFlows({
|
|
136
186
|
repository: flowRepository,
|
|
137
187
|
runtime: createFlowRuntime(env.FLOWS),
|
|
138
188
|
id: ulid,
|
|
139
189
|
now,
|
|
140
|
-
folderAccess: async (actor, folderId) => await
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
// requirements list and every
|
|
190
|
+
folderAccess: async (actor, folderId) => await nodes.folderAccess(actor, folderId),
|
|
191
|
+
nodeChildren: async (actor, folderId, limit) => await nodes.childrenBounded(actor, { parentId: folderId, limit }),
|
|
192
|
+
// The tree's own visibility lookup, unchanged on the way through: the relation graph, the
|
|
193
|
+
// requirements list and every tree link of every run read this one answer, so none of
|
|
144
194
|
// them can be kinder than the others.
|
|
145
|
-
|
|
195
|
+
visibleNodes: async (actor, nodeId) => await nodes.visibleNode(actor, nodeId),
|
|
146
196
|
toolFingerprint: async (actor, toolName) => {
|
|
147
197
|
const catalog = await tools
|
|
148
198
|
.catalog({ id: actor.id, email: actor.email, canExecute: true })
|
|
@@ -155,15 +205,30 @@ export default {
|
|
|
155
205
|
baseUrl: env.INTEL_URL,
|
|
156
206
|
gateUrl: env.GATE_URL,
|
|
157
207
|
gate,
|
|
158
|
-
|
|
208
|
+
nodes,
|
|
159
209
|
flows,
|
|
160
210
|
tools,
|
|
211
|
+
bundle: createBundle({
|
|
212
|
+
repository: nodeRepository,
|
|
213
|
+
flows: flowRepository,
|
|
214
|
+
content: contentStore,
|
|
215
|
+
id: ulid,
|
|
216
|
+
now,
|
|
217
|
+
hash: async (content) => {
|
|
218
|
+
const source = typeof content === "string"
|
|
219
|
+
? new TextEncoder().encode(content)
|
|
220
|
+
: Uint8Array.from(content).buffer;
|
|
221
|
+
return await sha256Hex(crypto, source);
|
|
222
|
+
},
|
|
223
|
+
indexing: createIndexQueue(env.INDEXING),
|
|
224
|
+
}),
|
|
225
|
+
agents,
|
|
161
226
|
auth,
|
|
162
227
|
}).fetch(request);
|
|
163
228
|
},
|
|
164
229
|
async queue(batch, env) {
|
|
165
230
|
const indexing = createIndexing({
|
|
166
|
-
repository:
|
|
231
|
+
repository: createNodeIndexRepository(env.DB),
|
|
167
232
|
content: createContentStore(env.CONTENT),
|
|
168
233
|
semantic: env.AI && env.SEARCH ? createSemanticIndex({ ai: env.AI, index: env.SEARCH }) : undefined,
|
|
169
234
|
converter: env.AI ? createDocumentConverter(env.AI) : undefined,
|
|
@@ -182,7 +247,7 @@ export default {
|
|
|
182
247
|
}
|
|
183
248
|
catch (error) {
|
|
184
249
|
if (error instanceof PermanentIndexingError) {
|
|
185
|
-
console.warn("Intel discarded a permanently unindexable
|
|
250
|
+
console.warn("Intel discarded a permanently unindexable node version", {
|
|
186
251
|
versionId: parsed.data.versionId,
|
|
187
252
|
error: error.message,
|
|
188
253
|
});
|
|
@@ -9,6 +9,17 @@ export interface CloudflareEnv {
|
|
|
9
9
|
ASSETS?: {
|
|
10
10
|
fetch(request: Request): Promise<Response>;
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* The agent runtime Worker, bound as a service.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ Optional, and it has to be: a deployment may run Intel without agents, and a hard requirement
|
|
16
|
+
* here would make the whole installation fail to start over a feature nobody uses. Where it is
|
|
17
|
+
* absent the agent routes answer 503 by name; where it is present the browser reaches the runtime
|
|
18
|
+
* through Intel and never across a second origin (#178).
|
|
19
|
+
*/
|
|
20
|
+
AGENT?: {
|
|
21
|
+
fetch(request: Request): Promise<Response>;
|
|
22
|
+
};
|
|
12
23
|
DB: D1Database;
|
|
13
24
|
CONTENT: R2Bucket;
|
|
14
25
|
INDEXING: QueueProducer<IndexMessage>;
|
|
@@ -19,6 +30,15 @@ export interface CloudflareEnv {
|
|
|
19
30
|
GATE_SERVICE_KEY: string;
|
|
20
31
|
INTEL_URL: string;
|
|
21
32
|
INTEL_SESSION_SECRET: string;
|
|
33
|
+
/**
|
|
34
|
+
* The shared secret that proves a key handover reached the agent runtime through this Worker
|
|
35
|
+
* (D29, #207). The SAME value the agent Worker holds as `AGENT_HANDOVER_SECRET`.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ Optional exactly like `AGENT`: a deployment may run Intel without agents, and a hard
|
|
38
|
+
* requirement would make the whole installation fail to start over a feature nobody uses. Where
|
|
39
|
+
* it is absent, creating an agent refuses by name instead of leaving one without a key.
|
|
40
|
+
*/
|
|
41
|
+
AGENT_HANDOVER_SECRET?: string;
|
|
22
42
|
TOOL_SOURCE_ORIGINS: string;
|
|
23
43
|
MCP_PORTAL_URL?: string;
|
|
24
44
|
ALLOW_INSECURE_OAUTH?: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Flow, FlowGraph, FlowRun, FlowVersion, } from "@anchrd/intel-contract";
|
|
2
|
-
import { calleeIds } from "../../flows/flows.js";
|
|
2
|
+
import { calleeIds, treeLinkKinds } from "../../flows/flows.js";
|
|
3
3
|
import { flowCallable, flowInSubtree, flowInSubtreeBindings, flowVerbBindings, flowVerbQuery, readableOrRunnableCte, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
4
4
|
const flowColumnNames = [
|
|
5
5
|
"id",
|
|
@@ -35,6 +35,11 @@ const runColumns = runColumnNames.join(", ");
|
|
|
35
35
|
// splitting the rendered string would break the moment it is reformatted.
|
|
36
36
|
const qualifiedRunColumns = (alias) => runColumnNames.map((column) => `${alias}.${column}`).join(", ");
|
|
37
37
|
const qualifiedFlowColumns = (alias) => flowColumnNames.map((column) => `${alias}.${column}`).join(", ");
|
|
38
|
+
// The same four kinds `treeLinkNodes` filters on, rendered for SQL. Derived rather than written out
|
|
39
|
+
// a second time: a fifth link kind added to the contract would otherwise reach every reader of a
|
|
40
|
+
// graph except this one, and the miss would look like an empty result rather than an error — which
|
|
41
|
+
// is precisely how the pre-0008 `knowledge` matcher survived unnoticed (#153).
|
|
42
|
+
const treeLinkKindList = treeLinkKinds.map((kind) => `'${kind}'`).join(", ");
|
|
38
43
|
function mapFlow(row) {
|
|
39
44
|
return Flow.parse({
|
|
40
45
|
id: row.id,
|
|
@@ -102,7 +107,7 @@ function mapRun(row) {
|
|
|
102
107
|
const idList = "(SELECT value FROM json_each(?))";
|
|
103
108
|
export function createFlowRepository(deps) {
|
|
104
109
|
// A flow holds no grant of its own any more (ADR-0004 §2). What reaches it is the folder it is
|
|
105
|
-
// filed in, read through the same walk
|
|
110
|
+
// filed in, read through the same walk the tree uses, so the two can never drift apart. The CTE
|
|
106
111
|
// stands in front of the statement, so its bindings come before every other one.
|
|
107
112
|
const readableBindings = (actor) => subtreeBindings(actor, "read", deps.now().toISOString());
|
|
108
113
|
// An absent `parentId` asks for every visible flow; `null` asks for the root of the shared tree.
|
|
@@ -205,10 +210,10 @@ export function createFlowRepository(deps) {
|
|
|
205
210
|
return "out-of-reach";
|
|
206
211
|
const row = await deps.db
|
|
207
212
|
.prepare(`WITH RECURSIVE ancestors(id, parent_id) AS (
|
|
208
|
-
SELECT id, parent_id FROM
|
|
213
|
+
SELECT id, parent_id FROM nodes WHERE id = ?
|
|
209
214
|
UNION
|
|
210
215
|
SELECT parent.id, parent.parent_id
|
|
211
|
-
FROM
|
|
216
|
+
FROM nodes parent
|
|
212
217
|
JOIN ancestors child ON child.parent_id = parent.id
|
|
213
218
|
)
|
|
214
219
|
SELECT
|
|
@@ -237,9 +242,9 @@ export function createFlowRepository(deps) {
|
|
|
237
242
|
const result = await deps.db
|
|
238
243
|
.prepare(`${subtreeCte},
|
|
239
244
|
scope(id) AS (
|
|
240
|
-
SELECT id FROM
|
|
245
|
+
SELECT id FROM nodes WHERE id = ?
|
|
241
246
|
UNION
|
|
242
|
-
SELECT child.id FROM
|
|
247
|
+
SELECT child.id FROM nodes child JOIN scope ON child.parent_id = scope.id
|
|
243
248
|
)
|
|
244
249
|
SELECT DISTINCT ${qualifiedFlowColumns("flow")},
|
|
245
250
|
CASE WHEN ${flowInSubtree} THEN 1 ELSE 0 END AS visible
|
|
@@ -260,38 +265,42 @@ export function createFlowRepository(deps) {
|
|
|
260
265
|
hidden: rows.filter((row) => row.visible !== 1).length,
|
|
261
266
|
};
|
|
262
267
|
},
|
|
263
|
-
// The
|
|
268
|
+
// The documents the flows in this folder's subtree read, so whoever shares the folder
|
|
264
269
|
// can be told what the grant does not cover (ADR-0004 §4).
|
|
265
270
|
//
|
|
266
271
|
// ⚠️ `flowInSubtree` again, the very predicate `listVisible` uses: a flow this actor may not see
|
|
267
272
|
// must not reach the answer even as a number, because what it reads would then be attributed to
|
|
268
|
-
// a folder they administer. Whether any of the returned IDs may be *named* is
|
|
273
|
+
// a folder they administer. Whether any of the returned IDs may be *named* is the tree's
|
|
269
274
|
// question — this one hands back IDs and no titles.
|
|
270
275
|
//
|
|
271
|
-
// The
|
|
272
|
-
//
|
|
273
|
-
|
|
276
|
+
// ⚠️ The link kinds, one `resourceId` each — the shape migration 0008 left behind. Before #153
|
|
277
|
+
// this asked for a `knowledge` node with a `resourceIds` array, and both had been rewritten away:
|
|
278
|
+
// the query matched nothing, and the warning it feeds went quiet without ever failing. A stored
|
|
279
|
+
// graph from before 0008 still cannot break it — its kind is not in the list, so it is not read
|
|
280
|
+
// at all — and a node whose configuration carries no `resourceId` is dropped rather than passed
|
|
281
|
+
// on as a null the caller would look up.
|
|
282
|
+
async nodeReferences(actor, folderId) {
|
|
274
283
|
const result = await deps.db
|
|
275
284
|
.prepare(`${subtreeCte},
|
|
276
285
|
scope(id) AS (
|
|
277
|
-
SELECT id FROM
|
|
286
|
+
SELECT id FROM nodes WHERE id = ?
|
|
278
287
|
UNION
|
|
279
|
-
SELECT child.id FROM
|
|
288
|
+
SELECT child.id FROM nodes child JOIN scope ON child.parent_id = scope.id
|
|
280
289
|
),
|
|
281
|
-
referencing(
|
|
282
|
-
SELECT json_extract(node.value, '$.configuration.
|
|
290
|
+
referencing(resource_id) AS (
|
|
291
|
+
SELECT json_extract(node.value, '$.configuration.resourceId')
|
|
283
292
|
FROM flows flow
|
|
284
293
|
JOIN flow_versions version ON version.id = flow.published_version_id
|
|
285
294
|
JOIN json_each(version.graph_json, '$.nodes') node
|
|
286
|
-
WHERE json_extract(node.value, '$.kind')
|
|
295
|
+
WHERE json_extract(node.value, '$.kind') IN (${treeLinkKindList})
|
|
287
296
|
AND flow.archived_at IS NULL
|
|
288
297
|
AND flow.parent_id IN (SELECT id FROM scope)
|
|
289
298
|
AND ${flowInSubtree}
|
|
290
299
|
)
|
|
291
|
-
SELECT DISTINCT
|
|
300
|
+
SELECT DISTINCT resource_id
|
|
292
301
|
FROM referencing
|
|
293
|
-
|
|
294
|
-
ORDER BY
|
|
302
|
+
WHERE resource_id IS NOT NULL
|
|
303
|
+
ORDER BY resource_id`)
|
|
295
304
|
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), folderId, ...flowInSubtreeBindings(actor))
|
|
296
305
|
.all();
|
|
297
306
|
return (result.results ?? []).map((row) => row.resource_id);
|
|
@@ -407,7 +416,7 @@ export function createFlowRepository(deps) {
|
|
|
407
416
|
// flow that went in.
|
|
408
417
|
//
|
|
409
418
|
// ⚠️ The idempotency row is written only if the UPDATE actually matched — same guard as
|
|
410
|
-
// `
|
|
419
|
+
// `node.archive` — so a stale `baseUpdatedAt` leaves no key behind that would make the
|
|
411
420
|
// retry of a *lost* write look like a replay of a successful one.
|
|
412
421
|
async archiveFlow(input) {
|
|
413
422
|
try {
|
|
@@ -466,6 +475,22 @@ export function createFlowRepository(deps) {
|
|
|
466
475
|
async getVersion(versionId) {
|
|
467
476
|
return (await this.getVersions([versionId]))[0] ?? null;
|
|
468
477
|
},
|
|
478
|
+
// `graph_json` is deliberately absent from the SELECT: the history is metadata, and reading
|
|
479
|
+
// every graph of a much-edited flow to draw a table would grow with the number of edits.
|
|
480
|
+
async listVersions(flowId) {
|
|
481
|
+
const result = await deps.db
|
|
482
|
+
.prepare(`SELECT id, flow_id, sequence, created_by, created_at
|
|
483
|
+
FROM flow_versions WHERE flow_id = ? ORDER BY sequence`)
|
|
484
|
+
.bind(flowId)
|
|
485
|
+
.all();
|
|
486
|
+
return (result.results ?? []).map((row) => ({
|
|
487
|
+
id: row.id,
|
|
488
|
+
flowId: row.flow_id,
|
|
489
|
+
sequence: row.sequence,
|
|
490
|
+
createdBy: row.created_by,
|
|
491
|
+
createdAt: row.created_at,
|
|
492
|
+
}));
|
|
493
|
+
},
|
|
469
494
|
async insertVersion(input) {
|
|
470
495
|
const version = input.version;
|
|
471
496
|
try {
|
|
@@ -543,6 +568,45 @@ export function createFlowRepository(deps) {
|
|
|
543
568
|
}
|
|
544
569
|
return ((await this.findIdempotent(input.actorId, "flows.publish", input.idempotencyKey)) !== null);
|
|
545
570
|
},
|
|
571
|
+
// The mirror of `publish`, with the same shape of guards: the idempotency row is written only
|
|
572
|
+
// if the UPDATE actually withdrew something, so revoking what was never published leaves no key
|
|
573
|
+
// behind that would make a later retry look like a success.
|
|
574
|
+
async unpublish(input) {
|
|
575
|
+
try {
|
|
576
|
+
await deps.db.batch([
|
|
577
|
+
deps.db
|
|
578
|
+
.prepare(`UPDATE flows SET published_version_id = NULL, updated_at = ?
|
|
579
|
+
WHERE id = ? AND published_version_id IS NOT NULL`)
|
|
580
|
+
.bind(input.occurredAt, input.flowId),
|
|
581
|
+
deps.db
|
|
582
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
583
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
584
|
+
) SELECT ?, 'flows.unpublish', ?, ?, ?
|
|
585
|
+
WHERE EXISTS (
|
|
586
|
+
SELECT 1 FROM flows
|
|
587
|
+
WHERE id = ? AND published_version_id IS NULL AND updated_at = ?
|
|
588
|
+
)`)
|
|
589
|
+
.bind(input.actorId, input.idempotencyKey, input.flowId, input.occurredAt, input.flowId, input.occurredAt),
|
|
590
|
+
deps.db
|
|
591
|
+
.prepare(`INSERT INTO audit_events (
|
|
592
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
593
|
+
) SELECT ?, ?, 'flows.unpublish', 'flow', ?, ?, ?
|
|
594
|
+
WHERE EXISTS (
|
|
595
|
+
SELECT 1 FROM idempotency_keys
|
|
596
|
+
WHERE actor_id = ? AND operation = 'flows.unpublish' AND idempotency_key = ?
|
|
597
|
+
)`)
|
|
598
|
+
.bind(input.auditId, input.actorId, input.flowId,
|
|
599
|
+
// What was withdrawn, so the trail can say which version was live until here.
|
|
600
|
+
JSON.stringify({ versionId: input.versionId }), input.occurredAt, input.actorId, input.idempotencyKey),
|
|
601
|
+
]);
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
if (!(await this.findIdempotent(input.actorId, "flows.unpublish", input.idempotencyKey))) {
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return ((await this.findIdempotent(input.actorId, "flows.unpublish", input.idempotencyKey)) !== null);
|
|
609
|
+
},
|
|
546
610
|
async insertRun(input) {
|
|
547
611
|
const run = input.run;
|
|
548
612
|
try {
|
|
@@ -713,6 +777,70 @@ export function createFlowRepository(deps) {
|
|
|
713
777
|
currentNodeId: row.current_node_id,
|
|
714
778
|
}));
|
|
715
779
|
},
|
|
780
|
+
// One write for the run and everything below it. The recursion gathers only non-terminal
|
|
781
|
+
// descendants, and the root joins the set only while it is itself non-terminal — so a run that
|
|
782
|
+
// turned terminal between the caller's check and this write cancels nothing at all, and the
|
|
783
|
+
// missing idempotency row reports the conflict. `flow_run_steps` is deliberately untouched:
|
|
784
|
+
// cancelling is not a step outcome (#145).
|
|
785
|
+
async cancelRun(input) {
|
|
786
|
+
const nonTerminal = "status NOT IN ('completed', 'failed', 'cancelled')";
|
|
787
|
+
const affected = `WITH RECURSIVE affected(id) AS (
|
|
788
|
+
SELECT id FROM flow_runs WHERE id = ? AND ${nonTerminal}
|
|
789
|
+
UNION
|
|
790
|
+
SELECT child.id FROM flow_runs child
|
|
791
|
+
JOIN affected ON child.parent_run_id = affected.id
|
|
792
|
+
WHERE child.${nonTerminal}
|
|
793
|
+
)`;
|
|
794
|
+
try {
|
|
795
|
+
await deps.db.batch([
|
|
796
|
+
deps.db
|
|
797
|
+
.prepare(`${affected}
|
|
798
|
+
UPDATE flow_runs SET status = 'cancelled', current_node_id = NULL,
|
|
799
|
+
updated_at = ?, completed_at = ?
|
|
800
|
+
WHERE id IN (SELECT id FROM affected)`)
|
|
801
|
+
.bind(input.runId, input.occurredAt, input.occurredAt),
|
|
802
|
+
deps.db
|
|
803
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
804
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
805
|
+
) SELECT ?, 'flows.cancel', ?, ?, ?
|
|
806
|
+
WHERE EXISTS (
|
|
807
|
+
SELECT 1 FROM flow_runs WHERE id = ? AND status = 'cancelled' AND updated_at = ?
|
|
808
|
+
)`)
|
|
809
|
+
.bind(input.actorId, input.idempotencyKey, input.runId, input.occurredAt, input.runId, input.occurredAt),
|
|
810
|
+
deps.db
|
|
811
|
+
.prepare(`INSERT INTO audit_events (
|
|
812
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
813
|
+
) SELECT ?, ?, 'flows.cancel', 'flow-run', ?, ?, ?
|
|
814
|
+
WHERE EXISTS (
|
|
815
|
+
SELECT 1 FROM idempotency_keys
|
|
816
|
+
WHERE actor_id = ? AND operation = 'flows.cancel' AND idempotency_key = ?
|
|
817
|
+
)`)
|
|
818
|
+
.bind(input.auditId, input.actorId, input.runId, JSON.stringify({ flowId: input.flowId }), input.occurredAt, input.actorId, input.idempotencyKey),
|
|
819
|
+
]);
|
|
820
|
+
}
|
|
821
|
+
catch (error) {
|
|
822
|
+
if (!(await this.findIdempotent(input.actorId, "flows.cancel", input.idempotencyKey))) {
|
|
823
|
+
throw error;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
const replayed = await this.findIdempotent(input.actorId, "flows.cancel", input.idempotencyKey);
|
|
827
|
+
if (replayed !== input.runId)
|
|
828
|
+
return "conflict";
|
|
829
|
+
// What this write ended, read back by the timestamp it stamped: the root and its descendants,
|
|
830
|
+
// narrowed to the rows this very write turned. A child cancelled by an earlier call keeps its
|
|
831
|
+
// earlier timestamp and stays out, so nothing is signalled twice.
|
|
832
|
+
const result = await deps.db
|
|
833
|
+
.prepare(`WITH RECURSIVE affected(id) AS (
|
|
834
|
+
SELECT id FROM flow_runs WHERE id = ?
|
|
835
|
+
UNION
|
|
836
|
+
SELECT child.id FROM flow_runs child JOIN affected ON child.parent_run_id = affected.id
|
|
837
|
+
)
|
|
838
|
+
SELECT id FROM flow_runs
|
|
839
|
+
WHERE id IN (SELECT id FROM affected) AND status = 'cancelled' AND updated_at = ?`)
|
|
840
|
+
.bind(input.runId, input.occurredAt)
|
|
841
|
+
.all();
|
|
842
|
+
return { cancelled: (result.results ?? []).map((row) => row.id) };
|
|
843
|
+
},
|
|
716
844
|
async advanceRun(input) {
|
|
717
845
|
const run = input.run;
|
|
718
846
|
try {
|
|
@@ -6,6 +6,17 @@ export interface GrantActor {
|
|
|
6
6
|
}
|
|
7
7
|
export declare const subtreeCte: string;
|
|
8
8
|
export declare function subtreeBindings(actor: GrantActor, verb: ResourceVerb, now: string): unknown[];
|
|
9
|
+
/**
|
|
10
|
+
* One named folder and everything filed beneath it, appended to a statement that already carries
|
|
11
|
+
* `subtreeCte`. It answers "where", never "whether": a statement joins `descendants` *in addition
|
|
12
|
+
* to* `allowed`, so the scope can only take rows away from an answer the actor was already entitled
|
|
13
|
+
* to. Joining it instead of `allowed` would turn a search argument into a permission, which is
|
|
14
|
+
* exactly what ADR-0004 forbids — the caller supplies the id, and a caller is not a grant.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ `UNION`, never `UNION ALL`, for the reason `nodeVerbQuery` gives: a cycle in `parent_id` can
|
|
17
|
+
* reach the table and this walks downwards into it.
|
|
18
|
+
*/
|
|
19
|
+
export declare const descendantsCte = "descendants(id) AS (\n SELECT scope.id FROM nodes scope WHERE scope.id = ?\n UNION\n SELECT child.id\n FROM nodes child\n JOIN descendants parent ON child.parent_id = parent.id\n )";
|
|
9
20
|
/**
|
|
10
21
|
* Both walks in one statement: what the actor may open, and what they may run. Its bindings are
|
|
11
22
|
* `subtreeBindings(actor, "read", now)` followed by `subtreeBindings(actor, "execute", now)`, in
|
|
@@ -20,7 +31,7 @@ export declare const readableOrRunnableCte: string;
|
|
|
20
31
|
*/
|
|
21
32
|
export declare const flowCallable = "(\n ? = 1\n OR flow.owner_id = ?\n OR flow.parent_id IN (SELECT id FROM readable)\n OR flow.parent_id IN (SELECT id FROM runnable)\n)";
|
|
22
33
|
/**
|
|
23
|
-
* The point check for one
|
|
34
|
+
* The point check for one node: the node itself and every ancestor above it. Cheaper than
|
|
24
35
|
* the subtree walk and the same answer, because a grant reaches down and never sideways.
|
|
25
36
|
*
|
|
26
37
|
* ⚠️ `UNION`, never `UNION ALL`. The service refuses to move a node into its own descendant, but a
|
|
@@ -33,7 +44,7 @@ export declare const nodeVerbQuery: string;
|
|
|
33
44
|
export declare function nodeVerbBindings(nodeId: string, actor: GrantActor, verb: ResourceVerb, now: string): unknown[];
|
|
34
45
|
/**
|
|
35
46
|
* The same question for a flow. A flow carries no grant of its own any more: what reaches it is the
|
|
36
|
-
* folder it is filed in and that folder's ancestors. Its owner keeps it, the way a
|
|
47
|
+
* folder it is filed in and that folder's ancestors. Its owner keeps it, the way a node's
|
|
37
48
|
* owner keeps theirs — otherwise a flow at the root of the tree would be unreachable by the person
|
|
38
49
|
* who created it. `UNION` for the same reason as above.
|
|
39
50
|
*/
|