@nullplatform/mcp 0.1.14 → 0.1.16
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 +23 -6
- package/dist/render.js +12 -13
- package/dist/surfaces/developer.js +15 -5
- package/dist/tool.js +84 -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 +52 -30
- package/dist/tools/deployments.js +1 -1
- 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/releases.js +1 -1
- package/dist/tools/set-params.js +17 -18
- package/dist/tools/shared.js +9 -0
- package/dist/tools/status.js +13 -6
- 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/deploy.js
CHANGED
|
@@ -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: '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,
|
|
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, the rollout panel this renders OWNS the traffic walk — its slider + auto-advance ramp traffic, with finalize and rollback right there, so do NOT follow up with application_deployment_update to step it yourself (each model call stacks another rollout panel); the user drives it in the ONE panel. Call application_deployment_update directly only for one move the user explicitly names ("go straight to 100%", "roll back") with no panel open — it steers this same rollout. 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: "
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
|
|
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 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
|
|
16
|
+
description: 'List ONE application\'s deployments across all its scopes, rendered as a PANEL for the user to SEE — 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 ONLY when the user asks to look at the deploy history ("show the deployments", "what was deployed when"). Does NOT deploy or change traffic (use application_deployment_create / application_deployment_update) and shows no logs/metrics. CRUCIAL — do NOT render this to QUERY: to FIND or IDENTIFY a deployment for your OWN next step — the previously deployed version to roll back to, the active rollout\'s id, the newest deployment across apps — read it HEADLESSLY with entity_list (entity:"deployment", parent_type:"scope") or entity_get, never this panel. Rendering it to read history is wasted: the rows ride in _meta and never reach your reasoning, so you would paint a panel, get nothing, and have to re-read headlessly anyway. Render this ONLY to show the user a list they asked to see. Read-only.',
|
|
17
17
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
18
18
|
widget: "deployments",
|
|
19
19
|
errorKey: "deploymentList.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
|
-
|
|
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: '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
|
|
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 any DATA lookup you reason over — whether AGGREGATING across apps ("the latest release across all my apps", "which app has a failing build", "every scope on the newest build") OR reading ONE app\'s history to identify something for your next move ("the previously deployed version to roll back to", "the release behind the current deployment", "what version ran before this one", "find the active rollout"). List the app\'s deployments/releases/builds with this (NO panel), then compute the answer yourself. Prefer it over application_release_list/application_build_list/application_deployment_list/application_get WHENEVER you are gathering to reason — including a single app, not just aggregates — because those reads RENDER A PANEL the user didn\'t ask for; and the LIST panels (release/build/deployment) split their rows into _meta, so they would not even reach your reasoning. Those reads are ONLY for showing the user a list/status they asked to see. 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(
|
|
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) {
|
package/dist/tools/logs.js
CHANGED
|
@@ -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 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". Logs are per-scope: omit `scope` to use the only one (or be asked which)
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return `${prefix}${
|
|
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
|
|
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.
|
|
84
|
-
message: entry.message
|
|
180
|
+
ts: entry.date ?? null,
|
|
181
|
+
message: entry.message,
|
|
85
182
|
})),
|
|
86
183
|
});
|
|
87
184
|
},
|
package/dist/tools/overview.js
CHANGED
|
@@ -14,7 +14,7 @@ const MAX_APPS = 30;
|
|
|
14
14
|
export const overviewTool = defineTool({
|
|
15
15
|
name: TOOL.organizationGet,
|
|
16
16
|
title: "Organization overview",
|
|
17
|
-
description: 'A cross-application health digest for the WHOLE org, rendered as a panel: what\'s mid-rollout right now, and which scopes\' last deployment failed or rolled back. Use it for org-wide questions that name no app — "is anything broken?", "what\'s deploying?", "any failed rollouts?". Scans up to the first ~30 applications (truncation is reported, never silent).
|
|
17
|
+
description: 'A cross-application health digest for the WHOLE org, rendered as a panel: what\'s mid-rollout right now, and which scopes\' last deployment failed or rolled back. Use it ONLY for org-wide HEALTH questions that name no app — "is anything broken?", "what\'s deploying?", "any failed rollouts?". Scans up to the first ~30 applications (truncation is reported, never silent). This is HEALTH only — do NOT call it to understand org STRUCTURE or to gather context for CREATING an app (where apps live, which accounts/namespaces exist, where to place a new one): navigate that headlessly with entity_list (account → namespace → application), which renders no panel. Also does NOT show one app\'s detail (use application_get) or logs/metrics, and for the latest release/build ACROSS apps use entity_list and compute it. Read-only.',
|
|
18
18
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
19
19
|
widget: "overview",
|
|
20
20
|
errorKey: "overview.errorLabel",
|
package/dist/tools/params.js
CHANGED
|
@@ -61,7 +61,13 @@ export const paramsTool = defineTool({
|
|
|
61
61
|
resolvedScope = { id: picked.scope.id, name: picked.scope.name };
|
|
62
62
|
readNrn = picked.scope.nrn;
|
|
63
63
|
}
|
|
64
|
-
|
|
64
|
+
// A scope read interpolates so the platform resolves the EFFECTIVE value per parameter (scope
|
|
65
|
+
// value › most-specific dimension match › app default) — app-level/dimension values inherit
|
|
66
|
+
// down. Without it a scope NRN returns only values pinned at that exact scope (usually none).
|
|
67
|
+
const parameters = await listParameters(context.np, readNrn, {
|
|
68
|
+
offset: args.offset,
|
|
69
|
+
interpolate: Boolean(resolvedScope),
|
|
70
|
+
});
|
|
65
71
|
if (parameters === undefined) {
|
|
66
72
|
return reply(translate("params.unavailable") + linkLine(translate("params.viewInDashboard"), dashboard), { available: false, ...appRef });
|
|
67
73
|
}
|
package/dist/tools/releases.js
CHANGED
|
@@ -12,7 +12,7 @@ import { appArg, offsetArg, pageOf, requireApp } from "./shared.js";
|
|
|
12
12
|
export const releasesTool = defineTool({
|
|
13
13
|
name: TOOL.applicationReleaseList,
|
|
14
14
|
title: "Releases",
|
|
15
|
-
description: "List ONE application's releases as a PANEL for the user — semver, status (active/deprecated/unstable/failed), the build each pins, and age
|
|
15
|
+
description: "List ONE application's releases as a PANEL for the user to SEE — semver, status (active/deprecated/unstable/failed), the build each pins, and age; each row carries a Deploy button. Use it ONLY when the user wants to look at what's shippable or click a release to deploy. CRUCIAL — do NOT render this to QUERY: to FIND or IDENTIFY a release for your OWN next step — the previous version to roll back to, the release behind the current deployment, the latest across apps — read it HEADLESSLY with entity_list (entity:\"release\") and compute the answer, never this panel. Rendering it to read history is wasted: the rows ride in _meta and never reach your reasoning, so you would paint a panel, get nothing, and re-read headlessly anyway. Render this ONLY to show the user a list they asked to see.",
|
|
16
16
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
17
17
|
widget: "releases",
|
|
18
18
|
errorKey: "releaseList.errorLabel",
|
package/dist/tools/set-params.js
CHANGED
|
@@ -5,6 +5,22 @@ import { listParameters, listScopes, setParameters } from "../np/journey.js";
|
|
|
5
5
|
import { defineTool, errorMessage, fail, reply } from "../tool.js";
|
|
6
6
|
import { TOOL } from "../tool-names.js";
|
|
7
7
|
import { appArg, pickScope, requireApp } from "./shared.js";
|
|
8
|
+
/** Honest upsert summary: "created N" / "updated N" / a mixed count — so a retry reads as "0 new". */
|
|
9
|
+
function paramSummary(result, app, names) {
|
|
10
|
+
if (result.updated === 0) {
|
|
11
|
+
return plural(result.created, "setParams.created.one", "setParams.created.many", { app, names });
|
|
12
|
+
}
|
|
13
|
+
if (result.created === 0) {
|
|
14
|
+
return plural(result.updated, "setParams.updated.one", "setParams.updated.many", { app, names });
|
|
15
|
+
}
|
|
16
|
+
return translate("setParams.mixed", {
|
|
17
|
+
created: result.created,
|
|
18
|
+
updated: result.updated,
|
|
19
|
+
total: result.created + result.updated,
|
|
20
|
+
app,
|
|
21
|
+
names,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
8
24
|
export const setParamsTool = defineTool({
|
|
9
25
|
name: TOOL.applicationParameterCreate,
|
|
10
26
|
title: "Set parameters",
|
|
@@ -72,24 +88,7 @@ export const setParamsTool = defineTool({
|
|
|
72
88
|
const result = await setParameters(context.np, app.nrn, inputs);
|
|
73
89
|
const names = args.params.map((param) => `\`${param.name}\``).join(", ");
|
|
74
90
|
const total = result.created + result.updated;
|
|
75
|
-
|
|
76
|
-
const summary = result.updated === 0
|
|
77
|
-
? plural(result.created, "setParams.created.one", "setParams.created.many", {
|
|
78
|
-
app: app.name,
|
|
79
|
-
names,
|
|
80
|
-
})
|
|
81
|
-
: result.created === 0
|
|
82
|
-
? plural(result.updated, "setParams.updated.one", "setParams.updated.many", {
|
|
83
|
-
app: app.name,
|
|
84
|
-
names,
|
|
85
|
-
})
|
|
86
|
-
: translate("setParams.mixed", {
|
|
87
|
-
created: result.created,
|
|
88
|
-
updated: result.updated,
|
|
89
|
-
total,
|
|
90
|
-
app: app.name,
|
|
91
|
-
names,
|
|
92
|
-
});
|
|
91
|
+
const summary = paramSummary(result, app.name, names);
|
|
93
92
|
// Mirror the params widget's shape so the SAME panel renders the resulting set (with its
|
|
94
93
|
// scope picker + add affordance) after the write; the markdown stays the plain confirmation.
|
|
95
94
|
const parameters = await listParameters(context.np, app.nrn).catch(() => undefined);
|
package/dist/tools/shared.js
CHANGED
|
@@ -124,6 +124,15 @@ export function resolveChoice(options, hint, settings = {}) {
|
|
|
124
124
|
*/
|
|
125
125
|
export function matchScopes(scopes, wanted) {
|
|
126
126
|
const query = wanted.trim().toLowerCase();
|
|
127
|
+
// "#<id>" — an exact, unambiguous scope id. This is the form widgets send from their scope picker
|
|
128
|
+
// (the option value is the numeric id) and a documented `scope` arg form. Matched FIRST so a numeric
|
|
129
|
+
// id never falls through to name/substring matching — which can't see `id` and would return nothing,
|
|
130
|
+
// failing the call before it ever reads parameters (the widget scope-resolve bug).
|
|
131
|
+
const byId = /^#(\d+)$/.exec(query);
|
|
132
|
+
if (byId) {
|
|
133
|
+
const id = Number(byId[1]);
|
|
134
|
+
return scopes.filter((scope) => scope.id === id);
|
|
135
|
+
}
|
|
127
136
|
const keyValue = /^([a-z0-9_-]+)\s*=\s*(.+)$/.exec(query);
|
|
128
137
|
if (keyValue?.[1] && keyValue[2] !== undefined) {
|
|
129
138
|
const [, dimensionKey, dimensionValue] = keyValue;
|
package/dist/tools/status.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { dashboardLink } from "../md.js";
|
|
3
3
|
import { pmap } from "../np/context.js";
|
|
4
|
-
import { getDeployment, getScope, isDeploymentTerminal, listBuilds, listReleases, listScopeDeployments, listScopes, } from "../np/journey.js";
|
|
4
|
+
import { getDeployment, getRelease, getScope, isDeploymentTerminal, listBuilds, listReleases, listScopeDeployments, listScopes, } from "../np/journey.js";
|
|
5
5
|
import { renderRollout, renderStatus } from "../render.js";
|
|
6
6
|
import { defineTool, reply } from "../tool.js";
|
|
7
7
|
import { TOOL } from "../tool-names.js";
|
|
@@ -25,9 +25,9 @@ async function scopeViews(context, applicationId) {
|
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
|
-
* Build an application's full status view (the
|
|
28
|
+
* Build an application's full status view (the app overview payload). Exported so write
|
|
29
29
|
* tools that should hand off into the same live panel — e.g. create-scope, after provisioning a
|
|
30
|
-
* new deploy target — can return this exact shape and let
|
|
30
|
+
* new deploy target — can return this exact shape and let the status panel render the app + Deploy.
|
|
31
31
|
*/
|
|
32
32
|
export async function buildAppStatus(context, app) {
|
|
33
33
|
const [views, builds, releases, orgSlug] = await Promise.all([
|
|
@@ -41,9 +41,9 @@ export async function buildAppStatus(context, app) {
|
|
|
41
41
|
export const statusTool = defineTool({
|
|
42
42
|
name: TOOL.applicationGet,
|
|
43
43
|
title: "Application status",
|
|
44
|
-
description: 'THE place to start for ONE application\'s live status — renders a status PANEL: each scope with what\'s live on it (release + traffic), the latest build, the latest release, and the single obvious next action. Use it for "what\'s the status of my app", "what\'s deployed where", "is it healthy" — a STATUS question. For a DIRECT action ("deploy", "send traffic", "show logs/metrics") reach for that action\'s own tool
|
|
44
|
+
description: 'THE place to start for ONE application\'s live status — renders a status PANEL: each scope with what\'s live on it (release + traffic), the latest build, the latest release, and the single obvious next action. Use it for "what\'s the status of my app", "what\'s deployed where", "is it healthy" — a STATUS question. For a DIRECT action ("deploy", "roll back", "send traffic", "show logs/metrics") reach for that action\'s own tool — do NOT render this panel FIRST to "check current state" or "see what\'s live" before acting. When you need current state to DECIDE (which version is live, what the previous version is), read it HEADLESSLY with entity_get / entity_list and act on that; rendering this panel just to orient drops a status panel the user never asked for. Render it ONLY when the USER wants to SEE status. To LOOK UP a field for your OWN work — the repository_url to clone/edit, the app id, where it lives, its nrn — read it HEADLESSLY with entity_get (entity:"application", id:<id>); do NOT render this status panel just to grab a value. Call with NO arguments inside a git repo to use the linked app; pass `app` (name or "#id") for a different one; pass `deployment:<id>` to watch one rollout\'s live detail. Does NOT return logs, metrics, parameters, or a build\'s assets — use application_log_list / application_metric_list / application_parameter_list / application_build_list for those. Do NOT loop this to WAIT for a build or deployment to finish: it repaints the whole panel every call (stacking duplicate panels) — poll headlessly with entity_get (entity:"build" or "deployment", id:<id>) instead, and show this panel once at the end.',
|
|
45
45
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
46
|
-
widget: "
|
|
46
|
+
widget: "status",
|
|
47
47
|
errorKey: "status.errorLabel",
|
|
48
48
|
inputSchema: {
|
|
49
49
|
app: appArg,
|
|
@@ -52,13 +52,20 @@ export const statusTool = defineTool({
|
|
|
52
52
|
async handler(args, context) {
|
|
53
53
|
if (args.deployment) {
|
|
54
54
|
const deployment = await getDeployment(context.np, args.deployment);
|
|
55
|
-
|
|
55
|
+
// Resolve the release too, so the rollout panel keeps showing its version on every live poll
|
|
56
|
+
// (this read is what the panel polls every 2.5s — without the release, release_semver goes null
|
|
57
|
+
// and the version disappears after the first refresh).
|
|
58
|
+
const [scope, release, orgSlug] = await Promise.all([
|
|
56
59
|
deployment.scope_id ? getScope(context.np, deployment.scope_id).catch(() => undefined) : undefined,
|
|
60
|
+
deployment.release_id
|
|
61
|
+
? getRelease(context.np, deployment.release_id).catch(() => undefined)
|
|
62
|
+
: undefined,
|
|
57
63
|
context.org.organizationSlug(),
|
|
58
64
|
]);
|
|
59
65
|
const { md, structured } = renderRollout({
|
|
60
66
|
deployment,
|
|
61
67
|
scope,
|
|
68
|
+
release,
|
|
62
69
|
dashboard: dashboardLink(orgSlug, scope?.nrn),
|
|
63
70
|
});
|
|
64
71
|
return reply(md, structured);
|
package/dist/tools/traffic.js
CHANGED
|
@@ -6,12 +6,43 @@ import { renderRollout } from "../render.js";
|
|
|
6
6
|
import { defineTool, fail, reply } from "../tool.js";
|
|
7
7
|
import { TOOL } from "../tool-names.js";
|
|
8
8
|
import { appArg, delays, requireApp, sleep } from "./shared.js";
|
|
9
|
+
/**
|
|
10
|
+
* Find which deployment to steer. An explicit `deployment_id` wins; otherwise resolve the app and
|
|
11
|
+
* fan out across its scopes for the single non-terminal rollout. Mirrors requireApp's shape:
|
|
12
|
+
* returns { deploymentId, scope } or an `out` reply — a `fail` when nothing is in flight, or the
|
|
13
|
+
* disambiguation table when several rollouts are active at once (pass deployment_id to pick one).
|
|
14
|
+
*/
|
|
15
|
+
async function resolveActiveRollout(context, args) {
|
|
16
|
+
if (args.deployment_id)
|
|
17
|
+
return { deploymentId: args.deployment_id };
|
|
18
|
+
const resolved = await requireApp(context, args);
|
|
19
|
+
if ("out" in resolved)
|
|
20
|
+
return { out: resolved.out };
|
|
21
|
+
const scopes = await listScopes(context.np, resolved.app.id);
|
|
22
|
+
const activeRollouts = [];
|
|
23
|
+
for (const candidate of scopes) {
|
|
24
|
+
const deployments = await listScopeDeployments(context.np, candidate.id, 3);
|
|
25
|
+
const active = deployments.find((deployment) => !isDeploymentTerminal(deployment.status));
|
|
26
|
+
if (active)
|
|
27
|
+
activeRollouts.push({ deploymentId: active.id, scope: candidate, status: active.status });
|
|
28
|
+
}
|
|
29
|
+
if (activeRollouts.length === 0) {
|
|
30
|
+
return { out: fail(translate("traffic.noActive", { app: resolved.app.name })) };
|
|
31
|
+
}
|
|
32
|
+
if (activeRollouts.length > 1) {
|
|
33
|
+
return {
|
|
34
|
+
out: reply(`${translate("traffic.several")}\n\n${table([translate("header.deployment"), translate("header.scope"), translate("header.status")], activeRollouts.map((rollout) => [`#${rollout.deploymentId}`, rollout.scope.name, rollout.status]))}`),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const only = activeRollouts[0]; // length === 1 checked above
|
|
38
|
+
return { deploymentId: only.deploymentId, scope: only.scope };
|
|
39
|
+
}
|
|
9
40
|
export const trafficTool = defineTool({
|
|
10
41
|
name: TOOL.applicationDeploymentUpdate,
|
|
11
42
|
title: "Update traffic",
|
|
12
|
-
description: 'Drive an IN-FLIGHT rollout — the
|
|
43
|
+
description: 'Drive an IN-FLIGHT rollout, rendered as the live rollout PANEL — the SAME single panel application_deployment_create opens, where provisioning status, the traffic slider, auto-advance, finalize, rollback and live logs ALL live. Call it DIRECTLY with `percent` (snaps to 0,1,5,10,25,50,75,90,95,99,100) to move traffic, action:"finalize" to retire the old version, or action:"rollback" to return traffic to the previous version (the safety hatch). You do NOT need to read status or look up a deployment id first — it finds the app\'s single active rollout automatically (pass `deployment_id` only when several are in flight at once). Use it for "send 25% traffic", "promote to 100%", "finalize", "roll back". THE ROLLOUT IS ONE PANEL: if a rollout panel is already open (you just deployed), the user walks traffic, finalizes and rolls back IN it via its own controls — do NOT call this yourself to step 25 → 50 → 100 → finalize, because each model call stacks ANOTHER rollout panel. Call this directly ONLY for one move the user explicitly named with no panel open. Does NOT create a deployment — use application_deployment_create first; this only steers one that already exists.',
|
|
13
44
|
annotations: { destructiveHint: true, openWorldHint: true },
|
|
14
|
-
widget: "
|
|
45
|
+
widget: "status",
|
|
15
46
|
errorKey: "traffic.errorLabel",
|
|
16
47
|
inputSchema: {
|
|
17
48
|
app: appArg,
|
|
@@ -23,30 +54,11 @@ export const trafficTool = defineTool({
|
|
|
23
54
|
if (args.percent === undefined && !args.action) {
|
|
24
55
|
return fail(translate("traffic.sayWhat"));
|
|
25
56
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return resolved.out;
|
|
32
|
-
const scopes = await listScopes(context.np, resolved.app.id);
|
|
33
|
-
const activeRollouts = [];
|
|
34
|
-
for (const candidate of scopes) {
|
|
35
|
-
const deployments = await listScopeDeployments(context.np, candidate.id, 3);
|
|
36
|
-
const active = deployments.find((deployment) => !isDeploymentTerminal(deployment.status));
|
|
37
|
-
if (active)
|
|
38
|
-
activeRollouts.push({ deploymentId: active.id, scope: candidate, status: active.status });
|
|
39
|
-
}
|
|
40
|
-
if (activeRollouts.length === 0) {
|
|
41
|
-
return fail(translate("traffic.noActive", { app: resolved.app.name }));
|
|
42
|
-
}
|
|
43
|
-
if (activeRollouts.length > 1) {
|
|
44
|
-
return reply(`${translate("traffic.several")}\n\n${table([translate("header.deployment"), translate("header.scope"), translate("header.status")], activeRollouts.map((rollout) => [`#${rollout.deploymentId}`, rollout.scope.name, rollout.status]))}`);
|
|
45
|
-
}
|
|
46
|
-
const only = activeRollouts[0]; // length === 1 checked above
|
|
47
|
-
deploymentId = only.deploymentId;
|
|
48
|
-
scope = only.scope;
|
|
49
|
-
}
|
|
57
|
+
const rollout = await resolveActiveRollout(context, args);
|
|
58
|
+
if ("out" in rollout)
|
|
59
|
+
return rollout.out;
|
|
60
|
+
const deploymentId = rollout.deploymentId;
|
|
61
|
+
let scope = rollout.scope;
|
|
50
62
|
if (args.action === "finalize")
|
|
51
63
|
await deploymentAction(context.np, deploymentId, "finalize");
|
|
52
64
|
else if (args.action === "rollback")
|