ai-workflow-logic 0.1.0 → 0.1.2
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/package.json +8 -5
- package/src/agent-resource-brand.ts +20 -0
- package/src/agent-resources.ts +505 -0
- package/src/errors.ts +15 -0
- package/src/filesystem.ts +18 -0
- package/src/index.ts +221 -6
- package/src/run-freeze.ts +270 -0
package/package.json
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-workflow-logic",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "AI workflow domain projections and pure runtime helpers with an explicit Halfcode filesystem adapter",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.ts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/index.ts",
|
|
10
|
-
"./browser": "./src/browser.ts"
|
|
10
|
+
"./browser": "./src/browser.ts",
|
|
11
|
+
"./filesystem": "./src/filesystem.ts",
|
|
12
|
+
"./run-freeze": "./src/run-freeze.ts"
|
|
11
13
|
},
|
|
12
14
|
"dependencies": {
|
|
13
|
-
"ai-workflow-contract": "0.1.
|
|
15
|
+
"ai-workflow-contract": "0.1.2",
|
|
16
|
+
"halfcode-compiler.xnl": "0.2.2"
|
|
14
17
|
},
|
|
15
18
|
"files": [
|
|
16
19
|
"src"
|
|
@@ -19,4 +22,4 @@
|
|
|
19
22
|
"access": "public",
|
|
20
23
|
"registry": "https://registry.npmjs.com"
|
|
21
24
|
}
|
|
22
|
-
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AIWorkflowAgentResourceProjection,
|
|
3
|
+
AIWorkflowResourceRegistryView,
|
|
4
|
+
} from 'ai-workflow-contract';
|
|
5
|
+
|
|
6
|
+
const sourceRegistryByProjection = new WeakMap<object, AIWorkflowResourceRegistryView>();
|
|
7
|
+
|
|
8
|
+
export function markAIWorkflowAgentResourceProjection(
|
|
9
|
+
projection: AIWorkflowAgentResourceProjection,
|
|
10
|
+
registry: AIWorkflowResourceRegistryView,
|
|
11
|
+
): AIWorkflowAgentResourceProjection {
|
|
12
|
+
sourceRegistryByProjection.set(projection, registry);
|
|
13
|
+
return projection;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function sourceRegistryForAIWorkflowAgentResourceProjection(
|
|
17
|
+
projection: AIWorkflowAgentResourceProjection,
|
|
18
|
+
): AIWorkflowResourceRegistryView | undefined {
|
|
19
|
+
return sourceRegistryByProjection.get(projection);
|
|
20
|
+
}
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AIAgentDefinitionProjection,
|
|
3
|
+
AIAgentMessageProjection,
|
|
4
|
+
AIAgentMessageRole,
|
|
5
|
+
AIWorkflowAgentResourceProjection,
|
|
6
|
+
AIWorkflowAgentResourceRelation,
|
|
7
|
+
AIWorkflowAgentTaskRef,
|
|
8
|
+
AIWorkflowDefinitionResourceKind,
|
|
9
|
+
AIWorkflowResourceProjectionDiagnostic,
|
|
10
|
+
AIWorkflowResourceRegistryView,
|
|
11
|
+
AIWorkflowTypedResourceRef,
|
|
12
|
+
MaterialBindingProjection,
|
|
13
|
+
MaterialCardinality,
|
|
14
|
+
MaterialPortProjection,
|
|
15
|
+
} from 'ai-workflow-contract';
|
|
16
|
+
import type {
|
|
17
|
+
ResourceDependencyEdge,
|
|
18
|
+
ResourceNode,
|
|
19
|
+
ResourceRecord,
|
|
20
|
+
ResourceValue,
|
|
21
|
+
} from 'halfcode-compiler.xnl/resource-core';
|
|
22
|
+
import { markAIWorkflowAgentResourceProjection } from './agent-resource-brand.js';
|
|
23
|
+
import { AIWorkflowResourceProjectionError } from './errors.js';
|
|
24
|
+
|
|
25
|
+
const AGENT_SUBDOMAINS = Object.freeze([
|
|
26
|
+
'Messages',
|
|
27
|
+
'InputSchemaRef',
|
|
28
|
+
'OutputSchemaRef',
|
|
29
|
+
'ToolRefs',
|
|
30
|
+
'EffectPolicyRef',
|
|
31
|
+
'MaterialPortRefs',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export function projectAIWorkflowAgentResources(
|
|
35
|
+
registry: AIWorkflowResourceRegistryView,
|
|
36
|
+
): AIWorkflowAgentResourceProjection {
|
|
37
|
+
const diagnostics: AIWorkflowResourceProjectionDiagnostic[] = [];
|
|
38
|
+
const resources = buildResourceIndex(registry, diagnostics);
|
|
39
|
+
const edges: ResourceDependencyEdge[] = [];
|
|
40
|
+
|
|
41
|
+
const materialPorts = recordsForKind(registry, 'MaterialPort')
|
|
42
|
+
.map((resource) => projectMaterialPort(resource, resources, edges, diagnostics))
|
|
43
|
+
.filter((value): value is MaterialPortProjection => value !== undefined);
|
|
44
|
+
const portsById = new Map(materialPorts.map((port) => [port.resource.resourceId, port]));
|
|
45
|
+
|
|
46
|
+
const agentDefinitions = recordsForKind(registry, 'AIAgentDefinition')
|
|
47
|
+
.map((resource) => projectAgentDefinition(resource, resources, edges, diagnostics))
|
|
48
|
+
.filter((value): value is AIAgentDefinitionProjection => value !== undefined);
|
|
49
|
+
|
|
50
|
+
const materialBindings = recordsForKind(registry, 'MaterialBinding')
|
|
51
|
+
.map((resource) => projectMaterialBinding(resource, resources, portsById, edges, diagnostics))
|
|
52
|
+
.filter((value): value is MaterialBindingProjection => value !== undefined);
|
|
53
|
+
validateBindingIdentity(materialBindings, diagnostics);
|
|
54
|
+
|
|
55
|
+
if (diagnostics.length > 0) throwProjectionError(diagnostics);
|
|
56
|
+
return markAIWorkflowAgentResourceProjection(Object.freeze({
|
|
57
|
+
agentDefinitions: Object.freeze(agentDefinitions),
|
|
58
|
+
materialPorts: Object.freeze(materialPorts),
|
|
59
|
+
materialBindings: Object.freeze(materialBindings),
|
|
60
|
+
dependencyEdges: Object.freeze([...edges].sort(compareEdges)),
|
|
61
|
+
}), registry);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildResourceIndex(
|
|
65
|
+
registry: AIWorkflowResourceRegistryView,
|
|
66
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
67
|
+
): ReadonlyMap<string, ResourceRecord> {
|
|
68
|
+
const resources = new Map<string, ResourceRecord>();
|
|
69
|
+
const records = [...registry.byKind.entries()]
|
|
70
|
+
.sort(([left], [right]) => compareCodeUnits(left, right))
|
|
71
|
+
.flatMap(([, values]) => [...values])
|
|
72
|
+
.sort(compareRecords);
|
|
73
|
+
for (const resource of records) {
|
|
74
|
+
if (resources.has(resource.resourceId)) {
|
|
75
|
+
diagnostics.push(diagnostic(
|
|
76
|
+
'AI_RESOURCE_ID_DUPLICATE',
|
|
77
|
+
resource.documentUri,
|
|
78
|
+
`Resource identity '${resource.resourceId}' occurs more than once in the registry view.`,
|
|
79
|
+
));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
resources.set(resource.resourceId, resource);
|
|
83
|
+
}
|
|
84
|
+
return resources;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function recordsForKind(
|
|
88
|
+
registry: AIWorkflowResourceRegistryView,
|
|
89
|
+
kind: string,
|
|
90
|
+
): readonly ResourceRecord[] {
|
|
91
|
+
return [...(registry.byKind.get(kind) ?? [])].sort(compareRecords);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function projectMaterialPort(
|
|
95
|
+
resource: ResourceRecord,
|
|
96
|
+
resources: ReadonlyMap<string, ResourceRecord>,
|
|
97
|
+
edges: ResourceDependencyEdge[],
|
|
98
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
99
|
+
): MaterialPortProjection | undefined {
|
|
100
|
+
validateAllowedSubdomains(resource, ['SchemaRef'], diagnostics);
|
|
101
|
+
const materialKind = canonicalString(resource.node.properties.materialKind);
|
|
102
|
+
const required = resource.node.properties.required;
|
|
103
|
+
const cardinality = resource.node.properties.cardinality;
|
|
104
|
+
let valid = true;
|
|
105
|
+
if (!materialKind) {
|
|
106
|
+
diagnostics.push(diagnostic('AI_MATERIAL_PORT_KIND_INVALID', resource.documentUri, 'MaterialPort materialKind must be an explicit non-empty string.'));
|
|
107
|
+
valid = false;
|
|
108
|
+
}
|
|
109
|
+
if (typeof required !== 'boolean') {
|
|
110
|
+
diagnostics.push(diagnostic('AI_MATERIAL_PORT_REQUIRED_INVALID', resource.documentUri, 'MaterialPort required must be an explicit boolean.'));
|
|
111
|
+
valid = false;
|
|
112
|
+
}
|
|
113
|
+
if (!isMaterialCardinality(cardinality)) {
|
|
114
|
+
diagnostics.push(diagnostic('AI_MATERIAL_PORT_CARDINALITY_INVALID', resource.documentUri, 'MaterialPort cardinality must be one or many.'));
|
|
115
|
+
valid = false;
|
|
116
|
+
}
|
|
117
|
+
const schema = projectOptionalTypedSubdomain(
|
|
118
|
+
resource,
|
|
119
|
+
'SchemaRef',
|
|
120
|
+
resources,
|
|
121
|
+
'material-port-schema',
|
|
122
|
+
edges,
|
|
123
|
+
diagnostics,
|
|
124
|
+
);
|
|
125
|
+
if (!valid || !materialKind || typeof required !== 'boolean' || !isMaterialCardinality(cardinality)) return undefined;
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
kind: 'MaterialPort',
|
|
128
|
+
fqn: resource.fqn ?? resource.resourceId,
|
|
129
|
+
resource,
|
|
130
|
+
materialKind,
|
|
131
|
+
required,
|
|
132
|
+
cardinality,
|
|
133
|
+
...(schema === undefined ? {} : { schema }),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function projectAgentDefinition(
|
|
138
|
+
resource: ResourceRecord,
|
|
139
|
+
resources: ReadonlyMap<string, ResourceRecord>,
|
|
140
|
+
edges: ResourceDependencyEdge[],
|
|
141
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
142
|
+
): AIAgentDefinitionProjection | undefined {
|
|
143
|
+
validateAllowedSubdomains(resource, AGENT_SUBDOMAINS, diagnostics);
|
|
144
|
+
const messagesNode = requiredSubdomain(resource, 'Messages', diagnostics);
|
|
145
|
+
const toolRefsNode = requiredSubdomain(resource, 'ToolRefs', diagnostics);
|
|
146
|
+
const materialPortRefsNode = requiredSubdomain(resource, 'MaterialPortRefs', diagnostics);
|
|
147
|
+
const messages = messagesNode
|
|
148
|
+
? projectMessages(resource, messagesNode, resources, edges, diagnostics)
|
|
149
|
+
: [];
|
|
150
|
+
const inputSchema = projectOptionalTypedSubdomain(resource, 'InputSchemaRef', resources, 'agent-input-schema', edges, diagnostics);
|
|
151
|
+
const outputSchema = projectOptionalTypedSubdomain(resource, 'OutputSchemaRef', resources, 'agent-output-schema', edges, diagnostics);
|
|
152
|
+
const tools = toolRefsNode
|
|
153
|
+
? projectTypedRefList(resource, toolRefsNode, 'ToolRef', resources, 'agent-tool', edges, diagnostics)
|
|
154
|
+
: [];
|
|
155
|
+
const effectPolicy = projectOptionalTypedSubdomain(resource, 'EffectPolicyRef', resources, 'agent-effect-policy', edges, diagnostics);
|
|
156
|
+
const materialPorts = materialPortRefsNode
|
|
157
|
+
? projectTypedRefList(resource, materialPortRefsNode, 'MaterialPortRef', resources, 'agent-material-port', edges, diagnostics, 'MaterialPort')
|
|
158
|
+
: [];
|
|
159
|
+
|
|
160
|
+
if (!messagesNode || !toolRefsNode || !materialPortRefsNode) return undefined;
|
|
161
|
+
return Object.freeze({
|
|
162
|
+
kind: 'AIAgentDefinition',
|
|
163
|
+
fqn: resource.fqn ?? resource.resourceId,
|
|
164
|
+
resource,
|
|
165
|
+
messages: Object.freeze(messages),
|
|
166
|
+
...(inputSchema === undefined ? {} : { inputSchema }),
|
|
167
|
+
...(outputSchema === undefined ? {} : { outputSchema }),
|
|
168
|
+
tools: Object.freeze(tools),
|
|
169
|
+
...(effectPolicy === undefined ? {} : { effectPolicy }),
|
|
170
|
+
materialPorts: Object.freeze(materialPorts),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function projectMessages(
|
|
175
|
+
resource: ResourceRecord,
|
|
176
|
+
messagesNode: ResourceNode,
|
|
177
|
+
resources: ReadonlyMap<string, ResourceRecord>,
|
|
178
|
+
edges: ResourceDependencyEdge[],
|
|
179
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
180
|
+
): AIAgentMessageProjection[] {
|
|
181
|
+
const output: AIAgentMessageProjection[] = [];
|
|
182
|
+
const ids = new Set<string>();
|
|
183
|
+
messagesNode.body.forEach((value, index) => {
|
|
184
|
+
const location = `${resource.documentUri}#Messages:${index}`;
|
|
185
|
+
if (!isResourceNode(value) || value.tag !== 'Message') {
|
|
186
|
+
diagnostics.push(diagnostic('AI_AGENT_MESSAGE_NODE_INVALID', location, 'Messages may contain only Message nodes.'));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
validateAllowedNodeSubdomains(value, ['SchemaRef'], location, diagnostics);
|
|
190
|
+
const id = canonicalString(value.resourceId);
|
|
191
|
+
const role = value.properties.role;
|
|
192
|
+
if (!id) {
|
|
193
|
+
diagnostics.push(diagnostic('AI_AGENT_MESSAGE_ID_MISSING', location, 'Message must declare an explicit #id.'));
|
|
194
|
+
} else if (ids.has(id)) {
|
|
195
|
+
diagnostics.push(diagnostic('AI_AGENT_MESSAGE_ID_DUPLICATE', location, `Message id '${id}' is declared more than once.`));
|
|
196
|
+
} else {
|
|
197
|
+
ids.add(id);
|
|
198
|
+
}
|
|
199
|
+
if (!isMessageRole(role)) {
|
|
200
|
+
diagnostics.push(diagnostic('AI_AGENT_MESSAGE_ROLE_INVALID', location, 'Message role must be system, developer, user, or assistant.'));
|
|
201
|
+
}
|
|
202
|
+
const prompt = projectTypedValues(
|
|
203
|
+
value.properties.promptKind,
|
|
204
|
+
value.properties.promptRef,
|
|
205
|
+
resources,
|
|
206
|
+
location,
|
|
207
|
+
diagnostics,
|
|
208
|
+
);
|
|
209
|
+
if (prompt) edges.push(edge(resource.resourceId, prompt.resource.resourceId, 'agent-message-prompt'));
|
|
210
|
+
const schemaNode = value.subdomains.SchemaRef;
|
|
211
|
+
const schema = schemaNode?.tag === 'SchemaRef'
|
|
212
|
+
? projectTypedRefNode(schemaNode, resources, `${location}#SchemaRef`, diagnostics)
|
|
213
|
+
: undefined;
|
|
214
|
+
if (schemaNode && schemaNode.tag !== 'SchemaRef') diagnostics.push(diagnostic('AI_RESOURCE_SUBDOMAIN_TAG_MISMATCH', `${location}#SchemaRef`, `Subdomain SchemaRef must contain a SchemaRef node, got '${schemaNode.tag}'.`));
|
|
215
|
+
if (schema) edges.push(edge(resource.resourceId, schema.resource.resourceId, 'agent-message-schema'));
|
|
216
|
+
if (!id || !isMessageRole(role) || !prompt) return;
|
|
217
|
+
output.push(Object.freeze({ id, role, prompt, ...(schema === undefined ? {} : { schema }) }));
|
|
218
|
+
});
|
|
219
|
+
return output;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function projectTypedRefList(
|
|
223
|
+
owner: ResourceRecord,
|
|
224
|
+
list: ResourceNode,
|
|
225
|
+
childTag: string,
|
|
226
|
+
resources: ReadonlyMap<string, ResourceRecord>,
|
|
227
|
+
relation: AIWorkflowAgentResourceRelation,
|
|
228
|
+
edges: ResourceDependencyEdge[],
|
|
229
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
230
|
+
expectedKind?: string,
|
|
231
|
+
): AIWorkflowTypedResourceRef[] {
|
|
232
|
+
const output: AIWorkflowTypedResourceRef[] = [];
|
|
233
|
+
const ids = new Set<string>();
|
|
234
|
+
const targetIds = new Set<string>();
|
|
235
|
+
list.body.forEach((value, index) => {
|
|
236
|
+
const location = `${owner.documentUri}#${list.tag}:${index}`;
|
|
237
|
+
if (!isResourceNode(value) || value.tag !== childTag) {
|
|
238
|
+
diagnostics.push(diagnostic('AI_RESOURCE_REF_NODE_INVALID', location, `${list.tag} may contain only ${childTag} nodes.`));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const id = canonicalString(value.resourceId);
|
|
242
|
+
if (!id) diagnostics.push(diagnostic('AI_RESOURCE_REF_ID_MISSING', location, `${childTag} must declare an explicit #id.`));
|
|
243
|
+
else if (ids.has(id)) diagnostics.push(diagnostic('AI_RESOURCE_REF_ID_DUPLICATE', location, `${childTag} id '${id}' is declared more than once.`));
|
|
244
|
+
else ids.add(id);
|
|
245
|
+
const typedRef = projectTypedRefNode(value, resources, location, diagnostics, expectedKind);
|
|
246
|
+
if (typedRef && targetIds.has(typedRef.resource.resourceId)) {
|
|
247
|
+
diagnostics.push(diagnostic('AI_RESOURCE_REF_TARGET_DUPLICATE', location, `${childTag} target '${typedRef.resource.resourceId}' is declared more than once.`));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (!typedRef || !id) return;
|
|
251
|
+
targetIds.add(typedRef.resource.resourceId);
|
|
252
|
+
output.push(typedRef);
|
|
253
|
+
edges.push(edge(owner.resourceId, typedRef.resource.resourceId, relation));
|
|
254
|
+
});
|
|
255
|
+
return output;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function projectOptionalTypedSubdomain(
|
|
259
|
+
owner: ResourceRecord,
|
|
260
|
+
name: string,
|
|
261
|
+
resources: ReadonlyMap<string, ResourceRecord>,
|
|
262
|
+
relation: AIWorkflowAgentResourceRelation,
|
|
263
|
+
edges: ResourceDependencyEdge[],
|
|
264
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
265
|
+
): AIWorkflowTypedResourceRef | undefined {
|
|
266
|
+
const node = owner.node.subdomains[name];
|
|
267
|
+
if (!node) return undefined;
|
|
268
|
+
if (node.tag !== name) {
|
|
269
|
+
diagnostics.push(diagnostic('AI_RESOURCE_SUBDOMAIN_TAG_MISMATCH', `${owner.documentUri}#${name}`, `Subdomain ${name} must contain a ${name} node, got '${node.tag}'.`));
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
const typedRef = projectTypedRefNode(node, resources, `${owner.documentUri}#${name}`, diagnostics);
|
|
273
|
+
if (typedRef) edges.push(edge(owner.resourceId, typedRef.resource.resourceId, relation));
|
|
274
|
+
return typedRef;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function projectMaterialBinding(
|
|
278
|
+
resource: ResourceRecord,
|
|
279
|
+
resources: ReadonlyMap<string, ResourceRecord>,
|
|
280
|
+
portsById: ReadonlyMap<string, MaterialPortProjection>,
|
|
281
|
+
edges: ResourceDependencyEdge[],
|
|
282
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
283
|
+
): MaterialBindingProjection | undefined {
|
|
284
|
+
validateAllowedSubdomains(resource, ['AgentTaskRef', 'PortRef', 'MaterialRef'], diagnostics);
|
|
285
|
+
const taskNode = requiredSubdomain(resource, 'AgentTaskRef', diagnostics);
|
|
286
|
+
const portNode = requiredSubdomain(resource, 'PortRef', diagnostics);
|
|
287
|
+
const materialNode = requiredSubdomain(resource, 'MaterialRef', diagnostics);
|
|
288
|
+
const task = taskNode ? projectTaskRef(taskNode, resources, `${resource.documentUri}#AgentTaskRef`, diagnostics) : undefined;
|
|
289
|
+
const portRef = portNode ? projectTypedRefNode(portNode, resources, `${resource.documentUri}#PortRef`, diagnostics, 'MaterialPort') : undefined;
|
|
290
|
+
const material = materialNode ? projectTypedRefNode(materialNode, resources, `${resource.documentUri}#MaterialRef`, diagnostics) : undefined;
|
|
291
|
+
if (portRef) edges.push(edge(resource.resourceId, portRef.resource.resourceId, 'material-binding-port'));
|
|
292
|
+
if (material) edges.push(edge(resource.resourceId, material.resource.resourceId, 'material-binding-material'));
|
|
293
|
+
const port = portRef ? portsById.get(portRef.resource.resourceId) : undefined;
|
|
294
|
+
if (portRef && !port) diagnostics.push(diagnostic('AI_MATERIAL_BINDING_PORT_PROJECTION_MISSING', resource.documentUri, `MaterialBinding port '${portRef.resource.resourceId}' has no valid MaterialPort projection.`));
|
|
295
|
+
if (!task || !port || !material) return undefined;
|
|
296
|
+
return Object.freeze({
|
|
297
|
+
kind: 'MaterialBinding',
|
|
298
|
+
fqn: resource.fqn ?? resource.resourceId,
|
|
299
|
+
resource,
|
|
300
|
+
task,
|
|
301
|
+
port,
|
|
302
|
+
material,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function projectTaskRef(
|
|
307
|
+
node: ResourceNode,
|
|
308
|
+
resources: ReadonlyMap<string, ResourceRecord>,
|
|
309
|
+
location: string,
|
|
310
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
311
|
+
): AIWorkflowAgentTaskRef | undefined {
|
|
312
|
+
const workflowKind = node.properties.workflowKind;
|
|
313
|
+
const workflowRef = canonicalString(node.properties.workflowRef);
|
|
314
|
+
const nodeId = canonicalString(node.properties.nodeId);
|
|
315
|
+
const agentDefinitionRef = canonicalString(node.properties.agentDefinitionRef);
|
|
316
|
+
let valid = true;
|
|
317
|
+
if (!isWorkflowKind(workflowKind)) {
|
|
318
|
+
diagnostics.push(diagnostic('AI_AGENT_TASK_WORKFLOW_KIND_INVALID', location, 'AgentTaskRef workflowKind must be AICtrlWorkflow or AIDataWorkflow.'));
|
|
319
|
+
valid = false;
|
|
320
|
+
}
|
|
321
|
+
const workflow = resolveRef(workflowRef, resources, location, diagnostics);
|
|
322
|
+
if (workflow && isWorkflowKind(workflowKind) && workflow.kind !== workflowKind) {
|
|
323
|
+
diagnostics.push(diagnostic('AI_RESOURCE_REF_KIND_MISMATCH', location, `Resource '${workflow.resourceId}' has kind '${workflow.kind}', expected '${workflowKind}'.`));
|
|
324
|
+
valid = false;
|
|
325
|
+
}
|
|
326
|
+
if (!nodeId) {
|
|
327
|
+
diagnostics.push(diagnostic('AI_AGENT_TASK_NODE_ID_INVALID', location, 'AgentTaskRef nodeId must be an explicit canonical string.'));
|
|
328
|
+
valid = false;
|
|
329
|
+
}
|
|
330
|
+
const agent = resolveRef(agentDefinitionRef, resources, location, diagnostics);
|
|
331
|
+
if (agent && agent.kind !== 'AIAgentDefinition') {
|
|
332
|
+
diagnostics.push(diagnostic('AI_RESOURCE_REF_KIND_MISMATCH', location, `Resource '${agent.resourceId}' has kind '${agent.kind}', expected 'AIAgentDefinition'.`));
|
|
333
|
+
valid = false;
|
|
334
|
+
}
|
|
335
|
+
if (!valid || !isWorkflowKind(workflowKind) || !workflowRef || !resourceIdFromRef(workflowRef) || !nodeId || !agentDefinitionRef || !resourceIdFromRef(agentDefinitionRef) || !workflow || !agent) return undefined;
|
|
336
|
+
return Object.freeze({
|
|
337
|
+
workflowKind,
|
|
338
|
+
workflowRef: workflowRef as `resource://${string}`,
|
|
339
|
+
nodeId,
|
|
340
|
+
agentDefinitionRef: agentDefinitionRef as `resource://${string}`,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function projectTypedRefNode(
|
|
345
|
+
node: ResourceNode,
|
|
346
|
+
resources: ReadonlyMap<string, ResourceRecord>,
|
|
347
|
+
location: string,
|
|
348
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
349
|
+
expectedKind?: string,
|
|
350
|
+
): AIWorkflowTypedResourceRef | undefined {
|
|
351
|
+
return projectTypedValues(node.properties.kind, node.properties.ref, resources, location, diagnostics, expectedKind);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function projectTypedValues(
|
|
355
|
+
kindValue: ResourceValue | undefined,
|
|
356
|
+
refValue: ResourceValue | undefined,
|
|
357
|
+
resources: ReadonlyMap<string, ResourceRecord>,
|
|
358
|
+
location: string,
|
|
359
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
360
|
+
expectedKind?: string,
|
|
361
|
+
): AIWorkflowTypedResourceRef | undefined {
|
|
362
|
+
const kind = canonicalString(kindValue);
|
|
363
|
+
const ref = canonicalString(refValue);
|
|
364
|
+
let valid = true;
|
|
365
|
+
if (!kind || expectedKind && kind !== expectedKind) {
|
|
366
|
+
diagnostics.push(diagnostic('AI_RESOURCE_REF_KIND_INVALID', location, expectedKind ? `Typed ref kind must be '${expectedKind}'.` : 'Typed ref kind must be an explicit canonical string.'));
|
|
367
|
+
valid = false;
|
|
368
|
+
}
|
|
369
|
+
const target = resolveRef(ref, resources, location, diagnostics);
|
|
370
|
+
if (target && kind && target.kind !== kind) {
|
|
371
|
+
diagnostics.push(diagnostic('AI_RESOURCE_REF_KIND_MISMATCH', location, `Resource '${target.resourceId}' has kind '${target.kind}', expected '${kind}'.`));
|
|
372
|
+
valid = false;
|
|
373
|
+
}
|
|
374
|
+
if (!valid || !kind || !ref || !resourceIdFromRef(ref) || !target) return undefined;
|
|
375
|
+
return Object.freeze({ kind, ref: ref as `resource://${string}`, resource: target });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function resolveRef(
|
|
379
|
+
ref: string | undefined,
|
|
380
|
+
resources: ReadonlyMap<string, ResourceRecord>,
|
|
381
|
+
location: string,
|
|
382
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
383
|
+
): ResourceRecord | undefined {
|
|
384
|
+
const resourceId = resourceIdFromRef(ref);
|
|
385
|
+
if (!resourceId) {
|
|
386
|
+
diagnostics.push(diagnostic('AI_RESOURCE_REF_INVALID', location, 'Typed ref must use one exact non-empty resource:// identity.'));
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
const target = resources.get(resourceId);
|
|
390
|
+
if (!target) diagnostics.push(diagnostic('AI_RESOURCE_REF_TARGET_MISSING', location, `Resource '${resourceId}' does not exist in the registry view.`));
|
|
391
|
+
return target;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function requiredSubdomain(
|
|
395
|
+
resource: ResourceRecord,
|
|
396
|
+
name: string,
|
|
397
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
398
|
+
): ResourceNode | undefined {
|
|
399
|
+
const node = resource.node.subdomains[name];
|
|
400
|
+
if (!node) diagnostics.push(diagnostic('AI_RESOURCE_SUBDOMAIN_MISSING', resource.documentUri, `${resource.kind} '${resource.resourceId}' must declare ${name}.`));
|
|
401
|
+
else if (node.tag !== name) {
|
|
402
|
+
diagnostics.push(diagnostic('AI_RESOURCE_SUBDOMAIN_TAG_MISMATCH', `${resource.documentUri}#${name}`, `Subdomain ${name} must contain a ${name} node, got '${node.tag}'.`));
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
return node;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function validateAllowedSubdomains(
|
|
409
|
+
resource: ResourceRecord,
|
|
410
|
+
allowed: readonly string[],
|
|
411
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
412
|
+
): void {
|
|
413
|
+
validateAllowedNodeSubdomains(resource.node, allowed, resource.documentUri, diagnostics);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function validateAllowedNodeSubdomains(
|
|
417
|
+
node: ResourceNode,
|
|
418
|
+
allowed: readonly string[],
|
|
419
|
+
location: string,
|
|
420
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
421
|
+
): void {
|
|
422
|
+
const allowedNames = new Set(allowed);
|
|
423
|
+
for (const name of Object.keys(node.subdomains)) {
|
|
424
|
+
if (!allowedNames.has(name)) diagnostics.push(diagnostic('AI_RESOURCE_SUBDOMAIN_UNSUPPORTED', `${location}#${name}`, `Subdomain '${name}' is not part of the fixed ${node.tag} contract.`));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function validateBindingIdentity(
|
|
429
|
+
bindings: readonly MaterialBindingProjection[],
|
|
430
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
431
|
+
): void {
|
|
432
|
+
const keys = new Map<string, string>();
|
|
433
|
+
for (const binding of bindings) {
|
|
434
|
+
const key = `${taskKey(binding.task)}\u0000${binding.port.resource.resourceId}\u0000${binding.material.resource.resourceId}`;
|
|
435
|
+
const previous = keys.get(key);
|
|
436
|
+
if (previous) diagnostics.push(diagnostic('AI_MATERIAL_BINDING_DUPLICATE', binding.resource.documentUri, `MaterialBinding duplicates the relation declared by '${previous}'.`));
|
|
437
|
+
else keys.set(key, binding.resource.resourceId);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function throwProjectionError(diagnostics: AIWorkflowResourceProjectionDiagnostic[]): never {
|
|
442
|
+
throw new AIWorkflowResourceProjectionError(Object.freeze([...diagnostics].sort((left, right) =>
|
|
443
|
+
compareCodeUnits(left.location, right.location) || compareCodeUnits(left.code, right.code),
|
|
444
|
+
)));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function edge(fromResourceId: string, toResourceId: string, relation: AIWorkflowAgentResourceRelation): ResourceDependencyEdge {
|
|
448
|
+
return Object.freeze({ fromResourceId, toResourceId, relation, declaredBy: fromResourceId });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function compareEdges(left: ResourceDependencyEdge, right: ResourceDependencyEdge): number {
|
|
452
|
+
return compareCodeUnits(left.fromResourceId, right.fromResourceId)
|
|
453
|
+
|| compareCodeUnits(left.toResourceId, right.toResourceId)
|
|
454
|
+
|| compareCodeUnits(left.relation, right.relation)
|
|
455
|
+
|| compareCodeUnits(left.declaredBy, right.declaredBy);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function compareRecords(left: ResourceRecord, right: ResourceRecord): number {
|
|
459
|
+
return compareCodeUnits(left.resourceId, right.resourceId) || compareCodeUnits(left.kind, right.kind);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function taskKey(task: AIWorkflowAgentTaskRef): string {
|
|
463
|
+
return `${task.workflowKind}\u0000${task.workflowRef}\u0000${task.nodeId}\u0000${task.agentDefinitionRef}`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function resourceIdFromRef(value: string | undefined): string | undefined {
|
|
467
|
+
const prefix = 'resource://';
|
|
468
|
+
if (!value || value !== value.trim() || !value.startsWith(prefix)) return undefined;
|
|
469
|
+
const resourceId = value.slice(prefix.length);
|
|
470
|
+
return resourceId.length > 0 && !resourceId.includes('://') && resourceId === resourceId.trim()
|
|
471
|
+
? resourceId
|
|
472
|
+
: undefined;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function canonicalString(value: ResourceValue | string | undefined): string | undefined {
|
|
476
|
+
return typeof value === 'string' && value.length > 0 && value === value.trim() ? value : undefined;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function isMessageRole(value: ResourceValue | undefined): value is AIAgentMessageRole {
|
|
480
|
+
return value === 'system' || value === 'developer' || value === 'user' || value === 'assistant';
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function isMaterialCardinality(value: ResourceValue | undefined): value is MaterialCardinality {
|
|
484
|
+
return value === 'one' || value === 'many';
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function isWorkflowKind(value: ResourceValue | undefined): value is AIWorkflowDefinitionResourceKind {
|
|
488
|
+
return value === 'AICtrlWorkflow' || value === 'AIDataWorkflow';
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function isResourceNode(value: ResourceValue): value is ResourceNode {
|
|
492
|
+
return typeof value === 'object'
|
|
493
|
+
&& value !== null
|
|
494
|
+
&& !Array.isArray(value)
|
|
495
|
+
&& typeof (value as ResourceNode).tag === 'string'
|
|
496
|
+
&& Array.isArray((value as ResourceNode).body);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function diagnostic(code: string, location: string, message: string): AIWorkflowResourceProjectionDiagnostic {
|
|
500
|
+
return Object.freeze({ code, location, message });
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function compareCodeUnits(left: string, right: string): number {
|
|
504
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
505
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AIWorkflowResourceProjectionDiagnostic } from 'ai-workflow-contract';
|
|
2
|
+
|
|
3
|
+
export class AIWorkflowFoundationError extends Error {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'AIWorkflowFoundationError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class AIWorkflowResourceProjectionError extends AIWorkflowFoundationError {
|
|
11
|
+
constructor(public readonly diagnostics: readonly AIWorkflowResourceProjectionDiagnostic[]) {
|
|
12
|
+
super(`AI workflow resource projection failed with ${diagnostics.length} diagnostic(s)`);
|
|
13
|
+
this.name = 'AIWorkflowResourceProjectionError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {
|
|
2
|
+
loadResourceTree,
|
|
3
|
+
type LoadResourceTreeOptions,
|
|
4
|
+
} from 'halfcode-compiler.xnl/resource-core';
|
|
5
|
+
import type { LoadedAIWorkflowResourcePackage } from 'ai-workflow-contract';
|
|
6
|
+
import { projectAIWorkflowAppBundles } from './index.js';
|
|
7
|
+
|
|
8
|
+
export async function loadAIWorkflowResourcePackage(
|
|
9
|
+
options: LoadResourceTreeOptions,
|
|
10
|
+
): Promise<LoadedAIWorkflowResourcePackage> {
|
|
11
|
+
const tree = await loadResourceTree(options);
|
|
12
|
+
return Object.freeze({
|
|
13
|
+
tree,
|
|
14
|
+
appBundles: projectAIWorkflowAppBundles(tree.registry, {
|
|
15
|
+
packageResourceId: tree.manifest.resourceId,
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,9 @@ import {
|
|
|
6
6
|
AI_WORKFLOW_MANUAL_NODE_DEFAULT_REUSE_POLICY,
|
|
7
7
|
type AICtrlWorkflowDefinitionBinding,
|
|
8
8
|
type AIDataWorkflowDefinitionBinding,
|
|
9
|
+
type AIWorkflowAppBundleProjection,
|
|
10
|
+
type AIWorkflowBindingProjection,
|
|
11
|
+
type AIWorkflowDefinitionResourceKind,
|
|
9
12
|
type AIWorkflowEffectiveReusePolicy,
|
|
10
13
|
type AIWorkflowEmbeddedEffectProvider,
|
|
11
14
|
type AIWorkflowEffectProvider,
|
|
@@ -17,6 +20,8 @@ import {
|
|
|
17
20
|
type AIWorkflowKind,
|
|
18
21
|
type AIWorkflowNodeType,
|
|
19
22
|
type AIWorkflowProfileDescriptor,
|
|
23
|
+
type AIWorkflowResourceProjectionDiagnostic,
|
|
24
|
+
type AIWorkflowResourceRegistryView,
|
|
20
25
|
type AIWorkflowResourceRef,
|
|
21
26
|
type AIWorkflowResourceRefScheme,
|
|
22
27
|
type AIWorkflowReusePolicy,
|
|
@@ -24,14 +29,13 @@ import {
|
|
|
24
29
|
type AIWorkflowRuntimeContext,
|
|
25
30
|
type AIWorkflowStateStore,
|
|
26
31
|
type AIWorkflowSubstrate,
|
|
32
|
+
type ProjectAIWorkflowAppBundlesOptions,
|
|
27
33
|
} from 'ai-workflow-contract';
|
|
34
|
+
import type { ResourceNode, ResourceRecord, ResourceValue } from 'halfcode-compiler.xnl/resource-core';
|
|
35
|
+
import { AIWorkflowFoundationError, AIWorkflowResourceProjectionError } from './errors.js';
|
|
28
36
|
|
|
29
|
-
export
|
|
30
|
-
|
|
31
|
-
super(message);
|
|
32
|
-
this.name = 'AIWorkflowFoundationError';
|
|
33
|
-
}
|
|
34
|
-
}
|
|
37
|
+
export { AIWorkflowFoundationError, AIWorkflowResourceProjectionError } from './errors.js';
|
|
38
|
+
export { projectAIWorkflowAgentResources } from './agent-resources.js';
|
|
35
39
|
|
|
36
40
|
export class AIWorkflowEffectHandlerNotFoundError extends AIWorkflowFoundationError {
|
|
37
41
|
constructor(
|
|
@@ -91,6 +95,214 @@ export function defineAIDataWorkflow(
|
|
|
91
95
|
});
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
export function projectAIWorkflowAppBundles(
|
|
99
|
+
registry: AIWorkflowResourceRegistryView,
|
|
100
|
+
options: ProjectAIWorkflowAppBundlesOptions = {},
|
|
101
|
+
): readonly AIWorkflowAppBundleProjection[] {
|
|
102
|
+
const diagnostics: AIWorkflowResourceProjectionDiagnostic[] = [];
|
|
103
|
+
const workflowRecords = new Map<string, ResourceRecord>();
|
|
104
|
+
for (const kind of AI_WORKFLOW_DEFINITION_RESOURCE_KINDS) {
|
|
105
|
+
for (const resource of registry.byKind.get(kind) ?? []) {
|
|
106
|
+
workflowRecords.set(resource.resourceId, resource);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const appBundles = [...(registry.byKind.get('AIWorkflowAppBundle') ?? [])]
|
|
111
|
+
.sort((left, right) => compareCodeUnits(left.resourceId, right.resourceId))
|
|
112
|
+
.map((resource) => projectAppBundle(resource, workflowRecords, options, diagnostics));
|
|
113
|
+
|
|
114
|
+
if (diagnostics.length > 0) {
|
|
115
|
+
throw new AIWorkflowResourceProjectionError(Object.freeze(
|
|
116
|
+
diagnostics.sort((left, right) => compareCodeUnits(left.location, right.location)
|
|
117
|
+
|| compareCodeUnits(left.code, right.code)),
|
|
118
|
+
));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return Object.freeze(appBundles.filter((bundle): bundle is AIWorkflowAppBundleProjection => bundle !== undefined));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const AI_WORKFLOW_DEFINITION_RESOURCE_KINDS = Object.freeze([
|
|
125
|
+
'AICtrlWorkflow',
|
|
126
|
+
'AIDataWorkflow',
|
|
127
|
+
] as const satisfies readonly AIWorkflowDefinitionResourceKind[]);
|
|
128
|
+
|
|
129
|
+
function projectAppBundle(
|
|
130
|
+
resource: ResourceRecord,
|
|
131
|
+
workflowRecords: ReadonlyMap<string, ResourceRecord>,
|
|
132
|
+
options: ProjectAIWorkflowAppBundlesOptions,
|
|
133
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
134
|
+
): AIWorkflowAppBundleProjection | undefined {
|
|
135
|
+
const bindingsNode = resource.node.subdomains.WorkflowBindings;
|
|
136
|
+
if (!bindingsNode) {
|
|
137
|
+
diagnostics.push(projectionDiagnostic(
|
|
138
|
+
'AI_WORKFLOW_BINDINGS_MISSING',
|
|
139
|
+
resource.documentUri,
|
|
140
|
+
`AIWorkflowAppBundle '${resource.resourceId}' must declare WorkflowBindings.`,
|
|
141
|
+
));
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const bindingIds = new Set<string>();
|
|
146
|
+
const workflowBindings: AIWorkflowBindingProjection[] = [];
|
|
147
|
+
for (const [index, value] of bindingsNode.body.entries()) {
|
|
148
|
+
const location = `${resource.documentUri}#WorkflowBindings:${index}`;
|
|
149
|
+
if (!isResourceNode(value) || value.tag !== 'WorkflowBinding') {
|
|
150
|
+
diagnostics.push(projectionDiagnostic(
|
|
151
|
+
'AI_WORKFLOW_BINDING_NODE_INVALID',
|
|
152
|
+
location,
|
|
153
|
+
'WorkflowBindings may contain only WorkflowBinding nodes.',
|
|
154
|
+
));
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const binding = projectWorkflowBinding(value, workflowRecords, location, diagnostics);
|
|
158
|
+
if (!binding) continue;
|
|
159
|
+
if (bindingIds.has(binding.id)) {
|
|
160
|
+
diagnostics.push(projectionDiagnostic(
|
|
161
|
+
'AI_WORKFLOW_BINDING_ID_DUPLICATE',
|
|
162
|
+
location,
|
|
163
|
+
`WorkflowBinding id '${binding.id}' is declared more than once.`,
|
|
164
|
+
));
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
bindingIds.add(binding.id);
|
|
168
|
+
workflowBindings.push(binding);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const entrypoints = workflowBindings.filter((binding) => binding.entrypoint);
|
|
172
|
+
if (entrypoints.length === 0) {
|
|
173
|
+
diagnostics.push(projectionDiagnostic(
|
|
174
|
+
'AI_WORKFLOW_APP_ENTRYPOINT_MISSING',
|
|
175
|
+
resource.documentUri,
|
|
176
|
+
`AIWorkflowAppBundle '${resource.resourceId}' must declare at least one workflow entrypoint.`,
|
|
177
|
+
));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const frozenBindings = Object.freeze(workflowBindings);
|
|
181
|
+
return Object.freeze({
|
|
182
|
+
kind: 'AIWorkflowAppBundle',
|
|
183
|
+
fqn: resource.fqn ?? resource.resourceId,
|
|
184
|
+
...(options.packageResourceId === undefined ? {} : { packageResourceId: options.packageResourceId }),
|
|
185
|
+
resource,
|
|
186
|
+
workflowBindings: frozenBindings,
|
|
187
|
+
entrypoints: Object.freeze(entrypoints),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function projectWorkflowBinding(
|
|
192
|
+
node: ResourceNode,
|
|
193
|
+
workflowRecords: ReadonlyMap<string, ResourceRecord>,
|
|
194
|
+
location: string,
|
|
195
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
196
|
+
): AIWorkflowBindingProjection | undefined {
|
|
197
|
+
const id = node.resourceId;
|
|
198
|
+
const kind = asString(node.properties.kind);
|
|
199
|
+
const ref = asString(node.properties.ref);
|
|
200
|
+
const entrypoint = node.properties.entrypoint;
|
|
201
|
+
let valid = true;
|
|
202
|
+
|
|
203
|
+
if (!id) {
|
|
204
|
+
diagnostics.push(projectionDiagnostic(
|
|
205
|
+
'AI_WORKFLOW_BINDING_ID_MISSING',
|
|
206
|
+
location,
|
|
207
|
+
'WorkflowBinding must declare an explicit #id.',
|
|
208
|
+
));
|
|
209
|
+
valid = false;
|
|
210
|
+
}
|
|
211
|
+
if (!isWorkflowDefinitionResourceKind(kind)) {
|
|
212
|
+
diagnostics.push(projectionDiagnostic(
|
|
213
|
+
'AI_WORKFLOW_BINDING_KIND_INVALID',
|
|
214
|
+
location,
|
|
215
|
+
'WorkflowBinding kind must be AICtrlWorkflow or AIDataWorkflow.',
|
|
216
|
+
));
|
|
217
|
+
valid = false;
|
|
218
|
+
}
|
|
219
|
+
const resourceId = resourceIdFromRef(ref);
|
|
220
|
+
if (!resourceId) {
|
|
221
|
+
diagnostics.push(projectionDiagnostic(
|
|
222
|
+
'AI_WORKFLOW_BINDING_REF_INVALID',
|
|
223
|
+
location,
|
|
224
|
+
'WorkflowBinding ref must be a non-empty resource:// identity.',
|
|
225
|
+
));
|
|
226
|
+
valid = false;
|
|
227
|
+
}
|
|
228
|
+
if (typeof entrypoint !== 'boolean') {
|
|
229
|
+
diagnostics.push(projectionDiagnostic(
|
|
230
|
+
'AI_WORKFLOW_BINDING_ENTRYPOINT_INVALID',
|
|
231
|
+
location,
|
|
232
|
+
'WorkflowBinding entrypoint must be an explicit boolean.',
|
|
233
|
+
));
|
|
234
|
+
valid = false;
|
|
235
|
+
}
|
|
236
|
+
if (
|
|
237
|
+
!valid
|
|
238
|
+
|| !id
|
|
239
|
+
|| !isWorkflowDefinitionResourceKind(kind)
|
|
240
|
+
|| !resourceId
|
|
241
|
+
|| typeof entrypoint !== 'boolean'
|
|
242
|
+
) return undefined;
|
|
243
|
+
|
|
244
|
+
const target = workflowRecords.get(resourceId);
|
|
245
|
+
if (!target) {
|
|
246
|
+
diagnostics.push(projectionDiagnostic(
|
|
247
|
+
'AI_WORKFLOW_BINDING_TARGET_MISSING',
|
|
248
|
+
location,
|
|
249
|
+
`WorkflowBinding target '${resourceId}' does not exist in the effective resource registry.`,
|
|
250
|
+
));
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
if (target.kind !== kind) {
|
|
254
|
+
diagnostics.push(projectionDiagnostic(
|
|
255
|
+
'AI_WORKFLOW_BINDING_TARGET_KIND_MISMATCH',
|
|
256
|
+
location,
|
|
257
|
+
`WorkflowBinding declares kind '${kind}', but target '${resourceId}' has kind '${target.kind}'.`,
|
|
258
|
+
));
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return Object.freeze({
|
|
263
|
+
id,
|
|
264
|
+
kind,
|
|
265
|
+
ref: ref as `resource://${string}`,
|
|
266
|
+
entrypoint,
|
|
267
|
+
resource: target,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function isWorkflowDefinitionResourceKind(value: string | undefined): value is AIWorkflowDefinitionResourceKind {
|
|
272
|
+
return value === 'AICtrlWorkflow' || value === 'AIDataWorkflow';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function resourceIdFromRef(value: string | undefined): string | undefined {
|
|
276
|
+
const prefix = 'resource://';
|
|
277
|
+
if (!value?.startsWith(prefix)) return undefined;
|
|
278
|
+
const resourceId = value.slice(prefix.length);
|
|
279
|
+
return resourceId.length > 0 && !resourceId.includes('://') ? resourceId : undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function asString(value: ResourceValue | undefined): string | undefined {
|
|
283
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isResourceNode(value: ResourceValue): value is ResourceNode {
|
|
287
|
+
return typeof value === 'object'
|
|
288
|
+
&& value !== null
|
|
289
|
+
&& !Array.isArray(value)
|
|
290
|
+
&& typeof (value as ResourceNode).tag === 'string'
|
|
291
|
+
&& Array.isArray((value as ResourceNode).body);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function projectionDiagnostic(
|
|
295
|
+
code: string,
|
|
296
|
+
location: string,
|
|
297
|
+
message: string,
|
|
298
|
+
): AIWorkflowResourceProjectionDiagnostic {
|
|
299
|
+
return Object.freeze({ code, location, message });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function compareCodeUnits(left: string, right: string): number {
|
|
303
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
94
306
|
export interface ResolveAIWorkflowReusePolicyInput {
|
|
95
307
|
readonly nodeType: AIWorkflowNodeType;
|
|
96
308
|
readonly explicitPolicy?: AIWorkflowReusePolicy;
|
|
@@ -273,6 +485,7 @@ function hasContainmentEscape(target: string): boolean {
|
|
|
273
485
|
return decoded.split(/[/?#]+/).some((segment) => segment === '..');
|
|
274
486
|
}
|
|
275
487
|
|
|
488
|
+
/** @deprecated Compatibility helper; Halfcode owns canonical resource-reference validation. */
|
|
276
489
|
export function parseAIWorkflowResourceRef(value: string): ParsedAIWorkflowResourceRef | undefined {
|
|
277
490
|
const match = RESOURCE_REF_PATTERN.exec(value);
|
|
278
491
|
if (!match) return undefined;
|
|
@@ -286,10 +499,12 @@ export function parseAIWorkflowResourceRef(value: string): ParsedAIWorkflowResou
|
|
|
286
499
|
};
|
|
287
500
|
}
|
|
288
501
|
|
|
502
|
+
/** @deprecated Compatibility helper; use typed refs projected from Halfcode ResourceRecord facts. */
|
|
289
503
|
export function isAIWorkflowResourceRef(value: string): value is AIWorkflowResourceRef {
|
|
290
504
|
return parseAIWorkflowResourceRef(value) !== undefined;
|
|
291
505
|
}
|
|
292
506
|
|
|
507
|
+
/** @deprecated Compatibility helper; use typed refs projected from Halfcode ResourceRecord facts. */
|
|
293
508
|
export function assertAIWorkflowResourceRef(value: string): AIWorkflowResourceRef {
|
|
294
509
|
const parsed = parseAIWorkflowResourceRef(value);
|
|
295
510
|
if (!parsed) {
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AIAgentDefinitionProjection,
|
|
3
|
+
AIWorkflowAgentResourceProjection,
|
|
4
|
+
AIWorkflowAgentTaskRef,
|
|
5
|
+
AIWorkflowResourceProjectionDiagnostic,
|
|
6
|
+
AIWorkflowRunResourceFreezeReceipt,
|
|
7
|
+
MaterialBindingProjection,
|
|
8
|
+
MaterialPortProjection,
|
|
9
|
+
} from 'ai-workflow-contract';
|
|
10
|
+
import {
|
|
11
|
+
buildResourceDependencySnapshot,
|
|
12
|
+
sha256Digest,
|
|
13
|
+
type EffectiveResourceRegistry,
|
|
14
|
+
type ResourceContentIdentity,
|
|
15
|
+
type ResourceDependencyEdge,
|
|
16
|
+
} from 'halfcode-compiler.xnl/resource-core';
|
|
17
|
+
import { sourceRegistryForAIWorkflowAgentResourceProjection } from './agent-resource-brand.js';
|
|
18
|
+
|
|
19
|
+
export interface FreezeAIWorkflowRunResourcesInput {
|
|
20
|
+
readonly registry: EffectiveResourceRegistry;
|
|
21
|
+
readonly projection: AIWorkflowAgentResourceProjection;
|
|
22
|
+
readonly task: AIWorkflowAgentTaskRef;
|
|
23
|
+
readonly contentIdentities: ReadonlyMap<string, ResourceContentIdentity>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class AIWorkflowRunResourceFreezeError extends Error {
|
|
27
|
+
constructor(public readonly diagnostics: readonly AIWorkflowResourceProjectionDiagnostic[]) {
|
|
28
|
+
super(`AI workflow run resource freeze failed with ${diagnostics.length} diagnostic(s)`);
|
|
29
|
+
this.name = 'AIWorkflowRunResourceFreezeError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function freezeAIWorkflowRunResources(
|
|
34
|
+
input: FreezeAIWorkflowRunResourcesInput,
|
|
35
|
+
): AIWorkflowRunResourceFreezeReceipt {
|
|
36
|
+
const diagnostics: AIWorkflowResourceProjectionDiagnostic[] = [];
|
|
37
|
+
if (sourceRegistryForAIWorkflowAgentResourceProjection(input.projection) !== input.registry) {
|
|
38
|
+
diagnostics.push(diagnostic(
|
|
39
|
+
'AI_RUN_RESOURCE_PROJECTION_UNTRUSTED',
|
|
40
|
+
'run-freeze:projection',
|
|
41
|
+
'Run freeze requires the exact immutable Agent resource projection produced for this registry.',
|
|
42
|
+
));
|
|
43
|
+
throwFreezeError(diagnostics);
|
|
44
|
+
}
|
|
45
|
+
const task = normalizeTask(input.task, diagnostics);
|
|
46
|
+
if (!task) throwFreezeError(diagnostics);
|
|
47
|
+
const workflowId = resourceIdFromRef(task.workflowRef)!;
|
|
48
|
+
const agentId = resourceIdFromRef(task.agentDefinitionRef)!;
|
|
49
|
+
const workflow = input.registry.byId.get(workflowId)?.resource;
|
|
50
|
+
if (!workflow || workflow.kind !== task.workflowKind) diagnostics.push(diagnostic('AI_RUN_WORKFLOW_MISSING', `resource://${workflowId}`, `Workflow '${workflowId}' is absent or has another kind.`));
|
|
51
|
+
const agent = input.projection.agentDefinitions.find((value) => value.resource.resourceId === agentId);
|
|
52
|
+
if (!agent) diagnostics.push(diagnostic('AI_RUN_AGENT_DEFINITION_MISSING', `resource://${agentId}`, `Agent definition '${agentId}' is absent from the domain projection.`));
|
|
53
|
+
if (diagnostics.length > 0 || !workflow || !agent) throwFreezeError(diagnostics);
|
|
54
|
+
|
|
55
|
+
const selectedBindings = input.projection.materialBindings.filter((binding) => sameTask(binding.task, task));
|
|
56
|
+
const orderedBindings = validateAndOrderBindings(
|
|
57
|
+
agent,
|
|
58
|
+
input.projection.materialPorts,
|
|
59
|
+
selectedBindings,
|
|
60
|
+
diagnostics,
|
|
61
|
+
);
|
|
62
|
+
if (diagnostics.length > 0) throwFreezeError(diagnostics);
|
|
63
|
+
|
|
64
|
+
const workflowEdges: ResourceDependencyEdge[] = [
|
|
65
|
+
edge(workflowId, agentId, 'workflow-agent-task'),
|
|
66
|
+
...orderedBindings.map((binding) => edge(workflowId, binding.resource.resourceId, 'workflow-material-binding')),
|
|
67
|
+
];
|
|
68
|
+
const dependencySnapshot = buildResourceDependencySnapshot({
|
|
69
|
+
registry: input.registry,
|
|
70
|
+
roots: [workflowId, agentId, ...orderedBindings.map((binding) => binding.resource.resourceId)],
|
|
71
|
+
edges: [...input.projection.dependencyEdges, ...workflowEdges],
|
|
72
|
+
contentIdentities: input.contentIdentities,
|
|
73
|
+
});
|
|
74
|
+
const frozenTask = Object.freeze({
|
|
75
|
+
workflowKind: task.workflowKind,
|
|
76
|
+
workflowRef: task.workflowRef,
|
|
77
|
+
nodeId: task.nodeId,
|
|
78
|
+
agentDefinitionRef: task.agentDefinitionRef,
|
|
79
|
+
});
|
|
80
|
+
const bindingResourceIds = Object.freeze(orderedBindings.map((binding) => binding.resource.resourceId));
|
|
81
|
+
const semanticFingerprint = semanticResourceFingerprint(
|
|
82
|
+
frozenTask,
|
|
83
|
+
agent,
|
|
84
|
+
orderedBindings,
|
|
85
|
+
dependencySnapshot.closure,
|
|
86
|
+
dependencySnapshot.edges,
|
|
87
|
+
);
|
|
88
|
+
return Object.freeze({
|
|
89
|
+
schemaVersion: 'ai-workflow.run-resource-freeze/v1',
|
|
90
|
+
task: frozenTask,
|
|
91
|
+
bindingResourceIds,
|
|
92
|
+
dependencySnapshot,
|
|
93
|
+
semanticFingerprint,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function validateAndOrderBindings(
|
|
98
|
+
agent: AIAgentDefinitionProjection,
|
|
99
|
+
materialPorts: readonly MaterialPortProjection[],
|
|
100
|
+
selected: readonly MaterialBindingProjection[],
|
|
101
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
102
|
+
): MaterialBindingProjection[] {
|
|
103
|
+
const agentPortOrder = new Map(agent.materialPorts.map((ref, index) => [ref.resource.resourceId, index]));
|
|
104
|
+
const bindingsByPort = new Map<string, MaterialBindingProjection[]>();
|
|
105
|
+
for (const binding of selected) {
|
|
106
|
+
const portId = binding.port.resource.resourceId;
|
|
107
|
+
if (!agentPortOrder.has(portId)) {
|
|
108
|
+
diagnostics.push(diagnostic('AI_RUN_MATERIAL_PORT_FOREIGN', binding.resource.documentUri, `Port '${portId}' is not declared by Agent '${agent.resource.resourceId}'.`));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (binding.material.resource.kind !== binding.port.materialKind) {
|
|
112
|
+
diagnostics.push(diagnostic('AI_RUN_MATERIAL_KIND_MISMATCH', binding.resource.documentUri, `Material '${binding.material.resource.resourceId}' has kind '${binding.material.resource.kind}', expected '${binding.port.materialKind}'.`));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const values = bindingsByPort.get(portId) ?? [];
|
|
116
|
+
values.push(binding);
|
|
117
|
+
bindingsByPort.set(portId, values);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ordered: MaterialBindingProjection[] = [];
|
|
121
|
+
for (const portRef of agent.materialPorts) {
|
|
122
|
+
const port = materialPorts.find((value) => value.resource.resourceId === portRef.resource.resourceId);
|
|
123
|
+
if (!port) {
|
|
124
|
+
diagnostics.push(diagnostic('AI_RUN_MATERIAL_PORT_PROJECTION_MISSING', agent.resource.documentUri, `Agent port '${portRef.resource.resourceId}' has no projected MaterialPort.`));
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const values = [...(bindingsByPort.get(port.resource.resourceId) ?? [])]
|
|
128
|
+
.sort((left, right) => compareCodeUnits(left.resource.resourceId, right.resource.resourceId));
|
|
129
|
+
if (port.required && values.length === 0) diagnostics.push(diagnostic('AI_RUN_REQUIRED_MATERIAL_MISSING', port.resource.documentUri, `Required MaterialPort '${port.resource.resourceId}' has no binding for this task.`));
|
|
130
|
+
if (port.cardinality === 'one' && values.length > 1) diagnostics.push(diagnostic('AI_RUN_MATERIAL_CARDINALITY_EXCEEDED', port.resource.documentUri, `MaterialPort '${port.resource.resourceId}' accepts at most one binding.`));
|
|
131
|
+
ordered.push(...values);
|
|
132
|
+
}
|
|
133
|
+
return ordered;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function semanticResourceFingerprint(
|
|
137
|
+
task: AIWorkflowAgentTaskRef,
|
|
138
|
+
agent: AIAgentDefinitionProjection,
|
|
139
|
+
bindings: readonly MaterialBindingProjection[],
|
|
140
|
+
closure: readonly { resourceId: string; kind: string; contentDigest: string }[],
|
|
141
|
+
edges: readonly ResourceDependencyEdge[],
|
|
142
|
+
): `sha256:${string}` {
|
|
143
|
+
const fact = {
|
|
144
|
+
schemaVersion: 'ai-workflow-resource-semantic-hash/v1',
|
|
145
|
+
task: {
|
|
146
|
+
workflowKind: task.workflowKind,
|
|
147
|
+
workflowRef: task.workflowRef,
|
|
148
|
+
nodeId: task.nodeId,
|
|
149
|
+
agentDefinitionRef: task.agentDefinitionRef,
|
|
150
|
+
},
|
|
151
|
+
agent: {
|
|
152
|
+
resourceId: agent.resource.resourceId,
|
|
153
|
+
messages: agent.messages.map((message) => ({
|
|
154
|
+
id: message.id,
|
|
155
|
+
role: message.role,
|
|
156
|
+
prompt: [message.prompt.kind, message.prompt.resource.resourceId],
|
|
157
|
+
schema: message.schema ? [message.schema.kind, message.schema.resource.resourceId] : null,
|
|
158
|
+
})),
|
|
159
|
+
inputSchema: agent.inputSchema ? [agent.inputSchema.kind, agent.inputSchema.resource.resourceId] : null,
|
|
160
|
+
outputSchema: agent.outputSchema ? [agent.outputSchema.kind, agent.outputSchema.resource.resourceId] : null,
|
|
161
|
+
tools: agent.tools.map((ref) => [ref.kind, ref.resource.resourceId]),
|
|
162
|
+
effectPolicy: agent.effectPolicy ? [agent.effectPolicy.kind, agent.effectPolicy.resource.resourceId] : null,
|
|
163
|
+
materialPorts: agent.materialPorts.map((ref) => [ref.kind, ref.resource.resourceId]),
|
|
164
|
+
},
|
|
165
|
+
bindings: bindings.map((binding) => ({
|
|
166
|
+
resourceId: binding.resource.resourceId,
|
|
167
|
+
port: [binding.port.resource.resourceId, binding.port.materialKind, binding.port.required, binding.port.cardinality],
|
|
168
|
+
material: [binding.material.kind, binding.material.resource.resourceId],
|
|
169
|
+
})),
|
|
170
|
+
closure: closure.map((resource) => ({
|
|
171
|
+
resourceId: resource.resourceId,
|
|
172
|
+
kind: resource.kind,
|
|
173
|
+
contentDigest: resource.contentDigest,
|
|
174
|
+
})),
|
|
175
|
+
edges: edges.map((value) => ({
|
|
176
|
+
fromResourceId: value.fromResourceId,
|
|
177
|
+
toResourceId: value.toResourceId,
|
|
178
|
+
relation: value.relation,
|
|
179
|
+
declaredBy: value.declaredBy,
|
|
180
|
+
})),
|
|
181
|
+
};
|
|
182
|
+
return sha256Digest(JSON.stringify(fact));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeTask(
|
|
186
|
+
value: unknown,
|
|
187
|
+
diagnostics: AIWorkflowResourceProjectionDiagnostic[],
|
|
188
|
+
): AIWorkflowAgentTaskRef | undefined {
|
|
189
|
+
const initialDiagnosticCount = diagnostics.length;
|
|
190
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
191
|
+
diagnostics.push(diagnostic('AI_RUN_AGENT_TASK_INVALID', 'run-freeze:task', 'Run freeze task must be a fixed four-field object.'));
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
const record = value as Record<string, unknown>;
|
|
195
|
+
const allowedFields = new Set([
|
|
196
|
+
'workflowKind',
|
|
197
|
+
'workflowRef',
|
|
198
|
+
'nodeId',
|
|
199
|
+
'agentDefinitionRef',
|
|
200
|
+
]);
|
|
201
|
+
const unsupportedFields = Reflect.ownKeys(record)
|
|
202
|
+
.filter((key) => typeof key !== 'string' || !allowedFields.has(key))
|
|
203
|
+
.map((key) => typeof key === 'string' ? key : key.toString())
|
|
204
|
+
.sort(compareCodeUnits);
|
|
205
|
+
for (const field of unsupportedFields) {
|
|
206
|
+
diagnostics.push(diagnostic('AI_RUN_AGENT_TASK_FIELD_UNSUPPORTED', `run-freeze:task:${field}`, `Run freeze task field '${field}' is not part of the fixed Agent task tuple.`));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const workflowKind = ownDataValue(record, 'workflowKind');
|
|
210
|
+
const workflowRef = ownDataValue(record, 'workflowRef');
|
|
211
|
+
const nodeId = ownDataValue(record, 'nodeId');
|
|
212
|
+
const agentDefinitionRef = ownDataValue(record, 'agentDefinitionRef');
|
|
213
|
+
if (workflowKind !== 'AICtrlWorkflow' && workflowKind !== 'AIDataWorkflow') {
|
|
214
|
+
diagnostics.push(diagnostic('AI_RUN_AGENT_TASK_INVALID', 'run-freeze:task:workflowKind', 'Run freeze task workflowKind must be AICtrlWorkflow or AIDataWorkflow.'));
|
|
215
|
+
}
|
|
216
|
+
if (typeof workflowRef !== 'string' || resourceIdFromRef(workflowRef) === undefined) {
|
|
217
|
+
diagnostics.push(diagnostic('AI_RUN_AGENT_TASK_INVALID', 'run-freeze:task:workflowRef', 'Run freeze task workflowRef must be one exact resource:// identity.'));
|
|
218
|
+
}
|
|
219
|
+
if (typeof nodeId !== 'string' || nodeId.length === 0 || nodeId !== nodeId.trim()) {
|
|
220
|
+
diagnostics.push(diagnostic('AI_RUN_AGENT_TASK_INVALID', 'run-freeze:task:nodeId', 'Run freeze task nodeId must be an explicit canonical string.'));
|
|
221
|
+
}
|
|
222
|
+
if (typeof agentDefinitionRef !== 'string' || resourceIdFromRef(agentDefinitionRef) === undefined) {
|
|
223
|
+
diagnostics.push(diagnostic('AI_RUN_AGENT_TASK_INVALID', 'run-freeze:task:agentDefinitionRef', 'Run freeze task agentDefinitionRef must be one exact resource:// identity.'));
|
|
224
|
+
}
|
|
225
|
+
if (diagnostics.length !== initialDiagnosticCount
|
|
226
|
+
|| workflowKind !== 'AICtrlWorkflow' && workflowKind !== 'AIDataWorkflow'
|
|
227
|
+
|| typeof workflowRef !== 'string'
|
|
228
|
+
|| typeof nodeId !== 'string'
|
|
229
|
+
|| typeof agentDefinitionRef !== 'string') return undefined;
|
|
230
|
+
return Object.freeze({ workflowKind, workflowRef: workflowRef as `resource://${string}`, nodeId, agentDefinitionRef: agentDefinitionRef as `resource://${string}` });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function ownDataValue(record: Record<string, unknown>, field: string): unknown {
|
|
234
|
+
const descriptor = Object.getOwnPropertyDescriptor(record, field);
|
|
235
|
+
return descriptor && 'value' in descriptor ? descriptor.value : undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function sameTask(left: AIWorkflowAgentTaskRef, right: AIWorkflowAgentTaskRef): boolean {
|
|
239
|
+
return left.workflowKind === right.workflowKind
|
|
240
|
+
&& left.workflowRef === right.workflowRef
|
|
241
|
+
&& left.nodeId === right.nodeId
|
|
242
|
+
&& left.agentDefinitionRef === right.agentDefinitionRef;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function resourceIdFromRef(value: unknown): string | undefined {
|
|
246
|
+
const prefix = 'resource://';
|
|
247
|
+
if (typeof value !== 'string' || value !== value.trim() || !value.startsWith(prefix)) return undefined;
|
|
248
|
+
const resourceId = value.slice(prefix.length);
|
|
249
|
+
return resourceId.length > 0 && resourceId === resourceId.trim() && !resourceId.includes('://')
|
|
250
|
+
? resourceId
|
|
251
|
+
: undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function edge(fromResourceId: string, toResourceId: string, relation: string): ResourceDependencyEdge {
|
|
255
|
+
return Object.freeze({ fromResourceId, toResourceId, relation, declaredBy: fromResourceId });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function throwFreezeError(diagnostics: AIWorkflowResourceProjectionDiagnostic[]): never {
|
|
259
|
+
throw new AIWorkflowRunResourceFreezeError(Object.freeze([...diagnostics].sort((left, right) =>
|
|
260
|
+
compareCodeUnits(left.location, right.location) || compareCodeUnits(left.code, right.code),
|
|
261
|
+
)));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function diagnostic(code: string, location: string, message: string): AIWorkflowResourceProjectionDiagnostic {
|
|
265
|
+
return Object.freeze({ code, location, message });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function compareCodeUnits(left: string, right: string): number {
|
|
269
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
270
|
+
}
|