@nullplatform/mcp 0.1.13 → 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.
Files changed (57) hide show
  1. package/README.md +10 -0
  2. package/dist/http.js +16 -12
  3. package/dist/i18n.js +6 -0
  4. package/dist/log.js +53 -0
  5. package/dist/np/client.js +10 -1
  6. package/dist/np/context.js +30 -21
  7. package/dist/np/journey.js +14 -4
  8. package/dist/render.js +12 -13
  9. package/dist/surfaces/developer.js +21 -4
  10. package/dist/tool.js +64 -6
  11. package/dist/tools/approvals.js +1 -1
  12. package/dist/tools/builds.js +1 -1
  13. package/dist/tools/create-app.js +158 -132
  14. package/dist/tools/create-link.js +82 -55
  15. package/dist/tools/create-release.js +1 -1
  16. package/dist/tools/create-scope.js +35 -23
  17. package/dist/tools/create-service.js +39 -25
  18. package/dist/tools/delete-link.js +1 -1
  19. package/dist/tools/delete-param.js +1 -1
  20. package/dist/tools/delete-service.js +1 -1
  21. package/dist/tools/deploy.js +52 -30
  22. package/dist/tools/deployments.js +1 -1
  23. package/dist/tools/entity-get.js +1 -1
  24. package/dist/tools/entity-list.js +14 -6
  25. package/dist/tools/find-apps.js +5 -2
  26. package/dist/tools/logs.js +114 -17
  27. package/dist/tools/metrics.js +1 -1
  28. package/dist/tools/overview.js +1 -1
  29. package/dist/tools/params.js +8 -2
  30. package/dist/tools/playbook.js +1 -1
  31. package/dist/tools/releases.js +1 -1
  32. package/dist/tools/services.js +1 -1
  33. package/dist/tools/set-params.js +18 -19
  34. package/dist/tools/shared.js +52 -0
  35. package/dist/tools/status.js +4 -4
  36. package/dist/tools/traffic.js +38 -26
  37. package/dist/tools/update-link.js +1 -1
  38. package/dist/tools/update-service.js +1 -1
  39. package/dist/ui.js +1 -1
  40. package/package.json +5 -1
  41. package/widgets-dist/approvals.html +120 -10
  42. package/widgets-dist/builds.html +127 -17
  43. package/widgets-dist/create-app.html +129 -19
  44. package/widgets-dist/deployments.html +132 -22
  45. package/widgets-dist/find-apps.html +131 -21
  46. package/widgets-dist/logs.html +133 -23
  47. package/widgets-dist/manifest.json +16 -16
  48. package/widgets-dist/metrics.html +120 -10
  49. package/widgets-dist/overview.html +136 -26
  50. package/widgets-dist/params.html +121 -11
  51. package/widgets-dist/releases.html +136 -26
  52. package/widgets-dist/service-action.html +124 -14
  53. package/widgets-dist/service-create.html +123 -13
  54. package/widgets-dist/service-delete.html +120 -10
  55. package/widgets-dist/service-link.html +123 -13
  56. package/widgets-dist/services.html +120 -10
  57. package/widgets-dist/{np-panel.html → status.html} +135 -25
