From 7b6d01af5d0312af33b3a7d43c14c5b50eee6bc8 Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Sun, 28 Jun 2026 17:30:26 +0100 Subject: [PATCH 01/13] Add validation to mock provisioner --- .changeset/forty-areas-remain.md | 5 + packages/lightning-mock/src/api-rest.ts | 45 ++++++++ packages/lightning-mock/test/rest.test.ts | 120 +++++++++++++++++++++- 3 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 .changeset/forty-areas-remain.md diff --git a/.changeset/forty-areas-remain.md b/.changeset/forty-areas-remain.md new file mode 100644 index 000000000..c01a3887d --- /dev/null +++ b/.changeset/forty-areas-remain.md @@ -0,0 +1,5 @@ +--- +'@openfn/lightning-mock': patch +--- + +Add validation to the provisioner endpoint diff --git a/packages/lightning-mock/src/api-rest.ts b/packages/lightning-mock/src/api-rest.ts index 0727c8f74..342bfa1c4 100644 --- a/packages/lightning-mock/src/api-rest.ts +++ b/packages/lightning-mock/src/api-rest.ts @@ -83,6 +83,43 @@ workflows: enabled: true `; +// Validates a provisioner payload, returning an error body if invalid or null if valid. +// Mirrors Lightning's error format so deploy code sees realistic rejection responses. +export function validateProvisionPayload(incoming: any): Record | null { + const workflowErrors: Record = {}; + + const wfList: any[] = Array.isArray(incoming.workflows) + ? incoming.workflows + : Object.values(incoming.workflows ?? {}); + + for (const wf of wfList) { + const edgeErrors: Record = {}; + const edgeList: any[] = Array.isArray(wf.edges) + ? wf.edges + : Object.values(wf.edges ?? {}); + + for (const edge of edgeList) { + if (!edge.source_trigger_id && !edge.source_job_id) { + const key = edge.id ?? '->'; + edgeErrors[key] = { + source_job_id: ['source_job_id or source_trigger_id must be present'], + }; + } + } + + if (Object.keys(edgeErrors).length > 0) { + const wfKey = wf.name ?? wf.id ?? 'unknown'; + workflowErrors[wfKey] = { edges: edgeErrors }; + } + } + + if (Object.keys(workflowErrors).length > 0) { + return { errors: { workflows: workflowErrors } }; + } + + return null; +} + export default ( app: DevServer, state: ServerState, @@ -121,6 +158,14 @@ export default ( router.post('/api/provision', (ctx) => { const incoming: any = ctx.request.body; + + const validationErrors = validateProvisionPayload(incoming); + if (validationErrors) { + ctx.response.status = 422; + ctx.response.body = validationErrors; + return; + } + const now = new Date().toISOString(); if (!state.projects[incoming.id]) { diff --git a/packages/lightning-mock/test/rest.test.ts b/packages/lightning-mock/test/rest.test.ts index d5dc67ed9..7a03f0f5a 100644 --- a/packages/lightning-mock/test/rest.test.ts +++ b/packages/lightning-mock/test/rest.test.ts @@ -2,7 +2,7 @@ import test from 'ava'; import { setup } from './util'; -import { DEFAULT_PROJECT_ID } from '../src/api-rest'; +import { DEFAULT_PROJECT_ID, validateProvisionPayload } from '../src/api-rest'; // @ts-ignore let server: any; @@ -84,3 +84,121 @@ test.serial("should return 404 if a collection isn't found", async (t) => { }); test.todo("should return 403 if a collection isn't authorized"); + +test('validateProvisionPayload: returns null for a valid edge with source_trigger_id', (t) => { + const payload = { + id: 'proj-1', + workflows: [ + { + name: 'wf1', + edges: [ + { + id: 'e1', + source_trigger_id: 'trig-uuid', + target_job_id: 'job-uuid', + enabled: true, + }, + ], + }, + ], + }; + t.is(validateProvisionPayload(payload), null); +}); + +test('validateProvisionPayload: returns null for a valid edge with source_job_id', (t) => { + const payload = { + id: 'proj-1', + workflows: [ + { + name: 'wf1', + edges: [ + { + id: 'e1', + source_job_id: 'job-uuid', + target_job_id: 'job-uuid-2', + enabled: true, + }, + ], + }, + ], + }; + t.is(validateProvisionPayload(payload), null); +}); + +test('validateProvisionPayload: returns errors when edge has no source', (t) => { + const payload = { + id: 'proj-1', + workflows: [ + { + name: 'wf1', + edges: [ + { + id: 'edge-1', + source_trigger_id: null, + target_job_id: '', + enabled: true, + }, + ], + }, + ], + }; + const result = validateProvisionPayload(payload); + t.truthy(result); + t.deepEqual(result, { + errors: { + workflows: { + wf1: { + edges: { + 'edge-1': { + source_job_id: [ + 'source_job_id or source_trigger_id must be present', + ], + }, + }, + }, + }, + }, + }); +}); + +test('validateProvisionPayload: returns null when there are no edges', (t) => { + const payload = { + id: 'proj-1', + workflows: [{ name: 'wf1', edges: [] }], + }; + t.is(validateProvisionPayload(payload), null); +}); + +test.serial( + 'should return 422 when a workflow edge has no source', + async (t) => { + const response = await fetch(`${endpoint}/api/provision`, { + method: 'POST', + body: JSON.stringify({ + id: 'bad-proj', + name: 'Bad Project', + workflows: [ + { + id: 'wf-uuid', + name: 'wf1', + jobs: [], + triggers: [], + edges: [ + { + id: 'e1', + source_trigger_id: null, + target_job_id: '', + enabled: true, + }, + ], + }, + ], + }), + headers: { 'content-type': 'application/json' }, + }); + + t.is(response.status, 422); + const body = await response.json(); + t.truthy(body.errors?.workflows?.wf1?.edges); + } +); From 1d5a41ffb6ebe0489dfa225ae7cac49c5f9062c6 Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Sun, 28 Jun 2026 18:01:38 +0100 Subject: [PATCH 02/13] experiment with using Project to generate a spec file from state --- integration-tests/cli/test/deploy.test.ts | 47 ++++++++- packages/cli/src/deploy/handler.ts | 2 +- packages/cli/test/deploy/deploy.test.ts | 18 ++++ packages/deploy/src/index.ts | 1 - .../project/src/serialize/to-app-state.ts | 99 +++++++++++-------- .../test/serialize/to-app-state.test.ts | 70 +++++++++++++ 6 files changed, 190 insertions(+), 47 deletions(-) diff --git a/integration-tests/cli/test/deploy.test.ts b/integration-tests/cli/test/deploy.test.ts index 2425d0177..3f07a7ec8 100644 --- a/integration-tests/cli/test/deploy.test.ts +++ b/integration-tests/cli/test/deploy.test.ts @@ -12,6 +12,26 @@ const port = 8967; const endpoint = `http://localhost:${port}`; let tmpDir = path.resolve('tmp/deploy'); +const testProjectV2 = ` +id: my-project +name: My Project +schema_version: '4.0' +workflows: + - id: my-workflow + name: My Workflow + start: webhook + steps: + - id: webhook + type: webhook + enabled: true + next: + transform-data: {} + - id: transform-data + name: Transform data + expression: 'fn(s => s)' + adaptor: '@openfn/language-common@latest' +`.trim(); + const testProject = ` name: test-project workflows: @@ -97,7 +117,6 @@ test.serial('deploy a local project', async (t) => { --log-json \ -l debug` ); - t.falsy(stderr); const logs = extractLogs(stdout); @@ -269,7 +288,7 @@ test.serial('redirect to v2 protocol if openfn.yaml is present', async (t) => { ); }); -test.serial('deploy a v2 spec file', async (t) => { +test.serial.only('deploy a v2 spec file', async (t) => { const testProjectV2 = ` name: test-project schema_version: '4.0' @@ -301,7 +320,7 @@ workflows: --log-json \ -l debug` ); - + console.log(stdout); t.falsy(stderr); const logs = extractLogs(stdout); @@ -378,3 +397,25 @@ test.serial('deploy then pull, changes one workflow, deploy', async (t) => { t.is(Object.keys(server.state.projects).length, 1); t.truthy(server.state.projects[projectId]); }); + +test.serial('deploy a v2 project.yaml', async (t) => { + await fs.writeFile(path.join(tmpDir, 'project.yaml'), testProjectV2); + + const { stdout, stderr } = await run( + `openfn deploy \ + --project-path ${tmpDir}/project.yaml \ + --state-path ${tmpDir}/.state.json \ + --no-confirm \ + --log-json \ + -l debug` + ); + + t.falsy(stderr); + + const logs = extractLogs(stdout); + assertLog(t, logs, /Deployed/); + + t.is(Object.keys(server.state.projects).length, 1); + const [project] = Object.values(server.state.projects) as any[]; + t.is(project.name, 'My Project'); +}); diff --git a/packages/cli/src/deploy/handler.ts b/packages/cli/src/deploy/handler.ts index 4c04b9c2d..9620e4079 100644 --- a/packages/cli/src/deploy/handler.ts +++ b/packages/cli/src/deploy/handler.ts @@ -150,7 +150,7 @@ export const maybeConvertV2spec = async (yaml: string): Promise => { const json = yamlToJson(yaml) as any; if (detectVersion(json) > 1) { const project = await Project.from('project', json); - return project.serialize('state', { format: 'yaml' }) as string; + return project.serialize('state', { format: 'yaml', asSpec: true }) as string; } return yaml; }; diff --git a/packages/cli/test/deploy/deploy.test.ts b/packages/cli/test/deploy/deploy.test.ts index f47633d9a..cec8b1dbe 100644 --- a/packages/cli/test/deploy/deploy.test.ts +++ b/packages/cli/test/deploy/deploy.test.ts @@ -260,6 +260,24 @@ test('maybeConvertV2spec: converts v2 (schema_version) to v1', async (t) => { t.falsy(json.schema_version); }); +test('maybeConvertV2spec: converted edges use key references, not UUIDs', async (t) => { + const result = await maybeConvertV2spec(v2Yaml); + const json = yamlToJson(result) as any; + + const workflow = Object.values(json.workflows)[0] as any; + const edge = Object.values(workflow.edges)[0] as any; + + // edge must use spec format (key references) so mergeSpecIntoState can resolve them + t.truthy(edge.source_trigger); + t.truthy(edge.target_job); + t.falsy(edge.source_trigger_id); + t.falsy(edge.target_job_id); + + // source_trigger must match a trigger key; target_job must match a job key + t.truthy(workflow.triggers[edge.source_trigger]); + t.truthy(workflow.jobs[edge.target_job]); +}); + test('maybeConvertV2spec: converts legacy v2 (cli.version: 2) to v1', async (t) => { const legacyV2Yaml = `id: my-project name: My Project diff --git a/packages/deploy/src/index.ts b/packages/deploy/src/index.ts index 5d3ecc558..215aceb38 100644 --- a/packages/deploy/src/index.ts +++ b/packages/deploy/src/index.ts @@ -118,7 +118,6 @@ export async function deploy(config: DeployConfig, logger: Logger) { throw new DeployError(`${config.specPath} has errors`, 'VALIDATION_ERROR'); } const nextState = mergeSpecIntoState(state, spec.doc); - validateProjectState(nextState); // Convert the state to a payload for the API. diff --git a/packages/project/src/serialize/to-app-state.ts b/packages/project/src/serialize/to-app-state.ts index 7805910a1..6fc7e61de 100644 --- a/packages/project/src/serialize/to-app-state.ts +++ b/packages/project/src/serialize/to-app-state.ts @@ -10,7 +10,7 @@ import Workflow from '../Workflow'; import slugify from '../util/slugify'; import getCredentialName from '../util/get-credential-name'; -type Options = { format?: 'json' | 'yaml' }; +type Options = { format?: 'json' | 'yaml'; asSpec?: boolean }; const defaultJobProps = { // TODO why does the provisioner throw if these keys are not set? @@ -56,7 +56,7 @@ export default function ( })); state.workflows = project.workflows - .map((w) => mapWorkflow(w, credentialsWithUuids)) + .map((w) => mapWorkflow(w, credentialsWithUuids, options)) .reduce((obj: any, wf) => { obj[slugify(wf.name ?? wf.id)] = wf; return obj; @@ -75,8 +75,11 @@ export default function ( export const mapWorkflow = ( workflow: Workflow, - credentials: CredentialState[] = [] + credentials: CredentialState[] = [], + options: Options = {} ) => { + const useUuids = !options.asSpec; + if (workflow instanceof Workflow) { // @ts-ignore workflow = workflow.toJSON(); @@ -85,7 +88,7 @@ export const mapWorkflow = ( const { uuid, ...originalOpenfnProps } = workflow.openfn ?? {}; const wfState = { ...originalOpenfnProps, - id: workflow.openfn?.uuid ?? randomUUID(), + ...(useUuids ? { id: workflow.openfn?.uuid ?? randomUUID() } : {}), jobs: {}, triggers: {}, edges: {}, @@ -96,17 +99,17 @@ export const mapWorkflow = ( wfState.name = workflow.name; } - // lookup of local-ids to project-ids + // lookup of local-ids to project-ids (only needed when using UUIDs) const lookup = workflow.steps.reduce((obj, next) => { - if (!next.openfn?.uuid) { - // If there's no tracked id, we generate one here - // TODO there is no unit test on this - next.openfn ??= {}; - next.openfn.uuid = randomUUID(); + if (useUuids) { + if (!next.openfn?.uuid) { + // If there's no tracked id, we generate one here + next.openfn ??= {}; + next.openfn.uuid = randomUUID(); + } + // @ts-ignore + obj[next.id] = next.openfn.uuid; } - - // @ts-ignore - obj[next.id] = next.openfn.uuid; return obj; }, {}) as Record; @@ -122,13 +125,15 @@ export const mapWorkflow = ( node = { ...rest, type: s.type ?? 'webhook', // this is mostly for tests - ...renameKeys(openfn, { uuid: 'id' }), + ...(useUuids ? renameKeys(openfn, { uuid: 'id' }) : {}), } as Provisioner.Trigger; wfState.triggers[node.type] = node; } else { node = omitBy(pick(s, ['name', 'adaptor']), isNil) as Provisioner.Job; const { uuid, ...otherOpenFnProps } = s.openfn ?? {}; - node.id = uuid; + if (useUuids) { + node.id = uuid; + } if (s.expression) { node.body = s.expression; } @@ -145,16 +150,11 @@ export const mapWorkflow = ( if (mappedCredential) { projectCredentialId = mappedCredential.uuid; } - // else { - // console.warn(`WARING! Failed to map credential ${projectCredentialId} - Lightning may throw an error. - - // Ensure the credential exists in project.yaml and try again (maybe ensure the credential is attached to the project in the app and run project fetch)`); - // } otherOpenFnProps.project_credential_id = projectCredentialId; } } - Object.assign(node, defaultJobProps, otherOpenFnProps); + Object.assign(node, useUuids ? defaultJobProps : {}, otherOpenFnProps); wfState.jobs[s.id ?? slugify(s.name)] = node; } @@ -165,18 +165,31 @@ export const mapWorkflow = ( const { uuid, ...otherOpenFnProps } = rules.openfn ?? {}; - const e = { - id: uuid ?? randomUUID(), - target_job_id: lookup[next], - enabled: !rules.disabled, - source_trigger_id: null, // lightning complains if this isn't set, even if its falsy :( - } as Provisioner.Edge; - Object.assign(e, otherOpenFnProps); - - if (isTrigger) { - e.source_trigger_id = node.id; + let e: any; + if (useUuids) { + e = { + id: uuid ?? randomUUID(), + target_job_id: lookup[next], + enabled: !rules.disabled, + source_trigger_id: null, // lightning complains if this isn't set, even if its falsy :( + } as Provisioner.Edge; + Object.assign(e, otherOpenFnProps); + if (isTrigger) { + e.source_trigger_id = node.id; + } else { + e.source_job_id = node.id; + } } else { - e.source_job_id = node.id; + e = { + enabled: !rules.disabled, + target_job: next, + }; + Object.assign(e, otherOpenFnProps); + if (isTrigger) { + e.source_trigger = s.type; + } else { + e.source_job = s.id; + } } if (rules.label) { @@ -202,16 +215,18 @@ export const mapWorkflow = ( }); }); - // Sort edges by UUID (for more predictable comparisons in test) - wfState.edges = Object.keys(wfState.edges) - // convert edge ids to strings just in case a number creeps in (it might in test) - .sort((a, b) => - `${wfState.edges[a].id}`.localeCompare('' + wfState.edges[b].id) - ) - .reduce((obj: any, key) => { - obj[key] = wfState.edges[key]; - return obj; - }, {}); + if (useUuids) { + // Sort edges by UUID (for more predictable comparisons in test) + wfState.edges = Object.keys(wfState.edges) + // convert edge ids to strings just in case a number creeps in (it might in test) + .sort((a, b) => + `${wfState.edges[a].id}`.localeCompare('' + wfState.edges[b].id) + ) + .reduce((obj: any, key) => { + obj[key] = wfState.edges[key]; + return obj; + }, {}); + } return wfState; }; diff --git a/packages/project/test/serialize/to-app-state.test.ts b/packages/project/test/serialize/to-app-state.test.ts index c007f114c..bcfd7ea76 100644 --- a/packages/project/test/serialize/to-app-state.test.ts +++ b/packages/project/test/serialize/to-app-state.test.ts @@ -612,6 +612,76 @@ test('should convert a project back to app state in json', (t) => { // TODO this test is failing because the order of keys in the yaml have changed! // We probably need to force alphabetical sorting on yaml keys +// asSpec: true — spec format for deploy pipeline + +const v2ProjectData: any = { + id: 'my-project', + name: 'My Project', + schema_version: '4.0', + workflows: [ + { + id: 'my-workflow', + name: 'My Workflow', + start: 'webhook', + steps: [ + { + id: 'webhook', + type: 'webhook', + enabled: true, + next: { 'transform-data': {} }, + }, + { + id: 'transform-data', + name: 'Transform data', + expression: 'fn(s => s)', + adaptor: '@openfn/language-common@latest', + }, + ], + }, + ], +}; + +test('asSpec:true - edges use source_trigger/target_job keys, not UUIDs', (t) => { + const project = new Project(v2ProjectData, { formats: { project: 'json' } }); + const result = toAppState(project, { format: 'json', asSpec: true }) as any; + + const edge = Object.values(result.workflows['my-workflow'].edges)[0] as any; + t.truthy(edge.source_trigger); + t.truthy(edge.target_job); + t.falsy(edge.source_trigger_id); + t.falsy(edge.target_job_id); + t.falsy(edge.id); +}); + +test('asSpec:true - source_trigger matches the trigger key', (t) => { + const project = new Project(v2ProjectData, { formats: { project: 'json' } }); + const result = toAppState(project, { format: 'json', asSpec: true }) as any; + + const wf = result.workflows['my-workflow']; + const edge = Object.values(wf.edges)[0] as any; + t.truthy(wf.triggers[edge.source_trigger]); +}); + +test('asSpec:true - target_job matches the job key', (t) => { + const project = new Project(v2ProjectData, { formats: { project: 'json' } }); + const result = toAppState(project, { format: 'json', asSpec: true }) as any; + + const wf = result.workflows['my-workflow']; + const edge = Object.values(wf.edges)[0] as any; + t.truthy(wf.jobs[edge.target_job]); +}); + +test('asSpec:true - triggers and jobs have no generated id', (t) => { + const project = new Project(v2ProjectData, { formats: { project: 'json' } }); + const result = toAppState(project, { format: 'json', asSpec: true }) as any; + + const wf = result.workflows['my-workflow']; + const trigger = Object.values(wf.triggers)[0] as any; + const job = Object.values(wf.jobs)[0] as any; + t.falsy(trigger.id); + t.falsy(job.id); +}); + test.skip('should convert a project back to app state in yaml', (t) => { // this is a serialized project file const data: any = { From d7c0d76ef3bc240e5c75eea1f0e7f9bc2cb14596 Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Tue, 30 Jun 2026 15:54:08 +0100 Subject: [PATCH 03/13] format --- packages/cli/.state.json | 59 +++++++++++++++++++++++++ packages/cli/src/deploy/handler.ts | 5 ++- packages/lightning-mock/src/api-rest.ts | 4 +- 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 packages/cli/.state.json diff --git a/packages/cli/.state.json b/packages/cli/.state.json new file mode 100644 index 000000000..f6df8b7ca --- /dev/null +++ b/packages/cli/.state.json @@ -0,0 +1,59 @@ +{ + "env": null, + "id": "e5aea408-80e8-4b3f-8cc2-dbc0a4775f1f", + "name": "basic10", + "description": null, + "color": null, + "concurrency": null, + "inserted_at": "2026-06-28T17:08:11Z", + "updated_at": "2026-06-28T17:08:11Z", + "parent_id": null, + "scheduled_deletion": null, + "history_retention_period": null, + "dataclip_retention_period": null, + "retention_policy": "retain_all", + "project_credentials": {}, + "workflows": { + "wf1": { + "id": "6aa53c12-f7a1-43ed-b430-af58ab35e9e4", + "name": "wf1", + "edges": { + "webhook->a": { + "enabled": true, + "id": "dadc2183-6ad6-41b5-b0fb-6715cced654a", + "condition_type": "always", + "source_trigger_id": "5831e3c2-5456-4903-9359-faa68edb175f" + } + }, + "concurrency": null, + "inserted_at": "2026-06-28T17:08:11Z", + "updated_at": "2026-06-28T17:08:11Z", + "jobs": { + "b": { + "id": "aafaac67-dc12-4816-aaec-c34e38ce1ceb", + "name": "b", + "body": "log($.data)\n", + "adaptor": "@openfn/language-common@latest", + "project_credential_id": null + } + }, + "lock_version": 1, + "deleted_at": null, + "triggers": { + "webhook": { + "enabled": true, + "id": "5831e3c2-5456-4903-9359-faa68edb175f", + "type": "webhook", + "webhook_reply": "before_start" + } + }, + "version_history": [ + "cli:0e8bc140349a" + ] + } + }, + "collections": {}, + "allow_support_access": false, + "requires_mfa": false, + "channels": {} +} \ No newline at end of file diff --git a/packages/cli/src/deploy/handler.ts b/packages/cli/src/deploy/handler.ts index 9620e4079..767e59231 100644 --- a/packages/cli/src/deploy/handler.ts +++ b/packages/cli/src/deploy/handler.ts @@ -150,7 +150,10 @@ export const maybeConvertV2spec = async (yaml: string): Promise => { const json = yamlToJson(yaml) as any; if (detectVersion(json) > 1) { const project = await Project.from('project', json); - return project.serialize('state', { format: 'yaml', asSpec: true }) as string; + return project.serialize('state', { + format: 'yaml', + asSpec: true, + }) as string; } return yaml; }; diff --git a/packages/lightning-mock/src/api-rest.ts b/packages/lightning-mock/src/api-rest.ts index 342bfa1c4..464cc2ce4 100644 --- a/packages/lightning-mock/src/api-rest.ts +++ b/packages/lightning-mock/src/api-rest.ts @@ -85,7 +85,9 @@ workflows: // Validates a provisioner payload, returning an error body if invalid or null if valid. // Mirrors Lightning's error format so deploy code sees realistic rejection responses. -export function validateProvisionPayload(incoming: any): Record | null { +export function validateProvisionPayload( + incoming: any +): Record | null { const workflowErrors: Record = {}; const wfList: any[] = Array.isArray(incoming.workflows) From 8037bc2ba9b2fda9e5954e7d5c695b4a00eb19d7 Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Tue, 30 Jun 2026 15:56:23 +0100 Subject: [PATCH 04/13] restore tests --- integration-tests/cli/test/deploy.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/integration-tests/cli/test/deploy.test.ts b/integration-tests/cli/test/deploy.test.ts index 3f07a7ec8..baae823d6 100644 --- a/integration-tests/cli/test/deploy.test.ts +++ b/integration-tests/cli/test/deploy.test.ts @@ -288,7 +288,7 @@ test.serial('redirect to v2 protocol if openfn.yaml is present', async (t) => { ); }); -test.serial.only('deploy a v2 spec file', async (t) => { +test.serial('deploy a v2 spec file', async (t) => { const testProjectV2 = ` name: test-project schema_version: '4.0' @@ -320,7 +320,6 @@ workflows: --log-json \ -l debug` ); - console.log(stdout); t.falsy(stderr); const logs = extractLogs(stdout); From 79c4a4dd6f5f19c8fa6346a5d8b6e4d05be4404e Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Tue, 30 Jun 2026 16:18:42 +0100 Subject: [PATCH 05/13] little style tweak --- packages/project/src/serialize/to-app-state.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/project/src/serialize/to-app-state.ts b/packages/project/src/serialize/to-app-state.ts index 6fc7e61de..21c893044 100644 --- a/packages/project/src/serialize/to-app-state.ts +++ b/packages/project/src/serialize/to-app-state.ts @@ -88,13 +88,16 @@ export const mapWorkflow = ( const { uuid, ...originalOpenfnProps } = workflow.openfn ?? {}; const wfState = { ...originalOpenfnProps, - ...(useUuids ? { id: workflow.openfn?.uuid ?? randomUUID() } : {}), jobs: {}, triggers: {}, edges: {}, lock_version: workflow.openfn?.lock_version ?? null, // TODO needs testing } as Provisioner.Workflow; + if (useUuids) { + wfState.id = (workflow.openfn?.uuid ?? randomUUID) as any; + } + if (workflow.name) { wfState.name = workflow.name; } From afff136844482f08d4f98a5e8b848399a9452e26 Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Tue, 30 Jun 2026 16:26:41 +0100 Subject: [PATCH 06/13] remove state.json --- packages/cli/.state.json | 59 ---------------------------------------- 1 file changed, 59 deletions(-) delete mode 100644 packages/cli/.state.json diff --git a/packages/cli/.state.json b/packages/cli/.state.json deleted file mode 100644 index f6df8b7ca..000000000 --- a/packages/cli/.state.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "env": null, - "id": "e5aea408-80e8-4b3f-8cc2-dbc0a4775f1f", - "name": "basic10", - "description": null, - "color": null, - "concurrency": null, - "inserted_at": "2026-06-28T17:08:11Z", - "updated_at": "2026-06-28T17:08:11Z", - "parent_id": null, - "scheduled_deletion": null, - "history_retention_period": null, - "dataclip_retention_period": null, - "retention_policy": "retain_all", - "project_credentials": {}, - "workflows": { - "wf1": { - "id": "6aa53c12-f7a1-43ed-b430-af58ab35e9e4", - "name": "wf1", - "edges": { - "webhook->a": { - "enabled": true, - "id": "dadc2183-6ad6-41b5-b0fb-6715cced654a", - "condition_type": "always", - "source_trigger_id": "5831e3c2-5456-4903-9359-faa68edb175f" - } - }, - "concurrency": null, - "inserted_at": "2026-06-28T17:08:11Z", - "updated_at": "2026-06-28T17:08:11Z", - "jobs": { - "b": { - "id": "aafaac67-dc12-4816-aaec-c34e38ce1ceb", - "name": "b", - "body": "log($.data)\n", - "adaptor": "@openfn/language-common@latest", - "project_credential_id": null - } - }, - "lock_version": 1, - "deleted_at": null, - "triggers": { - "webhook": { - "enabled": true, - "id": "5831e3c2-5456-4903-9359-faa68edb175f", - "type": "webhook", - "webhook_reply": "before_start" - } - }, - "version_history": [ - "cli:0e8bc140349a" - ] - } - }, - "collections": {}, - "allow_support_access": false, - "requires_mfa": false, - "channels": {} -} \ No newline at end of file From 19f62347608841576daf10e735218d0ede11cd5a Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Wed, 1 Jul 2026 16:47:29 +0100 Subject: [PATCH 07/13] handle credentials properly in spec --- packages/cli/src/deploy/handler.ts | 1 + packages/cli/test/deploy/deploy.test.ts | 27 ++++++++-- packages/cli/test/projects/deploy.test.ts | 1 - .../project/src/serialize/to-app-state.ts | 49 +++++++++++++------ .../test/serialize/to-app-state.test.ts | 28 +++++++++-- 5 files changed, 82 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/deploy/handler.ts b/packages/cli/src/deploy/handler.ts index 767e59231..1fd0dfd9f 100644 --- a/packages/cli/src/deploy/handler.ts +++ b/packages/cli/src/deploy/handler.ts @@ -64,6 +64,7 @@ async function deployHandler( const rawSpec = await fs.readFile(config.specPath, 'utf-8'); const convertedSpec = await maybeConvertV2spec(rawSpec); + console.log(convertedSpec); if (convertedSpec !== rawSpec) { logger.info( 'Detected v2 spec file - converting to legacy format; validation will be skipped.' diff --git a/packages/cli/test/deploy/deploy.test.ts b/packages/cli/test/deploy/deploy.test.ts index cec8b1dbe..9ce544e76 100644 --- a/packages/cli/test/deploy/deploy.test.ts +++ b/packages/cli/test/deploy/deploy.test.ts @@ -188,8 +188,6 @@ test.serial('catches DeployErrors', async (t) => { process.exitCode = origExitCode; }); -// maybeConvertV2spec - const v1Yaml = `id: '1234' name: My Project workflows: @@ -221,6 +219,9 @@ project_credentials: [] const v2Yaml = `id: my-project name: My Project schema_version: '4.0' +credentials: + - name: http1 + owner: super@openfn.org workflows: - id: my-workflow name: My Workflow @@ -235,6 +236,7 @@ workflows: name: Transform data expression: 'fn(s => s)' adaptor: '@openfn/language-common@latest' + configuration: super@openfn.org|http1 `; test('maybeConvertV2spec: returns v1 yaml unchanged', async (t) => { @@ -242,7 +244,7 @@ test('maybeConvertV2spec: returns v1 yaml unchanged', async (t) => { t.is(result, v1Yaml); }); -test('maybeConvertV2spec: converts v2 (schema_version) to v1', async (t) => { +test('maybeConvertV2spec: converts v2 to v1', async (t) => { const result = await maybeConvertV2spec(v2Yaml); const json = yamlToJson(result) as any; @@ -256,10 +258,29 @@ test('maybeConvertV2spec: converts v2 (schema_version) to v1', async (t) => { t.falsy(workflow.steps); t.truthy(workflow.triggers); + // no uuids + const edge = workflow.edges['webhook->transform-data']; + t.is(edge.target_job, 'transform-data'); + t.is(edge.source_trigger, 'webhook'); + t.falsy(workflow.jobs['transform-data'].id); + // no v2 marker t.falsy(json.schema_version); }); +test('maybeConvertV2spec: converts with credentials', async (t) => { + const result = await maybeConvertV2spec(v2Yaml); + const json = yamlToJson(result) as any; + + t.deepEqual(json.project_credentials, [ + { name: 'http1', owner: 'super@openfn.org' }, + ]); + t.is( + json.workflows['my-workflow'].jobs['transform-data'].project_credential_id, + 'super@openfn.org|http1' + ); +}); + test('maybeConvertV2spec: converted edges use key references, not UUIDs', async (t) => { const result = await maybeConvertV2spec(v2Yaml); const json = yamlToJson(result) as any; diff --git a/packages/cli/test/projects/deploy.test.ts b/packages/cli/test/projects/deploy.test.ts index b0c1774e5..d6ac61ca8 100644 --- a/packages/cli/test/projects/deploy.test.ts +++ b/packages/cli/test/projects/deploy.test.ts @@ -19,7 +19,6 @@ import { TWO_WORKFLOWS_UUID, } from './fixtures'; import { checkout } from '../../src/projects'; -import { readFileSync } from 'node:fs'; let server: any; const logger = createMockLogger(undefined, { level: 'debug' }); diff --git a/packages/project/src/serialize/to-app-state.ts b/packages/project/src/serialize/to-app-state.ts index 21c893044..239c95cb3 100644 --- a/packages/project/src/serialize/to-app-state.ts +++ b/packages/project/src/serialize/to-app-state.ts @@ -41,22 +41,34 @@ export default function ( state.id = (uuid as string) ?? randomUUID(); Object.assign(state, rest, project.options); - - const credentialsWithUuids = - project.credentials?.map((c) => ({ - ...c, - uuid: (c as CredentialState).uuid ?? randomUUID(), - })) ?? []; - - state.project_credentials = credentialsWithUuids.map((c) => ({ - // note the subtle conversion here - id: c.uuid as string, - name: c.name, - owner: c.owner, - })); + if (options.asSpec) { + for (const c of project.credentials) { + // note that credentials for a spec file are not the + // the same format as a state file, + // so typings break here + (state as any).credentials ??= {}; + (state as any).credentials[getCredentialName(c)] = { + name: c.name, + owner: c.owner, + }; + } + } else { + const credentialsWithUuids = + project.credentials?.map((c) => ({ + ...c, + uuid: (c as CredentialState).uuid ?? randomUUID(), + })) ?? []; + + state.project_credentials = credentialsWithUuids.map((c) => ({ + // note the subtle conversion here + id: c.uuid as string, + name: c.name, + owner: c.owner, + })); + } state.workflows = project.workflows - .map((w) => mapWorkflow(w, credentialsWithUuids, options)) + .map((w) => mapWorkflow(w, project.credentials, options)) .reduce((obj: any, wf) => { obj[slugify(wf.name ?? wf.id)] = wf; return obj; @@ -150,10 +162,15 @@ export const mapWorkflow = ( const name = getCredentialName(c); return name === projectCredentialId; }); - if (mappedCredential) { + if (mappedCredential && useUuids) { projectCredentialId = mappedCredential.uuid; } - otherOpenFnProps.project_credential_id = projectCredentialId; + + if (useUuids) { + otherOpenFnProps.project_credential_id = projectCredentialId; + } else { + otherOpenFnProps.project_credential = projectCredentialId; + } } } diff --git a/packages/project/test/serialize/to-app-state.test.ts b/packages/project/test/serialize/to-app-state.test.ts index bcfd7ea76..709b17c20 100644 --- a/packages/project/test/serialize/to-app-state.test.ts +++ b/packages/project/test/serialize/to-app-state.test.ts @@ -4,6 +4,7 @@ import toAppState from '../../src/serialize/to-app-state'; import { generateProject } from '../../src/gen/generator'; import type { Provisioner } from '@openfn/lexicon/lightning'; +import { cloneDeep } from 'lodash-es'; const state: Provisioner.Project = { id: 'e16c5f09-f0cb-4ba7-a4c2-73fcb2f29d00', @@ -610,10 +611,6 @@ test('should convert a project back to app state in json', (t) => { t.deepEqual(newState, state); }); -// TODO this test is failing because the order of keys in the yaml have changed! -// We probably need to force alphabetical sorting on yaml keys -// asSpec: true — spec format for deploy pipeline - const v2ProjectData: any = { id: 'my-project', name: 'My Project', @@ -653,6 +650,29 @@ test('asSpec:true - edges use source_trigger/target_job keys, not UUIDs', (t) => t.falsy(edge.id); }); +test('asSpec:true - handle credentials', (t) => { + const data = cloneDeep(v2ProjectData); + data.credentials = [ + { + name: 'x', + owner: 'a@b.org,', + uuid: '123', + }, + ]; + data.workflows[0].steps[1].configuration = `a@b.org|x`; + + const project = new Project(data, { formats: { project: 'json' } }); + const result = toAppState(project, { format: 'json', asSpec: true }) as any; + + t.deepEqual(result.credentials, { + 'a@b.org,|x': { name: 'x', owner: 'a@b.org,' }, + }); + t.is( + result.workflows['my-workflow'].jobs['transform-data'].project_credential, + 'a@b.org|x' + ); +}); + test('asSpec:true - source_trigger matches the trigger key', (t) => { const project = new Project(v2ProjectData, { formats: { project: 'json' } }); const result = toAppState(project, { format: 'json', asSpec: true }) as any; From d808ee5897f14013ff7e6bcacd13e148a41e5bbd Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Thu, 2 Jul 2026 11:18:43 +0100 Subject: [PATCH 08/13] update tests --- packages/cli/test/deploy/deploy.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cli/test/deploy/deploy.test.ts b/packages/cli/test/deploy/deploy.test.ts index 9ce544e76..b28ca1e79 100644 --- a/packages/cli/test/deploy/deploy.test.ts +++ b/packages/cli/test/deploy/deploy.test.ts @@ -272,11 +272,12 @@ test('maybeConvertV2spec: converts with credentials', async (t) => { const result = await maybeConvertV2spec(v2Yaml); const json = yamlToJson(result) as any; - t.deepEqual(json.project_credentials, [ - { name: 'http1', owner: 'super@openfn.org' }, - ]); + t.deepEqual(json.credentials, { + 'super@openfn.org|http1': { name: 'http1', owner: 'super@openfn.org' }, + }); + t.is( - json.workflows['my-workflow'].jobs['transform-data'].project_credential_id, + json.workflows['my-workflow'].jobs['transform-data'].project_credential, 'super@openfn.org|http1' ); }); From 2c43ad614ef1a857b6866fd07671302346f01c16 Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Thu, 2 Jul 2026 11:32:01 +0100 Subject: [PATCH 09/13] mock: handle deleted edges --- packages/lightning-mock/src/api-rest.ts | 2 +- packages/lightning-mock/test/rest.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/lightning-mock/src/api-rest.ts b/packages/lightning-mock/src/api-rest.ts index 464cc2ce4..6dc8d6e64 100644 --- a/packages/lightning-mock/src/api-rest.ts +++ b/packages/lightning-mock/src/api-rest.ts @@ -101,7 +101,7 @@ export function validateProvisionPayload( : Object.values(wf.edges ?? {}); for (const edge of edgeList) { - if (!edge.source_trigger_id && !edge.source_job_id) { + if (!edge.delete && !edge.source_trigger_id && !edge.source_job_id) { const key = edge.id ?? '->'; edgeErrors[key] = { source_job_id: ['source_job_id or source_trigger_id must be present'], diff --git a/packages/lightning-mock/test/rest.test.ts b/packages/lightning-mock/test/rest.test.ts index 7a03f0f5a..87db89a96 100644 --- a/packages/lightning-mock/test/rest.test.ts +++ b/packages/lightning-mock/test/rest.test.ts @@ -161,6 +161,25 @@ test('validateProvisionPayload: returns errors when edge has no source', (t) => }); }); +test('validateProvisionPayload: returns null for deleted edges', (t) => { + const payload = { + id: 'proj-1', + workflows: [ + { + name: 'wf1', + edges: [ + { + id: 'edge-1', + delete: true, + }, + ], + }, + ], + }; + const result = validateProvisionPayload(payload); + t.falsy(result); +}); + test('validateProvisionPayload: returns null when there are no edges', (t) => { const payload = { id: 'proj-1', From f0cf6ba121379114305052c48f5b0db0064be86a Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Thu, 2 Jul 2026 11:33:28 +0100 Subject: [PATCH 10/13] fix tests --- integration-tests/cli/test/deploy.test.ts | 1 - packages/project/src/serialize/to-app-state.ts | 11 ++++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/integration-tests/cli/test/deploy.test.ts b/integration-tests/cli/test/deploy.test.ts index baae823d6..839effc78 100644 --- a/integration-tests/cli/test/deploy.test.ts +++ b/integration-tests/cli/test/deploy.test.ts @@ -382,7 +382,6 @@ test.serial('deploy then pull, changes one workflow, deploy', async (t) => { // And deploy those changes const { stdout, stderr } = await run(deployCmd); - t.falsy(stderr); const logs = extractLogs(stdout); diff --git a/packages/project/src/serialize/to-app-state.ts b/packages/project/src/serialize/to-app-state.ts index 239c95cb3..e996348f7 100644 --- a/packages/project/src/serialize/to-app-state.ts +++ b/packages/project/src/serialize/to-app-state.ts @@ -10,7 +10,16 @@ import Workflow from '../Workflow'; import slugify from '../util/slugify'; import getCredentialName from '../util/get-credential-name'; -type Options = { format?: 'json' | 'yaml'; asSpec?: boolean }; +type Options = { + format?: 'json' | 'yaml'; + /** + * Serialize the project into a v1 spec format (not state) + * This is awkward and ugly but should only be a temporary solution + * If we decide we need it long term, we should generate a separate + * to-app-spec function which does a more focused job of it. + */ + asSpec?: boolean; +}; const defaultJobProps = { // TODO why does the provisioner throw if these keys are not set? From bd68b0c89a85f09dae060a3d520bcb3a49ca42d9 Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Thu, 2 Jul 2026 11:43:16 +0100 Subject: [PATCH 11/13] correct project credential name --- packages/cli/.state.json | 66 +++++++++++++++++++ .../project/src/serialize/to-app-state.ts | 2 +- .../test/serialize/to-app-state.test.ts | 2 +- 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 packages/cli/.state.json diff --git a/packages/cli/.state.json b/packages/cli/.state.json new file mode 100644 index 000000000..4a1bf1558 --- /dev/null +++ b/packages/cli/.state.json @@ -0,0 +1,66 @@ +{ + "env": null, + "id": "df93d14a-b866-4fa2-9283-4d30686f6afe", + "name": "cred-23", + "description": null, + "color": null, + "collections": {}, + "channels": {}, + "concurrency": null, + "inserted_at": "2026-07-02T10:42:49Z", + "scheduled_deletion": null, + "parent_id": null, + "history_retention_period": null, + "allow_support_access": false, + "dataclip_retention_period": null, + "project_credentials": { + "super@openfn.org|http1": { + "id": "b0dc2852-bace-4279-994c-82ab5b3bf013", + "name": "http1", + "owner": "super@openfn.org" + } + }, + "requires_mfa": false, + "retention_policy": "retain_all", + "updated_at": "2026-07-02T10:42:49Z", + "workflows": { + "wf1": { + "id": "b1bc9de2-d9ce-4cd1-ad14-8bf4057633a7", + "name": "wf1", + "edges": { + "cron->a": { + "enabled": true, + "id": "c276527b-a131-4166-9397-1679727527f0", + "target_job_id": "4b4b4c2d-8e3f-4bc1-9e92-cad25fd17a06", + "source_trigger_id": "18b6d376-9efa-4173-a818-3e6fb59e58d9", + "condition_type": "always" + } + }, + "concurrency": null, + "inserted_at": "2026-07-02T10:42:49Z", + "jobs": { + "a": { + "id": "4b4b4c2d-8e3f-4bc1-9e92-cad25fd17a06", + "name": "a", + "body": "// Get some data from an API...\nget('https://www.example.com');\n", + "project_credential_id": "b0dc2852-bace-4279-994c-82ab5b3bf013", + "adaptor": "@openfn/language-http@latest" + } + }, + "updated_at": "2026-07-02T10:42:49Z", + "deleted_at": null, + "triggers": { + "cron": { + "enabled": true, + "id": "18b6d376-9efa-4173-a818-3e6fb59e58d9", + "type": "cron", + "cron_expression": "0 0 * * *" + } + }, + "lock_version": 1, + "version_history": [ + "cli:eebc5ac958f1" + ] + } + } +} \ No newline at end of file diff --git a/packages/project/src/serialize/to-app-state.ts b/packages/project/src/serialize/to-app-state.ts index e996348f7..5864c2a25 100644 --- a/packages/project/src/serialize/to-app-state.ts +++ b/packages/project/src/serialize/to-app-state.ts @@ -178,7 +178,7 @@ export const mapWorkflow = ( if (useUuids) { otherOpenFnProps.project_credential_id = projectCredentialId; } else { - otherOpenFnProps.project_credential = projectCredentialId; + otherOpenFnProps.credential = projectCredentialId; } } } diff --git a/packages/project/test/serialize/to-app-state.test.ts b/packages/project/test/serialize/to-app-state.test.ts index 709b17c20..efa7990e2 100644 --- a/packages/project/test/serialize/to-app-state.test.ts +++ b/packages/project/test/serialize/to-app-state.test.ts @@ -668,7 +668,7 @@ test('asSpec:true - handle credentials', (t) => { 'a@b.org,|x': { name: 'x', owner: 'a@b.org,' }, }); t.is( - result.workflows['my-workflow'].jobs['transform-data'].project_credential, + result.workflows['my-workflow'].jobs['transform-data'].credential, 'a@b.org|x' ); }); From 9918f13e327b96d27da77e9c46dd032145560441 Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Thu, 2 Jul 2026 11:51:19 +0100 Subject: [PATCH 12/13] one more test for luck --- integration-tests/cli/test/deploy.test.ts | 64 +++++++++++++++++++++- packages/cli/.state.json | 66 ----------------------- 2 files changed, 63 insertions(+), 67 deletions(-) delete mode 100644 packages/cli/.state.json diff --git a/integration-tests/cli/test/deploy.test.ts b/integration-tests/cli/test/deploy.test.ts index 839effc78..0cc5761b9 100644 --- a/integration-tests/cli/test/deploy.test.ts +++ b/integration-tests/cli/test/deploy.test.ts @@ -1,6 +1,6 @@ import test from 'ava'; import path from 'node:path'; -import fs from 'node:fs/promises'; +import fs, { rm } from 'node:fs/promises'; import run from '../src/run'; import createLightningServer from '@openfn/lightning-mock'; import { extractLogs, assertLog } from '../src/util'; @@ -32,6 +32,30 @@ workflows: adaptor: '@openfn/language-common@latest' `.trim(); +const testProjectV2WithCredential = ` +id: my-project +name: My Project +schema_version: '4.0' +credentials: + - name: http1 + owner: super@openfn.org +workflows: + - id: my-workflow + name: My Workflow + start: webhook + steps: + - id: webhook + type: webhook + enabled: true + next: + transform-data: {} + - id: transform-data + name: Transform data + expression: 'fn(s => s)' + adaptor: '@openfn/language-common@latest' + configuration: super@openfn.org|http1 +`.trim(); + const testProject = ` name: test-project workflows: @@ -417,3 +441,41 @@ test.serial('deploy a v2 project.yaml', async (t) => { const [project] = Object.values(server.state.projects) as any[]; t.is(project.name, 'My Project'); }); + +test.serial('deploy a new v2 project.yaml with credentials', async (t) => { + await fs.writeFile( + path.join(tmpDir, 'project.yaml'), + testProjectV2WithCredential + ); + + try { + await rm(`${tmpDir}/.state.json`); + } catch (e) { + // ignore + } + + const { stdout, stderr } = await run( + `openfn deploy \ + --project-path ${tmpDir}/project.yaml \ + --no-confirm \ + --log-json \ + -l debug` + ); + + t.falsy(stderr); + + const logs = extractLogs(stdout); + assertLog(t, logs, /Deployed/); + + t.is(Object.keys(server.state.projects).length, 1); + const [project] = Object.values(server.state.projects) as any[]; + t.is(project.name, 'My Project'); + + t.is(project.project_credentials[0].name, 'http1'); + t.is(project.project_credentials[0].owner, 'super@openfn.org'); + + const uuid = project.project_credentials[0].id; + + const workflow: any = Object.values(project.workflows).pop(); + t.is(workflow.jobs[0].project_credential_id, uuid); +}); diff --git a/packages/cli/.state.json b/packages/cli/.state.json deleted file mode 100644 index 4a1bf1558..000000000 --- a/packages/cli/.state.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "env": null, - "id": "df93d14a-b866-4fa2-9283-4d30686f6afe", - "name": "cred-23", - "description": null, - "color": null, - "collections": {}, - "channels": {}, - "concurrency": null, - "inserted_at": "2026-07-02T10:42:49Z", - "scheduled_deletion": null, - "parent_id": null, - "history_retention_period": null, - "allow_support_access": false, - "dataclip_retention_period": null, - "project_credentials": { - "super@openfn.org|http1": { - "id": "b0dc2852-bace-4279-994c-82ab5b3bf013", - "name": "http1", - "owner": "super@openfn.org" - } - }, - "requires_mfa": false, - "retention_policy": "retain_all", - "updated_at": "2026-07-02T10:42:49Z", - "workflows": { - "wf1": { - "id": "b1bc9de2-d9ce-4cd1-ad14-8bf4057633a7", - "name": "wf1", - "edges": { - "cron->a": { - "enabled": true, - "id": "c276527b-a131-4166-9397-1679727527f0", - "target_job_id": "4b4b4c2d-8e3f-4bc1-9e92-cad25fd17a06", - "source_trigger_id": "18b6d376-9efa-4173-a818-3e6fb59e58d9", - "condition_type": "always" - } - }, - "concurrency": null, - "inserted_at": "2026-07-02T10:42:49Z", - "jobs": { - "a": { - "id": "4b4b4c2d-8e3f-4bc1-9e92-cad25fd17a06", - "name": "a", - "body": "// Get some data from an API...\nget('https://www.example.com');\n", - "project_credential_id": "b0dc2852-bace-4279-994c-82ab5b3bf013", - "adaptor": "@openfn/language-http@latest" - } - }, - "updated_at": "2026-07-02T10:42:49Z", - "deleted_at": null, - "triggers": { - "cron": { - "enabled": true, - "id": "18b6d376-9efa-4173-a818-3e6fb59e58d9", - "type": "cron", - "cron_expression": "0 0 * * *" - } - }, - "lock_version": 1, - "version_history": [ - "cli:eebc5ac958f1" - ] - } - } -} \ No newline at end of file From d7b81a80e924b507329d7e142632f98a0aa1062a Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Thu, 2 Jul 2026 11:56:03 +0100 Subject: [PATCH 13/13] one more test fix and log removal --- packages/cli/src/deploy/handler.ts | 1 - packages/cli/test/deploy/deploy.test.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/src/deploy/handler.ts b/packages/cli/src/deploy/handler.ts index 1fd0dfd9f..767e59231 100644 --- a/packages/cli/src/deploy/handler.ts +++ b/packages/cli/src/deploy/handler.ts @@ -64,7 +64,6 @@ async function deployHandler( const rawSpec = await fs.readFile(config.specPath, 'utf-8'); const convertedSpec = await maybeConvertV2spec(rawSpec); - console.log(convertedSpec); if (convertedSpec !== rawSpec) { logger.info( 'Detected v2 spec file - converting to legacy format; validation will be skipped.' diff --git a/packages/cli/test/deploy/deploy.test.ts b/packages/cli/test/deploy/deploy.test.ts index b28ca1e79..db0dfe633 100644 --- a/packages/cli/test/deploy/deploy.test.ts +++ b/packages/cli/test/deploy/deploy.test.ts @@ -277,7 +277,7 @@ test('maybeConvertV2spec: converts with credentials', async (t) => { }); t.is( - json.workflows['my-workflow'].jobs['transform-data'].project_credential, + json.workflows['my-workflow'].jobs['transform-data'].credential, 'super@openfn.org|http1' ); });