@ateam-ai/mcp 0.3.57 → 0.4.1
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/package.json +1 -1
- package/src/tools.js +309 -65
package/package.json
CHANGED
package/src/tools.js
CHANGED
|
@@ -83,6 +83,89 @@ function _resolveDottedField(obj, dottedPath) {
|
|
|
83
83
|
return { parent, leaf: parts[parts.length - 1] };
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// ─── Protected array fields (v0.4.0 sibling-loss guard) ─────────────
|
|
87
|
+
//
|
|
88
|
+
// Historically, `ateam_patch(target:"solution", updates:{ linked_skills:["only-one"] })`
|
|
89
|
+
// silently REPLACED the whole linked_skills array — wiping every other
|
|
90
|
+
// skill wired into the solution. Same footgun on ui_plugins, handoffs,
|
|
91
|
+
// grants, connectors, etc. The Solution Builder skill hit this in the
|
|
92
|
+
// wild and wiped a tenant's whole solution.
|
|
93
|
+
//
|
|
94
|
+
// From v0.4.0, ateam_patch REFUSES a bare array-replace on any of these
|
|
95
|
+
// fields unless the caller explicitly opts in via one of:
|
|
96
|
+
// updates: { _replace: true, linked_skills: [...] } // object-level
|
|
97
|
+
// updates: { linked_skills: [...], linked_skills_replace: true } // field-level
|
|
98
|
+
// To ADD or REMOVE without opt-in, use the _push / _delete / _update
|
|
99
|
+
// suffix pattern that has always been the correct form.
|
|
100
|
+
const SOLUTION_ARRAY_FIELDS = new Set([
|
|
101
|
+
'linked_skills',
|
|
102
|
+
'ui_plugins',
|
|
103
|
+
'platform_connectors',
|
|
104
|
+
'handoffs',
|
|
105
|
+
'grants',
|
|
106
|
+
'triggers',
|
|
107
|
+
'notification_routes',
|
|
108
|
+
'channels',
|
|
109
|
+
'actor_types',
|
|
110
|
+
'admin_roles',
|
|
111
|
+
'plugins',
|
|
112
|
+
'security_contracts',
|
|
113
|
+
'connectors',
|
|
114
|
+
'skills',
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
const SKILL_ARRAY_FIELDS = new Set([
|
|
118
|
+
'tools',
|
|
119
|
+
'connectors',
|
|
120
|
+
'handoffs',
|
|
121
|
+
'scenarios',
|
|
122
|
+
'triggers',
|
|
123
|
+
'notification_routes',
|
|
124
|
+
'plugins',
|
|
125
|
+
'bootstrap_tools',
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
// Returns { ok:false, ... } if the write would REPLACE a protected array
|
|
129
|
+
// without an explicit opt-in; returns null if the write is safe to proceed.
|
|
130
|
+
function _guardArrayReplace({ target, key, value, current, updates }) {
|
|
131
|
+
const knownFields = target === 'skill' ? SKILL_ARRAY_FIELDS : SOLUTION_ARRAY_FIELDS;
|
|
132
|
+
if (!knownFields.has(key)) return null;
|
|
133
|
+
if (!Array.isArray(value)) return null;
|
|
134
|
+
const currentArr = Array.isArray(current) ? current : [];
|
|
135
|
+
if (currentArr.length === 0) return null; // nothing to lose
|
|
136
|
+
if (updates && updates._replace === true) return null;
|
|
137
|
+
if (updates && updates[key + '_replace'] === true) return null;
|
|
138
|
+
|
|
139
|
+
// Compute what would be dropped so the error message is actionable.
|
|
140
|
+
const keyOf = (item) => (item && typeof item === 'object') ? (item.id ?? item.name ?? JSON.stringify(item)) : item;
|
|
141
|
+
const newKeys = new Set(value.map(keyOf));
|
|
142
|
+
const dropped = currentArr.map(keyOf).filter(k => !newKeys.has(k));
|
|
143
|
+
const kept = currentArr.map(keyOf).filter(k => newKeys.has(k));
|
|
144
|
+
const wouldAdd = value.map(keyOf).filter(k => !currentArr.map(keyOf).includes(k));
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
phase: 'patch',
|
|
149
|
+
error:
|
|
150
|
+
`⚠️ REFUSED: bare-array replace on ${target}.${key} would drop ${dropped.length} sibling item(s): ` +
|
|
151
|
+
`[${dropped.slice(0, 8).join(', ')}${dropped.length > 8 ? ', ...' : ''}]. ` +
|
|
152
|
+
`This footgun wiped a whole solution in the wild — v0.4.0 refuses it by default. ` +
|
|
153
|
+
`\n\nWhat to do instead:` +
|
|
154
|
+
`\n • To ADD items: updates: { "${key}_push": ${JSON.stringify(wouldAdd)} }` +
|
|
155
|
+
(dropped.length ? `\n • To REMOVE items: updates: { "${key}_delete": ${JSON.stringify(dropped)} }` : '') +
|
|
156
|
+
`\n • To FULLY REPLACE (rare): updates: { "${key}": [...], "${key}_replace": true }` +
|
|
157
|
+
`\n • To replace many arrays in one call: updates: { _replace: true, "${key}": [...] }`,
|
|
158
|
+
dropped_ids: dropped,
|
|
159
|
+
kept_ids: kept,
|
|
160
|
+
would_add: wouldAdd,
|
|
161
|
+
safe_alternatives: {
|
|
162
|
+
push: { [`${key}_push`]: wouldAdd },
|
|
163
|
+
...(dropped.length && { delete: { [`${key}_delete`]: dropped } }),
|
|
164
|
+
force_replace: { [key]: value, [`${key}_replace`]: true },
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
86
169
|
// ─── Tool definitions ───────────────────────────────────────────────
|
|
87
170
|
|
|
88
171
|
export const tools = [
|
|
@@ -428,24 +511,27 @@ export const tools = [
|
|
|
428
511
|
core: true,
|
|
429
512
|
description:
|
|
430
513
|
"Surgically update ANY field in a skill or solution definition, redeploy, and optionally re-test — all in one step.\n\n" +
|
|
431
|
-
"
|
|
514
|
+
"⚠️ MERGE-BY-DEFAULT (v0.4.0) — Arrays are protected from silent replace. Bare array writes on solution.linked_skills / ui_plugins / platform_connectors / handoffs / grants / triggers (etc.) and skill.tools / connectors / handoffs / scenarios are REFUSED to prevent sibling loss. Add or remove items with the _push / _delete / _update suffixes; opt into a full-array replace only when you really mean it.\n\n" +
|
|
515
|
+
"OPERATIONS (safe by construction):\n" +
|
|
432
516
|
"1. Scalar (dot notation): { \"problem.statement\": \"new value\", \"role.persona\": \"You are...\" }\n" +
|
|
433
517
|
"2. Deep nested: { \"intents.thresholds.accept\": 0.9, \"policy.escalation.enabled\": true }\n" +
|
|
434
|
-
"3. Array
|
|
435
|
-
"4. Array
|
|
436
|
-
"5. Array
|
|
437
|
-
"6.
|
|
438
|
-
"EXAMPLES:\n" +
|
|
439
|
-
"-
|
|
440
|
-
"-
|
|
518
|
+
"3. Array APPEND: { \"tools_push\": [{ name: \"new_tool\", description: \"...\" }] }\n" +
|
|
519
|
+
"4. Array REMOVE: { \"tools_delete\": [\"tool_name\"] }\n" +
|
|
520
|
+
"5. Array MODIFY-ONE: { \"tools_update\": [{ name: \"existing_tool\", description: \"updated\" }] }\n" +
|
|
521
|
+
"6. Full-array REPLACE (opt-in): { \"linked_skills\": [...], \"linked_skills_replace\": true } — or { _replace: true, ... } to opt every array in this call.\n\n" +
|
|
522
|
+
"SOLUTION-LEVEL EXAMPLES (target='solution'):\n" +
|
|
523
|
+
"- ADD a skill to the solution: updates: { \"linked_skills_push\": [\"my-new-skill\"] } ← NOT { linked_skills: [\"my-new-skill\"] } (that would REFUSE — it drops your other skills)\n" +
|
|
524
|
+
"- REMOVE a skill: updates: { \"linked_skills_delete\": [\"old-skill\"] }\n" +
|
|
525
|
+
"- ADD a UI plugin: updates: { \"ui_plugins_push\": [{ id: \"mcp:conn:panel\", ... }] }\n" +
|
|
526
|
+
"- ADD a handoff: updates: { \"handoffs_push\": [{ id: \"h1\", ... }] }\n\n" +
|
|
527
|
+
"SKILL-LEVEL EXAMPLES (target='skill' + skill_id):\n" +
|
|
528
|
+
"- Change persona: updates: { \"role.persona\": \"You are a friendly assistant\" }\n" +
|
|
529
|
+
"- Append to persona: updates: { \"persona_append\": \"\\n\\nALWAYS respond in 2 sentences.\" }\n" +
|
|
441
530
|
"- Add a guardrail: updates: { \"policy.guardrails.never_push\": [\"Never share passwords\"] }\n" +
|
|
442
|
-
"- Update problem: updates: { \"problem.statement\": \"...\", \"problem.goals\": [\"goal1\"] }\n" +
|
|
443
531
|
"- Add a tool: updates: { \"tools_push\": [{ name: \"conn.tool\", description: \"...\", inputs: [...], output: {...} }] }\n" +
|
|
444
532
|
"- Change intent: updates: { \"intents.supported_update\": [{ id: \"i1\", description: \"new desc\" }] }\n" +
|
|
445
|
-
"-
|
|
446
|
-
"
|
|
447
|
-
" If the skill doesn't exist yet, a default scaffold is created and the updates are applied on top. The skill is automatically added to the solution topology.\n\n" +
|
|
448
|
-
"Use target='skill' + skill_id for skill fields. Use target='solution' for solution-level fields (linked_skills, platform_connectors, ui_plugins).",
|
|
533
|
+
"- CREATE a new skill: target='skill', skill_id='my-new-skill', updates: { \"problem.statement\": \"...\", \"role.persona\": \"...\" } — auto-scaffolded and added to solution topology.\n\n" +
|
|
534
|
+
"PREVIEW BEFORE WRITING: pass dry_run:true to see the diff (arrays_merged, arrays_replaced, dropped_ids, added_ids) without applying. Use this before any destructive-looking edit.",
|
|
449
535
|
inputSchema: {
|
|
450
536
|
type: "object",
|
|
451
537
|
properties: {
|
|
@@ -473,6 +559,16 @@ export const tools = [
|
|
|
473
559
|
type: "string",
|
|
474
560
|
description: "Optional: re-test the skill after patching. Requires skill_id.",
|
|
475
561
|
},
|
|
562
|
+
dry_run: {
|
|
563
|
+
type: "boolean",
|
|
564
|
+
description: "If true, apply the patch in memory and return the diff (arrays_merged, arrays_replaced, dropped_ids, added_ids, would_write_bytes) WITHOUT writing to GitHub or redeploying. Preview a change before committing to it.",
|
|
565
|
+
},
|
|
566
|
+
source: {
|
|
567
|
+
type: "string",
|
|
568
|
+
enum: ["github", "local"],
|
|
569
|
+
description:
|
|
570
|
+
"Where the solution/skill definition lives. 'github' (DEFAULT) — read from and write to the tenant's GitHub repo (GitHub is master; the normal path). 'local' — read from and write to the Builder FS store (no GitHub repo required). Use 'local' ONLY for a repo-less bootstrap tenant (e.g. freshly onboarded from a template, before GitHub is connected). This is a DEDICATED, EXPLICIT switch — never a fallback. Redeploy is local in both modes.",
|
|
571
|
+
},
|
|
476
572
|
},
|
|
477
573
|
required: ["solution_id", "target", "updates"],
|
|
478
574
|
},
|
|
@@ -516,7 +612,9 @@ export const tools = [
|
|
|
516
612
|
name: "ateam_delete_solution",
|
|
517
613
|
core: true,
|
|
518
614
|
description:
|
|
519
|
-
"
|
|
615
|
+
"⚠️ IRREVERSIBLE — kills Mongo state, running MCP processes, and Builder FS for the whole solution and every skill. " +
|
|
616
|
+
"REQUIRES `confirm:true` AND `confirm_solution_id` echoing the solution id you're destroying (defeats typos and hallucinated ids). " +
|
|
617
|
+
"RECOVERY: the GitHub repo is untouched; `ateam_github_pull` rebuilds the solution from `main`. Prefer that over re-deploying from memory.",
|
|
520
618
|
inputSchema: {
|
|
521
619
|
type: "object",
|
|
522
620
|
properties: {
|
|
@@ -524,15 +622,24 @@ export const tools = [
|
|
|
524
622
|
type: "string",
|
|
525
623
|
description: "The solution ID to delete",
|
|
526
624
|
},
|
|
625
|
+
confirm: {
|
|
626
|
+
type: "boolean",
|
|
627
|
+
description: "REQUIRED. Must be exactly true. A missing/false value refuses the call with a recovery hint.",
|
|
628
|
+
},
|
|
629
|
+
confirm_solution_id: {
|
|
630
|
+
type: "string",
|
|
631
|
+
description: "REQUIRED. Must exactly equal `solution_id`. This defeats typos and hallucinated ids — you can't wipe a solution you couldn't spell.",
|
|
632
|
+
},
|
|
527
633
|
},
|
|
528
|
-
required: ["solution_id"],
|
|
634
|
+
required: ["solution_id", "confirm", "confirm_solution_id"],
|
|
529
635
|
},
|
|
530
636
|
},
|
|
531
637
|
{
|
|
532
638
|
name: "ateam_delete_skill",
|
|
533
639
|
core: true,
|
|
534
640
|
description:
|
|
535
|
-
"
|
|
641
|
+
"⚠️ IRREVERSIBLE in Core + Builder FS — kills the running MCP process, unregisters from skill registry, deletes the Mongo record, drops from solution.skills[] and solution.linked_skills, and removes the skill's files from Builder FS. " +
|
|
642
|
+
"REQUIRES `confirm:true`. RECOVERY: the skill still lives in GitHub — `ateam_github_pull` rebuilds the whole solution (no per-skill restore path).",
|
|
536
643
|
inputSchema: {
|
|
537
644
|
type: "object",
|
|
538
645
|
properties: {
|
|
@@ -544,15 +651,22 @@ export const tools = [
|
|
|
544
651
|
type: "string",
|
|
545
652
|
description: "The skill ID to remove (e.g. 'linkedin-agent')",
|
|
546
653
|
},
|
|
654
|
+
confirm: {
|
|
655
|
+
type: "boolean",
|
|
656
|
+
description: "REQUIRED. Must be exactly true. A missing/false value refuses the call with a recovery hint.",
|
|
657
|
+
},
|
|
547
658
|
},
|
|
548
|
-
required: ["solution_id", "skill_id"],
|
|
659
|
+
required: ["solution_id", "skill_id", "confirm"],
|
|
549
660
|
},
|
|
550
661
|
},
|
|
551
662
|
{
|
|
552
663
|
name: "ateam_delete_connector",
|
|
553
664
|
core: true,
|
|
554
665
|
description:
|
|
555
|
-
"
|
|
666
|
+
"⚠️ CASCADING — any skill whose engine.bootstrap_tools or tools[] name a tool from this connector will FAIL its next execution. " +
|
|
667
|
+
"Stops and deletes the connector from A-Team Core; drops references from the solution definition (grants, platform_connectors, ui_plugins ids starting `mcp:<connector-id>:*`) and skill definitions (connectors array); cleans up mcp-store files. " +
|
|
668
|
+
"GitHub source is preserved — a follow-up `ateam_build_and_run(github:true)` can resurrect. " +
|
|
669
|
+
"REQUIRES `confirm:true`.",
|
|
556
670
|
inputSchema: {
|
|
557
671
|
type: "object",
|
|
558
672
|
properties: {
|
|
@@ -564,8 +678,12 @@ export const tools = [
|
|
|
564
678
|
type: "string",
|
|
565
679
|
description: "The connector ID to remove (e.g. 'device-mock-mcp')",
|
|
566
680
|
},
|
|
681
|
+
confirm: {
|
|
682
|
+
type: "boolean",
|
|
683
|
+
description: "REQUIRED. Must be exactly true. A missing/false value refuses the call with a recovery hint.",
|
|
684
|
+
},
|
|
567
685
|
},
|
|
568
|
-
required: ["solution_id", "connector_id"],
|
|
686
|
+
required: ["solution_id", "connector_id", "confirm"],
|
|
569
687
|
},
|
|
570
688
|
},
|
|
571
689
|
|
|
@@ -2535,21 +2653,45 @@ const handlers = {
|
|
|
2535
2653
|
// Updates → Redeploys → Optionally tests
|
|
2536
2654
|
// One call replaces: ateam_update + ateam_redeploy
|
|
2537
2655
|
|
|
2538
|
-
ateam_patch: async ({ solution_id, target, skill_id, updates, test_message }, sid) => {
|
|
2656
|
+
ateam_patch: async ({ solution_id, target, skill_id, updates, test_message, dry_run, source }, sid) => {
|
|
2539
2657
|
const phases = [];
|
|
2540
2658
|
let isNewSkill = false;
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
//
|
|
2544
|
-
|
|
2545
|
-
//
|
|
2659
|
+
const _diff = { arrays_merged: [], arrays_replaced: [], scalars_changed: [], sections_replaced: [] };
|
|
2660
|
+
|
|
2661
|
+
// Two backing stores, chosen EXPLICITLY by `source` (never inferred):
|
|
2662
|
+
// 'github' (default) — GitHub-first: read from GitHub → apply patch → write
|
|
2663
|
+
// back → redeploy. GitHub stays the single source of truth.
|
|
2664
|
+
// 'local' — Builder-FS-first: read from and write to the Builder store for a
|
|
2665
|
+
// repo-less bootstrap tenant (freshly onboarded from a template, GitHub not
|
|
2666
|
+
// yet connected). GitHub is still master overall; local is a temporary
|
|
2667
|
+
// bootstrap until the tenant connects a repo (then local is pushed → GitHub).
|
|
2668
|
+
// Redeploy (Phase 4) is local (Builder FS → Core) in BOTH modes.
|
|
2669
|
+
const isLocal = source === "local";
|
|
2670
|
+
|
|
2671
|
+
// Phase 1: Read current state (or create scaffold if new skill)
|
|
2546
2672
|
let current;
|
|
2547
2673
|
const filePath = target === "skill" && skill_id
|
|
2548
2674
|
? `skills/${skill_id}/skill.json`
|
|
2549
2675
|
: `solution.json`;
|
|
2550
2676
|
try {
|
|
2551
|
-
|
|
2552
|
-
|
|
2677
|
+
if (isLocal) {
|
|
2678
|
+
// Read the raw definition from the Builder store — no GitHub repo needed.
|
|
2679
|
+
if (target === "skill" && skill_id) {
|
|
2680
|
+
const r = await get(`/deploy/solutions/${solution_id}/skills/${encodeURIComponent(skill_id)}`, sid);
|
|
2681
|
+
current = r.skill || r.definition || r;
|
|
2682
|
+
} else {
|
|
2683
|
+
// ?raw=1 → the agent-api returns the UNSTRIPPED solution (keeps
|
|
2684
|
+
// linked_skills/conversation) so _delete/_push operate on the real arrays.
|
|
2685
|
+
const r = await get(`/deploy/solutions/${solution_id}/definition?raw=1`, sid);
|
|
2686
|
+
current = r.solution || r;
|
|
2687
|
+
}
|
|
2688
|
+
if (!current || typeof current !== "object") {
|
|
2689
|
+
throw new Error(`Local ${filePath} not found (empty definition)`);
|
|
2690
|
+
}
|
|
2691
|
+
} else {
|
|
2692
|
+
const readResult = await get(`/deploy/solutions/${solution_id}/github/read?path=${encodeURIComponent(filePath)}`, sid);
|
|
2693
|
+
current = JSON.parse(readResult.content);
|
|
2694
|
+
}
|
|
2553
2695
|
} catch (err) {
|
|
2554
2696
|
// If it's a skill that doesn't exist yet, create a default scaffold.
|
|
2555
2697
|
// This lets agents use ateam_patch to both CREATE and UPDATE skills —
|
|
@@ -2569,7 +2711,7 @@ const handlers = {
|
|
|
2569
2711
|
role: { name: "", persona: "", goals: [], limitations: [], communication_style: { tone: "professional", verbosity: "concise" } },
|
|
2570
2712
|
intents: { supported: [], thresholds: { accept: 0.8, clarify: 0.5, reject: 0.5 }, out_of_domain: { action: "redirect", message: "" } },
|
|
2571
2713
|
tools: [],
|
|
2572
|
-
policy: { guardrails: { never: [], always: [] }, approvals: [], workflows: [], escalation: { enabled: false, conditions: [], target: "" } },
|
|
2714
|
+
policy: { access: { requires_roles: [] }, guardrails: { never: [], always: [] }, approvals: [], workflows: [], escalation: { enabled: false, conditions: [], target: "" } },
|
|
2573
2715
|
engine: { rv2: { max_iterations: 10, iteration_timeout_ms: 120000, allow_parallel_tools: false, on_max_iterations: "ask_user" }, hlr: { enabled: true, critic: { enabled: true, check_interval: 3, strictness: "medium" }, reflection: { enabled: true, depth: "shallow" }, replanning: { enabled: true, max_replans: 3 } }, autonomy: { level: "supervised" }, finalization_gate: { enabled: true, max_retries: 2 } },
|
|
2574
2716
|
access_policy: { rules: [{ tools: ["*"], effect: "allow" }] },
|
|
2575
2717
|
grant_mappings: [],
|
|
@@ -2581,7 +2723,7 @@ const handlers = {
|
|
|
2581
2723
|
};
|
|
2582
2724
|
phases.push({ phase: "read", status: "created_scaffold", skill_id });
|
|
2583
2725
|
} else {
|
|
2584
|
-
return { ok: false, phase: "read", error: `Failed to read ${filePath} from GitHub: ${err.message}` };
|
|
2726
|
+
return { ok: false, phase: "read", error: `Failed to read ${filePath} from ${isLocal ? "Builder store (local)" : "GitHub"}: ${err.message}` };
|
|
2585
2727
|
}
|
|
2586
2728
|
}
|
|
2587
2729
|
|
|
@@ -2656,9 +2798,30 @@ const handlers = {
|
|
|
2656
2798
|
else arr.push(upd);
|
|
2657
2799
|
}
|
|
2658
2800
|
parent[leaf] = arr;
|
|
2801
|
+
} else if (key === "_replace" || key.endsWith("_replace")) {
|
|
2802
|
+
// Escape-hatch flags handled by the guard — skip them here so they
|
|
2803
|
+
// don't get written into the patched object as literal fields.
|
|
2804
|
+
continue;
|
|
2659
2805
|
} else if (key.includes(".")) {
|
|
2660
2806
|
// Dot notation: "role.persona", "intents.thresholds.accept"
|
|
2661
2807
|
const parts = key.split(".");
|
|
2808
|
+
// Sibling-loss guard: if the leaf resolves to an existing non-empty
|
|
2809
|
+
// array and the incoming value is also an array, refuse the replace
|
|
2810
|
+
// unless the caller opted in. (Dot-notation is how many agents
|
|
2811
|
+
// accidentally hit this — e.g. updates:{ "linked_skills": ["one"] }
|
|
2812
|
+
// on target='solution'.)
|
|
2813
|
+
const leafKey = parts[parts.length - 1];
|
|
2814
|
+
let cursor = patched;
|
|
2815
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
2816
|
+
if (!cursor || typeof cursor[parts[i]] !== 'object') { cursor = null; break; }
|
|
2817
|
+
cursor = cursor[parts[i]];
|
|
2818
|
+
}
|
|
2819
|
+
const currentLeaf = cursor && Object.prototype.hasOwnProperty.call(cursor, leafKey) ? cursor[leafKey] : undefined;
|
|
2820
|
+
const guardErr = _guardArrayReplace({ target, key: leafKey, value, current: currentLeaf, updates });
|
|
2821
|
+
if (guardErr) return guardErr;
|
|
2822
|
+
if (Array.isArray(value) && Array.isArray(currentLeaf)) _diff.arrays_replaced.push(key);
|
|
2823
|
+
else if (typeof value === 'object' && value !== null && !Array.isArray(value)) _diff.sections_replaced.push(key);
|
|
2824
|
+
else _diff.scalars_changed.push(key);
|
|
2662
2825
|
let obj = patched;
|
|
2663
2826
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
2664
2827
|
if (!obj[parts[i]] || typeof obj[parts[i]] !== "object") obj[parts[i]] = {};
|
|
@@ -2666,7 +2829,14 @@ const handlers = {
|
|
|
2666
2829
|
}
|
|
2667
2830
|
obj[parts[parts.length - 1]] = value;
|
|
2668
2831
|
} else {
|
|
2669
|
-
// Direct field replacement
|
|
2832
|
+
// Direct top-level field replacement. Sibling-loss guard: if this
|
|
2833
|
+
// names a known array field and would drop items, refuse unless the
|
|
2834
|
+
// caller passed _replace:true (object-level) or <field>_replace:true.
|
|
2835
|
+
const guardErr = _guardArrayReplace({ target, key, value, current: patched[key], updates });
|
|
2836
|
+
if (guardErr) return guardErr;
|
|
2837
|
+
if (Array.isArray(value) && Array.isArray(patched[key])) _diff.arrays_replaced.push(key);
|
|
2838
|
+
else if (typeof value === 'object' && value !== null && !Array.isArray(value)) _diff.sections_replaced.push(key);
|
|
2839
|
+
else _diff.scalars_changed.push(key);
|
|
2670
2840
|
patched[key] = value;
|
|
2671
2841
|
}
|
|
2672
2842
|
}
|
|
@@ -2675,42 +2845,80 @@ const handlers = {
|
|
|
2675
2845
|
return { ok: false, phase: "patch", error: `Failed to apply patch: ${err.message}` };
|
|
2676
2846
|
}
|
|
2677
2847
|
|
|
2678
|
-
//
|
|
2848
|
+
// Dry-run: return diff + would-be after-state without writing to GitHub
|
|
2849
|
+
// or redeploying. Lets an agent preview any destructive-looking edit.
|
|
2850
|
+
if (dry_run) {
|
|
2851
|
+
return {
|
|
2852
|
+
ok: true,
|
|
2853
|
+
dry_run: true,
|
|
2854
|
+
target,
|
|
2855
|
+
solution_id,
|
|
2856
|
+
skill_id,
|
|
2857
|
+
phases,
|
|
2858
|
+
_diff,
|
|
2859
|
+
after_state: patched,
|
|
2860
|
+
would_write_bytes: JSON.stringify(patched, null, 2).length,
|
|
2861
|
+
hint: "No changes applied. Remove dry_run:true to commit + redeploy.",
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
// Phase 3: Write patched version back to the chosen store.
|
|
2679
2866
|
try {
|
|
2680
2867
|
const patchKeys = Object.keys(updates || {});
|
|
2681
2868
|
const message = `Patch: ${target}${skill_id ? ` ${skill_id}` : ""} — ${patchKeys.join(", ")}`;
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2869
|
+
if (isLocal) {
|
|
2870
|
+
// Write the FULL patched object to the Builder store. Top-level keys are
|
|
2871
|
+
// replaced (removals honored) — the client-side merge above already
|
|
2872
|
+
// resolved _push/_delete/_update, so we send the resolved object.
|
|
2873
|
+
const endpoint = (target === "skill" && skill_id)
|
|
2874
|
+
? `/deploy/solutions/${solution_id}/skills/${encodeURIComponent(skill_id)}`
|
|
2875
|
+
: `/deploy/solutions/${solution_id}`;
|
|
2876
|
+
await patch(endpoint, { state_update: patched }, sid, { timeoutMs: 30_000 });
|
|
2877
|
+
phases.push({ phase: "local_write", status: "done" });
|
|
2878
|
+
} else {
|
|
2879
|
+
await post(`/deploy/solutions/${solution_id}/github/patch`, {
|
|
2880
|
+
path: filePath,
|
|
2881
|
+
content: JSON.stringify(patched, null, 2),
|
|
2882
|
+
message,
|
|
2883
|
+
}, sid, { timeoutMs: 30_000 });
|
|
2884
|
+
phases.push({ phase: "github_write", status: "done" });
|
|
2885
|
+
}
|
|
2688
2886
|
} catch (err) {
|
|
2689
|
-
|
|
2887
|
+
const store = isLocal ? "Builder store (local)" : "GitHub";
|
|
2888
|
+
return { ok: false, phase: isLocal ? "local_write" : "github_write", error: `Patch applied but failed to write to ${store}: ${err.message}`, phases };
|
|
2690
2889
|
}
|
|
2691
2890
|
|
|
2692
2891
|
// Phase 3b: If new skill, add it to solution.json topology (skills[], linked_skills)
|
|
2693
2892
|
if (isNewSkill && skill_id) {
|
|
2694
2893
|
try {
|
|
2695
|
-
const solRead = await get(`/deploy/solutions/${solution_id}/github/read?path=solution.json`, sid);
|
|
2696
|
-
const sol = JSON.parse(solRead.content);
|
|
2697
2894
|
const skillEntry = { id: skill_id, name: patched.name || skill_id, role: "worker", description: patched.description || "", connectors: patched.connectors || [] };
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2895
|
+
if (isLocal) {
|
|
2896
|
+
// Local: _push the entries via the Builder store (dedup handled by the
|
|
2897
|
+
// store's _push — it updates in place if the id already exists).
|
|
2898
|
+
await patch(`/deploy/solutions/${solution_id}`, {
|
|
2899
|
+
state_update: { skills_push: [skillEntry], linked_skills_push: [skill_id] },
|
|
2900
|
+
}, sid, { timeoutMs: 30_000 });
|
|
2901
|
+
phases.push({ phase: "solution_topology", status: "done", added: skill_id });
|
|
2902
|
+
} else {
|
|
2903
|
+
const solRead = await get(`/deploy/solutions/${solution_id}/github/read?path=solution.json`, sid);
|
|
2904
|
+
const sol = JSON.parse(solRead.content);
|
|
2905
|
+
// Add to skills[] if not already present
|
|
2906
|
+
if (!sol.skills) sol.skills = [];
|
|
2907
|
+
if (!sol.skills.find(s => s.id === skill_id)) {
|
|
2908
|
+
sol.skills.push(skillEntry);
|
|
2909
|
+
}
|
|
2910
|
+
// Add to linked_skills if not already present
|
|
2911
|
+
if (!sol.linked_skills) sol.linked_skills = [];
|
|
2912
|
+
if (!sol.linked_skills.includes(skill_id)) {
|
|
2913
|
+
sol.linked_skills.push(skill_id);
|
|
2914
|
+
}
|
|
2915
|
+
await post(`/deploy/solutions/${solution_id}/github/patch`, {
|
|
2916
|
+
path: "solution.json",
|
|
2917
|
+
content: JSON.stringify(sol, null, 2),
|
|
2918
|
+
message: `Add skill "${skill_id}" to solution topology`,
|
|
2919
|
+
}, sid, { timeoutMs: 30_000 });
|
|
2920
|
+
phases.push({ phase: "solution_topology", status: "done", added: skill_id });
|
|
2707
2921
|
}
|
|
2708
|
-
await post(`/deploy/solutions/${solution_id}/github/patch`, {
|
|
2709
|
-
path: "solution.json",
|
|
2710
|
-
content: JSON.stringify(sol, null, 2),
|
|
2711
|
-
message: `Add skill "${skill_id}" to solution topology`,
|
|
2712
|
-
}, sid, { timeoutMs: 30_000 });
|
|
2713
|
-
phases.push({ phase: "solution_topology", status: "done", added: skill_id });
|
|
2714
2922
|
} catch (err) {
|
|
2715
2923
|
// Non-fatal: skill.json was written, topology can be fixed manually
|
|
2716
2924
|
phases.push({ phase: "solution_topology", status: "warning", error: err.message });
|
|
@@ -2756,19 +2964,23 @@ const handlers = {
|
|
|
2756
2964
|
}
|
|
2757
2965
|
|
|
2758
2966
|
const redeployOk = phases.some(p => p.phase === "redeploy" && p.status === "done");
|
|
2967
|
+
const store = isLocal ? "Builder store (local)" : "GitHub";
|
|
2759
2968
|
return {
|
|
2760
2969
|
ok: true,
|
|
2761
2970
|
solution_id,
|
|
2762
|
-
|
|
2971
|
+
source: isLocal ? "local" : "github",
|
|
2972
|
+
...(isLocal ? {} : { branch: 'main' }),
|
|
2763
2973
|
phases,
|
|
2764
2974
|
patched: patched,
|
|
2765
2975
|
...(isNewSkill && { created_skill: skill_id }),
|
|
2766
2976
|
...(redeployResult && { redeploy: redeployResult }),
|
|
2767
2977
|
...(test_result && { test_result }),
|
|
2768
2978
|
_status: redeployOk
|
|
2769
|
-
?
|
|
2770
|
-
:
|
|
2771
|
-
_next:
|
|
2979
|
+
? `✅ Patched on ${store} + redeployed.`
|
|
2980
|
+
: `⚠️ Patched on ${store} ✅ but redeploy timed out. Run: ateam_redeploy(solution_id` + (skill_id ? `, skill_id: "${skill_id}"` : '') + ')',
|
|
2981
|
+
_next: isLocal
|
|
2982
|
+
? 'Local edit saved + redeployed. When the tenant connects a GitHub repo, the local state is pushed → GitHub (which then becomes master).'
|
|
2983
|
+
: 'Create a checkpoint before making more changes: ateam_github_promote(solution_id)',
|
|
2772
2984
|
};
|
|
2773
2985
|
},
|
|
2774
2986
|
|
|
@@ -3364,14 +3576,46 @@ const handlers = {
|
|
|
3364
3576
|
ateam_github_list_versions: async ({ solution_id }, sid) =>
|
|
3365
3577
|
get(`/deploy/solutions/${solution_id}/versions/dev`, sid),
|
|
3366
3578
|
|
|
3367
|
-
ateam_delete_solution: async ({ solution_id }, sid) =>
|
|
3368
|
-
|
|
3579
|
+
ateam_delete_solution: async ({ solution_id, confirm, confirm_solution_id }, sid) => {
|
|
3580
|
+
if (confirm !== true) {
|
|
3581
|
+
return {
|
|
3582
|
+
ok: false,
|
|
3583
|
+
error: "⚠️ REFUSED: ateam_delete_solution requires confirm:true. This is irreversible in Core + Builder FS. GitHub source is preserved — ateam_github_pull rebuilds from `main` if you already deleted by mistake.",
|
|
3584
|
+
recovery: "ateam_github_pull(solution_id, ref:'main')",
|
|
3585
|
+
};
|
|
3586
|
+
}
|
|
3587
|
+
if (confirm_solution_id !== solution_id) {
|
|
3588
|
+
return {
|
|
3589
|
+
ok: false,
|
|
3590
|
+
error: `⚠️ REFUSED: confirm_solution_id must exactly equal solution_id. Got confirm_solution_id="${confirm_solution_id}" but solution_id="${solution_id}". This check defeats typos and hallucinated ids — you should not be able to wipe a solution whose id you can't spell correctly.`,
|
|
3591
|
+
expected: solution_id,
|
|
3592
|
+
received: confirm_solution_id,
|
|
3593
|
+
};
|
|
3594
|
+
}
|
|
3595
|
+
return del(`/deploy/solutions/${solution_id}`, sid);
|
|
3596
|
+
},
|
|
3369
3597
|
|
|
3370
|
-
ateam_delete_skill: async ({ solution_id, skill_id }, sid) =>
|
|
3371
|
-
|
|
3598
|
+
ateam_delete_skill: async ({ solution_id, skill_id, confirm }, sid) => {
|
|
3599
|
+
if (confirm !== true) {
|
|
3600
|
+
return {
|
|
3601
|
+
ok: false,
|
|
3602
|
+
error: `⚠️ REFUSED: ateam_delete_skill requires confirm:true. Kills the running MCP process and deletes the skill from Core + Builder FS. GitHub source is preserved — ateam_github_pull rebuilds the whole solution.`,
|
|
3603
|
+
recovery: "ateam_github_pull(solution_id, ref:'main') — no per-skill restore path",
|
|
3604
|
+
};
|
|
3605
|
+
}
|
|
3606
|
+
return del(`/deploy/solutions/${solution_id}/skills/${skill_id}`, sid);
|
|
3607
|
+
},
|
|
3372
3608
|
|
|
3373
|
-
ateam_delete_connector: async ({ solution_id, connector_id }, sid) =>
|
|
3374
|
-
|
|
3609
|
+
ateam_delete_connector: async ({ solution_id, connector_id, confirm }, sid) => {
|
|
3610
|
+
if (confirm !== true) {
|
|
3611
|
+
return {
|
|
3612
|
+
ok: false,
|
|
3613
|
+
error: `⚠️ REFUSED: ateam_delete_connector requires confirm:true. Cascading — any skill wired to this connector's tools will fail its next execution. GitHub source is preserved.`,
|
|
3614
|
+
recovery: "ateam_build_and_run(solution_id, github:true) can resurrect from GitHub",
|
|
3615
|
+
};
|
|
3616
|
+
}
|
|
3617
|
+
return del(`/deploy/solutions/${solution_id}/connectors/${connector_id}`, sid);
|
|
3618
|
+
},
|
|
3375
3619
|
|
|
3376
3620
|
ateam_upload_connector: async ({ solution_id, connector_id, github, files, ref, replace }, sid) =>
|
|
3377
3621
|
post(
|