From 79bac5e75b08746be2d92ee627b9986c30dbe0c0 Mon Sep 17 00:00:00 2001 From: enyineer Date: Fri, 26 Jun 2026 17:45:31 +0200 Subject: [PATCH 1/5] feat: new-user onboarding overhaul + realtime-signal & mobile fixes Reduce the initial hurdle new users face around the catalog and health checks, plus follow-up fixes surfaced during review. Onboarding: - Atomic healthcheck.createAndAssign RPC; AI propose tool prefers HTTP over a script and creates + assigns in one step; gated onboarding system-prompt. - catalog.createEnvironment / setSystemEnvironments AI tools + listEnvironments. - FirstCheckWizard (new @checkstack/ui Stepper): system + HTTP check + assignment in one guided flow, from the Health Checks empty state and a "Quick start" header button; new-or-existing system; environment nudges. - Docs: enable Mermaid, add architecture/onboarding diagrams, clarify assignments and environments. AI chat: - askOperator tool: clickable answer chips instead of plaintext questions. Fixes: - catalog + healthcheck now broadcast realtime signals on mutations, so out-of-band writes (AI, GitOps, other pods/users) refresh open clients - fixes a stale-cache 404 on the system page after AI-created systems. - Mobile: nav drawer bound to the dynamic viewport (scrolls to the last item); navbar wordmark hidden on small screens. - associateSystem no longer drops the per-assignment notificationPolicy. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011v4Nr8YnMGMRziVhgqTc1z --- .changeset/ai-ask-operator-chips.md | 17 + .changeset/ai-onboarding-playbook.md | 12 + .changeset/catalog-environment-ai-tools.md | 11 + .changeset/catalog-realtime-signal.md | 19 + .changeset/frontend-first-check-wizard.md | 18 + .../healthcheck-config-changed-signal.md | 17 + .changeset/healthcheck-create-and-assign.md | 19 + .changeset/mobile-nav-layout.md | 16 + .changeset/ui-stepper.md | 11 + bun.lock | 156 ++++++- core/ai-backend/src/chat/chat-service.ts | 30 ++ .../ai-backend/src/chat/system-prompt.test.ts | 27 ++ core/ai-backend/src/chat/system-prompt.ts | 44 +- .../ai-backend/src/tools/ask-operator.test.ts | 44 ++ core/ai-backend/src/tools/ask-operator.ts | 87 ++++ core/ai-backend/src/tools/composite-tools.ts | 5 + .../ai-backend/src/tools/tool-set.e2e.test.ts | 1 + .../src/components/QuestionCardView.tsx | 75 +++ core/ai-frontend/src/lib/chat-state.test.ts | 27 ++ core/ai-frontend/src/lib/chat-state.ts | 18 +- .../ai-frontend/src/lib/stream-parser.test.ts | 57 +++ core/ai-frontend/src/lib/stream-parser.ts | 52 +++ core/ai-frontend/src/pages/ChatPage.tsx | 34 ++ core/catalog-backend/package.json | 1 + .../src/ai/catalog-create-environment.test.ts | 63 +++ .../src/ai/catalog-create-environment.ts | 67 +++ .../src/ai/catalog-create-system.ts | 2 +- .../catalog-set-system-environments.test.ts | 60 +++ .../src/ai/catalog-set-system-environments.ts | 76 ++++ .../src/ai/register-ai-tools.ts | 4 + core/catalog-backend/src/index.ts | 13 + core/catalog-backend/src/router.ts | 85 ++++ core/catalog-backend/tsconfig.json | 3 + core/catalog-common/package.json | 1 + core/catalog-common/src/index.ts | 1 + core/catalog-common/src/signals.test.ts | 32 ++ core/catalog-common/src/signals.ts | 31 ++ core/catalog-common/tsconfig.json | 3 + .../src/components/SystemEditor.tsx | 26 ++ core/common/src/docs-links.ts | 1 + core/frontend/src/App.tsx | 8 +- .../src/ai/healthcheck-propose.test.ts | 89 ++++ .../src/ai/healthcheck-propose.ts | 56 ++- core/healthcheck-backend/src/index.ts | 1 + .../src/router-create-and-assign.test.ts | 220 +++++++++ core/healthcheck-backend/src/router.ts | 176 ++++++-- core/healthcheck-backend/src/service.ts | 68 +++ core/healthcheck-common/src/index.ts | 25 + core/healthcheck-common/src/rpc-contract.ts | 31 ++ core/healthcheck-common/src/schemas.ts | 38 ++ .../components/FirstCheckWizard.logic.test.ts | 53 +++ .../src/components/FirstCheckWizard.logic.ts | 63 +++ .../src/components/FirstCheckWizard.tsx | 427 ++++++++++++++++++ .../src/pages/AssignmentIDEPage.tsx | 23 +- .../src/pages/HealthCheckConfigPage.tsx | 27 +- core/ui/src/components/Sheet.tsx | 6 +- core/ui/src/components/Stepper.logic.test.ts | 73 +++ core/ui/src/components/Stepper.logic.ts | 72 +++ core/ui/src/components/Stepper.tsx | 218 +++++++++ core/ui/src/index.ts | 1 + core/ui/stories/Stepper.stories.tsx | 106 +++++ docs/astro.config.mjs | 8 + docs/package.json | 4 + .../architecture/plugin-system.md | 56 +++ .../docs/user-guide/concepts/health-checks.md | 63 +++ .../docs/user-guide/concepts/overview.md | 20 +- .../user-guide/concepts/systems-and-groups.md | 6 +- .../user-guide/guides/first-health-check.md | 27 ++ 68 files changed, 3168 insertions(+), 63 deletions(-) create mode 100644 .changeset/ai-ask-operator-chips.md create mode 100644 .changeset/ai-onboarding-playbook.md create mode 100644 .changeset/catalog-environment-ai-tools.md create mode 100644 .changeset/catalog-realtime-signal.md create mode 100644 .changeset/frontend-first-check-wizard.md create mode 100644 .changeset/healthcheck-config-changed-signal.md create mode 100644 .changeset/healthcheck-create-and-assign.md create mode 100644 .changeset/mobile-nav-layout.md create mode 100644 .changeset/ui-stepper.md create mode 100644 core/ai-backend/src/tools/ask-operator.test.ts create mode 100644 core/ai-backend/src/tools/ask-operator.ts create mode 100644 core/ai-frontend/src/components/QuestionCardView.tsx create mode 100644 core/catalog-backend/src/ai/catalog-create-environment.test.ts create mode 100644 core/catalog-backend/src/ai/catalog-create-environment.ts create mode 100644 core/catalog-backend/src/ai/catalog-set-system-environments.test.ts create mode 100644 core/catalog-backend/src/ai/catalog-set-system-environments.ts create mode 100644 core/catalog-common/src/signals.test.ts create mode 100644 core/catalog-common/src/signals.ts create mode 100644 core/healthcheck-backend/src/router-create-and-assign.test.ts create mode 100644 core/healthcheck-frontend/src/components/FirstCheckWizard.logic.test.ts create mode 100644 core/healthcheck-frontend/src/components/FirstCheckWizard.logic.ts create mode 100644 core/healthcheck-frontend/src/components/FirstCheckWizard.tsx create mode 100644 core/ui/src/components/Stepper.logic.test.ts create mode 100644 core/ui/src/components/Stepper.logic.ts create mode 100644 core/ui/src/components/Stepper.tsx create mode 100644 core/ui/stories/Stepper.stories.tsx diff --git a/.changeset/ai-ask-operator-chips.md b/.changeset/ai-ask-operator-chips.md new file mode 100644 index 000000000..888fdbf17 --- /dev/null +++ b/.changeset/ai-ask-operator-chips.md @@ -0,0 +1,17 @@ +--- +"@checkstack/ai-backend": minor +"@checkstack/ai-frontend": minor +--- + +feat(ai): clickable answer options in chat (askOperator) + +Add an `askOperator` tool the assistant calls to ask a question with clickable +answer chips (plus an optional free-text box) instead of a plaintext list. +Clicking a chip sends that answer as the operator's next message. The chat +renders the chips from a `__question` tool-output card, mirroring the existing +confirm-card pattern, and calling the tool ends the turn (the operator's choice +arrives as their next message). + +The system prompt now steers the model to use `askOperator` for discrete-choice +clarifications (which system, which protocol, how often, which environment), +reserving prose questions for free-form values like a URL. diff --git a/.changeset/ai-onboarding-playbook.md b/.changeset/ai-onboarding-playbook.md new file mode 100644 index 000000000..68534f0bc --- /dev/null +++ b/.changeset/ai-onboarding-playbook.md @@ -0,0 +1,12 @@ +--- +"@checkstack/ai-backend": minor +--- + +feat(ai): add an onboarding playbook to the chat assistant + +When a monitoring-setup tool is in scope this turn (creating a system, proposing +a health check, or managing environments), the chat system prompt now injects an +onboarding section that steers the model to prefer the HTTP strategy for a URL, +ask before guessing, create-and-assign a check in one step, and use environments +instead of cloning a system per deployment stage. Like the automation playbook, +it stays out of the always-on prompt on pure read turns. diff --git a/.changeset/catalog-environment-ai-tools.md b/.changeset/catalog-environment-ai-tools.md new file mode 100644 index 000000000..187e0cd54 --- /dev/null +++ b/.changeset/catalog-environment-ai-tools.md @@ -0,0 +1,11 @@ +--- +"@checkstack/catalog-backend": minor +--- + +feat(catalog): AI tools for environments + +Add `catalog.createEnvironment` and `catalog.setSystemEnvironments` AI tools plus +a `catalog.listEnvironments` read projection, so the assistant can model +one-system-many-environments instead of suggesting a separate system per +environment. The `catalog.createSystem` tool description now teaches the 1-1 +system/check pairing and points to environments for modelling dev/staging/prod. diff --git a/.changeset/catalog-realtime-signal.md b/.changeset/catalog-realtime-signal.md new file mode 100644 index 000000000..96a9a1e37 --- /dev/null +++ b/.changeset/catalog-realtime-signal.md @@ -0,0 +1,19 @@ +--- +"@checkstack/catalog-common": minor +"@checkstack/catalog-backend": minor +--- + +fix(catalog): emit a realtime signal on catalog mutations so clients refresh + +Catalog was the only domain plugin that never broadcast a realtime signal, so +any out-of-band write - the AI assistant (which mutates on the backend, with no +frontend mutation to invalidate), GitOps reconcile, or another pod/user - left +every other client's catalog cache stale until a hard reload. Most visibly, a +system created via the assistant 404'd on the catalog detail page (which +resolves a system by finding it in the cached `getSystems` list) until reload. + +Add a `CATALOG_CHANGED` signal (`catalog.changed`) and broadcast it from every +catalog mutation (system, group, environment CRUD and membership changes). The +frontend signal auto-invalidator refreshes the `[[catalog]]` react-query cache +on every connected client, so out-of-band catalog changes now appear without a +reload. diff --git a/.changeset/frontend-first-check-wizard.md b/.changeset/frontend-first-check-wizard.md new file mode 100644 index 000000000..64ccda19f --- /dev/null +++ b/.changeset/frontend-first-check-wizard.md @@ -0,0 +1,18 @@ +--- +"@checkstack/healthcheck-frontend": minor +"@checkstack/catalog-frontend": minor +--- + +feat(frontend): guided "create your first check" wizard and onboarding nudges + +Add a `FirstCheckWizard`, reachable both from the Health Checks empty state and +an always-available "Quick start" header button: the user picks a system (a new +one or an existing one), pastes a URL, and the wizard creates the HTTP health +check and the assignment (started immediately) in one guided flow, built on the +new `@checkstack/ui` Stepper. This makes guided setup usable when onboarding into +an instance that already has systems and checks, not only on first run. + +Also add two in-product nudges: an inline "one system, many environments" hint +on the Create System form (so new users stop cloning a system per stage), and a +clear "what an assignment is and why a check needs one" explainer on the +assignment screen's empty state. diff --git a/.changeset/healthcheck-config-changed-signal.md b/.changeset/healthcheck-config-changed-signal.md new file mode 100644 index 000000000..b43b3f162 --- /dev/null +++ b/.changeset/healthcheck-config-changed-signal.md @@ -0,0 +1,17 @@ +--- +"@checkstack/healthcheck-common": minor +"@checkstack/healthcheck-backend": minor +--- + +fix(healthcheck): emit a realtime signal on config/assignment changes + +The health-check executor broadcasts run/status signals, but config and +assignment CRUD (create/update/delete/pause/resume, associate/disassociate, +create-and-assign) emitted nothing - so a check created or edited out-of-band +(the AI assistant, GitOps, another pod/user) did not appear in an open Health +Checks list until the first run fired a status signal, up to an interval later. + +Add a `HEALTHCHECK_CONFIG_CHANGED` (`healthcheck.config.changed`) signal, +broadcast from every config/assignment mutation, so the frontend signal +auto-invalidator refreshes the `[[healthcheck]]` cache on every connected client +immediately. diff --git a/.changeset/healthcheck-create-and-assign.md b/.changeset/healthcheck-create-and-assign.md new file mode 100644 index 000000000..bfa08a483 --- /dev/null +++ b/.changeset/healthcheck-create-and-assign.md @@ -0,0 +1,19 @@ +--- +"@checkstack/healthcheck-common": minor +"@checkstack/healthcheck-backend": minor +--- + +feat(healthcheck): atomically create and assign a health check in one step + +Add a `createAndAssign` RPC that creates a health-check configuration and +assigns it to a system in a single transaction, so the common "one system, one +check" case can never leave a dormant, unassigned check that runs nothing. When +the assignment is enabled it is scheduled immediately, exactly like +`associateSystem`. + +The AI `healthcheck.propose` tool now prefers the HTTP strategy for a URL +(instead of authoring a script health check) and, when given `assignToSystemId`, +creates, assigns, and starts the check in the same approval. + +Also fixes a latent bug where the `associateSystem` handler silently dropped the +per-assignment `notificationPolicy` before it reached the database. diff --git a/.changeset/mobile-nav-layout.md b/.changeset/mobile-nav-layout.md new file mode 100644 index 000000000..86721d1e1 --- /dev/null +++ b/.changeset/mobile-nav-layout.md @@ -0,0 +1,16 @@ +--- +"@checkstack/ui": minor +"@checkstack/frontend": minor +--- + +fix(mobile): make the nav drawer fully scrollable and de-clutter the navbar + +The mobile navigation drawer (`Sheet`) spanned the layout viewport +(`inset-y-0 ... h-full`), so on a phone its bottom - and the last menu items - +sat behind the browser URL bar and could not be reached. The sheet is now bound +to the dynamic viewport (`h-[100dvh]`, top-anchored), so it ends at the visible +bottom and scrolls to the last item. + +The "Checkstack" wordmark in the navbar is now hidden below the `sm` breakpoint +(the logo still anchors the home link), freeing space on the cramped mobile +navbar. diff --git a/.changeset/ui-stepper.md b/.changeset/ui-stepper.md new file mode 100644 index 000000000..d1798f58c --- /dev/null +++ b/.changeset/ui-stepper.md @@ -0,0 +1,11 @@ +--- +"@checkstack/ui": minor +--- + +feat(ui): add a Stepper primitive + +Add a presentational `Stepper` step-indicator component and a `useStepper` state +hook for building guided multi-step flows (used by the new "create your first +check" onboarding wizard). Completed steps are navigable; the active step is +highlighted; future steps are muted. Animations are disabled on low-power +devices via `usePerformance`. diff --git a/bun.lock b/bun.lock index c0bc81753..b9e510cb6 100644 --- a/bun.lock +++ b/bun.lock @@ -625,6 +625,7 @@ "@checkstack/gitops-backend": "workspace:*", "@checkstack/gitops-common": "workspace:*", "@checkstack/notification-common": "workspace:*", + "@checkstack/signal-common": "workspace:*", "@orpc/contract": "^1.14.4", "@orpc/server": "^1.14.4", "drizzle-orm": "^0.45.0", @@ -651,6 +652,7 @@ "@checkstack/common": "workspace:*", "@checkstack/frontend-api": "workspace:*", "@checkstack/notification-common": "workspace:*", + "@checkstack/signal-common": "workspace:*", "@orpc/contract": "^1.14.4", "zod": "^4.2.1", }, @@ -1808,7 +1810,7 @@ }, "core/sdk": { "name": "@checkstack/sdk", - "version": "0.115.1", + "version": "0.116.0", "dependencies": { "@checkstack/announcement-common": "workspace:*", "@checkstack/anomaly-common": "workspace:*", @@ -2378,6 +2380,10 @@ "docs": { "name": "@checkstack/docs", "version": "0.1.0", + "dependencies": { + "astro-mermaid": "^2.1.0", + "mermaid": "^11.16.0", + }, "devDependencies": { "@astrojs/check": "^0.9.4", "@astrojs/starlight": "^0.38.4", @@ -3055,6 +3061,8 @@ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@astrojs/check": ["@astrojs/check@0.9.9", "", { "dependencies": { "@astrojs/language-server": "^2.16.7", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-A5UW8uIuErLWEoRQvzgXpO1gTjUFtK8r7nU2Z7GewAMxUb7bPvpk11qaKKgxqXlHJWlAvaaxy+Xg28A6bmQ1Tg=="], "@astrojs/compiler": ["@astrojs/compiler@4.0.0", "", {}, "sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA=="], @@ -3133,6 +3141,8 @@ "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], + "@capsizecss/unpack": ["@capsizecss/unpack@4.0.0", "", { "dependencies": { "fontkitten": "^1.0.0" } }, "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA=="], "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.1", "", { "dependencies": { "@changesets/config": "^3.1.4", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA=="], @@ -3463,6 +3473,8 @@ "@checkstack/ui": ["@checkstack/ui@workspace:core/ui"], + "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], + "@clack/core": ["@clack/core@1.3.0", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA=="], "@clack/prompts": ["@clack/prompts@1.3.0", "", { "dependencies": { "@clack/core": "1.3.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw=="], @@ -3703,6 +3715,10 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@iconify/utils": ["@iconify/utils@3.1.3", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], @@ -3821,6 +3837,8 @@ "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="], + "@module-federation/dts-plugin": ["@module-federation/dts-plugin@2.5.1", "", { "dependencies": { "@module-federation/error-codes": "2.5.1", "@module-federation/managers": "2.5.1", "@module-federation/sdk": "2.5.1", "@module-federation/third-party-dts-extractor": "2.5.1", "adm-zip": "0.5.10", "ansi-colors": "4.1.3", "isomorphic-ws": "5.0.0", "node-schedule": "2.1.1", "undici": "7.24.7", "ws": "8.21.0" }, "peerDependencies": { "typescript": "^4.9.0 || ^5.0.0", "vue-tsc": ">=1.0.24" }, "optionalPeers": ["vue-tsc"] }, "sha512-XylIL02As+VXk4DzFb8qYQs1YYoY0MZOpIqhf06LMkZBjPdOE0wJ1GOBvhFP9kM/cfuK7QV//mNdrWlZgvEkRA=="], "@module-federation/error-codes": ["@module-federation/error-codes@2.5.1", "", {}, "sha512-3KIR8XbEW0Y+Fn8IAnxzDWMvXQWiS40Z1TE/Fft9aTeXP9dDAM7AiVhjTh5yF2csAwHSt/1LJVZbiCmS13mE8A=="], @@ -4251,26 +4269,62 @@ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + + "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + + "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + + "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + + "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + + "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], @@ -4291,6 +4345,8 @@ "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/handlebars": ["@types/handlebars@4.1.0", "", { "dependencies": { "handlebars": "*" } }, "sha512-gq9YweFKNNB1uFK71eRqsd4niVkXrxHugqWFQkeLRJvGjnxsLr16bYtcsG4tOFwmYi0Bax+wCkbf1reUfdl4kA=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], @@ -4387,6 +4443,8 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="], @@ -4483,6 +4541,8 @@ "astro-expressive-code": ["astro-expressive-code@0.41.7", "", { "dependencies": { "rehype-expressive-code": "^0.41.7" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta" } }, "sha512-hUpogGc6DdAd+I7pPXsctyYPRBJDK7Q7d06s4cyP0Vz3OcbziP3FNzN0jZci1BpCvLn9675DvS7B9ctKKX64JQ=="], + "astro-mermaid": ["astro-mermaid@2.1.0", "", { "dependencies": { "import-meta-resolve": "^4.2.0", "mdast-util-to-string": "^4.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@mermaid-js/layout-elk": "^0.2.0", "astro": ">=4", "mermaid": "^10.0.0 || ^11.0.0" }, "optionalPeers": ["@mermaid-js/layout-elk"] }, "sha512-fFRUN0BTZh+DZhDiLyblXoO26XqJ1Rr+qK3JGgSu7OBspKHDm59jkztg/aHsrdo1vO/tIq/+xhP/vgT8Mp92XA=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], "async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="], @@ -4647,6 +4707,8 @@ "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], + "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="], "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], @@ -4677,24 +4739,62 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "cytoscape": ["cytoscape@3.34.0", "", {}, "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg=="], + + "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], + + "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], + + "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + + "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + + "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], + + "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], @@ -4709,6 +4809,8 @@ "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], @@ -4735,6 +4837,8 @@ "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], @@ -4981,6 +5085,8 @@ "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="], @@ -5067,6 +5173,8 @@ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], @@ -5177,8 +5285,12 @@ "jsonpath-plus": ["jsonpath-plus@10.4.0", "", { "dependencies": { "@jsep-plugin/assignment": "^1.3.0", "@jsep-plugin/regex": "^1.0.4", "jsep": "^1.4.0" }, "bin": { "jsonpath": "bin/jsonpath-cli.js", "jsonpath-plus": "bin/jsonpath-cli.js" } }, "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA=="], + "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="], @@ -5189,6 +5301,8 @@ "launder": ["launder@1.7.1", "", { "dependencies": { "dayjs": "^1.11.7" } }, "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw=="], + "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], "ldapts": ["ldapts@8.1.7", "", { "dependencies": { "strict-event-emitter-types": "2.0.0" } }, "sha512-TJl6T92eIwMf/OJ0hDfKVa6ISwzo+lqCWCI5Mf//ARlKa3LKQZaSrme/H2rCRBhW0DZCQlrsV+fgoW5YHRNLUw=="], @@ -5227,6 +5341,8 @@ "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], @@ -5307,6 +5423,8 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "mermaid": ["mermaid@11.16.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -5525,6 +5643,8 @@ "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], @@ -5573,6 +5693,10 @@ "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], + + "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], @@ -5769,6 +5893,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], + "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], "rollup": ["rollup@4.60.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="], @@ -5777,12 +5903,16 @@ "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], "run-async": ["run-async@4.0.6", "", {}, "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -5887,6 +6017,8 @@ "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -6185,6 +6317,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@antfu/install-pkg/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "@astrojs/language-server/@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], "@astrojs/language-server/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], @@ -6379,6 +6513,16 @@ "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], + + "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "dockerode/@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="], @@ -6417,6 +6561,8 @@ "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -6425,6 +6571,8 @@ "mdast-util-gfm-table/mdast-util-to-markdown": ["mdast-util-to-markdown@0.6.5", "", { "dependencies": { "@types/unist": "^2.0.0", "longest-streak": "^2.0.0", "mdast-util-to-string": "^2.0.0", "parse-entities": "^2.0.0", "repeat-string": "^1.0.0", "zwitch": "^1.0.0" } }, "sha512-XeV9sDE7ZlOQvs45C9UKMtfTcctcaj/pGwH8YLbMHoMOXNNCn2LsqVQOqrF1+/NU8lKDAqozme9SCXWyo9oAcQ=="], + "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "oxc-parser/@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], @@ -6679,6 +6827,12 @@ "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "dockerode/tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], "dockerode/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], diff --git a/core/ai-backend/src/chat/chat-service.ts b/core/ai-backend/src/chat/chat-service.ts index 60c14d322..53f81c330 100644 --- a/core/ai-backend/src/chat/chat-service.ts +++ b/core/ai-backend/src/chat/chat-service.ts @@ -201,6 +201,32 @@ function isAutomationToolName(name: string): boolean { return name.startsWith("automation.") || name.startsWith("automation_"); } +/** + * Monitoring-setup tools whose presence injects the onboarding playbook this + * turn: creating a system, proposing a health check, or managing environments. + * Deliberately NOT the catalog read tools (e.g. `catalog.listSystems`), so the + * onboarding prose stays off pure read turns. + */ +const ONBOARDING_TOOL_NAMES = [ + "healthcheck.propose", + "catalog.createSystem", + "catalog.createEnvironment", + "catalog.setSystemEnvironments", +]; + +/** + * Whether a resolved tool is a monitoring-setup tool, so the onboarding + * playbook is injected into the prompt this turn. Registered names keep dots + * (`catalog.createSystem`); the provider-safe form uses underscores + * (`catalog_createSystem`) — match either so the check is robust to where it is + * read. + */ +function isOnboardingToolName(name: string): boolean { + return ONBOARDING_TOOL_NAMES.some( + (toolName) => name === toolName || name === toolName.replaceAll(".", "_"), + ); +} + /** Per-turn dedupe key for a mutating tool call: `:`. */ function turnKey({ tool, @@ -869,6 +895,9 @@ export function createChatService({ // turns — see buildChatSystemPrompt). Tool names are provider-safe ids, so an // `automation.*` tool surfaces as `automation_*`. const automationTools = allowed.some((t) => isAutomationToolName(t.name)); + // Inject the onboarding playbook only when a monitoring-setup tool is in + // scope this turn (same gating rationale as the automation playbook). + const onboardingTools = allowed.some((t) => isOnboardingToolName(t.name)); const sdkTools = buildAgentSdkTools({ tools: allowed, principal, @@ -904,6 +933,7 @@ export function createChatService({ timeZone, mode: conversation.permissionMode, automationTools, + onboardingTools, modelFamily, staleSinceMs, }), diff --git a/core/ai-backend/src/chat/system-prompt.test.ts b/core/ai-backend/src/chat/system-prompt.test.ts index ddc9d4ca8..2e87a7c4b 100644 --- a/core/ai-backend/src/chat/system-prompt.test.ts +++ b/core/ai-backend/src/chat/system-prompt.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { ACCESS_SCOPE_INSTRUCTION, AUTOMATION_BUILDING_INSTRUCTION, + ONBOARDING_INSTRUCTION, CHAT_SYSTEM_PROMPT, DATE_FORMAT_INSTRUCTION, DOCS_GROUNDING_INSTRUCTION, @@ -119,6 +120,32 @@ describe("buildChatSystemPrompt", () => { expect(prompt).toContain("script_result.result"); }); + test("injects the onboarding playbook only when a setup tool is in scope", () => { + const withTools = buildChatSystemPrompt({ + timeZone: "Europe/Berlin", + mode: "approve", + onboardingTools: true, + }); + expect(withTools).toContain("## Setting up monitoring"); + expect(withTools).toContain(ONBOARDING_INSTRUCTION); + // Steer to HTTP, not script, for a URL. + expect(withTools).toContain("PREFER THE HTTP STRATEGY"); + expect(withTools).toContain("healthcheck-http.request"); + // Close the loop: create + assign in one step. + expect(withTools).toContain("assignToSystemId"); + // Teach environments instead of cloning systems. + expect(withTools).toContain("catalog.setSystemEnvironments"); + expect(withTools).toContain("never clone the system"); + + // Absent on a turn with no setup tool in scope (kept out of the always-on + // prompt, like the automation playbook). + const withoutTools = buildChatSystemPrompt({ + timeZone: "Europe/Berlin", + mode: "approve", + }); + expect(withoutTools).not.toContain("## Setting up monitoring"); + }); + test("a capable model family gets a calibration note; generic does not", () => { // The calibration note tells capable instruction-followers to read CAPS / // MUST / NEVER as emphasis, not as license to over-ask — gated behind the diff --git a/core/ai-backend/src/chat/system-prompt.ts b/core/ai-backend/src/chat/system-prompt.ts index ba008fac8..5d07d2698 100644 --- a/core/ai-backend/src/chat/system-prompt.ts +++ b/core/ai-backend/src/chat/system-prompt.ts @@ -138,6 +138,38 @@ export const AUTOMATION_BUILDING_INSTRUCTION = "Validate any script with automation.testScript before calling " + "automation.propose."; +/** + * Onboarding playbook, injected ONLY when a setup tool (a health-check or + * catalog create/propose tool) is in this turn's resolved tool set. It steers + * the model through the common "first system + first check" flow: prefer HTTP, + * ask before guessing, create AND assign in one step, and teach environments + * instead of cloning systems. Kept out of the always-on prompt so pure read + * turns stay lean (mirrors the automation-playbook gating above). + */ +export const ONBOARDING_INSTRUCTION = + "When the operator wants to monitor something or set up a health check, guide " + + "them through it instead of guessing. PREFER THE HTTP STRATEGY for anything " + + 'reachable over HTTP(S): for a URL, draft a check with strategyId "http" and ' + + 'the "healthcheck-http.request" collector (config { url }) asserting on ' + + "statusCode. Do NOT author a SCRIPT health check for a URL - only use a script " + + "when the operator explicitly asks for one or no built-in strategy fits. If " + + "you do not know what the endpoint returns, call probeUrl first, then assert " + + "on the real response. " + + "A health check does NOT run until it is assigned to a system, so close the " + + "loop in the SAME flow: resolve the target system's id with " + + "catalog.listSystems (create it with catalog.createSystem if it does not " + + "exist), then pass assignToSystemId to healthcheck.propose so the check is " + + "created, assigned, and started in one approval. Most systems have exactly " + + "one check. " + + "Do NOT create a separate system per environment. A system belongs to many " + + "ENVIRONMENTS (e.g. dev/staging/prod) and one assignment runs the check once " + + "per environment - use catalog.createEnvironment and " + + "catalog.setSystemEnvironments to model prod/staging, never clone the system. " + + "If a critical detail is missing, ASK before drafting - and when the answer " + + "is a discrete choice (which system, how often, which environment), use the " + + "askOperator tool to present clickable options; ask in prose only for a " + + "free-form value like the URL."; + /** * Ground concepts in the docs and never fabricate. Shared by chat and headless. * @@ -341,7 +373,11 @@ const CHAT_CLARIFY_INSTRUCTION = "If you are missing a value a tool needs and cannot obtain it from a " + "discovery/list tool or the docs, ASK the operator a specific clarifying " + "question instead of inventing one - a clarifying question is always better " + - "than a guess, especially when building an automation or proposing a change."; + "than a guess, especially when building an automation or proposing a change. " + + "When the answer is a discrete choice (one of a known set of systems, " + + "protocols, intervals, environments, ...), use the askOperator tool to offer " + + "clickable options instead of asking in prose; reserve prose questions for " + + "free-form answers like a URL."; /** * How long a conversation may sit idle before the model is told its remembered @@ -418,6 +454,7 @@ export function buildChatSystemPrompt({ now, mode, automationTools = false, + onboardingTools = false, modelFamily = "generic", staleSinceMs, }: { @@ -426,6 +463,8 @@ export function buildChatSystemPrompt({ mode: AiPermissionMode; /** Inject the automation-building playbook this turn (an automation tool is in scope). */ automationTools?: boolean; + /** Inject the onboarding playbook this turn (a health-check / catalog setup tool is in scope). */ + onboardingTools?: boolean; /** Declared model family; capable families get the lighter-touch calibration note. */ modelFamily?: AiModelFamily; /** @@ -449,6 +488,9 @@ export function buildChatSystemPrompt({ if (automationTools) { sections.push(section("Building automations", AUTOMATION_BUILDING_INSTRUCTION)); } + if (onboardingTools) { + sections.push(section("Setting up monitoring", ONBOARDING_INSTRUCTION)); + } if (isCapableFamily(modelFamily)) { // Single calibration note for capable families (no per-string fork). sections.push(section("Calibration", CAPABLE_FAMILY_CALIBRATION)); diff --git a/core/ai-backend/src/tools/ask-operator.test.ts b/core/ai-backend/src/tools/ask-operator.test.ts new file mode 100644 index 000000000..c6a77f35d --- /dev/null +++ b/core/ai-backend/src/tools/ask-operator.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import type { AuthUser, RpcClient } from "@checkstack/backend-api"; +import { createAskOperatorTool } from "./ask-operator"; + +const principal: AuthUser = { type: "user", id: "u1", accessRules: [] }; +const rpcClient = {} as unknown as RpcClient; + +describe("ai.askOperator tool", () => { + test("is a read tool gated by a chat-use access rule", () => { + const tool = createAskOperatorTool(); + expect(tool.name).toBe("askOperator"); + expect(tool.effect).toBe("read"); + expect(tool.requiredAccessRules.length).toBeGreaterThan(0); + expect(typeof tool.description).toBe("string"); + // The model must know this ends the turn. + expect(tool.description).toContain("ENDS your turn"); + }); + + test("execute returns a __question card with options normalized (value defaults to label)", async () => { + const tool = createAskOperatorTool(); + const result = await tool.execute({ + input: { + question: "Which system should this monitor?", + options: [ + { label: "Payments API" }, + { label: "Create a new system", value: "new" }, + ], + allowFreeText: true, + }, + principal, + rpcClient, + }); + + expect(result.__question).toBe(true); + expect(result.question).toBe("Which system should this monitor?"); + expect(result.options).toEqual([ + { label: "Payments API", value: "Payments API" }, + { label: "Create a new system", value: "new" }, + ]); + expect(result.allowFreeText).toBe(true); + // The model-facing stop instruction (ignored by the frontend). + expect(typeof result.note).toBe("string"); + }); +}); diff --git a/core/ai-backend/src/tools/ask-operator.ts b/core/ai-backend/src/tools/ask-operator.ts new file mode 100644 index 000000000..7d0f570ff --- /dev/null +++ b/core/ai-backend/src/tools/ask-operator.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; +import { qualifyAccessRuleId } from "@checkstack/common"; +import { + aiAccess, + pluginMetadata as aiPluginMetadata, +} from "@checkstack/ai-common"; +import type { RegisteredAiTool } from "../tool-registry"; + +/** One selectable answer to an {@link createAskOperatorTool} question. */ +export const AskOperatorOptionSchema = z.object({ + /** The chip label shown to the operator. */ + label: z.string().min(1), + /** + * The text sent back as the operator's reply when this chip is clicked. + * Defaults to the label when omitted. + */ + value: z.string().optional(), +}); + +/** Input for `ai.askOperator`: a question plus its clickable options. */ +export const AskOperatorInputSchema = z.object({ + /** The question to ask. Keep it short; it renders above the options. */ + question: z.string().min(1), + /** 1-6 discrete answers, rendered as clickable chips. */ + options: z.array(AskOperatorOptionSchema).min(1).max(6), + /** + * Whether the operator may also type a free-text answer instead of clicking a + * chip. Defaults to true. + */ + allowFreeText: z.boolean().default(true), +}); +export type AskOperatorInput = z.infer; + +/** + * The marked "question card" the tool returns. The chat frontend detects the + * `__question` marker (mirroring the confirm card's `__confirm`) and renders the + * options as clickable chips; the `note` tells the MODEL to stop and wait for + * the operator's reply (the frontend ignores it). + */ +export interface AskOperatorCard { + __question: true; + question: string; + options: Array<{ label: string; value: string }>; + allowFreeText: boolean; + note: string; +} + +/** + * `ai.askOperator` - ask the operator a question with clickable answer options + * instead of a plaintext list they must type a reply to. The model calls it with + * a question + 1-6 options; the chat renders them as chips (plus a free-text box + * when `allowFreeText`), and clicking a chip sends that answer as the operator's + * next message. + * + * `effect: "read"` - it surfaces a UI prompt and commits nothing, so it + * auto-runs. Calling it ENDS the turn: the operator's selection arrives as a new + * user message, so the model must stop after calling it (the returned `note` + * reinforces this, exactly like a confirm card ends a propose turn). Gated by + * the same chat-use rule as the other platform read tools. + */ +export function createAskOperatorTool(): RegisteredAiTool< + AskOperatorInput, + AskOperatorCard +> { + return { + name: "askOperator", + description: + "Ask the operator a question with clickable answer options instead of a plaintext list. Provide a short question and 1-6 options (each a label, plus an optional value that is sent when clicked). Use this for ANY clarifying question that has discrete choices - which system, which protocol, how often, which environment - rather than asking in prose. Calling this ENDS your turn: the operator's choice arrives as their next message, so do NOT add more text or call other tools after it. For a genuinely free-form answer (e.g. a URL), either ask in prose or set allowFreeText so they can type one alongside the chips.", + effect: "read", + input: AskOperatorInputSchema, + requiredAccessRules: [ + qualifyAccessRuleId(aiPluginMetadata, aiAccess.chatUse), + ], + async execute({ input }) { + return { + __question: true, + question: input.question, + options: input.options.map((option) => ({ + label: option.label, + value: option.value ?? option.label, + })), + allowFreeText: input.allowFreeText, + note: "These options were shown to the operator as clickable choices. Stop here and wait for their reply; do not restate the options or add more text.", + }; + }, + }; +} diff --git a/core/ai-backend/src/tools/composite-tools.ts b/core/ai-backend/src/tools/composite-tools.ts index 93dd4a1f3..f9398413e 100644 --- a/core/ai-backend/src/tools/composite-tools.ts +++ b/core/ai-backend/src/tools/composite-tools.ts @@ -1,6 +1,7 @@ import type { RegisteredAiTool } from "../tool-registry"; import { createDocsTools } from "./docs-tools"; import { createProbeUrlTool } from "./probe-url"; +import { createAskOperatorTool } from "./ask-operator"; /** * ai-backend's OWN platform AI tools, registered through `aiToolExtensionPoint`. @@ -20,5 +21,9 @@ export function buildCompositeTools(): RegisteredAiTool[] { // URL introspection (effect: "read"): probe a public URL to see what it // returns before drafting check assertions. SSRF-guarded (no internal hosts). createProbeUrlTool(), + // Operator elicitation (effect: "read"): ask a question with clickable + // answer chips instead of a plaintext list. Ends the turn; the operator's + // choice arrives as their next message. + createAskOperatorTool(), ]; } diff --git a/core/ai-backend/src/tools/tool-set.e2e.test.ts b/core/ai-backend/src/tools/tool-set.e2e.test.ts index e445054c3..52331e0f1 100644 --- a/core/ai-backend/src/tools/tool-set.e2e.test.ts +++ b/core/ai-backend/src/tools/tool-set.e2e.test.ts @@ -43,6 +43,7 @@ describe("ai-backend's own platform tool set", () => { "ai_searchDocs", "ai_getDoc", "ai_probeUrl", + "ai_askOperator", ]) { expect(names).toContain(expected); } diff --git a/core/ai-frontend/src/components/QuestionCardView.tsx b/core/ai-frontend/src/components/QuestionCardView.tsx new file mode 100644 index 000000000..1ba909a1d --- /dev/null +++ b/core/ai-frontend/src/components/QuestionCardView.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { Button, Input } from "@checkstack/ui"; +import type { QuestionCard } from "../lib/stream-parser"; + +/** + * Renders a QUESTION CARD the model posed via `askOperator`: a short question + * with clickable answer chips plus an optional free-text box. Clicking a chip + * (or submitting free text) calls `onAnswer`, which the page sends as the + * operator's next message - turning a plaintext "what should I use?" into a + * one-click reply. + */ +export function QuestionCardView({ + card, + onAnswer, + disabled, +}: { + card: QuestionCard; + /** Send the operator's chosen/typed answer as their next message. */ + onAnswer: (value: string) => void; + /** Disabled while a turn is streaming. */ + disabled?: boolean; +}) { + const [freeText, setFreeText] = useState(""); + + const submitFreeText = () => { + const trimmed = freeText.trim(); + if (!trimmed) return; + setFreeText(""); + onAnswer(trimmed); + }; + + return ( +
+

{card.question}

+
+ {card.options.map((option) => ( + + ))} +
+ {card.allowFreeText ? ( +
{ + event.preventDefault(); + submitFreeText(); + }} + > + setFreeText(event.target.value)} + placeholder="Or type an answer..." + disabled={disabled} + /> + +
+ ) : null} +
+ ); +} diff --git a/core/ai-frontend/src/lib/chat-state.test.ts b/core/ai-frontend/src/lib/chat-state.test.ts index 9778b4fdc..337053d26 100644 --- a/core/ai-frontend/src/lib/chat-state.test.ts +++ b/core/ai-frontend/src/lib/chat-state.test.ts @@ -147,6 +147,33 @@ describe("chat-state reducer (DOM-free)", () => { } }); + test("a question card replaces its askOperator tool part in place", () => { + let msgs = startAssistantMessage({ messages: [], id: "a1" }); + msgs = applyStreamEvent({ + messages: msgs, + event: { type: "tool-call", toolCallId: "q1", toolName: "ai.askOperator" }, + }); + msgs = applyStreamEvent({ + messages: msgs, + event: { + type: "question-card", + toolCallId: "q1", + card: { + question: "Which system?", + options: [{ label: "Payments API", value: "Payments API" }], + allowFreeText: true, + }, + }, + }); + expect(partsOf(msgs)).toHaveLength(1); + const part = partsOf(msgs)[0]; + expect(part.kind).toBe("question"); + if (part.kind === "question") { + expect(part.card.question).toBe("Which system?"); + expect(part.card.options).toHaveLength(1); + } + }); + test("an auto-applied result replaces its tool part with an applied card", () => { let msgs = startAssistantMessage({ messages: [], id: "a1" }); msgs = applyStreamEvent({ diff --git a/core/ai-frontend/src/lib/chat-state.ts b/core/ai-frontend/src/lib/chat-state.ts index 035b31fe6..c1cd983b8 100644 --- a/core/ai-frontend/src/lib/chat-state.ts +++ b/core/ai-frontend/src/lib/chat-state.ts @@ -1,4 +1,9 @@ -import type { ChatStreamEvent, ConfirmCard, AppliedCard } from "./stream-parser"; +import type { + ChatStreamEvent, + ConfirmCard, + AppliedCard, + QuestionCard, +} from "./stream-parser"; /** * One ordered piece of an assistant turn. The model interleaves prose and tool @@ -19,7 +24,8 @@ export type AssistantPart = errorText?: string; } | { kind: "confirm"; toolCallId: string; card: ConfirmCard } - | { kind: "applied"; toolCallId: string; card: AppliedCard }; + | { kind: "applied"; toolCallId: string; card: AppliedCard } + | { kind: "question"; toolCallId: string; card: QuestionCard }; /** A rendered chat message in the UI. */ export interface ChatMessage { @@ -216,6 +222,14 @@ export function applyStreamEvent({ }); break; } + case "question-card": { + parts = toCard(parts, event.toolCallId, { + kind: "question", + toolCallId: event.toolCallId, + card: event.card, + }); + break; + } case "error": { const prefix = parts.length > 0 ? "\n\n" : ""; parts = appendText(parts, `${prefix}Error: ${event.message}`); diff --git a/core/ai-frontend/src/lib/stream-parser.test.ts b/core/ai-frontend/src/lib/stream-parser.test.ts index 55433a230..f4661a180 100644 --- a/core/ai-frontend/src/lib/stream-parser.test.ts +++ b/core/ai-frontend/src/lib/stream-parser.test.ts @@ -2,12 +2,69 @@ import { describe, expect, test } from "bun:test"; import { asAppliedCard, asConfirmCard, + asQuestionCard, chunkToEvent, parseSseBuffer, readChatStream, type ChatStreamEvent, } from "./stream-parser"; +describe("asQuestionCard", () => { + test("narrows an askOperator card and keeps option label/value", () => { + const card = asQuestionCard({ + __question: true, + question: "Which system?", + options: [ + { label: "Payments API", value: "Payments API" }, + { label: "New system", value: "new" }, + ], + allowFreeText: true, + note: "stop and wait", + }); + expect(card).toEqual({ + question: "Which system?", + options: [ + { label: "Payments API", value: "Payments API" }, + { label: "New system", value: "new" }, + ], + allowFreeText: true, + }); + }); + + test("rejects non-question / malformed values", () => { + expect(asQuestionCard({ question: "x", options: [] })).toBeUndefined(); + expect( + asQuestionCard({ __question: true, question: "x", options: [] }), + ).toBeUndefined(); + expect( + asQuestionCard({ __question: true, question: 1, options: [] }), + ).toBeUndefined(); + expect(asQuestionCard(null)).toBeUndefined(); + }); + + test("chunkToEvent maps an askOperator output to a question-card event", () => { + const event = chunkToEvent({ + type: "tool-output-available", + toolCallId: "t1", + output: { + __question: true, + question: "How often?", + options: [{ label: "60s", value: "60s" }], + allowFreeText: false, + }, + }); + expect(event).toEqual({ + type: "question-card", + toolCallId: "t1", + card: { + question: "How often?", + options: [{ label: "60s", value: "60s" }], + allowFreeText: false, + }, + }); + }); +}); + describe("asConfirmCard", () => { test("recognises a well-formed confirm card", () => { const card = asConfirmCard({ diff --git a/core/ai-frontend/src/lib/stream-parser.ts b/core/ai-frontend/src/lib/stream-parser.ts index 4f4250b5e..9d40d2346 100644 --- a/core/ai-frontend/src/lib/stream-parser.ts +++ b/core/ai-frontend/src/lib/stream-parser.ts @@ -42,6 +42,25 @@ export interface AppliedCard { result: unknown; } +/** One clickable answer to a {@link QuestionCard}. */ +export interface QuestionOption { + label: string; + /** Sent back as the operator's reply when this option is clicked. */ + value: string; +} + +/** + * A question card emitted when the model asked the operator a discrete-choice + * question via the `askOperator` tool. The UI renders the options as clickable + * chips; clicking one sends its `value` as the operator's next message. + */ +export interface QuestionCard { + question: string; + options: QuestionOption[]; + /** Whether the operator may also type a free-text answer. */ + allowFreeText: boolean; +} + /** * Incremental events surfaced to the chat UI. Tool events carry the SDK's * `toolCallId` so the reducer can correlate a tool call with its later result / @@ -54,6 +73,7 @@ export type ChatStreamEvent = | { type: "tool-error"; toolCallId: string; toolName?: string; message: string } | { type: "confirm-card"; toolCallId: string; card: ConfirmCard } | { type: "applied-card"; toolCallId: string; card: AppliedCard } + | { type: "question-card"; toolCallId: string; card: QuestionCard } // A new agent step (model round) began. The SDK emits one `start-step` per // round; we surface it so the UI can show a live, server-driven progress // heartbeat ("Working... step N") in the gaps between tool calls, which is how @@ -115,6 +135,30 @@ export function asAppliedCard(value: unknown): AppliedCard | undefined { return { toolName, summary, result, diff: asFieldDiff(value.diff) }; } +/** Narrow a parsed JSON object into a QuestionCard (askOperator output). */ +export function asQuestionCard(value: unknown): QuestionCard | undefined { + if (!isRecord(value)) return undefined; + if (value.__question !== true) return undefined; + if (typeof value.question !== "string") return undefined; + if (!Array.isArray(value.options)) return undefined; + const options: QuestionOption[] = []; + for (const entry of value.options) { + if ( + isRecord(entry) && + typeof entry.label === "string" && + typeof entry.value === "string" + ) { + options.push({ label: entry.label, value: entry.value }); + } + } + if (options.length === 0) return undefined; + return { + question: value.question, + options, + allowFreeText: value.allowFreeText === true, + }; +} + /** Read a chunk's `toolCallId` (empty string when absent — defensive). */ function asToolCallId(chunk: Record): string { return typeof chunk.toolCallId === "string" ? chunk.toolCallId : ""; @@ -198,6 +242,14 @@ export function chunkToEvent(chunk: unknown): ChatStreamEvent | undefined { if (applied) { return { type: "applied-card", toolCallId: asToolCallId(chunk), card: applied }; } + const question = asQuestionCard(output); + if (question) { + return { + type: "question-card", + toolCallId: asToolCallId(chunk), + card: question, + }; + } return { type: "tool-result", toolCallId: asToolCallId(chunk) }; } // A tool's `execute` threw (e.g. an authz/read failure inside the tool). diff --git a/core/ai-frontend/src/pages/ChatPage.tsx b/core/ai-frontend/src/pages/ChatPage.tsx index 00bc8229c..bcb9070aa 100644 --- a/core/ai-frontend/src/pages/ChatPage.tsx +++ b/core/ai-frontend/src/pages/ChatPage.tsx @@ -50,6 +50,7 @@ import { import { useChatTurn } from "../lib/use-chat-turn"; import { ConfirmCardView } from "../components/ConfirmCardView"; import { AppliedCardView } from "../components/AppliedCardView"; +import { QuestionCardView } from "../components/QuestionCardView"; import { buildModelOptions } from "../lib/model-options.logic"; import { decideNewChatAction } from "../lib/new-chat.logic"; import { @@ -130,12 +131,18 @@ function ToolStatusLine({ function MessageRow({ message, onDecision, + onAnswer, + busy, }: { message: ChatMessage; onDecision: (decision: { token: string; decision: "apply" | "decline"; }) => void; + /** Send the operator's answer to an `askOperator` question card. */ + onAnswer: (value: string) => void; + /** A turn is streaming, so question-card chips are disabled. */ + busy: boolean; }) { const { isLowPower } = usePerformance(); if (message.role === "user") { @@ -209,6 +216,16 @@ function MessageRow({ /> ); } + if (part.kind === "question") { + return ( + + ); + } return ( { + const text = answer.trim(); + if (!text || !connectionId || !conversationId || streaming) return; + void send({ conversationId, connectionId, model, text, skillId }).then(() => + queryClient.invalidateQueries({ + queryKey: [[pluginMetadata.pluginId]], + }), + ); + }; + // After the operator applies/declines a confirm card, stream the model's // acknowledgment so the conversation continues instead of dead-ending on // "waiting for your confirmation". The apply itself already ran via applyTool @@ -671,6 +703,8 @@ export function ChatPage() { key={m.id} message={m} onDecision={handleDecision} + onAnswer={onAnswer} + busy={streaming} /> )) )} diff --git a/core/catalog-backend/package.json b/core/catalog-backend/package.json index d11202964..f6df7ce82 100644 --- a/core/catalog-backend/package.json +++ b/core/catalog-backend/package.json @@ -28,6 +28,7 @@ "@checkstack/gitops-backend": "workspace:*", "@checkstack/gitops-common": "workspace:*", "@checkstack/notification-common": "workspace:*", + "@checkstack/signal-common": "workspace:*", "@orpc/contract": "^1.14.4", "@orpc/server": "^1.14.4", "drizzle-orm": "^0.45.0", diff --git a/core/catalog-backend/src/ai/catalog-create-environment.test.ts b/core/catalog-backend/src/ai/catalog-create-environment.test.ts new file mode 100644 index 000000000..50278faf9 --- /dev/null +++ b/core/catalog-backend/src/ai/catalog-create-environment.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test, mock } from "bun:test"; +import type { AuthUser, RpcClient } from "@checkstack/backend-api"; +import { createCatalogCreateEnvironmentTool } from "./catalog-create-environment"; + +const principal: AuthUser = { + type: "user", + id: "u1", + accessRules: ["catalog.environment.manage"], +}; + +const environment = { + id: "env1", + name: "production", + description: "Prod", + systemIds: [], + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +function fakeRpcClient({ + createEnvironment, +}: { + createEnvironment: ReturnType; +}): RpcClient { + return { + forPlugin: () => ({ createEnvironment }), + } as unknown as RpcClient; +} + +describe("catalog.createEnvironment tool", () => { + test("declares mutate effect + the environment manage rule", () => { + const tool = createCatalogCreateEnvironmentTool(); + expect(tool.name).toBe("catalog.createEnvironment"); + expect(tool.effect).toBe("mutate"); + expect(tool.requiredAccessRules).toEqual(["catalog.environment.manage"]); + expect(typeof tool.dryRun).toBe("function"); + }); + + test("dryRun summarizes the create and NEVER creates", async () => { + const createEnvironment = mock(() => Promise.resolve(environment)); + const rpcClient = fakeRpcClient({ createEnvironment }); + const tool = createCatalogCreateEnvironmentTool(); + const preview = await tool.dryRun!({ + input: { name: "production" }, + principal, + rpcClient, + }); + expect(createEnvironment).not.toHaveBeenCalled(); + expect(preview.summary).toContain("production"); + expect(preview.payload).toEqual({ name: "production" }); + }); + + test("execute (apply) creates via createEnvironment", async () => { + const createEnvironment = mock(() => Promise.resolve(environment)); + const rpcClient = fakeRpcClient({ createEnvironment }); + const tool = createCatalogCreateEnvironmentTool(); + const input = { name: "production", description: "Prod" }; + const result = await tool.execute({ input, principal, rpcClient }); + expect(createEnvironment).toHaveBeenCalledWith(input); + expect(result).toEqual({ environment }); + }); +}); diff --git a/core/catalog-backend/src/ai/catalog-create-environment.ts b/core/catalog-backend/src/ai/catalog-create-environment.ts new file mode 100644 index 000000000..cd206d575 --- /dev/null +++ b/core/catalog-backend/src/ai/catalog-create-environment.ts @@ -0,0 +1,67 @@ +import { qualifyAccessRuleId } from "@checkstack/common"; +import type { RpcClient, AuthUser } from "@checkstack/backend-api"; +import { + CatalogApi, + catalogAccess, + pluginMetadata, + CreateEnvironmentSchema, + type CreateEnvironment, + type Environment, +} from "@checkstack/catalog-common"; +import type { AiProposalPreview } from "@checkstack/ai-common"; +import type { RegisteredAiTool } from "@checkstack/ai-backend"; + +/** Output returned once a human applies the create (the created environment). */ +export interface CatalogCreateEnvironmentApplyResult { + environment: Environment; +} + +/** + * `catalog.createEnvironment` - create an instance-wide environment (e.g. + * `production`, `staging`) that systems can be attached to. Environments are the + * RIGHT way to model the same system across deployment stages: a system belongs + * to many environments and one health-check assignment runs once per + * environment, so never clone a system per environment. + * + * `effect: "mutate"` - a non-destructive create, so it auto-applies in AUTO mode + * and is confirm-gated in APPROVE mode via the propose/apply machinery. The + * underlying RPC uses the USER-SCOPED client passed at call time, so the + * `environment.manage` rule is enforced exactly as a direct UI/RPC call, + * re-checked at propose AND apply time by the propose/apply service. + */ +export function createCatalogCreateEnvironmentTool(): RegisteredAiTool< + CreateEnvironment, + CatalogCreateEnvironmentApplyResult +> { + const dryRun = async ({ + input, + }: { + input: CreateEnvironment; + principal: AuthUser; + rpcClient: RpcClient; + }): Promise> => { + return { + summary: `Create environment "${input.name}"${ + input.description ? ` (${input.description})` : "" + }.`, + payload: input, + }; + }; + + return { + name: "catalog.createEnvironment", + description: + "Create an instance-wide environment (for example production, staging, or dev) that systems can be attached to. Use environments to monitor the same system across deployment stages: a system belongs to many environments and one health-check assignment runs once per environment - do NOT create a separate system per environment. Never creates directly; a person must approve unless the conversation is in auto mode.", + effect: "mutate", + input: CreateEnvironmentSchema, + requiredAccessRules: [ + qualifyAccessRuleId(pluginMetadata, catalogAccess.environment.manage), + ], + dryRun, + async execute({ input, rpcClient }) { + const catalogClient = rpcClient.forPlugin(CatalogApi); + const environment = await catalogClient.createEnvironment(input); + return { environment }; + }, + }; +} diff --git a/core/catalog-backend/src/ai/catalog-create-system.ts b/core/catalog-backend/src/ai/catalog-create-system.ts index 0b2f5e27d..04f459c66 100644 --- a/core/catalog-backend/src/ai/catalog-create-system.ts +++ b/core/catalog-backend/src/ai/catalog-create-system.ts @@ -62,7 +62,7 @@ export function createCatalogCreateSystemTool(): RegisteredAiTool< return { name: "catalog.createSystem", description: - "Create a new system (a service or resource) in the catalog with a name and optional description and metadata. Never creates directly; a person must approve unless the conversation is in auto mode.", + "Create a new system (a service or resource) in the catalog with a name and optional description and metadata. A system usually pairs 1-1 with a single health check (create and assign it with healthcheck.propose + assignToSystemId). To monitor the same system across deployment stages, attach ENVIRONMENTS (catalog.createEnvironment + catalog.setSystemEnvironments) - do NOT create a separate system per environment. Never creates directly; a person must approve unless the conversation is in auto mode.", effect: "mutate", input: CatalogCreateSystemInputSchema, requiredAccessRules: [ diff --git a/core/catalog-backend/src/ai/catalog-set-system-environments.test.ts b/core/catalog-backend/src/ai/catalog-set-system-environments.test.ts new file mode 100644 index 000000000..fbfdd6ee5 --- /dev/null +++ b/core/catalog-backend/src/ai/catalog-set-system-environments.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test, mock } from "bun:test"; +import type { AuthUser, RpcClient } from "@checkstack/backend-api"; +import { createCatalogSetSystemEnvironmentsTool } from "./catalog-set-system-environments"; + +const principal: AuthUser = { + type: "user", + id: "u1", + accessRules: ["catalog.environment.manage", "catalog.system.manage"], +}; + +function fakeRpcClient({ + setSystemEnvironments, +}: { + setSystemEnvironments: ReturnType; +}): RpcClient { + return { + forPlugin: () => ({ setSystemEnvironments }), + } as unknown as RpcClient; +} + +describe("catalog.setSystemEnvironments tool", () => { + test("declares mutate effect + the environment manage rule", () => { + const tool = createCatalogSetSystemEnvironmentsTool(); + expect(tool.name).toBe("catalog.setSystemEnvironments"); + expect(tool.effect).toBe("mutate"); + expect(tool.requiredAccessRules).toEqual(["catalog.environment.manage"]); + expect(typeof tool.dryRun).toBe("function"); + }); + + test("dryRun summarizes the desired set and NEVER mutates", async () => { + const setSystemEnvironments = mock(() => + Promise.resolve({ success: true }), + ); + const rpcClient = fakeRpcClient({ setSystemEnvironments }); + const tool = createCatalogSetSystemEnvironmentsTool(); + const preview = await tool.dryRun!({ + input: { systemId: "sys1", environmentIds: ["e1", "e2"] }, + principal, + rpcClient, + }); + expect(setSystemEnvironments).not.toHaveBeenCalled(); + expect(preview.summary).toContain("2 environments"); + expect(preview.payload).toEqual({ + systemId: "sys1", + environmentIds: ["e1", "e2"], + }); + }); + + test("execute (apply) sets the membership via setSystemEnvironments", async () => { + const setSystemEnvironments = mock(() => + Promise.resolve({ success: true }), + ); + const rpcClient = fakeRpcClient({ setSystemEnvironments }); + const tool = createCatalogSetSystemEnvironmentsTool(); + const input = { systemId: "sys1", environmentIds: ["e1"] }; + const result = await tool.execute({ input, principal, rpcClient }); + expect(setSystemEnvironments).toHaveBeenCalledWith(input); + expect(result).toEqual({ success: true }); + }); +}); diff --git a/core/catalog-backend/src/ai/catalog-set-system-environments.ts b/core/catalog-backend/src/ai/catalog-set-system-environments.ts new file mode 100644 index 000000000..b6a40c19a --- /dev/null +++ b/core/catalog-backend/src/ai/catalog-set-system-environments.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; +import { qualifyAccessRuleId } from "@checkstack/common"; +import type { RpcClient, AuthUser } from "@checkstack/backend-api"; +import { + CatalogApi, + catalogAccess, + pluginMetadata, +} from "@checkstack/catalog-common"; +import type { AiProposalPreview } from "@checkstack/ai-common"; +import type { RegisteredAiTool } from "@checkstack/ai-backend"; + +/** + * Input for `catalog.setSystemEnvironments`: the DESIRED set of environments a + * system belongs to. Mirrors the contract's `setSystemEnvironments` input (a + * desired-set diff: adds missing links, prunes stale ones). + */ +export const CatalogSetSystemEnvironmentsInputSchema = z.object({ + systemId: z.string(), + environmentIds: z.array(z.string()), +}); +export type CatalogSetSystemEnvironmentsInput = z.infer< + typeof CatalogSetSystemEnvironmentsInputSchema +>; + +/** Output returned once a human applies the change. */ +export interface CatalogSetSystemEnvironmentsApplyResult { + success: boolean; +} + +/** + * `catalog.setSystemEnvironments` - set which environments a system belongs to + * (desired-set: adds missing links, prunes stale ones). This is how a system is + * placed into production/staging/dev so its single health-check assignment fans + * out one run per environment. + * + * `effect: "mutate"`. Authorization is scoped on the TARGET SYSTEM + * (`catalog.system` manage) plus the `environment.manage` feature gate, enforced + * via the USER-SCOPED client and re-checked at propose AND apply time. + */ +export function createCatalogSetSystemEnvironmentsTool(): RegisteredAiTool< + CatalogSetSystemEnvironmentsInput, + CatalogSetSystemEnvironmentsApplyResult +> { + const dryRun = async ({ + input, + }: { + input: CatalogSetSystemEnvironmentsInput; + principal: AuthUser; + rpcClient: RpcClient; + }): Promise> => { + const count = input.environmentIds.length; + return { + summary: `Set this system to belong to ${count} environment${ + count === 1 ? "" : "s" + } (replaces the current set).`, + payload: input, + }; + }; + + return { + name: "catalog.setSystemEnvironments", + description: + "Set the exact set of environments a system belongs to (adds missing links and removes ones not listed). Use this to put a system into production/staging/dev so its health-check assignment runs once per environment. Resolve environment ids with catalog.listEnvironments (create them with catalog.createEnvironment first). Never changes directly; a person must approve unless the conversation is in auto mode.", + effect: "mutate", + input: CatalogSetSystemEnvironmentsInputSchema, + requiredAccessRules: [ + qualifyAccessRuleId(pluginMetadata, catalogAccess.environment.manage), + ], + dryRun, + async execute({ input, rpcClient }) { + const catalogClient = rpcClient.forPlugin(CatalogApi); + const result = await catalogClient.setSystemEnvironments(input); + return { success: result.success }; + }, + }; +} diff --git a/core/catalog-backend/src/ai/register-ai-tools.ts b/core/catalog-backend/src/ai/register-ai-tools.ts index f4e0e2508..8f95defa2 100644 --- a/core/catalog-backend/src/ai/register-ai-tools.ts +++ b/core/catalog-backend/src/ai/register-ai-tools.ts @@ -7,6 +7,8 @@ import { createCatalogUpdateGroupTool } from "./catalog-update-group"; import { createCatalogDeleteGroupTool } from "./catalog-delete-group"; import { createCatalogAddSystemToGroupTool } from "./catalog-add-system-to-group"; import { createCatalogRemoveSystemFromGroupTool } from "./catalog-remove-system-from-group"; +import { createCatalogCreateEnvironmentTool } from "./catalog-create-environment"; +import { createCatalogSetSystemEnvironmentsTool } from "./catalog-set-system-environments"; /** * The catalog plugin's AI tools, registered into the AI registry via @@ -31,5 +33,7 @@ export function buildCatalogAiTools(): RegisteredAiTool[] { createCatalogDeleteGroupTool(), createCatalogAddSystemToGroupTool(), createCatalogRemoveSystemFromGroupTool(), + createCatalogCreateEnvironmentTool(), + createCatalogSetSystemEnvironmentsTool(), ]; } diff --git a/core/catalog-backend/src/index.ts b/core/catalog-backend/src/index.ts index 8848f9ef4..968521112 100644 --- a/core/catalog-backend/src/index.ts +++ b/core/catalog-backend/src/index.ts @@ -407,6 +407,7 @@ export default createBackendPlugin({ logger: coreServices.logger, cacheManager: coreServices.cacheManager, resourceResolverRegistry: coreServices.resourceResolverRegistry, + signalService: coreServices.signalService, }, // Phase 2: Register router only - no RPC calls to other plugins init: async ({ @@ -416,6 +417,7 @@ export default createBackendPlugin({ logger, cacheManager, resourceResolverRegistry, + signalService, }) => { logger.debug("Initializing Catalog Backend..."); @@ -468,6 +470,7 @@ export default createBackendPlugin({ pluginId: pluginMetadata.pluginId, cache, logger, + signalService, getSystemEntity: () => systemEntity, getGroupEntity: () => groupEntity, }); @@ -512,6 +515,16 @@ export default createBackendPlugin({ effect: "read", execute: deferredProjectionExecute, }); + aiProjectionExt.expose({ + procedure: catalogContract.listEnvironments, + sourcePluginMetadata: pluginMetadata, + procedureKey: "listEnvironments", + name: "catalog.listEnvironments", + description: + "List all environments (e.g. production, staging) with their ids and names. Read-only. Use this to resolve an environment name to its id before calling catalog.setSystemEnvironments.", + effect: "read", + execute: deferredProjectionExecute, + }); // Register catalog systems as searchable in the command palette registerSearchProvider({ diff --git a/core/catalog-backend/src/router.ts b/core/catalog-backend/src/router.ts index 36e21657a..5aefd2f6a 100644 --- a/core/catalog-backend/src/router.ts +++ b/core/catalog-backend/src/router.ts @@ -4,8 +4,10 @@ import { catalogContract, catalogSystemTarget, catalogGroupTarget, + CATALOG_CHANGED, type SystemContact, } from "@checkstack/catalog-common"; +import { type SignalService } from "@checkstack/signal-common"; import { EntityService } from "./services/entity-service"; import { diffSystemEnvironments } from "./services/environment-membership"; import { isUniqueViolation } from "./services/pg-errors"; @@ -47,6 +49,13 @@ export interface CatalogRouterDeps { pluginId: string; cache: CatalogCache; logger: Logger; + /** + * Broadcasts the `catalog.changed` realtime signal so EVERY client's + * `[[catalog]]` react-query cache auto-invalidates after a mutation - the only + * way an out-of-band write (AI assistant, GitOps, another pod/user) reaches an + * already-open catalog/system page. Optional so existing tests can omit it. + */ + signalService?: SignalService; /** Resolvers for the reactive catalog entities (§10.4). Undefined in tests. */ getSystemEntity?: () => EntityHandle | undefined; getGroupEntity?: () => EntityHandle | undefined; @@ -60,11 +69,31 @@ export const createCatalogRouter = ({ pluginId: _pluginId, cache, logger, + signalService, getSystemEntity, getGroupEntity, }: CatalogRouterDeps) => { const entityService = new EntityService(database); + /** + * Fire the `catalog.changed` signal so every client's `[[catalog]]` cache + * refreshes after this write. Best-effort: a signal failure must never fail + * the mutation (the data is already committed), so swallow + log. + */ + const broadcastChanged = async (payload: { + entity: "system" | "group" | "environment" | "membership"; + action: "created" | "updated" | "deleted"; + id?: string; + }) => { + try { + await signalService?.broadcast(CATALOG_CHANGED, payload); + } catch (error) { + logger.warn( + `Failed to broadcast catalog.changed signal: ${extractErrorMessage(error, "unknown")}`, + ); + } + }; + const enforceNotGitOpsLocked = async (kind: string, entityId: string) => { const provenance = await gitOpsClient.getProvenance({ kind, @@ -265,6 +294,11 @@ export const createCatalogRouter = ({ await refreshSystemParents(result.id); await cache.invalidateTopology(); + await broadcastChanged({ + entity: "system", + action: "created", + id: result.id, + }); return result; }); @@ -351,6 +385,11 @@ export const createCatalogRouter = ({ if (input.data.name !== undefined) { await upsertSystemResource({ id: result.id, name: result.name }); } + await broadcastChanged({ + entity: "system", + action: "updated", + id: result.id, + }); return result; }); @@ -381,6 +420,11 @@ export const createCatalogRouter = ({ cache.invalidateTopology(), cache.invalidateContacts(systemId), ]); + await broadcastChanged({ + entity: "system", + action: "deleted", + id: systemId, + }); return { success: true }; }); @@ -411,6 +455,11 @@ export const createCatalogRouter = ({ await upsertGroupResource({ id: result.id, name: result.name }); await cache.invalidateTopology(); + await broadcastChanged({ + entity: "group", + action: "created", + id: result.id, + }); // New groups have no systems yet return { @@ -472,6 +521,11 @@ export const createCatalogRouter = ({ if (input.data.name !== undefined) { await upsertGroupResource({ id: fullGroup.id, name: fullGroup.name }); } + await broadcastChanged({ + entity: "group", + action: "updated", + id: fullGroup.id, + }); return fullGroup; }); @@ -493,6 +547,7 @@ export const createCatalogRouter = ({ await removeGroupResource(input); await cache.invalidateTopology(); + await broadcastChanged({ entity: "group", action: "deleted", id: input }); return { success: true }; }); @@ -504,6 +559,11 @@ export const createCatalogRouter = ({ // Push refreshed parent edges so notification-backend's dispatcher // walks the new membership when computing inherited subscribers. await refreshSystemParents(input.systemId); + await broadcastChanged({ + entity: "membership", + action: "updated", + id: input.systemId, + }); return { success: true }; }); @@ -513,6 +573,11 @@ export const createCatalogRouter = ({ await entityService.removeSystemFromGroup(input); await cache.invalidateTopology(); await refreshSystemParents(input.systemId); + await broadcastChanged({ + entity: "membership", + action: "updated", + id: input.systemId, + }); return { success: true }; }, ); @@ -702,6 +767,11 @@ export const createCatalogRouter = ({ const createEnvironment = os.createEnvironment.handler(async ({ input }) => { const created = await entityService.createEnvironment(input); + await broadcastChanged({ + entity: "environment", + action: "created", + id: created.id, + }); // New environments have no systems yet. return { ...created, systemIds: [] }; }); @@ -731,12 +801,22 @@ export const createCatalogRouter = ({ message: "Environment not found after update", }); } + await broadcastChanged({ + entity: "environment", + action: "updated", + id: input.environmentId, + }); return updated; }); const deleteEnvironment = os.deleteEnvironment.handler(async ({ input }) => { await enforceNotGitOpsLocked("Environment", input.environmentId); await entityService.deleteEnvironment(input.environmentId); + await broadcastChanged({ + entity: "environment", + action: "deleted", + id: input.environmentId, + }); return { success: true }; }); @@ -762,6 +842,11 @@ export const createCatalogRouter = ({ systemId: input.systemId, }); } + await broadcastChanged({ + entity: "membership", + action: "updated", + id: input.systemId, + }); return { success: true }; }, ); diff --git a/core/catalog-backend/tsconfig.json b/core/catalog-backend/tsconfig.json index dd56435ba..03a4b0c72 100644 --- a/core/catalog-backend/tsconfig.json +++ b/core/catalog-backend/tsconfig.json @@ -49,6 +49,9 @@ { "path": "../notification-common" }, + { + "path": "../signal-common" + }, { "path": "../test-utils-backend" } diff --git a/core/catalog-common/package.json b/core/catalog-common/package.json index 1308c6b26..85a879b3f 100644 --- a/core/catalog-common/package.json +++ b/core/catalog-common/package.json @@ -16,6 +16,7 @@ "@checkstack/auth-common": "workspace:*", "@checkstack/frontend-api": "workspace:*", "@checkstack/notification-common": "workspace:*", + "@checkstack/signal-common": "workspace:*", "@orpc/contract": "^1.14.4", "zod": "^4.2.1" }, diff --git a/core/catalog-common/src/index.ts b/core/catalog-common/src/index.ts index dbc606e1e..604678f3b 100644 --- a/core/catalog-common/src/index.ts +++ b/core/catalog-common/src/index.ts @@ -4,5 +4,6 @@ export * from "./types"; export * from "./slots"; export * from "./plugin-metadata"; export * from "./notifications"; +export * from "./signals"; export { catalogRoutes } from "./routes"; export { assertCatalogResourcesReadable } from "./access-check"; diff --git a/core/catalog-common/src/signals.test.ts b/core/catalog-common/src/signals.test.ts new file mode 100644 index 000000000..8410477a3 --- /dev/null +++ b/core/catalog-common/src/signals.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { CATALOG_CHANGED } from "./signals"; + +describe("CATALOG_CHANGED signal", () => { + test("is keyed to the catalog plugin so the auto-invalidator refreshes [[catalog]]", () => { + // The frontend SignalAutoInvalidator routes a signal to + // queryClient.invalidateQueries({ queryKey: [[pluginId]] }); this id is the + // entire reason the catalog page refreshes after an out-of-band write. + expect(CATALOG_CHANGED.pluginId).toBe("catalog"); + expect(CATALOG_CHANGED.id).toBe("catalog.changed"); + }); + + test("payload schema accepts the entity/action descriptor", () => { + const parsed = CATALOG_CHANGED.payloadSchema.parse({ + entity: "system", + action: "created", + id: "sys-1", + }); + expect(parsed).toEqual({ + entity: "system", + action: "created", + id: "sys-1", + }); + // id is optional (e.g. a bulk membership change). + expect( + CATALOG_CHANGED.payloadSchema.parse({ + entity: "membership", + action: "updated", + }), + ).toEqual({ entity: "membership", action: "updated" }); + }); +}); diff --git a/core/catalog-common/src/signals.ts b/core/catalog-common/src/signals.ts new file mode 100644 index 000000000..5d25757cd --- /dev/null +++ b/core/catalog-common/src/signals.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; +import { createSignal } from "@checkstack/signal-common"; +import { pluginMetadata } from "./plugin-metadata"; + +/** + * Broadcast whenever a catalog entity (system, group, environment, or a + * system's memberships) is created, updated, or deleted - by ANY path: the UI, + * the AI assistant (which mutates on the backend, so no frontend mutation + * runs), GitOps reconcile, or another pod/user. The frontend signal + * auto-invalidator refreshes every client's `[[catalog]]` react-query cache + * from it, so an open catalog/system page never serves a stale list after an + * out-of-band change. + * + * Catalog reads are coarse (one `getSystems` list backs the catalog page AND + * the system detail page, which resolves a system by finding it in that list), + * so a single coarse "changed" signal is the right granularity: the + * auto-invalidator keys off `pluginId`, not the payload. The payload is + * descriptive only (debugging / future targeted consumers). + */ +export const CATALOG_CHANGED = createSignal({ + pluginMetadata, + event: "changed", + payloadSchema: z.object({ + /** Which kind of catalog entity changed. */ + entity: z.enum(["system", "group", "environment", "membership"]), + /** What happened to it. */ + action: z.enum(["created", "updated", "deleted"]), + /** The affected entity id, when applicable. */ + id: z.string().optional(), + }), +}); diff --git a/core/catalog-common/tsconfig.json b/core/catalog-common/tsconfig.json index 87280dfeb..8aa2961d1 100644 --- a/core/catalog-common/tsconfig.json +++ b/core/catalog-common/tsconfig.json @@ -15,6 +15,9 @@ }, { "path": "../notification-common" + }, + { + "path": "../signal-common" } ] } diff --git a/core/catalog-frontend/src/components/SystemEditor.tsx b/core/catalog-frontend/src/components/SystemEditor.tsx index 9c060ee48..9162edc00 100644 --- a/core/catalog-frontend/src/components/SystemEditor.tsx +++ b/core/catalog-frontend/src/components/SystemEditor.tsx @@ -9,9 +9,15 @@ import { DialogHeader, DialogTitle, DialogFooter, + Alert, + AlertContent, + AlertDescription, + AlertIcon, + AlertTitle, useToast, toastError, } from "@checkstack/ui"; +import { Layers } from "lucide-react"; import { TeamAccessEditor, TeamOwnershipPicker, @@ -124,6 +130,26 @@ export const SystemEditor: React.FC = ({ /> + {/* Modelling hint - only when creating a new system. Steers new + users away from "one system per environment", which is the most + common onboarding mistake. */} + {!initialData?.id && ( + + + + + + One system, many environments + + A system usually pairs with a single health check. To monitor + it across dev, staging, and prod, do not create a system per + stage - create it once, then attach Environments after saving + and one check runs once per environment. + + + + )} + {/* Owning team picker - only shown when creating a new system */} {!initialData?.id && (
diff --git a/core/common/src/docs-links.ts b/core/common/src/docs-links.ts index d6bc19341..0f67dabdb 100644 --- a/core/common/src/docs-links.ts +++ b/core/common/src/docs-links.ts @@ -13,6 +13,7 @@ export const APP_DOC_SLUGS = { teamsAndAccess: "user-guide/concepts/teams-and-access", apiKeys: "user-guide/reference/api-keys", systemsAndGroups: "user-guide/concepts/systems-and-groups", + environments: "user-guide/concepts/environments", healthChecks: "user-guide/concepts/health-checks", slo: "user-guide/concepts/slo", incidents: "user-guide/concepts/incidents", diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 374425c78..a4f2b43e8 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -299,8 +299,12 @@ function AppShellLayout() { - -

Checkstack

+ Checkstack + {/* The wordmark is dropped on small screens to de-clutter the + navbar; the logo still anchors the home link. */} +

+ Checkstack +