From ce8dc4caa293bb148d7f7a64e431291ed9aa0339 Mon Sep 17 00:00:00 2001 From: Bobby Christopher Date: Mon, 22 Jun 2026 14:56:22 -0400 Subject: [PATCH 1/5] Add deploy --metadata passthrough for Genesis managed rollouts. Rollout intent (source, channel, rollout_org_ids) merges onto the deployment record via --metadata or AGENTUITY_DEPLOY_METADATA; complete may return rolloutId. Supersedes source_blob_id stdout emission approach. Co-authored-by: Cursor --- packages/cli/src/cmd/cloud/deploy.ts | 3 + packages/cli/src/cmd/cloud/deploy/build.ts | 6 ++ packages/cli/src/deploy-rollout-metadata.ts | 64 +++++++++++++++++++++ packages/cli/src/types.ts | 6 ++ packages/core/src/env.d.ts | 3 + packages/server/src/api/project/deploy.ts | 7 +++ 6 files changed, 89 insertions(+) create mode 100644 packages/cli/src/deploy-rollout-metadata.ts diff --git a/packages/cli/src/cmd/cloud/deploy.ts b/packages/cli/src/cmd/cloud/deploy.ts index 77c9b8384..823a6dc58 100644 --- a/packages/cli/src/cmd/cloud/deploy.ts +++ b/packages/cli/src/cmd/cloud/deploy.ts @@ -167,6 +167,7 @@ const DeployResponseSchema = z.object({ success: z.boolean().describe('Whether deployment succeeded'), deploymentId: z.string().describe('Deployment ID'), projectId: z.string().describe('Project ID'), + rolloutId: z.string().optional().describe('Genesis managed rollout id when fan-out was triggered'), logs: z.array(z.string()).optional().describe('The deployment startup logs'), urls: z .object({ @@ -397,6 +398,7 @@ export const deploySubcommand = createSubcommand({ if (opts.pullRequestUrl) childArgs.push(`--pull-request-url=${opts.pullRequestUrl}`); if (opts.skipDnsValidation) childArgs.push('--skip-dns-validation'); if (opts.skipTypeCheck) childArgs.push('--skip-type-check'); + if (opts.metadata) childArgs.push(`--metadata=${opts.metadata}`); const result = await runForkedDeploy({ projectDir, @@ -830,6 +832,7 @@ export const deploySubcommand = createSubcommand({ success: true, deploymentId: deployment.id, projectId: project.projectId, + rolloutId: complete?.rolloutId, logs, urls: complete?.publicUrls ? { diff --git a/packages/cli/src/cmd/cloud/deploy/build.ts b/packages/cli/src/cmd/cloud/deploy/build.ts index cba2ef684..dbb1e71fc 100644 --- a/packages/cli/src/cmd/cloud/deploy/build.ts +++ b/packages/cli/src/cmd/cloud/deploy/build.ts @@ -39,6 +39,10 @@ import type { BuildReportCollector } from '../../../build-report.ts'; import { getCachedProject } from '../../../cache/index.ts'; import { loadBuildMetadata } from '../../../config.ts'; import { generateDeployMetadata } from '../../../deploy-metadata.ts'; +import { + mergeDeployRolloutMetadata, + resolveDeployRolloutMetadata, +} from '../../../deploy-rollout-metadata.ts'; import { type Step, type StepContext, @@ -181,6 +185,7 @@ export function buildBuildStep(params: BuildStepParams): Step { if (registeredProjectName) { build.project.name = registeredProjectName; } + build = mergeDeployRolloutMetadata(build, resolveDeployRolloutMetadata(deployOptions)); } else { build = await generateDeployMetadata({ buildResult, @@ -198,6 +203,7 @@ export function buildBuildStep(params: BuildStepParams): Step { } logger.debug('Launch metadata: %s', JSON.stringify(build.launch, null, 2)); + build = mergeDeployRolloutMetadata(build, resolveDeployRolloutMetadata(deployOptions)); state.build = build; // 6. Send metadata to the server, get back upload URLs. diff --git a/packages/cli/src/deploy-rollout-metadata.ts b/packages/cli/src/deploy-rollout-metadata.ts new file mode 100644 index 000000000..62be553e3 --- /dev/null +++ b/packages/cli/src/deploy-rollout-metadata.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +export const DeployRolloutMetadataSchema = z + .object({ + source: z.enum(['managed']).optional(), + channel: z.enum(['edge', 'stable', 'commit']).optional(), + rollout_org_ids: z.array(z.string().min(1)).optional(), + }) + .strict(); + +export type DeployRolloutMetadata = z.infer; + +export function parseDeployRolloutMetadata(raw: string | undefined): DeployRolloutMetadata | undefined { + const value = raw?.trim(); + if (!value) { + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new Error( + `Invalid deploy metadata JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const result = DeployRolloutMetadataSchema.safeParse(parsed); + if (!result.success) { + const details = result.error.issues.map((issue) => issue.message).join(', '); + throw new Error(`Invalid deploy metadata: ${details}`); + } + if (!result.data.source && !result.data.channel && !result.data.rollout_org_ids?.length) { + return undefined; + } + return result.data; +} + +export function resolveDeployRolloutMetadata(options?: { + metadata?: string; +}): DeployRolloutMetadata | undefined { + return parseDeployRolloutMetadata(options?.metadata ?? process.env.AGENTUITY_DEPLOY_METADATA); +} + +export function mergeDeployRolloutMetadata }>( + build: T, + rolloutMetadata: DeployRolloutMetadata | undefined, +): T { + if (!rolloutMetadata || !build.deployment) { + return build; + } + const deployment = { ...build.deployment }; + if (rolloutMetadata.source) { + deployment.source = rolloutMetadata.source; + } + if (rolloutMetadata.channel) { + deployment.channel = rolloutMetadata.channel; + } + if (rolloutMetadata.rollout_org_ids?.length) { + deployment.rollout_org_ids = rolloutMetadata.rollout_org_ids; + } + return { + ...build, + deployment, + }; +} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 3f4fa6450..a0807535d 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -619,6 +619,12 @@ export const DeployOptionsSchema = zod .optional() .describe('Skip custom domain DNS validation before deploying'), skipTypeCheck: zod.boolean().optional().describe('Skip TypeScript validation during deploy'), + metadata: zod + .string() + .optional() + .describe( + 'JSON deployment metadata merged onto the deployment record (e.g. Genesis managed rollout intent)' + ), }) .merge(GitOptionsSchema); diff --git a/packages/core/src/env.d.ts b/packages/core/src/env.d.ts index 36ec5c8d6..75bcbf1fc 100644 --- a/packages/core/src/env.d.ts +++ b/packages/core/src/env.d.ts @@ -172,6 +172,9 @@ declare global { /** Deployment name/identifier */ AGENTUITY_DEPLOYMENT?: string; + /** JSON metadata merged onto the deployment record during deploy (Genesis managed rollouts) */ + AGENTUITY_DEPLOY_METADATA?: string; + // ============================================================================ // Database // ============================================================================ diff --git a/packages/server/src/api/project/deploy.ts b/packages/server/src/api/project/deploy.ts index abbdea347..ca27b51c9 100644 --- a/packages/server/src/api/project/deploy.ts +++ b/packages/server/src/api/project/deploy.ts @@ -212,6 +212,9 @@ export const BuildMetadataSchema = z.object({ arch: z.string().describe('the machine architecture'), platform: z.string().describe('the machine os platform'), }), + source: z.enum(['github', 'cli', 'managed']).optional(), + channel: z.enum(['edge', 'stable', 'commit']).optional(), + rollout_org_ids: z.array(z.string()).optional(), }) ), launch: LaunchMetadataSchema.optional().describe( @@ -307,6 +310,10 @@ export async function projectDeploymentUpdate( export const DeploymentCompleteSchema = z.object({ streamId: z.string().optional().describe('the stream id for warmup logs'), + rolloutId: z + .string() + .optional() + .describe('Genesis managed rollout id when source deploy triggered fan-out'), publicUrls: z .object({ latest: z.string().url().describe('the public url for the latest deployment'), From 95aa561ec527ca9bc547a1b000404b54b572b34c Mon Sep 17 00:00:00 2001 From: Bobby Christopher Date: Mon, 22 Jun 2026 16:06:16 -0400 Subject: [PATCH 2/5] Fix Biome formatting in deploy rollout metadata helper. Co-authored-by: Cursor --- packages/cli/src/deploy-rollout-metadata.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/deploy-rollout-metadata.ts b/packages/cli/src/deploy-rollout-metadata.ts index 62be553e3..a3794c0c8 100644 --- a/packages/cli/src/deploy-rollout-metadata.ts +++ b/packages/cli/src/deploy-rollout-metadata.ts @@ -10,7 +10,9 @@ export const DeployRolloutMetadataSchema = z export type DeployRolloutMetadata = z.infer; -export function parseDeployRolloutMetadata(raw: string | undefined): DeployRolloutMetadata | undefined { +export function parseDeployRolloutMetadata( + raw: string | undefined, +): DeployRolloutMetadata | undefined { const value = raw?.trim(); if (!value) { return undefined; @@ -20,7 +22,7 @@ export function parseDeployRolloutMetadata(raw: string | undefined): DeployRollo parsed = JSON.parse(value); } catch (error) { throw new Error( - `Invalid deploy metadata JSON: ${error instanceof Error ? error.message : String(error)}`, + `Invalid deploy metadata JSON: ${error instanceof Error ? error.message : String(error)}` ); } const result = DeployRolloutMetadataSchema.safeParse(parsed); @@ -42,7 +44,7 @@ export function resolveDeployRolloutMetadata(options?: { export function mergeDeployRolloutMetadata }>( build: T, - rolloutMetadata: DeployRolloutMetadata | undefined, + rolloutMetadata: DeployRolloutMetadata | undefined ): T { if (!rolloutMetadata || !build.deployment) { return build; From 36a4f159835a181cf4d468ba9d1793cf7735f25c Mon Sep 17 00:00:00 2001 From: Bobby Christopher Date: Mon, 22 Jun 2026 16:07:47 -0400 Subject: [PATCH 3/5] Fix Biome ci formatting for deploy rollout metadata changes. Co-authored-by: Cursor --- packages/cli/src/cmd/cloud/deploy.ts | 5 ++++- packages/cli/src/cmd/cloud/deploy/build.ts | 5 ++++- packages/cli/src/deploy-rollout-metadata.ts | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/cmd/cloud/deploy.ts b/packages/cli/src/cmd/cloud/deploy.ts index 823a6dc58..2f85da094 100644 --- a/packages/cli/src/cmd/cloud/deploy.ts +++ b/packages/cli/src/cmd/cloud/deploy.ts @@ -167,7 +167,10 @@ const DeployResponseSchema = z.object({ success: z.boolean().describe('Whether deployment succeeded'), deploymentId: z.string().describe('Deployment ID'), projectId: z.string().describe('Project ID'), - rolloutId: z.string().optional().describe('Genesis managed rollout id when fan-out was triggered'), + rolloutId: z + .string() + .optional() + .describe('Genesis managed rollout id when fan-out was triggered'), logs: z.array(z.string()).optional().describe('The deployment startup logs'), urls: z .object({ diff --git a/packages/cli/src/cmd/cloud/deploy/build.ts b/packages/cli/src/cmd/cloud/deploy/build.ts index dbb1e71fc..194ecb77a 100644 --- a/packages/cli/src/cmd/cloud/deploy/build.ts +++ b/packages/cli/src/cmd/cloud/deploy/build.ts @@ -185,7 +185,10 @@ export function buildBuildStep(params: BuildStepParams): Step { if (registeredProjectName) { build.project.name = registeredProjectName; } - build = mergeDeployRolloutMetadata(build, resolveDeployRolloutMetadata(deployOptions)); + build = mergeDeployRolloutMetadata( + build, + resolveDeployRolloutMetadata(deployOptions) + ); } else { build = await generateDeployMetadata({ buildResult, diff --git a/packages/cli/src/deploy-rollout-metadata.ts b/packages/cli/src/deploy-rollout-metadata.ts index a3794c0c8..01d3054ff 100644 --- a/packages/cli/src/deploy-rollout-metadata.ts +++ b/packages/cli/src/deploy-rollout-metadata.ts @@ -11,7 +11,7 @@ export const DeployRolloutMetadataSchema = z export type DeployRolloutMetadata = z.infer; export function parseDeployRolloutMetadata( - raw: string | undefined, + raw: string | undefined ): DeployRolloutMetadata | undefined { const value = raw?.trim(); if (!value) { From f2393dfc031db2d5ef5fea0e5acc64b6d0120879 Mon Sep 17 00:00:00 2001 From: Bobby Christopher Date: Thu, 25 Jun 2026 19:26:28 -0400 Subject: [PATCH 4/5] Pass Obs admin rollout_id through deploy metadata to app complete. Genesis GHA supplies rollout_id in AGENTUITY_DEPLOY_METADATA so Ion fan-out and verify use the same admin rollout record instead of minting a new id. Co-authored-by: Cursor --- packages/cli/src/deploy-rollout-metadata.ts | 15 +++++- .../cli/test/deploy-rollout-metadata.test.ts | 46 +++++++++++++++++++ packages/server/src/api/project/deploy.ts | 5 ++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/deploy-rollout-metadata.test.ts diff --git a/packages/cli/src/deploy-rollout-metadata.ts b/packages/cli/src/deploy-rollout-metadata.ts index 01d3054ff..27846e6aa 100644 --- a/packages/cli/src/deploy-rollout-metadata.ts +++ b/packages/cli/src/deploy-rollout-metadata.ts @@ -5,6 +5,11 @@ export const DeployRolloutMetadataSchema = z source: z.enum(['managed']).optional(), channel: z.enum(['edge', 'stable', 'commit']).optional(), rollout_org_ids: z.array(z.string().min(1)).optional(), + rollout_id: z + .string() + .min(1) + .regex(/^[A-Za-z0-9._:-]+$/) + .optional(), }) .strict(); @@ -30,7 +35,12 @@ export function parseDeployRolloutMetadata( const details = result.error.issues.map((issue) => issue.message).join(', '); throw new Error(`Invalid deploy metadata: ${details}`); } - if (!result.data.source && !result.data.channel && !result.data.rollout_org_ids?.length) { + if ( + !result.data.source && + !result.data.channel && + !result.data.rollout_org_ids?.length && + !result.data.rollout_id + ) { return undefined; } return result.data; @@ -59,6 +69,9 @@ export function mergeDeployRolloutMetadata { + test('accepts rollout_id for Obs-triggered managed deploys', () => { + expect( + parseDeployRolloutMetadata( + '{"source":"managed","channel":"edge","rollout_id":"rollout_3FdsBqGeMpUSDtpBt1uz2n6Drps"}' + ) + ).toEqual({ + source: 'managed', + channel: 'edge', + rollout_id: 'rollout_3FdsBqGeMpUSDtpBt1uz2n6Drps', + }); + }); +}); + +describe('mergeDeployRolloutMetadata', () => { + test('merges rollout_id onto deployment metadata', () => { + expect( + mergeDeployRolloutMetadata( + { + deployment: { + source: 'managed', + channel: 'edge', + }, + }, + { + source: 'managed', + channel: 'edge', + rollout_id: 'rollout_test', + } + ) + ).toEqual({ + deployment: { + source: 'managed', + channel: 'edge', + rollout_id: 'rollout_test', + }, + }); + }); +}); diff --git a/packages/server/src/api/project/deploy.ts b/packages/server/src/api/project/deploy.ts index ca27b51c9..e8b24078f 100644 --- a/packages/server/src/api/project/deploy.ts +++ b/packages/server/src/api/project/deploy.ts @@ -215,6 +215,11 @@ export const BuildMetadataSchema = z.object({ source: z.enum(['github', 'cli', 'managed']).optional(), channel: z.enum(['edge', 'stable', 'commit']).optional(), rollout_org_ids: z.array(z.string()).optional(), + rollout_id: z + .string() + .min(1) + .optional() + .describe('correlation id for a managed shared-source rollout wave'), }) ), launch: LaunchMetadataSchema.optional().describe( From 771aefd87d39c686b48f0cc1d9388d1a074f4018 Mon Sep 17 00:00:00 2001 From: Bobby Christopher Date: Thu, 25 Jun 2026 19:29:50 -0400 Subject: [PATCH 5/5] Use passthrough deploy metadata instead of a strict allowlist. Validate known Genesis managed fields but forward extra keys to app complete so new rollout metadata does not require another CLI release. Co-authored-by: Cursor --- packages/cli/src/deploy-rollout-metadata.ts | 42 +++++++++---------- .../cli/test/deploy-rollout-metadata.test.ts | 23 +++++++++- 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/deploy-rollout-metadata.ts b/packages/cli/src/deploy-rollout-metadata.ts index 27846e6aa..65d5a7861 100644 --- a/packages/cli/src/deploy-rollout-metadata.ts +++ b/packages/cli/src/deploy-rollout-metadata.ts @@ -11,10 +11,25 @@ export const DeployRolloutMetadataSchema = z .regex(/^[A-Za-z0-9._:-]+$/) .optional(), }) - .strict(); + .passthrough(); export type DeployRolloutMetadata = z.infer; +function hasDeployRolloutMetadata(value: DeployRolloutMetadata): boolean { + return Object.entries(value).some(([, fieldValue]) => { + if (fieldValue === undefined) { + return false; + } + if (Array.isArray(fieldValue)) { + return fieldValue.length > 0; + } + if (typeof fieldValue === 'string') { + return fieldValue.length > 0; + } + return true; + }); +} + export function parseDeployRolloutMetadata( raw: string | undefined ): DeployRolloutMetadata | undefined { @@ -35,12 +50,7 @@ export function parseDeployRolloutMetadata( const details = result.error.issues.map((issue) => issue.message).join(', '); throw new Error(`Invalid deploy metadata: ${details}`); } - if ( - !result.data.source && - !result.data.channel && - !result.data.rollout_org_ids?.length && - !result.data.rollout_id - ) { + if (!hasDeployRolloutMetadata(result.data)) { return undefined; } return result.data; @@ -59,21 +69,11 @@ export function mergeDeployRolloutMetadata { rollout_id: 'rollout_3FdsBqGeMpUSDtpBt1uz2n6Drps', }); }); + + test('passes through unknown keys for forward-compatible managed metadata', () => { + expect( + parseDeployRolloutMetadata( + '{"source":"managed","channel":"edge","rollout_id":"rollout_test","initiated_by":"user_abc"}' + ) + ).toEqual({ + source: 'managed', + channel: 'edge', + rollout_id: 'rollout_test', + initiated_by: 'user_abc', + }); + }); + + test('rejects invalid known fields', () => { + expect(() => parseDeployRolloutMetadata('{"source":"github","channel":"edge"}')).toThrow( + 'Invalid deploy metadata' + ); + }); }); describe('mergeDeployRolloutMetadata', () => { - test('merges rollout_id onto deployment metadata', () => { + test('merges rollout metadata onto deployment', () => { expect( mergeDeployRolloutMetadata( { @@ -33,6 +52,7 @@ describe('mergeDeployRolloutMetadata', () => { source: 'managed', channel: 'edge', rollout_id: 'rollout_test', + initiated_by: 'user_abc', } ) ).toEqual({ @@ -40,6 +60,7 @@ describe('mergeDeployRolloutMetadata', () => { source: 'managed', channel: 'edge', rollout_id: 'rollout_test', + initiated_by: 'user_abc', }, }); });