@codemation/host 0.8.0 → 0.9.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.
Files changed (148) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/{ApiPaths-Dv1dcHu_.js → ApiPaths-DCvrlIjg.js} +12 -1
  3. package/dist/{ApiPaths-Dv1dcHu_.js.map → ApiPaths-DCvrlIjg.js.map} +1 -1
  4. package/dist/{AppConfigFactory-Cx4qQvRk.js → AppConfigFactory-D4LL1aOR.js} +77 -297
  5. package/dist/AppConfigFactory-D4LL1aOR.js.map +1 -0
  6. package/dist/{AppConfigFactory-BT0y0LVC.d.ts → AppConfigFactory-DncmwCD1.d.ts} +2918 -199
  7. package/dist/{AppContainerFactory-DRTjG7nG.js → AppContainerFactory-CHCXP2rn.js} +1735 -474
  8. package/dist/AppContainerFactory-CHCXP2rn.js.map +1 -0
  9. package/dist/{CodemationAppContext-CGFYVcSb.d.ts → CodemationAppContext-K51b7oXe.d.ts} +3 -3
  10. package/dist/{CodemationAuthoring.types-DiKKogum.d.ts → CodemationAuthoring.types-BXlXIl4K.d.ts} +4 -4
  11. package/dist/{CodemationConfigNormalizer-48f-T66P.d.ts → CodemationConfigNormalizer-B4rDYC9h.d.ts} +3 -3
  12. package/dist/{CodemationConsumerConfigLoader-_PIYqwVx.d.ts → CodemationConsumerConfigLoader-Dt4jyLx6.d.ts} +2 -2
  13. package/dist/{CodemationPluginListMerger-DP7djJ9S.d.ts → CodemationPluginListMerger-DS6I3Xe0.d.ts} +24 -12
  14. package/dist/{persistenceServer-C-hH4z6l.js → CodemationPostgresPrismaClientFactory-C7156Fe-.js} +2 -2
  15. package/dist/CodemationPostgresPrismaClientFactory-C7156Fe-.js.map +1 -0
  16. package/dist/CodemationPostgresPrismaClientFactory-CTNTPnDr.d.ts +9 -0
  17. package/dist/{CredentialContractsRegistry-Bq2bq28t.d.ts → CredentialContractsRegistry-Dgu-rEXi.d.ts} +16 -3
  18. package/dist/{CredentialServices-BLloBztI.d.ts → CredentialServices-B3wPyp2y.d.ts} +4 -4
  19. package/dist/{CredentialServices-Dk8yypeL.js → CredentialServices-Bios0dM8.js} +10 -4
  20. package/dist/CredentialServices-Bios0dM8.js.map +1 -0
  21. package/dist/{InternalHonoApiRouteRegistrar-c7t3KnV_.d.ts → InternalHonoApiRouteRegistrar-Ce1yxpnO.d.ts} +1 -1
  22. package/dist/{InternalPingRegistrar-DY3kSfxP.js → InternalPingRegistrar-BavAAnvk.js} +19 -16
  23. package/dist/InternalPingRegistrar-BavAAnvk.js.map +1 -0
  24. package/dist/{ItemsInputNormalizer-_RwIfRIQ.d.ts → ItemsInputNormalizer-CFkfNMLt.d.ts} +1434 -1225
  25. package/dist/PrismaMigrationDeployer-DdEcXXVi.d.ts +14 -0
  26. package/dist/{PublicFrontendBootstrapFactory-Dv04tJ-6.d.ts → PublicFrontendBootstrapFactory-ClEjZP74.d.ts} +2 -2
  27. package/dist/{PublicFrontendBootstrapJsonCodec-CXG9Dxft.d.ts → PublicFrontendBootstrapJsonCodec-HNItQ7ol.d.ts} +6 -1
  28. package/dist/{TelemetryContracts-BtDx84Cp.d.ts → TelemetryContracts-DpZEODQM.d.ts} +2 -2
  29. package/dist/{WorkflowPolicyUiPresentationFactory-6MyjCvBO.d.ts → WorkflowPolicyUiPresentationFactory-BNn2fvR_.d.ts} +2 -2
  30. package/dist/{WorkflowPolicyUiPresentationFactory-Bb-ae_Zh.js → WorkflowPolicyUiPresentationFactory-DfvD2VHk.js} +1 -1
  31. package/dist/{WorkflowPolicyUiPresentationFactory-Bb-ae_Zh.js.map → WorkflowPolicyUiPresentationFactory-DfvD2VHk.js.map} +1 -1
  32. package/dist/authoring.d.ts +4 -4
  33. package/dist/client.d.ts +1 -1
  34. package/dist/client.js +1 -1
  35. package/dist/consumer.d.ts +5 -5
  36. package/dist/credentials.d.ts +5 -5
  37. package/dist/credentials.js +1 -1
  38. package/dist/devServerSidecar.d.ts +2 -2
  39. package/dist/dto.d.ts +5 -5
  40. package/dist/{index-DilAYwnH.d.ts → index-ChIfeWzk.d.ts} +71 -28
  41. package/dist/index.d.ts +17 -16
  42. package/dist/index.js +8 -8
  43. package/dist/infrastructure/persistence/PrismaMigrationOperations.d.ts +44 -0
  44. package/dist/infrastructure/persistence/PrismaMigrationOperations.js +302 -0
  45. package/dist/infrastructure/persistence/PrismaMigrationOperations.js.map +1 -0
  46. package/dist/mapping.d.ts +2 -2
  47. package/dist/mapping.js +1 -1
  48. package/dist/nextServer.d.ts +15 -13
  49. package/dist/nextServer.js +6 -6
  50. package/dist/pairing.d.ts +28 -9
  51. package/dist/pairing.js +19 -3
  52. package/dist/pairing.js.map +1 -0
  53. package/dist/{pairing.types-snfZ_OzB.d.ts → pairing.types-D9Bjn98U.d.ts} +1 -1
  54. package/dist/persistenceServer.d.ts +31 -7
  55. package/dist/persistenceServer.js +2 -2
  56. package/dist/{server-09PKasWR.d.ts → server-B5trn7y4.d.ts} +5 -5
  57. package/dist/{server-vtRCPgRJ.js → server-CNj_y0QO.js} +4 -4
  58. package/dist/{server-vtRCPgRJ.js.map → server-CNj_y0QO.js.map} +1 -1
  59. package/dist/server.d.ts +10 -10
  60. package/dist/server.js +8 -8
  61. package/package.json +11 -10
  62. package/playwright.config.ts +8 -2
  63. package/playwright.scaffolded-dev.config.ts +8 -2
  64. package/prisma/migrations/20260526120000_credential_material_pointer/migration.sql +18 -0
  65. package/prisma/migrations/20260527120000_add_human_task/migration.sql +32 -0
  66. package/prisma/migrations/20260527130000_add_hitl_state_json/migration.sql +6 -0
  67. package/prisma/migrations/20260527130000_add_hmac_nonce/migration.sql +12 -0
  68. package/prisma/migrations.sqlite/20260526120000_credential_material_pointer/migration.sql +13 -0
  69. package/prisma/migrations.sqlite/20260527120000_add_human_task/migration.sql +30 -0
  70. package/prisma/migrations.sqlite/20260527130000_add_hitl_state_json/migration.sql +6 -0
  71. package/prisma/migrations.sqlite/20260527130000_add_hmac_nonce/migration.sql +9 -0
  72. package/prisma/schema.postgresql.prisma +48 -0
  73. package/prisma/schema.sqlite.prisma +48 -0
  74. package/prisma-generated/prisma-postgresql-client/edge.js +40 -6
  75. package/prisma-generated/prisma-postgresql-client/index-browser.js +36 -2
  76. package/prisma-generated/prisma-postgresql-client/index.d.ts +3179 -163
  77. package/prisma-generated/prisma-postgresql-client/index.js +40 -6
  78. package/prisma-generated/prisma-postgresql-client/package.json +1 -1
  79. package/prisma-generated/prisma-postgresql-client/schema.prisma +48 -0
  80. package/prisma-generated/prisma-sqlite-client/edge.js +40 -6
  81. package/prisma-generated/prisma-sqlite-client/index-browser.js +36 -2
  82. package/prisma-generated/prisma-sqlite-client/index.d.ts +3175 -163
  83. package/prisma-generated/prisma-sqlite-client/index.js +40 -6
  84. package/prisma-generated/prisma-sqlite-client/package.json +1 -1
  85. package/prisma-generated/prisma-sqlite-client/schema.prisma +48 -0
  86. package/src/application/contracts/CredentialContractsRegistry.ts +15 -0
  87. package/src/application/credentials/AppGalleryProjector.ts +69 -0
  88. package/src/application/hitl/DecideHumanTaskCommandHandler.ts +149 -0
  89. package/src/application/hitl/DecisionSchemaValidator.ts +22 -0
  90. package/src/application/hitl/HitlCallbackHandler.ts +96 -0
  91. package/src/application/mapping/WorkflowDefinitionMapper.ts +1 -3
  92. package/src/application/queries/CredentialQueryHandlers.ts +2 -0
  93. package/src/application/queries/GetCredentialAppsQuery.ts +4 -0
  94. package/src/application/queries/GetCredentialAppsQueryHandler.ts +27 -0
  95. package/src/application/telemetry/ResumeTelemetryContextForRun.ts +53 -0
  96. package/src/application/telemetry/TelemetryRetentionTimestampFactory.ts +9 -8
  97. package/src/applicationTokens.ts +11 -1
  98. package/src/auth/managed/ManagedCorsMiddleware.ts +20 -5
  99. package/src/bootstrap/AppContainerFactory.ts +100 -0
  100. package/src/credentials/CachingCredentialMaterialProvider.ts +96 -0
  101. package/src/credentials/CompositeCredentialMaterialProvider.ts +47 -0
  102. package/src/credentials/ControlPlaneCatalogFetcher.ts +4 -24
  103. package/src/credentials/ControlPlaneCredentialMaterialProvider.ts +79 -0
  104. package/src/credentials/CredentialOAuth2MaterialReader.ts +2 -7
  105. package/src/credentials/InternalCredentialsBindingRegistrar.ts +83 -0
  106. package/src/credentials/LocalCredentialMaterialProvider.ts +92 -0
  107. package/src/domain/credentials/CredentialInstanceService.ts +5 -1
  108. package/src/domain/credentials/CredentialTypeRegistryImpl.ts +18 -4
  109. package/src/domain/workflows/WorkflowActivationPreflightRules.ts +7 -4
  110. package/src/dto.ts +2 -0
  111. package/src/hitl/ControlPlaneInboxChannel.ts +102 -0
  112. package/src/hitl/HitlResumeTokenSigner.ts +80 -0
  113. package/src/hitl/HitlTimeoutJobScheduler.ts +89 -0
  114. package/src/hitl/HitlTimeoutWorker.ts +143 -0
  115. package/src/hitl/InboxChannelResolver.ts +49 -0
  116. package/src/hitl/LocalInboxChannel.ts +37 -0
  117. package/src/infrastructure/persistence/PrismaCredentialStore.ts +10 -0
  118. package/src/infrastructure/persistence/PrismaHmacNonceStore.ts +29 -0
  119. package/src/infrastructure/persistence/PrismaHumanTaskStore.ts +156 -0
  120. package/src/infrastructure/persistence/PrismaMigrationDeployer.ts +53 -383
  121. package/src/infrastructure/persistence/PrismaMigrationOperations.ts +401 -0
  122. package/src/infrastructure/persistence/PrismaWorkflowRunRepository.ts +39 -0
  123. package/src/mcp/AgentMcpIntegrationImpl.ts +5 -1
  124. package/src/pairing/HmacNonceStore.ts +14 -0
  125. package/src/pairing/HmacNonceStoreToken.ts +4 -0
  126. package/src/pairing/HmacRequestSigner.ts +10 -1
  127. package/src/pairing/InMemoryHmacNonceStore.ts +24 -0
  128. package/src/pairing/IncomingHmacVerifier.ts +28 -12
  129. package/src/pairing/InternalHmacAuthMiddleware.ts +1 -1
  130. package/src/pairing/index.ts +3 -0
  131. package/src/presentation/http/ApiPaths.ts +14 -0
  132. package/src/presentation/http/hono/HonoHttpAnonymousRoutePolicyRegistry.ts +4 -0
  133. package/src/presentation/http/hono/registrars/CredentialHonoApiRouteRegistrar.ts +1 -0
  134. package/src/presentation/http/hono/registrars/HitlDecideHonoApiRouteRegistrar.ts +54 -0
  135. package/src/presentation/http/hono/registrars/HitlInternalCallbackHonoApiRouteRegistrar.ts +33 -0
  136. package/src/presentation/http/hono/registrars/HitlResumeHonoApiRouteRegistrar.ts +43 -0
  137. package/src/presentation/http/routeHandlers/CredentialHttpRouteHandler.ts +9 -0
  138. package/src/presentation/http/routeHandlers/OAuth2HttpRouteHandlerFactory.ts +1 -1
  139. package/src/server.ts +7 -2
  140. package/src/workflows/InternalWorkflowTestRunRegistrar.ts +9 -0
  141. package/tsconfig.json +1 -0
  142. package/dist/AppConfigFactory-Cx4qQvRk.js.map +0 -1
  143. package/dist/AppContainerFactory-DRTjG7nG.js.map +0 -1
  144. package/dist/CredentialServices-Dk8yypeL.js.map +0 -1
  145. package/dist/InternalPingRegistrar-DY3kSfxP.js.map +0 -1
  146. package/dist/persistenceServer-B71RGvSj.d.ts +0 -30
  147. package/dist/persistenceServer-C-hH4z6l.js.map +0 -1
  148. package/src/credentials/catalogTypes.ts +0 -4
