@elevasis/core 0.33.0 → 0.34.1

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.
@@ -1,890 +1,883 @@
1
- /**
2
- * ResourceRegistry - Resource discovery and lookup
3
- * Handles resource definitions from OrganizationRegistry
4
- *
5
- * Features:
6
- * - Resource discovery by organization
7
- * - Startup validation (duplicate IDs, model configs, relationships, interface-schema alignment)
8
- * - Pre-serialization cache for instant API responses
9
- * - Command View data generation
10
- */
11
-
12
- import type { WorkflowDefinition } from '../../execution/engine/workflow/types'
13
- import type { AgentDefinition } from '../../execution/engine/agent/core/types'
14
- import type {
15
- OrganizationModel
16
- } from '../../organization-model/types'
17
- import type { ResourceEntry } from '../../organization-model/domains/resources'
18
- import type { SystemEntry } from '../../organization-model/domains/systems'
19
- import { listAllSystems } from '../../organization-model/helpers'
20
- import type {
21
- ResourceStatus,
22
- ResourceDefinition,
23
- ResourceList,
24
- TriggerDefinition,
25
- IntegrationDefinition,
26
- ResourceRelationships,
27
- ExternalResourceDefinition,
28
- HumanCheckpointDefinition
29
- } from './types'
30
- import type {
31
- SerializedOrganizationData,
32
- SerializedAgentDefinition,
33
- SerializedWorkflowDefinition,
34
- CommandViewData
35
- } from './serialized-types'
36
- import { validateDeploymentSpec, validateRelationships } from './validation'
37
- import { serializeAllOrganizations, serializeOrganization } from './serialization'
38
- import { isReservedResourceId } from './reserved'
39
-
40
- /** Filter out archived resources from an organization's resource collection */
41
- function filterArchived(org: DeploymentSpec): DeploymentSpec {
42
- return {
43
- ...org,
44
- workflows: org.workflows?.filter((w) => !w.config.archived),
45
- agents: org.agents?.filter((a) => !a.config.archived),
46
- triggers: org.triggers?.filter((t) => !t.archived),
47
- integrations: org.integrations?.filter((i) => !i.archived),
48
- externalResources: org.externalResources?.filter((e) => !e.archived),
49
- humanCheckpoints: org.humanCheckpoints?.filter((h) => !h.archived)
50
- }
51
- }
52
-
53
- function summarizeSystem(system: SystemEntry | undefined): ResourceDefinition['system'] {
54
- if (!system) return undefined
55
-
56
- return {
57
- id: system.id,
58
- title: system.label ?? system.title,
59
- description: system.description,
60
- kind: system.kind,
61
- lifecycle: system.lifecycle
62
- }
63
- }
64
-
65
- /**
66
- * Configuration for a remotely-deployed organization
67
- *
68
- * Stored alongside runtime-registered organizations to support
69
- * worker thread execution branching and credential management.
70
- */
71
- export interface RemoteOrgConfig {
72
- /** Supabase Storage path: "{orgId}/{deploymentId}/bundle.js" */
73
- storagePath: string
74
- /** Deployment record ID */
75
- deploymentId: string
76
- /** OS temp path to bundle -- set after first download, used by worker threads */
77
- cachedTempPath?: string
78
- /** Platform tool name -> credential name mapping */
79
- toolCredentials?: Record<string, string>
80
- /** SDK version used to deploy this bundle */
81
- sdkVersion?: string
82
- /** Deployment version (semver) of the deployed bundle */
83
- deploymentVersion?: string
84
- }
85
-
86
- /**
87
- * Configuration for a first-class System resource.
88
- *
89
- * System resources are owned by the platform, registered under the 'system' org,
90
- * and execute via the static-bundle loader mode in executeInWorker(). The moduleId
91
- * maps to an entry in the API's STATIC_MODULE_MAP.
92
- */
93
- export interface SystemConfig {
94
- kind: 'static'
95
- moduleId: string
96
- /** Always undefined for system resources; present for API compatibility with RemoteOrgConfig consumers */
97
- sdkVersion?: never
98
- }
99
-
100
- /**
101
- * Organization-specific resource collection
102
- *
103
- * Complete manifest of all automation resources for an organization.
104
- * Used by ResourceRegistry for discovery and Command View for visualization.
105
- */
106
- export interface DeploymentSpec {
1
+ /**
2
+ * ResourceRegistry - Resource discovery and lookup
3
+ * Handles resource definitions from OrganizationRegistry
4
+ *
5
+ * Features:
6
+ * - Resource discovery by organization
7
+ * - Startup validation (duplicate IDs, model configs, relationships, interface-schema alignment)
8
+ * - Pre-serialization cache for instant API responses
9
+ * - Command View data generation
10
+ */
11
+
12
+ import type { WorkflowDefinition } from '../../execution/engine/workflow/types'
13
+ import type { AgentDefinition } from '../../execution/engine/agent/core/types'
14
+ import type { OrganizationModel } from '../../organization-model/types'
15
+ import type { ResourceEntry } from '../../organization-model/domains/resources'
16
+ import type { SystemEntry } from '../../organization-model/domains/systems'
17
+ import { listAllSystems } from '../../organization-model/helpers'
18
+ import type {
19
+ ResourceStatus,
20
+ ResourceDefinition,
21
+ ResourceList,
22
+ TriggerDefinition,
23
+ IntegrationDefinition,
24
+ ResourceRelationships,
25
+ ExternalResourceDefinition,
26
+ HumanCheckpointDefinition
27
+ } from './types'
28
+ import type {
29
+ SerializedOrganizationData,
30
+ SerializedAgentDefinition,
31
+ SerializedWorkflowDefinition,
32
+ CommandViewData
33
+ } from './serialized-types'
34
+ import { validateDeploymentSpec, validateRelationships } from './validation'
35
+ import { serializeAllOrganizations, serializeOrganization } from './serialization'
36
+ import { isReservedResourceId } from './reserved'
37
+
38
+ /** Filter out archived resources from an organization's resource collection */
39
+ function filterArchived(org: DeploymentSpec): DeploymentSpec {
40
+ return {
41
+ ...org,
42
+ workflows: org.workflows?.filter((w) => !w.config.archived),
43
+ agents: org.agents?.filter((a) => !a.config.archived),
44
+ triggers: org.triggers?.filter((t) => !t.archived),
45
+ integrations: org.integrations?.filter((i) => !i.archived),
46
+ externalResources: org.externalResources?.filter((e) => !e.archived),
47
+ humanCheckpoints: org.humanCheckpoints?.filter((h) => !h.archived)
48
+ }
49
+ }
50
+
51
+ function summarizeSystem(system: SystemEntry | undefined): ResourceDefinition['system'] {
52
+ if (!system) return undefined
53
+
54
+ return {
55
+ id: system.id,
56
+ title: system.label ?? system.title,
57
+ description: system.description,
58
+ kind: system.kind,
59
+ lifecycle: system.lifecycle
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Configuration for a remotely-deployed organization
65
+ *
66
+ * Stored alongside runtime-registered organizations to support
67
+ * worker thread execution branching and credential management.
68
+ */
69
+ export interface RemoteOrgConfig {
70
+ /** Supabase Storage path: "{orgId}/{deploymentId}/bundle.js" */
71
+ storagePath: string
72
+ /** Deployment record ID */
73
+ deploymentId: string
74
+ /** OS temp path to bundle -- set after first download, used by worker threads */
75
+ cachedTempPath?: string
76
+ /** Platform tool name -> credential name mapping */
77
+ toolCredentials?: Record<string, string>
78
+ /** SDK version used to deploy this bundle */
79
+ sdkVersion?: string
80
+ /** Deployment version (semver) of the deployed bundle */
81
+ deploymentVersion?: string
82
+ }
83
+
84
+ /**
85
+ * Configuration for a first-class System resource.
86
+ *
87
+ * System resources are owned by the platform, registered under the 'system' org,
88
+ * and execute via the static-bundle loader mode in executeInWorker(). The moduleId
89
+ * maps to an entry in the API's STATIC_MODULE_MAP.
90
+ */
91
+ export interface SystemConfig {
92
+ kind: 'static'
93
+ moduleId: string
94
+ /** Always undefined for system resources; present for API compatibility with RemoteOrgConfig consumers */
95
+ sdkVersion?: never
96
+ }
97
+
98
+ /**
99
+ * Organization-specific resource collection
100
+ *
101
+ * Complete manifest of all automation resources for an organization.
102
+ * Used by ResourceRegistry for discovery and Command View for visualization.
103
+ */
104
+ export interface DeploymentSpec {
107
105
  /** Deployment version (semver) */
108
106
  version: string
109
- /** Optional Organization Model governance catalog used for OM-code validation */
110
- organizationModel?: Partial<
111
- Pick<
112
- OrganizationModel,
113
- 'systems' | 'resources' | 'ontology' | 'topology' | 'roles' | 'policies' | 'entities' | 'actions'
114
- >
115
- >
116
- /** Workflow definitions */
117
- workflows?: WorkflowDefinition[]
118
- /** Agent definitions */
119
- agents?: AgentDefinition[]
120
-
121
- // Resource Manifest fields (optional for backwards compatibility)
122
- /** Trigger definitions - entry points that initiate executions */
123
- triggers?: TriggerDefinition[]
124
- /** Integration definitions - external service connections */
125
- integrations?: IntegrationDefinition[]
126
- /** Explicit relationship declarations between resources */
127
- relationships?: ResourceRelationships
128
- /** External automation resources (n8n, Make, Zapier, etc.) */
129
- externalResources?: ExternalResourceDefinition[]
130
- /** Human checkpoint definitions - human decision points in automation */
131
- humanCheckpoints?: HumanCheckpointDefinition[]
132
- }
133
-
134
- /**
135
- * Organization Registry type
136
- */
137
- export type OrganizationRegistry = Record<string, DeploymentSpec>
138
-
139
- export class ResourceRegistry {
140
- /**
141
- * Pre-serialized organization data cache
142
- * Computed once at construction for static orgs, updated incrementally for runtime orgs
143
- */
144
- private serializedCache: Map<string, SerializedOrganizationData>
145
-
146
- /**
147
- * Per-resource remote configuration (external deployments)
148
- * Key: "orgName/resourceId", Value: RemoteOrgConfig for that resource.
149
- * Tracks which individual resources were added at runtime via deploy pipeline.
150
- * Static and remote resources coexist in the same org.
151
- */
152
- private remoteResources = new Map<string, RemoteOrgConfig>()
153
-
154
- /**
155
- * System configs for first-class platform resources.
156
- * Key: "orgName/resourceId", Value: SystemConfig.
157
- * Registered at startup alongside registerStaticResources().
158
- */
159
- private systemConfigs = new Map<string, SystemConfig>()
160
-
161
- constructor(private registry: OrganizationRegistry) {
162
- this.validateRegistry()
163
- this.validateRelationships()
164
- this.serializedCache = serializeAllOrganizations(registry)
165
- }
166
-
167
- /**
168
- * Validates registry on construction
169
- * - Checks for duplicate resourceIds within organizations
170
- * - Validates model configurations against constraints
171
- * - Validates ExecutionInterface matches inputSchema
172
- * @throws Error if validation fails
173
- */
174
- private validateRegistry(): void {
175
- for (const [orgName, resources] of Object.entries(this.registry)) {
176
- validateDeploymentSpec(orgName, resources)
177
- }
178
- }
179
-
180
- /**
181
- * Validates relationship declarations reference valid resources
182
- * Runs at API server startup - fails fast in development
183
- * @throws Error if validation fails
184
- */
185
- private validateRelationships(): void {
186
- for (const [orgName, resources] of Object.entries(this.registry)) {
187
- validateRelationships(orgName, resources)
188
- }
189
- }
190
-
191
- /**
192
- * Get the remote resource IDs currently registered for an organization.
193
- * Used to validate redeployments against the post-swap state before any
194
- * live registry mutation occurs.
195
- */
196
- private getRemoteResourceIds(orgName: string): Set<string> {
197
- const prefix = `${orgName}/`
198
- const remoteIds = new Set<string>()
199
- for (const key of this.remoteResources.keys()) {
200
- if (key.startsWith(prefix)) {
201
- remoteIds.add(key.slice(prefix.length))
202
- }
203
- }
204
- return remoteIds
205
- }
206
-
207
- /**
208
- * Build the "static + surviving" baseline for registration validation.
209
- * On redeploy, this strips the currently remote-owned resources and
210
- * deployment-owned metadata so validation reflects the state after swap.
211
- */
212
- private buildRegistrationBase(orgName: string): DeploymentSpec | undefined {
213
- const existingOrg = this.registry[orgName]
214
- if (!existingOrg) return undefined
215
-
216
- const remoteIds = this.getRemoteResourceIds(orgName)
217
- if (remoteIds.size === 0) return existingOrg
218
-
219
- const relationships = existingOrg.relationships
220
- ? Object.fromEntries(
221
- Object.entries(existingOrg.relationships).filter(([resourceId]) => !remoteIds.has(resourceId))
222
- )
223
- : undefined
224
-
225
- return {
226
- ...existingOrg,
227
- version: existingOrg.version ?? '0.0.0',
228
- workflows: (existingOrg.workflows ?? []).filter((w) => !remoteIds.has(w.config.resourceId)),
229
- agents: (existingOrg.agents ?? []).filter((a) => !remoteIds.has(a.config.resourceId)),
230
- triggers: undefined,
231
- integrations: undefined,
232
- humanCheckpoints: undefined,
233
- externalResources: undefined,
234
- relationships: relationships && Object.keys(relationships).length > 0 ? relationships : undefined
235
- }
236
- }
237
-
238
- /**
239
- * Validate the registry state that would exist after registration succeeds.
240
- * This runs before any live mutation so invalid redeploys preserve the
241
- * currently active remote resources.
242
- */
243
- private validateRegistrationCandidate(orgName: string, incoming: DeploymentSpec): void {
244
- const base = this.buildRegistrationBase(orgName)
245
-
246
- const candidate: DeploymentSpec = base
247
- ? {
248
- ...base,
249
- version: incoming.version ?? base.version ?? '0.0.0',
250
- workflows: [...(base.workflows ?? []), ...(incoming.workflows ?? [])],
251
- agents: [...(base.agents ?? []), ...(incoming.agents ?? [])],
252
- triggers: incoming.triggers,
253
- integrations: incoming.integrations,
254
- humanCheckpoints: incoming.humanCheckpoints,
255
- externalResources: incoming.externalResources,
256
- relationships: incoming.relationships
257
- ? {
258
- ...(base.relationships ?? {}),
259
- ...incoming.relationships
260
- }
261
- : base.relationships
262
- }
263
- : {
264
- ...incoming,
265
- version: incoming.version ?? '0.0.0'
266
- }
267
-
268
- validateDeploymentSpec(orgName, candidate)
269
- validateRelationships(orgName, candidate)
270
- }
271
-
272
- /**
273
- * Get a resource definition by ID
274
- * Returns full definition (WorkflowDefinition or AgentDefinition)
275
- * Check definition.config.type to determine if it's a workflow or agent
276
- */
277
- getResourceDefinition(organizationName: string, resourceId: string): WorkflowDefinition | AgentDefinition | null {
278
- const orgResources = this.registry[organizationName]
279
- if (!orgResources) return null
280
-
281
- // Check workflows first
282
- const workflow = orgResources.workflows?.find((w) => w.config.resourceId === resourceId)
283
- if (workflow) return workflow
284
-
285
- // Check agents
286
- const agent = orgResources.agents?.find((a) => a.config.resourceId === resourceId)
287
- if (agent) return agent
288
-
289
- return null
290
- }
291
-
292
- /**
293
- * List all resources for an organization
294
- * Returns ResourceDefinition metadata (not full definitions)
295
- *
296
- * All resources are returned regardless of server environment.
297
- * Pass an explicit `environment` filter to get only 'dev' or 'prod' resources.
298
- */
299
- listResourcesForOrganization(organizationName: string, environment?: ResourceStatus): ResourceList {
300
- const orgResources = this.registry[organizationName]
301
- if (!orgResources) {
302
- return {
303
- workflows: [],
304
- agents: [],
305
- total: 0,
306
- organizationName,
307
- environment
308
- }
309
- }
310
-
311
- const resourcesById = new Map(Object.values(orgResources.organizationModel?.resources ?? {}).map((r) => [r.id, r]))
312
- // Build a path-keyed map that includes nested systems (DFS via listAllSystems).
313
- // resource.systemPath is the dot-separated system path used as the lookup key.
314
- const omSystems = orgResources.organizationModel?.systems
315
- const systemsByPath = new Map<string, SystemEntry>(
316
- omSystems
317
- ? listAllSystems({ systems: omSystems } as OrganizationModel).map(({ path, system }) => [path, system])
318
- : []
319
- )
320
- const getGovernanceMetadata = (
321
- resourceId: string,
322
- descriptor: ResourceEntry | undefined
323
- ): Pick<ResourceDefinition, 'systemPath' | 'system' | 'governanceStatus'> => {
324
- const resource = descriptor ?? resourcesById.get(resourceId)
325
- if (!resource) return {}
326
-
327
- return {
328
- systemPath: resource.systemPath,
329
- system: summarizeSystem(systemsByPath.get(resource.systemPath)),
330
- governanceStatus: resource.status
331
- }
332
- }
333
-
334
- // Map workflows to ResourceDefinition metadata and filter by environment
335
- const workflows: ResourceDefinition[] = (orgResources.workflows || [])
336
- .map((def) => ({
337
- resourceId: def.config.resourceId,
338
- name: def.config.name,
339
- description: def.config.description,
340
- version: def.config.version,
341
- type: def.config.type,
342
- status: def.config.status,
343
- links: def.config.links,
344
- category: def.config.category,
345
- origin: this.remoteResources.has(`${organizationName}/${def.config.resourceId}`)
346
- ? ('remote' as const)
347
- : ('local' as const),
348
- ...getGovernanceMetadata(def.config.resourceId, def.config.resource)
349
- }))
350
- .filter((resource) => !environment || resource.status === environment)
351
-
352
- // Map agents to ResourceDefinition metadata and filter by environment
353
- const agents: ResourceDefinition[] = (orgResources.agents || [])
354
- .map((def) => ({
355
- resourceId: def.config.resourceId,
356
- name: def.config.name,
357
- description: def.config.description,
358
- version: def.config.version,
359
- type: def.config.type,
360
- status: def.config.status,
361
- links: def.config.links,
362
- category: def.config.category,
363
- sessionCapable: def.config.sessionCapable ?? false,
364
- origin: this.remoteResources.has(`${organizationName}/${def.config.resourceId}`)
365
- ? ('remote' as const)
366
- : ('local' as const),
367
- ...getGovernanceMetadata(def.config.resourceId, def.config.resource)
368
- }))
369
- .filter((resource) => !environment || resource.status === environment)
370
-
371
- return {
372
- workflows,
373
- agents,
374
- total: workflows.length + agents.length,
375
- organizationName,
376
- environment
377
- }
378
- }
379
-
380
- /**
381
- * List all resources from all organizations
382
- * NOTE: For debugging only - returns raw registry data
383
- */
384
- listAllResources(): OrganizationRegistry {
385
- return this.registry
386
- }
387
-
388
- // ============================================================================
389
- // Runtime Organization Registration (External Deployments)
390
- // ============================================================================
391
-
392
- /**
393
- * Register external resources at runtime
394
- *
395
- * Called during deploy pipeline when an external developer deploys their bundle.
396
- * Merges the incoming stub definitions into the org's registry and stores
397
- * per-resource remote config for worker thread execution branching.
398
- *
399
- * Static and remote resources coexist in the same org. If the org already
400
- * has static resources, the incoming remote resources are merged alongside them.
401
- * If redeploying (some resources already registered as remote for this org),
402
- * the previous remote resources are unregistered first.
403
- *
404
- * @param orgName - Organization name (used as registry key)
405
- * @param org - Stub resource definitions (workflows/agents with placeholder handlers)
406
- * @param remote - Remote configuration (bundle path, deployment ID, env vars)
407
- * @throws Error if incoming resourceId conflicts with a static resource
408
- * @throws Error if incoming deployment contains duplicate resourceIds
409
- */
410
- registerOrganization(orgName: string, org: DeploymentSpec, remote: RemoteOrgConfig): void {
411
- // Filter out archived resources before any processing
412
- org = filterArchived(org)
413
-
414
- // Collect all incoming resource IDs for conflict checking
415
- const incomingWorkflowIds = (org.workflows ?? []).map((w) => w.config.resourceId)
416
- const incomingAgentIds = (org.agents ?? []).map((a) => a.config.resourceId)
417
- const incomingIds = [...incomingWorkflowIds, ...incomingAgentIds]
418
-
419
- // Check for intra-deployment duplicates
420
- const seen = new Set<string>()
421
- for (const id of incomingIds) {
422
- if (seen.has(id)) {
423
- throw new Error(`Duplicate resource ID '${id}' in deployment. Each resource must have a unique ID.`)
424
- }
425
- seen.add(id)
426
- }
427
-
428
- // Check for reserved resource IDs (cannot be claimed by external deployments)
429
- for (const id of incomingIds) {
430
- if (isReservedResourceId(id)) {
431
- throw new Error(
432
- `Resource ID '${id}' is reserved for platform use. External deployments cannot use reserved resource IDs.`
433
- )
434
- }
435
- }
436
-
437
- // Check for conflicts against the static/surviving org state.
438
- // On redeploy, current remote resources are excluded so a deployment can
439
- // legitimately replace its own resource IDs without colliding with itself.
440
- const validationBase = this.buildRegistrationBase(orgName)
441
- if (validationBase) {
442
- const staticWorkflowIds = new Set((validationBase.workflows ?? []).map((w) => w.config.resourceId))
443
- const staticAgentIds = new Set((validationBase.agents ?? []).map((a) => a.config.resourceId))
444
-
445
- for (const id of incomingIds) {
446
- if (staticWorkflowIds.has(id) || staticAgentIds.has(id)) {
447
- throw new Error(
448
- `Resource '${id}' already exists in '${orgName}' as an internal resource. External deployments cannot override internal resources.`
449
- )
450
- }
451
- }
452
- }
453
-
454
- // Validate the merged deployment shape before mutating the live registry.
455
- // This keeps the current deployment intact if a redeploy introduces
456
- // invalid relationships or other registry-level validation failures.
457
- this.validateRegistrationCandidate(orgName, org)
458
-
459
- // If redeploying (some resources already registered as remote for this org), clean up only
460
- // after validation succeeds so a failed redeploy preserves the current remote resources.
461
- if (this.isRemote(orgName)) {
462
- this.unregisterOrganization(orgName)
463
- }
464
-
465
- // Merge incoming resources into the org (or create new org entry)
466
- const existingOrg = this.registry[orgName]
467
- if (existingOrg) {
468
- existingOrg.workflows = [...(existingOrg.workflows ?? []), ...(org.workflows ?? [])]
469
- existingOrg.agents = [...(existingOrg.agents ?? []), ...(org.agents ?? [])]
470
- // Deployment-owned metadata: replace entirely (unregister cleared these)
471
- existingOrg.triggers = org.triggers
472
- existingOrg.integrations = org.integrations
473
- existingOrg.humanCheckpoints = org.humanCheckpoints
474
- existingOrg.externalResources = org.externalResources
475
- if (org.relationships) {
476
- existingOrg.relationships = {
477
- ...(existingOrg.relationships ?? {}),
478
- ...org.relationships
479
- }
480
- }
481
- } else {
482
- this.registry[orgName] = org
483
- }
484
-
485
- // Populate per-resource remote config entries
486
- for (const id of incomingIds) {
487
- this.remoteResources.set(`${orgName}/${id}`, remote)
488
- }
489
-
490
- // Rebuild serialized cache for the full merged org
491
- this.serializedCache.set(orgName, serializeOrganization(this.registry[orgName]))
492
- }
493
-
494
- /**
495
- * Patch serialized cache with pre-serialized schemas from an external manifest.
496
- *
497
- * External deployments use stub definitions with z.any() schemas (never called).
498
- * The manifest carries the real schemas as pre-serialized JSON Schema from the worker.
499
- * This method patches those into the serialized cache so describe/CLI display them.
500
- *
501
- * @param orgName - Organization name
502
- * @param manifestSchemas - Map of resourceId -> { contract, steps } with JSON Schema
503
- */
504
- patchManifestSchemas(
505
- orgName: string,
506
- manifestSchemas: Array<{
507
- resourceId: string
508
- type: 'workflow' | 'agent'
509
- contract?: { inputSchema?: object; outputSchema?: object }
510
- steps?: Array<{ id: string; inputSchema?: object; outputSchema?: object }>
511
- }>
512
- ): void {
513
- const cache = this.serializedCache.get(orgName)
514
- if (!cache) return
515
-
516
- for (const entry of manifestSchemas) {
517
- if (entry.type === 'workflow') {
518
- const def = cache.definitions.workflows.get(entry.resourceId)
519
- if (!def) continue
520
-
521
- // Patch contract schemas
522
- if (entry.contract?.inputSchema) def.contract.inputSchema = entry.contract.inputSchema
523
- if (entry.contract?.outputSchema) def.contract.outputSchema = entry.contract.outputSchema
524
-
525
- // Patch step schemas
526
- if (entry.steps && def.steps) {
527
- for (const stepPatch of entry.steps) {
528
- const step = def.steps.find((s) => s.id === stepPatch.id)
529
- if (!step) continue
530
- if (stepPatch.inputSchema) step.inputSchema = stepPatch.inputSchema
531
- if (stepPatch.outputSchema) step.outputSchema = stepPatch.outputSchema
532
- }
533
- }
534
- } else if (entry.type === 'agent') {
535
- const def = cache.definitions.agents.get(entry.resourceId)
536
- if (!def) continue
537
- if (entry.contract?.inputSchema) def.contract.inputSchema = entry.contract.inputSchema
538
- if (entry.contract?.outputSchema) def.contract.outputSchema = entry.contract.outputSchema
539
- }
540
- }
541
- }
542
-
543
- /**
544
- * Register built-in platform resources (static, local execution)
545
- *
546
- * Unlike registerOrganization(), these resources:
547
- * - Do NOT have remote config (execute in-process, not in worker threads)
548
- * - Are NOT removed by unregisterOrganization() (persist across redeployments)
549
- * - Use reserved resource IDs that external deployments cannot claim
550
- *
551
- * @param orgName - Organization name
552
- * @param org - Resource definitions with real handlers (not stubs)
553
- */
554
- registerStaticResources(orgName: string, org: DeploymentSpec): void {
555
- // Filter out archived resources before any processing
556
- org = filterArchived(org)
557
-
558
- const incomingWorkflowIds = (org.workflows ?? []).map((w) => w.config.resourceId)
559
- const incomingAgentIds = (org.agents ?? []).map((a) => a.config.resourceId)
560
- const incomingIds = [...incomingWorkflowIds, ...incomingAgentIds]
561
-
562
- // Check for duplicates within incoming resources
563
- const seen = new Set<string>()
564
- for (const id of incomingIds) {
565
- if (seen.has(id)) {
566
- throw new Error(`Duplicate resource ID '${id}' in static resources.`)
567
- }
568
- seen.add(id)
569
- }
570
-
571
- // Check for conflicts with existing resources in this org
572
- const existingOrg = this.registry[orgName]
573
- if (existingOrg) {
574
- const existingWorkflowIds = new Set((existingOrg.workflows ?? []).map((w) => w.config.resourceId))
575
- const existingAgentIds = new Set((existingOrg.agents ?? []).map((a) => a.config.resourceId))
576
-
577
- for (const id of incomingIds) {
578
- if (existingWorkflowIds.has(id) || existingAgentIds.has(id)) {
579
- throw new Error(`Static resource '${id}' conflicts with existing resource in '${orgName}'.`)
580
- }
581
- }
582
- }
583
-
584
- // Merge into registry (no remote config = local execution path)
585
- if (existingOrg) {
586
- existingOrg.workflows = [...(existingOrg.workflows ?? []), ...(org.workflows ?? [])]
587
- existingOrg.agents = [...(existingOrg.agents ?? []), ...(org.agents ?? [])]
588
- } else {
589
- this.registry[orgName] = org
590
- }
591
-
592
- // Rebuild serialized cache
593
- this.serializedCache.set(orgName, serializeOrganization(this.registry[orgName]))
594
- }
595
-
596
- /**
597
- * Unregister runtime-registered resources for an organization
598
- *
599
- * Removes only resources that were registered at runtime (via registerOrganization).
600
- * Static resources loaded at startup are preserved. If the org still has static
601
- * resources after removal, the serialization cache is rebuilt. If no resources
602
- * remain, the org is fully removed from the registry.
603
- * No-op if the org has no remote resources.
604
- *
605
- * @param orgName - Organization name to unregister remote resources from
606
- */
607
- unregisterOrganization(orgName: string): void {
608
- // Find all remote resource keys for this org
609
- const prefix = `${orgName}/`
610
- const remoteIds = new Set<string>()
611
- for (const key of this.remoteResources.keys()) {
612
- if (key.startsWith(prefix)) {
613
- remoteIds.add(key.slice(prefix.length))
614
- this.remoteResources.delete(key)
615
- }
616
- }
617
-
618
- // No-op if org had no remote resources
619
- if (remoteIds.size === 0) return
620
-
621
- const orgResources = this.registry[orgName]
622
- if (!orgResources) return
623
-
624
- // Remove remote resources from the org's arrays
625
- orgResources.workflows = (orgResources.workflows ?? []).filter((w) => !remoteIds.has(w.config.resourceId))
626
- orgResources.agents = (orgResources.agents ?? []).filter((a) => !remoteIds.has(a.config.resourceId))
627
-
628
- // Remove deployment-owned metadata (triggers, integrations, checkpoints, external resources)
629
- // These are always fully owned by the deployment — replaced on each deploy
630
- orgResources.triggers = undefined
631
- orgResources.integrations = undefined
632
- orgResources.humanCheckpoints = undefined
633
- orgResources.externalResources = undefined
634
-
635
- // Remove relationship entries for remote resource IDs
636
- if (orgResources.relationships) {
637
- for (const id of remoteIds) {
638
- delete orgResources.relationships[id]
639
- }
640
- if (Object.keys(orgResources.relationships).length === 0) {
641
- delete orgResources.relationships
642
- }
643
- }
644
-
645
- // If the org still has static resources, rebuild cache; otherwise fully remove
646
- // (triggers, integrations, checkpoints, externalResources were cleared above)
647
- const remaining = (orgResources.workflows?.length ?? 0) + (orgResources.agents?.length ?? 0)
648
-
649
- if (remaining > 0) {
650
- this.serializedCache.set(orgName, serializeOrganization(orgResources))
651
- } else {
652
- delete this.registry[orgName]
653
- this.serializedCache.delete(orgName)
654
- }
655
- }
656
-
657
- /**
658
- * Get remote configuration for a specific resource.
659
- *
660
- * Returns RemoteOrgConfig for externally-deployed resources, SystemConfig for
661
- * first-class platform resources, or null for static in-process resources.
662
- * Used by the execution coordinator to determine the execution path.
663
- *
664
- * @param orgName - Organization name
665
- * @param resourceId - Resource ID
666
- * @returns Remote or System config, or null
667
- */
668
- getRemoteConfig(orgName: string, resourceId: string): RemoteOrgConfig | SystemConfig | null {
669
- const key = `${orgName}/${resourceId}`
670
- return this.remoteResources.get(key) ?? this.systemConfigs.get(key) ?? null
671
- }
672
-
673
- /**
674
- * Register a System config for a first-class platform resource.
675
- *
676
- * Called at startup alongside registerStaticResources() so that
677
- * getRemoteConfig('system', resourceId) returns truthy and the execution
678
- * coordinator routes the resource through the worker-thread path.
679
- *
680
- * @param orgName - Organization name (typically 'system')
681
- * @param resourceId - Resource ID
682
- * @param config - SystemConfig with kind:'static' and moduleId
683
- */
684
- registerSystemConfig(orgName: string, resourceId: string, config: SystemConfig): void {
685
- this.systemConfigs.set(`${orgName}/${resourceId}`, config)
686
- }
687
-
688
- /**
689
- * Check if an organization has any remote (externally deployed) resources
690
- *
691
- * @param orgName - Organization name
692
- * @returns true if the org has at least one runtime-registered resource
693
- */
694
- isRemote(orgName: string): boolean {
695
- const prefix = `${orgName}/`
696
- for (const key of this.remoteResources.keys()) {
697
- if (key.startsWith(prefix)) return true
698
- }
699
- return false
700
- }
701
-
702
- /**
703
- * Get the remote config for any resource in an organization.
704
- * Used when the specific resource ID is unknown (e.g., to clean up a
705
- * temp file before unregistering an org -- all resources share one config).
706
- *
707
- * @param orgName - Organization name
708
- * @returns Remote config or null if org has no remote resources
709
- */
710
- getAnyRemoteConfig(orgName: string): RemoteOrgConfig | null {
711
- const prefix = `${orgName}/`
712
- for (const [key, config] of this.remoteResources) {
713
- if (key.startsWith(prefix)) return config
714
- }
715
- return null
716
- }
717
-
718
- /**
719
- * Get statistics about remotely-deployed resources
720
- * Used by the health endpoint for platform-wide deployment visibility.
721
- */
722
- getRemoteStats(): { activeOrgs: number; totalResources: number } {
723
- const orgs = new Set<string>()
724
- for (const key of this.remoteResources.keys()) {
725
- const orgName = key.split('/')[0]
726
- orgs.add(orgName)
727
- }
728
- return {
729
- activeOrgs: orgs.size,
730
- totalResources: this.remoteResources.size
731
- }
732
- }
733
-
734
- // ============================================================================
735
- // Resource Manifest Accessors
736
- // ============================================================================
737
-
738
- /**
739
- * Get triggers for an organization
740
- * @param organizationName - Organization name
741
- * @returns Array of trigger definitions (empty if none defined)
742
- */
743
- getTriggers(organizationName: string): TriggerDefinition[] {
744
- return this.registry[organizationName]?.triggers ?? []
745
- }
746
-
747
- /**
748
- * Get integrations for an organization
749
- * @param organizationName - Organization name
750
- * @returns Array of integration definitions (empty if none defined)
751
- */
752
- getIntegrations(organizationName: string): IntegrationDefinition[] {
753
- return this.registry[organizationName]?.integrations ?? []
754
- }
755
-
756
- /**
757
- * Get resource relationships for an organization
758
- * @param organizationName - Organization name
759
- * @returns Resource relationships map (undefined if none defined)
760
- */
761
- getRelationships(organizationName: string): ResourceRelationships | undefined {
762
- return this.registry[organizationName]?.relationships
763
- }
764
-
765
- /**
766
- * Get a specific trigger by ID
767
- * @param organizationName - Organization name
768
- * @param triggerId - Trigger ID
769
- * @returns Trigger definition or null if not found
770
- */
771
- getTrigger(organizationName: string, triggerId: string): TriggerDefinition | null {
772
- const triggers = this.getTriggers(organizationName)
773
- return triggers.find((t) => t.resourceId === triggerId) ?? null
774
- }
775
-
776
- /**
777
- * Get a specific integration by ID
778
- * @param organizationName - Organization name
779
- * @param integrationId - Integration ID
780
- * @returns Integration definition or null if not found
781
- */
782
- getIntegration(organizationName: string, integrationId: string): IntegrationDefinition | null {
783
- const integrations = this.getIntegrations(organizationName)
784
- return integrations.find((i) => i.resourceId === integrationId) ?? null
785
- }
786
-
787
- /**
788
- * Get external resources for an organization
789
- * @param organizationName - Organization name
790
- * @returns Array of external resource definitions (empty if none defined)
791
- */
792
- getExternalResources(organizationName: string): ExternalResourceDefinition[] {
793
- return this.registry[organizationName]?.externalResources ?? []
794
- }
795
-
796
- /**
797
- * Get a specific external resource by ID
798
- * @param organizationName - Organization name
799
- * @param externalId - External resource ID
800
- * @returns External resource definition or null if not found
801
- */
802
- getExternalResource(organizationName: string, externalId: string): ExternalResourceDefinition | null {
803
- const externalResources = this.getExternalResources(organizationName)
804
- return externalResources.find((e) => e.resourceId === externalId) ?? null
805
- }
806
-
807
- /**
808
- * Get human checkpoints for an organization
809
- * @param organizationName - Organization name
810
- * @returns Array of human checkpoint definitions (empty if none defined)
811
- */
812
- getHumanCheckpoints(organizationName: string): HumanCheckpointDefinition[] {
813
- return this.registry[organizationName]?.humanCheckpoints ?? []
814
- }
815
-
816
- /**
817
- * Get a specific human checkpoint by ID
818
- * @param organizationName - Organization name
819
- * @param humanCheckpointId - Human checkpoint ID
820
- * @returns Human checkpoint definition or null if not found
821
- */
822
- getHumanCheckpoint(organizationName: string, humanCheckpointId: string): HumanCheckpointDefinition | null {
823
- const humanCheckpoints = this.getHumanCheckpoints(organizationName)
824
- return humanCheckpoints.find((hc) => hc.resourceId === humanCheckpointId) ?? null
825
- }
826
-
827
- // ============================================================================
828
- // Serialized Data Access (Pre-computed at Startup)
829
- // ============================================================================
830
-
831
- /**
832
- * Get serialized resource definition (instant lookup)
833
- * Use for API responses - returns pre-computed JSON-safe structure
834
- *
835
- * @param organizationName - Organization name
836
- * @param resourceId - Resource ID
837
- * @returns Serialized definition or null if not found
838
- */
839
- getSerializedDefinition(
840
- organizationName: string,
841
- resourceId: string
842
- ): SerializedAgentDefinition | SerializedWorkflowDefinition | null {
843
- const cache = this.serializedCache.get(organizationName)
844
- if (!cache) return null
845
-
846
- return cache.definitions.agents.get(resourceId) ?? cache.definitions.workflows.get(resourceId) ?? null
847
- }
848
-
849
- /**
850
- * Get resource list for organization (instant lookup)
851
- * Use for /resources endpoint - returns pre-computed ResourceDefinition array
852
- *
853
- * @param organizationName - Organization name
854
- * @returns Resource list with workflows, agents, and total count
855
- */
856
- getResourceList(organizationName: string): {
857
- workflows: ResourceDefinition[]
858
- agents: ResourceDefinition[]
859
- total: number
860
- } {
861
- const cache = this.serializedCache.get(organizationName)
862
- if (!cache) {
863
- return { workflows: [], agents: [], total: 0 }
864
- }
865
- return cache.resources
866
- }
867
-
868
- /**
869
- * Get Command View data for organization (instant lookup)
870
- * Use for /command-view endpoint - returns complete graph data
871
- *
872
- * @param organizationName - Organization name
873
- * @returns Command View data with nodes and edges
874
- */
875
- getCommandViewData(organizationName: string): CommandViewData {
876
- const cache = this.serializedCache.get(organizationName)
877
- if (!cache) {
878
- return {
879
- workflows: [],
880
- agents: [],
881
- triggers: [],
882
- integrations: [],
883
- externalResources: [],
884
- humanCheckpoints: [],
885
- edges: []
886
- }
887
- }
888
- return cache.commandView
889
- }
890
- }
107
+ /** Optional full Organization Model snapshot used for OM-code validation and deployment persistence */
108
+ organizationModel?: OrganizationModel
109
+ /** Workflow definitions */
110
+ workflows?: WorkflowDefinition[]
111
+ /** Agent definitions */
112
+ agents?: AgentDefinition[]
113
+
114
+ // Resource Manifest fields (optional for backwards compatibility)
115
+ /** Trigger definitions - entry points that initiate executions */
116
+ triggers?: TriggerDefinition[]
117
+ /** Integration definitions - external service connections */
118
+ integrations?: IntegrationDefinition[]
119
+ /** Explicit relationship declarations between resources */
120
+ relationships?: ResourceRelationships
121
+ /** External automation resources (n8n, Make, Zapier, etc.) */
122
+ externalResources?: ExternalResourceDefinition[]
123
+ /** Human checkpoint definitions - human decision points in automation */
124
+ humanCheckpoints?: HumanCheckpointDefinition[]
125
+ }
126
+
127
+ /**
128
+ * Organization Registry type
129
+ */
130
+ export type OrganizationRegistry = Record<string, DeploymentSpec>
131
+
132
+ export class ResourceRegistry {
133
+ /**
134
+ * Pre-serialized organization data cache
135
+ * Computed once at construction for static orgs, updated incrementally for runtime orgs
136
+ */
137
+ private serializedCache: Map<string, SerializedOrganizationData>
138
+
139
+ /**
140
+ * Per-resource remote configuration (external deployments)
141
+ * Key: "orgName/resourceId", Value: RemoteOrgConfig for that resource.
142
+ * Tracks which individual resources were added at runtime via deploy pipeline.
143
+ * Static and remote resources coexist in the same org.
144
+ */
145
+ private remoteResources = new Map<string, RemoteOrgConfig>()
146
+
147
+ /**
148
+ * System configs for first-class platform resources.
149
+ * Key: "orgName/resourceId", Value: SystemConfig.
150
+ * Registered at startup alongside registerStaticResources().
151
+ */
152
+ private systemConfigs = new Map<string, SystemConfig>()
153
+
154
+ constructor(private registry: OrganizationRegistry) {
155
+ this.validateRegistry()
156
+ this.validateRelationships()
157
+ this.serializedCache = serializeAllOrganizations(registry)
158
+ }
159
+
160
+ /**
161
+ * Validates registry on construction
162
+ * - Checks for duplicate resourceIds within organizations
163
+ * - Validates model configurations against constraints
164
+ * - Validates ExecutionInterface matches inputSchema
165
+ * @throws Error if validation fails
166
+ */
167
+ private validateRegistry(): void {
168
+ for (const [orgName, resources] of Object.entries(this.registry)) {
169
+ validateDeploymentSpec(orgName, resources)
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Validates relationship declarations reference valid resources
175
+ * Runs at API server startup - fails fast in development
176
+ * @throws Error if validation fails
177
+ */
178
+ private validateRelationships(): void {
179
+ for (const [orgName, resources] of Object.entries(this.registry)) {
180
+ validateRelationships(orgName, resources)
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Get the remote resource IDs currently registered for an organization.
186
+ * Used to validate redeployments against the post-swap state before any
187
+ * live registry mutation occurs.
188
+ */
189
+ private getRemoteResourceIds(orgName: string): Set<string> {
190
+ const prefix = `${orgName}/`
191
+ const remoteIds = new Set<string>()
192
+ for (const key of this.remoteResources.keys()) {
193
+ if (key.startsWith(prefix)) {
194
+ remoteIds.add(key.slice(prefix.length))
195
+ }
196
+ }
197
+ return remoteIds
198
+ }
199
+
200
+ /**
201
+ * Build the "static + surviving" baseline for registration validation.
202
+ * On redeploy, this strips the currently remote-owned resources and
203
+ * deployment-owned metadata so validation reflects the state after swap.
204
+ */
205
+ private buildRegistrationBase(orgName: string): DeploymentSpec | undefined {
206
+ const existingOrg = this.registry[orgName]
207
+ if (!existingOrg) return undefined
208
+
209
+ const remoteIds = this.getRemoteResourceIds(orgName)
210
+ if (remoteIds.size === 0) return existingOrg
211
+
212
+ const relationships = existingOrg.relationships
213
+ ? Object.fromEntries(
214
+ Object.entries(existingOrg.relationships).filter(([resourceId]) => !remoteIds.has(resourceId))
215
+ )
216
+ : undefined
217
+
218
+ return {
219
+ ...existingOrg,
220
+ version: existingOrg.version ?? '0.0.0',
221
+ workflows: (existingOrg.workflows ?? []).filter((w) => !remoteIds.has(w.config.resourceId)),
222
+ agents: (existingOrg.agents ?? []).filter((a) => !remoteIds.has(a.config.resourceId)),
223
+ triggers: undefined,
224
+ integrations: undefined,
225
+ humanCheckpoints: undefined,
226
+ externalResources: undefined,
227
+ relationships: relationships && Object.keys(relationships).length > 0 ? relationships : undefined
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Validate the registry state that would exist after registration succeeds.
233
+ * This runs before any live mutation so invalid redeploys preserve the
234
+ * currently active remote resources.
235
+ */
236
+ private validateRegistrationCandidate(orgName: string, incoming: DeploymentSpec): void {
237
+ const base = this.buildRegistrationBase(orgName)
238
+
239
+ const candidate: DeploymentSpec = base
240
+ ? {
241
+ ...base,
242
+ version: incoming.version ?? base.version ?? '0.0.0',
243
+ workflows: [...(base.workflows ?? []), ...(incoming.workflows ?? [])],
244
+ agents: [...(base.agents ?? []), ...(incoming.agents ?? [])],
245
+ triggers: incoming.triggers,
246
+ integrations: incoming.integrations,
247
+ humanCheckpoints: incoming.humanCheckpoints,
248
+ externalResources: incoming.externalResources,
249
+ relationships: incoming.relationships
250
+ ? {
251
+ ...(base.relationships ?? {}),
252
+ ...incoming.relationships
253
+ }
254
+ : base.relationships
255
+ }
256
+ : {
257
+ ...incoming,
258
+ version: incoming.version ?? '0.0.0'
259
+ }
260
+
261
+ validateDeploymentSpec(orgName, candidate)
262
+ validateRelationships(orgName, candidate)
263
+ }
264
+
265
+ /**
266
+ * Get a resource definition by ID
267
+ * Returns full definition (WorkflowDefinition or AgentDefinition)
268
+ * Check definition.config.type to determine if it's a workflow or agent
269
+ */
270
+ getResourceDefinition(organizationName: string, resourceId: string): WorkflowDefinition | AgentDefinition | null {
271
+ const orgResources = this.registry[organizationName]
272
+ if (!orgResources) return null
273
+
274
+ // Check workflows first
275
+ const workflow = orgResources.workflows?.find((w) => w.config.resourceId === resourceId)
276
+ if (workflow) return workflow
277
+
278
+ // Check agents
279
+ const agent = orgResources.agents?.find((a) => a.config.resourceId === resourceId)
280
+ if (agent) return agent
281
+
282
+ return null
283
+ }
284
+
285
+ /**
286
+ * List all resources for an organization
287
+ * Returns ResourceDefinition metadata (not full definitions)
288
+ *
289
+ * All resources are returned regardless of server environment.
290
+ * Pass an explicit `environment` filter to get only 'dev' or 'prod' resources.
291
+ */
292
+ listResourcesForOrganization(organizationName: string, environment?: ResourceStatus): ResourceList {
293
+ const orgResources = this.registry[organizationName]
294
+ if (!orgResources) {
295
+ return {
296
+ workflows: [],
297
+ agents: [],
298
+ total: 0,
299
+ organizationName,
300
+ environment
301
+ }
302
+ }
303
+
304
+ const resourcesById = new Map(Object.values(orgResources.organizationModel?.resources ?? {}).map((r) => [r.id, r]))
305
+ // Build a path-keyed map that includes nested systems (DFS via listAllSystems).
306
+ // resource.systemPath is the dot-separated system path used as the lookup key.
307
+ const omSystems = orgResources.organizationModel?.systems
308
+ const systemsByPath = new Map<string, SystemEntry>(
309
+ omSystems
310
+ ? listAllSystems({ systems: omSystems } as OrganizationModel).map(({ path, system }) => [path, system])
311
+ : []
312
+ )
313
+ const getGovernanceMetadata = (
314
+ resourceId: string,
315
+ descriptor: ResourceEntry | undefined
316
+ ): Pick<ResourceDefinition, 'systemPath' | 'system' | 'governanceStatus'> => {
317
+ const resource = descriptor ?? resourcesById.get(resourceId)
318
+ if (!resource) return {}
319
+
320
+ return {
321
+ systemPath: resource.systemPath,
322
+ system: summarizeSystem(systemsByPath.get(resource.systemPath)),
323
+ governanceStatus: resource.status
324
+ }
325
+ }
326
+
327
+ // Map workflows to ResourceDefinition metadata and filter by environment
328
+ const workflows: ResourceDefinition[] = (orgResources.workflows || [])
329
+ .map((def) => ({
330
+ resourceId: def.config.resourceId,
331
+ name: def.config.name,
332
+ description: def.config.description,
333
+ version: def.config.version,
334
+ type: def.config.type,
335
+ status: def.config.status,
336
+ links: def.config.links,
337
+ category: def.config.category,
338
+ origin: this.remoteResources.has(`${organizationName}/${def.config.resourceId}`)
339
+ ? ('remote' as const)
340
+ : ('local' as const),
341
+ ...getGovernanceMetadata(def.config.resourceId, def.config.resource)
342
+ }))
343
+ .filter((resource) => !environment || resource.status === environment)
344
+
345
+ // Map agents to ResourceDefinition metadata and filter by environment
346
+ const agents: ResourceDefinition[] = (orgResources.agents || [])
347
+ .map((def) => ({
348
+ resourceId: def.config.resourceId,
349
+ name: def.config.name,
350
+ description: def.config.description,
351
+ version: def.config.version,
352
+ type: def.config.type,
353
+ status: def.config.status,
354
+ links: def.config.links,
355
+ category: def.config.category,
356
+ sessionCapable: def.config.sessionCapable ?? false,
357
+ origin: this.remoteResources.has(`${organizationName}/${def.config.resourceId}`)
358
+ ? ('remote' as const)
359
+ : ('local' as const),
360
+ ...getGovernanceMetadata(def.config.resourceId, def.config.resource)
361
+ }))
362
+ .filter((resource) => !environment || resource.status === environment)
363
+
364
+ return {
365
+ workflows,
366
+ agents,
367
+ total: workflows.length + agents.length,
368
+ organizationName,
369
+ environment
370
+ }
371
+ }
372
+
373
+ /**
374
+ * List all resources from all organizations
375
+ * NOTE: For debugging only - returns raw registry data
376
+ */
377
+ listAllResources(): OrganizationRegistry {
378
+ return this.registry
379
+ }
380
+
381
+ // ============================================================================
382
+ // Runtime Organization Registration (External Deployments)
383
+ // ============================================================================
384
+
385
+ /**
386
+ * Register external resources at runtime
387
+ *
388
+ * Called during deploy pipeline when an external developer deploys their bundle.
389
+ * Merges the incoming stub definitions into the org's registry and stores
390
+ * per-resource remote config for worker thread execution branching.
391
+ *
392
+ * Static and remote resources coexist in the same org. If the org already
393
+ * has static resources, the incoming remote resources are merged alongside them.
394
+ * If redeploying (some resources already registered as remote for this org),
395
+ * the previous remote resources are unregistered first.
396
+ *
397
+ * @param orgName - Organization name (used as registry key)
398
+ * @param org - Stub resource definitions (workflows/agents with placeholder handlers)
399
+ * @param remote - Remote configuration (bundle path, deployment ID, env vars)
400
+ * @throws Error if incoming resourceId conflicts with a static resource
401
+ * @throws Error if incoming deployment contains duplicate resourceIds
402
+ */
403
+ registerOrganization(orgName: string, org: DeploymentSpec, remote: RemoteOrgConfig): void {
404
+ // Filter out archived resources before any processing
405
+ org = filterArchived(org)
406
+
407
+ // Collect all incoming resource IDs for conflict checking
408
+ const incomingWorkflowIds = (org.workflows ?? []).map((w) => w.config.resourceId)
409
+ const incomingAgentIds = (org.agents ?? []).map((a) => a.config.resourceId)
410
+ const incomingIds = [...incomingWorkflowIds, ...incomingAgentIds]
411
+
412
+ // Check for intra-deployment duplicates
413
+ const seen = new Set<string>()
414
+ for (const id of incomingIds) {
415
+ if (seen.has(id)) {
416
+ throw new Error(`Duplicate resource ID '${id}' in deployment. Each resource must have a unique ID.`)
417
+ }
418
+ seen.add(id)
419
+ }
420
+
421
+ // Check for reserved resource IDs (cannot be claimed by external deployments)
422
+ for (const id of incomingIds) {
423
+ if (isReservedResourceId(id)) {
424
+ throw new Error(
425
+ `Resource ID '${id}' is reserved for platform use. External deployments cannot use reserved resource IDs.`
426
+ )
427
+ }
428
+ }
429
+
430
+ // Check for conflicts against the static/surviving org state.
431
+ // On redeploy, current remote resources are excluded so a deployment can
432
+ // legitimately replace its own resource IDs without colliding with itself.
433
+ const validationBase = this.buildRegistrationBase(orgName)
434
+ if (validationBase) {
435
+ const staticWorkflowIds = new Set((validationBase.workflows ?? []).map((w) => w.config.resourceId))
436
+ const staticAgentIds = new Set((validationBase.agents ?? []).map((a) => a.config.resourceId))
437
+
438
+ for (const id of incomingIds) {
439
+ if (staticWorkflowIds.has(id) || staticAgentIds.has(id)) {
440
+ throw new Error(
441
+ `Resource '${id}' already exists in '${orgName}' as an internal resource. External deployments cannot override internal resources.`
442
+ )
443
+ }
444
+ }
445
+ }
446
+
447
+ // Validate the merged deployment shape before mutating the live registry.
448
+ // This keeps the current deployment intact if a redeploy introduces
449
+ // invalid relationships or other registry-level validation failures.
450
+ this.validateRegistrationCandidate(orgName, org)
451
+
452
+ // If redeploying (some resources already registered as remote for this org), clean up only
453
+ // after validation succeeds so a failed redeploy preserves the current remote resources.
454
+ if (this.isRemote(orgName)) {
455
+ this.unregisterOrganization(orgName)
456
+ }
457
+
458
+ // Merge incoming resources into the org (or create new org entry)
459
+ const existingOrg = this.registry[orgName]
460
+ if (existingOrg) {
461
+ existingOrg.workflows = [...(existingOrg.workflows ?? []), ...(org.workflows ?? [])]
462
+ existingOrg.agents = [...(existingOrg.agents ?? []), ...(org.agents ?? [])]
463
+ // Deployment-owned metadata: replace entirely (unregister cleared these)
464
+ existingOrg.triggers = org.triggers
465
+ existingOrg.integrations = org.integrations
466
+ existingOrg.humanCheckpoints = org.humanCheckpoints
467
+ existingOrg.externalResources = org.externalResources
468
+ if (org.relationships) {
469
+ existingOrg.relationships = {
470
+ ...(existingOrg.relationships ?? {}),
471
+ ...org.relationships
472
+ }
473
+ }
474
+ } else {
475
+ this.registry[orgName] = org
476
+ }
477
+
478
+ // Populate per-resource remote config entries
479
+ for (const id of incomingIds) {
480
+ this.remoteResources.set(`${orgName}/${id}`, remote)
481
+ }
482
+
483
+ // Rebuild serialized cache for the full merged org
484
+ this.serializedCache.set(orgName, serializeOrganization(this.registry[orgName]))
485
+ }
486
+
487
+ /**
488
+ * Patch serialized cache with pre-serialized schemas from an external manifest.
489
+ *
490
+ * External deployments use stub definitions with z.any() schemas (never called).
491
+ * The manifest carries the real schemas as pre-serialized JSON Schema from the worker.
492
+ * This method patches those into the serialized cache so describe/CLI display them.
493
+ *
494
+ * @param orgName - Organization name
495
+ * @param manifestSchemas - Map of resourceId -> { contract, steps } with JSON Schema
496
+ */
497
+ patchManifestSchemas(
498
+ orgName: string,
499
+ manifestSchemas: Array<{
500
+ resourceId: string
501
+ type: 'workflow' | 'agent'
502
+ contract?: { inputSchema?: object; outputSchema?: object }
503
+ steps?: Array<{ id: string; inputSchema?: object; outputSchema?: object }>
504
+ }>
505
+ ): void {
506
+ const cache = this.serializedCache.get(orgName)
507
+ if (!cache) return
508
+
509
+ for (const entry of manifestSchemas) {
510
+ if (entry.type === 'workflow') {
511
+ const def = cache.definitions.workflows.get(entry.resourceId)
512
+ if (!def) continue
513
+
514
+ // Patch contract schemas
515
+ if (entry.contract?.inputSchema) def.contract.inputSchema = entry.contract.inputSchema
516
+ if (entry.contract?.outputSchema) def.contract.outputSchema = entry.contract.outputSchema
517
+
518
+ // Patch step schemas
519
+ if (entry.steps && def.steps) {
520
+ for (const stepPatch of entry.steps) {
521
+ const step = def.steps.find((s) => s.id === stepPatch.id)
522
+ if (!step) continue
523
+ if (stepPatch.inputSchema) step.inputSchema = stepPatch.inputSchema
524
+ if (stepPatch.outputSchema) step.outputSchema = stepPatch.outputSchema
525
+ }
526
+ }
527
+ } else if (entry.type === 'agent') {
528
+ const def = cache.definitions.agents.get(entry.resourceId)
529
+ if (!def) continue
530
+ if (entry.contract?.inputSchema) def.contract.inputSchema = entry.contract.inputSchema
531
+ if (entry.contract?.outputSchema) def.contract.outputSchema = entry.contract.outputSchema
532
+ }
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Register built-in platform resources (static, local execution)
538
+ *
539
+ * Unlike registerOrganization(), these resources:
540
+ * - Do NOT have remote config (execute in-process, not in worker threads)
541
+ * - Are NOT removed by unregisterOrganization() (persist across redeployments)
542
+ * - Use reserved resource IDs that external deployments cannot claim
543
+ *
544
+ * @param orgName - Organization name
545
+ * @param org - Resource definitions with real handlers (not stubs)
546
+ */
547
+ registerStaticResources(orgName: string, org: DeploymentSpec): void {
548
+ // Filter out archived resources before any processing
549
+ org = filterArchived(org)
550
+
551
+ const incomingWorkflowIds = (org.workflows ?? []).map((w) => w.config.resourceId)
552
+ const incomingAgentIds = (org.agents ?? []).map((a) => a.config.resourceId)
553
+ const incomingIds = [...incomingWorkflowIds, ...incomingAgentIds]
554
+
555
+ // Check for duplicates within incoming resources
556
+ const seen = new Set<string>()
557
+ for (const id of incomingIds) {
558
+ if (seen.has(id)) {
559
+ throw new Error(`Duplicate resource ID '${id}' in static resources.`)
560
+ }
561
+ seen.add(id)
562
+ }
563
+
564
+ // Check for conflicts with existing resources in this org
565
+ const existingOrg = this.registry[orgName]
566
+ if (existingOrg) {
567
+ const existingWorkflowIds = new Set((existingOrg.workflows ?? []).map((w) => w.config.resourceId))
568
+ const existingAgentIds = new Set((existingOrg.agents ?? []).map((a) => a.config.resourceId))
569
+
570
+ for (const id of incomingIds) {
571
+ if (existingWorkflowIds.has(id) || existingAgentIds.has(id)) {
572
+ throw new Error(`Static resource '${id}' conflicts with existing resource in '${orgName}'.`)
573
+ }
574
+ }
575
+ }
576
+
577
+ // Merge into registry (no remote config = local execution path)
578
+ if (existingOrg) {
579
+ existingOrg.workflows = [...(existingOrg.workflows ?? []), ...(org.workflows ?? [])]
580
+ existingOrg.agents = [...(existingOrg.agents ?? []), ...(org.agents ?? [])]
581
+ } else {
582
+ this.registry[orgName] = org
583
+ }
584
+
585
+ // Rebuild serialized cache
586
+ this.serializedCache.set(orgName, serializeOrganization(this.registry[orgName]))
587
+ }
588
+
589
+ /**
590
+ * Unregister runtime-registered resources for an organization
591
+ *
592
+ * Removes only resources that were registered at runtime (via registerOrganization).
593
+ * Static resources loaded at startup are preserved. If the org still has static
594
+ * resources after removal, the serialization cache is rebuilt. If no resources
595
+ * remain, the org is fully removed from the registry.
596
+ * No-op if the org has no remote resources.
597
+ *
598
+ * @param orgName - Organization name to unregister remote resources from
599
+ */
600
+ unregisterOrganization(orgName: string): void {
601
+ // Find all remote resource keys for this org
602
+ const prefix = `${orgName}/`
603
+ const remoteIds = new Set<string>()
604
+ for (const key of this.remoteResources.keys()) {
605
+ if (key.startsWith(prefix)) {
606
+ remoteIds.add(key.slice(prefix.length))
607
+ this.remoteResources.delete(key)
608
+ }
609
+ }
610
+
611
+ // No-op if org had no remote resources
612
+ if (remoteIds.size === 0) return
613
+
614
+ const orgResources = this.registry[orgName]
615
+ if (!orgResources) return
616
+
617
+ // Remove remote resources from the org's arrays
618
+ orgResources.workflows = (orgResources.workflows ?? []).filter((w) => !remoteIds.has(w.config.resourceId))
619
+ orgResources.agents = (orgResources.agents ?? []).filter((a) => !remoteIds.has(a.config.resourceId))
620
+
621
+ // Remove deployment-owned metadata (triggers, integrations, checkpoints, external resources)
622
+ // These are always fully owned by the deployment — replaced on each deploy
623
+ orgResources.triggers = undefined
624
+ orgResources.integrations = undefined
625
+ orgResources.humanCheckpoints = undefined
626
+ orgResources.externalResources = undefined
627
+
628
+ // Remove relationship entries for remote resource IDs
629
+ if (orgResources.relationships) {
630
+ for (const id of remoteIds) {
631
+ delete orgResources.relationships[id]
632
+ }
633
+ if (Object.keys(orgResources.relationships).length === 0) {
634
+ delete orgResources.relationships
635
+ }
636
+ }
637
+
638
+ // If the org still has static resources, rebuild cache; otherwise fully remove
639
+ // (triggers, integrations, checkpoints, externalResources were cleared above)
640
+ const remaining = (orgResources.workflows?.length ?? 0) + (orgResources.agents?.length ?? 0)
641
+
642
+ if (remaining > 0) {
643
+ this.serializedCache.set(orgName, serializeOrganization(orgResources))
644
+ } else {
645
+ delete this.registry[orgName]
646
+ this.serializedCache.delete(orgName)
647
+ }
648
+ }
649
+
650
+ /**
651
+ * Get remote configuration for a specific resource.
652
+ *
653
+ * Returns RemoteOrgConfig for externally-deployed resources, SystemConfig for
654
+ * first-class platform resources, or null for static in-process resources.
655
+ * Used by the execution coordinator to determine the execution path.
656
+ *
657
+ * @param orgName - Organization name
658
+ * @param resourceId - Resource ID
659
+ * @returns Remote or System config, or null
660
+ */
661
+ getRemoteConfig(orgName: string, resourceId: string): RemoteOrgConfig | SystemConfig | null {
662
+ const key = `${orgName}/${resourceId}`
663
+ return this.remoteResources.get(key) ?? this.systemConfigs.get(key) ?? null
664
+ }
665
+
666
+ /**
667
+ * Register a System config for a first-class platform resource.
668
+ *
669
+ * Called at startup alongside registerStaticResources() so that
670
+ * getRemoteConfig('system', resourceId) returns truthy and the execution
671
+ * coordinator routes the resource through the worker-thread path.
672
+ *
673
+ * @param orgName - Organization name (typically 'system')
674
+ * @param resourceId - Resource ID
675
+ * @param config - SystemConfig with kind:'static' and moduleId
676
+ */
677
+ registerSystemConfig(orgName: string, resourceId: string, config: SystemConfig): void {
678
+ this.systemConfigs.set(`${orgName}/${resourceId}`, config)
679
+ }
680
+
681
+ /**
682
+ * Check if an organization has any remote (externally deployed) resources
683
+ *
684
+ * @param orgName - Organization name
685
+ * @returns true if the org has at least one runtime-registered resource
686
+ */
687
+ isRemote(orgName: string): boolean {
688
+ const prefix = `${orgName}/`
689
+ for (const key of this.remoteResources.keys()) {
690
+ if (key.startsWith(prefix)) return true
691
+ }
692
+ return false
693
+ }
694
+
695
+ /**
696
+ * Get the remote config for any resource in an organization.
697
+ * Used when the specific resource ID is unknown (e.g., to clean up a
698
+ * temp file before unregistering an org -- all resources share one config).
699
+ *
700
+ * @param orgName - Organization name
701
+ * @returns Remote config or null if org has no remote resources
702
+ */
703
+ getAnyRemoteConfig(orgName: string): RemoteOrgConfig | null {
704
+ const prefix = `${orgName}/`
705
+ for (const [key, config] of this.remoteResources) {
706
+ if (key.startsWith(prefix)) return config
707
+ }
708
+ return null
709
+ }
710
+
711
+ /**
712
+ * Get statistics about remotely-deployed resources
713
+ * Used by the health endpoint for platform-wide deployment visibility.
714
+ */
715
+ getRemoteStats(): { activeOrgs: number; totalResources: number } {
716
+ const orgs = new Set<string>()
717
+ for (const key of this.remoteResources.keys()) {
718
+ const orgName = key.split('/')[0]
719
+ orgs.add(orgName)
720
+ }
721
+ return {
722
+ activeOrgs: orgs.size,
723
+ totalResources: this.remoteResources.size
724
+ }
725
+ }
726
+
727
+ // ============================================================================
728
+ // Resource Manifest Accessors
729
+ // ============================================================================
730
+
731
+ /**
732
+ * Get triggers for an organization
733
+ * @param organizationName - Organization name
734
+ * @returns Array of trigger definitions (empty if none defined)
735
+ */
736
+ getTriggers(organizationName: string): TriggerDefinition[] {
737
+ return this.registry[organizationName]?.triggers ?? []
738
+ }
739
+
740
+ /**
741
+ * Get integrations for an organization
742
+ * @param organizationName - Organization name
743
+ * @returns Array of integration definitions (empty if none defined)
744
+ */
745
+ getIntegrations(organizationName: string): IntegrationDefinition[] {
746
+ return this.registry[organizationName]?.integrations ?? []
747
+ }
748
+
749
+ /**
750
+ * Get resource relationships for an organization
751
+ * @param organizationName - Organization name
752
+ * @returns Resource relationships map (undefined if none defined)
753
+ */
754
+ getRelationships(organizationName: string): ResourceRelationships | undefined {
755
+ return this.registry[organizationName]?.relationships
756
+ }
757
+
758
+ /**
759
+ * Get a specific trigger by ID
760
+ * @param organizationName - Organization name
761
+ * @param triggerId - Trigger ID
762
+ * @returns Trigger definition or null if not found
763
+ */
764
+ getTrigger(organizationName: string, triggerId: string): TriggerDefinition | null {
765
+ const triggers = this.getTriggers(organizationName)
766
+ return triggers.find((t) => t.resourceId === triggerId) ?? null
767
+ }
768
+
769
+ /**
770
+ * Get a specific integration by ID
771
+ * @param organizationName - Organization name
772
+ * @param integrationId - Integration ID
773
+ * @returns Integration definition or null if not found
774
+ */
775
+ getIntegration(organizationName: string, integrationId: string): IntegrationDefinition | null {
776
+ const integrations = this.getIntegrations(organizationName)
777
+ return integrations.find((i) => i.resourceId === integrationId) ?? null
778
+ }
779
+
780
+ /**
781
+ * Get external resources for an organization
782
+ * @param organizationName - Organization name
783
+ * @returns Array of external resource definitions (empty if none defined)
784
+ */
785
+ getExternalResources(organizationName: string): ExternalResourceDefinition[] {
786
+ return this.registry[organizationName]?.externalResources ?? []
787
+ }
788
+
789
+ /**
790
+ * Get a specific external resource by ID
791
+ * @param organizationName - Organization name
792
+ * @param externalId - External resource ID
793
+ * @returns External resource definition or null if not found
794
+ */
795
+ getExternalResource(organizationName: string, externalId: string): ExternalResourceDefinition | null {
796
+ const externalResources = this.getExternalResources(organizationName)
797
+ return externalResources.find((e) => e.resourceId === externalId) ?? null
798
+ }
799
+
800
+ /**
801
+ * Get human checkpoints for an organization
802
+ * @param organizationName - Organization name
803
+ * @returns Array of human checkpoint definitions (empty if none defined)
804
+ */
805
+ getHumanCheckpoints(organizationName: string): HumanCheckpointDefinition[] {
806
+ return this.registry[organizationName]?.humanCheckpoints ?? []
807
+ }
808
+
809
+ /**
810
+ * Get a specific human checkpoint by ID
811
+ * @param organizationName - Organization name
812
+ * @param humanCheckpointId - Human checkpoint ID
813
+ * @returns Human checkpoint definition or null if not found
814
+ */
815
+ getHumanCheckpoint(organizationName: string, humanCheckpointId: string): HumanCheckpointDefinition | null {
816
+ const humanCheckpoints = this.getHumanCheckpoints(organizationName)
817
+ return humanCheckpoints.find((hc) => hc.resourceId === humanCheckpointId) ?? null
818
+ }
819
+
820
+ // ============================================================================
821
+ // Serialized Data Access (Pre-computed at Startup)
822
+ // ============================================================================
823
+
824
+ /**
825
+ * Get serialized resource definition (instant lookup)
826
+ * Use for API responses - returns pre-computed JSON-safe structure
827
+ *
828
+ * @param organizationName - Organization name
829
+ * @param resourceId - Resource ID
830
+ * @returns Serialized definition or null if not found
831
+ */
832
+ getSerializedDefinition(
833
+ organizationName: string,
834
+ resourceId: string
835
+ ): SerializedAgentDefinition | SerializedWorkflowDefinition | null {
836
+ const cache = this.serializedCache.get(organizationName)
837
+ if (!cache) return null
838
+
839
+ return cache.definitions.agents.get(resourceId) ?? cache.definitions.workflows.get(resourceId) ?? null
840
+ }
841
+
842
+ /**
843
+ * Get resource list for organization (instant lookup)
844
+ * Use for /resources endpoint - returns pre-computed ResourceDefinition array
845
+ *
846
+ * @param organizationName - Organization name
847
+ * @returns Resource list with workflows, agents, and total count
848
+ */
849
+ getResourceList(organizationName: string): {
850
+ workflows: ResourceDefinition[]
851
+ agents: ResourceDefinition[]
852
+ total: number
853
+ } {
854
+ const cache = this.serializedCache.get(organizationName)
855
+ if (!cache) {
856
+ return { workflows: [], agents: [], total: 0 }
857
+ }
858
+ return cache.resources
859
+ }
860
+
861
+ /**
862
+ * Get Command View data for organization (instant lookup)
863
+ * Use for /command-view endpoint - returns complete graph data
864
+ *
865
+ * @param organizationName - Organization name
866
+ * @returns Command View data with nodes and edges
867
+ */
868
+ getCommandViewData(organizationName: string): CommandViewData {
869
+ const cache = this.serializedCache.get(organizationName)
870
+ if (!cache) {
871
+ return {
872
+ workflows: [],
873
+ agents: [],
874
+ triggers: [],
875
+ integrations: [],
876
+ externalResources: [],
877
+ humanCheckpoints: [],
878
+ edges: []
879
+ }
880
+ }
881
+ return cache.commandView
882
+ }
883
+ }