@nullplatform/mcp 0.1.14 → 0.1.15
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/README.md +10 -0
- package/dist/http.js +16 -12
- package/dist/i18n.js +6 -0
- package/dist/log.js +53 -0
- package/dist/np/client.js +10 -1
- package/dist/np/context.js +24 -19
- package/dist/np/journey.js +14 -4
- package/dist/render.js +12 -13
- package/dist/surfaces/developer.js +14 -4
- package/dist/tool.js +64 -6
- package/dist/tools/create-app.js +125 -111
- package/dist/tools/create-link.js +81 -54
- package/dist/tools/create-scope.js +34 -22
- package/dist/tools/create-service.js +38 -24
- package/dist/tools/deploy.js +51 -29
- package/dist/tools/entity-list.js +14 -6
- package/dist/tools/logs.js +114 -17
- package/dist/tools/overview.js +1 -1
- package/dist/tools/params.js +7 -1
- package/dist/tools/set-params.js +17 -18
- package/dist/tools/shared.js +9 -0
- package/dist/tools/status.js +4 -4
- package/dist/tools/traffic.js +38 -26
- package/dist/ui.js +1 -1
- package/package.json +2 -1
- package/widgets-dist/approvals.html +112 -10
- package/widgets-dist/builds.html +119 -17
- package/widgets-dist/create-app.html +121 -19
- package/widgets-dist/deployments.html +124 -22
- package/widgets-dist/find-apps.html +123 -21
- package/widgets-dist/logs.html +125 -23
- package/widgets-dist/manifest.json +16 -16
- package/widgets-dist/metrics.html +112 -10
- package/widgets-dist/overview.html +128 -26
- package/widgets-dist/params.html +119 -17
- package/widgets-dist/releases.html +128 -26
- package/widgets-dist/service-action.html +116 -14
- package/widgets-dist/service-create.html +115 -13
- package/widgets-dist/service-delete.html +112 -10
- package/widgets-dist/service-link.html +115 -13
- package/widgets-dist/services.html +112 -10
- package/widgets-dist/{np-panel.html → status.html} +127 -25
package/dist/tools/create-app.js
CHANGED
|
@@ -41,10 +41,11 @@ async function buildFormReply(context, prefill) {
|
|
|
41
41
|
// of the global set. Fetch only once a namespace is pinned (the widget refetches per namespace on
|
|
42
42
|
// change); the org-level NRN returns a misleading near-empty subset.
|
|
43
43
|
const templates = picked ? await listTemplates(context.np, picked.nrn).catch(() => []) : [];
|
|
44
|
-
// Same generic rule as the namespace: resolve the
|
|
44
|
+
// Same generic rule as the namespace: resolve the MODEL's stack hint (`template`) — or an explicit
|
|
45
45
|
// template_id — against THIS namespace's templates, matching on name + tags, and pin the only one.
|
|
46
|
-
//
|
|
47
|
-
//
|
|
46
|
+
// Inference is the model's job (it holds the context); the tool only resolves the hint it passes —
|
|
47
|
+
// no keyword guessing here. Leave it null on a genuine unhinted/ambiguous choice (a wrong stack is
|
|
48
|
+
// worse than a deliberate pick).
|
|
48
49
|
const templateId = resolveChoice(templates, { id: prefill.template_id, text: prefill.template }, { pinOnly: true, haystack: (template) => `${template.name} ${(template.tags ?? []).join(" ")}` })?.id ?? null;
|
|
49
50
|
const namespaces = skeleton.namespaces.map((namespace) => ({
|
|
50
51
|
id: namespace.id,
|
|
@@ -56,6 +57,9 @@ async function buildFormReply(context, prefill) {
|
|
|
56
57
|
namespaces: namespaces.map((namespace) => namespace.name).join(", ") || "—",
|
|
57
58
|
}), {
|
|
58
59
|
mode: "form",
|
|
60
|
+
// Model-facing note (not rendered to the user): the form is already open with every template
|
|
61
|
+
// loaded in its dropdown, so the model must NOT re-call to "pre-select" a template it just saw.
|
|
62
|
+
note: "This create FORM is now OPEN with all of this namespace's templates loaded in its Template dropdown. Do NOT call application_create again to pre-select a template or refine the namespace you just learned about — re-opening renders a DUPLICATE form panel for the same app (the mistake to avoid). To steer the choice: either name the recommended template in your text reply for the user to pick in this form's dropdown, OR (better, next time) pass a `template` stack hint on the FIRST call so it pre-selects in one render. The user picks/confirms in THIS form.",
|
|
59
63
|
namespaces,
|
|
60
64
|
templates: templates.map((template) => ({
|
|
61
65
|
id: template.id,
|
|
@@ -72,10 +76,109 @@ async function buildFormReply(context, prefill) {
|
|
|
72
76
|
},
|
|
73
77
|
});
|
|
74
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Resolve where a new app lives — the (account, namespace) pair — from the create args. Mirrors
|
|
81
|
+
* requireApp's shape: returns the pinned namespace, or an `out` reply — a `fail` when a named
|
|
82
|
+
* account/namespace matches nothing, or the create FORM when it's a real choice that needs the
|
|
83
|
+
* picker rather than a dead-end ask. Narrows to the named account first (a namespace name can
|
|
84
|
+
* repeat across accounts), then matches by id/name and auto-picks the sole candidate.
|
|
85
|
+
*/
|
|
86
|
+
async function resolveCreateNamespace(context, args, name, repoUrl) {
|
|
87
|
+
const skeleton = await context.org.getSkeleton();
|
|
88
|
+
const accountQuery = args.account?.toLowerCase();
|
|
89
|
+
const candidates = accountQuery
|
|
90
|
+
? skeleton.namespaces.filter((candidate) => (candidate.account_name ?? "").toLowerCase().includes(accountQuery))
|
|
91
|
+
: skeleton.namespaces;
|
|
92
|
+
if (accountQuery && candidates.length === 0) {
|
|
93
|
+
const accounts = [
|
|
94
|
+
...new Set(skeleton.namespaces.map((candidate) => candidate.account_name).filter(Boolean)),
|
|
95
|
+
];
|
|
96
|
+
return {
|
|
97
|
+
out: fail(translate("createApp.noAccountMatch", { account: args.account ?? "", names: accounts.join(", ") })),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
let namespace = args.namespace_id
|
|
101
|
+
? candidates.find((candidate) => candidate.id === args.namespace_id)
|
|
102
|
+
: undefined;
|
|
103
|
+
if (!namespace && args.namespace) {
|
|
104
|
+
const query = args.namespace.toLowerCase();
|
|
105
|
+
const matches = candidates.filter((candidate) => candidate.name.toLowerCase().includes(query));
|
|
106
|
+
if (matches.length === 1)
|
|
107
|
+
namespace = matches[0];
|
|
108
|
+
else if (matches.length === 0) {
|
|
109
|
+
return {
|
|
110
|
+
out: fail(translate("createApp.noNamespaceMatch", {
|
|
111
|
+
namespace: args.namespace,
|
|
112
|
+
names: candidates.map((candidate) => candidate.name).join(", "),
|
|
113
|
+
})),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// One namespace in scope (the whole org, or the chosen account) → auto-pick it.
|
|
118
|
+
if (!namespace && candidates.length === 1)
|
|
119
|
+
namespace = candidates[0];
|
|
120
|
+
if (!namespace) {
|
|
121
|
+
if (skeleton.namespaces.length === 0)
|
|
122
|
+
return { out: fail(translate("createApp.noNamespaces")) };
|
|
123
|
+
return {
|
|
124
|
+
out: await buildFormReply(context, {
|
|
125
|
+
name,
|
|
126
|
+
account: args.account,
|
|
127
|
+
namespace: args.namespace,
|
|
128
|
+
template: args.template,
|
|
129
|
+
template_id: args.template_id,
|
|
130
|
+
repository_url: repoUrl,
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return { namespace };
|
|
135
|
+
}
|
|
136
|
+
/** The convergent "this repo is already an app" reply — shared by the pre-create check and the
|
|
137
|
+
* create-conflict recovery (a retried create that hit "already exists"), so both read identically. */
|
|
138
|
+
async function reusedAppReply(context, app, repoUrl) {
|
|
139
|
+
const orgSlug = await context.org.organizationSlug();
|
|
140
|
+
return reply(translate("createApp.alreadyLinked", { name: app.name, id: app.id, repo: repoUrl }) +
|
|
141
|
+
next(translate("createApp.createdHint")) +
|
|
142
|
+
linkLine(translate("md.dashboard"), dashboardLink(orgSlug, app.nrn)), {
|
|
143
|
+
application: { id: app.id, name: app.name, status: app.status, nrn: app.nrn },
|
|
144
|
+
reused: true,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** Poll a freshly-created app briefly (pending → active), then build its reply — ready vs still-
|
|
148
|
+
* provisioning, with the next-step hint (a scaffolded NEW repo must be cloned before building). */
|
|
149
|
+
async function provisioningReply(context, created, details) {
|
|
150
|
+
let app = created;
|
|
151
|
+
for (let attempt = 0; attempt < 8 && app.status === "pending"; attempt++) {
|
|
152
|
+
await sleep(delays.appPoll);
|
|
153
|
+
app = await getApplication(context.np, created.id).catch(() => app);
|
|
154
|
+
}
|
|
155
|
+
const orgSlug = await context.org.organizationSlug();
|
|
156
|
+
const dashboard = linkLine(translate("md.dashboard"), dashboardLink(orgSlug, app.nrn));
|
|
157
|
+
const recentMessages = (app.messages ?? [])
|
|
158
|
+
.slice(-3)
|
|
159
|
+
.map((entry) => `- ${entry.message ?? JSON.stringify(entry)}`)
|
|
160
|
+
.join("\n");
|
|
161
|
+
const structured = {
|
|
162
|
+
application: { id: app.id, name: details.name, status: app.status, nrn: app.nrn },
|
|
163
|
+
scaffolded: details.scaffolded,
|
|
164
|
+
repository_url: details.repoUrl,
|
|
165
|
+
};
|
|
166
|
+
const createdHint = details.scaffolded
|
|
167
|
+
? translate("createApp.createdHintNew", { repo: details.repoUrl })
|
|
168
|
+
: translate("createApp.createdHint");
|
|
169
|
+
if (app.status === "active") {
|
|
170
|
+
return reply(`${glyph("active")} ${translate("createApp.created", { name: details.name, id: app.id, namespace: details.namespaceName, repo: details.repoUrl })}${recentMessages ? `\n${recentMessages}` : ""}` +
|
|
171
|
+
next(createdHint) +
|
|
172
|
+
dashboard, structured);
|
|
173
|
+
}
|
|
174
|
+
return reply(`${translate("createApp.pending", { name: details.name, id: app.id, status: app.status })}${recentMessages ? `\n${recentMessages}` : ""}` +
|
|
175
|
+
next(translate("createApp.pendingHint", { id: app.id })) +
|
|
176
|
+
dashboard, structured);
|
|
177
|
+
}
|
|
75
178
|
export const createAppTool = defineTool({
|
|
76
179
|
name: TOOL.applicationCreate,
|
|
77
180
|
title: "Create application",
|
|
78
|
-
description: 'Create a nullplatform application. GATE FIRST: if the request is bare and gives you NOTHING to infer from (e.g. just "create an app" / "create a new application" with no hint of what it\'s for or what to name it), DO NOT call this tool yet — first ask the user, in chat, what the app is for AND what to name it, and WAIT for both; call this only once you have the purpose (to pre-fill the namespace) AND the name in hand. Do NOT call it in the meantime, while still waiting on the name — opening a blank or half-filled form and continuing to ask is a failure, not a fallback. When you DO have context, INFER the best-fitting namespace and account from what the app is FOR (a demo → a demos/examples namespace, a backend service → a services namespace) and pass them as `namespace`/`account` so the form opens pre-selected on your inference — the user can still change it. Calling without `provision:true` opens an interactive form and creates nothing — the user confirms the namespace, then either a NEW repository (scaffolded from a template, repo URL auto-generated) or IMPORTS an existing repository (optionally a monorepo folder), and the form itself submits with provision:true to actually create. Only the form\'s submit sets provision:true; you (the model) NEVER set it and NEVER pass a repository_url to "pre-fill" expecting a create — passing a repository_url without provision just opens the form, it does NOT write anything. Provisioning is async — the create waits briefly and reports progress.',
|
|
181
|
+
description: 'Create a nullplatform application. THIS IS THE ONE AND ONLY WAY to create/scaffold/set-up/bootstrap a new app, and it is the PLATFORM\'s job: nullplatform itself creates the git repository (on whatever provider the account is configured for — GitHub, GitLab, Bitbucket, …) from a template and provisions it. So for ANY "create / scaffold / set up / build a new app" request, route it HERE — do NOT brainstorm or design the app first, do NOT run an implementation process, do NOT `git init` or write files locally, and do NOT offer a "scaffold locally first" choice or ask "where should it live / how do we deploy": there is no local path, so that is not a real question. Infer the stack and pass it as `template` ON THE FIRST CALL — a SEMANTIC word ("frontend", "react", "go", "python", "api") is enough; the tool resolves it to the namespace\'s real templates and pre-selects the best fit, so do NOT open the form with the template blank and then recommend one in text. Never ask an open "what stack / what scope" brainstorm question. GATE FIRST: if the request is bare and gives you NOTHING to infer from (e.g. just "create an app" / "create a new application" with no hint of what it\'s for or what to name it), DO NOT call this tool yet — first ask the user, in chat, what the app is for AND what to name it, and WAIT for both; call this only once you have the purpose (to pre-fill the namespace) AND the name in hand. Do NOT call it in the meantime, while still waiting on the name — opening a blank or half-filled form and continuing to ask is a failure, not a fallback. When you DO have context, INFER the best-fitting namespace and account from what the app is FOR (a demo → a demos/examples namespace, a backend service → a services namespace) and pass them as `namespace`/`account` so the form opens pre-selected on your inference — the user can still change it. Calling without `provision:true` opens an interactive form and creates nothing — the user confirms the namespace, then either a NEW repository (scaffolded from a template, repo URL auto-generated) or IMPORTS an existing repository (optionally a monorepo folder), and the form itself submits with provision:true to actually create. Only the form\'s submit sets provision:true; you (the model) NEVER set it and NEVER pass a repository_url to "pre-fill" expecting a create — passing a repository_url without provision just opens the form, it does NOT write anything. Provisioning is async — the create waits briefly and reports progress.',
|
|
79
182
|
annotations: { destructiveHint: false, openWorldHint: true },
|
|
80
183
|
widget: "create-app",
|
|
81
184
|
errorKey: "createApp.errorLabel",
|
|
@@ -97,7 +200,7 @@ export const createAppTool = defineTool({
|
|
|
97
200
|
template: z
|
|
98
201
|
.string()
|
|
99
202
|
.optional()
|
|
100
|
-
.describe('
|
|
203
|
+
.describe('Stack to scaffold from — ALWAYS pass this on the first call so the form opens with the template ALREADY selected; never open the form with this blank and then recommend a template in text. Two ways to fill it: (1) if a single SEMANTIC word uniquely identifies the stack from the app\'s purpose/name (a Python service → "python", a Go API → "go"), pass that word — the tool matches it to the namespace\'s real templates by name + tags. (2) if a semantic word would be AMBIGUOUS — several templates could match (e.g. multiple frontend kits), or you want the BEST fit for the use case (a bank demo → a bank-themed frontend kit) — you MUST first LIST the templates headlessly with `entity_list entity:"template" parent_type:"namespace" parent_id:<namespace_id>` (NO panel), pick the SPECIFIC one, and pass its name here. Do the entity_list BEFORE opening this form. (prefer template_id only when you already know the exact id.)'),
|
|
101
204
|
template_id: z
|
|
102
205
|
.number()
|
|
103
206
|
.optional()
|
|
@@ -132,24 +235,8 @@ export const createAppTool = defineTool({
|
|
|
132
235
|
}
|
|
133
236
|
// Convergent under retries: a repo already linked to an app returns that app, never a duplicate.
|
|
134
237
|
const alreadyLinked = await context.org.inferAppFromRepo(repoUrl);
|
|
135
|
-
if (alreadyLinked)
|
|
136
|
-
|
|
137
|
-
return reply(translate("createApp.alreadyLinked", {
|
|
138
|
-
name: alreadyLinked.name,
|
|
139
|
-
id: alreadyLinked.id,
|
|
140
|
-
repo: repoUrl,
|
|
141
|
-
}) +
|
|
142
|
-
next(translate("createApp.createdHint")) +
|
|
143
|
-
linkLine(translate("md.dashboard"), dashboardLink(orgSlug, alreadyLinked.nrn)), {
|
|
144
|
-
application: {
|
|
145
|
-
id: alreadyLinked.id,
|
|
146
|
-
name: alreadyLinked.name,
|
|
147
|
-
status: alreadyLinked.status,
|
|
148
|
-
nrn: alreadyLinked.nrn,
|
|
149
|
-
},
|
|
150
|
-
reused: true,
|
|
151
|
-
});
|
|
152
|
-
}
|
|
238
|
+
if (alreadyLinked)
|
|
239
|
+
return reusedAppReply(context, alreadyLinked, repoUrl);
|
|
153
240
|
const name = args.name ??
|
|
154
241
|
repoUrl
|
|
155
242
|
.split("/")
|
|
@@ -157,51 +244,12 @@ export const createAppTool = defineTool({
|
|
|
157
244
|
?.replace(/\.git$/, "");
|
|
158
245
|
if (!name)
|
|
159
246
|
return fail(translate("createApp.noName", { url: repoUrl }));
|
|
160
|
-
// Resolve where the app lives — an (account, namespace) pair.
|
|
161
|
-
// (a
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
const candidates = accountQuery
|
|
167
|
-
? skeleton.namespaces.filter((candidate) => (candidate.account_name ?? "").toLowerCase().includes(accountQuery))
|
|
168
|
-
: skeleton.namespaces;
|
|
169
|
-
if (accountQuery && candidates.length === 0) {
|
|
170
|
-
const accounts = [
|
|
171
|
-
...new Set(skeleton.namespaces.map((candidate) => candidate.account_name).filter(Boolean)),
|
|
172
|
-
];
|
|
173
|
-
return fail(translate("createApp.noAccountMatch", { account: args.account ?? "", names: accounts.join(", ") }));
|
|
174
|
-
}
|
|
175
|
-
let namespace = args.namespace_id
|
|
176
|
-
? candidates.find((candidate) => candidate.id === args.namespace_id)
|
|
177
|
-
: undefined;
|
|
178
|
-
if (!namespace && args.namespace) {
|
|
179
|
-
const query = args.namespace.toLowerCase();
|
|
180
|
-
const matches = candidates.filter((candidate) => candidate.name.toLowerCase().includes(query));
|
|
181
|
-
if (matches.length === 1)
|
|
182
|
-
namespace = matches[0];
|
|
183
|
-
else if (matches.length === 0) {
|
|
184
|
-
return fail(translate("createApp.noNamespaceMatch", {
|
|
185
|
-
namespace: args.namespace,
|
|
186
|
-
names: candidates.map((candidate) => candidate.name).join(", "),
|
|
187
|
-
}));
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
// One namespace in scope (the whole org, or the chosen account) → auto-pick it.
|
|
191
|
-
if (!namespace && candidates.length === 1)
|
|
192
|
-
namespace = candidates[0];
|
|
193
|
-
if (!namespace) {
|
|
194
|
-
if (skeleton.namespaces.length === 0)
|
|
195
|
-
return fail(translate("createApp.noNamespaces"));
|
|
196
|
-
return buildFormReply(context, {
|
|
197
|
-
name,
|
|
198
|
-
account: args.account,
|
|
199
|
-
namespace: args.namespace,
|
|
200
|
-
template: args.template,
|
|
201
|
-
template_id: args.template_id,
|
|
202
|
-
repository_url: repoUrl,
|
|
203
|
-
});
|
|
204
|
-
}
|
|
247
|
+
// Resolve where the app lives — an (account, namespace) pair. Returns the pinned namespace, or
|
|
248
|
+
// an `out` reply (a fail on no match, or the form picker when it's a real choice) — see the helper.
|
|
249
|
+
const resolvedNamespace = await resolveCreateNamespace(context, args, name, repoUrl);
|
|
250
|
+
if ("out" in resolvedNamespace)
|
|
251
|
+
return resolvedNamespace.out;
|
|
252
|
+
const namespace = resolvedNamespace.namespace;
|
|
205
253
|
let created;
|
|
206
254
|
try {
|
|
207
255
|
created = await createApplication(context.np, {
|
|
@@ -222,50 +270,16 @@ export const createAppTool = defineTool({
|
|
|
222
270
|
(await context.org.findApps({ query: name, namespace: namespace.name }).catch(() => [])).find((candidate) => candidate.name.toLowerCase() === name.toLowerCase() && candidate.namespace_id === namespace.id);
|
|
223
271
|
if (!existing)
|
|
224
272
|
throw caught;
|
|
225
|
-
|
|
226
|
-
return reply(translate("createApp.alreadyLinked", { name: existing.name, id: existing.id, repo: repoUrl }) +
|
|
227
|
-
next(translate("createApp.createdHint")) +
|
|
228
|
-
linkLine(translate("md.dashboard"), dashboardLink(orgSlug, existing.nrn)), {
|
|
229
|
-
application: {
|
|
230
|
-
id: existing.id,
|
|
231
|
-
name: existing.name,
|
|
232
|
-
status: existing.status,
|
|
233
|
-
nrn: existing.nrn,
|
|
234
|
-
},
|
|
235
|
-
reused: true,
|
|
236
|
-
});
|
|
273
|
+
return reusedAppReply(context, existing, repoUrl);
|
|
237
274
|
}
|
|
238
|
-
// Provisioning is async (pending -> active); wait briefly so the common case answers "ready".
|
|
239
|
-
let app = created;
|
|
240
|
-
for (let attempt = 0; attempt < 8 && app.status === "pending"; attempt++) {
|
|
241
|
-
await sleep(delays.appPoll);
|
|
242
|
-
app = await getApplication(context.np, created.id).catch(() => app);
|
|
243
|
-
}
|
|
244
|
-
const orgSlug = await context.org.organizationSlug();
|
|
245
|
-
const dashboard = linkLine(translate("md.dashboard"), dashboardLink(orgSlug, app.nrn));
|
|
246
|
-
const recentMessages = (app.messages ?? [])
|
|
247
|
-
.slice(-3)
|
|
248
|
-
.map((entry) => `- ${entry.message ?? JSON.stringify(entry)}`)
|
|
249
|
-
.join("\n");
|
|
250
275
|
// A new repo scaffolded from a template is a fresh REMOTE repo the developer has never cloned —
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
};
|
|
259
|
-
const createdHint = scaffolded
|
|
260
|
-
? translate("createApp.createdHintNew", { repo: repoUrl })
|
|
261
|
-
: translate("createApp.createdHint");
|
|
262
|
-
if (app.status === "active") {
|
|
263
|
-
return reply(`${glyph("active")} ${translate("createApp.created", { name, id: app.id, namespace: namespace.name, repo: repoUrl })}${recentMessages ? `\n${recentMessages}` : ""}` +
|
|
264
|
-
next(createdHint) +
|
|
265
|
-
dashboard, structured);
|
|
266
|
-
}
|
|
267
|
-
return reply(`${translate("createApp.pending", { name, id: app.id, status: app.status })}${recentMessages ? `\n${recentMessages}` : ""}` +
|
|
268
|
-
next(translate("createApp.pendingHint", { id: app.id })) +
|
|
269
|
-
dashboard, structured);
|
|
276
|
+
// its next move is to clone-then-build; an import is already local, so it just pushes. The two
|
|
277
|
+
// paths get different guidance (carried by `scaffolded`). Poll briefly so the common case is ready.
|
|
278
|
+
return provisioningReply(context, created, {
|
|
279
|
+
name,
|
|
280
|
+
namespaceName: namespace.name,
|
|
281
|
+
repoUrl,
|
|
282
|
+
scaffolded: args.template_id !== undefined,
|
|
283
|
+
});
|
|
270
284
|
},
|
|
271
285
|
});
|
|
@@ -9,6 +9,71 @@ const slug = (text) => text
|
|
|
9
9
|
.toLowerCase()
|
|
10
10
|
.replace(/[^a-z0-9]+/g, "-")
|
|
11
11
|
.replace(/^-+|-+$/g, "");
|
|
12
|
+
/**
|
|
13
|
+
* Pick which provisioned service to link from the `service` hint. Mirrors requireApp's shape:
|
|
14
|
+
* returns the single matched service, or an `out` reply — a `fail` when the app has no linkable
|
|
15
|
+
* services, or the pick-service panel when the hint is missing/ambiguous (zero or many matches).
|
|
16
|
+
*/
|
|
17
|
+
async function resolveLinkService(context, app, serviceHint, dashboard) {
|
|
18
|
+
const services = (await listAppServices(context.np, app.nrn)).filter((service) => service.status !== "failed");
|
|
19
|
+
if (services.length === 0) {
|
|
20
|
+
return {
|
|
21
|
+
out: fail(translate("createLink.noServices", { app: app.name }) + next(translate("createLink.noServicesHint"))),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const wanted = serviceHint?.toLowerCase();
|
|
25
|
+
const matched = wanted
|
|
26
|
+
? services.filter((service) => service.name.toLowerCase().includes(wanted) || service.id === serviceHint)
|
|
27
|
+
: [];
|
|
28
|
+
if (matched.length === 1)
|
|
29
|
+
return { service: matched[0] };
|
|
30
|
+
// Zero or many matches → let the user pick (the panel lists the candidates, or all services).
|
|
31
|
+
const list = matched.length > 1 ? matched : services;
|
|
32
|
+
const md = `${translate(matched.length > 1 ? "createLink.matchAmbiguous" : "createLink.pickService", {
|
|
33
|
+
app: app.name,
|
|
34
|
+
})}\n\n${table([translate("header.name"), translate("header.status")], list.map((service) => [service.name, service.status]))}${next(translate("createLink.pickHint"))}`;
|
|
35
|
+
return {
|
|
36
|
+
out: reply(md, {
|
|
37
|
+
mode: "pick-service",
|
|
38
|
+
app: `#${app.id}`,
|
|
39
|
+
app_name: app.name,
|
|
40
|
+
services: list,
|
|
41
|
+
dashboard,
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Create the link, or reuse an existing non-failed one on the same target (convergent under
|
|
47
|
+
* retries). A credential link (linkCreate present) is created WITHOUT activate-now and then has its
|
|
48
|
+
* create-action enqueued — the agent provisions it; a plain link activates immediately on creation.
|
|
49
|
+
*/
|
|
50
|
+
async function createOrReuseLink(context, input) {
|
|
51
|
+
const { service, target, linkSpec, linkCreate } = input;
|
|
52
|
+
const existing = (await listLinks(context.np, target.nrn)).find((link) => link.service_id === service.id && link.status !== "failed");
|
|
53
|
+
if (existing) {
|
|
54
|
+
return { link: { id: existing.id, status: existing.status, service_id: service.id }, reused: true };
|
|
55
|
+
}
|
|
56
|
+
const link = await createLink(context.np, {
|
|
57
|
+
name: `lnk ${slug(service.name)}`,
|
|
58
|
+
service_id: service.id,
|
|
59
|
+
entity_nrn: target.nrn,
|
|
60
|
+
specification_id: linkSpec.id,
|
|
61
|
+
dimensions: target.dimensions,
|
|
62
|
+
// No create action → no agent will provision it, so it MUST be sent active or it sticks
|
|
63
|
+
// `pending` forever. With one, OMIT status and enqueue the create action.
|
|
64
|
+
activateImmediately: !linkCreate,
|
|
65
|
+
});
|
|
66
|
+
if (linkCreate) {
|
|
67
|
+
// THE link action — POST /link/{id}/action with the link's own parameters (e.g. the
|
|
68
|
+
// database-user's permissions). Without it the link is `pending` with no credentials.
|
|
69
|
+
await createLinkAction(context.np, link.id, {
|
|
70
|
+
name: `create-${slug(service.name)}-link`,
|
|
71
|
+
specification_id: linkCreate.id,
|
|
72
|
+
parameters: input.parameters ?? {},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return { link, reused: false };
|
|
76
|
+
}
|
|
12
77
|
/**
|
|
13
78
|
* Link a provisioned dependency service to an application or one of its scopes — a SEPARATE
|
|
14
79
|
* operation from provisioning it (`application_service_create`). Creating the link is one or two
|
|
@@ -52,29 +117,11 @@ export const createLinkTool = defineTool({
|
|
|
52
117
|
return fail(translate("resolve.noNrn", { app: app.name }));
|
|
53
118
|
const orgSlug = await context.org.organizationSlug();
|
|
54
119
|
const dashboard = dashboardLink(orgSlug, app.nrn);
|
|
55
|
-
// The app's provisioned services — pick which one to link.
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
return
|
|
59
|
-
|
|
60
|
-
const wanted = args.service?.toLowerCase();
|
|
61
|
-
const matched = wanted
|
|
62
|
-
? services.filter((service) => service.name.toLowerCase().includes(wanted) || service.id === args.service)
|
|
63
|
-
: [];
|
|
64
|
-
if (matched.length !== 1) {
|
|
65
|
-
const list = matched.length > 1 ? matched : services;
|
|
66
|
-
const md = `${translate(matched.length > 1 ? "createLink.matchAmbiguous" : "createLink.pickService", {
|
|
67
|
-
app: app.name,
|
|
68
|
-
})}\n\n${table([translate("header.name"), translate("header.status")], list.map((service) => [service.name, service.status]))}${next(translate("createLink.pickHint"))}`;
|
|
69
|
-
return reply(md, {
|
|
70
|
-
mode: "pick-service",
|
|
71
|
-
app: `#${app.id}`,
|
|
72
|
-
app_name: app.name,
|
|
73
|
-
services: list,
|
|
74
|
-
dashboard,
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
const service = matched[0];
|
|
120
|
+
// The app's provisioned services — pick which one to link (or surface the picker / no-services fail).
|
|
121
|
+
const resolvedService = await resolveLinkService(context, { id: app.id, name: app.name, nrn: app.nrn }, args.service, dashboard);
|
|
122
|
+
if ("out" in resolvedService)
|
|
123
|
+
return resolvedService.out;
|
|
124
|
+
const service = resolvedService.service;
|
|
78
125
|
// The link spec that applies to this service + its create-action (the form's params). Prefer the
|
|
79
126
|
// spec that PROVISIONS credentials (use_default_actions) — e.g. Postgres `database-user`.
|
|
80
127
|
const linkSpecs = service.specification_id
|
|
@@ -116,37 +163,17 @@ export const createLinkTool = defineTool({
|
|
|
116
163
|
dashboard,
|
|
117
164
|
});
|
|
118
165
|
}
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
link = await createLink(context.np, {
|
|
128
|
-
name: `lnk ${slug(service.name)}`,
|
|
129
|
-
service_id: service.id,
|
|
130
|
-
entity_nrn: target.nrn,
|
|
131
|
-
specification_id: linkSpec.id,
|
|
132
|
-
dimensions: target.dimensions,
|
|
133
|
-
// No create action → no agent will provision it, so it MUST be sent active or it sticks
|
|
134
|
-
// `pending` forever. With one, OMIT status and enqueue the create action below.
|
|
135
|
-
activateImmediately: !linkCreate,
|
|
136
|
-
});
|
|
137
|
-
if (linkCreate) {
|
|
138
|
-
// THE link action — POST /link/{id}/action with the link's own parameters (e.g. the
|
|
139
|
-
// database-user's permissions). Without it the link is `pending` with no credentials.
|
|
140
|
-
await createLinkAction(context.np, link.id, {
|
|
141
|
-
name: `create-${slug(service.name)}-link`,
|
|
142
|
-
specification_id: linkCreate.id,
|
|
143
|
-
parameters: args.parameters ?? {},
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
}
|
|
166
|
+
// Create the link (or reuse a non-failed one on this target — convergent under retries).
|
|
167
|
+
const { link: linked, reused } = await createOrReuseLink(context, {
|
|
168
|
+
service,
|
|
169
|
+
target,
|
|
170
|
+
linkSpec,
|
|
171
|
+
linkCreate,
|
|
172
|
+
parameters: args.parameters,
|
|
173
|
+
});
|
|
147
174
|
await sleep(delays.appPoll);
|
|
148
|
-
link = await getLink(context.np,
|
|
149
|
-
const headline =
|
|
175
|
+
const link = await getLink(context.np, linked.id).catch(() => linked);
|
|
176
|
+
const headline = reused
|
|
150
177
|
? translate("createLink.reused", { service: service.name, target: target.label, status: link.status })
|
|
151
178
|
: translate("createLink.linking", { service: service.name, target: target.label, status: link.status });
|
|
152
179
|
const md = `${headline}${next(translate("createLink.linkedHint"))}${linkLine(translate("md.dashboard"), dashboard)}`;
|
|
@@ -156,7 +183,7 @@ export const createLinkTool = defineTool({
|
|
|
156
183
|
app_name: app.name,
|
|
157
184
|
service: { id: service.id, name: service.name },
|
|
158
185
|
link: { id: link.id, status: link.status, target: target.label },
|
|
159
|
-
reused
|
|
186
|
+
reused,
|
|
160
187
|
dashboard,
|
|
161
188
|
});
|
|
162
189
|
},
|
|
@@ -6,13 +6,41 @@ import { defineTool, fail, reply } from "../tool.js";
|
|
|
6
6
|
import { TOOL } from "../tool-names.js";
|
|
7
7
|
import { appArg, dimsLabel, requireApp } from "./shared.js";
|
|
8
8
|
import { buildAppStatus } from "./status.js";
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the scope `type` (+ provider) to provision with. An explicit `type` wins; otherwise, for
|
|
11
|
+
* an app with an NRN, auto-pick the org's single scope type. Mirrors requireApp's shape: returns
|
|
12
|
+
* { type, provider } (type left undefined when there are none — the handler fails on that), or an
|
|
13
|
+
* `out` reply — the which-type form (riding the overview) when the org offers several.
|
|
14
|
+
*/
|
|
15
|
+
async function resolveScopeType(context, app, scopeName, argType, argProvider) {
|
|
16
|
+
if (argType || !app.nrn)
|
|
17
|
+
return { type: argType, provider: argProvider };
|
|
18
|
+
const scopeTypes = await listScopeTypes(context.np, app.nrn);
|
|
19
|
+
const onlyType = scopeTypes.length === 1 ? scopeTypes[0] : undefined;
|
|
20
|
+
if (onlyType) {
|
|
21
|
+
return { type: onlyType.type ?? onlyType.name, provider: argProvider ?? onlyType.provider ?? undefined };
|
|
22
|
+
}
|
|
23
|
+
if (scopeTypes.length > 1) {
|
|
24
|
+
// The create-scope form picks a type from scope_types; the overview rides along so a fresh app
|
|
25
|
+
// (no scopes yet) renders that form instead of an empty panel.
|
|
26
|
+
const { structured } = await buildAppStatus(context, app);
|
|
27
|
+
return {
|
|
28
|
+
out: reply(`${translate("createScope.whichType", { name: scopeName })}\n\n${table([translate("header.type"), translate("header.name"), translate("header.provider")], scopeTypes.map((scopeType) => [
|
|
29
|
+
scopeType.type ?? "",
|
|
30
|
+
scopeType.name ?? "",
|
|
31
|
+
scopeType.provider ?? "",
|
|
32
|
+
]))}${next(translate("createScope.typeHint", { name: scopeName }))}`, { ...structured, scope_types: scopeTypes }),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return { type: argType, provider: argProvider };
|
|
36
|
+
}
|
|
9
37
|
export const createScopeTool = defineTool({
|
|
10
38
|
name: TOOL.applicationScopeCreate,
|
|
11
39
|
title: "Create scope",
|
|
12
40
|
description: 'Create a SCOPE — a deploy target (e.g. dev/staging/main) — for an application; provisions real infrastructure asynchronously. Use it for "add a dev/staging/prod environment", "create a scope", "set up a new deploy target". Pass `name`; the scope `type` is inferred or auto-pinned when the org has one, else the form lists the org\'s types to pick from; `dimensions` default to the shape of an existing scope when the org uses them. Convergent: an existing scope of the same name is returned, never duplicated. Renders the app panel; once active, deploy to it with application_deployment_create scope:"<name>".',
|
|
13
41
|
// Convergent under retries (reuses the scope-by-name), so idempotent.
|
|
14
42
|
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
15
|
-
widget: "
|
|
43
|
+
widget: "status",
|
|
16
44
|
errorKey: "createScope.errorLabel",
|
|
17
45
|
inputSchema: {
|
|
18
46
|
app: appArg,
|
|
@@ -42,26 +70,10 @@ export const createScopeTool = defineTool({
|
|
|
42
70
|
next(translate("createScope.provisioningHint", { name: existing.name })) +
|
|
43
71
|
linkLine(translate("md.dashboard"), dashboardLink(orgSlug, existing.nrn)), { ...status.structured, scope: existing, reused: true });
|
|
44
72
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const onlyType = scopeTypes.length === 1 ? scopeTypes[0] : undefined;
|
|
50
|
-
if (onlyType) {
|
|
51
|
-
type = onlyType.type ?? onlyType.name;
|
|
52
|
-
provider = provider ?? onlyType.provider ?? undefined;
|
|
53
|
-
}
|
|
54
|
-
else if (scopeTypes.length > 1) {
|
|
55
|
-
// np-panel's create-scope form picks a type from scope_types; the overview rides along
|
|
56
|
-
// so a fresh app (no scopes yet) renders that form instead of an empty panel.
|
|
57
|
-
const { structured } = await buildAppStatus(context, app);
|
|
58
|
-
return reply(`${translate("createScope.whichType", { name: args.name })}\n\n${table([translate("header.type"), translate("header.name"), translate("header.provider")], scopeTypes.map((scopeType) => [
|
|
59
|
-
scopeType.type ?? "",
|
|
60
|
-
scopeType.name ?? "",
|
|
61
|
-
scopeType.provider ?? "",
|
|
62
|
-
]))}${next(translate("createScope.typeHint", { name: args.name }))}`, { ...structured, scope_types: scopeTypes });
|
|
63
|
-
}
|
|
64
|
-
}
|
|
73
|
+
const typeResult = await resolveScopeType(context, app, args.name, args.type, args.provider);
|
|
74
|
+
if ("out" in typeResult)
|
|
75
|
+
return typeResult.out;
|
|
76
|
+
const { type, provider } = typeResult;
|
|
65
77
|
if (!type)
|
|
66
78
|
return fail(translate("createScope.noTypes"));
|
|
67
79
|
// Orgs with runtime dimensions usually require them — borrow the shape from a sibling scope.
|
|
@@ -78,7 +90,7 @@ export const createScopeTool = defineTool({
|
|
|
78
90
|
provider,
|
|
79
91
|
dimensions,
|
|
80
92
|
});
|
|
81
|
-
// Hand off into
|
|
93
|
+
// Hand off into the status panel: the overview now includes the provisioning scope, so the panel
|
|
82
94
|
// shows it alongside a Deploy button — the literal next move (read-after-write).
|
|
83
95
|
const [orgSlug, status] = await Promise.all([
|
|
84
96
|
context.org.organizationSlug(),
|
|
@@ -19,6 +19,38 @@ function matchSpecs(specs, query) {
|
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
21
|
const specCategory = (spec) => [spec.category, spec.subCategory].filter(Boolean).join(" › ");
|
|
22
|
+
/**
|
|
23
|
+
* Pick which catalog dependency to provision from the `specification_id` / `type` hints. Mirrors
|
|
24
|
+
* requireApp's shape: returns the matched spec, or an `out` reply — a `fail` when the app's catalog
|
|
25
|
+
* is empty, or the pick-spec panel when the hint is missing/ambiguous (resolved via the shared
|
|
26
|
+
* resolveChoice on name + category + sub-category, never an arbitrary first entry).
|
|
27
|
+
*/
|
|
28
|
+
async function resolveServiceSpec(context, app, hints, dashboard) {
|
|
29
|
+
const specs = await listDependencySpecs(context.np, app.nrn);
|
|
30
|
+
if (specs.length === 0) {
|
|
31
|
+
return {
|
|
32
|
+
out: fail(translate("createService.noSpecs", { app: app.name }) +
|
|
33
|
+
linkLine(translate("md.dashboard"), dashboard)),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const spec = resolveChoice(specs, { id: hints.specification_id, text: hints.type }, {
|
|
37
|
+
haystack: (candidate) => `${candidate.name} ${candidate.category ?? ""} ${candidate.subCategory ?? ""}`,
|
|
38
|
+
});
|
|
39
|
+
if (spec)
|
|
40
|
+
return { spec };
|
|
41
|
+
// None / ambiguous → return the catalog (or the narrowed matches) to pick from.
|
|
42
|
+
const typed = hints.type ? matchSpecs(specs, hints.type) : [];
|
|
43
|
+
const list = typed.length > 1 ? typed : specs;
|
|
44
|
+
const heading = translate(typed.length > 1 ? "createService.matchAmbiguous" : "createService.pickSpec", {
|
|
45
|
+
app: app.name,
|
|
46
|
+
query: hints.type ?? "",
|
|
47
|
+
count: specs.length,
|
|
48
|
+
});
|
|
49
|
+
const md = `${heading}\n\n${table([translate("header.name"), translate("createService.category"), translate("createService.provider")], list.map((candidate) => [candidate.name, specCategory(candidate), candidate.provider ?? ""]))}${next(translate("createService.pickHint"))}`;
|
|
50
|
+
return {
|
|
51
|
+
out: reply(md, { mode: "pick-spec", app: `#${app.id}`, app_name: app.name, specs: list, dashboard }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
22
54
|
/**
|
|
23
55
|
* Provision a dependency service (the service record + its create action). Linking it to an app or
|
|
24
56
|
* scope is a SEPARATE operation (`application_link_create`) — two distinct platform entities with
|
|
@@ -27,7 +59,7 @@ const specCategory = (spec) => [spec.category, spec.subCategory].filter(Boolean)
|
|
|
27
59
|
export const createServiceTool = defineTool({
|
|
28
60
|
name: TOOL.applicationServiceCreate,
|
|
29
61
|
title: "Provision service",
|
|
30
|
-
description: 'Provision a dependency service (SQL database, queue, cache, …) for an application.
|
|
62
|
+
description: 'Provision a dependency service (SQL database, queue, cache, …) for an application. ALWAYS pass a SEMANTIC `type` from what the user asked for (e.g. "postgres", "redis", "queue", "cache") — you do NOT need the exact catalog spec name/id; the tool matches your word to the real catalog and pre-selects it, so the form opens with the dependency already chosen. Do NOT open the form with no `type` and then list the catalog or recommend one in text — pre-select it. Provisioning creates real cloud resources (cost-bearing) and runs asynchronously, so call WITHOUT `provision:true` first to open the form; the user confirms by submitting it. Once it is active, LINK it to a scope with application_link_create so its connection details flow in as parameters.',
|
|
31
63
|
annotations: { destructiveHint: false, openWorldHint: true },
|
|
32
64
|
widget: "service-create",
|
|
33
65
|
errorKey: "createService.errorLabel",
|
|
@@ -60,29 +92,11 @@ export const createServiceTool = defineTool({
|
|
|
60
92
|
return fail(translate("resolve.noNrn", { app: app.name }));
|
|
61
93
|
const orgSlug = await context.org.organizationSlug();
|
|
62
94
|
const dashboard = dashboardLink(orgSlug, app.nrn);
|
|
63
|
-
// The provisionable catalog
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
// Pre-select via the shared resolver (same rule as namespace/template): an exact spec id (form
|
|
70
|
-
// submit) or the model's `type` hint, matched against the catalog on name + category + sub-category.
|
|
71
|
-
// None / ambiguous → return the catalog (or the narrowed matches) to pick from — never an arbitrary one.
|
|
72
|
-
const spec = resolveChoice(specs, { id: args.specification_id, text: args.type }, {
|
|
73
|
-
haystack: (candidate) => `${candidate.name} ${candidate.category ?? ""} ${candidate.subCategory ?? ""}`,
|
|
74
|
-
});
|
|
75
|
-
if (!spec) {
|
|
76
|
-
const typed = args.type ? matchSpecs(specs, args.type) : [];
|
|
77
|
-
const list = typed.length > 1 ? typed : specs;
|
|
78
|
-
const heading = translate(typed.length > 1 ? "createService.matchAmbiguous" : "createService.pickSpec", {
|
|
79
|
-
app: app.name,
|
|
80
|
-
query: args.type ?? "",
|
|
81
|
-
count: specs.length,
|
|
82
|
-
});
|
|
83
|
-
const md = `${heading}\n\n${table([translate("header.name"), translate("createService.category"), translate("createService.provider")], list.map((candidate) => [candidate.name, specCategory(candidate), candidate.provider ?? ""]))}${next(translate("createService.pickHint"))}`;
|
|
84
|
-
return reply(md, { mode: "pick-spec", app: `#${app.id}`, app_name: app.name, specs: list, dashboard });
|
|
85
|
-
}
|
|
95
|
+
// The provisionable catalog → pre-select the spec (or surface the picker / empty-catalog fail).
|
|
96
|
+
const resolvedSpec = await resolveServiceSpec(context, { id: app.id, name: app.name, nrn: app.nrn }, { specification_id: args.specification_id, type: args.type }, dashboard);
|
|
97
|
+
if ("out" in resolvedSpec)
|
|
98
|
+
return resolvedSpec.out;
|
|
99
|
+
const spec = resolvedSpec.spec;
|
|
86
100
|
const createAction = await serviceCreateActionSpec(context.np, spec.id);
|
|
87
101
|
const name = (args.name ?? spec.name).trim();
|
|
88
102
|
// Form-first: no explicit provision OR a required create-action param still missing → show the
|