@nullplatform/mcp 0.1.5 → 0.1.7

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 (48) hide show
  1. package/dist/i18n.js +206 -22
  2. package/dist/md.js +14 -4
  3. package/dist/np/client.js +3 -0
  4. package/dist/np/journey.js +236 -24
  5. package/dist/surfaces/developer.js +1 -1
  6. package/dist/tool-names.js +7 -0
  7. package/dist/tools/action-flow.js +94 -0
  8. package/dist/tools/approvals.js +2 -2
  9. package/dist/tools/builds.js +23 -6
  10. package/dist/tools/create-link.js +163 -0
  11. package/dist/tools/create-service.js +149 -0
  12. package/dist/tools/delete-flow.js +30 -0
  13. package/dist/tools/delete-link.js +95 -0
  14. package/dist/tools/delete-param.js +76 -0
  15. package/dist/tools/delete-service.js +108 -0
  16. package/dist/tools/deployments.js +2 -2
  17. package/dist/tools/index.js +14 -0
  18. package/dist/tools/logs.js +2 -2
  19. package/dist/tools/metrics.js +4 -1
  20. package/dist/tools/overview.js +2 -2
  21. package/dist/tools/params.js +78 -17
  22. package/dist/tools/playbook.js +2 -1
  23. package/dist/tools/releases.js +2 -2
  24. package/dist/tools/services.js +2 -2
  25. package/dist/tools/set-params.js +61 -11
  26. package/dist/tools/shared.js +4 -0
  27. package/dist/tools/update-link.js +87 -0
  28. package/dist/tools/update-service.js +92 -0
  29. package/dist/ui.js +4 -1
  30. package/package.json +3 -1
  31. package/skills/starting-a-new-app/SKILL.md +71 -0
  32. package/widgets-dist/approvals.html +257 -53
  33. package/widgets-dist/builds.html +261 -57
  34. package/widgets-dist/create-app.html +252 -48
  35. package/widgets-dist/deployments.html +253 -49
  36. package/widgets-dist/find-apps.html +251 -47
  37. package/widgets-dist/logs.html +256 -52
  38. package/widgets-dist/manifest.json +16 -13
  39. package/widgets-dist/metrics.html +261 -57
  40. package/widgets-dist/np-panel.html +257 -53
  41. package/widgets-dist/overview.html +261 -57
  42. package/widgets-dist/params.html +257 -53
  43. package/widgets-dist/releases.html +259 -55
  44. package/widgets-dist/service-action.html +1118 -0
  45. package/widgets-dist/service-create.html +1117 -0
  46. package/widgets-dist/{playbook.html → service-delete.html} +258 -58
  47. package/widgets-dist/service-link.html +1117 -0
  48. package/widgets-dist/services.html +255 -51
@@ -16,6 +16,17 @@ export function bumpSemver(semver) {
16
16
  const parts = /^(v?)(\d+)\.(\d+)\.(\d+)/.exec(semver ?? "");
17
17
  return parts ? `${parts[1]}${parts[2]}.${parts[3]}.${Number(parts[4]) + 1}` : "0.0.1";
18
18
  }
