newo 3.7.3 → 3.7.4
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
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.7.4] - 2026-06-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **V2 push now creates new skills from inline flow YAML definitions.** Previously the `newo_v2` push path could only update content of skills already known to the local project map; a skill added locally to `{FlowIdn}.yaml` after the last pull was silently ignored, leaving its `.nsl`/`.nslg` script with no way to reach the platform. `V2ProjectSyncStrategy.push()` now reconciles inline skill metadata before iterating script changes: missing remote skills are created via `POST /api/v1/designer/flows/{flowId}/skills`, race-condition duplicates (already-exists on create) fall back to fetching the remote skill via `listFlowSkills` and updating it, and missing skill parameters are filled in via `POST /api/v1/designer/flows/skills/{skillId}/parameters`. The local project map and SHA256 hash store are updated atomically after the reconciliation pass.
|
|
15
|
+
- **Strict model validation before V2 skill writes.** New `assertSkillModelResolved` helper throws a descriptive error ("Set either skill.model.* or flow default_model_idn/default_provider_idn") when neither the inline skill nor the flow declares a model/provider, instead of letting the platform return a generic 4xx with no hint about which YAML field is missing.
|
|
16
|
+
- **33 unit tests** in `test/v2-push-helpers.test.js` covering `isAlreadyExistsApiError`, `assertSkillModelResolved`, `normalizeRunnerType`, `normalizeParameters`, `skillMetadataDiffers`, `buildV2SkillMetadataFromYaml`, and `createMissingSkillParameters` (stubbed API client). Regression-locks the false-positive matcher fix, the model-validation contract, the parameter-creation counting, and the model-key-order false positive.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **`isAlreadyExistsApiError` no longer matches "does not exist".** The previous detector accepted any 400/409/422 response whose body contained the substring `"exist"`, which incorrectly classified "Skill does not exist" / "Flow doesn't exist" errors as duplicates and triggered a spurious reuse fallback. The matcher is now tightened to only `"already exists"` and `"duplicate key"`.
|
|
21
|
+
- **`validate()` reports missing scripts for YAML-declared skills.** Previously `V2ProjectSyncStrategy.validate()` only checked scripts listed in the local map; skills added directly to `{FlowIdn}.yaml` since the last pull were never validated. Validation now walks the YAML when present and surfaces a `Script file not found:` error per skill, matching the new push contract.
|
|
22
|
+
- **Missing-script skills are reported, not silently skipped.** Instead of a `verbose`-only "Skipping skill without local script" log, the push now records `[newo_v2] Missing script for skill {project}/{agent}/{flow}/{skill}: {path}` in the push errors. Failures are isolated per skill: one broken skill no longer aborts the push of every other flow/project in the workspace.
|
|
23
|
+
- **Model validation only runs before actual platform writes.** `assertSkillModelResolved` fires only when a skill is about to be created or its metadata updated — a pre-existing flow YAML without `skill.model.*` / flow `default_model_idn` no longer blocks pushes of unrelated, untouched skills.
|
|
24
|
+
- **Parameter-creation count is no longer inflated by already-existing parameters.** `createMissingSkillParameters` incremented its counter (and logged "Created skill parameter") even when the platform answered "already exists", which spuriously triggered a follow-up `updateSkill` call. Count and log now happen only on successful creation.
|
|
25
|
+
- **`isAlreadyExistsApiError` no longer throws on `null`/`undefined` errors** (optional chaining on `.response`), which previously masked the original failure inside catch handlers.
|
|
26
|
+
- **`skillMetadataDiffers` no longer flags every skill as changed.** The model comparison used `JSON.stringify`, which is key-order-sensitive: the project map stores `{provider_idn, model_idn}` (platform API order) while YAML-built metadata uses `{model_idn, provider_idn}`. Verified against a live account: every push rewrote all 1342 skills. Model is now compared field-by-field and parameters are compared order-insensitively (sorted by name).
|
|
27
|
+
- **Skill parameters are now created explicitly after `createSkill`.** The platform's create endpoint ignores inline `parameters` in the request body (verified against the live platform) — newly created skills silently lost their YAML-declared parameters. The create path now calls `POST /flows/skills/{skillId}/parameters` per parameter, same as the update path.
|
|
28
|
+
|
|
10
29
|
## [3.7.3] - 2026-05-25
|
|
11
30
|
|
|
12
31
|
### Fixed
|
|
@@ -1061,7 +1080,8 @@ Another Item: $Price [Modifiers: modifier3]
|
|
|
1061
1080
|
- GitHub Actions CI/CD integration
|
|
1062
1081
|
- Robust authentication with token refresh
|
|
1063
1082
|
|
|
1064
|
-
[Unreleased]: https://github.com/sabbah13/newo-cli/compare/v3.7.
|
|
1083
|
+
[Unreleased]: https://github.com/sabbah13/newo-cli/compare/v3.7.4...HEAD
|
|
1084
|
+
[3.7.4]: https://github.com/sabbah13/newo-cli/compare/v3.7.3...v3.7.4
|
|
1065
1085
|
[3.7.3]: https://github.com/sabbah13/newo-cli/compare/v3.7.2...v3.7.3
|
|
1066
1086
|
[3.7.2]: https://github.com/sabbah13/newo-cli/compare/v3.7.1...v3.7.2
|
|
1067
1087
|
[3.7.1]: https://github.com/sabbah13/newo-cli/compare/v3.7.0...v3.7.1
|
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
**NEWO CLI** - Professional command-line tool for NEWO AI Agent development. Features **modular architecture**, **IDN-based file management**, and **comprehensive multi-customer support**.
|
|
9
9
|
|
|
10
10
|
Sync NEWO "Project → Agent → Flow → Skills" structure to local files with:
|
|
11
|
+
- 🆕 **V2 skill creation on push** (v3.7.4) - adding a skill inline to a `newo_v2` `{FlowIdn}.yaml` and pushing now creates it on the platform (previously only updates of existing skills worked)
|
|
11
12
|
- 🆕 **Canvas blank-screen hardening** (v3.7.3) - JSON-typed attributes (e.g. Workflow Builder canvas) with Markdown `\_` escapes or structural newlines no longer corrupt the canvas on push ([#7](https://github.com/sabbah13/newo-cli/pull/7))
|
|
12
13
|
- 🆕 **Flow metadata sync** (v3.7.2) - `newo push` now reconciles flow title, events, and state_fields from local `metadata.yaml` to the platform (closes [#3](https://github.com/sabbah13/newo-cli/issues/3))
|
|
13
14
|
- 🆕 **Dual format support** (v3.6.0) - `cli_v1` (native) and `newo_v2` (platform compatible), auto-detected per customer
|
|
@@ -65,6 +65,39 @@ export declare class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta,
|
|
|
65
65
|
* shared syncFlowMetadata routine that calls PATCH/POST/DELETE per child.
|
|
66
66
|
*/
|
|
67
67
|
private pushV2FlowYamlUpdate;
|
|
68
|
+
/**
|
|
69
|
+
* Reconcile inline skill definitions from V2 flow YAML before pushing scripts.
|
|
70
|
+
*
|
|
71
|
+
* V2 keeps skill metadata (model, runner_type, parameters) in the flow YAML,
|
|
72
|
+
* not in a separate skill metadata file. The map only contains the remote IDs
|
|
73
|
+
* from a previous pull, so new local skills must be created before their
|
|
74
|
+
* callers can be published.
|
|
75
|
+
*/
|
|
76
|
+
private syncV2FlowYamlDefinitions;
|
|
77
|
+
private createMissingSkillParameters;
|
|
78
|
+
/**
|
|
79
|
+
* Detect "resource already exists" API errors.
|
|
80
|
+
*
|
|
81
|
+
* Matches only on the precise phrases the platform actually returns
|
|
82
|
+
* ("already exists", "duplicate key"). Loose substrings like "exist"
|
|
83
|
+
* would otherwise sweep up unrelated "does not exist" / "doesn't exist"
|
|
84
|
+
* errors and trigger an incorrect reuse fallback.
|
|
85
|
+
*/
|
|
86
|
+
private isAlreadyExistsApiError;
|
|
87
|
+
private normalizeRunnerType;
|
|
88
|
+
private normalizeParameters;
|
|
89
|
+
/**
|
|
90
|
+
* Fail fast if no model could be resolved for a V2 skill.
|
|
91
|
+
*
|
|
92
|
+
* `buildV2SkillMetadataFromYaml` falls back to empty strings when neither
|
|
93
|
+
* the skill nor the flow declare a model. The platform rejects empty
|
|
94
|
+
* model_idn/provider_idn at creation/update time, but the error it returns
|
|
95
|
+
* is generic — we surface a clearer message before issuing the request.
|
|
96
|
+
*/
|
|
97
|
+
private assertSkillModelResolved;
|
|
98
|
+
private buildV2SkillMetadataFromYaml;
|
|
99
|
+
private skillMetadataDiffers;
|
|
100
|
+
private resolveV2FlowSkillScriptPath;
|
|
68
101
|
/**
|
|
69
102
|
* Push a V2 skill update
|
|
70
103
|
*
|
|
@@ -81,6 +114,7 @@ export declare class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta,
|
|
|
81
114
|
*/
|
|
82
115
|
private publishAllFlows;
|
|
83
116
|
getChanges(customer: CustomerConfig): Promise<ChangeItem<LocalProjectData>[]>;
|
|
117
|
+
private loadLocalV2FlowSkills;
|
|
84
118
|
validate(customer: CustomerConfig, _items: LocalProjectData[]): Promise<ValidationResult>;
|
|
85
119
|
getStatus(customer: CustomerConfig): Promise<StatusSummary>;
|
|
86
120
|
}
|
|
@@ -14,13 +14,13 @@
|
|
|
14
14
|
* skills/{SkillIdn}.nsl|.nslg
|
|
15
15
|
*/
|
|
16
16
|
import fs from 'fs-extra';
|
|
17
|
-
import { listProjects, listAgents, listFlowSkills, listFlowEvents, listFlowStates, updateSkill, publishFlow, getProjectAttributes, getCustomerAttributes, listLibraries, updateLibrarySkill, getFlow, } from '../../../api.js';
|
|
17
|
+
import { listProjects, listAgents, listFlowSkills, listFlowEvents, listFlowStates, createSkill, createSkillParameter, updateSkill, publishFlow, getProjectAttributes, getCustomerAttributes, listLibraries, updateLibrarySkill, getFlow, } from '../../../api.js';
|
|
18
18
|
import { syncFlowMetadata, emptyFlowSyncCounts, totalFlowSyncOps, describeFlowSyncCounts } from '../../../sync/flow-metadata.js';
|
|
19
19
|
import { ensureStateOnly, writeFileSafe, mapPath, } from '../../../fsutil.js';
|
|
20
20
|
import { sha256, saveHashes, loadHashes } from '../../../hash.js';
|
|
21
|
-
import { v2ImportVersionPath, v2ProjectYamlPath, v2AgentYamlPath, v2FlowYamlPath, v2SkillScriptPath, v2SkillRelativePath, v2ProjectAttributesPath, v2CustomerAttributesPath, v2AkbDir, v2AkbPath, v2LibraryYamlPath, v2LibrarySkillScriptPath, v2LibrarySkillRelativePath, } from '../../../format/paths-v2.js';
|
|
21
|
+
import { v2ImportVersionPath, v2ProjectYamlPath, v2AgentDir, v2AgentYamlPath, v2FlowYamlPath, v2SkillScriptPath, v2SkillRelativePath, v2ProjectAttributesPath, v2CustomerAttributesPath, v2AkbDir, v2AkbPath, v2LibraryYamlPath, v2LibrarySkillScriptPath, v2LibrarySkillRelativePath, } from '../../../format/paths-v2.js';
|
|
22
22
|
import { V2_IMPORT_VERSION, } from '../../../format/types.js';
|
|
23
|
-
import { generateV2FlowYaml, generateV2ProjectYaml, generateV2AgentYaml, buildV2InlineSkill, buildV2FlowEvent, buildV2StateField,
|
|
23
|
+
import { generateV2FlowYaml, generateV2ProjectYaml, generateV2AgentYaml, parseV2FlowYaml, buildV2InlineSkill, buildV2FlowEvent, buildV2StateField, } from '../../../format/v2-yaml.js';
|
|
24
24
|
import { isContentDifferent } from '../../../sync/skill-files.js';
|
|
25
25
|
import yaml from 'js-yaml';
|
|
26
26
|
import { patchYamlToPyyaml } from '../../../format/yaml-patch.js';
|
|
@@ -438,8 +438,15 @@ export class V2ProjectSyncStrategy {
|
|
|
438
438
|
return result;
|
|
439
439
|
}
|
|
440
440
|
const mapData = await fs.readJson(mapFile);
|
|
441
|
+
const metadataSync = await this.syncV2FlowYamlDefinitions(client, customer, mapData, newHashes);
|
|
442
|
+
result.created += metadataSync.created;
|
|
443
|
+
result.updated += metadataSync.updated;
|
|
444
|
+
result.errors.push(...metadataSync.errors);
|
|
441
445
|
for (const change of changes) {
|
|
442
446
|
try {
|
|
447
|
+
if (metadataSync.syncedPaths.has(change.path)) {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
443
450
|
if (change.operation === 'modified') {
|
|
444
451
|
// V2 flow YAML: newo_customers/{cust}/{proj}/agents/{agent}/flows/{flow}/{flow}.yaml
|
|
445
452
|
// The flow YAML carries title, events, and state_fields inline, so
|
|
@@ -461,6 +468,9 @@ export class V2ProjectSyncStrategy {
|
|
|
461
468
|
result.errors.push(`Failed to push ${change.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
462
469
|
}
|
|
463
470
|
}
|
|
471
|
+
if (metadataSync.created > 0 || metadataSync.updated > 0) {
|
|
472
|
+
await writeFileSafe(mapFile, JSON.stringify(mapData, null, 2));
|
|
473
|
+
}
|
|
464
474
|
await saveHashes(newHashes, customer.idn);
|
|
465
475
|
if (result.created > 0 || result.updated > 0) {
|
|
466
476
|
await this.publishAllFlows(client, mapData);
|
|
@@ -564,6 +574,245 @@ export class V2ProjectSyncStrategy {
|
|
|
564
574
|
newHashes[change.path] = sha256(content);
|
|
565
575
|
return total;
|
|
566
576
|
}
|
|
577
|
+
/**
|
|
578
|
+
* Reconcile inline skill definitions from V2 flow YAML before pushing scripts.
|
|
579
|
+
*
|
|
580
|
+
* V2 keeps skill metadata (model, runner_type, parameters) in the flow YAML,
|
|
581
|
+
* not in a separate skill metadata file. The map only contains the remote IDs
|
|
582
|
+
* from a previous pull, so new local skills must be created before their
|
|
583
|
+
* callers can be published.
|
|
584
|
+
*/
|
|
585
|
+
async syncV2FlowYamlDefinitions(client, customer, mapData, newHashes) {
|
|
586
|
+
let created = 0;
|
|
587
|
+
let updated = 0;
|
|
588
|
+
const syncedPaths = new Set();
|
|
589
|
+
const errors = [];
|
|
590
|
+
for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
|
|
591
|
+
for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
|
|
592
|
+
for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
|
|
593
|
+
const flowYamlPath = v2FlowYamlPath(customer.idn, projectIdn, agentIdn, flowIdn);
|
|
594
|
+
if (!(await fs.pathExists(flowYamlPath))) {
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
let flowDef;
|
|
598
|
+
try {
|
|
599
|
+
flowDef = await parseV2FlowYaml(flowYamlPath);
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
this.logger.warn(`[newo_v2] Failed to parse flow YAML ${flowYamlPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
for (const skill of flowDef.skills || []) {
|
|
606
|
+
const skillLocator = `${projectIdn}/${agentIdn}/${flowIdn}/${skill.idn}`;
|
|
607
|
+
// Per-skill failure isolation: one broken skill must not abort the
|
|
608
|
+
// push of every other project/flow in the workspace.
|
|
609
|
+
try {
|
|
610
|
+
const runnerType = this.normalizeRunnerType(skill.runner_type);
|
|
611
|
+
const scriptPath = await this.resolveV2FlowSkillScriptPath(customer.idn, projectIdn, agentIdn, flowIdn, skill.idn, runnerType, skill.prompt_script);
|
|
612
|
+
if (!(await fs.pathExists(scriptPath))) {
|
|
613
|
+
errors.push(`[newo_v2] Missing script for skill ${skillLocator}: ${scriptPath}`);
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const content = await fs.readFile(scriptPath, 'utf8');
|
|
617
|
+
const localMetadata = this.buildV2SkillMetadataFromYaml(skill, flowDef, runnerType, flowData.skills[skill.idn]);
|
|
618
|
+
const existingSkill = flowData.skills[skill.idn];
|
|
619
|
+
if (!existingSkill) {
|
|
620
|
+
this.assertSkillModelResolved(localMetadata, skillLocator);
|
|
621
|
+
try {
|
|
622
|
+
const createdSkill = await createSkill(client, flowData.id, {
|
|
623
|
+
idn: localMetadata.idn,
|
|
624
|
+
title: localMetadata.title,
|
|
625
|
+
prompt_script: content,
|
|
626
|
+
runner_type: localMetadata.runner_type,
|
|
627
|
+
model: localMetadata.model,
|
|
628
|
+
parameters: localMetadata.parameters,
|
|
629
|
+
path: localMetadata.path || ''
|
|
630
|
+
});
|
|
631
|
+
// The create endpoint ignores inline `parameters` (verified
|
|
632
|
+
// against the live platform) — create them explicitly.
|
|
633
|
+
await this.createMissingSkillParameters(client, { ...localMetadata, id: createdSkill.id, parameters: [] }, localMetadata);
|
|
634
|
+
flowData.skills[skill.idn] = {
|
|
635
|
+
...localMetadata,
|
|
636
|
+
id: createdSkill.id
|
|
637
|
+
};
|
|
638
|
+
newHashes[scriptPath] = sha256(content);
|
|
639
|
+
syncedPaths.add(scriptPath);
|
|
640
|
+
created++;
|
|
641
|
+
this.logger.info(`[newo_v2] Created skill: ${flowIdn}/${skill.idn}`);
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
if (!this.isAlreadyExistsApiError(error)) {
|
|
645
|
+
throw error;
|
|
646
|
+
}
|
|
647
|
+
const remoteSkills = await listFlowSkills(client, flowData.id);
|
|
648
|
+
const remoteSkill = remoteSkills.find(s => s.idn === skill.idn);
|
|
649
|
+
if (!remoteSkill) {
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
const remoteMetadata = {
|
|
653
|
+
id: remoteSkill.id,
|
|
654
|
+
idn: remoteSkill.idn,
|
|
655
|
+
title: remoteSkill.title,
|
|
656
|
+
runner_type: remoteSkill.runner_type,
|
|
657
|
+
model: remoteSkill.model,
|
|
658
|
+
parameters: this.normalizeParameters(remoteSkill.parameters),
|
|
659
|
+
path: remoteSkill.path
|
|
660
|
+
};
|
|
661
|
+
await this.createMissingSkillParameters(client, remoteMetadata, localMetadata);
|
|
662
|
+
await updateSkill(client, {
|
|
663
|
+
id: remoteSkill.id,
|
|
664
|
+
title: localMetadata.title,
|
|
665
|
+
idn: localMetadata.idn,
|
|
666
|
+
prompt_script: content,
|
|
667
|
+
runner_type: localMetadata.runner_type,
|
|
668
|
+
model: localMetadata.model,
|
|
669
|
+
parameters: localMetadata.parameters,
|
|
670
|
+
path: remoteSkill.path || localMetadata.path
|
|
671
|
+
});
|
|
672
|
+
flowData.skills[skill.idn] = {
|
|
673
|
+
...localMetadata,
|
|
674
|
+
id: remoteSkill.id,
|
|
675
|
+
path: remoteSkill.path || localMetadata.path
|
|
676
|
+
};
|
|
677
|
+
newHashes[scriptPath] = sha256(content);
|
|
678
|
+
syncedPaths.add(scriptPath);
|
|
679
|
+
updated++;
|
|
680
|
+
this.logger.info(`[newo_v2] Reused existing skill: ${flowIdn}/${skill.idn}`);
|
|
681
|
+
}
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
const createdParameters = await this.createMissingSkillParameters(client, existingSkill, localMetadata);
|
|
685
|
+
if (createdParameters > 0 || this.skillMetadataDiffers(existingSkill, localMetadata)) {
|
|
686
|
+
this.assertSkillModelResolved(localMetadata, skillLocator);
|
|
687
|
+
await updateSkill(client, {
|
|
688
|
+
id: existingSkill.id,
|
|
689
|
+
title: localMetadata.title,
|
|
690
|
+
idn: localMetadata.idn,
|
|
691
|
+
prompt_script: content,
|
|
692
|
+
runner_type: localMetadata.runner_type,
|
|
693
|
+
model: localMetadata.model,
|
|
694
|
+
parameters: localMetadata.parameters,
|
|
695
|
+
path: localMetadata.path
|
|
696
|
+
});
|
|
697
|
+
flowData.skills[skill.idn] = {
|
|
698
|
+
...localMetadata,
|
|
699
|
+
id: existingSkill.id
|
|
700
|
+
};
|
|
701
|
+
newHashes[scriptPath] = sha256(content);
|
|
702
|
+
syncedPaths.add(scriptPath);
|
|
703
|
+
updated++;
|
|
704
|
+
this.logger.info(`[newo_v2] Updated skill metadata: ${flowIdn}/${skill.idn}`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
errors.push(`Failed to sync skill ${skillLocator}: ${error instanceof Error ? error.message : String(error)}`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return { created, updated, syncedPaths, errors };
|
|
715
|
+
}
|
|
716
|
+
async createMissingSkillParameters(client, existing, local) {
|
|
717
|
+
const existingNames = new Set(this.normalizeParameters(existing.parameters).map(p => p.name));
|
|
718
|
+
let created = 0;
|
|
719
|
+
for (const parameter of local.parameters) {
|
|
720
|
+
if (existingNames.has(parameter.name)) {
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
try {
|
|
724
|
+
await createSkillParameter(client, existing.id, {
|
|
725
|
+
name: parameter.name,
|
|
726
|
+
default_value: parameter.default_value ?? ''
|
|
727
|
+
});
|
|
728
|
+
created++;
|
|
729
|
+
this.logger.info(`[newo_v2] Created skill parameter: ${local.idn}/${parameter.name}`);
|
|
730
|
+
}
|
|
731
|
+
catch (error) {
|
|
732
|
+
if (!this.isAlreadyExistsApiError(error)) {
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
existingNames.add(parameter.name);
|
|
737
|
+
}
|
|
738
|
+
return created;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Detect "resource already exists" API errors.
|
|
742
|
+
*
|
|
743
|
+
* Matches only on the precise phrases the platform actually returns
|
|
744
|
+
* ("already exists", "duplicate key"). Loose substrings like "exist"
|
|
745
|
+
* would otherwise sweep up unrelated "does not exist" / "doesn't exist"
|
|
746
|
+
* errors and trigger an incorrect reuse fallback.
|
|
747
|
+
*/
|
|
748
|
+
isAlreadyExistsApiError(error) {
|
|
749
|
+
const response = error?.response;
|
|
750
|
+
const status = response?.status;
|
|
751
|
+
if (status !== 400 && status !== 409 && status !== 422) {
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
const haystack = JSON.stringify(response?.data ?? (error instanceof Error ? error.message : String(error))).toLowerCase();
|
|
755
|
+
return haystack.includes('already exists') || haystack.includes('duplicate key');
|
|
756
|
+
}
|
|
757
|
+
normalizeRunnerType(runnerType) {
|
|
758
|
+
return runnerType === 'nsl' ? 'nsl' : 'guidance';
|
|
759
|
+
}
|
|
760
|
+
normalizeParameters(parameters) {
|
|
761
|
+
return (parameters || []).map(p => ({
|
|
762
|
+
name: p.name,
|
|
763
|
+
default_value: p.default_value ?? ''
|
|
764
|
+
}));
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Fail fast if no model could be resolved for a V2 skill.
|
|
768
|
+
*
|
|
769
|
+
* `buildV2SkillMetadataFromYaml` falls back to empty strings when neither
|
|
770
|
+
* the skill nor the flow declare a model. The platform rejects empty
|
|
771
|
+
* model_idn/provider_idn at creation/update time, but the error it returns
|
|
772
|
+
* is generic — we surface a clearer message before issuing the request.
|
|
773
|
+
*/
|
|
774
|
+
assertSkillModelResolved(metadata, locator) {
|
|
775
|
+
if (!metadata.model.model_idn || !metadata.model.provider_idn) {
|
|
776
|
+
throw new Error(`[newo_v2] Cannot resolve model for skill ${locator}: ` +
|
|
777
|
+
`model_idn="${metadata.model.model_idn}", provider_idn="${metadata.model.provider_idn}". ` +
|
|
778
|
+
`Set either skill.model.* or flow default_model_idn/default_provider_idn in the flow YAML.`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
buildV2SkillMetadataFromYaml(skill, flowDef, runnerType, existing) {
|
|
782
|
+
return {
|
|
783
|
+
id: existing?.id || '',
|
|
784
|
+
idn: skill.idn,
|
|
785
|
+
title: skill.title || '',
|
|
786
|
+
runner_type: runnerType,
|
|
787
|
+
model: {
|
|
788
|
+
model_idn: skill.model?.model_idn || flowDef.default_model_idn || '',
|
|
789
|
+
provider_idn: skill.model?.provider_idn || flowDef.default_provider_idn || ''
|
|
790
|
+
},
|
|
791
|
+
parameters: this.normalizeParameters(skill.parameters),
|
|
792
|
+
path: existing?.path || ''
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
skillMetadataDiffers(existing, local) {
|
|
796
|
+
// Compare model/parameters field-by-field, never via JSON.stringify of the
|
|
797
|
+
// raw objects: the map stores model keys in platform API order
|
|
798
|
+
// (provider_idn first) while YAML-built metadata uses model_idn first, and
|
|
799
|
+
// a key-order-sensitive comparison flags every skill as changed.
|
|
800
|
+
const paramsKey = (params) => JSON.stringify(this.normalizeParameters(params).sort((a, b) => a.name.localeCompare(b.name)));
|
|
801
|
+
return (existing.title !== local.title ||
|
|
802
|
+
existing.runner_type !== local.runner_type ||
|
|
803
|
+
existing.model.model_idn !== local.model.model_idn ||
|
|
804
|
+
existing.model.provider_idn !== local.model.provider_idn ||
|
|
805
|
+
paramsKey(existing.parameters) !== paramsKey(local.parameters));
|
|
806
|
+
}
|
|
807
|
+
async resolveV2FlowSkillScriptPath(customerIdn, projectIdn, agentIdn, flowIdn, skillIdn, runnerType, promptScript) {
|
|
808
|
+
if (promptScript) {
|
|
809
|
+
const fromPromptScript = `${v2AgentDir(customerIdn, projectIdn, agentIdn)}/${promptScript}`;
|
|
810
|
+
if (await fs.pathExists(fromPromptScript)) {
|
|
811
|
+
return fromPromptScript;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return v2SkillScriptPath(customerIdn, projectIdn, agentIdn, flowIdn, skillIdn, runnerType);
|
|
815
|
+
}
|
|
567
816
|
/**
|
|
568
817
|
* Push a V2 skill update
|
|
569
818
|
*
|
|
@@ -686,8 +935,18 @@ export class V2ProjectSyncStrategy {
|
|
|
686
935
|
}
|
|
687
936
|
}
|
|
688
937
|
for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
|
|
689
|
-
|
|
690
|
-
|
|
938
|
+
const flowYamlSkills = await this.loadLocalV2FlowSkills(customer.idn, projectIdn, agentIdn, flowIdn);
|
|
939
|
+
const skillIdns = new Set([
|
|
940
|
+
...Object.keys(flowData.skills),
|
|
941
|
+
...flowYamlSkills.keys()
|
|
942
|
+
]);
|
|
943
|
+
for (const skillIdn of skillIdns) {
|
|
944
|
+
const yamlSkill = flowYamlSkills.get(skillIdn);
|
|
945
|
+
const skillMeta = flowData.skills[skillIdn];
|
|
946
|
+
const runnerType = this.normalizeRunnerType(yamlSkill?.runner_type || skillMeta?.runner_type);
|
|
947
|
+
const scriptPath = yamlSkill
|
|
948
|
+
? await this.resolveV2FlowSkillScriptPath(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn, runnerType, yamlSkill.prompt_script)
|
|
949
|
+
: v2SkillScriptPath(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn, runnerType);
|
|
691
950
|
if (await fs.pathExists(scriptPath)) {
|
|
692
951
|
const content = await fs.readFile(scriptPath, 'utf8');
|
|
693
952
|
const currentHash = sha256(content);
|
|
@@ -726,6 +985,19 @@ export class V2ProjectSyncStrategy {
|
|
|
726
985
|
}
|
|
727
986
|
return changes;
|
|
728
987
|
}
|
|
988
|
+
async loadLocalV2FlowSkills(customerIdn, projectIdn, agentIdn, flowIdn) {
|
|
989
|
+
const flowYamlPath = v2FlowYamlPath(customerIdn, projectIdn, agentIdn, flowIdn);
|
|
990
|
+
if (!(await fs.pathExists(flowYamlPath))) {
|
|
991
|
+
return new Map();
|
|
992
|
+
}
|
|
993
|
+
try {
|
|
994
|
+
const flowDef = await parseV2FlowYaml(flowYamlPath);
|
|
995
|
+
return new Map((flowDef.skills || []).map(skill => [skill.idn, skill]));
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
return new Map();
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
729
1001
|
async validate(customer, _items) {
|
|
730
1002
|
const errors = [];
|
|
731
1003
|
const mapFile = mapPath(customer.idn);
|
|
@@ -741,7 +1013,46 @@ export class V2ProjectSyncStrategy {
|
|
|
741
1013
|
for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
|
|
742
1014
|
for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
|
|
743
1015
|
for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
|
|
1016
|
+
const flowYamlPath = v2FlowYamlPath(customer.idn, projectIdn, agentIdn, flowIdn);
|
|
1017
|
+
let localYamlSkills;
|
|
1018
|
+
if (await fs.pathExists(flowYamlPath)) {
|
|
1019
|
+
try {
|
|
1020
|
+
const flowDef = await parseV2FlowYaml(flowYamlPath);
|
|
1021
|
+
localYamlSkills = new Map((flowDef.skills || []).map(s => [s.idn, s]));
|
|
1022
|
+
const skillIdns = new Set([
|
|
1023
|
+
...Object.keys(flowData.skills),
|
|
1024
|
+
...localYamlSkills.keys()
|
|
1025
|
+
]);
|
|
1026
|
+
for (const skillIdn of skillIdns) {
|
|
1027
|
+
const localYamlSkill = localYamlSkills.get(skillIdn);
|
|
1028
|
+
const skillMeta = flowData.skills[skillIdn];
|
|
1029
|
+
if (!localYamlSkill) {
|
|
1030
|
+
errors.push({
|
|
1031
|
+
field: `skill.${skillIdn}`,
|
|
1032
|
+
message: `Skill exists in project map but is missing from flow YAML: ${flowYamlPath}`,
|
|
1033
|
+
path: flowYamlPath
|
|
1034
|
+
});
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
const runnerType = this.normalizeRunnerType(localYamlSkill.runner_type || skillMeta?.runner_type);
|
|
1038
|
+
const scriptPath = await this.resolveV2FlowSkillScriptPath(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn, runnerType, localYamlSkill.prompt_script);
|
|
1039
|
+
if (!(await fs.pathExists(scriptPath))) {
|
|
1040
|
+
errors.push({
|
|
1041
|
+
field: `skill.${localYamlSkill.idn}`,
|
|
1042
|
+
message: `Script file not found: ${scriptPath}`,
|
|
1043
|
+
path: scriptPath
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
localYamlSkills = undefined;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
744
1052
|
for (const [skillIdn, skillMeta] of Object.entries(flowData.skills)) {
|
|
1053
|
+
if (localYamlSkills) {
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
745
1056
|
const scriptPath = v2SkillScriptPath(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
|
|
746
1057
|
if (!(await fs.pathExists(scriptPath))) {
|
|
747
1058
|
errors.push({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "newo",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.4",
|
|
4
4
|
"description": "NEWO CLI: Professional command-line tool with modular architecture for NEWO AI Agent development. Features account migration, integration management, webhook automation, AKB knowledge base, project attributes, sandbox testing, IDN-based file management, real-time progress tracking, intelligent sync operations, and comprehensive multi-customer support.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -46,6 +46,8 @@ import {
|
|
|
46
46
|
listFlowSkills,
|
|
47
47
|
listFlowEvents,
|
|
48
48
|
listFlowStates,
|
|
49
|
+
createSkill,
|
|
50
|
+
createSkillParameter,
|
|
49
51
|
updateSkill,
|
|
50
52
|
publishFlow,
|
|
51
53
|
getProjectAttributes,
|
|
@@ -70,6 +72,7 @@ import { sha256, saveHashes, loadHashes } from '../../../hash.js';
|
|
|
70
72
|
import {
|
|
71
73
|
v2ImportVersionPath,
|
|
72
74
|
v2ProjectYamlPath,
|
|
75
|
+
v2AgentDir,
|
|
73
76
|
v2AgentYamlPath,
|
|
74
77
|
v2FlowYamlPath,
|
|
75
78
|
v2SkillScriptPath,
|
|
@@ -89,10 +92,10 @@ import {
|
|
|
89
92
|
generateV2FlowYaml,
|
|
90
93
|
generateV2ProjectYaml,
|
|
91
94
|
generateV2AgentYaml,
|
|
95
|
+
parseV2FlowYaml,
|
|
92
96
|
buildV2InlineSkill,
|
|
93
97
|
buildV2FlowEvent,
|
|
94
98
|
buildV2StateField,
|
|
95
|
-
parseV2FlowYaml,
|
|
96
99
|
type V2InlineSkill,
|
|
97
100
|
type V2FlowEvent,
|
|
98
101
|
type V2StateField,
|
|
@@ -100,6 +103,7 @@ import {
|
|
|
100
103
|
import { isContentDifferent } from '../../../sync/skill-files.js';
|
|
101
104
|
import yaml from 'js-yaml';
|
|
102
105
|
import { patchYamlToPyyaml } from '../../../format/yaml-patch.js';
|
|
106
|
+
import type { RunnerType, SkillParameter } from '../../../types.js';
|
|
103
107
|
|
|
104
108
|
/**
|
|
105
109
|
* V2ProjectSyncStrategy - same API, newo_v2 file layout
|
|
@@ -655,9 +659,17 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
655
659
|
}
|
|
656
660
|
|
|
657
661
|
const mapData = await fs.readJson(mapFile) as ProjectMap;
|
|
662
|
+
const metadataSync = await this.syncV2FlowYamlDefinitions(client, customer, mapData, newHashes);
|
|
663
|
+
result.created += metadataSync.created;
|
|
664
|
+
result.updated += metadataSync.updated;
|
|
665
|
+
result.errors.push(...metadataSync.errors);
|
|
658
666
|
|
|
659
667
|
for (const change of changes) {
|
|
660
668
|
try {
|
|
669
|
+
if (metadataSync.syncedPaths.has(change.path)) {
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
|
|
661
673
|
if (change.operation === 'modified') {
|
|
662
674
|
// V2 flow YAML: newo_customers/{cust}/{proj}/agents/{agent}/flows/{flow}/{flow}.yaml
|
|
663
675
|
// The flow YAML carries title, events, and state_fields inline, so
|
|
@@ -682,6 +694,10 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
682
694
|
}
|
|
683
695
|
}
|
|
684
696
|
|
|
697
|
+
if (metadataSync.created > 0 || metadataSync.updated > 0) {
|
|
698
|
+
await writeFileSafe(mapFile, JSON.stringify(mapData, null, 2));
|
|
699
|
+
}
|
|
700
|
+
|
|
685
701
|
await saveHashes(newHashes, customer.idn);
|
|
686
702
|
|
|
687
703
|
if (result.created > 0 || result.updated > 0) {
|
|
@@ -800,6 +816,321 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
800
816
|
return total;
|
|
801
817
|
}
|
|
802
818
|
|
|
819
|
+
/**
|
|
820
|
+
* Reconcile inline skill definitions from V2 flow YAML before pushing scripts.
|
|
821
|
+
*
|
|
822
|
+
* V2 keeps skill metadata (model, runner_type, parameters) in the flow YAML,
|
|
823
|
+
* not in a separate skill metadata file. The map only contains the remote IDs
|
|
824
|
+
* from a previous pull, so new local skills must be created before their
|
|
825
|
+
* callers can be published.
|
|
826
|
+
*/
|
|
827
|
+
private async syncV2FlowYamlDefinitions(
|
|
828
|
+
client: AxiosInstance,
|
|
829
|
+
customer: CustomerConfig,
|
|
830
|
+
mapData: ProjectMap,
|
|
831
|
+
newHashes: HashStore
|
|
832
|
+
): Promise<{ created: number; updated: number; syncedPaths: Set<string>; errors: string[] }> {
|
|
833
|
+
let created = 0;
|
|
834
|
+
let updated = 0;
|
|
835
|
+
const syncedPaths = new Set<string>();
|
|
836
|
+
const errors: string[] = [];
|
|
837
|
+
|
|
838
|
+
for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
|
|
839
|
+
for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
|
|
840
|
+
for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
|
|
841
|
+
const flowYamlPath = v2FlowYamlPath(customer.idn, projectIdn, agentIdn, flowIdn);
|
|
842
|
+
if (!(await fs.pathExists(flowYamlPath))) {
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
let flowDef;
|
|
847
|
+
try {
|
|
848
|
+
flowDef = await parseV2FlowYaml(flowYamlPath);
|
|
849
|
+
} catch (error) {
|
|
850
|
+
this.logger.warn(
|
|
851
|
+
`[newo_v2] Failed to parse flow YAML ${flowYamlPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
852
|
+
);
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
for (const skill of flowDef.skills || []) {
|
|
857
|
+
const skillLocator = `${projectIdn}/${agentIdn}/${flowIdn}/${skill.idn}`;
|
|
858
|
+
// Per-skill failure isolation: one broken skill must not abort the
|
|
859
|
+
// push of every other project/flow in the workspace.
|
|
860
|
+
try {
|
|
861
|
+
const runnerType = this.normalizeRunnerType(skill.runner_type);
|
|
862
|
+
const scriptPath = await this.resolveV2FlowSkillScriptPath(
|
|
863
|
+
customer.idn,
|
|
864
|
+
projectIdn,
|
|
865
|
+
agentIdn,
|
|
866
|
+
flowIdn,
|
|
867
|
+
skill.idn,
|
|
868
|
+
runnerType,
|
|
869
|
+
skill.prompt_script
|
|
870
|
+
);
|
|
871
|
+
|
|
872
|
+
if (!(await fs.pathExists(scriptPath))) {
|
|
873
|
+
errors.push(
|
|
874
|
+
`[newo_v2] Missing script for skill ${skillLocator}: ${scriptPath}`
|
|
875
|
+
);
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const content = await fs.readFile(scriptPath, 'utf8');
|
|
880
|
+
const localMetadata = this.buildV2SkillMetadataFromYaml(skill, flowDef, runnerType, flowData.skills[skill.idn]);
|
|
881
|
+
const existingSkill = flowData.skills[skill.idn];
|
|
882
|
+
|
|
883
|
+
if (!existingSkill) {
|
|
884
|
+
this.assertSkillModelResolved(localMetadata, skillLocator);
|
|
885
|
+
try {
|
|
886
|
+
const createdSkill = await createSkill(client, flowData.id, {
|
|
887
|
+
idn: localMetadata.idn,
|
|
888
|
+
title: localMetadata.title,
|
|
889
|
+
prompt_script: content,
|
|
890
|
+
runner_type: localMetadata.runner_type,
|
|
891
|
+
model: localMetadata.model,
|
|
892
|
+
parameters: localMetadata.parameters,
|
|
893
|
+
path: localMetadata.path || ''
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
// The create endpoint ignores inline `parameters` (verified
|
|
897
|
+
// against the live platform) — create them explicitly.
|
|
898
|
+
await this.createMissingSkillParameters(
|
|
899
|
+
client,
|
|
900
|
+
{ ...localMetadata, id: createdSkill.id, parameters: [] },
|
|
901
|
+
localMetadata
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
flowData.skills[skill.idn] = {
|
|
905
|
+
...localMetadata,
|
|
906
|
+
id: createdSkill.id
|
|
907
|
+
};
|
|
908
|
+
newHashes[scriptPath] = sha256(content);
|
|
909
|
+
syncedPaths.add(scriptPath);
|
|
910
|
+
created++;
|
|
911
|
+
this.logger.info(`[newo_v2] Created skill: ${flowIdn}/${skill.idn}`);
|
|
912
|
+
} catch (error) {
|
|
913
|
+
if (!this.isAlreadyExistsApiError(error)) {
|
|
914
|
+
throw error;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const remoteSkills = await listFlowSkills(client, flowData.id);
|
|
918
|
+
const remoteSkill = remoteSkills.find(s => s.idn === skill.idn);
|
|
919
|
+
if (!remoteSkill) {
|
|
920
|
+
throw error;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const remoteMetadata: SkillMetadata = {
|
|
924
|
+
id: remoteSkill.id,
|
|
925
|
+
idn: remoteSkill.idn,
|
|
926
|
+
title: remoteSkill.title,
|
|
927
|
+
runner_type: remoteSkill.runner_type,
|
|
928
|
+
model: remoteSkill.model,
|
|
929
|
+
parameters: this.normalizeParameters(remoteSkill.parameters),
|
|
930
|
+
path: remoteSkill.path
|
|
931
|
+
};
|
|
932
|
+
await this.createMissingSkillParameters(client, remoteMetadata, localMetadata);
|
|
933
|
+
await updateSkill(client, {
|
|
934
|
+
id: remoteSkill.id,
|
|
935
|
+
title: localMetadata.title,
|
|
936
|
+
idn: localMetadata.idn,
|
|
937
|
+
prompt_script: content,
|
|
938
|
+
runner_type: localMetadata.runner_type,
|
|
939
|
+
model: localMetadata.model,
|
|
940
|
+
parameters: localMetadata.parameters,
|
|
941
|
+
path: remoteSkill.path || localMetadata.path
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
flowData.skills[skill.idn] = {
|
|
945
|
+
...localMetadata,
|
|
946
|
+
id: remoteSkill.id,
|
|
947
|
+
path: remoteSkill.path || localMetadata.path
|
|
948
|
+
};
|
|
949
|
+
newHashes[scriptPath] = sha256(content);
|
|
950
|
+
syncedPaths.add(scriptPath);
|
|
951
|
+
updated++;
|
|
952
|
+
this.logger.info(`[newo_v2] Reused existing skill: ${flowIdn}/${skill.idn}`);
|
|
953
|
+
}
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const createdParameters = await this.createMissingSkillParameters(client, existingSkill, localMetadata);
|
|
958
|
+
|
|
959
|
+
if (createdParameters > 0 || this.skillMetadataDiffers(existingSkill, localMetadata)) {
|
|
960
|
+
this.assertSkillModelResolved(localMetadata, skillLocator);
|
|
961
|
+
await updateSkill(client, {
|
|
962
|
+
id: existingSkill.id,
|
|
963
|
+
title: localMetadata.title,
|
|
964
|
+
idn: localMetadata.idn,
|
|
965
|
+
prompt_script: content,
|
|
966
|
+
runner_type: localMetadata.runner_type,
|
|
967
|
+
model: localMetadata.model,
|
|
968
|
+
parameters: localMetadata.parameters,
|
|
969
|
+
path: localMetadata.path
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
flowData.skills[skill.idn] = {
|
|
973
|
+
...localMetadata,
|
|
974
|
+
id: existingSkill.id
|
|
975
|
+
};
|
|
976
|
+
newHashes[scriptPath] = sha256(content);
|
|
977
|
+
syncedPaths.add(scriptPath);
|
|
978
|
+
updated++;
|
|
979
|
+
this.logger.info(`[newo_v2] Updated skill metadata: ${flowIdn}/${skill.idn}`);
|
|
980
|
+
}
|
|
981
|
+
} catch (error) {
|
|
982
|
+
errors.push(
|
|
983
|
+
`Failed to sync skill ${skillLocator}: ${error instanceof Error ? error.message : String(error)}`
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return { created, updated, syncedPaths, errors };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
private async createMissingSkillParameters(
|
|
995
|
+
client: AxiosInstance,
|
|
996
|
+
existing: SkillMetadata,
|
|
997
|
+
local: SkillMetadata
|
|
998
|
+
): Promise<number> {
|
|
999
|
+
const existingNames = new Set(this.normalizeParameters(existing.parameters).map(p => p.name));
|
|
1000
|
+
let created = 0;
|
|
1001
|
+
|
|
1002
|
+
for (const parameter of local.parameters) {
|
|
1003
|
+
if (existingNames.has(parameter.name)) {
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
try {
|
|
1008
|
+
await createSkillParameter(client, existing.id, {
|
|
1009
|
+
name: parameter.name,
|
|
1010
|
+
default_value: parameter.default_value ?? ''
|
|
1011
|
+
});
|
|
1012
|
+
created++;
|
|
1013
|
+
this.logger.info(`[newo_v2] Created skill parameter: ${local.idn}/${parameter.name}`);
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
if (!this.isAlreadyExistsApiError(error)) {
|
|
1016
|
+
throw error;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
existingNames.add(parameter.name);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
return created;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Detect "resource already exists" API errors.
|
|
1027
|
+
*
|
|
1028
|
+
* Matches only on the precise phrases the platform actually returns
|
|
1029
|
+
* ("already exists", "duplicate key"). Loose substrings like "exist"
|
|
1030
|
+
* would otherwise sweep up unrelated "does not exist" / "doesn't exist"
|
|
1031
|
+
* errors and trigger an incorrect reuse fallback.
|
|
1032
|
+
*/
|
|
1033
|
+
private isAlreadyExistsApiError(error: unknown): boolean {
|
|
1034
|
+
const response = (error as { response?: { status?: number; data?: unknown } } | null | undefined)?.response;
|
|
1035
|
+
const status = response?.status;
|
|
1036
|
+
if (status !== 400 && status !== 409 && status !== 422) {
|
|
1037
|
+
return false;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const haystack = JSON.stringify(
|
|
1041
|
+
response?.data ?? (error instanceof Error ? error.message : String(error))
|
|
1042
|
+
).toLowerCase();
|
|
1043
|
+
|
|
1044
|
+
return haystack.includes('already exists') || haystack.includes('duplicate key');
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
private normalizeRunnerType(runnerType: string | undefined): RunnerType {
|
|
1048
|
+
return runnerType === 'nsl' ? 'nsl' : 'guidance';
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
private normalizeParameters(parameters: readonly SkillParameter[] | undefined): SkillParameter[] {
|
|
1052
|
+
return (parameters || []).map(p => ({
|
|
1053
|
+
name: p.name,
|
|
1054
|
+
default_value: p.default_value ?? ''
|
|
1055
|
+
}));
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Fail fast if no model could be resolved for a V2 skill.
|
|
1060
|
+
*
|
|
1061
|
+
* `buildV2SkillMetadataFromYaml` falls back to empty strings when neither
|
|
1062
|
+
* the skill nor the flow declare a model. The platform rejects empty
|
|
1063
|
+
* model_idn/provider_idn at creation/update time, but the error it returns
|
|
1064
|
+
* is generic — we surface a clearer message before issuing the request.
|
|
1065
|
+
*/
|
|
1066
|
+
private assertSkillModelResolved(metadata: SkillMetadata, locator: string): void {
|
|
1067
|
+
if (!metadata.model.model_idn || !metadata.model.provider_idn) {
|
|
1068
|
+
throw new Error(
|
|
1069
|
+
`[newo_v2] Cannot resolve model for skill ${locator}: ` +
|
|
1070
|
+
`model_idn="${metadata.model.model_idn}", provider_idn="${metadata.model.provider_idn}". ` +
|
|
1071
|
+
`Set either skill.model.* or flow default_model_idn/default_provider_idn in the flow YAML.`
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
private buildV2SkillMetadataFromYaml(
|
|
1077
|
+
skill: V2InlineSkill,
|
|
1078
|
+
flowDef: Awaited<ReturnType<typeof parseV2FlowYaml>>,
|
|
1079
|
+
runnerType: RunnerType,
|
|
1080
|
+
existing?: SkillMetadata
|
|
1081
|
+
): SkillMetadata {
|
|
1082
|
+
return {
|
|
1083
|
+
id: existing?.id || '',
|
|
1084
|
+
idn: skill.idn,
|
|
1085
|
+
title: skill.title || '',
|
|
1086
|
+
runner_type: runnerType,
|
|
1087
|
+
model: {
|
|
1088
|
+
model_idn: skill.model?.model_idn || flowDef.default_model_idn || '',
|
|
1089
|
+
provider_idn: skill.model?.provider_idn || flowDef.default_provider_idn || ''
|
|
1090
|
+
},
|
|
1091
|
+
parameters: this.normalizeParameters(skill.parameters),
|
|
1092
|
+
path: existing?.path || ''
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
private skillMetadataDiffers(existing: SkillMetadata, local: SkillMetadata): boolean {
|
|
1097
|
+
// Compare model/parameters field-by-field, never via JSON.stringify of the
|
|
1098
|
+
// raw objects: the map stores model keys in platform API order
|
|
1099
|
+
// (provider_idn first) while YAML-built metadata uses model_idn first, and
|
|
1100
|
+
// a key-order-sensitive comparison flags every skill as changed.
|
|
1101
|
+
const paramsKey = (params: readonly SkillParameter[] | undefined): string =>
|
|
1102
|
+
JSON.stringify(
|
|
1103
|
+
this.normalizeParameters(params).sort((a, b) => a.name.localeCompare(b.name))
|
|
1104
|
+
);
|
|
1105
|
+
|
|
1106
|
+
return (
|
|
1107
|
+
existing.title !== local.title ||
|
|
1108
|
+
existing.runner_type !== local.runner_type ||
|
|
1109
|
+
existing.model.model_idn !== local.model.model_idn ||
|
|
1110
|
+
existing.model.provider_idn !== local.model.provider_idn ||
|
|
1111
|
+
paramsKey(existing.parameters) !== paramsKey(local.parameters)
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
private async resolveV2FlowSkillScriptPath(
|
|
1116
|
+
customerIdn: string,
|
|
1117
|
+
projectIdn: string,
|
|
1118
|
+
agentIdn: string,
|
|
1119
|
+
flowIdn: string,
|
|
1120
|
+
skillIdn: string,
|
|
1121
|
+
runnerType: RunnerType,
|
|
1122
|
+
promptScript?: string
|
|
1123
|
+
): Promise<string> {
|
|
1124
|
+
if (promptScript) {
|
|
1125
|
+
const fromPromptScript = `${v2AgentDir(customerIdn, projectIdn, agentIdn)}/${promptScript}`;
|
|
1126
|
+
if (await fs.pathExists(fromPromptScript)) {
|
|
1127
|
+
return fromPromptScript;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
return v2SkillScriptPath(customerIdn, projectIdn, agentIdn, flowIdn, skillIdn, runnerType);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
803
1134
|
/**
|
|
804
1135
|
* Push a V2 skill update
|
|
805
1136
|
*
|
|
@@ -949,11 +1280,30 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
949
1280
|
}
|
|
950
1281
|
|
|
951
1282
|
for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1283
|
+
const flowYamlSkills = await this.loadLocalV2FlowSkills(customer.idn, projectIdn, agentIdn, flowIdn);
|
|
1284
|
+
const skillIdns = new Set([
|
|
1285
|
+
...Object.keys(flowData.skills),
|
|
1286
|
+
...flowYamlSkills.keys()
|
|
1287
|
+
]);
|
|
1288
|
+
|
|
1289
|
+
for (const skillIdn of skillIdns) {
|
|
1290
|
+
const yamlSkill = flowYamlSkills.get(skillIdn);
|
|
1291
|
+
const skillMeta = flowData.skills[skillIdn];
|
|
1292
|
+
const runnerType = this.normalizeRunnerType(yamlSkill?.runner_type || skillMeta?.runner_type);
|
|
1293
|
+
const scriptPath = yamlSkill
|
|
1294
|
+
? await this.resolveV2FlowSkillScriptPath(
|
|
1295
|
+
customer.idn,
|
|
1296
|
+
projectIdn,
|
|
1297
|
+
agentIdn,
|
|
1298
|
+
flowIdn,
|
|
1299
|
+
skillIdn,
|
|
1300
|
+
runnerType,
|
|
1301
|
+
yamlSkill.prompt_script
|
|
1302
|
+
)
|
|
1303
|
+
: v2SkillScriptPath(
|
|
1304
|
+
customer.idn, projectIdn, agentIdn, flowIdn, skillIdn,
|
|
1305
|
+
runnerType
|
|
1306
|
+
);
|
|
957
1307
|
|
|
958
1308
|
if (await fs.pathExists(scriptPath)) {
|
|
959
1309
|
const content = await fs.readFile(scriptPath, 'utf8');
|
|
@@ -1002,6 +1352,25 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
1002
1352
|
return changes;
|
|
1003
1353
|
}
|
|
1004
1354
|
|
|
1355
|
+
private async loadLocalV2FlowSkills(
|
|
1356
|
+
customerIdn: string,
|
|
1357
|
+
projectIdn: string,
|
|
1358
|
+
agentIdn: string,
|
|
1359
|
+
flowIdn: string
|
|
1360
|
+
): Promise<Map<string, V2InlineSkill>> {
|
|
1361
|
+
const flowYamlPath = v2FlowYamlPath(customerIdn, projectIdn, agentIdn, flowIdn);
|
|
1362
|
+
if (!(await fs.pathExists(flowYamlPath))) {
|
|
1363
|
+
return new Map();
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
try {
|
|
1367
|
+
const flowDef = await parseV2FlowYaml(flowYamlPath);
|
|
1368
|
+
return new Map((flowDef.skills || []).map(skill => [skill.idn, skill]));
|
|
1369
|
+
} catch {
|
|
1370
|
+
return new Map();
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1005
1374
|
async validate(customer: CustomerConfig, _items: LocalProjectData[]): Promise<ValidationResult> {
|
|
1006
1375
|
const errors: ValidationError[] = [];
|
|
1007
1376
|
|
|
@@ -1020,7 +1389,61 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
1020
1389
|
for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
|
|
1021
1390
|
for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
|
|
1022
1391
|
for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
|
|
1392
|
+
const flowYamlPath = v2FlowYamlPath(customer.idn, projectIdn, agentIdn, flowIdn);
|
|
1393
|
+
let localYamlSkills: Map<string, V2InlineSkill> | undefined;
|
|
1394
|
+
if (await fs.pathExists(flowYamlPath)) {
|
|
1395
|
+
try {
|
|
1396
|
+
const flowDef = await parseV2FlowYaml(flowYamlPath);
|
|
1397
|
+
localYamlSkills = new Map((flowDef.skills || []).map(s => [s.idn, s]));
|
|
1398
|
+
const skillIdns = new Set([
|
|
1399
|
+
...Object.keys(flowData.skills),
|
|
1400
|
+
...localYamlSkills.keys()
|
|
1401
|
+
]);
|
|
1402
|
+
|
|
1403
|
+
for (const skillIdn of skillIdns) {
|
|
1404
|
+
const localYamlSkill = localYamlSkills.get(skillIdn);
|
|
1405
|
+
const skillMeta = flowData.skills[skillIdn];
|
|
1406
|
+
|
|
1407
|
+
if (!localYamlSkill) {
|
|
1408
|
+
errors.push({
|
|
1409
|
+
field: `skill.${skillIdn}`,
|
|
1410
|
+
message: `Skill exists in project map but is missing from flow YAML: ${flowYamlPath}`,
|
|
1411
|
+
path: flowYamlPath
|
|
1412
|
+
});
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
const runnerType = this.normalizeRunnerType(
|
|
1417
|
+
localYamlSkill.runner_type || skillMeta?.runner_type
|
|
1418
|
+
);
|
|
1419
|
+
const scriptPath = await this.resolveV2FlowSkillScriptPath(
|
|
1420
|
+
customer.idn,
|
|
1421
|
+
projectIdn,
|
|
1422
|
+
agentIdn,
|
|
1423
|
+
flowIdn,
|
|
1424
|
+
skillIdn,
|
|
1425
|
+
runnerType,
|
|
1426
|
+
localYamlSkill.prompt_script
|
|
1427
|
+
);
|
|
1428
|
+
|
|
1429
|
+
if (!(await fs.pathExists(scriptPath))) {
|
|
1430
|
+
errors.push({
|
|
1431
|
+
field: `skill.${localYamlSkill.idn}`,
|
|
1432
|
+
message: `Script file not found: ${scriptPath}`,
|
|
1433
|
+
path: scriptPath
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
} catch {
|
|
1438
|
+
localYamlSkills = undefined;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1023
1442
|
for (const [skillIdn, skillMeta] of Object.entries(flowData.skills)) {
|
|
1443
|
+
if (localYamlSkills) {
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1024
1447
|
const scriptPath = v2SkillScriptPath(
|
|
1025
1448
|
customer.idn, projectIdn, agentIdn, flowIdn, skillIdn,
|
|
1026
1449
|
skillMeta.runner_type
|