diff --git a/src/configuration/store.ts b/src/configuration/store.ts index e173a23c..26cd3665 100644 --- a/src/configuration/store.ts +++ b/src/configuration/store.ts @@ -1,5 +1,5 @@ import { parseTriggerDocument } from "../triggers/configuration/index.js"; -import { dump } from "js-yaml"; +import { dump, load } from "js-yaml"; import { z } from "zod"; import { compiledConfigurationHash, @@ -318,6 +318,9 @@ export function parseProjectConfiguration( if (adapter.success) { if (revision.rawYaml === null) throw new Error("Trigger revision is missing its authored document"); + // Preserved legacy workflows share the adapter but retain their original run policy. + const legacy = z.object({ legacy_multistep: z.object({}) }).safeParse(load(revision.rawYaml)); + if (legacy.success) return toProjectConfiguration(configuration); const policy = parseTriggerDocument(revision.rawYaml).run.continuation; return toProjectConfiguration({ ...configuration, diff --git a/src/provider-applications/internal/runtime-owner.test.ts b/src/provider-applications/internal/runtime-owner.test.ts index 20012b00..90d1b28e 100644 --- a/src/provider-applications/internal/runtime-owner.test.ts +++ b/src/provider-applications/internal/runtime-owner.test.ts @@ -12,8 +12,78 @@ import type { SlackProviderApplicationConfiguration, } from "../index.js"; import { DynamicProviderRuntime } from "./runtime-owner.js"; +import { OutputExecutorRegistry, replyOutputTool } from "../../execution-capabilities/outputs.js"; +import { createGitHubReplyExecutor, githubReplyAvailable } from "../../triggers/github/reply.js"; describe("dynamic provider runtime", () => { + it("exposes and delivers GitHub replies through the hosted provider registration", async () => { + const comments: unknown[] = []; + const runtime = new DynamicProviderRuntime({ + database: createMemoryDatabase(), + auth: testAuth(), + applicationBaseUrl: "https://hub.test", + registrationFactory: () => ({ + ...connectionRegistration("github", "A1"), + outputs: [ + { + type: "github.reply", + tool: replyOutputTool, + available: githubReplyAvailable, + execute: createGitHubReplyExecutor({ + client: { + createIssueComment: async (comment) => { + comments.push(comment); + }, + }, + }), + }, + ], + }), + }); + const registry = new OutputExecutorRegistry(); + for (const registration of runtime.registrations()) { + for (const output of registration.outputs) registry.register(output); + } + const candidate = await runtime.prepare( + "github", + providerConfiguration("github", "A1"), + "https://hub.test", + providerIdentity("github", "A1"), + 1, + ); + await candidate.start(); + candidate.publish(); + const outputContext = { + provider: "github", + target: { installationId: 42, repository: "acme/repo" }, + event: { github: { item: { number: 7 } } }, + }; + const grants = [{ type: "github.reply", required: false }]; + assert.equal(registry.materialize(grants, outputContext).length, 1); + assert.equal( + registry.materialize(grants, { + ...outputContext, + event: { github: { item: null } }, + }).length, + 0, + ); + await registry.execute({ + agentExecutionId: "execution-1", + toolType: "github.reply", + args: { content: "remembered" }, + outputContext, + }); + assert.deepEqual(comments, [ + { + installationId: 42, + owner: "acme", + repo: "repo", + issueNumber: 7, + body: "remembered", + }, + ]); + }); + it.each([ "github.issues", "github.issue_comment", diff --git a/src/provider-applications/internal/runtime-owner.ts b/src/provider-applications/internal/runtime-owner.ts index 33f0e904..61bb42c5 100644 --- a/src/provider-applications/internal/runtime-owner.ts +++ b/src/provider-applications/internal/runtime-owner.ts @@ -2,7 +2,7 @@ import type { AuthServer } from "../../auth/server.js"; import { createHash } from "node:crypto"; import type { GitHubConfigurationProvider } from "../../configuration/github-sync.js"; import type { Database } from "../../db/types.js"; -import { outputContextProvider, replyOutputTool } from "../../execution-capabilities/outputs.js"; +import { replyOutputTool } from "../../execution-capabilities/outputs.js"; import { logger } from "../../logger.js"; import { reportFailure } from "../../failures/index.js"; import { createDiscordRegistration } from "../../providers/discord/index.js"; @@ -399,26 +399,28 @@ export class DynamicProviderRuntime implements ProviderRuntimeOwner { }, ], sources: [source], - outputs: - provider === "github" - ? [] - : [ - { - type: `${provider}.reply`, - tool: replyOutputTool, - available: outputContextProvider(provider), - execute: (input) => { - const active = slot.active; - const output = active?.registration.outputs.find( - (candidate) => candidate.type === `${provider}.reply`, - ); - if (active === undefined || output === undefined) { - throw unavailable(`${provider}_output_unavailable`); - } - return this.withLease(active, () => output.execute(input)); - }, - }, - ], + outputs: [ + { + type: `${provider}.reply`, + tool: replyOutputTool, + available: (context) => { + const output = slot.active?.registration.outputs.find( + (candidate) => candidate.type === `${provider}.reply`, + ); + return output !== undefined && (output.available?.(context) ?? true); + }, + execute: (input) => { + const active = slot.active; + const output = active?.registration.outputs.find( + (candidate) => candidate.type === `${provider}.reply`, + ); + if (active === undefined || output === undefined) { + throw unavailable(`${provider}_output_unavailable`); + } + return this.withLease(active, () => output.execute(input)); + }, + }, + ], requests: provider === "discord" ? [] diff --git a/src/triggers/configuration/index.test.ts b/src/triggers/configuration/index.test.ts index 30fe5100..b8416416 100644 --- a/src/triggers/configuration/index.test.ts +++ b/src/triggers/configuration/index.test.ts @@ -176,4 +176,12 @@ it("applies the continuation default when reading old trigger revisions without assert.equal(stored.triggers[0]?.steps[0]?.continuation, undefined); const legacy = parseProjectConfiguration({ ...revision, sourceEvidence: { kind: "manual" } }); assert.equal(legacy.triggers[0]?.steps[0]?.continuation, undefined); + const migratedLegacy = parseProjectConfiguration({ + ...revision, + rawYaml: JSON.stringify({ + name: "preserved-workflow", + legacy_multistep: { trigger: stored.triggers[0], environments: stored.environments }, + }), + }); + assert.deepEqual(migratedLegacy, legacy); });