19
+ function mapBuild(build) {
20
+ return {
21
+ id: build.id,
22
+ status: build.status,
23
+ branch: build.branch,
24
+ commit: String(build.commit?.id ?? build.commit_id ?? "") || undefined,
25
+ commit_url: build.commit?.permalink,
26
+ description: build.description ?? undefined,
27
+ created_at: build.created_at,
28
+ };
29
+ }
19
30
  export async function listBuilds(np, applicationId, options = {}) {
20
31
  const query = {
21
32
  application_id: applicationId,
@@ -29,13 +40,13 @@ export async function listBuilds(np, applicationId, options = {}) {
29
40
  if (options.offset)
30
41
  query.offset = options.offset;
31
42
  const page = await np.get("/build", query).catch(() => ({ results: [] }));
32
- return (page.results ?? []).map((build) => ({
33
- id: build.id,
34
- status: build.status,
35
- branch: build.branch,
36
- commit: String(build.commit?.id ?? build.commit_id ?? "") || undefined,
37
- created_at: build.created_at,
38
- }));
43
+ return (page.results ?? []).map(mapBuild);
44
+ }
45
+ /** A single build by id (GET /build/{id}) — titles a build's asset view with its real status,
46
+ * commit and description even when reached directly (not drilled in from the list). */
47
+ export async function getBuild(np, buildId) {
48
+ const raw = await np.get(`/build/${buildId}`).catch(() => null);
49
+ return raw ? mapBuild(raw) : null;
39
50
  }
40
51
  function mapRelease(raw) {
41
52
  return {
@@ -232,11 +243,43 @@ export async function deploymentAction(np, deploymentId, action) {
232
243
  status: action === "finalize" ? "finalizing" : "cancelling",
233
244
  });
234
245
  }
246
+ /** The scope id encoded in a value's NRN (…:scope=<id>), or null for an application-level value. */
247
+ function scopeIdFromNrn(nrn) {
248
+ const match = nrn?.match(/:scope=(\d+)/);
249
+ return match ? Number(match[1]) : null;
250
+ }
251
+ function mapParameter(raw) {
252
+ const secret = !!raw.secret;
253
+ return {
254
+ id: raw.id,
255
+ name: raw.name,
256
+ variable: raw.variable,
257
+ destination_path: raw.destination_path,
258
+ type: raw.type,
259
+ secret,
260
+ read_only: raw.read_only,
261
+ values: (raw.values ?? []).map((entry) => {
262
+ const dimensions = entry.dimensions && Object.keys(entry.dimensions).length ? entry.dimensions : null;
263
+ const scopeId = scopeIdFromNrn(entry.nrn);
264
+ return {
265
+ id: entry.id,
266
+ // a value row only exists where a value is set, so secrets become a fixed marker (never plaintext)
267
+ value: secret ? "•••" : entry.value,
268
+ nrn: entry.nrn,
269
+ scope_id: scopeId,
270
+ dimensions,
271
+ base: !scopeId && !dimensions,
272
+ };
273
+ }),
274
+ };
275
+ }
235
276
  /**
236
- * Upsert parameters. Agents retry, and re-running "set DATABASE_URL" must not accrete a
237
- * duplicate definition so reuse the existing definition (matched by variable/name at this
238
- * NRN) and just add a value; only POST a new definition when none exists. Reports how many
239
- * were created vs updated.
277
+ * Upsert parameters, optionally targeting a scope (pin `nrn`) or a dimension set (`dimensions`).
278
+ * Agents retry, and re-running "set DATABASE_URL" must not accrete a duplicate definition — so
279
+ * reuse the existing definition (matched by variable/name at this NRN) and just add a value; only
280
+ * POST a new definition when none exists. The value write is convergent on (nrn, dimensions,
281
+ * value) server-side, so a retried set is a no-op new version, never a duplicate. Reports how
282
+ * many definitions were created vs reused.
240
283
  */
241
284
  export async function setParameters(np, nrn, params) {
242
285
  const existing = (await listParameters(np, nrn)) ?? [];
@@ -261,28 +304,24 @@ export async function setParameters(np, nrn, params) {
261
304
  definitionId = definition.id;
262
305
  created++;
263
306
  }
264
- await np.post(`/parameter/${definitionId}/values`, { values: [{ nrn, value: param.value }] });
307
+ const valueBody = { nrn: param.nrn ?? nrn, value: param.value };
308
+ if (param.dimensions && Object.keys(param.dimensions).length) {
309
+ valueBody.dimensions = param.dimensions;
310
+ }
311
+ await np.post(`/parameter/${definitionId}/values`, { values: [valueBody] });
265
312
  }
266
313
  return { created, updated };
267
314
  }
268
- /** Read parameters — NRN-scoped on the public API; returns undefined when unavailable. */
315
+ /** Read parameters with their full value set — NRN-scoped on the public API. Pass an application
316
+ * NRN to get EVERY value (all dimensions + scopes); pass a scope NRN and the platform collapses
317
+ * each parameter to the single effective value for that scope. undefined when unavailable. */
269
318
  export async function listParameters(np, nrn, options = {}) {
270
319
  try {
271
320
  const query = { nrn, limit: options.limit ?? 100 };
272
321
  if (options.offset)
273
322
  query.offset = options.offset;
274
323
  const page = await np.get("/parameter", query);
275
- return (page.results ?? []).map((parameter) => ({
276
- id: parameter.id,
277
- name: parameter.name,
278
- variable: parameter.variable,
279
- type: parameter.type,
280
- secret: !!parameter.secret,
281
- values: (parameter.values ?? []).map((entry) => ({
282
- value: parameter.secret ? "•••" : entry.value,
283
- nrn: entry.nrn,
284
- })),
285
- }));
324
+ return (page.results ?? []).map(mapParameter);
286
325
  }
287
326
  catch {
288
327
  return undefined;
@@ -436,3 +475,176 @@ export async function listServiceSpecifications(bff, nrn) {
436
475
  .catch(() => ({ results: [] }));
437
476
  return (page.results ?? []).map((spec) => ({ id: spec.id, name: spec.name ?? spec.id }));
438
477
  }
478
+ // ---- service provisioning & linking (WRITES — core API, like scopes/deployments) ----
479
+ //
480
+ // Grounded against the public service-api OpenAPI (request/response SHAPE) and the platform team's
481
+ // np-developer-actions skill (BEHAVIOUR — the working implementation of exactly this flow).
482
+ //
483
+ // Provisioning is TWO sequential POSTs: `POST /service` creates the record in `pending`, then
484
+ // `POST /service/{id}/action` enqueues the "create" action an external agent processes to `active`
485
+ // (without #2 the service is `pending` forever). Linking is one OR two POSTs depending on the LINK
486
+ // spec's `use_default_actions` — the SQL landmine, encoded in `linkService` so `status` is derived,
487
+ // never an input. Reads `.catch` to empty; writes throw so the tool surfaces the platform error.
488
+ const TERMINAL_SERVICE = new Set(["active", "failed", "cancelled"]);
489
+ export const isServiceTerminal = (status) => TERMINAL_SERVICE.has(status ?? "");
490
+ /**
491
+ * Provisionable dependency specs at an entity NRN, read from the CORE API (not the BFF projection
492
+ * that `listServiceSpecifications` uses) so the write flow can reach the action specs and
493
+ * `use_default_actions` the dashboard list omits.
494
+ */
495
+ export async function listDependencySpecs(np, nrn) {
496
+ const page = await np
497
+ .get("/service_specification", { nrn, type: "dependency", limit: 100 })
498
+ .catch(() => ({ results: [] }));
499
+ return (page.results ?? []).map((spec) => ({
500
+ id: spec.id,
501
+ name: spec.name ?? spec.id,
502
+ category: spec.selectors?.category,
503
+ subCategory: spec.selectors?.sub_category,
504
+ provider: spec.selectors?.provider,
505
+ }));
506
+ }
507
+ const mapActionSpec = (spec) => ({
508
+ id: spec.id,
509
+ type: spec.type ?? "",
510
+ name: spec.name,
511
+ slug: spec.slug,
512
+ schema: spec.parameters?.schema,
513
+ });
514
+ /** Every action spec of a service spec (`create`/`update`/`delete`/`custom`), mapped. The caller
515
+ * filters by `type` — `custom` for run-an-action, `delete` for deprovision, `create` for provision. */
516
+ export async function serviceActionSpecs(np, serviceSpecId) {
517
+ const page = await np
518
+ .get(`/service_specification/${serviceSpecId}/action_specification`)
519
+ .catch(() => ({ results: [] }));
520
+ return (page.results ?? []).map(mapActionSpec);
521
+ }
522
+ /** The "create" action spec drives provisioning; its `parameters.schema` is the input form. */
523
+ export async function serviceCreateActionSpec(np, serviceSpecId) {
524
+ return (await serviceActionSpecs(np, serviceSpecId)).find((spec) => spec.type === "create");
525
+ }
526
+ /**
527
+ * `POST /service` — creates the record in `pending`. Never sends `status` (the backend sets it).
528
+ * Provisioning does NOT start until `createServiceAction` enqueues the create action.
529
+ */
530
+ export async function provisionService(np, input) {
531
+ return np.post("/service", {
532
+ name: input.name,
533
+ specification_id: input.specification_id,
534
+ entity_nrn: input.entity_nrn,
535
+ linkable_to: input.linkable_to ?? [input.entity_nrn],
536
+ dimensions: input.dimensions ?? {},
537
+ selectors: { imported: false },
538
+ });
539
+ }
540
+ /** `POST /service/{id}/action` — enqueue an action (the create action provisions). */
541
+ export async function createServiceAction(np, serviceId, action) {
542
+ return np.post(`/service/${serviceId}/action`, {
543
+ name: action.name,
544
+ specification_id: action.specification_id,
545
+ parameters: action.parameters ?? {},
546
+ });
547
+ }
548
+ export async function getService(np, serviceId) {
549
+ return np.get(`/service/${serviceId}`);
550
+ }
551
+ /** The link spec(s) that apply to a service spec — the OpenAPI maps them directly. Each carries
552
+ * `use_default_actions`, which decides the linking flow (see `linkService`). */
553
+ export async function linkSpecsForService(np, serviceSpecId) {
554
+ const page = await np
555
+ .get(`/service_specification/${serviceSpecId}/link_specification`)
556
+ .catch(() => ({ results: [] }));
557
+ return (page.results ?? []).map((spec) => ({
558
+ id: spec.id,
559
+ name: spec.name ?? spec.id,
560
+ use_default_actions: spec.use_default_actions ?? false,
561
+ }));
562
+ }
563
+ /** Every action spec of a link spec — the caller filters by `type` (`custom`/`delete`/`create`). */
564
+ export async function linkActionSpecs(np, linkSpecId) {
565
+ const page = await np
566
+ .get(`/link_specification/${linkSpecId}/action_specification`)
567
+ .catch(() => ({ results: [] }));
568
+ return (page.results ?? []).map(mapActionSpec);
569
+ }
570
+ /** The create action spec of a LINK spec (only present when `use_default_actions` is true). */
571
+ export async function linkCreateActionSpec(np, linkSpecId) {
572
+ return (await linkActionSpecs(np, linkSpecId)).find((spec) => spec.type === "create");
573
+ }
574
+ /** `POST /link`. `status` is the SQL landmine: it is DERIVED from the link spec's
575
+ * `use_default_actions`, never passed in. Caller passes the resolved spec; this never takes a free
576
+ * `status`. */
577
+ export async function createLink(np, input) {
578
+ const body = {
579
+ name: input.name,
580
+ service_id: input.service_id,
581
+ entity_nrn: input.entity_nrn,
582
+ specification_id: input.specification_id,
583
+ dimensions: input.dimensions ?? {},
584
+ };
585
+ // use_default_actions=false → no agent exists, so `active` must be set or the link is stuck
586
+ // `pending` forever. use_default_actions=true → OMIT status; a follow-up create-action provisions
587
+ // credentials. Sending `active` there yields an active-but-credential-less, unrecoverable link.
588
+ if (input.activateImmediately)
589
+ body.status = "active";
590
+ return np.post("/link", body);
591
+ }
592
+ export async function createLinkAction(np, linkId, action) {
593
+ return np.post(`/link/${linkId}/action`, {
594
+ name: action.name,
595
+ specification_id: action.specification_id,
596
+ parameters: action.parameters ?? {},
597
+ });
598
+ }
599
+ export async function getLink(np, linkId) {
600
+ return np.get(`/link/${linkId}`);
601
+ }
602
+ /** Existing dependency services on an app (core API) — for convergence: a retried provision must
603
+ * reuse a same-name service rather than spin up a second (cost-bearing) one. */
604
+ export async function listAppServices(np, nrn) {
605
+ const page = await np
606
+ .get("/service", { nrn, type: "dependency", limit: 100 })
607
+ .catch(() => ({ results: [] }));
608
+ return (page.results ?? []).map((service) => ({
609
+ id: service.id,
610
+ name: service.name ?? service.id,
611
+ status: service.status ?? "",
612
+ specification_id: service.specification_id,
613
+ }));
614
+ }
615
+ /** Existing links on an app (core API) — for convergence: a retried link must reuse an existing one
616
+ * for the same service+target rather than create a duplicate; also feeds the link picker (name) and
617
+ * the link-action / link-delete flows (specification_id → the link spec's action specs). */
618
+ export async function listLinks(np, nrn) {
619
+ const page = await np.get("/link", { nrn }).catch(() => ({ results: [] }));
620
+ return (page.results ?? []).map((link) => ({
621
+ id: link.id,
622
+ name: link.name ?? link.id,
623
+ status: link.status ?? "",
624
+ service_id: link.service_id,
625
+ entity_nrn: link.entity_nrn,
626
+ specification_id: link.specification_id,
627
+ }));
628
+ }
629
+ /** Poll one action of a service — `pending` → `in_progress` → `success`|`failed`. */
630
+ export async function getServiceAction(np, serviceId, actionId) {
631
+ return np.get(`/service/${serviceId}/action/${actionId}`);
632
+ }
633
+ /** Poll one action of a link. */
634
+ export async function getLinkAction(np, linkId, actionId) {
635
+ return np.get(`/link/${linkId}/action/${actionId}`);
636
+ }
637
+ /** `DELETE /service/{id}`. A `failed` service (no provisioned resources) deletes directly; an active
638
+ * one needs `force` OR a prior delete-action that deprovisions — the tool decides which path. */
639
+ export async function deleteService(np, serviceId, force = false) {
640
+ await np.del(`/service/${serviceId}`, force ? { force: true } : undefined);
641
+ }
642
+ /** `DELETE /link/{id}`. `force` skips the deprovisioning delete-action (orphans credentials), so the
643
+ * tool only forces when there's no delete-action to run; otherwise it runs the action first. */
644
+ export async function deleteLink(np, linkId, force = false) {
645
+ await np.del(`/link/${linkId}`, force ? { force: true } : undefined);
646
+ }
647
+ /** `DELETE /parameter/{id}` — removes a whole parameter definition (and all its values). */
648
+ export async function deleteParameter(np, parameterId) {
649
+ await np.del(`/parameter/${parameterId}`);
650
+ }
@@ -12,7 +12,7 @@ You run in the developer's own environment, so fuse the local repo with platform
12
12
 
13
13
  Every tool accepts \`language\`: ALWAYS set it to the language the user is conversing in (ISO code, e.g. "es", "en") — answers come back in the user's language.
14
14
 
15
- Most tools render an interactive panel in clients that support it — the user sees apps, status, logs, metrics and parameters as live UI. When that panel renders, the user can already see the result: do NOT restate or summarise it in text (no re-listing applications, reprinting tables, or repeating status/metrics). Reply with at most one short sentence — the single key takeaway or next step — or nothing at all. Add prose only for something the panel doesn't already show.
15
+ Most tools render an interactive panel in clients that support it — apps, status, builds, releases, deployments, logs, metrics, parameters and approvals all appear as live UI. **When a tool's panel renders, that panel IS the answer: do not reproduce its data in your text reply.** The user already sees every row, status and value. NEVER print a markdown table of the same rows, re-list the items, or restate per-row status — duplicating the panel in text is the single most common mistake. Reply with AT MOST one short sentence — the one key takeaway or the next step — or nothing at all. A one-line insight the panel doesn't itself show is fine ("builds 3 and 5 were never released"); re-rendering the list as a table is not.
16
16
 
17
17
  When the user wants to create, scaffold, set up or import an application, call \`application_create\` right away — pass a name if they gave one, otherwise no arguments. Its panel is an interactive FORM that collects the namespace, template and repository itself. Do NOT gather those details in conversation first, and while that form is on screen do NOT ask the user clarifying questions or use any question-asking tool for fields the form already covers (namespace, template, new-vs-import repository, monorepo path) — the form is the input surface. Just call \`application_create\` and let it drive; react only to what it reports back.
18
18
 
@@ -21,6 +21,13 @@ export const TOOL = {
21
21
  applicationReleaseCreate: "application_release_create",
22
22
  applicationScopeCreate: "application_scope_create",
23
23
  applicationServiceList: "application_service_list",
24
+ applicationServiceCreate: "application_service_create",
25
+ applicationServiceUpdate: "application_service_update",
26
+ applicationServiceDelete: "application_service_delete",
27
+ applicationLinkCreate: "application_link_create",
28
+ applicationLinkUpdate: "application_link_update",
29
+ applicationLinkDelete: "application_link_delete",
30
+ applicationParameterDelete: "application_parameter_delete",
24
31
  applicationApprovalList: "application_approval_list",
25
32
  organizationGet: "organization_get",
26
33
  playbookGet: "playbook_get",
@@ -0,0 +1,94 @@
1
+ import { translate } from "../i18n.js";
2
+ import { linkLine, next, table } from "../md.js";
3
+ import { fail, reply } from "../tool.js";
4
+ import { delays, sleep } from "./shared.js";
5
+ const slug = (text) => text
6
+ .toLowerCase()
7
+ .replace(/[^a-z0-9]+/g, "-")
8
+ .replace(/^-+|-+$/g, "");
9
+ const actionLabel = (spec) => spec.name ?? spec.slug ?? spec.id;
10
+ /**
11
+ * The run-a-custom-action flow shared by `application_service_update` and `application_link_update`:
12
+ * pick which action → fill its parameters (form-first; nothing runs without `run:true`) → POST the
13
+ * action → poll it once → report. A custom action is an explicit operation (it is NOT idempotent —
14
+ * running a DML twice runs it twice), so the safety here is the form gate, not convergence.
15
+ */
16
+ export async function runActionFlow(config, args) {
17
+ const { entity, entityName, specs, dashboard } = config;
18
+ if (specs.length === 0) {
19
+ return fail(translate("runAction.noActions", { name: entityName }) + linkLine(translate("md.dashboard"), dashboard));
20
+ }
21
+ // Match the requested action by name/slug; none or ambiguous → list the runnable actions.
22
+ const wanted = args.action?.toLowerCase();
23
+ const matched = wanted
24
+ ? specs.filter((spec) => actionLabel(spec).toLowerCase().includes(wanted) ||
25
+ (spec.slug ?? "").toLowerCase() === wanted ||
26
+ spec.id === args.action)
27
+ : [];
28
+ if (matched.length !== 1) {
29
+ const list = matched.length > 1 ? matched : specs;
30
+ const heading = translate(matched.length > 1 ? "runAction.matchAmbiguous" : "runAction.pickAction", {
31
+ name: entityName,
32
+ });
33
+ const md = `${heading}\n\n${table([translate("header.name"), translate("runAction.paramsHeader")], list.map((spec) => [
34
+ actionLabel(spec),
35
+ (spec.schema?.required ?? []).join(", ") || "—",
36
+ ]))}${next(translate("runAction.pickHint"))}`;
37
+ return reply(md, {
38
+ mode: "pick-action",
39
+ entity,
40
+ tool: config.tool,
41
+ app: config.appRef,
42
+ app_name: config.appName,
43
+ target: { id: config.entityId, name: entityName, status: config.entityStatus },
44
+ actions: list.map((spec) => ({ id: spec.id, name: actionLabel(spec), slug: spec.slug })),
45
+ dashboard,
46
+ });
47
+ }
48
+ const spec = matched[0];
49
+ // Form-first: no explicit run OR a required parameter still missing → return the form.
50
+ const requiredKeys = spec.schema?.required ?? [];
51
+ const missing = requiredKeys.filter((key) => args.parameters?.[key] === undefined);
52
+ if (!args.run || missing.length > 0) {
53
+ return reply(translate("runAction.form", { action: actionLabel(spec), name: entityName }) +
54
+ next(translate("runAction.formHint")), {
55
+ mode: "form",
56
+ entity,
57
+ tool: config.tool,
58
+ app: config.appRef,
59
+ app_name: config.appName,
60
+ target: { id: config.entityId, name: entityName, status: config.entityStatus },
61
+ action: { id: spec.id, name: actionLabel(spec), slug: spec.slug },
62
+ parameters_schema: spec.schema ?? null,
63
+ dashboard,
64
+ });
65
+ }
66
+ // ---- RUN THE ACTION ----
67
+ const fired = await config.run({
68
+ name: spec.slug ?? slug(actionLabel(spec)),
69
+ specification_id: spec.id,
70
+ parameters: args.parameters ?? {},
71
+ });
72
+ await sleep(delays.appPoll);
73
+ const action = await config
74
+ .poll(fired.id)
75
+ .catch(() => ({ id: fired.id, status: fired.status ?? "pending" }));
76
+ const md = `${translate("runAction.running", { action: actionLabel(spec), name: entityName, status: action.status })}` +
77
+ next(translate("runAction.ranHint")) +
78
+ linkLine(translate("md.dashboard"), dashboard);
79
+ return reply(md, {
80
+ mode: "progress",
81
+ entity,
82
+ tool: config.tool,
83
+ app: config.appRef,
84
+ app_name: config.appName,
85
+ target: { id: config.entityId, name: entityName, status: config.entityStatus },
86
+ action: {
87
+ id: action.id,
88
+ name: actionLabel(spec),
89
+ status: action.status,
90
+ results: action.results ?? null,
91
+ },
92
+ dashboard,
93
+ });
94
+ }
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { translate } from "../i18n.js";
2
+ import { plural, translate } from "../i18n.js";
3
3
  import { ago, dashboardLink, glyph, linkLine, next, table } from "../md.js";
4
4
  import { cancelApproval, executeApproval, isSafeApprovalId, listApprovals } from "../np/journey.js";
5
5
  import { defineTool, fail, reply } from "../tool.js";
@@ -52,7 +52,7 @@ export const approvalsTool = defineTool({
52
52
  return reply(translate("approvals.none", { app: app.name }), { app: `#${app.id}`, approvals: [] });
53
53
  }
54
54
  const markdown = [
55
- translate("approvals.title", { app: app.name, count: approvals.length }),
55
+ plural(approvals.length, "approvals.title.one", "approvals.title.many", { app: app.name }),
56
56
  "",
57
57
  table([
58
58
  translate("header.id"),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { translate } from "../i18n.js";
2
+ import { plural, translate } from "../i18n.js";
3
3
  import { ago, glyph, next, shortCommit, table } from "../md.js";
4
- import { listAssets, listBuilds, listReleases } from "../np/journey.js";
4
+ import { getBuild, listAssets, listBuilds, listReleases } from "../np/journey.js";
5
5
  import { defineTool, reply } from "../tool.js";
6
6
  import { TOOL } from "../tool-names.js";
7
7
  import { appArg, offsetArg, pageOf, requireApp } from "./shared.js";
@@ -33,10 +33,17 @@ export const buildsTool = defineTool({
33
33
  return resolved.out;
34
34
  const app = resolved.app;
35
35
  if (args.build) {
36
- const assets = await listAssets(context.np, args.build);
36
+ // Fetch the build alongside its assets so the asset view shows the real status/commit and
37
+ // a no-assets message that fits the build's state (running vs failed vs simply none).
38
+ const [build, assets] = await Promise.all([
39
+ getBuild(context.np, args.build),
40
+ listAssets(context.np, args.build),
41
+ ]);
42
+ const buildData = build ?? { id: args.build, status: "" };
37
43
  if (assets.length === 0) {
38
- return reply(translate("builds.noAssets", { build: args.build }), {
44
+ return reply(noAssetsText(build?.status, args.build), {
39
45
  build_id: args.build,
46
+ build: buildData,
40
47
  assets: [],
41
48
  });
42
49
  }
@@ -46,7 +53,7 @@ export const buildsTool = defineTool({
46
53
  table([translate("header.asset"), translate("header.type"), translate("header.platform")], assets.map((asset) => [asset.name, asset.type, asset.platform ?? ""])),
47
54
  next(translate("builds.deployHint", { build: args.build })),
48
55
  ].join("\n");
49
- return reply(markdown, { build_id: args.build, assets });
56
+ return reply(markdown, { build_id: args.build, build: buildData, assets });
50
57
  }
51
58
  const [builds, releases] = await Promise.all([
52
59
  listBuilds(context.np, app.id, { limit: args.limit ?? 10, commit: args.commit, offset: args.offset }),
@@ -61,7 +68,7 @@ export const buildsTool = defineTool({
61
68
  const releasedBuildIds = new Set(releases.map((release) => release.build_id).filter(Boolean));
62
69
  const releaseByBuild = new Map(releases.map((release) => [release.build_id, release]));
63
70
  const markdown = [
64
- translate("builds.title", { app: app.name, count: builds.length }),
71
+ plural(builds.length, "builds.title.one", "builds.title.many", { app: app.name }),
65
72
  "",
66
73
  table([
67
74
  translate("header.id"),
@@ -92,6 +99,16 @@ export const buildsTool = defineTool({
92
99
  });
93
100
  },
94
101
  });
102
+ /** No-assets message keyed to the build's state — "may still be running" only fits a build that
103
+ * actually still is; a failed or finished one needs the truth. */
104
+ function noAssetsText(status, build) {
105
+ if (status === "pending" || status === "in_progress") {
106
+ return translate("builds.noAssetsRunning", { build });
107
+ }
108
+ if (status === "failed")
109
+ return translate("builds.noAssetsFailed", { build });
110
+ return translate("builds.noAssets", { build });
111
+ }
95
112
  function buildsHint(builds, releasedBuildIds) {
96
113
  const newestSuccessful = builds.find((build) => build.status === "successful");
97
114
  if (newestSuccessful && !releasedBuildIds.has(newestSuccessful.id)) {