@@ -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
- description: "Create a scope (a deploy target, e.g. dev/staging/main) for an application. This provisions real infrastructure asynchronously. If the org has several scope types and none is given, it lists them to pick from.",
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: "np-panel",
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
- let type = args.type;
46
- let provider = args.provider;
47
- if (!type && app.nrn) {
48
- const scopeTypes = await listScopeTypes(context.np, app.nrn);
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 np-panel: the overview now includes the provisioning scope, so the panel
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(),
@@ -4,7 +4,7 @@ import { dashboardLink, linkLine, next, table } from "../md.js";
4
4
  import { createServiceAction, getService, listAppServices, listDependencySpecs, provisionService, serviceCreateActionSpec, } from "../np/journey.js";
5
5
  import { defineTool, fail, reply } from "../tool.js";
6
6
  import { TOOL } from "../tool-names.js";
7
- import { appArg, delays, requireApp, sleep } from "./shared.js";
7
+ import { appArg, delays, requireApp, resolveChoice, sleep } from "./shared.js";
8
8
  const slug = (text) => text
9
9
  .toLowerCase()
10
10
  .replace(/[^a-z0-9]+/g, "-")
@@ -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. Pass `type` to choose the dependency (e.g. "postgres", "redis"). 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.',
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 (core API carries the action specs the write path needs).
64
- const specs = await listDependencySpecs(context.np, app.nrn);
65
- if (specs.length === 0) {
66
- return fail(translate("createService.noSpecs", { app: app.name }) +
67
- linkLine(translate("md.dashboard"), dashboard));
68
- }
69
- // Match the requested dependency: exact id (form submit) wins; else fuzzy `type`. None or
70
- // ambiguous → return the catalog to pick from.
71
- const direct = args.specification_id
72
- ? specs.find((spec) => spec.id === args.specification_id)
73
- : undefined;
74
- const matches = direct ? [direct] : args.type ? matchSpecs(specs, args.type) : [];
75
- if (matches.length !== 1) {
76
- const list = matches.length > 1 ? matches : specs;
77
- const heading = translate(matches.length > 1 ? "createService.matchAmbiguous" : "createService.pickSpec", {
78
- app: app.name,
79
- query: args.type ?? "",
80
- count: specs.length,
81
- });
82
- const md = `${heading}\n\n${table([translate("header.name"), translate("createService.category"), translate("createService.provider")], list.map((spec) => [spec.name, specCategory(spec), spec.provider ?? ""]))}${next(translate("createService.pickHint"))}`;
83
- return reply(md, { mode: "pick-spec", app: `#${app.id}`, app_name: app.name, specs: list, dashboard });
84
- }
85
- const spec = matches[0];
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
@@ -19,7 +19,7 @@ const slug = (text) => text
19
19
  export const deleteLinkTool = defineTool({
20
20
  name: TOOL.applicationLinkDelete,
21
21
  title: "Delete link",
22
- description: "Delete a link between a service and an application, removing the parameters it exported. Pass `link` to choose which one (by name); call WITHOUT `confirm:true` first to preview, the user confirms by submitting. A credential link deprovisions its database user before the record is removed; a redeploy applies the parameter removal to the runtime.",
22
+ description: 'DELETE a link between a service and an application, removing the parameters it exported. Use it for "unlink/disconnect the <service> from <app>", "remove the link". Pass `link` (by name); call WITHOUT `confirm:true` first to open a confirm form (preview), the user confirms by submitting it. A credential link deprovisions its database user before the record is removed; a redeploy applies the parameter removal to the runtime. Destructive; nothing is removed without the submit.',
23
23
  annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true },
24
24
  widget: "service-delete",
25
25
  errorKey: "deleteEntity.linkErrorLabel",
@@ -13,7 +13,7 @@ import { appArg, requireApp } from "./shared.js";
13
13
  export const deleteParamTool = defineTool({
14
14
  name: TOOL.applicationParameterDelete,
15
15
  title: "Delete parameter",
16
- description: "Delete an application configuration parameter (the whole variable and all its values). Pass `name`; call WITHOUT `confirm:true` first to preview, the user confirms by submitting. Read-only parameters injected by a service link can't be deleted here — delete the link instead. Applies on the next deploy.",
16
+ description: 'DELETE an application configuration parameter the whole variable and ALL its values. Use it for "delete/remove the <NAME> env var/parameter". Pass `name`; call WITHOUT `confirm:true` first to open a confirm form (preview), the user confirms by submitting it. Read-only parameters injected by a service link can\'t be deleted here — delete the link instead (application_link_delete). Applies on the next deploy. To CHANGE a value instead of removing it, use application_parameter_create.',
17
17
  annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true },
18
18
  widget: "params",
19
19
  errorKey: "deleteParam.errorLabel",
@@ -19,7 +19,7 @@ const slug = (text) => text
19
19
  export const deleteServiceTool = defineTool({
20
20
  name: TOOL.applicationServiceDelete,
21
21
  title: "Delete service",
22
- description: "Delete a provisioned service and destroy its cloud resources. Pass `service` to choose which one (by name); call WITHOUT `confirm:true` first to see what will be destroyed, the user confirms by submitting. A service with active links can't be deleted — remove the links first (application_link_delete).",
22
+ description: 'DELETE a provisioned service and destroy its cloud resources. Use it for "delete/remove/tear down the <service>". Pass `service` (by name); call WITHOUT `confirm:true` first to open a confirm form showing what will be destroyed, the user confirms by submitting it. A service with active links can\'t be deleted — remove the links first with application_link_delete. Destructive and not reversible; nothing is destroyed without the submit.',
23
23
  annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true },
24
24
  widget: "service-delete",
25
25
  errorKey: "deleteEntity.serviceErrorLabel",
@@ -79,12 +79,54 @@ async function resolveRelease(context, applicationId, args) {
79
79
  return { release: releases[0], note: "" };
80
80
  return { out: fail(translate("deploy.nothing") + next(translate("deploy.nothingHint"))) };
81
81
  }
82
+ /**
83
+ * Resolve which scope to deploy to. Mirrors requireApp's shape: returns the picked scope, or an
84
+ * `out` reply — pickScope's own ask/fail, except a brand-new app with NO scopes gets a "create a
85
+ * scope first" guide listing the available scope types (the literal next move).
86
+ */
87
+ async function resolveDeployScope(context, app, scopeHint) {
88
+ const scopes = await listScopes(context.np, app.id);
89
+ const picked = pickScope(scopes, scopeHint);
90
+ if ("scope" in picked)
91
+ return { scope: picked.scope };
92
+ if (scopes.length === 0 && app.nrn) {
93
+ const scopeTypes = await listScopeTypes(context.np, app.nrn);
94
+ if (scopeTypes.length) {
95
+ const firstType = scopeTypes[0]?.type ?? scopeTypes[0]?.name ?? "";
96
+ const markdown = translate("deploy.noScopeTypes", {
97
+ types: scopeTypes.map((scopeType) => `**${scopeType.name}**`).join(", "),
98
+ }) + next(translate("deploy.createScopeHint", { type: firstType }));
99
+ return { out: fail(markdown, { scope_types: scopeTypes }) };
100
+ }
101
+ }
102
+ return { out: picked.out };
103
+ }
104
+ /**
105
+ * Resolve which asset to ship. An explicit `asset` wins; otherwise auto-pick by the scope's type.
106
+ * Mirrors requireApp's shape: returns the asset name (undefined when the build has none to name),
107
+ * or an `out` reply — the pick-asset panel when a multi-asset build can't be disambiguated.
108
+ */
109
+ async function resolveDeployAsset(context, release, scope, assetHint) {
110
+ if (assetHint)
111
+ return { assetName: assetHint };
112
+ const buildId = release.build_id ?? (await getRelease(context.np, release.id).catch(() => release)).build_id;
113
+ if (!buildId)
114
+ return { assetName: undefined };
115
+ const assets = await listAssets(context.np, buildId);
116
+ const choice = pickAsset(assets, scope.type);
117
+ if ("ambiguous" in choice) {
118
+ return {
119
+ out: reply(`${translate("deploy.multiAsset", { build: buildId, scope: scope.name, type: scope.type ?? "?" })}\n\n${table([translate("header.asset"), translate("header.type"), translate("header.platform")], choice.ambiguous.map((asset) => [asset.name, asset.type, asset.platform ?? ""]))}${next(translate("deploy.assetHint", { name: choice.ambiguous[0]?.name ?? "" }))}`, { assets: choice.ambiguous }),
120
+ };
121
+ }
122
+ return { assetName: choice.name };
123
+ }
82
124
  export const deployTool = defineTool({
83
125
  name: TOOL.applicationDeploymentCreate,
84
126
  title: "Deploy",
85
- description: 'Ship an application: deploys the latest code with sensible defaults uses the latest successful build, creates the release for you (semver auto-bump) if needed, targets the app\'s only scope (or scope:"name"). Returns the live rollout view. For an app with traffic, follow with the traffic tool to move traffic over.',
127
+ description: 'DEPLOY an application provisions real infrastructure (starts a rollout), rendered as the live rollout panel. With no extra args it ships the latest code: picks the latest successful build, cuts the release for you (semver auto-bumped) if needed, and targets the app\'s only scope. Use it for "deploy", "ship it", "release to dev/prod". Narrow the target with `scope` (name or "environment=production"); pin an exact `release_id` or `version` (a known version rolls FORWARD or BACK to it); or cut from a specific `build_id`. For an app that splits traffic, FOLLOW with application_deployment_update to walk traffic to the new version; to undo, application_deployment_update action:"rollback". Convergent under retries: re-running returns the in-flight rollout for the same scope+release rather than creating a duplicate.',
86
128
  annotations: { destructiveHint: false, openWorldHint: true },
87
- widget: "np-panel",
129
+ widget: "status",
88
130
  errorKey: "deploy.errorLabel",
89
131
  // The platform reports a missing/incompatible asset choice as a cross-app mismatch.
90
132
  onError: (message) => /different applications/i.test(message) ? fail(translate("deploy.rejected", { message })) : undefined,
@@ -111,39 +153,19 @@ export const deployTool = defineTool({
111
153
  if ("out" in resolved)
112
154
  return resolved.out;
113
155
  const app = resolved.app;
114
- const scopes = await listScopes(context.np, app.id);
115
- const picked = pickScope(scopes, args.scope);
116
- if ("out" in picked) {
117
- if (scopes.length === 0 && app.nrn) {
118
- const scopeTypes = await listScopeTypes(context.np, app.nrn);
119
- if (scopeTypes.length) {
120
- const firstType = scopeTypes[0]?.type ?? scopeTypes[0]?.name ?? "";
121
- const markdown = translate("deploy.noScopeTypes", {
122
- types: scopeTypes.map((scopeType) => `**${scopeType.name}**`).join(", "),
123
- }) + next(translate("deploy.createScopeHint", { type: firstType }));
124
- return fail(markdown, { scope_types: scopeTypes });
125
- }
126
- }
127
- return picked.out;
128
- }
129
- const scope = picked.scope;
156
+ const scopeResult = await resolveDeployScope(context, app, args.scope);
157
+ if ("out" in scopeResult)
158
+ return scopeResult.out;
159
+ const scope = scopeResult.scope;
130
160
  const shipping = await resolveRelease(context, app.id, args);
131
161
  if ("out" in shipping)
132
162
  return shipping.out;
133
163
  const { release, note } = shipping;
134
164
  // Multi-asset builds must name the asset to ship; the platform's error otherwise is misleading.
135
- let assetName = args.asset;
136
- if (!assetName) {
137
- const buildId = release.build_id ?? (await getRelease(context.np, release.id).catch(() => release)).build_id;
138
- if (buildId) {
139
- const assets = await listAssets(context.np, buildId);
140
- const choice = pickAsset(assets, scope.type);
141
- if ("ambiguous" in choice) {
142
- return reply(`${translate("deploy.multiAsset", { build: buildId, scope: scope.name, type: scope.type ?? "?" })}\n\n${table([translate("header.asset"), translate("header.type"), translate("header.platform")], choice.ambiguous.map((asset) => [asset.name, asset.type, asset.platform ?? ""]))}${next(translate("deploy.assetHint", { name: choice.ambiguous[0]?.name ?? "" }))}`, { assets: choice.ambiguous });
143
- }
144
- assetName = choice.name;
145
- }
146
- }
165
+ const assetResult = await resolveDeployAsset(context, release, scope, args.asset);
166
+ if ("out" in assetResult)
167
+ return assetResult.out;
168
+ const assetName = assetResult.assetName;
147
169
  const orgSlug = await context.org.organizationSlug();
148
170
  // Convergent under retries: if this exact release is already rolling out on this scope
149
171
  // (a non-terminal deployment), return that rollout instead of starting a second one.
@@ -13,7 +13,7 @@ import { appArg, offsetArg, pageOf, requireApp } from "./shared.js";
13
13
  export const deploymentsTool = defineTool({
14
14
  name: TOOL.applicationDeploymentList,
15
15
  title: "Deployments",
16
- description: "List an application's deployments across all scopes — which release landed on which scope, the rollout status, and age; a deployment group (one release to several scopes) is shown as one row. Use to see deploy history or find an active rollout.",
16
+ description: 'List ONE application\'s deployments across all its scopes, rendered as a PANEL — which release landed on which scope, the rollout status, and age (a deployment group landing one release on several scopes shows as one row). Use it for "deploy history", "what was deployed when", "find the active rollout". Does NOT deploy or change traffic (use application_deployment_create / application_deployment_update) and shows no logs/metrics. To find the newest deployment ACROSS apps, gather headlessly with entity_list (entity:"deployment") and compute it. Read-only.',
17
17
  annotations: { readOnlyHint: true, openWorldHint: true },
18
18
  widget: "deployments",
19
19
  errorKey: "deploymentList.errorLabel",
@@ -21,7 +21,7 @@ const ENTITIES = [
21
21
  export const entityGetTool = defineTool({
22
22
  name: TOOL.entityGet,
23
23
  title: "Read an entity (data)",
24
- description: 'Read-only, DATA-only fetch of ONE core entity by id — returns structuredContent and renders NO panel. Pass `entity` and `id` (e.g. entity:"application" id:123). Use this to drill into a specific entity while navigating with entity_list; use the rendered reads (application_get/…) when the USER wants to SEE it. Entities: organization, account, namespace, application, scope, build, release, deployment. (parameters/services/links/approvals have their own tools.)',
24
+ description: 'Read ONE entity by id as DATA — returns structuredContent and renders NO panel (no widget). Use it to (a) drill into a specific entity while navigating headlessly with entity_list, or (b) POLL a changing status WITHOUT spamming panels — loop entity_get entity:"build" id:<id> (or "deployment") until the status is final, instead of re-calling application_get each time. Pass `entity` + `id` (e.g. entity:"application" id:123). Entities: organization, account, namespace, application, scope, build, release, deployment. Use the rendered reads (application_get/…) when the USER wants to SEE it; parameters/services/links/approvals have their own tools.',
25
25
  annotations: { readOnlyHint: true },
26
26
  // No widget on purpose: data-only. See CLAUDE.md (the data-only read pattern).
27
27
  errorKey: "entityGet.errorLabel",
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { getApplication, getScope, listApprovals, listEntity, listLinks, listParameters, listServices, } from "../np/journey.js";
2
+ import { getApplication, getScope, listApprovals, listEntity, listLinks, listParameters, listServices, listTemplates, } from "../np/journey.js";
3
3
  import { defineTool, fail, reply } from "../tool.js";
4
4
  import { TOOL } from "../tool-names.js";
5
5
  /**
@@ -22,8 +22,9 @@ const REST_ENTITIES = [
22
22
  "release",
23
23
  "deployment",
24
24
  ];
25
- /** NRN-addressed collections — listed by the parent's NRN (resolved from parent_id, or given as nrn). */
26
- const NRN_ENTITIES = ["parameter", "service", "link", "approval"];
25
+ /** NRN-addressed collections — listed by the parent's NRN (resolved from parent_id, or given as nrn).
26
+ * `template` is scoped by a NAMESPACE's NRN (the scaffolding templates available to a new app there). */
27
+ const NRN_ENTITIES = ["parameter", "service", "link", "approval", "template"];
27
28
  const ENTITIES = [...REST_ENTITIES, ...NRN_ENTITIES];
28
29
  /** The parents a collection can hang under — its id (REST) or its NRN (nrn-scoped) scopes the list. */
29
30
  const PARENTS = ["organization", "account", "namespace", "application", "scope"];
@@ -38,6 +39,11 @@ async function resolveNrn(context, args) {
38
39
  return (await getApplication(context.np, args.parent_id)).nrn;
39
40
  if (args.parent_type === "scope")
40
41
  return (await getScope(context.np, args.parent_id)).nrn;
42
+ if (args.parent_type === "namespace") {
43
+ // Templates hang off a namespace; resolve its NRN from the cached org skeleton (no extra fetch).
44
+ const skeleton = await context.org.getSkeleton();
45
+ return skeleton.namespaces.find((namespace) => namespace.id === args.parent_id)?.nrn;
46
+ }
41
47
  return undefined;
42
48
  }
43
49
  /** List an nrn-scoped resource via its existing typed function (a different addressing scheme to REST). */
@@ -51,6 +57,8 @@ async function listByNrn(context, entity, nrn) {
51
57
  return listLinks(context.np, nrn);
52
58
  case "approval":
53
59
  return listApprovals(context.bff, nrn);
60
+ case "template":
61
+ return listTemplates(context.np, nrn);
54
62
  default:
55
63
  return [];
56
64
  }
@@ -58,14 +66,14 @@ async function listByNrn(context, entity, nrn) {
58
66
  export const entityListTool = defineTool({
59
67
  name: TOOL.entityList,
60
68
  title: "List entities (data)",
61
- description: 'Read-only, DATA-only list of an entity scoped to its parent — returns structuredContent and renders NO panel, so you can navigate the org and gather context without putting a widget on the user\'s screen. REST tree: organization → account → namespace → application → {scope, build, release}; scope → deployment — pass `entity` plus the `parent_type`+`parent_id` it lives under (e.g. entity:"build" parent_type:"application" parent_id:123; listing `account` needs no parent). NRN-scoped resources (parameter, service, link, approval) live under an application or scope — pass parent_type:"application"|"scope" + parent_id (the NRN is resolved for you), or `nrn` directly. Use this to reason for yourself; use the rendered reads (application_list/application_build_list/…) when the USER wants to SEE the data. For one entity by id use entity_get.',
69
+ description: 'List releases, builds, deployments, scopes, applications, namespaces, parameters, services, links or approvals as DATA — returns structuredContent and renders NO panel. THIS is the tool for AGGREGATING or COMPARING across many apps/scopes: "the latest release across all my apps", "which app has a failing build", "every scope on the newest build" — list each app\'s releases/builds with this (no panel per app), then compute the answer yourself. Prefer it over application_release_list/application_build_list/application_deployment_list whenever you are gathering to reason or to answer an aggregate — those panel reads are ONLY for showing ONE app\'s list to the user. Scoped to a parent: REST tree organization → account → namespace → application → {scope, build, release}; scope → deployment — pass `entity` plus the `parent_type`+`parent_id` it lives under (e.g. entity:"release" parent_type:"application" parent_id:123; listing `account` needs no parent). NRN-scoped resources (parameter, service, link, approval) live under an application or scope — pass parent_type:"application"|"scope" + parent_id (the NRN is resolved for you), or `nrn` directly. `template` is scoped by a NAMESPACE: pass entity:"template" parent_type:"namespace" parent_id:<namespace_id> to list the scaffolding templates available for a new app there — do this to choose a template BEFORE opening the application_create form, so the form opens with it pre-selected (don\'t open the form blind to discover templates). For one entity by id use entity_get.',
62
70
  annotations: { readOnlyHint: true },
63
71
  // No widget on purpose: data-only navigation. See CLAUDE.md (the data-only read pattern).
64
72
  errorKey: "entityList.errorLabel",
65
73
  inputSchema: {
66
74
  entity: z
67
75
  .enum(ENTITIES)
68
- .describe("Collection to list. REST tree: account, namespace, application, scope, build, release, deployment. NRN-scoped: parameter, service, link, approval."),
76
+ .describe("Collection to list. REST tree: account, namespace, application, scope, build, release, deployment. NRN-scoped: parameter, service, link, approval, template (template lists a namespace's scaffolding templates)."),
69
77
  parent_type: z
70
78
  .enum(PARENTS)
71
79
  .optional()
@@ -74,7 +82,7 @@ export const entityListTool = defineTool({
74
82
  nrn: z
75
83
  .string()
76
84
  .optional()
77
- .describe("For nrn-scoped entities (parameter/service/link/approval): the scoping NRN directly, if you have it; otherwise pass parent_type + parent_id and it's resolved for you."),
85
+ .describe('For nrn-scoped entities (parameter/service/link/approval/template): the scoping NRN directly, if you have it; otherwise pass parent_type + parent_id (for template, parent_type:"namespace") and it\'s resolved for you.'),
78
86
  limit: z.number().optional().describe("Max rows (default 100) — REST collections only."),
79
87
  },
80
88
  async handler(args, context) {
@@ -8,13 +8,16 @@ import { TOOL } from "../tool-names.js";
8
8
  export const findAppsTool = defineTool({
9
9
  name: TOOL.applicationList,
10
10
  title: "Applications",
11
- description: "Search applications across the whole organization by partial name (and optionally namespace). Fast (parallel + cached). Use when the repo isn't linked or the user names an app you don't know.",
11
+ description: 'Search applications across the organization by partial name and/or namespace, rendered as a list PANEL grouped by namespace. Fast (parallel + cached). When the user asks for the apps IN a namespace ("my apps in pablov", "list the payments-ns apps"), ALWAYS pass `namespace` so the search is filtered at the source — never list the whole org and then filter the result yourself (the org can have hundreds of apps; post-filtering in code is wrong). Namespace matching is lenient (case- and space-insensitive: "pablov" matches "Pablo V"). Use `query` for a partial app name. Use this when the repo isn\'t linked or the user names an app/namespace you don\'t know.',
12
12
  annotations: { readOnlyHint: true, openWorldHint: true },
13
13
  widget: "find-apps",
14
14
  errorKey: "findApps.errorLabel",
15
15
  inputSchema: {
16
16
  query: z.string().optional().describe("Partial app name (case-insensitive). Omit to list everything."),
17
- namespace: z.string().optional().describe("Limit to namespaces whose name contains this"),
17
+ namespace: z
18
+ .string()
19
+ .optional()
20
+ .describe('Limit to a namespace by name — pass this whenever the user scopes to a namespace. Matched leniently (case- and space-insensitive): "pablov" or "pablo v" both match "Pablo V".'),
18
21
  limit: z.number().optional(),
19
22
  },
20
23
  async handler(args, context) {
@@ -1,14 +1,27 @@
1
1
  import { z } from "zod";
2
2
  import { plural, translate } from "../i18n.js";
3
+ import { log } from "../log.js";
3
4
  import { dashboardLink, linkLine, next } from "../md.js";
4
5
  import { listScopes, readLogs } from "../np/journey.js";
5
6
  import { defineTool, fail, reply } from "../tool.js";
6
7
  import { TOOL } from "../tool-names.js";
7
8
  import { appArg, chooseScope, requireApp } from "./shared.js";
9
+ const parseMs = (value) => {
10
+ if (!value)
11
+ return 0;
12
+ const ms = new Date(value).getTime();
13
+ return Number.isNaN(ms) ? 0 : ms;
14
+ };
15
+ /** A log entry's timestamp as epoch ms, from the platform's `date` field (ISO-8601). The single time
16
+ * source for BOTH ordering and the staleness check. Absent → 0 (sorts as oldest, adds no recency).
17
+ * We never parse a time out of the message TEXT: the platform already resolves each line's time into
18
+ * `date`; an app that logs structured JSON keeps its own `"time"` inside the message, which is the
19
+ * app's payload to show verbatim, not ours to reinterpret. */
20
+ const logTimeMs = (entry) => parseMs(entry.date);
8
21
  export const logsTool = defineTool({
9
22
  name: TOOL.applicationLogList,
10
23
  title: "Logs",
11
- description: "Read recent application logs (optionally for one scope). Returns the latest lines, newest last good for a quick 'why is it failing?' look.",
24
+ description: 'Read ONE application\'s recent logs (per scope), rendered as a logs PANEL — the latest lines, newest last. Use it for "why is it failing?", "show me the logs", "what errored", "show <app> logs". Pass `app` (name OR "#id") and this resolves the application ITSELF — call it DIRECTLY; do NOT call application_list (to find the app) or application_get (to look it up) first, that just renders panels the user didn\'t ask for. "show <app> logs" is ONE call: this tool with app:"<app>". Logs are per-scope: omit `scope` to use the only one (or be asked which). With no window the SERVER auto-widens 1m → 5m → 30m and returns the first window that has logs, then falls back to the LATEST available lines (any age) so a quiet/stopped app still shows its last logs (e.g. the crash) — it prefers recent activity but is NEVER empty, so you NEVER need to re-call with a wider window (don\'t widen client-side — one call is enough). To pin a specific range pass `start_time`/`end_time` (ISO-8601) or a bigger `lines` (an explicit window skips the auto-widen). Page older lines with `page_token`. Does NOT return metrics or deploy status — use application_metric_list or application_get. Read-only.',
12
25
  annotations: { readOnlyHint: true, openWorldHint: true },
13
26
  widget: "logs",
14
27
  errorKey: "logs.errorLabel",
@@ -46,14 +59,84 @@ export const logsTool = defineTool({
46
59
  return picked.out;
47
60
  const scope = picked.scope;
48
61
  const maxLines = args.lines ?? 50;
49
- const page = await readLogs(context.np, {
50
- application_id: app.id,
51
- scope_id: scope.id,
52
- next_page_token: args.page_token,
53
- start_time: args.start_time,
54
- end_time: args.end_time,
55
- });
56
- const entries = (page.results ?? []).slice(-maxLines);
62
+ // No explicit window → progressively WIDEN the lookback (1m → 5m → 30m → 6h → 24h → 7d), stopping
63
+ // at the first window that actually has logs. So "show logs" starts with recent activity instead
64
+ // of an empty last-minute view on a quiet app. The wider steps past 30m are NOT cosmetic: an app
65
+ // that last logged, say, 45m ago has its newest lines just OUTSIDE the 30m "live" window. Without
66
+ // a 6h step the widen would skip straight to the unfiltered fallback — and an unfiltered read can
67
+ // return an OLDER partition than the most-recent lines (observed live: 06-19 lines surfaced while
68
+ // the app's real 06-22 lines sat in the 30m→unfiltered gap). An explicit `start_time` filters
69
+ // server-side, so the 6h step returns those 06-22 lines; staleness still flags them as not-live.
70
+ // The live tail re-runs this each poll. An explicit window / `page_token` skips the widening (the
71
+ // user/model asked for a specific range; widen further yourself to diagnose an older failure).
72
+ const explicit = Boolean(args.start_time || args.end_time || args.page_token);
73
+ const minute = 60_000;
74
+ const hour = 60 * minute;
75
+ const since = (ageMs) => ({ start_time: new Date(Date.now() - ageMs).toISOString() });
76
+ const windows = explicit
77
+ ? [{ start_time: args.start_time, end_time: args.end_time, next_page_token: args.page_token }]
78
+ : [
79
+ since(minute),
80
+ since(5 * minute),
81
+ since(30 * minute),
82
+ since(6 * hour),
83
+ since(24 * hour),
84
+ since(7 * 24 * hour),
85
+ // Last resort: latest-available, ANY age — an app silent for >7d still shows its last lines
86
+ // (e.g. an old crash), NEVER an empty panel. The explicit windows above are what surface the
87
+ // REAL most-recent lines; an unfiltered read can return an older partition, so it's only the
88
+ // truly-ancient safety net, never the primary path.
89
+ {},
90
+ ];
91
+ let page = await readLogs(context.np, { application_id: app.id, scope_id: scope.id, ...windows[0] });
92
+ let usedWindow = 0;
93
+ log.debug({ logsTool: { app: app.id, scope: scope.id, window: windows[0], count: page.results?.length ?? 0 } }, "logs: window 0");
94
+ for (let attempt = 1; attempt < windows.length && !(page.results ?? []).length; attempt++) {
95
+ page = await readLogs(context.np, { application_id: app.id, scope_id: scope.id, ...windows[attempt] });
96
+ usedWindow = attempt;
97
+ log.debug({
98
+ logsTool: {
99
+ app: app.id,
100
+ scope: scope.id,
101
+ window: windows[attempt],
102
+ count: page.results?.length ?? 0,
103
+ },
104
+ }, `logs: window ${attempt}`);
105
+ }
106
+ // Sort oldest → newest by timestamp, THEN take the newest `maxLines` (kept oldest-first for the
107
+ // "newest last" display). The platform returns lines newest-first, so a naive slice(-maxLines)
108
+ // took the OLDEST lines — which is why a multi-day log set surfaced days-old lines under "live".
109
+ const ordered = (page.results ?? []).slice().sort((left, right) => logTimeMs(left) - logTimeMs(right));
110
+ const entries = ordered.slice(-maxLines);
111
+ // Staleness: with no explicit window the auto-widen tries 1m → 5m → 30m before the latest-available
112
+ // fallback. So if the NEWEST line we got is older than that widest live window (30m), the app has had
113
+ // no recent activity and these are last-gasp lines (e.g. a crash days ago). Flag it so the panel/text
114
+ // says "no recent activity, last logs from <time>" instead of presenting old lines as a live tail.
115
+ // An explicit window/page_token means the user asked for that range — old lines there aren't "stale".
116
+ const newestMs = entries.reduce((max, entry) => Math.max(max, logTimeMs(entry)), 0);
117
+ const stale = !explicit && newestMs > 0 && Date.now() - newestMs > 30 * 60_000;
118
+ const newestTs = newestMs > 0 ? new Date(newestMs).toISOString() : null;
119
+ // The trace that pins down a logs-fetch complaint: which window won, how the staleness verdict was
120
+ // reached, and a sample of the RAW entries — the `date` field each line carries (the platform's
121
+ // timestamp) plus a short message head, so a missing/odd `date` is visible in the trace.
122
+ log.debug({
123
+ logsTool: {
124
+ app: app.id,
125
+ scope: scope.name,
126
+ explicit,
127
+ usedWindow,
128
+ rawCount: page.results?.length ?? 0,
129
+ entryCount: entries.length,
130
+ newestTs,
131
+ stale,
132
+ nowIso: new Date().toISOString(),
133
+ ageMin: newestMs > 0 ? Math.round((Date.now() - newestMs) / 60_000) : null,
134
+ sample: (page.results ?? []).slice(0, 6).map((entry) => ({
135
+ date: entry.date ?? null,
136
+ msgHead: entry.message.slice(0, 80),
137
+ })),
138
+ },
139
+ }, "logs: resolved entries + staleness");
57
140
  const scopeSuffix = ` · ${scope.name}`;
58
141
  // The full scope list rides along so the widget can offer a scope switcher.
59
142
  const scopeList = scopes.map((candidate) => ({ name: candidate.name, status: candidate.status }));
@@ -64,24 +147,38 @@ export const logsTool = defineTool({
64
147
  }
65
148
  const body = entries
66
149
  .map((entry) => {
67
- const timestamp = entry.datetime ?? entry.timestamp;
68
- const prefix = timestamp
69
- ? `${new Date(timestamp).toISOString().slice(5, 19).replace("T", " ")} `
70
- : "";
71
- return `${prefix}${(entry.message ?? "").trimEnd()}`;
150
+ // Timestamp column comes from the `date` field; guard a bad value (the API could return an
151
+ // unparseable date) so the prefix is just dropped rather than throwing.
152
+ const ms = parseMs(entry.date);
153
+ const prefix = ms ? `${new Date(ms).toISOString().slice(5, 19).replace("T", " ")} ` : "";
154
+ return `${prefix}${entry.message.trimEnd()}`;
72
155
  })
73
156
  .join("\n");
74
- const markdown = `${plural(entries.length, "logs.lastLines.one", "logs.lastLines.many", { app: app.name, scope: scopeSuffix })}\n\n\`\`\`\n${body}\n\`\`\``;
157
+ const heading = stale
158
+ ? plural(entries.length, "logs.staleLines.one", "logs.staleLines.many", {
159
+ app: app.name,
160
+ scope: scopeSuffix,
161
+ time: (newestTs ?? "").slice(0, 16).replace("T", " "),
162
+ })
163
+ : plural(entries.length, "logs.lastLines.one", "logs.lastLines.many", {
164
+ app: app.name,
165
+ scope: scopeSuffix,
166
+ });
167
+ const markdown = `${heading}\n\n\`\`\`\n${body}\n\`\`\``;
75
168
  return reply(markdown, {
76
169
  count: entries.length,
170
+ stale,
171
+ newest_ts: newestTs,
77
172
  next_page_token: page.next_page_token ?? null,
78
173
  app: `#${app.id}`,
79
174
  app_name: app.name,
80
175
  scope: scope.name,
81
176
  scopes: scopeList,
177
+ // Two columns: the timestamp from the `date` field (null → the widget shows no timestamp cell,
178
+ // just the message), and the message verbatim. No parsing time out of the text.
82
179
  lines: entries.map((entry) => ({
83
- ts: entry.datetime ?? entry.timestamp ?? null,
84
- message: entry.message ?? "",
180
+ ts: entry.date ?? null,
181
+ message: entry.message,
85
182
  })),
86
183
  });
87
184
  },
@@ -8,7 +8,7 @@ import { appArg, chooseScope, requireApp } from "./shared.js";
8
8
  export const metricsTool = defineTool({
9
9
  name: TOOL.applicationMetricList,
10
10
  title: "Performance metrics",
11
- description: "The golden signals of a scope — throughput (rpm), response time, error rate, CPU and memory — with sparkline trends over a time window. Use to answer 'how is it performing?' or to watch health during a rollout.",
11
+ description: 'The golden signals of ONE scope — throughput (rpm), response time, error rate, CPU, memory — with sparkline trends, rendered as a metrics PANEL. Use it for "how is it performing?", "is it healthy?", or to watch a scope during a rollout. Pick the `window` (1h/3h/24h/7d, default 3h); omit `scope` to use the only one. Does NOT return logs or deploy state — use application_log_list or application_get. Read-only.',
12
12
  annotations: { readOnlyHint: true, openWorldHint: true },
13
13
  widget: "metrics",
14
14
  errorKey: "metrics.errorLabel",