Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

### Minor Changes

- 8b9f402: fetch: allow state files to be writtem to JSON with --format
- 8b9f402: fetch: allow state files to be written to JSON with --format

### Patch Changes

Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/projects/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,13 @@ To ignore this error and override the local file, pass --force (-f)
// TODO canMergeInto needs to return a reason
if (!skipVersionCheck && !remoteProject.canMergeInto(localProject!)) {
// TODO allow rename
throw new Error('Error! An incompatible project exists at this location');
const e = new Error(
`Error! An incompatible project exists at this location.`
);

delete e.stack;

throw e;
}
}
}
9 changes: 8 additions & 1 deletion packages/project/src/Workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,14 @@ class Workflow {

// Get properties on any step or edge by id or uuid
get(id: string): WithMeta<l.Step | l.Trigger | l.StepEdge> {
const item = this.index.edges[id] || this.index.steps[id];
// first check if we're passed a UUID - in which case we map it to an id
if (id in this.index.id) {
id = this.index.id[id];
}

// now look up the item proper
let item = this.index.edges[id] || this.index.steps[id];

if (!item) {
throw new Error(`step/edge with id "${id}" does not exist in workflow`);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/project/src/gen/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ const initOperations = (options: any = {}) => {

n1.next ??= {};

n1.next[n2.name] = e;
n1.next[n2.id ?? slugify(n2.name)] = e;

return [n1, n2];
},
Expand Down
2 changes: 1 addition & 1 deletion packages/project/src/gen/workflow.ohm
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Workflow {

prop = (alnum | "-" | "_")+ "=" propValue

propValue = quoted_prop | bool | int | alnum+
propValue = quoted_prop | bool | int | alnum+

// TODO we only parse numbers as positive ints right now
// fine for tests
Expand Down
11 changes: 8 additions & 3 deletions packages/project/src/serialize/to-app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export default function (
return state;
}

const mapWorkflow = (workflow: Workflow) => {
export const mapWorkflow = (workflow: Workflow) => {
if (workflow instanceof Workflow) {
// @ts-ignore
workflow = workflow.toJSON();
Expand Down Expand Up @@ -96,10 +96,10 @@ const mapWorkflow = (workflow: Workflow) => {
let isTrigger = false;
let node: Provisioner.Job | Provisioner.Trigger;

if (s.type && !s.expression) {
if (!s.expression && !s.adaptor) {
isTrigger = true;
node = {
type: s.type,
type: s.type ?? 'webhook', // this is mostly for tests
...renameKeys(s.openfn, { uuid: 'id' }),
} as Provisioner.Trigger;
wfState.triggers[node.type] = node;
Expand Down Expand Up @@ -147,6 +147,11 @@ const mapWorkflow = (workflow: Workflow) => {
e.source_job_id = node.id;
}

if (rules.label) {
// TODO needs unit test
e.condition_label = rules.label;
}

if (rules.condition) {
if (typeof rules.condition === 'boolean') {
e.condition_type = rules.condition ? 'always' : 'never';
Expand Down
130 changes: 91 additions & 39 deletions packages/project/src/util/version.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1,125 @@
import { ConditionalStepEdge, Job, Trigger, Workflow } from '@openfn/lexicon';
import * as l from '@openfn/lexicon';
import crypto from 'node:crypto';
import { get } from 'lodash-es';
import { mapWorkflow } from '../serialize/to-app-state';
import Workflow from '../Workflow';

const SHORT_HASH_LENGTH = 12;

export const project = () => {};

function isDefined(v: any) {
return v !== undefined && v !== null;
}

export const generateHash = (workflow: Workflow, source = 'cli') => {
export const parse = (version: string) => {
const [source, hash] = version.split(':');
return { source, hash };
};

export const generateHash = (
wfJson: l.Workflow,
{ source = 'cli', sha = true } = {}
) => {
const workflow = new Workflow(wfJson);

const parts: string[] = [];

// convert the workflow into a v1 state object
// this means we can match keys with lightning
// and everything gets cleaner
const wfState = mapWorkflow(workflow);

// These are the keys we hash against
const wfKeys = ['name', 'credentials'].sort() as Array<keyof Workflow>;
const wfKeys = ['name', 'positions'].sort();

// These keys are manually sorted to match lightning equivalents
const stepKeys = [
'name',
'adaptors',
'adaptor', // there's both adaptor & adaptors key in steps somehow
'expression',
'configuration', // assumes a string credential id
'expression',

// TODO need to model trigger types in this, which I think are currently ignored
].sort() as Array<keyof Job | keyof Trigger>;
'adaptor',
'keychain_credential_id',
'project_credential_id',
'body',
].sort();

const triggerKeys = ['type', 'cron_expression', 'enabled'].sort();

const edgeKeys = [
'condition',
'name', // generated
'label',
'disabled', // This feels more like an option - should be excluded?
'condition_type',
'condition_label',
'condition_expression',
'enabled',
].sort();

wfKeys.forEach((key) => {
if (isDefined(workflow[key])) {
parts.push(key, serializeValue(workflow[key]));
const value = get(workflow, key);
if (isDefined(value)) {
parts.push(serializeValue(value));
}
});

const steps = (workflow.steps || []).slice().sort((a, b) => {
const aName = a.name ?? '';
const bName = b.name ?? '';
return aName.localeCompare(bName);
// do the trigger first
for (const triggerId in wfState.triggers) {
const trigger = wfState.triggers[triggerId];
triggerKeys.forEach((key) => {
const value = get(trigger, key);
if (isDefined(value)) {
parts.push(serializeValue(value));
}
});
}

// Now do all steps
const steps = Object.values(wfState.jobs).sort((a, b) => {
const aName = a.name ?? a.id ?? '';
const bName = b.name ?? b.id ?? '';
return aName.toLowerCase().localeCompare(bName.toLowerCase());
});

for (const step of steps) {
stepKeys.forEach((key) => {
if (isDefined((step as any)[key])) {
parts.push(key, serializeValue((step as any)[key]));
const value = get(step, key);
if (isDefined(value)) {
parts.push(serializeValue(value));
}
});
}

const edges = Object.values(wfState.edges)
.map((edge) => {
const source = workflow.get(edge.source_trigger_id ?? edge.source_job_id);
const target = workflow.get(edge.target_job_id);
(edge as any).name = `${source.name ?? source.id}-${
target.name ?? target.id
}`;
return edge;
})
.sort((a: any, b: any) => {
// sort edges by name
// where name is sourcename-target name
const aName = a.name ?? '';
const bName = b.name ?? '';
return aName.localeCompare(bName);
});

if (step.next && Array.isArray(step.next)) {
const steps = step.next.slice() as Array<ConditionalStepEdge>;
steps.slice().sort((a: ConditionalStepEdge, b: ConditionalStepEdge) => {
const aLabel = a.label || '';
const bLabel = b.label || '';
return aLabel.localeCompare(bLabel);
});
for (const edge of step.next) {
edgeKeys.forEach((key) => {
if (isDefined(edge[key])) {
parts.push(key, serializeValue(edge[key]));
}
});
// now do edges
for (const edge of edges) {
edgeKeys.forEach((key) => {
const value = get(edge, key);
if (isDefined(value)) {
parts.push(serializeValue(value));
}
}
});
}

const str = parts.join('');
const hash = crypto.createHash('sha256').update(str).digest('hex');
return `${source}:${hash.substring(0, SHORT_HASH_LENGTH)}`;
// console.log(str);
if (sha) {
const hash = crypto.createHash('sha256').update(str).digest('hex');
return `${source}:${hash.substring(0, SHORT_HASH_LENGTH)}`;
} else {
return `${source}:${str}`;
}
};

function serializeValue(val: unknown) {
Expand Down
33 changes: 32 additions & 1 deletion packages/project/test/parse/from-app-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,38 @@ test('should create a Project from prov state with a workflow', (t) => {
});
});

test('mapWorkflow: map a simple trigger', (t) => {
test('mapWorkflow: map a cron trigger', (t) => {
const mapped = mapWorkflow({
id: 'cron',
name: 'w',
deleted_at: null,
triggers: {
cron: {
id: '1234',
type: 'cron',
cron_expression: '0 1 0 0',
enabled: true,
},
},
jobs: {},
edges: {},
});

const [trigger] = mapped.steps;
console.log(trigger);
t.deepEqual(trigger, {
id: 'cron',
type: 'cron',
next: {},
openfn: {
enabled: true,
uuid: '1234',
cron_expression: '0 1 0 0',
},
});
});

test('mapWorkflow: map a cron trigger', (t) => {
const mapped = mapWorkflow(state.workflows['my-workflow']);

const [trigger] = mapped.steps;
Expand Down
Loading