diff --git a/docs/site/capabilities/background-jobs.md b/docs/site/capabilities/background-jobs.md index c1b2a5cf6..0281c4bce 100644 --- a/docs/site/capabilities/background-jobs.md +++ b/docs/site/capabilities/background-jobs.md @@ -303,7 +303,7 @@ The runtime scans workers/jobs (the primary user jobs directory) fo default-exported handlers, and also includes plugin-contributed handlers from plugins/workers/jobs (and plugins/triggers/jobs when present). The generated registry lands at -.netscript/generated/plugin-workers/jobs.registry.ts, keyed by each handler's +.netscript/generated/plugin-workers/job-registry.ts, keyed by each handler's id. Background execution runs from plugins/workers/bin/combined.ts, a separate process from the :8091 API service — the API enqueues, the runner executes. Set WORKERS_CONCURRENCY on the worker background process when you diff --git a/docs/site/glossary.md b/docs/site/glossary.md index b8961d501..a4a545b95 100644 --- a/docs/site/glossary.md +++ b/docs/site/glossary.md @@ -64,7 +64,7 @@ These are the words you will meet first — in the [Quickstart](/quickstart/), t ] }) }} {{ comp.apiTable({ caption: "R–S", rows: [ - { name: "registry", type: "generated registry file", desc: "A generated index the runtime reads to discover what your app contains without runtime reflection. netscript plugin list emits the plugin registry, and the workers profile generates a jobs registry (for example to .netscript/generated/plugin-workers/jobs.registry.ts, keyed by job id). Registries are derived artifacts — regenerate them, do not hand-edit. See Explanation → Plugin system." }, + { name: "registry", type: "generated registry file", desc: "A generated index the runtime reads to discover what your app contains without runtime reflection. netscript plugin list emits the plugin registry, and the workers profile generates a jobs registry (for example to .netscript/generated/plugin-workers/job-registry.ts, keyed by job id). Registries are derived artifacts — regenerate them, do not hand-edit. See Explanation → Plugin system." }, { name: "saga", type: "defineSaga(id)…build()", desc: "A durable, message-driven workflow authored with the fluent builder from @netscript/plugin-sagas-core: defineSaga(id).durability('t1').state<S>({...}).on<Type,Payload>(type, handler).build(). Each .on(...) handler reacts to a message and returns effects such as sagaComplete({...}). Runtime state persists to a chosen saga store backend (kv | prisma). The sagas service lists registered sagas at /api/v1/sagas/sagas on port 8092. See Tutorial → Durable workflow and Capabilities → Durable sagas." }, { name: "saga store backend", type: "NETSCRIPT_SAGA_STORE=kv|prisma", desc: "Where a durable saga runtime persists its checkpointed state — selectable as 'kv' or 'prisma' via NETSCRIPT_SAGA_STORE (or appsettings sagas.store.backend) and constructed with createDurableSagaRuntime({ backend, prisma }). The selection is mandatory — the runtime throws if it is unset, and Prisma mode requires a client. This is distinct from a saga's durability tier. See Capabilities → Durable sagas and Explanation → Durability model." }, { name: "service", type: "defineService(router, {...})", desc: "An oRPC HTTP application. Local app services use a one-shot defineService(router, { name, version, port, openapi }) call (the users service on port 3001), while plugin API services use the fluent createService(router, {...}).withCors()…serve() builder — two construction APIs in the same project. oRPC is served at /api/rpc/*; the service layer also offers an authn/authz middleware seam (.withAuthn() / .withAuthz()). See Capabilities → Services and reference/service/." }, diff --git a/docs/site/how-to/author-a-plugin.md b/docs/site/how-to/author-a-plugin.md index ba816554e..17a096c3c 100644 --- a/docs/site/how-to/author-a-plugin.md +++ b/docs/site/how-to/author-a-plugin.md @@ -269,7 +269,7 @@ canonical location: Listing the plugin is not enough — the runtime addresses contributions through a **generated registry**. For a worker-archetype plugin, the generator scans `plugins//jobs/` and writes a -jobs registry (e.g. `.netscript/generated/plugin-/jobs.registry.ts`) keyed by each handler's +jobs registry (e.g. `.netscript/generated/plugin-/job-registry.ts`) keyed by each handler's `id`. Generate it: ```sh diff --git a/packages/cli/src/kernel/assets/embedded.generated.ts b/packages/cli/src/kernel/assets/embedded.generated.ts index ffeaf01b4..586dfa7df 100644 --- a/packages/cli/src/kernel/assets/embedded.generated.ts +++ b/packages/cli/src/kernel/assets/embedded.generated.ts @@ -134,7 +134,7 @@ const template_063 = const template_064 = "{{__slot0__}}\n\nimport { resolve } from 'node:path';\nimport type { DistributedApplicationBuilder } from '{{__slot1__}}';\nimport type { NetScriptConfig } from '{{__slot2__}}';\nimport type { InfrastructureContext } from './register-infrastructure.mjs';\n\ninterface DbCliTarget {\n readonly configKey: string;\n readonly resourceKey: string;\n readonly databaseName: string;\n readonly envKey: string;\n readonly engine: string;\n readonly taskSuffix: string;\n readonly workdir: string;\n readonly resource: InfrastructureContext['primaryDatabase'];\n}\n\n/**\n * Short-circuit normal AppHost startup when invoked for a database CLI operation.\n *\n * @param builder - Aspire distributed application builder.\n * @param config - Parsed NetScript config.\n * @param infrastructure - Registered infrastructure resources.\n * @param appHostDir - Absolute project root.\n * @returns true when DB CLI mode handled the invocation.\n */\nexport async function tryHandleDbCliMode(\n builder: DistributedApplicationBuilder,\n config: NetScriptConfig,\n infrastructure: InfrastructureContext,\n appHostDir: string,\n): Promise {\n const configuration = await builder.getConfiguration();\n const operation = await readValue(\n configuration,\n 'prisma-operation',\n 'NETSCRIPT_PRISMA_OPERATION',\n );\n if (!operation) {\n return false;\n }\n\n const targets = resolveTargets(\n await readValue(\n configuration,\n 'prisma-target',\n 'NETSCRIPT_PRISMA_TARGET',\n ) ?? config.PrimaryDatabase ?? undefined,\n appHostDir,\n infrastructure,\n );\n\n const migrationName = await readValue(\n configuration,\n 'prisma-name',\n 'NETSCRIPT_PRISMA_NAME',\n );\n\n for (const target of targets) {\n const taskName = `db:${operation}:${target.taskSuffix}`;\n let resource = await builder.addExecutable(\n `prisma-${operation}-${target.configKey}`,\n 'deno',\n target.workdir,\n ['task', taskName],\n );\n\n if (target.engine === 'Sqlite') {\n const sqliteUrl = `file:./${target.databaseName}`;\n resource = await resource\n .withEnvironment('DATABASE_URL', sqliteUrl)\n .withEnvironment(target.envKey, sqliteUrl);\n }\n\n if (target.resource) {\n resource = await resource\n .withEnvironment('DATABASE_URL', target.resource)\n .withEnvironment(target.envKey, target.resource)\n .withReference(target.resource)\n .waitFor(target.resource);\n }\n\n if (migrationName) {\n resource = await resource.withEnvironment('PRISMA_MIGRATION_NAME', migrationName);\n }\n }\n\n return true;\n}\n\nfunction resolveTargets(\n requested: string | undefined,\n appHostDir: string,\n infrastructure: InfrastructureContext,\n): DbCliTarget[] {\n const targets: Record = {\n{{__slot3__}}\n };\n const values = Object.values(targets);\n if (requested === 'all') {\n return values;\n }\n if (!requested && values.length === 1) {\n return values;\n }\n const match = values.find((target) =>\n target.configKey === requested || target.databaseName === requested\n );\n if (!match) {\n throw new Error(`Unknown database target: ${requested ?? '(default)'}`);\n }\n return [match];\n}\n\nasync function readValue(\n configuration: {\n readonly getConfigValue: (key: string) => string | Promise | undefined;\n },\n key: string,\n envName: string,\n): Promise {\n const configured = await configuration.getConfigValue(key);\n return configured ?? process.env[envName];\n}\n"; const template_065 = - "{{__slot0__}}\n\nimport { resolve } from 'node:path';\nimport { parseAppSettings } from '{{__slot1__}}';\nimport type { DistributedApplicationBuilder } from '{{__slot2__}}';\nimport { configureDashboard } from '{{__slot3__}}';\nimport { registerInfrastructure } from '{{__slot4__}}';\nimport { tryHandleDbCliMode } from '{{__slot5__}}';\nimport { registerServices } from '{{__slot6__}}';\nimport { registerPlugins } from '{{__slot7__}}';\nimport { registerBackgroundProcessors } from '{{__slot8__}}';\nimport { registerApps } from '{{__slot9__}}';\nimport { registerTools } from '{{__slot10__}}';\n\n/**\n * Creates and configures a NetScript AppHost by parsing the project\n * configuration and registering all resources with the Aspire SDK builder.\n *\n * Registration order follows C# NuGet semantics:\n * 1. Dashboard OTLP endpoint configuration\n * 2. Infrastructure (databases + caches)\n * 3. DB CLI short-circuit mode\n * 4. Services (two-pass with cross-reference wiring)\n * 5. Plugins (two-pass with plugin→plugin + plugin→service refs)\n * 6. Background processors (workers, sagas, triggers)\n * 7. Applications (web apps, Tauri desktop, task apps)\n * 8. Development tools (Prisma Studio, etc.)\n *\n * @param builder - The Aspire distributed application builder\n * @param configPath - Path to appsettings.json\n */\nexport async function createNetScriptAppHost(\n builder: DistributedApplicationBuilder,\n configPath: string,\n): Promise {\n const { config } = await parseAppSettings(configPath);\n // apphost.mts lives under `aspire/` to keep the Node.js package graph\n // isolated from the Deno workspace root. Workspace paths declared in\n // appsettings.json (services, apps, plugins, workers) are relative to the\n // project root — which is one level up from `builder.appHostDirectory`.\n const appHostDir = resolve(await builder.appHostDirectory(), '..');\n\n // 1. Configure dashboard OTLP endpoint\n configureDashboard(config);\n\n // 2. Register infrastructure (databases + caches)\n const infrastructure = await registerInfrastructure(builder, config, appHostDir);\n\n // 3. DB CLI mode — short-circuit if active\n if (await tryHandleDbCliMode(builder, config, infrastructure, appHostDir)) {\n return;\n }\n\n // 4. Register services (two-pass: create all, then wire cross-refs)\n const services = await registerServices(builder, config, infrastructure, appHostDir);\n\n // 5. Register plugins (two-pass: create all, then wire plugin→plugin refs)\n const plugins = await registerPlugins(builder, config, infrastructure, services, appHostDir);\n\n // 6. Register background processors\n await registerBackgroundProcessors(builder, config, infrastructure, services, plugins, appHostDir);\n\n // 7. Register applications\n await registerApps(builder, config, infrastructure, services, plugins, appHostDir);\n\n // 8. Register development tools\n await registerTools(builder, config, infrastructure, appHostDir);\n}\n"; + "{{__slot0__}}\n\nimport { resolve } from 'node:path';\nimport { parseAppSettings } from '{{__slot1__}}';\nimport type { DistributedApplicationBuilder } from '{{__slot2__}}';\nimport { configureDashboard } from '{{__slot3__}}';\nimport { registerInfrastructure } from '{{__slot4__}}';\nimport { tryHandleDbCliMode } from '{{__slot5__}}';\nimport { registerServices, wireServiceReferences } from '{{__slot6__}}';\nimport { registerPlugins } from '{{__slot7__}}';\nimport { registerBackgroundProcessors } from '{{__slot8__}}';\nimport { registerApps } from '{{__slot9__}}';\nimport { registerTools } from '{{__slot10__}}';\n\n/**\n * Creates and configures a NetScript AppHost by parsing the project\n * configuration and registering all resources with the Aspire SDK builder.\n *\n * Registration order follows C# NuGet semantics:\n * 1. Dashboard OTLP endpoint configuration\n * 2. Infrastructure (databases + caches)\n * 3. DB CLI short-circuit mode\n * 4. Services (creation pass)\n * 5. Plugins (two-pass with plugin→plugin + plugin→service refs)\n * 6. Service reference wiring (service→service + service→plugin refs)\n * 7. Background processors (workers, sagas, triggers)\n * 8. Applications (web apps, Tauri desktop, task apps)\n * 9. Development tools (Prisma Studio, etc.)\n *\n * @param builder - The Aspire distributed application builder\n * @param configPath - Path to appsettings.json\n */\nexport async function createNetScriptAppHost(\n builder: DistributedApplicationBuilder,\n configPath: string,\n): Promise {\n const { config } = await parseAppSettings(configPath);\n // apphost.mts lives under `aspire/` to keep the Node.js package graph\n // isolated from the Deno workspace root. Workspace paths declared in\n // appsettings.json (services, apps, plugins, workers) are relative to the\n // project root — which is one level up from `builder.appHostDirectory`.\n const appHostDir = resolve(await builder.appHostDirectory(), '..');\n\n // 1. Configure dashboard OTLP endpoint\n configureDashboard(config);\n\n // 2. Register infrastructure (databases + caches)\n const infrastructure = await registerInfrastructure(builder, config, appHostDir);\n\n // 3. DB CLI mode — short-circuit if active\n if (await tryHandleDbCliMode(builder, config, infrastructure, appHostDir)) {\n return;\n }\n\n // 4. Register services\n const services = await registerServices(builder, config, infrastructure, appHostDir);\n\n // 5. Register plugins (two-pass: create all, then wire plugin→plugin refs)\n const plugins = await registerPlugins(builder, config, infrastructure, services, appHostDir);\n\n // 6. Wire service references after plugin APIs exist\n await wireServiceReferences(config, services, plugins);\n\n // 7. Register background processors\n await registerBackgroundProcessors(builder, config, infrastructure, services, plugins, appHostDir);\n\n // 8. Register applications\n await registerApps(builder, config, infrastructure, services, plugins, appHostDir);\n\n // 9. Register development tools\n await registerTools(builder, config, infrastructure, appHostDir);\n}\n"; const template_066 = "{{__slot0__}}\n\nimport type { DistributedApplicationBuilder } from '{{__slot1__}}';\nimport type { NetScriptConfig } from '{{__slot2__}}';\nimport type { InfrastructureContext } from './register-infrastructure.mjs';\nimport {\n buildOtelEnvVars,\n buildViteEnvVarName,\n extractDependencies,\n extractPluginReferences,\n extractServiceReferences,\n resolveWorkspacePath,\n} from '{{__slot3__}}';\n\ninterface EndpointProvider {\n getEndpoint(protocol: string): Promise | string | undefined;\n}\n\nasync function getResourceEndpoint(resource: unknown, protocol: string): Promise {\n if (!resource || typeof resource !== 'object' || !('getEndpoint' in resource)) {\n return undefined;\n }\n return await (resource as EndpointProvider).getEndpoint(protocol);\n}\n\n/**\n * Registers application resources (web apps, Tauri desktop apps, and tasks)\n * with the Aspire SDK builder.\n *\n * - `app` type: registered via `addExecutable()` with VITE env var injection\n * for service discovery (full, shorthand, and server-side formats).\n * - `tauri` type: registered via `addExecutable()` with optional remote\n * app reference.\n * - `task` type: registered via `addExecutable()` with simple config.\n *\n * All types receive OTEL telemetry (full executable env set), optional\n * HTTP endpoint binding, and optional KV cache dependency injection.\n *\n * @param builder - The Aspire distributed application builder\n * @param config - Parsed NetScript configuration\n * @param infrastructure - Infrastructure context with database/cache resources\n * @param services - Map of registered service resources (for endpoint-based env var wiring and VITE injection)\n * @param plugins - Map of registered plugin resources (for endpoint-based env var wiring and VITE injection)\n * @param appHostDir - Absolute path to the AppHost directory\n * @returns Map of registered app resources keyed by name\n */\nexport async function registerApps(\n builder: DistributedApplicationBuilder,\n config: NetScriptConfig,\n infrastructure: InfrastructureContext,\n services: Map,\n plugins: Map,\n appHostDir: string,\n): Promise> {\n const apps = new Map();\n\n{{__slot4__}}\n\n return apps;\n}\n"; const template_067 = @@ -144,7 +144,7 @@ const template_068 = const template_069 = "{{__slot0__}}\n\nimport type { DistributedApplicationBuilder } from '{{__slot1__}}';\nimport { EndpointProperty } from '{{__slot1__}}';\nimport type { NetScriptConfig } from '{{__slot2__}}';\nimport type { InfrastructureContext } from './register-infrastructure.mjs';\nimport {\n buildDatabaseUriEnvKey,\n buildOtelEnvVars,\n resolvePermissions,\n resolveWorkspacePath,\n} from '{{__slot3__}}';\n\ntype ExecutableResource = ReturnType;\n\n/**\n * Registers plugins with the Aspire SDK builder using a two-pass approach.\n *\n * - Pass 1: Create all plugin resources via `addExecutable()` with resolved\n * permissions, working directory, HTTP endpoint, executable-mode OTEL\n * telemetry, infrastructure dependencies, and service references\n * via endpoint env vars (`getEndpoint('http')` + `withEnvironment()`).\n * - Pass 2: Wire `PluginReferences` (plugin→plugin) via endpoint env vars\n * (`getEndpoint('http')` + `withEnvironment()`) to avoid forward-reference\n * issues.\n *\n * @param builder - The Aspire distributed application builder\n * @param config - Parsed NetScript configuration\n * @param infrastructure - Infrastructure context with database/cache resources\n * @param services - Map of registered service resources (for endpoint env vars)\n * @param appHostDir - Absolute path to the AppHost directory\n * @returns Map of registered plugin resources keyed by name\n */\nexport async function registerPlugins(\n builder: DistributedApplicationBuilder,\n config: NetScriptConfig,\n infrastructure: InfrastructureContext,\n services: Map,\n appHostDir: string,\n): Promise> {\n const plugins = new Map();\n const databaseEnvKey = buildDatabaseUriEnvKey(config);\n\n // --- Pass 1: Create all plugin resources ---\n{{__slot4__}}\n\n // --- Pass 2: Wire plugin→plugin cross-references ---\n{{__slot5__}}\n\n return plugins;\n}\n"; const template_070 = - "{{__slot0__}}\n\nimport type { DistributedApplicationBuilder } from '{{__slot1__}}';\nimport type { NetScriptConfig } from '{{__slot2__}}';\nimport type { InfrastructureContext } from './register-infrastructure.mjs';\nimport {\n buildDatabaseUriEnvKey,\n buildOtelEnvVars,\n resolvePermissions,\n resolveWorkspacePath,\n} from '{{__slot3__}}';\n\ntype ExecutableResource = ReturnType;\n\n/**\n * Registers services with the Aspire SDK builder using a two-pass approach.\n *\n * - Pass 1: Create all service resources via `addExecutable()` with resolved\n * permissions, working directory, HTTP endpoint, OTEL telemetry (3 vars\n * via `denoApp` mode), and infrastructure dependencies.\n * - Pass 2: Wire `ServiceReferences` via `getEndpoint('http')` +\n * `withEnvironment()` using the Aspire service-discovery env var\n * convention (`services__{name}__http__0`) to avoid forward-reference\n * issues where service A references service B but B hasn't been created yet.\n *\n * @param builder - The Aspire distributed application builder\n * @param config - Parsed NetScript configuration\n * @param infrastructure - Infrastructure context with database/cache resources\n * @param appHostDir - Absolute path to the AppHost directory\n * @returns Map of registered service resources keyed by name\n */\nexport async function registerServices(\n builder: DistributedApplicationBuilder,\n config: NetScriptConfig,\n infrastructure: InfrastructureContext,\n appHostDir: string,\n): Promise> {\n const services = new Map();\n const databaseEnvKey = buildDatabaseUriEnvKey(config);\n\n // --- Pass 1: Create all service resources ---\n{{__slot4__}}\n\n // --- Pass 2: Wire cross-references ---\n{{__slot5__}}\n\n return services;\n}\n"; + "{{__slot0__}}\n\nimport type { DistributedApplicationBuilder } from '{{__slot1__}}';\nimport type { NetScriptConfig } from '{{__slot2__}}';\nimport type { InfrastructureContext } from './register-infrastructure.mjs';\nimport {\n buildDatabaseUriEnvKey,\n buildOtelEnvVars,\n extractPluginReferences,\n extractServiceReferences,\n resolvePermissions,\n resolveWorkspacePath,\n} from '{{__slot3__}}';\n\ntype ExecutableResource = ReturnType;\n\n/**\n * Registers services with the Aspire SDK builder using a two-pass approach.\n *\n * - Pass 1: Create all service resources via `addExecutable()` with resolved\n * permissions, working directory, HTTP endpoint, OTEL telemetry (3 vars\n * via `denoApp` mode), and infrastructure dependencies.\n * - Reference wiring is completed by `wireServiceReferences(...)` after\n * services and plugins have both been created, so service -> plugin API\n * discovery uses the same endpoint environment convention as other resources.\n *\n * @param builder - The Aspire distributed application builder\n * @param config - Parsed NetScript configuration\n * @param infrastructure - Infrastructure context with database/cache resources\n * @param appHostDir - Absolute path to the AppHost directory\n * @returns Map of registered service resources keyed by name\n */\nexport async function registerServices(\n builder: DistributedApplicationBuilder,\n config: NetScriptConfig,\n infrastructure: InfrastructureContext,\n appHostDir: string,\n): Promise> {\n const services = new Map();\n const databaseEnvKey = buildDatabaseUriEnvKey(config);\n\n // --- Pass 1: Create all service resources ---\n{{__slot4__}}\n\n return services;\n}\n\n/**\n * Wires endpoint environment variables for service references.\n *\n * Services can reference plain services through `ServiceReferences` and plugin\n * API resources through `PluginReferences`. Plugin resources are created after\n * services, so this pass runs from the AppHost orchestrator after\n * `registerPlugins(...)` returns.\n *\n * @param config - Parsed NetScript configuration\n * @param services - Registered service resources\n * @param plugins - Registered plugin API resources\n */\nexport async function wireServiceReferences(\n config: NetScriptConfig,\n services: Map,\n plugins: Map,\n): Promise {\n for (const [name, entry] of Object.entries(config.Services)) {\n const resource = services.get(name);\n if (!resource) continue;\n\n for (const ref of extractServiceReferences(entry)) {\n const endpoint = await services.get(ref)?.getEndpoint('http');\n if (endpoint) {\n await resource.withEnvironment(`services__${ref}__http__0`, endpoint);\n }\n }\n\n for (const ref of extractPluginReferences(entry)) {\n const endpoint = await plugins.get(ref)?.getEndpoint('http');\n if (endpoint) {\n await resource.withEnvironment(`services__${ref}__http__0`, endpoint);\n }\n }\n }\n}\n"; const template_071 = "{{__slot0__}}\n\nimport type { DistributedApplicationBuilder } from '{{__slot1__}}';\nimport type { NetScriptConfig } from '{{__slot2__}}';\nimport type { InfrastructureContext } from './register-infrastructure.mjs';\nimport { buildDatabaseUriEnvKey, resolveWorkspacePath } from '{{__slot3__}}';\n\ntype ToolResource = Awaited>;\ntype ProcessCommandCapable = {\n readonly withProcessCommand?: (\n name: string,\n executable: string,\n args: readonly string[],\n ) => unknown;\n};\n\nconst PROCESS_COMMANDS_FLAG = 'NETSCRIPT_ASPIRE_PROCESS_COMMANDS';\n\n/**\n * Registers development tools (e.g., Prisma Studio) with the Aspire SDK\n * builder via `addExecutable()`.\n *\n * Tools are simple `deno task` wrappers and are NOT registered with\n * `addDenoApp()`. Each tool resolves to:\n * ```ts\n * builder.addExecutable(name, 'deno', workdir, ['task', taskName]);\n * ```\n *\n * Tools may depend on a specific named database or fall back to the\n * primary database from the infrastructure context.\n *\n * @param builder - The Aspire distributed application builder\n * @param config - Parsed NetScript configuration\n * @param infrastructure - Infrastructure context with database/cache resources\n * @returns Resolves when all tools are registered\n */\nexport async function registerTools(\n builder: DistributedApplicationBuilder,\n config: NetScriptConfig,\n infrastructure: InfrastructureContext,\n appHostDir: string,\n): Promise {\n{{__slot4__}}\n}\n\nasync function attachToolDatabase(\n resource: ToolResource,\n config: NetScriptConfig,\n infrastructure: InfrastructureContext,\n databaseKey?: string,\n): Promise {\n const resolvedKey = databaseKey || config.PrimaryDatabase;\n if (!resolvedKey) {\n return resource;\n }\n\n const databaseConfig = config.Databases[resolvedKey];\n if (databaseConfig?.Engine === 'Sqlite') {\n const sqliteUrl = `file:./${databaseConfig.DatabaseName ?? `${resolvedKey}.db`}`;\n return await resource.withEnvironment('DATABASE_URL', sqliteUrl);\n }\n\n const databaseResource = infrastructure.databases.get(resolvedKey) ?? infrastructure.primaryDatabase;\n if (!databaseResource) {\n return resource;\n }\n\n let databaseBinding = resource.withEnvironment('DATABASE_URL', databaseResource);\n const databaseEnvKey = buildDatabaseUriEnvKey(config);\n if (databaseEnvKey) {\n databaseBinding = databaseBinding.withEnvironment(databaseEnvKey, databaseResource);\n }\n\n return await databaseBinding.withReference(databaseResource).waitFor(databaseResource);\n}\n\nasync function maybeWithProcessCommand(\n resource: ToolResource,\n name: string,\n taskName: string,\n): Promise {\n if (process.env[PROCESS_COMMANDS_FLAG] !== '1') {\n return resource;\n }\n\n const commandCapable = resource as unknown as ProcessCommandCapable;\n const withProcessCommand = commandCapable.withProcessCommand;\n if (typeof withProcessCommand !== 'function') {\n return resource;\n }\n\n // Aspire 13.4 WithProcessCommand seam: wired, but disabled by default.\n void withProcessCommand.call(resource, `Run ${name}`, 'deno', ['task', taskName]);\n return resource;\n}\n\nfunction resolvePrismaStudioWorkdir(\n appHostDir: string,\n config: NetScriptConfig,\n databaseKey?: string,\n): string {\n const resolvedKey = databaseKey || config.PrimaryDatabase;\n if (!resolvedKey) {\n return resolveWorkspacePath(appHostDir, '{{__slot5__}}/prisma-studio');\n }\n\n const databaseConfig = config.Databases[resolvedKey];\n if (!databaseConfig?.Engine) {\n return resolveWorkspacePath(appHostDir, '{{__slot6__}}/prisma-studio');\n }\n\n return resolveWorkspacePath(appHostDir, `database/${toolEngineDir(databaseConfig.Engine)}`);\n}\n\nfunction toolEngineDir(engine: string): string {\n switch (engine) {\n case 'Postgres':\n return 'postgres';\n case 'Mysql':\n return 'mysql';\n case 'Mssql':\n return 'mssql';\n case 'Sqlite':\n return 'sqlite';\n default:\n return engine.toLowerCase();\n }\n}\n"; const template_072 = diff --git a/packages/cli/src/kernel/assets/generated/aspire/helpers/generate-index-1.ts.template b/packages/cli/src/kernel/assets/generated/aspire/helpers/generate-index-1.ts.template index 377954d31..297063d33 100644 --- a/packages/cli/src/kernel/assets/generated/aspire/helpers/generate-index-1.ts.template +++ b/packages/cli/src/kernel/assets/generated/aspire/helpers/generate-index-1.ts.template @@ -6,7 +6,7 @@ import type { DistributedApplicationBuilder } from '{{__slot2__}}'; import { configureDashboard } from '{{__slot3__}}'; import { registerInfrastructure } from '{{__slot4__}}'; import { tryHandleDbCliMode } from '{{__slot5__}}'; -import { registerServices } from '{{__slot6__}}'; +import { registerServices, wireServiceReferences } from '{{__slot6__}}'; import { registerPlugins } from '{{__slot7__}}'; import { registerBackgroundProcessors } from '{{__slot8__}}'; import { registerApps } from '{{__slot9__}}'; @@ -20,11 +20,12 @@ import { registerTools } from '{{__slot10__}}'; * 1. Dashboard OTLP endpoint configuration * 2. Infrastructure (databases + caches) * 3. DB CLI short-circuit mode - * 4. Services (two-pass with cross-reference wiring) + * 4. Services (creation pass) * 5. Plugins (two-pass with plugin→plugin + plugin→service refs) - * 6. Background processors (workers, sagas, triggers) - * 7. Applications (web apps, Tauri desktop, task apps) - * 8. Development tools (Prisma Studio, etc.) + * 6. Service reference wiring (service→service + service→plugin refs) + * 7. Background processors (workers, sagas, triggers) + * 8. Applications (web apps, Tauri desktop, task apps) + * 9. Development tools (Prisma Studio, etc.) * * @param builder - The Aspire distributed application builder * @param configPath - Path to appsettings.json @@ -51,18 +52,21 @@ export async function createNetScriptAppHost( return; } - // 4. Register services (two-pass: create all, then wire cross-refs) + // 4. Register services const services = await registerServices(builder, config, infrastructure, appHostDir); // 5. Register plugins (two-pass: create all, then wire plugin→plugin refs) const plugins = await registerPlugins(builder, config, infrastructure, services, appHostDir); - // 6. Register background processors + // 6. Wire service references after plugin APIs exist + await wireServiceReferences(config, services, plugins); + + // 7. Register background processors await registerBackgroundProcessors(builder, config, infrastructure, services, plugins, appHostDir); - // 7. Register applications + // 8. Register applications await registerApps(builder, config, infrastructure, services, plugins, appHostDir); - // 8. Register development tools + // 9. Register development tools await registerTools(builder, config, infrastructure, appHostDir); } diff --git a/packages/cli/src/kernel/assets/generated/aspire/helpers/generate-register-services-1.ts.template b/packages/cli/src/kernel/assets/generated/aspire/helpers/generate-register-services-1.ts.template index fa58bbb07..dc4785f46 100644 --- a/packages/cli/src/kernel/assets/generated/aspire/helpers/generate-register-services-1.ts.template +++ b/packages/cli/src/kernel/assets/generated/aspire/helpers/generate-register-services-1.ts.template @@ -6,6 +6,8 @@ import type { InfrastructureContext } from './register-infrastructure.mjs'; import { buildDatabaseUriEnvKey, buildOtelEnvVars, + extractPluginReferences, + extractServiceReferences, resolvePermissions, resolveWorkspacePath, } from '{{__slot3__}}'; @@ -18,10 +20,9 @@ type ExecutableResource = ReturnType plugin API + * discovery uses the same endpoint environment convention as other resources. * * @param builder - The Aspire distributed application builder * @param config - Parsed NetScript configuration @@ -41,8 +42,42 @@ export async function registerServices( // --- Pass 1: Create all service resources --- {{__slot4__}} - // --- Pass 2: Wire cross-references --- -{{__slot5__}} - return services; } + +/** + * Wires endpoint environment variables for service references. + * + * Services can reference plain services through `ServiceReferences` and plugin + * API resources through `PluginReferences`. Plugin resources are created after + * services, so this pass runs from the AppHost orchestrator after + * `registerPlugins(...)` returns. + * + * @param config - Parsed NetScript configuration + * @param services - Registered service resources + * @param plugins - Registered plugin API resources + */ +export async function wireServiceReferences( + config: NetScriptConfig, + services: Map, + plugins: Map, +): Promise { + for (const [name, entry] of Object.entries(config.Services)) { + const resource = services.get(name); + if (!resource) continue; + + for (const ref of extractServiceReferences(entry)) { + const endpoint = await services.get(ref)?.getEndpoint('http'); + if (endpoint) { + await resource.withEnvironment(`services__${ref}__http__0`, endpoint); + } + } + + for (const ref of extractPluginReferences(entry)) { + const endpoint = await plugins.get(ref)?.getEndpoint('http'); + if (endpoint) { + await resource.withEnvironment(`services__${ref}__http__0`, endpoint); + } + } + } +} diff --git a/packages/cli/src/kernel/assets/registry-generator-fixture.ts b/packages/cli/src/kernel/assets/registry-generator-fixture.ts index 4b7570390..ba64b69c8 100644 --- a/packages/cli/src/kernel/assets/registry-generator-fixture.ts +++ b/packages/cli/src/kernel/assets/registry-generator-fixture.ts @@ -30,7 +30,7 @@ for (const file of files) { entries.push(\` [\${name}.id, \${name}],\`); } await Deno.mkdir(join(projectRoot, '.netscript', 'generated', 'plugin-workers'), { recursive: true }); -await Deno.writeTextFile(join(projectRoot, '.netscript', 'generated', 'plugin-workers', 'jobs.registry.ts'), [ +await Deno.writeTextFile(join(projectRoot, '.netscript', 'generated', 'plugin-workers', 'job-registry.ts'), [ "import type { JobHandler, RegisterJobInput } from '@netscript/plugin-workers-core/runtime';", ...imports, '', diff --git a/packages/cli/src/kernel/templates/aspire/helpers/generate-index.ts b/packages/cli/src/kernel/templates/aspire/helpers/generate-index.ts index b87834fbc..7e21a402e 100644 --- a/packages/cli/src/kernel/templates/aspire/helpers/generate-index.ts +++ b/packages/cli/src/kernel/templates/aspire/helpers/generate-index.ts @@ -10,11 +10,12 @@ * 1. Dashboard OTLP endpoint configuration * 2. Infrastructure (databases + caches) * 3. DB CLI short-circuit mode - * 4. Services (two-pass with cross-reference wiring) + * 4. Services (creation pass) * 5. Plugins (two-pass with plugin→plugin + plugin→service refs) - * 6. Background processors (workers, sagas, triggers) - * 7. Applications (web apps, Tauri desktop, task apps) - * 8. Development tools (Prisma Studio, etc.) + * 6. Service reference wiring (service→service + service→plugin refs) + * 7. Background processors (workers, sagas, triggers) + * 8. Applications (web apps, Tauri desktop, task apps) + * 9. Development tools (Prisma Studio, etc.) * * Empty sections produce no-op functions in their respective modules — * the index file always includes all registration phases. diff --git a/packages/cli/src/kernel/templates/aspire/helpers/register/generate-register-services.ts b/packages/cli/src/kernel/templates/aspire/helpers/register/generate-register-services.ts index 36c7b286e..3415746c6 100644 --- a/packages/cli/src/kernel/templates/aspire/helpers/register/generate-register-services.ts +++ b/packages/cli/src/kernel/templates/aspire/helpers/register/generate-register-services.ts @@ -10,17 +10,17 @@ * - **Pass 1:** Create ALL service resources — `addExecutable()` + working dir + * HTTP endpoint + executable-mode OTEL telemetry + * infrastructure dependencies (`waitFor` primary database). - * - **Pass 2:** Wire `ServiceReferences` via `getEndpoint('http')` + - * `withEnvironment()` — all services now exist in the Map, so forward - * references resolve cleanly. Uses Aspire service-discovery env var - * convention: `services__{name}__http__0`. + * - **Pass 2:** Wire `ServiceReferences` and `PluginReferences` via + * `getEndpoint('http')` + `withEnvironment()` after services and plugins + * both exist. Uses Aspire service-discovery env var convention: + * `services__{name}__http__0`. * * Services use `--watch-hmr` for watch mode (HMR-capable), unlike background * processors and apps which use `--watch`. */ import type { RegisterServicesOptions } from '../types.ts'; -import { fileHeader, safeIdentifier } from '../_utils.ts'; +import { fileHeader } from '../_utils.ts'; import { SCAFFOLD_ASPIRE_MODULES } from '../../../../constants/scaffold/scaffold-aspire.ts'; import { SCAFFOLD_DIRS } from '../../../../constants/scaffold/scaffold-dirs.ts'; import { RESOURCE_DEFAULTS } from '@netscript/aspire/constants'; @@ -104,52 +104,16 @@ export function generateRegisterServices(options: RegisterServicesOptions): stri pass1Blocks.push(lines.join('\n')); } - // --- Pass 2 blocks: wire cross-references --- - const pass2Blocks: string[] = []; - - for (const [name, entry] of entries) { - const refs = entry.ServiceReferences; - if (!refs || refs.length === 0) continue; - - const lines: string[] = []; - lines.push(` // --- ${name}: wire ServiceReferences via endpoint env vars ---`); - lines.push(` {`); - lines.push(` const resource = services.get('${name}');`); - lines.push(` if (resource) {`); - - for (const ref of refs) { - const refId = safeIdentifier(ref); - lines.push( - ` const ${refId}Endpoint = await services.get('${ref}')?.getEndpoint('http');`, - ); - lines.push(` if (${refId}Endpoint) {`); - lines.push( - ` await resource.withEnvironment('services__${ref}__http__0', ${refId}Endpoint);`, - ); - lines.push(` }`); - } - - lines.push(` }`); - lines.push(` }`); - - pass2Blocks.push(lines.join('\n')); - } - // --- Compose full output --- const pass1Content = pass1Blocks.length > 0 ? pass1Blocks.join('\n\n') : ' // No services configured'; - const pass2Content = pass2Blocks.length > 0 - ? pass2Blocks.join('\n\n') - : ' // No cross-references to wire'; - return renderTemplateAssetSync(TEMPLATE_KEYS.generatedAspireHelpersGenerateRegisterServices1, { __slot0__: String(fileHeader('register-services.mts')), __slot1__: String(SCAFFOLD_ASPIRE_MODULES.SDK_IMPORT_FROM_HELPERS), __slot2__: String(SCAFFOLD_ASPIRE_MODULES.ASPIRE_COMPAT_IMPORT), __slot3__: String(SCAFFOLD_ASPIRE_MODULES.ASPIRE_COMPAT_IMPORT), __slot4__: String(pass1Content), - __slot5__: String(pass2Content), }); } diff --git a/packages/cli/src/kernel/templates/aspire/helpers/tests/generators-service-plugin_test.ts b/packages/cli/src/kernel/templates/aspire/helpers/tests/generators-service-plugin_test.ts index 62f20c9d0..070565ec1 100644 --- a/packages/cli/src/kernel/templates/aspire/helpers/tests/generators-service-plugin_test.ts +++ b/packages/cli/src/kernel/templates/aspire/helpers/tests/generators-service-plugin_test.ts @@ -42,9 +42,11 @@ describe('generateRegisterServices', () => { assertStringIncludes(output, 'export async function registerServices('); }); - it('should import buildOtelEnvVars, resolvePermissions, resolveWorkspacePath', () => { + it('should import register-services dependencies', () => { const output = generateRegisterServices(emptyOptions); assertStringIncludes(output, 'buildOtelEnvVars,'); + assertStringIncludes(output, 'extractPluginReferences,'); + assertStringIncludes(output, 'extractServiceReferences,'); assertStringIncludes(output, 'resolvePermissions,'); assertStringIncludes(output, 'resolveWorkspacePath,'); assertStringIncludes(output, "from './_aspire-compat.mjs'"); @@ -56,7 +58,7 @@ describe('generateRegisterServices', () => { services: { users: fixtures.MINIMAL_SERVICE }, }); assertStringIncludes(output, '// --- Pass 1: Create all service resources ---'); - assertStringIncludes(output, '// --- Pass 2: Wire cross-references ---'); + assertStringIncludes(output, 'export async function wireServiceReferences('); }); it('should register services via addExecutable with correct port and entrypoint', () => { @@ -109,7 +111,7 @@ describe('generateRegisterServices', () => { assertStringIncludes(output, '.waitFor(infrastructure.primaryDatabase)'); }); - it('should wire cross-references in pass 2', () => { + it('should wire service references from the services map', () => { const output = generateRegisterServices({ ...emptyOptions, services: { @@ -117,27 +119,27 @@ describe('generateRegisterServices', () => { orders: fixtures.SERVICE_WITH_REFS, }, }); - assertStringIncludes( - output, - '// --- orders: wire ServiceReferences via endpoint env vars ---', - ); - assertStringIncludes(output, "services.get('users')?.getEndpoint('http')"); - assertStringIncludes(output, "resource.withEnvironment('services__users__http__0'"); + assertStringIncludes(output, 'for (const ref of extractServiceReferences(entry))'); + assertStringIncludes(output, "const endpoint = await services.get(ref)?.getEndpoint('http')"); + assertStringIncludes(output, 'services__${ref}__http__0'); }); - it('should not emit pass 2 blocks for services without references', () => { + it('should wire plugin references from the plugins map', () => { const output = generateRegisterServices({ ...emptyOptions, - services: { users: fixtures.MINIMAL_SERVICE }, + services: { + reporting: fixtures.SERVICE_WITH_PLUGIN_REFS, + }, }); - // Pass 2 should have no-op comment since users has no ServiceReferences - assertStringIncludes(output, '// No cross-references to wire'); + assertStringIncludes(output, 'for (const ref of extractPluginReferences(entry))'); + assertStringIncludes(output, "const endpoint = await plugins.get(ref)?.getEndpoint('http')"); + assertStringIncludes(output, 'services__${ref}__http__0'); }); it('should handle empty services', () => { const output = generateRegisterServices(emptyOptions); assertStringIncludes(output, '// No services configured'); - assertStringIncludes(output, '// No cross-references to wire'); + assertStringIncludes(output, 'export async function wireServiceReferences('); }); }); // generateRegisterPlugins diff --git a/packages/cli/src/kernel/templates/aspire/helpers/tests/generators-test-support.ts b/packages/cli/src/kernel/templates/aspire/helpers/tests/generators-test-support.ts index 683396613..b8790fc9e 100644 --- a/packages/cli/src/kernel/templates/aspire/helpers/tests/generators-test-support.ts +++ b/packages/cli/src/kernel/templates/aspire/helpers/tests/generators-test-support.ts @@ -40,6 +40,15 @@ export const SERVICE_WITH_REFS: ServiceEntry = { ServiceReferences: ['users'], }; +export const SERVICE_WITH_PLUGIN_REFS: ServiceEntry = { + Enabled: true, + Runtime: 'deno', + Port: 3002, + Entrypoint: 'src/main.ts', + Workdir: 'services/reporting', + PluginReferences: ['workers-api'], +}; + // --- Plugin Fixtures --- export const MINIMAL_PLUGIN: PluginEntry = { diff --git a/packages/cli/src/kernel/templates/aspire/helpers/tests/generators-tools-db-index_test.ts b/packages/cli/src/kernel/templates/aspire/helpers/tests/generators-tools-db-index_test.ts index 33070e600..b1dd5d49a 100644 --- a/packages/cli/src/kernel/templates/aspire/helpers/tests/generators-tools-db-index_test.ts +++ b/packages/cli/src/kernel/templates/aspire/helpers/tests/generators-tools-db-index_test.ts @@ -255,7 +255,7 @@ describe('generateIndex', () => { ); assertStringIncludes( output, - "import { registerServices } from './register-services.mjs'", + "import { registerServices, wireServiceReferences } from './register-services.mjs'", ); assertStringIncludes( output, @@ -281,6 +281,7 @@ describe('generateIndex', () => { const infraIdx = output.indexOf('registerInfrastructure(builder'); const servicesIdx = output.indexOf('registerServices(builder'); const pluginsIdx = output.indexOf('registerPlugins(builder'); + const wireServicesIdx = output.indexOf('wireServiceReferences(config, services, plugins)'); const backgroundIdx = output.indexOf('registerBackgroundProcessors(builder'); const appsIdx = output.indexOf('registerApps(builder'); const toolsIdx = output.indexOf('registerTools(builder, config, infrastructure, appHostDir)'); @@ -289,7 +290,8 @@ describe('generateIndex', () => { assert(infraIdx > dashboardIdx, 'infrastructure should follow dashboard'); assert(servicesIdx > infraIdx, 'services should follow infrastructure'); assert(pluginsIdx > servicesIdx, 'plugins should follow services'); - assert(backgroundIdx > pluginsIdx, 'background should follow plugins'); + assert(wireServicesIdx > pluginsIdx, 'service reference wiring should follow plugins'); + assert(backgroundIdx > wireServicesIdx, 'background should follow service reference wiring'); assert(appsIdx > backgroundIdx, 'apps should follow background'); assert(toolsIdx > appsIdx, 'tools should follow apps'); }); diff --git a/packages/cli/src/maintainer/features/sync/plugin/copy-official-plugin-copy_test.ts b/packages/cli/src/maintainer/features/sync/plugin/copy-official-plugin-copy_test.ts index f6bfb6d67..b941b0d49 100644 --- a/packages/cli/src/maintainer/features/sync/plugin/copy-official-plugin-copy_test.ts +++ b/packages/cli/src/maintainer/features/sync/plugin/copy-official-plugin-copy_test.ts @@ -146,7 +146,7 @@ Deno.test("copyOfficialPlugin copies plugin and background source workspaces", a ); await writeSourceFile( sourceRoot, - ".netscript/generated/plugin-workers/jobs.registry.ts", + ".netscript/generated/plugin-workers/job-registry.ts", "import stale from '../../../plugins/triggers/jobs/file-import.ts';\nexport const registry = new Map([[stale.id, stale]]);\n", ); @@ -210,7 +210,7 @@ Deno.test("copyOfficialPlugin copies plugin and background source workspaces", a ); const registry = await Deno.readTextFile( - join(targetPath, ".netscript/generated/plugin-workers/jobs.registry.ts"), + join(targetPath, ".netscript/generated/plugin-workers/job-registry.ts"), ); assertEquals( registry.includes("../../../plugins/triggers/jobs/file-import.ts"), @@ -320,7 +320,7 @@ Deno.test("copyOfficialPlugin copies plugin and background source workspaces", a ); const integratedRegistry = await Deno.readTextFile( - join(targetPath, ".netscript/generated/plugin-workers/jobs.registry.ts"), + join(targetPath, ".netscript/generated/plugin-workers/job-registry.ts"), ); assertEquals(integratedRegistry.includes("send-welcome-email.ts"), true); assertEquals(integratedRegistry.includes("process-webhook-payload.ts"), true); diff --git a/packages/config/src/domain/config-section-types.ts b/packages/config/src/domain/config-section-types.ts index 22b45b2a9..77fc19390 100644 --- a/packages/config/src/domain/config-section-types.ts +++ b/packages/config/src/domain/config-section-types.ts @@ -125,6 +125,8 @@ export interface ServiceConfig { entrypoint?: string; /** Service dependencies by configured service name. */ dependsOn?: string[]; + /** Plugin API dependencies by configured plugin resource name. */ + pluginReferences?: string[]; } /** Frontend application configuration definition. */ diff --git a/packages/config/src/domain/schemas/service-schema.ts b/packages/config/src/domain/schemas/service-schema.ts index 8c286351f..792febf9d 100644 --- a/packages/config/src/domain/schemas/service-schema.ts +++ b/packages/config/src/domain/schemas/service-schema.ts @@ -15,4 +15,6 @@ export const ServiceConfigSchema: z.ZodType = z.object({ entrypoint: z.string().optional(), /** Services this service depends on */ dependsOn: z.array(z.string()).optional(), + /** Plugin APIs this service depends on */ + pluginReferences: z.array(z.string()).optional(), }); diff --git a/packages/config/tests/schema/service_schema_test.ts b/packages/config/tests/schema/service_schema_test.ts new file mode 100644 index 000000000..260fffa7f --- /dev/null +++ b/packages/config/tests/schema/service_schema_test.ts @@ -0,0 +1,13 @@ +import { assertEquals } from 'jsr:@std/assert@^1'; +import { ServiceConfigSchema } from '../../src/domain/schemas/service-schema.ts'; + +Deno.test('ServiceConfigSchema accepts plugin API references', () => { + const service = ServiceConfigSchema.parse({ + runtime: 'deno', + port: 3000, + dependsOn: ['users'], + pluginReferences: ['workers-api'], + }); + + assertEquals(service.pluginReferences, ['workers-api']); +}); diff --git a/plugins/workers/bin/combined.ts b/plugins/workers/bin/combined.ts index 91b86c86d..d293ee0a8 100644 --- a/plugins/workers/bin/combined.ts +++ b/plugins/workers/bin/combined.ts @@ -1,5 +1,10 @@ import type { StaticJobRegistry } from '@netscript/plugin-workers-core/runtime'; -import { startCombinedProcess, type StaticJobDefinitionRegistry } from './runtime.ts'; +import { + loadGeneratedJobRegistry, + startCombinedProcess, + type StaticJobDefinitionRegistry, + WORKERS_JOB_REGISTRY_PATH, +} from './runtime.ts'; const generated = await loadGeneratedJobs(); @@ -11,25 +16,6 @@ async function loadGeneratedJobs(): Promise< registry?: StaticJobRegistry; }> > { - const registryUrl = new URL( - '../../.netscript/generated/plugin-workers/jobs.registry.ts', - import.meta.url, - ); - - try { - await Deno.stat(registryUrl); - } catch (error) { - if (error instanceof Deno.errors.NotFound) return {}; - throw error; - } - - const module = await import(registryUrl.href); - const definitions = module.jobDefinitions instanceof Map - ? module.jobDefinitions - : module.definitions instanceof Map - ? module.definitions - : undefined; - const registry = module.registry instanceof Map ? module.registry : undefined; - - return { definitions, registry }; + const registryUrl = new URL(`../../${WORKERS_JOB_REGISTRY_PATH}`, import.meta.url); + return await loadGeneratedJobRegistry(registryUrl); } diff --git a/plugins/workers/bin/runtime.ts b/plugins/workers/bin/runtime.ts index 4c9ca094e..57e1ebaf2 100644 --- a/plugins/workers/bin/runtime.ts +++ b/plugins/workers/bin/runtime.ts @@ -7,18 +7,21 @@ // Register Redis/Garnet KV adapter before createWorkersServiceRuntime() calls getKv(). import '@netscript/kv/redis'; import { createDefaultTaskExecutor } from '@netscript/plugin-workers-core/executor'; -import type { RegisterJobInput, StaticJobRegistry } from '@netscript/plugin-workers-core/runtime'; +import type { StaticJobRegistry } from '@netscript/plugin-workers-core/runtime'; +export { + loadGeneratedJobRegistry, + projectFileUrl, + registerStaticJobDefinitions, + type StaticJobDefinitionRegistry, + WORKERS_JOB_REGISTRY_PATH, +} from '../src/runtime/generated-jobs.ts'; +import { + registerStaticJobDefinitions, + type StaticJobDefinitionRegistry, +} from '../src/runtime/generated-jobs.ts'; import { createWorkersServiceRuntime } from '../services/src/service-runtime.ts'; import { Scheduler, Worker } from '../worker/mod.ts'; -/** Generated static job definitions keyed by job id. */ -export type StaticJobDefinitionRegistry = ReadonlyMap; - -type StaticJobDefinitionRegistrar = Readonly<{ - get(id: string): Promise; - registerJob(input: RegisterJobInput): Promise; -}>; - /** Options for starting only the workers job execution process. */ export type StartWorkerProcessOptions = Readonly<{ /** Number of jobs the worker may execute concurrently. */ @@ -109,26 +112,3 @@ export async function startCombinedProcess( await worker.start(); return Object.freeze({ scheduler, worker }); } - -/** Register generated static job definitions if the project emitted them. */ -export async function registerStaticJobDefinitions( - registry: StaticJobDefinitionRegistrar, - definitions?: StaticJobDefinitionRegistry, -): Promise { - if (!definitions?.size) return; - - console.log(`[Workers Plugin] Registering ${definitions.size} static job definition(s)...`); - for (const [id, definition] of definitions) { - const existing = await registry.get(id); - if (existing) continue; - - try { - await registry.registerJob({ ...definition, id }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.includes(`Job with id '${id}' already exists`)) { - throw error; - } - } - } -} diff --git a/plugins/workers/scaffold.runtime.json b/plugins/workers/scaffold.runtime.json index ee383eeb5..9a46fd747 100644 --- a/plugins/workers/scaffold.runtime.json +++ b/plugins/workers/scaffold.runtime.json @@ -26,7 +26,7 @@ { "kind": "workers-job", "dir": "workers/jobs", - "registryPath": ".netscript/generated/plugin-workers/jobs.registry.ts", + "registryPath": ".netscript/generated/plugin-workers/job-registry.ts", "fileSuffixes": [".ts"], "exclude": ["_registry.ts", "job-tools.ts", "mod.ts", "types.ts"], "registryKey": "id", diff --git a/plugins/workers/services/src/generated-jobs.ts b/plugins/workers/services/src/generated-jobs.ts new file mode 100644 index 000000000..c40a3a95b --- /dev/null +++ b/plugins/workers/services/src/generated-jobs.ts @@ -0,0 +1,14 @@ +import { + loadGeneratedJobRegistry, + registerStaticJobDefinitions, +} from '../../src/runtime/generated-jobs.ts'; +import type { WorkersServiceRuntime } from './routers/router-context.ts'; + +/** Loads generated user job definitions into the workers API service runtime. */ +export async function registerGeneratedJobDefinitions( + runtime: WorkersServiceRuntime, + registryUrl: URL, +): Promise { + const generated = await loadGeneratedJobRegistry(registryUrl); + await registerStaticJobDefinitions(runtime.jobRegistry, generated.definitions); +} diff --git a/plugins/workers/services/src/generated-jobs_test.ts b/plugins/workers/services/src/generated-jobs_test.ts new file mode 100644 index 000000000..0720fb86e --- /dev/null +++ b/plugins/workers/services/src/generated-jobs_test.ts @@ -0,0 +1,70 @@ +import { assertEquals } from 'jsr:@std/assert@^1'; +import type { RegisterJobInput } from '@netscript/plugin-workers-core/runtime'; +import { registerGeneratedJobDefinitions } from './generated-jobs.ts'; +import type { WorkersServiceRuntime } from './routers/router-context.ts'; + +Deno.test('registerGeneratedJobDefinitions loads user jobs into the API service runtime', async () => { + const tempDir = await Deno.makeTempDir(); + const registryUrl = new URL(`file://${tempDir}/job-registry.ts`); + await Deno.writeTextFile( + registryUrl, + [ + 'export const jobDefinitions = new Map([', + ' ["embed-document", {', + ' id: "embed-document",', + ' name: "Embed Document",', + ' entrypoint: "./workers/jobs/embed-document.ts",', + ' topic: "default",', + ' source: "local",', + ' executionType: "deno",', + ' timezone: "UTC",', + ' timeout: 300000,', + ' maxRetries: 3,', + ' retryDelay: 1000,', + ' maxConcurrency: 1,', + ' priority: 50,', + ' enabled: true,', + ' persist: true,', + ' tags: [],', + ' }],', + ']);', + ].join('\n'), + ); + + const registered = new Map(); + const runtime = { + jobRegistry: { + get: (id: string) => Promise.resolve(registered.get(id)), + registerJob: (input: RegisterJobInput) => { + if (!input.id) throw new Error('registered job is missing id'); + registered.set(input.id, input); + return Promise.resolve(input); + }, + }, + } as unknown as WorkersServiceRuntime; + + await registerGeneratedJobDefinitions(runtime, registryUrl); + + assertEquals(registered.get('embed-document')?.entrypoint, './workers/jobs/embed-document.ts'); +}); + +Deno.test('registerGeneratedJobDefinitions tolerates a missing generated registry', async () => { + const registered = new Map(); + const runtime = { + jobRegistry: { + get: (id: string) => Promise.resolve(registered.get(id)), + registerJob: (input: RegisterJobInput) => { + if (!input.id) throw new Error('registered job is missing id'); + registered.set(input.id, input); + return Promise.resolve(input); + }, + }, + } as unknown as WorkersServiceRuntime; + + await registerGeneratedJobDefinitions( + runtime, + new URL(`file://${await Deno.makeTempDir()}/missing/job-registry.ts`), + ); + + assertEquals(registered.size, 0); +}); diff --git a/plugins/workers/services/src/main.ts b/plugins/workers/services/src/main.ts index f3203fbcb..3f10931cc 100644 --- a/plugins/workers/services/src/main.ts +++ b/plugins/workers/services/src/main.ts @@ -21,6 +21,8 @@ import type { RunningService } from '@netscript/service'; import { createPluginService } from '@netscript/plugin/service'; import { router } from './router.ts'; import { registerPluginJobs } from './init.ts'; +import { registerGeneratedJobDefinitions } from './generated-jobs.ts'; +import { projectFileUrl, WORKERS_JOB_REGISTRY_PATH } from '../../src/runtime/generated-jobs.ts'; import { createStreamMutationHook } from '../../streams/server.ts'; import { createWorkersServiceRuntime } from './service-runtime.ts'; @@ -45,6 +47,8 @@ export default async function createWorkersService( ): Promise { const port = parseInt(ctx.env.PORT ?? Deno.env.get('PORT') ?? '8091'); const runtime = await createWorkersServiceRuntime(); + await registerPluginJobs(runtime); + await registerGeneratedJobDefinitions(runtime, projectFileUrl(WORKERS_JOB_REGISTRY_PATH)); const running = await createPluginService(router, { name: 'workers', @@ -57,9 +61,8 @@ export default async function createWorkersService( context: () => ({ workers: runtime }), }).serve(); - queueMicrotask(async () => { + queueMicrotask(() => { try { - await registerPluginJobs(runtime); runtime.executionState.setMutationHook(createStreamMutationHook()); console.log( diff --git a/plugins/workers/src/adapter/resources/glue/runtime.stub.ts b/plugins/workers/src/adapter/resources/glue/runtime.stub.ts index 725c7691d..e9407f36e 100644 --- a/plugins/workers/src/adapter/resources/glue/runtime.stub.ts +++ b/plugins/workers/src/adapter/resources/glue/runtime.stub.ts @@ -15,8 +15,10 @@ export const runtimeGlueStub: StubSource = defineStub({ '', "import type { StaticJobRegistry } from '@netscript/plugin-workers-core/runtime';", 'import {', + ' projectFileUrl,', ' startCombinedProcess,', ' type StaticJobDefinitionRegistry,', + ' WORKERS_JOB_REGISTRY_PATH,', "} from '@netscript/plugin-workers/runtime';", '', 'if (import.meta.main) {', @@ -29,7 +31,7 @@ export const runtimeGlueStub: StubSource = defineStub({ ' registry?: StaticJobRegistry;', ' }>', '> {', - " const registryUrl = projectFileUrl('.netscript/generated/plugin-workers/jobs.registry.ts');", + ' const registryUrl = projectFileUrl(WORKERS_JOB_REGISTRY_PATH);', '', ' try {', ' await Deno.stat(registryUrl);', @@ -57,15 +59,6 @@ export const runtimeGlueStub: StubSource = defineStub({ ' return value instanceof Map;', '}', '', - 'function projectFileUrl(relativePath: string): URL {', - " const root = Deno.cwd().replaceAll('\\\\', '/');", - " const normalizedRoot = root.endsWith('/') ? root : `${root}/`;", - " const base = normalizedRoot.startsWith('/')", - ' ? `file://${normalizedRoot}`', - ' : `file:///${normalizedRoot}`;', - ' return new URL(relativePath, base);', - '}', - '', ].join('\n'), tokens: [], }); diff --git a/plugins/workers/src/runtime/generated-jobs.ts b/plugins/workers/src/runtime/generated-jobs.ts new file mode 100644 index 000000000..5a53093c2 --- /dev/null +++ b/plugins/workers/src/runtime/generated-jobs.ts @@ -0,0 +1,80 @@ +import type { RegisterJobInput, StaticJobRegistry } from '@netscript/plugin-workers-core/runtime'; + +/** Canonical project-relative path for the generated workers job registry. */ +export const WORKERS_JOB_REGISTRY_PATH = '.netscript/generated/plugin-workers/job-registry.ts'; + +/** Generated static job definitions keyed by job id. */ +export type StaticJobDefinitionRegistry = ReadonlyMap; + +/** Generated workers job registry module exports consumed by service and runtime processes. */ +export type GeneratedWorkersJobRegistry = Readonly<{ + definitions?: StaticJobDefinitionRegistry; + registry?: StaticJobRegistry; +}>; + +type StaticJobDefinitionRegistrar = Readonly<{ + get(id: string): Promise; + registerJob(input: RegisterJobInput): Promise; +}>; + +/** Loads a generated workers registry module if one exists. */ +export async function loadGeneratedJobRegistry( + registryUrl: URL, +): Promise { + try { + await Deno.stat(registryUrl); + } catch (error) { + if (error instanceof Deno.errors.NotFound) return {}; + throw error; + } + + const module = await import(registryUrl.href) as Record; + const definitions = isStaticJobDefinitionRegistry(module.jobDefinitions) + ? module.jobDefinitions + : isStaticJobDefinitionRegistry(module.definitions) + ? module.definitions + : undefined; + const registry = isStaticJobRegistry(module.registry) ? module.registry : undefined; + + return { definitions, registry }; +} + +/** Register generated static job definitions if the project emitted them. */ +export async function registerStaticJobDefinitions( + registry: StaticJobDefinitionRegistrar, + definitions?: StaticJobDefinitionRegistry, +): Promise { + if (!definitions?.size) return; + + for (const [id, definition] of definitions) { + const existing = await registry.get(id); + if (existing) continue; + + try { + await registry.registerJob({ ...definition, id }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes(`Job with id '${id}' already exists`)) { + throw error; + } + } + } +} + +/** Builds a file URL relative to the current project root. */ +export function projectFileUrl(relativePath: string): URL { + const root = Deno.cwd().replaceAll('\\', '/'); + const normalizedRoot = root.endsWith('/') ? root : `${root}/`; + const base = normalizedRoot.startsWith('/') + ? `file://${normalizedRoot}` + : `file:///${normalizedRoot}`; + return new URL(relativePath, base); +} + +function isStaticJobDefinitionRegistry(value: unknown): value is StaticJobDefinitionRegistry { + return value instanceof Map; +} + +function isStaticJobRegistry(value: unknown): value is StaticJobRegistry { + return value instanceof Map; +}