newo 3.7.1 → 3.7.2
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 +12 -0
- package/dist/api.d.ts +33 -1
- package/dist/api.js +22 -0
- package/dist/domain/strategies/sync/ProjectSyncStrategy.d.ts +7 -0
- package/dist/domain/strategies/sync/ProjectSyncStrategy.js +81 -1
- package/dist/domain/strategies/sync/V2ProjectSyncStrategy.d.ts +11 -0
- package/dist/domain/strategies/sync/V2ProjectSyncStrategy.js +125 -2
- package/dist/sync/flow-metadata.d.ts +67 -0
- package/dist/sync/flow-metadata.js +283 -0
- package/dist/sync/push.js +65 -2
- package/dist/types.d.ts +37 -0
- package/package.json +1 -1
- package/src/api.ts +61 -0
- package/src/domain/strategies/sync/ProjectSyncStrategy.ts +100 -0
- package/src/domain/strategies/sync/V2ProjectSyncStrategy.ts +147 -0
- package/src/sync/flow-metadata.ts +345 -0
- package/src/sync/push.ts +75 -2
- package/src/types.ts +40 -0
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
Skill,
|
|
34
34
|
FlowEvent,
|
|
35
35
|
FlowState,
|
|
36
|
+
FlowMetadata,
|
|
36
37
|
ProjectData,
|
|
37
38
|
ProjectMap,
|
|
38
39
|
SkillMetadata
|
|
@@ -51,7 +52,14 @@ import {
|
|
|
51
52
|
getCustomerAttributes,
|
|
52
53
|
listLibraries,
|
|
53
54
|
updateLibrarySkill,
|
|
55
|
+
getFlow,
|
|
54
56
|
} from '../../../api.js';
|
|
57
|
+
import {
|
|
58
|
+
syncFlowMetadata,
|
|
59
|
+
emptyFlowSyncCounts,
|
|
60
|
+
totalFlowSyncOps,
|
|
61
|
+
describeFlowSyncCounts
|
|
62
|
+
} from '../../../sync/flow-metadata.js';
|
|
55
63
|
import type { LibraryResponse } from '../../../api.js';
|
|
56
64
|
import {
|
|
57
65
|
ensureStateOnly,
|
|
@@ -84,7 +92,10 @@ import {
|
|
|
84
92
|
buildV2InlineSkill,
|
|
85
93
|
buildV2FlowEvent,
|
|
86
94
|
buildV2StateField,
|
|
95
|
+
parseV2FlowYaml,
|
|
87
96
|
type V2InlineSkill,
|
|
97
|
+
type V2FlowEvent,
|
|
98
|
+
type V2StateField,
|
|
88
99
|
} from '../../../format/v2-yaml.js';
|
|
89
100
|
import { isContentDifferent } from '../../../sync/skill-files.js';
|
|
90
101
|
import yaml from 'js-yaml';
|
|
@@ -648,6 +659,15 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
648
659
|
for (const change of changes) {
|
|
649
660
|
try {
|
|
650
661
|
if (change.operation === 'modified') {
|
|
662
|
+
// V2 flow YAML: newo_customers/{cust}/{proj}/agents/{agent}/flows/{flow}/{flow}.yaml
|
|
663
|
+
// The flow YAML carries title, events, and state_fields inline, so
|
|
664
|
+
// changes there must sync to the platform like V1 metadata.yaml.
|
|
665
|
+
if (this.isV2FlowYamlPath(change.path)) {
|
|
666
|
+
const count = await this.pushV2FlowYamlUpdate(client, change, mapData, newHashes);
|
|
667
|
+
result.updated += count;
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
|
|
651
671
|
// Detect if this is a library skill or flow skill by path
|
|
652
672
|
const isLibrary = change.path.includes('/libraries/');
|
|
653
673
|
const count = isLibrary
|
|
@@ -671,6 +691,115 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
671
691
|
return result;
|
|
672
692
|
}
|
|
673
693
|
|
|
694
|
+
/**
|
|
695
|
+
* Recognize the V2 flow YAML location: .../agents/{agent}/flows/{flow}/{flow}.yaml
|
|
696
|
+
* Distinguishes it from skill scripts, library YAMLs, and attribute files.
|
|
697
|
+
*/
|
|
698
|
+
private isV2FlowYamlPath(p: string): boolean {
|
|
699
|
+
const parts = p.split('/');
|
|
700
|
+
const file = parts[parts.length - 1];
|
|
701
|
+
if (!file || !file.endsWith('.yaml')) return false;
|
|
702
|
+
// .../agents/{agent}/flows/{flow}/{flow}.yaml → last 5 parts:
|
|
703
|
+
// agents, {agent}, flows, {flow}, {flow}.yaml
|
|
704
|
+
if (parts.length < 5) return false;
|
|
705
|
+
const flowsKeyword = parts[parts.length - 3];
|
|
706
|
+
const agentsKeyword = parts[parts.length - 5];
|
|
707
|
+
const flowFolder = parts[parts.length - 2] || '';
|
|
708
|
+
const stem = file.slice(0, -'.yaml'.length);
|
|
709
|
+
return flowsKeyword === 'flows' && agentsKeyword === 'agents' && stem === flowFolder;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Push V2 flow YAML changes. Closes GH issue #3 for newo_v2 layout.
|
|
714
|
+
* Parses the V2 YAML, converts to V1-shaped FlowMetadata, and reuses the
|
|
715
|
+
* shared syncFlowMetadata routine that calls PATCH/POST/DELETE per child.
|
|
716
|
+
*/
|
|
717
|
+
private async pushV2FlowYamlUpdate(
|
|
718
|
+
client: AxiosInstance,
|
|
719
|
+
change: ChangeItem<LocalProjectData>,
|
|
720
|
+
mapData: ProjectMap,
|
|
721
|
+
newHashes: HashStore
|
|
722
|
+
): Promise<number> {
|
|
723
|
+
const parts = change.path.split('/');
|
|
724
|
+
const flowIdn = parts[parts.length - 2] || '';
|
|
725
|
+
const agentIdn = parts[parts.length - 4] || '';
|
|
726
|
+
const projectIdn = parts[parts.length - 6] || '';
|
|
727
|
+
|
|
728
|
+
const projectData = mapData.projects[projectIdn];
|
|
729
|
+
const agentData = projectData?.agents[agentIdn];
|
|
730
|
+
const flowData = agentData?.flows[flowIdn];
|
|
731
|
+
|
|
732
|
+
if (!flowData?.id) {
|
|
733
|
+
this.logger.warn(`[newo_v2] Flow YAML change but flow not in project map: ${projectIdn}/${agentIdn}/${flowIdn}`);
|
|
734
|
+
return 0;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const v2Flow = await parseV2FlowYaml(change.path);
|
|
738
|
+
|
|
739
|
+
// Convert V2 → V1-shaped FlowMetadata for the shared sync routine.
|
|
740
|
+
// V2 events lack `id` and `description`; we fill defaults so the shape
|
|
741
|
+
// matches FlowEvent[] / FlowState[] expected by syncFlowMetadata.
|
|
742
|
+
// Optional fields are omitted (not set to undefined) to satisfy
|
|
743
|
+
// exactOptionalPropertyTypes.
|
|
744
|
+
const localMeta: FlowMetadata = {
|
|
745
|
+
id: flowData.id,
|
|
746
|
+
idn: v2Flow.idn,
|
|
747
|
+
title: v2Flow.title,
|
|
748
|
+
description: v2Flow.description ?? '',
|
|
749
|
+
default_runner_type: (v2Flow.default_runner_type as 'guidance' | 'nsl') || 'guidance',
|
|
750
|
+
default_model: {
|
|
751
|
+
provider_idn: v2Flow.default_provider_idn,
|
|
752
|
+
model_idn: v2Flow.default_model_idn,
|
|
753
|
+
},
|
|
754
|
+
events: (v2Flow.events || []).map((e: V2FlowEvent) => {
|
|
755
|
+
const out: FlowEvent = {
|
|
756
|
+
id: '',
|
|
757
|
+
idn: e.idn,
|
|
758
|
+
description: '',
|
|
759
|
+
skill_selector: e.skill_selector as 'first' | 'last' | 'random' | 'all',
|
|
760
|
+
interrupt_mode: (e.interrupt_mode || 'queue') as 'allow' | 'deny' | 'queue',
|
|
761
|
+
...(e.skill_idn != null ? { skill_idn: e.skill_idn } : {}),
|
|
762
|
+
...(e.state_idn != null ? { state_idn: e.state_idn } : {}),
|
|
763
|
+
...(e.integration_idn != null ? { integration_idn: e.integration_idn } : {}),
|
|
764
|
+
...(e.connector_idn != null ? { connector_idn: e.connector_idn } : {}),
|
|
765
|
+
};
|
|
766
|
+
return out;
|
|
767
|
+
}),
|
|
768
|
+
state_fields: (v2Flow.state_fields || []).map((s: V2StateField) => {
|
|
769
|
+
const out: FlowState = {
|
|
770
|
+
id: '',
|
|
771
|
+
idn: s.idn,
|
|
772
|
+
title: s.title || s.idn,
|
|
773
|
+
scope: (s.scope || 'flow') as 'flow' | 'agent' | 'project' | 'global',
|
|
774
|
+
...(s.default_value != null ? { default_value: s.default_value } : {}),
|
|
775
|
+
};
|
|
776
|
+
return out;
|
|
777
|
+
}),
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
let remoteFlow = null;
|
|
781
|
+
try {
|
|
782
|
+
remoteFlow = await getFlow(client, flowData.id);
|
|
783
|
+
} catch (error: any) {
|
|
784
|
+
this.logger.verbose(`[newo_v2] Could not GET flow ${flowIdn}: ${error.response?.status ?? error.message}`);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const counts = emptyFlowSyncCounts();
|
|
788
|
+
await syncFlowMetadata(client, flowData.id, localMeta, remoteFlow, false, counts);
|
|
789
|
+
|
|
790
|
+
const total = totalFlowSyncOps(counts);
|
|
791
|
+
if (total > 0) {
|
|
792
|
+
this.logger.info(`[newo_v2] ↑ Flow ${flowIdn}: ${describeFlowSyncCounts(counts)}`);
|
|
793
|
+
}
|
|
794
|
+
for (const err of counts.errors) {
|
|
795
|
+
this.logger.warn(err);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const content = await fs.readFile(change.path, 'utf8');
|
|
799
|
+
newHashes[change.path] = sha256(content);
|
|
800
|
+
return total;
|
|
801
|
+
}
|
|
802
|
+
|
|
674
803
|
/**
|
|
675
804
|
* Push a V2 skill update
|
|
676
805
|
*
|
|
@@ -801,6 +930,24 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
801
930
|
for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
|
|
802
931
|
// Flow skills
|
|
803
932
|
for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
|
|
933
|
+
for (const [flowIdn, _flowData] of Object.entries(agentData.flows)) {
|
|
934
|
+
// V2 stores flow events / state_fields / title inline in the flow
|
|
935
|
+
// YAML. Detect changes here so push() can sync them (GH issue #3).
|
|
936
|
+
const flowYamlPath = v2FlowYamlPath(customer.idn, projectIdn, agentIdn, flowIdn);
|
|
937
|
+
if (await fs.pathExists(flowYamlPath)) {
|
|
938
|
+
const content = await fs.readFile(flowYamlPath, 'utf8');
|
|
939
|
+
const currentHash = sha256(content);
|
|
940
|
+
const storedHash = hashes[flowYamlPath];
|
|
941
|
+
if (storedHash !== currentHash) {
|
|
942
|
+
changes.push({
|
|
943
|
+
item: {} as LocalProjectData,
|
|
944
|
+
operation: 'modified',
|
|
945
|
+
path: flowYamlPath
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
804
951
|
for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
|
|
805
952
|
for (const [skillIdn, skillMeta] of Object.entries(flowData.skills)) {
|
|
806
953
|
const scriptPath = v2SkillScriptPath(
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow metadata sync — reconciles local flow metadata.yaml with the platform.
|
|
3
|
+
*
|
|
4
|
+
* Closes the gap behind GH issue #3 (push wiping flow event subscriptions and
|
|
5
|
+
* title): before this module existed, push only updated skill scripts. Local
|
|
6
|
+
* edits to flow title, events, or state_fields silently never reached the
|
|
7
|
+
* platform; new events created via `newo create-event` had no path to flow
|
|
8
|
+
* therefore disappeared from local metadata.yaml after a subsequent pull.
|
|
9
|
+
*
|
|
10
|
+
* Reconciliation strategy per flow (only runs when metadata.yaml hash changed):
|
|
11
|
+
* - Compare local FlowMetadata against fresh GET responses from the platform
|
|
12
|
+
* - Update flow title/description/runner via PATCH /api/v1/designer/flows/{id}
|
|
13
|
+
* - For each child collection (events, state_fields):
|
|
14
|
+
* • idn present locally + missing remotely → create
|
|
15
|
+
* • idn present in both, contents differ → update
|
|
16
|
+
* • idn missing locally + present remotely → delete
|
|
17
|
+
*
|
|
18
|
+
* Hash-gating is critical: if the user never touched metadata.yaml, we never
|
|
19
|
+
* compute a remote diff, which means a stale or partially-pulled tree cannot
|
|
20
|
+
* accidentally wipe events that were created out-of-band via the Builder UI.
|
|
21
|
+
*/
|
|
22
|
+
import type { AxiosInstance } from 'axios';
|
|
23
|
+
import {
|
|
24
|
+
listFlowEvents,
|
|
25
|
+
listFlowStates,
|
|
26
|
+
createFlowEvent,
|
|
27
|
+
updateFlowEvent,
|
|
28
|
+
deleteFlowEvent,
|
|
29
|
+
createFlowState,
|
|
30
|
+
updateFlowState,
|
|
31
|
+
deleteFlowState,
|
|
32
|
+
updateFlow
|
|
33
|
+
} from '../api.js';
|
|
34
|
+
import type {
|
|
35
|
+
FlowMetadata,
|
|
36
|
+
FlowEvent,
|
|
37
|
+
FlowState,
|
|
38
|
+
CreateFlowEventRequest,
|
|
39
|
+
UpdateFlowEventRequest,
|
|
40
|
+
CreateFlowStateRequest,
|
|
41
|
+
UpdateFlowStateRequest,
|
|
42
|
+
UpdateFlowRequest
|
|
43
|
+
} from '../types.js';
|
|
44
|
+
|
|
45
|
+
export interface FlowMetadataSyncCounts {
|
|
46
|
+
flowsUpdated: number;
|
|
47
|
+
eventsCreated: number;
|
|
48
|
+
eventsUpdated: number;
|
|
49
|
+
eventsDeleted: number;
|
|
50
|
+
statesCreated: number;
|
|
51
|
+
statesUpdated: number;
|
|
52
|
+
statesDeleted: number;
|
|
53
|
+
errors: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function emptyFlowSyncCounts(): FlowMetadataSyncCounts {
|
|
57
|
+
return {
|
|
58
|
+
flowsUpdated: 0,
|
|
59
|
+
eventsCreated: 0,
|
|
60
|
+
eventsUpdated: 0,
|
|
61
|
+
eventsDeleted: 0,
|
|
62
|
+
statesCreated: 0,
|
|
63
|
+
statesUpdated: 0,
|
|
64
|
+
statesDeleted: 0,
|
|
65
|
+
errors: []
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* True when remote FlowEvent fields differ from what the local metadata says.
|
|
71
|
+
* We only compare semantic fields the platform stores - `id` is platform-owned.
|
|
72
|
+
*/
|
|
73
|
+
export function flowEventDiffers(local: FlowEvent, remote: FlowEvent): boolean {
|
|
74
|
+
return (
|
|
75
|
+
normalizeStr(local.description) !== normalizeStr(remote.description) ||
|
|
76
|
+
normalizeStr(local.skill_selector) !== normalizeStr(remote.skill_selector) ||
|
|
77
|
+
normalizeStr(local.skill_idn) !== normalizeStr(remote.skill_idn) ||
|
|
78
|
+
normalizeStr(local.state_idn) !== normalizeStr(remote.state_idn) ||
|
|
79
|
+
normalizeStr(local.interrupt_mode) !== normalizeStr(remote.interrupt_mode) ||
|
|
80
|
+
normalizeStr(local.integration_idn) !== normalizeStr(remote.integration_idn) ||
|
|
81
|
+
normalizeStr(local.connector_idn) !== normalizeStr(remote.connector_idn)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function flowStateDiffers(local: FlowState, remote: FlowState): boolean {
|
|
86
|
+
return (
|
|
87
|
+
normalizeStr(local.title) !== normalizeStr(remote.title) ||
|
|
88
|
+
normalizeStr(local.default_value) !== normalizeStr(remote.default_value) ||
|
|
89
|
+
normalizeStr(local.scope) !== normalizeStr(remote.scope)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Normalizes nullable/undefined string fields so YAML round-trips don't
|
|
95
|
+
* register as differences. `null`, `undefined`, and missing all collapse to ''.
|
|
96
|
+
*/
|
|
97
|
+
function normalizeStr(value: unknown): string {
|
|
98
|
+
if (value === null || value === undefined) return '';
|
|
99
|
+
return String(value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildEventCreateRequest(local: FlowEvent): CreateFlowEventRequest {
|
|
103
|
+
return {
|
|
104
|
+
idn: local.idn,
|
|
105
|
+
description: local.description ?? '',
|
|
106
|
+
skill_selector: local.skill_selector,
|
|
107
|
+
...(local.skill_idn != null ? { skill_idn: local.skill_idn } : {}),
|
|
108
|
+
state_idn: local.state_idn ?? null,
|
|
109
|
+
interrupt_mode: local.interrupt_mode,
|
|
110
|
+
integration_idn: local.integration_idn ?? '',
|
|
111
|
+
connector_idn: local.connector_idn ?? ''
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildEventUpdateRequest(local: FlowEvent): UpdateFlowEventRequest {
|
|
116
|
+
return {
|
|
117
|
+
idn: local.idn,
|
|
118
|
+
description: local.description ?? '',
|
|
119
|
+
skill_selector: local.skill_selector,
|
|
120
|
+
skill_idn: local.skill_idn ?? null,
|
|
121
|
+
state_idn: local.state_idn ?? null,
|
|
122
|
+
interrupt_mode: local.interrupt_mode,
|
|
123
|
+
integration_idn: local.integration_idn ?? null,
|
|
124
|
+
connector_idn: local.connector_idn ?? null
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildStateCreateRequest(local: FlowState): CreateFlowStateRequest {
|
|
129
|
+
const req: CreateFlowStateRequest = {
|
|
130
|
+
title: local.title || local.idn,
|
|
131
|
+
idn: local.idn,
|
|
132
|
+
scope: local.scope
|
|
133
|
+
};
|
|
134
|
+
if (local.default_value != null) {
|
|
135
|
+
req.default_value = local.default_value;
|
|
136
|
+
}
|
|
137
|
+
return req;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildStateUpdateRequest(local: FlowState): UpdateFlowStateRequest {
|
|
141
|
+
const req: UpdateFlowStateRequest = {
|
|
142
|
+
title: local.title || local.idn,
|
|
143
|
+
idn: local.idn,
|
|
144
|
+
scope: local.scope
|
|
145
|
+
};
|
|
146
|
+
if (local.default_value != null) {
|
|
147
|
+
req.default_value = local.default_value;
|
|
148
|
+
}
|
|
149
|
+
return req;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function shouldUpdateFlow(local: FlowMetadata, remote: { title: string; description: string | null; default_runner_type?: string }): boolean {
|
|
153
|
+
return (
|
|
154
|
+
normalizeStr(local.title) !== normalizeStr(remote.title) ||
|
|
155
|
+
normalizeStr(local.description) !== normalizeStr(remote.description) ||
|
|
156
|
+
normalizeStr(local.default_runner_type) !== normalizeStr(remote.default_runner_type)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Reconcile one flow's metadata with the platform.
|
|
162
|
+
*
|
|
163
|
+
* @param client authenticated Axios client
|
|
164
|
+
* @param flowId platform flow ID (UUID)
|
|
165
|
+
* @param local parsed FlowMetadata from the customer's local YAML
|
|
166
|
+
* @param remoteFlow flow data fetched from GET /flows/{id} - if null,
|
|
167
|
+
* flow-level updates are skipped (still syncs children).
|
|
168
|
+
* Pass null when caller already knows the GET endpoint
|
|
169
|
+
* will 404 (e.g. legacy data) or wants children-only.
|
|
170
|
+
* @param verbose when true, emits per-operation log lines
|
|
171
|
+
* @param counts shared counter mutated in place across multiple flows
|
|
172
|
+
*/
|
|
173
|
+
export async function syncFlowMetadata(
|
|
174
|
+
client: AxiosInstance,
|
|
175
|
+
flowId: string,
|
|
176
|
+
local: FlowMetadata,
|
|
177
|
+
remoteFlow: { title: string; description: string | null; default_runner_type?: string } | null,
|
|
178
|
+
verbose: boolean,
|
|
179
|
+
counts: FlowMetadataSyncCounts
|
|
180
|
+
): Promise<void> {
|
|
181
|
+
// 1. Flow-level fields (title, description, runner type)
|
|
182
|
+
if (remoteFlow && shouldUpdateFlow(local, remoteFlow)) {
|
|
183
|
+
try {
|
|
184
|
+
const updateRequest: UpdateFlowRequest = {
|
|
185
|
+
idn: local.idn,
|
|
186
|
+
title: local.title,
|
|
187
|
+
description: local.description ?? '',
|
|
188
|
+
default_runner_type: local.default_runner_type,
|
|
189
|
+
default_model: local.default_model
|
|
190
|
+
};
|
|
191
|
+
await updateFlow(client, flowId, updateRequest);
|
|
192
|
+
counts.flowsUpdated++;
|
|
193
|
+
if (verbose) {
|
|
194
|
+
console.log(` ↑ Updated flow metadata: ${local.idn} (title: "${remoteFlow.title}" → "${local.title}")`);
|
|
195
|
+
}
|
|
196
|
+
} catch (error: unknown) {
|
|
197
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
198
|
+
counts.errors.push(`Failed to update flow ${local.idn}: ${msg}`);
|
|
199
|
+
console.error(` ❌ Failed to update flow ${local.idn}: ${msg}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 2. Events
|
|
204
|
+
let remoteEvents: FlowEvent[] = [];
|
|
205
|
+
try {
|
|
206
|
+
remoteEvents = await listFlowEvents(client, flowId);
|
|
207
|
+
} catch (error: unknown) {
|
|
208
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
209
|
+
counts.errors.push(`Failed to list events for flow ${local.idn}: ${msg}`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const localEvents = local.events ?? [];
|
|
214
|
+
const remoteByIdn = new Map(remoteEvents.map(e => [e.idn, e]));
|
|
215
|
+
const localByIdn = new Map(localEvents.map(e => [e.idn, e]));
|
|
216
|
+
|
|
217
|
+
// Create or update events present locally
|
|
218
|
+
for (const localEvent of localEvents) {
|
|
219
|
+
const remote = remoteByIdn.get(localEvent.idn);
|
|
220
|
+
if (!remote) {
|
|
221
|
+
// Create
|
|
222
|
+
try {
|
|
223
|
+
await createFlowEvent(client, flowId, buildEventCreateRequest(localEvent));
|
|
224
|
+
counts.eventsCreated++;
|
|
225
|
+
if (verbose) console.log(` ↑ Created event: ${local.idn}/${localEvent.idn}`);
|
|
226
|
+
} catch (error: unknown) {
|
|
227
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
228
|
+
counts.errors.push(`Failed to create event ${localEvent.idn} in flow ${local.idn}: ${msg}`);
|
|
229
|
+
console.error(` ❌ Failed to create event ${localEvent.idn}: ${msg}`);
|
|
230
|
+
}
|
|
231
|
+
} else if (flowEventDiffers(localEvent, remote)) {
|
|
232
|
+
try {
|
|
233
|
+
await updateFlowEvent(client, remote.id, buildEventUpdateRequest(localEvent));
|
|
234
|
+
counts.eventsUpdated++;
|
|
235
|
+
if (verbose) console.log(` ↑ Updated event: ${local.idn}/${localEvent.idn}`);
|
|
236
|
+
} catch (error: unknown) {
|
|
237
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
238
|
+
counts.errors.push(`Failed to update event ${localEvent.idn} in flow ${local.idn}: ${msg}`);
|
|
239
|
+
console.error(` ❌ Failed to update event ${localEvent.idn}: ${msg}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Delete events present remotely but missing locally
|
|
245
|
+
for (const remoteEvent of remoteEvents) {
|
|
246
|
+
if (!localByIdn.has(remoteEvent.idn)) {
|
|
247
|
+
try {
|
|
248
|
+
await deleteFlowEvent(client, remoteEvent.id);
|
|
249
|
+
counts.eventsDeleted++;
|
|
250
|
+
if (verbose) console.log(` ↑ Deleted event: ${local.idn}/${remoteEvent.idn}`);
|
|
251
|
+
} catch (error: unknown) {
|
|
252
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
253
|
+
counts.errors.push(`Failed to delete event ${remoteEvent.idn} in flow ${local.idn}: ${msg}`);
|
|
254
|
+
console.error(` ❌ Failed to delete event ${remoteEvent.idn}: ${msg}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 3. State fields
|
|
260
|
+
let remoteStates: FlowState[] = [];
|
|
261
|
+
try {
|
|
262
|
+
remoteStates = await listFlowStates(client, flowId);
|
|
263
|
+
} catch (error: unknown) {
|
|
264
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
265
|
+
counts.errors.push(`Failed to list states for flow ${local.idn}: ${msg}`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const localStates = local.state_fields ?? [];
|
|
270
|
+
const remoteStatesByIdn = new Map(remoteStates.map(s => [s.idn, s]));
|
|
271
|
+
const localStatesByIdn = new Map(localStates.map(s => [s.idn, s]));
|
|
272
|
+
|
|
273
|
+
for (const localState of localStates) {
|
|
274
|
+
const remote = remoteStatesByIdn.get(localState.idn);
|
|
275
|
+
if (!remote) {
|
|
276
|
+
try {
|
|
277
|
+
await createFlowState(client, flowId, buildStateCreateRequest(localState));
|
|
278
|
+
counts.statesCreated++;
|
|
279
|
+
if (verbose) console.log(` ↑ Created state: ${local.idn}/${localState.idn}`);
|
|
280
|
+
} catch (error: unknown) {
|
|
281
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
282
|
+
counts.errors.push(`Failed to create state ${localState.idn} in flow ${local.idn}: ${msg}`);
|
|
283
|
+
console.error(` ❌ Failed to create state ${localState.idn}: ${msg}`);
|
|
284
|
+
}
|
|
285
|
+
} else if (flowStateDiffers(localState, remote)) {
|
|
286
|
+
try {
|
|
287
|
+
await updateFlowState(client, remote.id, buildStateUpdateRequest(localState));
|
|
288
|
+
counts.statesUpdated++;
|
|
289
|
+
if (verbose) console.log(` ↑ Updated state: ${local.idn}/${localState.idn}`);
|
|
290
|
+
} catch (error: unknown) {
|
|
291
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
292
|
+
counts.errors.push(`Failed to update state ${localState.idn} in flow ${local.idn}: ${msg}`);
|
|
293
|
+
console.error(` ❌ Failed to update state ${localState.idn}: ${msg}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for (const remoteState of remoteStates) {
|
|
299
|
+
if (!localStatesByIdn.has(remoteState.idn)) {
|
|
300
|
+
try {
|
|
301
|
+
await deleteFlowState(client, remoteState.id);
|
|
302
|
+
counts.statesDeleted++;
|
|
303
|
+
if (verbose) console.log(` ↑ Deleted state: ${local.idn}/${remoteState.idn}`);
|
|
304
|
+
} catch (error: unknown) {
|
|
305
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
306
|
+
counts.errors.push(`Failed to delete state ${remoteState.idn} in flow ${local.idn}: ${msg}`);
|
|
307
|
+
console.error(` ❌ Failed to delete state ${remoteState.idn}: ${msg}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Combined count of operations across all categories.
|
|
315
|
+
*/
|
|
316
|
+
export function totalFlowSyncOps(counts: FlowMetadataSyncCounts): number {
|
|
317
|
+
return (
|
|
318
|
+
counts.flowsUpdated +
|
|
319
|
+
counts.eventsCreated + counts.eventsUpdated + counts.eventsDeleted +
|
|
320
|
+
counts.statesCreated + counts.statesUpdated + counts.statesDeleted
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Human-readable summary line for the push report.
|
|
326
|
+
*/
|
|
327
|
+
export function describeFlowSyncCounts(counts: FlowMetadataSyncCounts): string {
|
|
328
|
+
const parts: string[] = [];
|
|
329
|
+
if (counts.flowsUpdated) parts.push(`${counts.flowsUpdated} flow(s)`);
|
|
330
|
+
if (counts.eventsCreated || counts.eventsUpdated || counts.eventsDeleted) {
|
|
331
|
+
const eventOps: string[] = [];
|
|
332
|
+
if (counts.eventsCreated) eventOps.push(`+${counts.eventsCreated}`);
|
|
333
|
+
if (counts.eventsUpdated) eventOps.push(`~${counts.eventsUpdated}`);
|
|
334
|
+
if (counts.eventsDeleted) eventOps.push(`-${counts.eventsDeleted}`);
|
|
335
|
+
parts.push(`events ${eventOps.join('/')}`);
|
|
336
|
+
}
|
|
337
|
+
if (counts.statesCreated || counts.statesUpdated || counts.statesDeleted) {
|
|
338
|
+
const stateOps: string[] = [];
|
|
339
|
+
if (counts.statesCreated) stateOps.push(`+${counts.statesCreated}`);
|
|
340
|
+
if (counts.statesUpdated) stateOps.push(`~${counts.statesUpdated}`);
|
|
341
|
+
if (counts.statesDeleted) stateOps.push(`-${counts.statesDeleted}`);
|
|
342
|
+
parts.push(`states ${stateOps.join('/')}`);
|
|
343
|
+
}
|
|
344
|
+
return parts.join(', ');
|
|
345
|
+
}
|
package/src/sync/push.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Push operations for changed files
|
|
3
3
|
*/
|
|
4
|
-
import { updateSkill, createAgent, createFlow, createSkill, publishFlow } from '../api.js';
|
|
4
|
+
import { updateSkill, createAgent, createFlow, createSkill, publishFlow, getFlow } from '../api.js';
|
|
5
5
|
import {
|
|
6
6
|
ensureState,
|
|
7
7
|
mapPath,
|
|
8
8
|
skillMetadataPath,
|
|
9
9
|
projectDir,
|
|
10
|
-
agentMetadataPath
|
|
10
|
+
agentMetadataPath,
|
|
11
|
+
flowMetadataPath
|
|
11
12
|
} from '../fsutil.js';
|
|
12
13
|
import {
|
|
13
14
|
validateSkillFolder,
|
|
@@ -21,6 +22,12 @@ import { generateFlowsYaml } from './metadata.js';
|
|
|
21
22
|
import { isProjectMap, isLegacyProjectMap } from './projects.js';
|
|
22
23
|
import { flowsYamlPath } from '../fsutil.js';
|
|
23
24
|
import { pushAllProjectAttributes } from './attributes.js';
|
|
25
|
+
import {
|
|
26
|
+
syncFlowMetadata,
|
|
27
|
+
emptyFlowSyncCounts,
|
|
28
|
+
totalFlowSyncOps,
|
|
29
|
+
describeFlowSyncCounts
|
|
30
|
+
} from './flow-metadata.js';
|
|
24
31
|
import type { AxiosInstance } from 'axios';
|
|
25
32
|
import type {
|
|
26
33
|
ProjectData,
|
|
@@ -506,6 +513,72 @@ export async function pushChanged(client: AxiosInstance, customer: CustomerConfi
|
|
|
506
513
|
}
|
|
507
514
|
}
|
|
508
515
|
|
|
516
|
+
// Sync flow metadata (title, events, state_fields) for any flow whose
|
|
517
|
+
// metadata.yaml hash has changed. This closes the loop on GH issue #3:
|
|
518
|
+
// previously push only updated skill scripts, leaving local edits to
|
|
519
|
+
// flow events and title silently un-synced.
|
|
520
|
+
const flowSyncCounts = emptyFlowSyncCounts();
|
|
521
|
+
for (const [projectIdn, projectData] of Object.entries(projects)) {
|
|
522
|
+
for (const [agentIdn, agentObj] of Object.entries(projectData.agents)) {
|
|
523
|
+
for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
|
|
524
|
+
if (!flowObj.id) continue;
|
|
525
|
+
|
|
526
|
+
const metaPath = flowMetadataPath(customer.idn, projectIdn, agentIdn, flowIdn);
|
|
527
|
+
if (!(await fs.pathExists(metaPath))) continue;
|
|
528
|
+
|
|
529
|
+
const metaContent = await fs.readFile(metaPath, 'utf8');
|
|
530
|
+
const metaHash = sha256(metaContent);
|
|
531
|
+
const oldHash = hashes[metaPath];
|
|
532
|
+
|
|
533
|
+
if (oldHash === metaHash) {
|
|
534
|
+
if (verbose) console.log(` ✓ Flow metadata unchanged: ${flowIdn}`);
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (verbose) console.log(` 🔄 Flow metadata changed, syncing: ${agentIdn}/${flowIdn}`);
|
|
539
|
+
|
|
540
|
+
let localFlow: FlowMetadata;
|
|
541
|
+
try {
|
|
542
|
+
localFlow = yaml.load(metaContent) as FlowMetadata;
|
|
543
|
+
} catch (error) {
|
|
544
|
+
console.error(`❌ Failed to parse flow metadata for ${flowIdn}: ${error instanceof Error ? error.message : String(error)}`);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
let remoteFlow = null;
|
|
549
|
+
try {
|
|
550
|
+
remoteFlow = await getFlow(client, flowObj.id);
|
|
551
|
+
} catch (error: any) {
|
|
552
|
+
// 404 means the flow ID is stale; skip flow-level update but still
|
|
553
|
+
// try to sync children since list endpoints may still work.
|
|
554
|
+
if (verbose) {
|
|
555
|
+
console.log(` ⚠️ Could not GET flow ${flowIdn}: ${error.response?.status ?? error.message}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const opsBefore = totalFlowSyncOps(flowSyncCounts);
|
|
560
|
+
await syncFlowMetadata(client, flowObj.id, localFlow, remoteFlow, verbose, flowSyncCounts);
|
|
561
|
+
const opsAfter = totalFlowSyncOps(flowSyncCounts);
|
|
562
|
+
|
|
563
|
+
if (opsAfter > opsBefore) {
|
|
564
|
+
pushed += (opsAfter - opsBefore);
|
|
565
|
+
metadataChanged = true;
|
|
566
|
+
}
|
|
567
|
+
// Hash is updated regardless of whether ops happened, so we don't
|
|
568
|
+
// re-scan the same untouched flow on the next push.
|
|
569
|
+
newHashes[metaPath] = metaHash;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const totalFlowOps = totalFlowSyncOps(flowSyncCounts);
|
|
575
|
+
if (totalFlowOps > 0) {
|
|
576
|
+
console.log(`↑ Flow metadata synced: ${describeFlowSyncCounts(flowSyncCounts)}`);
|
|
577
|
+
}
|
|
578
|
+
if (flowSyncCounts.errors.length > 0) {
|
|
579
|
+
console.warn(`⚠️ ${flowSyncCounts.errors.length} flow-metadata error(s) during push.`);
|
|
580
|
+
}
|
|
581
|
+
|
|
509
582
|
if (verbose) console.log(`🔄 Scanned ${scanned} files, found ${pushed} changes`);
|
|
510
583
|
|
|
511
584
|
// Push project attributes for all projects
|
package/src/types.ts
CHANGED
|
@@ -521,6 +521,23 @@ export interface CreateFlowEventResponse {
|
|
|
521
521
|
id: string;
|
|
522
522
|
}
|
|
523
523
|
|
|
524
|
+
/**
|
|
525
|
+
* Payload for PATCH /api/v1/designer/flows/events/{eventId}
|
|
526
|
+
*
|
|
527
|
+
* Required fields per probe testing: idn, skill_selector, interrupt_mode.
|
|
528
|
+
* Sending the full event body is the platform's expected shape.
|
|
529
|
+
*/
|
|
530
|
+
export interface UpdateFlowEventRequest {
|
|
531
|
+
idn: string;
|
|
532
|
+
description?: string | null;
|
|
533
|
+
skill_selector: string;
|
|
534
|
+
skill_idn?: string | null;
|
|
535
|
+
state_idn?: string | null;
|
|
536
|
+
interrupt_mode: string;
|
|
537
|
+
integration_idn?: string | null;
|
|
538
|
+
connector_idn?: string | null;
|
|
539
|
+
}
|
|
540
|
+
|
|
524
541
|
export interface CreateFlowStateRequest {
|
|
525
542
|
title: string;
|
|
526
543
|
idn: string;
|
|
@@ -532,6 +549,29 @@ export interface CreateFlowStateResponse {
|
|
|
532
549
|
id: string;
|
|
533
550
|
}
|
|
534
551
|
|
|
552
|
+
/**
|
|
553
|
+
* Payload for PUT /api/v1/designer/flows/states/{stateId}
|
|
554
|
+
*/
|
|
555
|
+
export interface UpdateFlowStateRequest {
|
|
556
|
+
title: string;
|
|
557
|
+
idn: string;
|
|
558
|
+
default_value?: string;
|
|
559
|
+
scope: string;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Payload for PATCH /api/v1/designer/flows/{flowId}
|
|
564
|
+
*
|
|
565
|
+
* Empty body returns 500; the platform requires the full descriptor.
|
|
566
|
+
*/
|
|
567
|
+
export interface UpdateFlowRequest {
|
|
568
|
+
idn: string;
|
|
569
|
+
title: string;
|
|
570
|
+
description?: string;
|
|
571
|
+
default_runner_type: RunnerType;
|
|
572
|
+
default_model: ModelConfig;
|
|
573
|
+
}
|
|
574
|
+
|
|
535
575
|
export interface CreateSkillParameterRequest {
|
|
536
576
|
name: string;
|
|
537
577
|
default_value?: string;
|