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 CHANGED
@@ -1,16 +1,19 @@
1
1
  {
2
2
  "name": "ai-workflow-logic",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
- "description": "Pure helpers for AI workflow profile descriptors, runtime contexts, and reuse policy resolution",
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.0"
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 class AIWorkflowFoundationError extends Error {
30
- constructor(message: string) {
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
+ }