@@ -0,0 +1,92 @@
1
+ import { inject, injectable } from "@codemation/core";
2
+ import type {
3
+ CallerContext,
4
+ CredentialMaterialProvider,
5
+ CredentialMaterialRef,
6
+ MaterialBundle,
7
+ } from "@codemation/core";
8
+ import { IllegalMaterialSourceError } from "@codemation/core";
9
+
10
+ import { ApplicationTokens } from "../applicationTokens";
11
+ import { CredentialSecretCipher } from "../domain/credentials/CredentialSecretCipher";
12
+ import type { CredentialStore } from "../domain/credentials/CredentialServices";
13
+
14
+ /**
15
+ * Local (OSS / standalone) implementation of `CredentialMaterialProvider`.
16
+ *
17
+ * Reads/writes OAuth material bytes through the existing `PrismaCredentialStore`
18
+ * (i.e. the workspace's `CredentialOAuth2Material` table). The
19
+ * `material:{source,ref}` pointer on the `CredentialInstance` row points back
20
+ * at the row's own instance id for local credentials.
21
+ *
22
+ * This provider is registered in DI; the resolver dispatches by `ref.source`.
23
+ *
24
+ * `callerContext` is accepted but ignored: standalone mode has no CP-side
25
+ * audit log. See `docs/design/credentials-oauth-unification.md`
26
+ * "Material provider seam".
27
+ */
28
+ @injectable()
29
+ export class LocalCredentialMaterialProvider implements CredentialMaterialProvider {
30
+ constructor(
31
+ @inject(ApplicationTokens.CredentialStore) private readonly credentialStore: CredentialStore,
32
+ @inject(CredentialSecretCipher) private readonly credentialSecretCipher: CredentialSecretCipher,
33
+ ) {}
34
+
35
+ async getMaterial(ref: CredentialMaterialRef, _context: CallerContext): Promise<MaterialBundle> {
36
+ this.assertLocalSource(ref);
37
+ const encrypted = await this.credentialStore.getOAuth2Material(ref.id);
38
+ if (!encrypted) {
39
+ throw new Error(`LocalCredentialMaterialProvider: no material for instance "${ref.id}"`);
40
+ }
41
+ const json = this.credentialSecretCipher.decrypt({
42
+ encryptedJson: encrypted.encryptedJson,
43
+ encryptionKeyId: encrypted.encryptionKeyId,
44
+ schemaVersion: encrypted.schemaVersion,
45
+ }) as {
46
+ accessToken?: unknown;
47
+ refreshToken?: unknown;
48
+ expiresAt?: unknown;
49
+ grantedScopes?: unknown;
50
+ };
51
+ return {
52
+ accessToken: typeof json.accessToken === "string" ? json.accessToken : "",
53
+ refreshToken: typeof json.refreshToken === "string" ? json.refreshToken : undefined,
54
+ expiresAt: typeof json.expiresAt === "string" ? json.expiresAt : undefined,
55
+ grantedScopes:
56
+ typeof json.grantedScopes === "string"
57
+ ? json.grantedScopes.split(/\s+/).filter((scope) => scope.length > 0)
58
+ : encrypted.scopes,
59
+ };
60
+ }
61
+
62
+ async setMaterial(ref: CredentialMaterialRef, material: MaterialBundle): Promise<void> {
63
+ this.assertLocalSource(ref);
64
+ const existing = await this.credentialStore.getOAuth2Material(ref.id);
65
+ const encrypted = this.credentialSecretCipher.encrypt({
66
+ accessToken: material.accessToken,
67
+ refreshToken: material.refreshToken ?? null,
68
+ expiresAt: material.expiresAt ?? null,
69
+ grantedScopes: material.grantedScopes.join(" "),
70
+ });
71
+ const now = new Date().toISOString();
72
+ await this.credentialStore.saveOAuth2Material({
73
+ instanceId: ref.id,
74
+ encryptedJson: encrypted.encryptedJson,
75
+ encryptionKeyId: encrypted.encryptionKeyId,
76
+ schemaVersion: encrypted.schemaVersion,
77
+ metadata: {
78
+ providerId: existing?.providerId ?? "",
79
+ connectedEmail: existing?.connectedEmail,
80
+ connectedAt: existing?.connectedAt,
81
+ scopes: [...material.grantedScopes],
82
+ updatedAt: now,
83
+ },
84
+ });
85
+ }
86
+
87
+ private assertLocalSource(ref: CredentialMaterialRef): void {
88
+ if (ref.source !== "local") {
89
+ throw new IllegalMaterialSourceError(ref.source, "LocalCredentialMaterialProvider");
90
+ }
91
+ }
92
+ }
@@ -114,8 +114,9 @@ export class CredentialInstanceService {
114
114
  const timestamp = new Date().toISOString();
115
115
  const strippedPublic = this.stripEnvManagedFieldValues(publicFields, request.publicConfig ?? {});
116
116
  const strippedSecretForRef = this.stripEnvManagedFieldValues(secretFields, request.secretConfig ?? {});
117
+ const instanceId = randomUUID();
117
118
  const instance: CredentialInstanceRecord = {
118
- instanceId: randomUUID(),
119
+ instanceId,
119
120
  typeId: request.typeId,
120
121
  displayName: request.displayName.trim(),
121
122
  sourceKind: request.sourceKind,
@@ -125,6 +126,9 @@ export class CredentialInstanceService {
125
126
  setupStatus: credentialType.definition.auth?.kind === "oauth2" ? "draft" : "ready",
126
127
  createdAt: timestamp,
127
128
  updatedAt: timestamp,
129
+ // New instances are local-mode by default. The CP material provider sets
130
+ // source="control-plane" when managed mode is detected.
131
+ material: { source: "local", ref: instanceId },
128
132
  };
129
133
  await this.credentialStore.saveInstance({
130
134
  instance,
@@ -54,7 +54,11 @@ export class CredentialTypeRegistryImpl implements CredentialTypeRegistry {
54
54
  const nextType: AnyCredentialType =
55
55
  sourcePriority === SOURCE_PRIORITY[existing.source]
56
56
  ? { ...existing.type, definition }
57
- : { definition, createSession: this.createUnsupportedSessionFactory(definition.typeId, source), test: this.createUnsupportedHealthTester(definition.typeId, source) };
57
+ : {
58
+ definition,
59
+ createSession: this.createUnsupportedSessionFactory(definition.typeId, source),
60
+ test: this.createUnsupportedHealthTester(definition.typeId, source),
61
+ };
58
62
  this.recordEntry(definition.typeId, { type: nextType, source });
59
63
  continue;
60
64
  }
@@ -90,7 +94,11 @@ export class CredentialTypeRegistryImpl implements CredentialTypeRegistry {
90
94
  return this.entries.get(typeId)?.type;
91
95
  }
92
96
 
93
- private insert(source: CredentialTypeSource, type: AnyCredentialType, logger: ReturnType<LoggerFactory["create"]>): void {
97
+ private insert(
98
+ source: CredentialTypeSource,
99
+ type: AnyCredentialType,
100
+ logger: ReturnType<LoggerFactory["create"]>,
101
+ ): void {
94
102
  const typeId = type.definition.typeId;
95
103
  const existing = this.entries.get(typeId);
96
104
  const sourcePriority = SOURCE_PRIORITY[source];
@@ -119,7 +127,10 @@ export class CredentialTypeRegistryImpl implements CredentialTypeRegistry {
119
127
  this.bySource.get(entry.source)!.add(typeId);
120
128
  }
121
129
 
122
- private createUnsupportedSessionFactory(typeId: CredentialTypeId, source: CredentialTypeSource): AnyCredentialType["createSession"] {
130
+ private createUnsupportedSessionFactory(
131
+ typeId: CredentialTypeId,
132
+ source: CredentialTypeSource,
133
+ ): AnyCredentialType["createSession"] {
123
134
  return async () => {
124
135
  throw new Error(
125
136
  `Credential type "${typeId}" (source "${source}") was registered with definition only — no createSession implementation is available in this runtime.`,
@@ -127,7 +138,10 @@ export class CredentialTypeRegistryImpl implements CredentialTypeRegistry {
127
138
  };
128
139
  }
129
140
 
130
- private createUnsupportedHealthTester(typeId: CredentialTypeId, source: CredentialTypeSource): AnyCredentialType["test"] {
141
+ private createUnsupportedHealthTester(
142
+ typeId: CredentialTypeId,
143
+ source: CredentialTypeSource,
144
+ ): AnyCredentialType["test"] {
131
145
  return async () => ({
132
146
  status: "unknown" as const,
133
147
  message: `Credential type "${typeId}" (source "${source}") has no local test implementation.`,
@@ -1,5 +1,10 @@
1
1
  import type { WorkflowCredentialHealthDto } from "../../application/contracts/CredentialContractsRegistry";
2
- import { getPersistedRuntimeTypeMetadata, injectable, type CredentialRequirement, type WorkflowDefinition } from "@codemation/core";
2
+ import {
3
+ getPersistedRuntimeTypeMetadata,
4
+ injectable,
5
+ type CredentialRequirement,
6
+ type WorkflowDefinition,
7
+ } from "@codemation/core";
3
8
  import { MissingRuntimeTriggerToken } from "@codemation/core/bootstrap";
4
9
  import { ManualTriggerNode } from "@codemation/core-nodes";
5
10
 
@@ -105,9 +110,7 @@ export class WorkflowActivationPreflightRules {
105
110
  const grantedSet = new Set(granted);
106
111
  const missing = required.filter((s) => !grantedSet.has(s));
107
112
  if (missing.length > 0) {
108
- lines.push(
109
- `Credential "${displayName}" missing scopes: ${missing.join(", ")}. Reconnect to grant.`,
110
- );
113
+ lines.push(`Credential "${displayName}" missing scopes: ${missing.join(", ")}. Reconnect to grant.`);
111
114
  }
112
115
  }
113
116
 
package/src/dto.ts CHANGED
@@ -12,6 +12,8 @@ export type {
12
12
  CreateCredentialInstanceRequest,
13
13
  UpdateCredentialInstanceRequest,
14
14
  UpsertCredentialBindingRequest,
15
+ AppGalleryEntry,
16
+ AppsResponse,
15
17
  } from "./application/contracts/CredentialContractsRegistry";
16
18
 
17
19
  export type {
@@ -0,0 +1,102 @@
1
+ import { inject, injectable } from "@codemation/core";
2
+ import type {
3
+ InboxChannel,
4
+ InboxDeliverArgs,
5
+ InboxDelivery,
6
+ InboxOnDecisionArgs,
7
+ InboxOnTimeoutArgs,
8
+ } from "@codemation/core";
9
+ import type { Logger } from "../application/logging/Logger";
10
+ import { PairedFetch } from "../pairing/PairedFetch";
11
+ import type { PairingConfig } from "../pairing/pairing.types";
12
+ import { PairingConfigToken } from "../pairing/PairingConfigToken";
13
+ import { ServerLoggerFactory } from "../infrastructure/logging/ServerLoggerFactory";
14
+
15
+ /**
16
+ * Inbox channel that pushes pending HITL tasks to the control plane via HMAC-signed HTTP.
17
+ *
18
+ * Registered only when `PairingConfig` is present (managed mode).
19
+ * The control plane stores the task in its own DB and renders the reviewer inbox.
20
+ * Decisions flow back to the framework via `POST /internal/hitl/tasks/:taskId/callback`.
21
+ */
22
+ @injectable()
23
+ export class ControlPlaneInboxChannel implements InboxChannel {
24
+ readonly kind = "control-plane-inbox" as const;
25
+
26
+ private readonly logger: Logger;
27
+
28
+ constructor(
29
+ @inject(PairedFetch) private readonly pairedFetch: PairedFetch,
30
+ @inject(PairingConfigToken) private readonly pairingConfig: PairingConfig,
31
+ @inject(ServerLoggerFactory) loggerFactory: ServerLoggerFactory,
32
+ ) {
33
+ this.logger = loggerFactory.create("codemation.hitl.cp-inbox");
34
+ }
35
+
36
+ async deliver(args: InboxDeliverArgs): Promise<InboxDelivery> {
37
+ const { task, subject, priority, item } = args;
38
+
39
+ const body = {
40
+ taskId: task.taskId,
41
+ workspaceId: this.pairingConfig.workspaceId,
42
+ runId: task.runId,
43
+ nodeId: task.nodeId,
44
+ subject,
45
+ priority,
46
+ expiresAt: task.expiresAt.toISOString(),
47
+ resumeUrl: task.resumeUrl,
48
+ item: { json: item.json, hasBinary: item.binary != null },
49
+ agentReasoning: task.metadata?.agentReasoning as string | undefined,
50
+ };
51
+
52
+ const url = `${this.pairingConfig.controlPlaneUrl}/internal/hitl/tasks`;
53
+ const res = await this.pairedFetch.post(url, body);
54
+
55
+ if (!res.ok) {
56
+ const text = await res.text().catch(() => "<unreadable>");
57
+ throw new Error(`ControlPlaneInboxChannel: CP push failed with status ${res.status}: ${text}`);
58
+ }
59
+
60
+ const json = (await res.json()) as { inboxItemId: string };
61
+ this.logger.info(`HITL task delivered to CP inbox — taskId=${task.taskId} inboxItemId=${json.inboxItemId}`);
62
+
63
+ return { kind: "cp", inboxItemId: json.inboxItemId, workspaceId: this.pairingConfig.workspaceId };
64
+ }
65
+
66
+ async updateOnDecision(args: InboxOnDecisionArgs): Promise<void> {
67
+ if (args.delivery.kind !== "cp") return;
68
+
69
+ const url = `${this.pairingConfig.controlPlaneUrl}/internal/hitl/tasks/${args.delivery.inboxItemId}/resolved`;
70
+ const body = {
71
+ decision: args.decision,
72
+ actor: args.actor,
73
+ resolvedAt: new Date().toISOString(),
74
+ };
75
+
76
+ const res = await this.pairedFetch.post(url, body);
77
+ if (!res.ok) {
78
+ const text = await res.text().catch(() => "<unreadable>");
79
+ this.logger.warn(
80
+ `Failed to notify CP of task decision — inboxItemId=${args.delivery.inboxItemId} status=${res.status} body=${text}`,
81
+ );
82
+ }
83
+ }
84
+
85
+ async updateOnTimeout(args: InboxOnTimeoutArgs): Promise<void> {
86
+ if (args.delivery.kind !== "cp") return;
87
+
88
+ const url = `${this.pairingConfig.controlPlaneUrl}/internal/hitl/tasks/${args.delivery.inboxItemId}/resolved`;
89
+ const body = {
90
+ decision: { kind: "timeout", policy: args.policy },
91
+ resolvedAt: new Date().toISOString(),
92
+ };
93
+
94
+ const res = await this.pairedFetch.post(url, body);
95
+ if (!res.ok) {
96
+ const text = await res.text().catch(() => "<unreadable>");
97
+ this.logger.warn(
98
+ `Failed to notify CP of task timeout — inboxItemId=${args.delivery.inboxItemId} status=${res.status} body=${text}`,
99
+ );
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,80 @@
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ import { inject, injectable } from "@codemation/core";
3
+ import { ApplicationTokens } from "../applicationTokens";
4
+ import type { AppConfig } from "../presentation/config/AppConfig";
5
+
6
+ /**
7
+ * Signs and verifies single-use HITL resume tokens.
8
+ *
9
+ * Token format: `<taskId>.<expiresAtUnix>.<schemaHash8>.<sigBase64Url>`
10
+ * Signature: HMAC-SHA256(AUTH_SECRET, `${taskId}.${expiresAtUnix}.${schemaHash8}`)
11
+ *
12
+ * Tokens are single-use (the HumanTask status transition from pending → decided/timed_out
13
+ * is the consumption mechanism — reuse returns 409).
14
+ */
15
+ @injectable()
16
+ export class HitlResumeTokenSigner {
17
+ // Cached secret bytes if AUTH_SECRET is present. Null otherwise — call sites that
18
+ // actually need to sign/verify will throw via requireSecret(). Deferring the throw
19
+ // to method invocation (instead of constructor) keeps test setups and bootstrap
20
+ // graphs that resolve the wider engine chain from failing when AUTH_SECRET is not
21
+ // set; production code paths that exercise HITL still fail loudly at use time.
22
+ private readonly secret: Buffer | null;
23
+
24
+ constructor(@inject(ApplicationTokens.AppConfig) appConfig: AppConfig) {
25
+ const raw = appConfig.env.AUTH_SECRET?.trim();
26
+ this.secret = raw ? Buffer.from(raw, "utf8") : null;
27
+ }
28
+
29
+ private requireSecret(): Buffer {
30
+ if (!this.secret) {
31
+ throw new Error("HitlResumeTokenSigner: AUTH_SECRET is required.");
32
+ }
33
+ return this.secret;
34
+ }
35
+
36
+ sign(args: { taskId: string; expiresAt: Date; schemaHash: string }): string {
37
+ const expiresAtUnix = String(Math.floor(args.expiresAt.getTime() / 1000));
38
+ const schemaHash8 = args.schemaHash.slice(0, 8);
39
+ const payload = `${args.taskId}.${expiresAtUnix}.${schemaHash8}`;
40
+ const sig = createHmac("sha256", this.requireSecret()).update(payload).digest("base64url");
41
+ return `${payload}.${sig}`;
42
+ }
43
+
44
+ verify(
45
+ token: string,
46
+ ):
47
+ | { ok: true; taskId: string; schemaHash: string; expiresAt: Date }
48
+ | { ok: false; reason: "malformed" | "bad_sig" | "expired" } {
49
+ const parts = token.split(".");
50
+ if (parts.length !== 4) {
51
+ return { ok: false, reason: "malformed" };
52
+ }
53
+ const [taskId, expiresAtUnixStr, schemaHash8, receivedSig] = parts as [string, string, string, string];
54
+
55
+ const expiresAtUnix = Number(expiresAtUnixStr);
56
+ if (!Number.isFinite(expiresAtUnix)) {
57
+ return { ok: false, reason: "malformed" };
58
+ }
59
+
60
+ const payload = `${taskId}.${expiresAtUnixStr}.${schemaHash8}`;
61
+ const expectedSig = createHmac("sha256", this.requireSecret()).update(payload).digest("base64url");
62
+ const expectedBuf = Buffer.from(expectedSig, "utf8");
63
+ const receivedBuf = Buffer.from(receivedSig, "utf8");
64
+ if (expectedBuf.length !== receivedBuf.length || !timingSafeEqual(expectedBuf, receivedBuf)) {
65
+ return { ok: false, reason: "bad_sig" };
66
+ }
67
+
68
+ const expiresAt = new Date(expiresAtUnix * 1000);
69
+ if (expiresAt <= new Date()) {
70
+ return { ok: false, reason: "expired" };
71
+ }
72
+
73
+ return { ok: true, taskId, schemaHash: schemaHash8, expiresAt };
74
+ }
75
+
76
+ /** SHA-256 hash of the full token string (used for revocation lookup). */
77
+ hashToken(token: string): string {
78
+ return createHash("sha256").update(token, "utf8").digest("hex");
79
+ }
80
+ }
@@ -0,0 +1,89 @@
1
+ import { Queue } from "bullmq";
2
+ import { inject, injectable } from "@codemation/core";
3
+ import { ApplicationTokens } from "../applicationTokens";
4
+ import type { AppConfig } from "../presentation/config/AppConfig";
5
+ import { RedisConnectionOptionsFactory } from "../infrastructure/scheduler/bullmq/RedisConnectionOptionsFactory";
6
+
7
+ export const HITL_TIMEOUT_QUEUE_NAME_SUFFIX = "hitl.timeout";
8
+
9
+ export interface HitlTimeoutJobPayload {
10
+ readonly kind: "hitl.timeout";
11
+ readonly taskId: string;
12
+ }
13
+
14
+ /**
15
+ * Schedules delayed BullMQ jobs that drive the timeout path for suspended HITL tasks.
16
+ * The processor (`HitlTimeoutJobProcessor`) handles these jobs.
17
+ *
18
+ * Job id: `hitl_timeout__<taskId>` — stable id allows reliable removal via `remove()`.
19
+ */
20
+ @injectable()
21
+ export class HitlTimeoutJobScheduler {
22
+ private queue: Queue | null = null;
23
+ private readonly queueName: string;
24
+ /** Redis URL when running in BullMQ mode; null in local/inline mode (no Redis). */
25
+ private readonly redisUrl: string | null;
26
+
27
+ constructor(@inject(ApplicationTokens.AppConfig) appConfig: AppConfig) {
28
+ // Gate BullMQ on the same scheduler abstraction the rest of the host uses
29
+ // (AppConfigFactory resolves kind === "bullmq" only when a Redis URL is
30
+ // present; otherwise "local"). In local/inline mode this scheduler does NOT
31
+ // touch Redis at all — enqueue/cancel become inert no-ops — so workspace
32
+ // pods without a Redis don't crash-loop on ECONNREFUSED 127.0.0.1:6379.
33
+ this.redisUrl = appConfig.scheduler.kind === "bullmq" ? (appConfig.scheduler.redisUrl ?? null) : null;
34
+ const queuePrefix = appConfig.env.CODEMATION_BULLMQ_PREFIX ?? "codemation";
35
+ this.queueName = `${queuePrefix}.${HITL_TIMEOUT_QUEUE_NAME_SUFFIX}`;
36
+ }
37
+
38
+ async enqueueTimeoutJob(args: { taskId: string; expiresAt: Date }): Promise<void> {
39
+ const queue = this.getOrCreateQueue();
40
+ if (!queue) return; // local/inline mode: no background timeout job.
41
+ const delay = Math.max(0, args.expiresAt.getTime() - Date.now());
42
+ await queue.add("hitl.timeout", { kind: "hitl.timeout", taskId: args.taskId } satisfies HitlTimeoutJobPayload, {
43
+ jobId: this.makeJobId(args.taskId),
44
+ delay,
45
+ removeOnComplete: true,
46
+ removeOnFail: true,
47
+ });
48
+ }
49
+
50
+ async cancelTimeoutJob(taskId: string): Promise<void> {
51
+ const queue = this.getOrCreateQueue();
52
+ if (!queue) return; // local/inline mode: nothing was ever enqueued.
53
+ const job = await queue.getJob(this.makeJobId(taskId));
54
+ await job?.remove();
55
+ }
56
+
57
+ async close(): Promise<void> {
58
+ if (this.queue) {
59
+ await this.queue.close();
60
+ this.queue = null;
61
+ }
62
+ }
63
+
64
+ getQueueName(): string {
65
+ return this.queueName;
66
+ }
67
+
68
+ /**
69
+ * Returns the BullMQ queue in Redis-backed mode, or null in local/inline mode.
70
+ * Construction is deferred to the first enqueue/cancel so DI consumers that
71
+ * never enqueue can resolve this scheduler without building a connection.
72
+ */
73
+ private getOrCreateQueue(): Queue | null {
74
+ if (this.redisUrl === null) {
75
+ return null;
76
+ }
77
+ if (!this.queue) {
78
+ const connectionOptions = RedisConnectionOptionsFactory.fromConfig({ url: this.redisUrl });
79
+ this.queue = new Queue(this.queueName, {
80
+ connection: connectionOptions as never,
81
+ });
82
+ }
83
+ return this.queue;
84
+ }
85
+
86
+ private makeJobId(taskId: string): string {
87
+ return `hitl_timeout__${taskId}`;
88
+ }
89
+ }
@@ -0,0 +1,143 @@
1
+ import type { Job } from "bullmq";
2
+ import { Worker } from "bullmq";
3
+ import { inject, injectable } from "@codemation/core";
4
+ import type { HumanTaskStore } from "@codemation/core";
5
+ import { CodemationTelemetryAttributeNames, HumanTaskStoreToken } from "@codemation/core";
6
+ import { Engine } from "@codemation/core/bootstrap";
7
+ import { ApplicationTokens } from "../applicationTokens";
8
+ import type { AppConfig } from "../presentation/config/AppConfig";
9
+ import { RedisConnectionOptionsFactory } from "../infrastructure/scheduler/bullmq/RedisConnectionOptionsFactory";
10
+ import type { HitlTimeoutJobPayload } from "./HitlTimeoutJobScheduler";
11
+ import { HitlTimeoutJobScheduler } from "./HitlTimeoutJobScheduler";
12
+ import { ResumeTelemetryContextForRun } from "../application/telemetry/ResumeTelemetryContextForRun";
13
+
14
+ /**
15
+ * BullMQ worker that processes `hitl.timeout` jobs.
16
+ *
17
+ * - If `task.onTimeout === "auto-accept"`: marks the task `auto_accepted` and resumes the run
18
+ * with `decision: { kind: "auto_accepted" }`.
19
+ * - If `task.onTimeout === "halt"`: marks the task `timed_out` and resumes the run
20
+ * with `decision: { kind: "timed_out" }`.
21
+ *
22
+ * The engine's resume handler distinguishes halt vs. continue based on the decision kind
23
+ * (the first-class HITL states handle the halt-the-run path).
24
+ *
25
+ * `processTimeoutForTask` is public to allow direct testing without BullMQ.
26
+ */
27
+ @injectable()
28
+ export class HitlTimeoutWorker {
29
+ private readonly taskStore: HumanTaskStore;
30
+ private worker: Worker | null = null;
31
+ /** Redis connection options in BullMQ mode; null in local/inline mode (no Redis). */
32
+ private readonly connectionOptions: Readonly<Record<string, unknown>> | null;
33
+
34
+ constructor(
35
+ @inject(HumanTaskStoreToken) taskStore: HumanTaskStore | undefined,
36
+ @inject(Engine) private readonly engine: Engine,
37
+ @inject(HitlTimeoutJobScheduler) private readonly scheduler: HitlTimeoutJobScheduler,
38
+ @inject(ApplicationTokens.AppConfig) appConfig: AppConfig,
39
+ @inject(ResumeTelemetryContextForRun) private readonly resumeTelemetry: ResumeTelemetryContextForRun,
40
+ ) {
41
+ if (!taskStore) {
42
+ throw new Error("HitlTimeoutWorker: HumanTaskStore is not registered.");
43
+ }
44
+ this.taskStore = taskStore;
45
+ // Same gate as HitlTimeoutJobScheduler: only build a Redis connection when
46
+ // running in BullMQ mode. In local/inline mode start() is a no-op — there is
47
+ // no Redis to consume timeout jobs from.
48
+ const redisUrl = appConfig.scheduler.kind === "bullmq" ? (appConfig.scheduler.redisUrl ?? null) : null;
49
+ this.connectionOptions = redisUrl === null ? null : RedisConnectionOptionsFactory.fromConfig({ url: redisUrl });
50
+ }
51
+
52
+ start(): void {
53
+ if (!this.connectionOptions) return; // local/inline mode: no background worker.
54
+ this.worker = new Worker(
55
+ this.scheduler.getQueueName(),
56
+ async (job: Job) => {
57
+ await this.processJob(job);
58
+ },
59
+ { connection: this.connectionOptions as never },
60
+ );
61
+ }
62
+
63
+ async stop(): Promise<void> {
64
+ if (this.worker) {
65
+ await this.worker.close();
66
+ this.worker = null;
67
+ }
68
+ }
69
+
70
+ async processTimeoutForTask(taskId: string): Promise<void> {
71
+ const task = await this.taskStore.findById(taskId);
72
+ if (!task) return;
73
+ if (task.status !== "pending") return;
74
+
75
+ const now = new Date();
76
+
77
+ if (task.onTimeout === "auto-accept") {
78
+ await this.taskStore.markAutoAccepted(taskId);
79
+
80
+ // Emit hitl.task.timed_out on the run's trace.
81
+ const telemetry = await this.resumeTelemetry.forTask(taskId);
82
+ await telemetry?.addSpanEvent({
83
+ name: "hitl.task.timed_out",
84
+ attributes: {
85
+ [CodemationTelemetryAttributeNames.hitlTaskId]: taskId,
86
+ policy: "auto-accept",
87
+ },
88
+ });
89
+
90
+ await this.engine.resumeRun({
91
+ runId: task.runId,
92
+ taskId: task.id,
93
+ resumeContext: {
94
+ decision: { kind: "auto_accepted", at: now },
95
+ delivery: task.deliveryRef ?? null,
96
+ task: {
97
+ taskId: task.id,
98
+ runId: task.runId,
99
+ nodeId: task.nodeId,
100
+ expiresAt: task.expiresAt,
101
+ resumeUrl: "",
102
+ },
103
+ },
104
+ });
105
+ } else {
106
+ await this.taskStore.markTimedOut(taskId);
107
+
108
+ // Emit hitl.task.timed_out on the run's trace.
109
+ const telemetry = await this.resumeTelemetry.forTask(taskId);
110
+ await telemetry?.addSpanEvent({
111
+ name: "hitl.task.timed_out",
112
+ attributes: {
113
+ [CodemationTelemetryAttributeNames.hitlTaskId]: taskId,
114
+ policy: "halt",
115
+ },
116
+ });
117
+
118
+ await this.engine.resumeRun({
119
+ runId: task.runId,
120
+ taskId: task.id,
121
+ resumeContext: {
122
+ decision: { kind: "timed_out", at: now },
123
+ delivery: task.deliveryRef ?? null,
124
+ task: {
125
+ taskId: task.id,
126
+ runId: task.runId,
127
+ nodeId: task.nodeId,
128
+ expiresAt: task.expiresAt,
129
+ resumeUrl: "",
130
+ },
131
+ },
132
+ });
133
+ }
134
+ }
135
+
136
+ private async processJob(job: Job): Promise<void> {
137
+ const data = job.data as HitlTimeoutJobPayload;
138
+ if (!data || data.kind !== "hitl.timeout") {
139
+ throw new Error(`Unexpected job payload for hitl.timeout queue: ${JSON.stringify(data)}`);
140
+ }
141
+ await this.processTimeoutForTask(data.taskId);
142
+ }
143
+ }
@@ -0,0 +1,49 @@
1
+ import { inject, injectable } from "@codemation/core";
2
+ import type { InboxChannel, InboxChannelResolverSeam } from "@codemation/core";
3
+ import { ControlPlaneInboxChannelToken, LocalInboxChannelToken } from "@codemation/core";
4
+ import type { Logger } from "../application/logging/Logger";
5
+ import type { PairingConfig } from "../pairing/pairing.types";
6
+ import { PairingConfigToken } from "../pairing/PairingConfigToken";
7
+ import { ServerLoggerFactory } from "../infrastructure/logging/ServerLoggerFactory";
8
+
9
+ /**
10
+ * Resolves the correct `InboxChannel` for the current deployment mode.
11
+ *
12
+ * - Managed mode (PairingConfig present + CP channel registered): returns CP channel.
13
+ * - Otherwise: returns local channel.
14
+ * - If managed mode is detected but CP channel is not registered, falls back to local
15
+ * and emits a warning (misconfiguration).
16
+ */
17
+ @injectable()
18
+ export class InboxChannelResolver implements InboxChannelResolverSeam {
19
+ private readonly logger: Logger;
20
+
21
+ constructor(
22
+ @inject(PairingConfigToken, { isOptional: true }) private readonly pairingConfig: PairingConfig | null,
23
+ @inject(LocalInboxChannelToken, { isOptional: true }) private readonly local: InboxChannel | null,
24
+ @inject(ControlPlaneInboxChannelToken, { isOptional: true }) private readonly cp: InboxChannel | null,
25
+ @inject(ServerLoggerFactory) loggerFactory: ServerLoggerFactory,
26
+ ) {
27
+ this.logger = loggerFactory.create("codemation.hitl.inbox");
28
+
29
+ if (pairingConfig && !cp) {
30
+ this.logger.warn(
31
+ "InboxChannelResolver: managed mode is active but no ControlPlaneInboxChannel is registered. " +
32
+ "Falling back to local inbox channel. Register a ControlPlaneInboxChannel to resolve this.",
33
+ );
34
+ }
35
+ }
36
+
37
+ resolve(): { channel: InboxChannel; workspaceId?: string } {
38
+ if (this.pairingConfig && this.cp) {
39
+ return { channel: this.cp, workspaceId: this.pairingConfig.workspaceId };
40
+ }
41
+ if (!this.local) {
42
+ throw new Error(
43
+ "InboxChannelResolver: no inbox channel is registered. " +
44
+ "Register a LocalInboxChannel or ControlPlaneInboxChannel.",
45
+ );
46
+ }
47
+ return { channel: this.local };
48
+ }
49
+ }