@codemation/host 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -0
- package/LICENSE +37 -1
- package/dist/{ApiPaths-Dv1dcHu_.js → ApiPaths-DCvrlIjg.js} +12 -1
- package/dist/{ApiPaths-Dv1dcHu_.js.map → ApiPaths-DCvrlIjg.js.map} +1 -1
- package/dist/{AppConfigFactory-Cx4qQvRk.js → AppConfigFactory-D4LL1aOR.js} +77 -297
- package/dist/AppConfigFactory-D4LL1aOR.js.map +1 -0
- package/dist/{AppConfigFactory-DnLoQ9Li.d.ts → AppConfigFactory-DncmwCD1.d.ts} +2918 -199
- package/dist/{AppContainerFactory-DqKYCRNP.js → AppContainerFactory-jpYXGZGe.js} +1733 -475
- package/dist/AppContainerFactory-jpYXGZGe.js.map +1 -0
- package/dist/{CodemationAppContext-CKVv9W9q.d.ts → CodemationAppContext-K51b7oXe.d.ts} +9 -3
- package/dist/{CodemationAuthoring.types-DA3G3s6d.d.ts → CodemationAuthoring.types-BXlXIl4K.d.ts} +9 -4
- package/dist/{CodemationAuthoring.types-NGkBcmmT.js → CodemationAuthoring.types-BteaR3Dc.js} +3 -2
- package/dist/CodemationAuthoring.types-BteaR3Dc.js.map +1 -0
- package/dist/{CodemationConfigNormalizer-BAKjetJ6.d.ts → CodemationConfigNormalizer-B4rDYC9h.d.ts} +3 -3
- package/dist/{CodemationConsumerConfigLoader-GYpBBvqE.js → CodemationConsumerConfigLoader-By-6tuGc.js} +3 -1
- package/dist/CodemationConsumerConfigLoader-By-6tuGc.js.map +1 -0
- package/dist/{CodemationConsumerConfigLoader-nxOqvv46.d.ts → CodemationConsumerConfigLoader-Dt4jyLx6.d.ts} +3 -2
- package/dist/{CodemationPluginListMerger-DKLAHT2b.d.ts → CodemationPluginListMerger-DS6I3Xe0.d.ts} +64 -27
- package/dist/{persistenceServer-C-hH4z6l.js → CodemationPostgresPrismaClientFactory-C7156Fe-.js} +2 -2
- package/dist/CodemationPostgresPrismaClientFactory-C7156Fe-.js.map +1 -0
- package/dist/CodemationPostgresPrismaClientFactory-CTNTPnDr.d.ts +9 -0
- package/dist/{CredentialContractsRegistry-Bq2bq28t.d.ts → CredentialContractsRegistry-Dgu-rEXi.d.ts} +16 -3
- package/dist/{CredentialServices-Be2I60Th.d.ts → CredentialServices-B3wPyp2y.d.ts} +4 -4
- package/dist/{CredentialServices-Dk8yypeL.js → CredentialServices-Bios0dM8.js} +10 -4
- package/dist/CredentialServices-Bios0dM8.js.map +1 -0
- package/dist/{InternalPingRegistrar-DY3kSfxP.js → InternalPingRegistrar-BavAAnvk.js} +19 -16
- package/dist/InternalPingRegistrar-BavAAnvk.js.map +1 -0
- package/dist/{ItemsInputNormalizer-_RwIfRIQ.d.ts → ItemsInputNormalizer-CFkfNMLt.d.ts} +1434 -1225
- package/dist/PrismaMigrationDeployer-DdEcXXVi.d.ts +14 -0
- package/dist/{PublicFrontendBootstrapFactory-CY2FS-5g.d.ts → PublicFrontendBootstrapFactory-ClEjZP74.d.ts} +2 -2
- package/dist/{PublicFrontendBootstrapJsonCodec-CXG9Dxft.d.ts → PublicFrontendBootstrapJsonCodec-HNItQ7ol.d.ts} +6 -1
- package/dist/{TelemetryContracts-BtDx84Cp.d.ts → TelemetryContracts-DpZEODQM.d.ts} +2 -2
- package/dist/{WorkflowPolicyUiPresentationFactory-6MyjCvBO.d.ts → WorkflowPolicyUiPresentationFactory-BNn2fvR_.d.ts} +2 -2
- package/dist/{WorkflowPolicyUiPresentationFactory-Bb-ae_Zh.js → WorkflowPolicyUiPresentationFactory-DfvD2VHk.js} +1 -1
- package/dist/{WorkflowPolicyUiPresentationFactory-Bb-ae_Zh.js.map → WorkflowPolicyUiPresentationFactory-DfvD2VHk.js.map} +1 -1
- package/dist/authoring.d.ts +4 -4
- package/dist/authoring.js +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/consumer.d.ts +5 -5
- package/dist/consumer.js +1 -1
- package/dist/credentials.d.ts +5 -5
- package/dist/credentials.js +1 -1
- package/dist/devServerSidecar.d.ts +2 -2
- package/dist/dto.d.ts +5 -5
- package/dist/{index-DilAYwnH.d.ts → index-ChIfeWzk.d.ts} +71 -28
- package/dist/index.d.ts +49 -17
- package/dist/index.js +106 -13
- package/dist/index.js.map +1 -0
- package/dist/infrastructure/persistence/PrismaMigrationOperations.d.ts +44 -0
- package/dist/infrastructure/persistence/PrismaMigrationOperations.js +302 -0
- package/dist/infrastructure/persistence/PrismaMigrationOperations.js.map +1 -0
- package/dist/mapping.d.ts +2 -2
- package/dist/mapping.js +1 -1
- package/dist/nextServer.d.ts +15 -39
- package/dist/nextServer.js +6 -6
- package/dist/pairing.d.ts +27 -8
- package/dist/pairing.js +19 -3
- package/dist/pairing.js.map +1 -0
- package/dist/{pairing.types-snfZ_OzB.d.ts → pairing.types-D9Bjn98U.d.ts} +1 -1
- package/dist/persistenceServer.d.ts +31 -7
- package/dist/persistenceServer.js +2 -2
- package/dist/{server-C4bS62rg.d.ts → server-B5trn7y4.d.ts} +5 -5
- package/dist/{server-Y7kxwtCK.js → server-BlG9qV5S.js} +5 -5
- package/dist/{server-Y7kxwtCK.js.map → server-BlG9qV5S.js.map} +1 -1
- package/dist/server.d.ts +10 -10
- package/dist/server.js +9 -9
- package/package.json +28 -25
- package/playwright.config.ts +8 -2
- package/playwright.scaffolded-dev.config.ts +8 -2
- package/prisma/migrations/20260526120000_credential_material_pointer/migration.sql +18 -0
- package/prisma/migrations/20260527120000_add_human_task/migration.sql +32 -0
- package/prisma/migrations/20260527130000_add_hitl_state_json/migration.sql +6 -0
- package/prisma/migrations/20260527130000_add_hmac_nonce/migration.sql +12 -0
- package/prisma/migrations.sqlite/20260526120000_credential_material_pointer/migration.sql +13 -0
- package/prisma/migrations.sqlite/20260527120000_add_human_task/migration.sql +30 -0
- package/prisma/migrations.sqlite/20260527130000_add_hitl_state_json/migration.sql +6 -0
- package/prisma/migrations.sqlite/20260527130000_add_hmac_nonce/migration.sql +9 -0
- package/prisma/schema.postgresql.prisma +48 -0
- package/prisma/schema.sqlite.prisma +48 -0
- package/prisma-generated/prisma-postgresql-client/edge.js +40 -6
- package/prisma-generated/prisma-postgresql-client/index-browser.js +36 -2
- package/prisma-generated/prisma-postgresql-client/index.d.ts +3179 -163
- package/prisma-generated/prisma-postgresql-client/index.js +40 -6
- package/prisma-generated/prisma-postgresql-client/package.json +1 -1
- package/prisma-generated/prisma-postgresql-client/schema.prisma +48 -0
- package/prisma-generated/prisma-sqlite-client/edge.js +40 -6
- package/prisma-generated/prisma-sqlite-client/index-browser.js +36 -2
- package/prisma-generated/prisma-sqlite-client/index.d.ts +3175 -163
- package/prisma-generated/prisma-sqlite-client/index.js +40 -6
- package/prisma-generated/prisma-sqlite-client/package.json +1 -1
- package/prisma-generated/prisma-sqlite-client/schema.prisma +48 -0
- package/src/application/contracts/CredentialContractsRegistry.ts +15 -0
- package/src/application/credentials/AppGalleryProjector.ts +69 -0
- package/src/application/hitl/DecideHumanTaskCommandHandler.ts +149 -0
- package/src/application/hitl/DecisionSchemaValidator.ts +22 -0
- package/src/application/hitl/HitlCallbackHandler.ts +96 -0
- package/src/application/mapping/WorkflowDefinitionMapper.ts +1 -3
- package/src/application/queries/CredentialQueryHandlers.ts +2 -0
- package/src/application/queries/GetCredentialAppsQuery.ts +4 -0
- package/src/application/queries/GetCredentialAppsQueryHandler.ts +27 -0
- package/src/application/telemetry/ResumeTelemetryContextForRun.ts +53 -0
- package/src/application/telemetry/TelemetryRetentionTimestampFactory.ts +9 -8
- package/src/applicationTokens.ts +11 -1
- package/src/auth/managed/ManagedCorsMiddleware.ts +20 -5
- package/src/bootstrap/AppContainerFactory.ts +121 -3
- package/src/bootstrap/runtime/HeadlessApiRuntime.ts +47 -0
- package/src/credentials/CachingCredentialMaterialProvider.ts +96 -0
- package/src/credentials/CompositeCredentialMaterialProvider.ts +47 -0
- package/src/credentials/ControlPlaneCatalogFetcher.ts +8 -28
- package/src/credentials/ControlPlaneCredentialMaterialProvider.ts +79 -0
- package/src/credentials/CredentialOAuth2MaterialReader.ts +2 -7
- package/src/credentials/InternalCredentialsBindingRegistrar.ts +83 -0
- package/src/credentials/LocalCredentialMaterialProvider.ts +92 -0
- package/src/domain/credentials/CredentialInstanceService.ts +5 -1
- package/src/domain/credentials/CredentialTypeRegistryImpl.ts +18 -4
- package/src/domain/workflows/WorkflowActivationPreflightRules.ts +7 -4
- package/src/dto.ts +2 -0
- package/src/hitl/ControlPlaneInboxChannel.ts +102 -0
- package/src/hitl/HitlResumeTokenSigner.ts +80 -0
- package/src/hitl/HitlTimeoutJobScheduler.ts +77 -0
- package/src/hitl/HitlTimeoutWorker.ts +138 -0
- package/src/hitl/InboxChannelResolver.ts +49 -0
- package/src/hitl/LocalInboxChannel.ts +37 -0
- package/src/index.ts +3 -0
- package/src/infrastructure/persistence/PrismaCredentialStore.ts +10 -0
- package/src/infrastructure/persistence/PrismaHmacNonceStore.ts +29 -0
- package/src/infrastructure/persistence/PrismaHumanTaskStore.ts +156 -0
- package/src/infrastructure/persistence/PrismaMigrationDeployer.ts +53 -383
- package/src/infrastructure/persistence/PrismaMigrationOperations.ts +401 -0
- package/src/infrastructure/persistence/PrismaWorkflowRunRepository.ts +39 -0
- package/src/mcp/AgentMcpIntegrationImpl.ts +5 -1
- package/src/pairing/HmacNonceStore.ts +14 -0
- package/src/pairing/HmacNonceStoreToken.ts +4 -0
- package/src/pairing/HmacRequestSigner.ts +10 -1
- package/src/pairing/InMemoryHmacNonceStore.ts +24 -0
- package/src/pairing/IncomingHmacVerifier.ts +28 -12
- package/src/pairing/InternalHmacAuthMiddleware.ts +1 -1
- package/src/pairing/index.ts +3 -0
- package/src/presentation/config/CodemationAuthoring.types.ts +7 -1
- package/src/presentation/config/CodemationConfig.ts +6 -0
- package/src/presentation/http/ApiPaths.ts +14 -0
- package/src/presentation/http/HeadlessHttpServerFactory.ts +56 -0
- package/src/presentation/http/hono/HonoHttpAnonymousRoutePolicyRegistry.ts +4 -0
- package/src/presentation/http/hono/registrars/CredentialHonoApiRouteRegistrar.ts +1 -0
- package/src/presentation/http/hono/registrars/HitlDecideHonoApiRouteRegistrar.ts +54 -0
- package/src/presentation/http/hono/registrars/HitlInternalCallbackHonoApiRouteRegistrar.ts +33 -0
- package/src/presentation/http/hono/registrars/HitlResumeHonoApiRouteRegistrar.ts +43 -0
- package/src/presentation/http/routeHandlers/CredentialHttpRouteHandler.ts +9 -0
- package/src/presentation/http/routeHandlers/OAuth2HttpRouteHandlerFactory.ts +1 -1
- package/src/presentation/server/CodemationConsumerConfigLoader.ts +7 -2
- package/src/presentation/websocket/WorkflowWebsocketServerFactory.ts +16 -0
- package/src/server.ts +7 -2
- package/src/workflows/InternalWorkflowTestRunRegistrar.ts +9 -0
- package/tsconfig.json +1 -0
- package/dist/AppConfigFactory-Cx4qQvRk.js.map +0 -1
- package/dist/AppContainerFactory-DqKYCRNP.js.map +0 -1
- package/dist/CodemationAuthoring.types-NGkBcmmT.js.map +0 -1
- package/dist/CodemationConsumerConfigLoader-GYpBBvqE.js.map +0 -1
- package/dist/CredentialServices-Dk8yypeL.js.map +0 -1
- package/dist/InternalPingRegistrar-DY3kSfxP.js.map +0 -1
- package/dist/persistenceServer-C-hH4z6l.js.map +0 -1
- package/dist/persistenceServer-CeTHtC6E.d.ts +0 -30
- package/src/credentials/catalogTypes.ts +0 -4
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { inject, injectable } from "@codemation/core";
|
|
2
|
+
import type { Hono } from "hono";
|
|
3
|
+
import type { Logger, LoggerFactory } from "../application/logging/Logger";
|
|
4
|
+
import { ApplicationRequestError } from "../application/ApplicationRequestError";
|
|
5
|
+
import { ApplicationTokens } from "../applicationTokens";
|
|
6
|
+
import { CredentialBindingService } from "../domain/credentials/CredentialServices";
|
|
7
|
+
import { InternalHmacAuthMiddleware } from "../pairing/InternalHmacAuthMiddleware";
|
|
8
|
+
import type { InternalHonoApiRouteRegistrar } from "../presentation/http/hono/InternalHonoApiRouteRegistrar";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Body shape pushed from the control-plane concierge when binding a
|
|
12
|
+
* credential instance to a workflow node slot.
|
|
13
|
+
*
|
|
14
|
+
* Note: the original story brief described the body as
|
|
15
|
+
* `{ credentialInstanceId, nodeId, slotName }`, but `CredentialBinding`
|
|
16
|
+
* requires a `workflowId` and the codebase uses `slotKey` (not `slotName`).
|
|
17
|
+
* The request shape was corrected here and on the matching concierge tool.
|
|
18
|
+
*/
|
|
19
|
+
type CredentialBindingBody = Readonly<{
|
|
20
|
+
workflowId: string;
|
|
21
|
+
nodeId: string;
|
|
22
|
+
slotKey: string;
|
|
23
|
+
credentialInstanceId: string;
|
|
24
|
+
}>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Registers POST /internal/credentials/binding — HMAC-verified endpoint that
|
|
28
|
+
* lets the control-plane concierge bind a credential instance to a workflow
|
|
29
|
+
* node slot on behalf of a workspace user.
|
|
30
|
+
*/
|
|
31
|
+
@injectable()
|
|
32
|
+
export class InternalCredentialsBindingRegistrar implements InternalHonoApiRouteRegistrar {
|
|
33
|
+
private readonly logger: Logger;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
@inject(InternalHmacAuthMiddleware) private readonly hmacMiddleware: InternalHmacAuthMiddleware,
|
|
37
|
+
@inject(CredentialBindingService) private readonly credentialBindingService: CredentialBindingService,
|
|
38
|
+
@inject(ApplicationTokens.LoggerFactory) loggerFactory: LoggerFactory,
|
|
39
|
+
) {
|
|
40
|
+
this.logger = loggerFactory.create("InternalCredentialsBindingRegistrar");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
register(app: Hono): void {
|
|
44
|
+
app.post("/internal/credentials/binding", this.hmacMiddleware.handle(), async (c) => {
|
|
45
|
+
try {
|
|
46
|
+
const rawBody = c.get("body" as never) as string | undefined;
|
|
47
|
+
const body = (rawBody ? JSON.parse(rawBody) : await c.req.json()) as Partial<CredentialBindingBody>;
|
|
48
|
+
|
|
49
|
+
if (!body.workflowId || typeof body.workflowId !== "string") {
|
|
50
|
+
return c.json({ error: "workflowId is required" }, 400);
|
|
51
|
+
}
|
|
52
|
+
if (!body.nodeId || typeof body.nodeId !== "string") {
|
|
53
|
+
return c.json({ error: "nodeId is required" }, 400);
|
|
54
|
+
}
|
|
55
|
+
if (!body.slotKey || typeof body.slotKey !== "string") {
|
|
56
|
+
return c.json({ error: "slotKey is required" }, 400);
|
|
57
|
+
}
|
|
58
|
+
if (!body.credentialInstanceId || typeof body.credentialInstanceId !== "string") {
|
|
59
|
+
return c.json({ error: "credentialInstanceId is required" }, 400);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const binding = await this.credentialBindingService.upsertBinding({
|
|
63
|
+
workflowId: body.workflowId,
|
|
64
|
+
nodeId: body.nodeId,
|
|
65
|
+
slotKey: body.slotKey,
|
|
66
|
+
instanceId: body.credentialInstanceId,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
this.logger.info(
|
|
70
|
+
`Credential binding upserted for workflow=${body.workflowId} node=${body.nodeId} slot=${body.slotKey}`,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return c.json({ ok: true, binding });
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error instanceof ApplicationRequestError) {
|
|
76
|
+
return c.json(error.payload, error.status as 400 | 404);
|
|
77
|
+
}
|
|
78
|
+
this.logger.error("Credential binding handler error", error instanceof Error ? error : undefined);
|
|
79
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -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
|
|
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
|
-
: {
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
@@ -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,77 @@
|
|
|
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
|
+
private readonly redisUrl: string;
|
|
25
|
+
|
|
26
|
+
constructor(@inject(ApplicationTokens.AppConfig) appConfig: AppConfig) {
|
|
27
|
+
const rawRedisUrl = appConfig.env.REDIS_URL ?? appConfig.env.CODEMATION_REDIS_URL;
|
|
28
|
+
// Defer URL parsing to queue construction so DI consumers that never enqueue
|
|
29
|
+
// (e.g. `codemation user create` with REDIS_URL="") can resolve this scheduler
|
|
30
|
+
// without throwing. fromUrl runs the first time getOrCreateQueue() fires.
|
|
31
|
+
this.redisUrl = rawRedisUrl && rawRedisUrl !== "" ? rawRedisUrl : "redis://127.0.0.1:6379";
|
|
32
|
+
const queuePrefix = appConfig.env.CODEMATION_BULLMQ_PREFIX ?? "codemation";
|
|
33
|
+
this.queueName = `${queuePrefix}.${HITL_TIMEOUT_QUEUE_NAME_SUFFIX}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async enqueueTimeoutJob(args: { taskId: string; expiresAt: Date }): Promise<void> {
|
|
37
|
+
const queue = this.getOrCreateQueue();
|
|
38
|
+
const delay = Math.max(0, args.expiresAt.getTime() - Date.now());
|
|
39
|
+
await queue.add("hitl.timeout", { kind: "hitl.timeout", taskId: args.taskId } satisfies HitlTimeoutJobPayload, {
|
|
40
|
+
jobId: this.makeJobId(args.taskId),
|
|
41
|
+
delay,
|
|
42
|
+
removeOnComplete: true,
|
|
43
|
+
removeOnFail: true,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async cancelTimeoutJob(taskId: string): Promise<void> {
|
|
48
|
+
const queue = this.getOrCreateQueue();
|
|
49
|
+
const job = await queue.getJob(this.makeJobId(taskId));
|
|
50
|
+
await job?.remove();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async close(): Promise<void> {
|
|
54
|
+
if (this.queue) {
|
|
55
|
+
await this.queue.close();
|
|
56
|
+
this.queue = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getQueueName(): string {
|
|
61
|
+
return this.queueName;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private getOrCreateQueue(): Queue {
|
|
65
|
+
if (!this.queue) {
|
|
66
|
+
const connectionOptions = RedisConnectionOptionsFactory.fromConfig({ url: this.redisUrl });
|
|
67
|
+
this.queue = new Queue(this.queueName, {
|
|
68
|
+
connection: connectionOptions as never,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return this.queue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private makeJobId(taskId: string): string {
|
|
75
|
+
return `hitl_timeout__${taskId}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
private readonly connectionOptions: Readonly<Record<string, unknown>>;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
@inject(HumanTaskStoreToken) taskStore: HumanTaskStore | undefined,
|
|
35
|
+
@inject(Engine) private readonly engine: Engine,
|
|
36
|
+
@inject(HitlTimeoutJobScheduler) private readonly scheduler: HitlTimeoutJobScheduler,
|
|
37
|
+
@inject(ApplicationTokens.AppConfig) appConfig: AppConfig,
|
|
38
|
+
@inject(ResumeTelemetryContextForRun) private readonly resumeTelemetry: ResumeTelemetryContextForRun,
|
|
39
|
+
) {
|
|
40
|
+
if (!taskStore) {
|
|
41
|
+
throw new Error("HitlTimeoutWorker: HumanTaskStore is not registered.");
|
|
42
|
+
}
|
|
43
|
+
this.taskStore = taskStore;
|
|
44
|
+
const redisUrl = appConfig.env.REDIS_URL ?? appConfig.env.CODEMATION_REDIS_URL ?? "redis://127.0.0.1:6379";
|
|
45
|
+
this.connectionOptions = RedisConnectionOptionsFactory.fromConfig({ url: redisUrl });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
start(): void {
|
|
49
|
+
this.worker = new Worker(
|
|
50
|
+
this.scheduler.getQueueName(),
|
|
51
|
+
async (job: Job) => {
|
|
52
|
+
await this.processJob(job);
|
|
53
|
+
},
|
|
54
|
+
{ connection: this.connectionOptions as never },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async stop(): Promise<void> {
|
|
59
|
+
if (this.worker) {
|
|
60
|
+
await this.worker.close();
|
|
61
|
+
this.worker = null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async processTimeoutForTask(taskId: string): Promise<void> {
|
|
66
|
+
const task = await this.taskStore.findById(taskId);
|
|
67
|
+
if (!task) return;
|
|
68
|
+
if (task.status !== "pending") return;
|
|
69
|
+
|
|
70
|
+
const now = new Date();
|
|
71
|
+
|
|
72
|
+
if (task.onTimeout === "auto-accept") {
|
|
73
|
+
await this.taskStore.markAutoAccepted(taskId);
|
|
74
|
+
|
|
75
|
+
// Emit hitl.task.timed_out on the run's trace.
|
|
76
|
+
const telemetry = await this.resumeTelemetry.forTask(taskId);
|
|
77
|
+
await telemetry?.addSpanEvent({
|
|
78
|
+
name: "hitl.task.timed_out",
|
|
79
|
+
attributes: {
|
|
80
|
+
[CodemationTelemetryAttributeNames.hitlTaskId]: taskId,
|
|
81
|
+
policy: "auto-accept",
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await this.engine.resumeRun({
|
|
86
|
+
runId: task.runId,
|
|
87
|
+
taskId: task.id,
|
|
88
|
+
resumeContext: {
|
|
89
|
+
decision: { kind: "auto_accepted", at: now },
|
|
90
|
+
delivery: task.deliveryRef ?? null,
|
|
91
|
+
task: {
|
|
92
|
+
taskId: task.id,
|
|
93
|
+
runId: task.runId,
|
|
94
|
+
nodeId: task.nodeId,
|
|
95
|
+
expiresAt: task.expiresAt,
|
|
96
|
+
resumeUrl: "",
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
} else {
|
|
101
|
+
await this.taskStore.markTimedOut(taskId);
|
|
102
|
+
|
|
103
|
+
// Emit hitl.task.timed_out on the run's trace.
|
|
104
|
+
const telemetry = await this.resumeTelemetry.forTask(taskId);
|
|
105
|
+
await telemetry?.addSpanEvent({
|
|
106
|
+
name: "hitl.task.timed_out",
|
|
107
|
+
attributes: {
|
|
108
|
+
[CodemationTelemetryAttributeNames.hitlTaskId]: taskId,
|
|
109
|
+
policy: "halt",
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await this.engine.resumeRun({
|
|
114
|
+
runId: task.runId,
|
|
115
|
+
taskId: task.id,
|
|
116
|
+
resumeContext: {
|
|
117
|
+
decision: { kind: "timed_out", at: now },
|
|
118
|
+
delivery: task.deliveryRef ?? null,
|
|
119
|
+
task: {
|
|
120
|
+
taskId: task.id,
|
|
121
|
+
runId: task.runId,
|
|
122
|
+
nodeId: task.nodeId,
|
|
123
|
+
expiresAt: task.expiresAt,
|
|
124
|
+
resumeUrl: "",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async processJob(job: Job): Promise<void> {
|
|
132
|
+
const data = job.data as HitlTimeoutJobPayload;
|
|
133
|
+
if (!data || data.kind !== "hitl.timeout") {
|
|
134
|
+
throw new Error(`Unexpected job payload for hitl.timeout queue: ${JSON.stringify(data)}`);
|
|
135
|
+
}
|
|
136
|
+
await this.processTimeoutForTask(data.taskId);
|
|
137
|
+
}
|
|
138
|
+
}
|