From eeb3386dfaf823556ebec5a4a14311075e7a9a08 Mon Sep 17 00:00:00 2001 From: Anshul Kushwaha <137977998+sudo-anshul@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:45:51 +0530 Subject: [PATCH 1/2] feat: prepare GoSignal for Render deployment --- .env.example | 2 + .github/workflows/azure-app-service.yml | 90 ---- .github/workflows/render-deploy.yml | 74 ++++ .node-version | 1 + ARCHITECTURE.md | 138 ++++++ CONTRIBUTING.md | 42 ++ DATA_RETENTION.md | 99 +++++ DEMO_RUNBOOK.md | 122 ++++++ LICENSE | 21 + MARKETPLACE.md | 123 ++++++ PRIVACY.md | 97 +++++ PROOF_CAPTURE.md | 106 +++++ README.md | 297 ++++++++----- RENDER_SETUP.md | 106 +++++ SECURITY.md | 97 +++++ SUBMISSION_CHECKLIST.md | 65 +++ package.json | 3 + render.yaml | 28 ++ src/app.ts | 47 +- src/config.ts | 2 + src/domain/constants.ts | 151 ++++++- src/domain/readiness.ts | 229 ++++++++-- src/domain/textSignals.ts | 4 +- src/domain/types.ts | 84 +++- src/handlers/registerHandlers.ts | 432 +++++++++++++++++- src/http/customRoutes.ts | 60 +++ src/repositories/schema.sql | 20 + src/repositories/workspaceAdminRepository.ts | 194 +++++++++ src/scripts/checkLlm.ts | 31 ++ src/scripts/smoke.ts | 141 ++++++ src/scripts/verifyHosted.ts | 102 +++++ src/services/homeService.ts | 27 +- src/services/launchService.ts | 260 +++++++++-- src/services/llmProvider.ts | 88 +++- src/services/slackSources.ts | 107 ++++- src/services/workspaceAdminService.ts | 150 +++++++ src/ui/appHome.ts | 435 ++++++++++++++++++- src/ui/blocks.ts | 314 ++++++++++++- src/ui/canvas.ts | 169 ++++++- src/ui/launchModals.ts | 257 +++++++++++ test/appHome.test.ts | 85 ++++ test/canvas.test.ts | 31 +- test/launchService.test.ts | 262 +++++++++-- test/llmProvider.test.ts | 15 + test/readinessEngine.test.ts | 225 +++++++++- test/ui.test.ts | 90 +++- test/workspaceAdminService.test.ts | 74 ++++ tsconfig.build.json | 8 + 48 files changed, 5233 insertions(+), 372 deletions(-) delete mode 100644 .github/workflows/azure-app-service.yml create mode 100644 .github/workflows/render-deploy.yml create mode 100644 .node-version create mode 100644 ARCHITECTURE.md create mode 100644 CONTRIBUTING.md create mode 100644 DATA_RETENTION.md create mode 100644 DEMO_RUNBOOK.md create mode 100644 LICENSE create mode 100644 MARKETPLACE.md create mode 100644 PRIVACY.md create mode 100644 PROOF_CAPTURE.md create mode 100644 RENDER_SETUP.md create mode 100644 SECURITY.md create mode 100644 SUBMISSION_CHECKLIST.md create mode 100644 render.yaml create mode 100644 src/http/customRoutes.ts create mode 100644 src/repositories/workspaceAdminRepository.ts create mode 100644 src/scripts/smoke.ts create mode 100644 src/scripts/verifyHosted.ts create mode 100644 src/services/workspaceAdminService.ts create mode 100644 src/ui/launchModals.ts create mode 100644 test/appHome.test.ts create mode 100644 test/workspaceAdminService.test.ts create mode 100644 tsconfig.build.json diff --git a/.env.example b/.env.example index 8d629ae..c3d5d38 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ PORT=3000 SLACK_SIGNING_SECRET= SLACK_BOT_TOKEN= SLACK_APP_TOKEN= +SLACK_TOKEN_VERIFICATION_ENABLED=true DATABASE_URL= USE_SOCKET_MODE=true ENABLE_LLM_SUMMARIES=false @@ -10,3 +11,4 @@ CEREBRAS_API_KEY= CEREBRAS_MODEL=gpt-oss-120b CEREBRAS_BASE_URL=https://api.cerebras.ai/v1 CEREBRAS_REASONING_EFFORT=none +PUBLIC_BASE_URL= diff --git a/.github/workflows/azure-app-service.yml b/.github/workflows/azure-app-service.yml deleted file mode 100644 index 0bff058..0000000 --- a/.github/workflows/azure-app-service.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: Build and Deploy GoSignal to Azure App Service - -on: - push: - branches: - - main - pull_request: - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: gosignal-${{ github.ref }} - cancel-in-progress: true - -env: - AZURE_WEBAPP_NAME: gosignal - -jobs: - build-test: - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 22.x - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Type-check - run: npm run check - - - name: Run tests - run: npm test - - - name: Build production bundle - run: npm run build - - - name: Prune dev dependencies - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') - run: npm prune --omit=dev - - - name: Assemble deployment package - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') - run: | - mkdir -p release - cp -R dist node_modules package.json package-lock.json README.md server.js release/ - - - name: Upload deployment artifact - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') - uses: actions/upload-artifact@v4 - with: - name: gosignal-release - path: release - if-no-files-found: error - - deploy: - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') - needs: build-test - runs-on: ubuntu-latest - environment: production - - steps: - - name: Validate Azure deployment settings - env: - AZURE_WEBAPP_PUBLISH_PROFILE: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_9492FD61CE93436AAFF591EA984DB633 }} - run: | - test -n "$AZURE_WEBAPP_NAME" || (echo "Missing AZURE_WEBAPP_NAME in workflow env" && exit 1) - test -n "$AZURE_WEBAPP_PUBLISH_PROFILE" || (echo "Missing GitHub repository secret AZUREAPPSERVICE_PUBLISHPROFILE_9492FD61CE93436AAFF591EA984DB633" && exit 1) - - - name: Download deployment artifact - uses: actions/download-artifact@v4 - with: - name: gosignal-release - path: release - - - name: Deploy to Azure App Service - uses: azure/webapps-deploy@v3 - with: - app-name: ${{ env.AZURE_WEBAPP_NAME }} - slot-name: Production - publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_9492FD61CE93436AAFF591EA984DB633 }} - package: release diff --git a/.github/workflows/render-deploy.yml b/.github/workflows/render-deploy.yml new file mode 100644 index 0000000..e8ae7f8 --- /dev/null +++ b/.github/workflows/render-deploy.yml @@ -0,0 +1,74 @@ +name: Verify and Deploy GoSignal to Render + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: gosignal-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: 22 + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Verify source, tests, and production entrypoint + run: npm run verify + + - name: Smoke test the built production artifact + run: npm run smoke + + deploy: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') + needs: verify + runs-on: ubuntu-latest + environment: production + + steps: + - name: Check whether the Render deploy hook is configured + id: render-hook + env: + RENDER_DEPLOY_HOOK_URL: ${{ secrets.RENDER_DEPLOY_HOOK_URL }} + run: | + if [ -n "$RENDER_DEPLOY_HOOK_URL" ]; then + echo "configured=true" >> "$GITHUB_OUTPUT" + else + echo "configured=false" >> "$GITHUB_OUTPUT" + fi + + - name: Trigger Render deploy hook for the exact commit + if: steps.render-hook.outputs.configured == 'true' + env: + RENDER_DEPLOY_HOOK_URL: ${{ secrets.RENDER_DEPLOY_HOOK_URL }} + COMMIT_SHA: ${{ github.sha }} + run: | + test -n "$RENDER_DEPLOY_HOOK_URL" || (echo "Missing GitHub repository secret RENDER_DEPLOY_HOOK_URL" && exit 1) + curl --fail --silent --show-error --request POST "${RENDER_DEPLOY_HOOK_URL}?ref=${COMMIT_SHA}" + + - name: Explain why Render deploy was skipped + if: steps.render-hook.outputs.configured != 'true' + run: | + echo "Render deploy skipped because the GitHub repository secret RENDER_DEPLOY_HOOK_URL is not set yet." + echo "Set the secret, then rerun this workflow or push a new commit to main." diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..e7e66bf --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,138 @@ +# Architecture + +GoSignal is a Slack-native launch readiness agent with a deterministic +decisioning core and an optional LLM phrasing layer. + +## High-Level Flow + +```mermaid +flowchart TD + A["Slack user"] --> B["Shortcut or app mention"] + B --> C["Bolt app handlers"] + C --> D["Thread retrieval"] + C --> E["Public search retrieval"] + C --> M["Workspace settings modal"] + D --> F["Deterministic readiness engine"] + E --> F + F --> G["Optional Cerebras summary layer"] + F --> H["Launch repository"] + C --> N["Workspace admin service"] + N --> O["Workspace settings store"] + N --> P["Audit ledger"] + G --> I["Thread response blocks"] + H --> J["App Home launches"] + O --> J + P --> J + F --> K["Canvas markdown writer"] + K --> L["Slack canvas"] +``` + +## Slack Surfaces + +- Agent view +- App Home +- Message shortcut +- Thread replies +- Markdown canvas +- App DMs for follow-up questions about an already analyzed launch + +## Component Map + +| Component | Files | Responsibility | +| --- | --- | --- | +| App bootstrap and routes | `src/app.ts`, `src/index.ts`, `src/http/customRoutes.ts` | Starts the Bolt app, serves health routes, and wires dependencies | +| Slack handlers | `src/handlers/registerHandlers.ts` | Receives app mentions, shortcut invocations, App Home opens, DMs, and block actions | +| Thread and search retrieval | `src/services/slackSources.ts` | Reads launch threads, public search context, and writes canvases | +| Readiness engine | `src/domain/readiness.ts`, `src/domain/textSignals.ts`, `src/domain/constants.ts` | Classifies approvals, blockers, ambiguity, rollback, dependencies, and next action | +| Launch orchestration | `src/services/launchService.ts` | Combines retrieval, readiness evaluation, summary generation, persistence, and canvas writing | +| Workspace admin service | `src/services/workspaceAdminService.ts` | Resolves workspace settings, records audit events, and drives the admin App Home behavior | +| Optional LLM layer | `src/services/llmProvider.ts` | Rewrites grounded results into more natural text, with deterministic fallback | +| Persistence | `src/repositories/memoryLaunchRepository.ts`, `src/repositories/postgresLaunchRepository.ts`, `src/repositories/workspaceAdminRepository.ts`, `src/repositories/schema.sql` | Stores launch records, workspace settings, and audit events | +| UI rendering | `src/ui/blocks.ts`, `src/ui/canvas.ts`, `src/ui/appHome.ts` | Builds thread blocks, canvas markdown, and App Home surfaces | +| Verification | `src/scripts/checkLlm.ts`, `src/scripts/smoke.ts` | Verifies the LLM path and the production artifact | + +## Storage Model + +The primary persisted object is a `LaunchRecord`, which includes: + +- `workspaceId` +- `sourceChannelId` +- `sourceThreadTs` +- launch name and status +- category states +- approvals +- blockers +- evidence excerpts +- final decision snapshot +- canvas linkage + +The workspace admin layer also persists: + +- `WorkspaceSettingsRecord` for search mode and audit retention +- `AuditEventRecord` for operator-visible actions such as analyze, rerun, + sign-off request, canvas open, and settings updates + +Workspace isolation is achieved through repository queries that always scope to +`workspace_id`, plus a unique index on: + +- `workspace_id` +- `source_channel_id` +- `source_thread_ts` + +## Deterministic And LLM Boundaries + +Deterministic logic is the source of truth for: + +- overall state +- blocker detection +- missing approvals +- dependency risk +- ambiguity +- next action + +Optional LLM usage is limited to: + +- natural summary phrasing +- natural DM answers about an existing launch + +The LLM does not override a deterministic red, yellow, green, or +`needs_review` state. + +## Deployment Topology + +Current deployment target: + +- Render Web Service +- Node.js 22 +- built artifact from `dist/` +- `server.js` as the production entrypoint + +Health endpoints: + +- `GET /` +- `GET /healthz` + +Repository verification path: + +- `npm run verify` +- `npm run smoke` + +## Current Trust Boundaries + +- Launch analysis is public-first in the current MVP. +- Public Slack thread messages form the main evidence base. +- Public search evidence is only used when a user-triggered action provides an + action token. +- Workspace settings can disable live search entirely and force thread-only + analysis. +- The thread board and markdown canvas both surface evidence receipts and live + search diagnostics from the current run. +- LLM output is grounded in structured launch data prepared by the deterministic + layer. + +## Known Architectural Gaps Before Final Submission + +- There is no dedicated installation store or encrypted token store yet. +- There is no self-service deletion UI or automated retention purge yet. +- Final Marketplace request URLs and five-workspace proof are external to the + repository and still pending. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4e13400 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing + +Thanks for contributing to GoSignal. + +## Local Workflow + +1. Install dependencies with `npm install`. +2. Copy `.env.example` to `.env` and add local Slack credentials. +3. Run `npm run dev` or `npm run dev:watch`. +4. Before pushing changes, run: + +```bash +npm run verify +npm run smoke +``` + +If you are changing the optional LLM path, also run: + +```bash +npm run check:llm +``` + +## Contribution Guidelines + +- Keep the deterministic readiness engine authoritative. +- Treat LLM output as optional phrasing, not the source of truth. +- Preserve workspace scoping in repository queries and UI flows. +- Do not commit secrets, tokens, or private screenshots. +- Update docs when the install, deployment, or Marketplace posture changes. + +## Pull Request Checklist + +- Code builds from the production path. +- Tests pass. +- Smoke test passes. +- New behavior is documented if it changes setup, deployment, or submission + proof. + +## Reporting Issues + +- General issues: `https://github.com/BitTriad/GoSignal/issues` +- Do not post secrets or live credentials in public issues. diff --git a/DATA_RETENTION.md b/DATA_RETENTION.md new file mode 100644 index 0000000..fd0ede3 --- /dev/null +++ b/DATA_RETENTION.md @@ -0,0 +1,99 @@ +# Data Retention + +This document records the current retention behavior of GoSignal and the manual +deletion flow that exists today. + +## Current Storage Modes + +| Mode | Current behavior | +| --- | --- | +| In-memory repository | Launch records disappear when the process restarts | +| Postgres repository | Launch records persist until rows are manually removed | + +## Current Repository Truth + +GoSignal does not yet implement: + +- an automatic TTL purge +- a self-service admin deletion UI + +That means retention is currently an operational policy, not an automated +product control. + +## Recommended Demo And Hackathon Policy + +- Keep demo workspaces short-lived. +- Use a separate database for demo or judging data. +- Purge demo launches after judging or when resetting the workspace. +- Avoid storing unnecessary production-like test data in the hackathon + environment. + +## Manual Deletion Flow + +### In-Memory Mode + +If GoSignal is running with the in-memory repository: + +- stop the process +- restart the app + +This clears stored launch records. + +### Postgres Mode + +If GoSignal is running with Postgres: + +- remove rows from the `launches` table +- scope deletes by `workspace_id` or `id` + +Examples: + +Delete one launch by ID: + +```sql +DELETE FROM launches +WHERE id = ''; +``` + +Delete all launches for one workspace: + +```sql +DELETE FROM launches +WHERE workspace_id = ''; +``` + +Clear the entire table: + +```sql +TRUNCATE TABLE launches; +``` + +Use the broad table clear only in demo or reset environments. + +## Data Included In Stored Launch Rows + +Each launch row includes a JSON payload containing: + +- launch summary fields +- approvals +- blockers +- evidence excerpts +- source metadata +- canvas linkage + +This means manual deletion of the row also removes the stored evidence snapshot +that GoSignal kept for that launch. + +## Canvas Retention Note + +The current repository stores the canvas ID and label in the launch record. +Canvas content itself lives in Slack. If you need full cleanup, remove the +launch row and also delete or replace the Slack canvas through the Slack app +surface or an admin workflow once that exists. + +## Gaps Before Final Submission + +- No automated retention schedule +- No admin-triggered deletion button in the UI +- No automated purge tied to the workspace retention setting +- No audit ledger for deletion events yet diff --git a/DEMO_RUNBOOK.md b/DEMO_RUNBOOK.md new file mode 100644 index 0000000..5192c64 --- /dev/null +++ b/DEMO_RUNBOOK.md @@ -0,0 +1,122 @@ +# Demo Runbook + +This runbook is designed for a clean, repeatable Track 3 demo using the +features that exist in the current repository. + +## Demo Goal + +Show that GoSignal can: + +- analyze a real launch thread in Slack +- explain the readiness state +- apply the right launch profile +- identify a missing sign-off +- assign a human owner to close that gap +- create a durable launch brief canvas +- update the decision after the sign-off lands +- show workspace-scoped launch history, settings, and audit activity in App Home +- export the brief and open launch history without leaving Slack + +## Recommended Demo Setup + +- Use a public demo channel such as `#launch-demo`. +- Invite GoSignal to the channel before the recording. +- Make sure the app has already been installed in the workspace. +- Use the current bound app from `.slack/apps.dev.json` unless you have already + switched to a production app. + +## Seed Thread + +Create a thread with messages similar to the following: + +```text +PM: Launch: Mobile v3 checkout rollout +PM: Profile: SaaS release +PM: Target: Today 5:00 PM IST +PM: Please confirm launch readiness. + +Engineering lead: Engineering approved for launch. @user +QA lead: QA signed off. @user +Ops lead: Rollback documented and ops approved. @user +PM: Release notes ready and shared. +Ops lead: Primary on call owner is @user. +PM: Waiting on support readiness approval. +``` + +This should produce a yellow state because support readiness is still missing. + +## Demo Script + +1. Open the seeded thread. +2. Use the `Analyze launch readiness` message shortcut. +3. Show the resulting readiness board in the thread. +4. Call out: + - overall state + - recommendation + - launch profile + - profile-specific evidence checks + - missing sign-off + - next action +5. Click `Open launch brief` and show the canvas. +6. Click `Assign owner` and assign support readiness to a real user. +7. Click `View history` and show the audit trail capturing that assignment. +8. Click `Request sign-off` and show the generated request text in the thread. +9. Optionally click `Remind owner` to show the follow-up flow. +10. Reply in the thread with a support sign-off, for example: + +```text +Support readiness: Support readiness approved for launch. @user +``` + +11. Click `Re-run readiness`. +12. Show the state moving from yellow to green. +13. Open App Home and show: + - the launch listed for the current workspace + - the at-risk and missing sign-off watchlist + - the current workspace search mode + - the default launch profile + - the recent audit events for analyze, assign owner, and rerun +14. Click `Export brief` and show the copy-ready launch export modal. + +## Expected Demo States + +Initial pass: + +- overall state: yellow +- reason: missing support readiness sign-off +- next action: request support readiness approval and rerun + +After support reply and rerun: + +- overall state: green +- reason: required sign-offs are now present and no open blockers remain + +## Current Demo Strength + +This demo is already strong for: + +- thread-first Slack UX +- deterministic and explainable readiness +- durable canvas output +- rerun flow +- App Home admin recap + +## Strongest Final Video + +The strongest Track 3 video now shows search evidence as a load-bearing part of +the product. Record one blocker or dependency found outside the current thread +and then show it folding into the readiness board, the evidence receipts, and +the launch brief canvas. + +## Fallback Plan + +If the live Slack thread is messy or inconsistent: + +- create a new public channel +- reseed the exact thread above +- rerun the shortcut + +If LLM wording varies: + +- rely on the deterministic state, blocker, and sign-off explanation +- keep the demo focused on the decision and workflow, not the phrasing style diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4c7e051 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 BitTriad + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MARKETPLACE.md b/MARKETPLACE.md new file mode 100644 index 0000000..1615872 --- /dev/null +++ b/MARKETPLACE.md @@ -0,0 +1,123 @@ +# Marketplace Readiness + +This document tracks what GoSignal can already prove from the repository and +what still needs manual evidence before a final Track 3 submission. + +## Current Status + +| Field | Current status | +| --- | --- | +| Public repo | `https://github.com/BitTriad/GoSignal` | +| Current Slack App ID | `A0BH56L2ETS` | +| App source | Slack CLI-bound development app from `.slack/apps.dev.json` | +| Manifest org deploy | `true` | +| Current runtime mode | Socket Mode enabled by default | +| Current install path | Manifest import or Slack CLI | +| Preferred hosting path | Render Web Service + PostgreSQL | +| Public hosted URL | Pending live verification | +| Marketplace submission proof | Pending manual capture | +| Five active workspace proof | Pending manual capture | + +If the final submission uses a different production app, replace the current +App ID everywhere in this proof pack. + +Use [PROOF_CAPTURE.md](PROOF_CAPTURE.md) as the final worksheet for hosted +deployment proof, Marketplace evidence, and the five-workspace install matrix. + +## Current Slack App Capabilities + +Verified in `manifest.json`: + +- Agent view +- App Home +- Bot user +- Message shortcut: `Analyze launch readiness` +- Canvases +- Public search scopes for live workspace context +- `org_deploy_enabled: true` + +## Current Policy And Support URLs + +These GitHub-hosted docs can be used as interim policy links for the hackathon +submission until a dedicated site exists. + +- Repository home: `https://github.com/BitTriad/GoSignal` +- Support URL: `https://github.com/BitTriad/GoSignal/issues` +- Privacy policy: `https://github.com/BitTriad/GoSignal/blob/main/PRIVACY.md` +- Security policy: `https://github.com/BitTriad/GoSignal/blob/main/SECURITY.md` +- Data retention policy: + `https://github.com/BitTriad/GoSignal/blob/main/DATA_RETENTION.md` +- Terms or license: `https://github.com/BitTriad/GoSignal/blob/main/LICENSE` + +## OAuth, Request URLs, And Distribution Status + +Current repo truth: + +- Socket Mode is enabled in the manifest for development. +- HTTP request URLs are not yet documented as live production endpoints. +- OAuth redirect URLs are not yet captured in the proof pack. +- Token rotation is still disabled in the manifest. + +Before final Marketplace-style submission: + +1. Deploy the hosted app and verify the final public URL. +2. Switch to `USE_SOCKET_MODE=false` if final judging depends on production + request URLs instead of Socket Mode. +3. Record the event request URL. +4. Record the interactivity request URL. +5. Record the OAuth redirect URL if the final install path uses OAuth. +6. Capture a Marketplace submission screenshot or timestamp. + +## Current Install And Admin Flow + +1. Create or bind the Slack app using `manifest.json` or Slack CLI. +2. Install the app into the target workspace. +3. Invite the bot into the public launch channel that will be analyzed. +4. Use the `Analyze launch readiness` message shortcut on a launch thread, or + mention `@GoSignal` directly inside that thread. +5. Open App Home to confirm the app is publishing workspace-scoped launches, + workspace settings, and audit history. +6. Use `Workspace settings` in App Home to choose thread-only or public-search + mode, set the default launch profile, and choose the audit retention window. +7. Use `Re-run readiness`, `Request sign-off`, `Assign owner`, `View history`, + `Export brief`, or `Open launch brief` from the generated thread response. + +Current limitation: + +- There is still no deletion control, channel allowlist, or required-role + configuration screen yet. + +## Scope Review + +Current grouped scope intent: + +| Scope group | Current scopes | Why they are present | +| --- | --- | --- | +| Slack agent + surfaces | `assistant:write`, `chat:write`, `chat:write.public`, `canvases:write` | Post replies, power the agent flow, and create launch brief canvases | +| Public launch context | `channels:history`, `channels:read`, `app_mentions:read` | Read the public launch thread and respond to mentions | +| DM follow-up flow | `im:history`, `im:read`, `im:write` | Answer questions about an already analyzed launch in app DMs | +| Search context | `search:read.public`, `search:read.files`, `search:read.users`, `files:read` | Pull public cross-workspace evidence when a user action includes an action token | +| Miscellaneous | `commands`, `users:read` | Present in the current manifest; review for least privilege before final submission | + +## External Evidence Still Required + +- Marketplace submission screenshot or submission timestamp +- Final production App ID if it differs from `A0BH56L2ETS` +- Final hosted URL +- Final event request URL +- Final interactivity request URL +- Final OAuth redirect URL if used +- Install proof across five active workspaces +- Screenshot or capture of App Home in at least one non-demo workspace + +## Practical Submission Guidance + +For a strong Track 3 submission, the repo should be treated as one half of the +proof. The other half must be captured manually: + +- a real hosted deployment, +- a real Marketplace or install path, +- and real multi-workspace evidence. + +This file exists so those manual artifacts have a precise place to be attached +or referenced before final submission. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..2ea8b91 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,97 @@ +# Privacy + +This document explains what GoSignal currently collects, how it uses that data, +and which privacy gaps still remain before a full production distribution +posture. + +## Product Scope + +GoSignal is a Slack-native launch readiness agent. Its purpose is to analyze a +launch thread, summarize the readiness state, and create a durable launch brief +canvas. + +## Data Collected + +Current repository behavior may store the following in a launch record: + +- workspace ID +- source channel ID +- source thread timestamp +- launch name +- category states and summaries +- approval states +- blockers +- evidence excerpts and summaries +- evidence metadata such as channel ID, permalink, and message timestamp +- decision summary and next action +- canvas ID and canvas label + +App context snapshots may also store: + +- workspace ID +- user ID +- last channel ID +- last thread timestamp +- timestamp of when the context was seen + +## Data Sources + +Current sources include: + +- public Slack thread messages +- public Slack search results when a user action includes an action token +- App Home context for the current workspace +- App DM follow-up questions about an already analyzed launch + +## What GoSignal Does Not Intend To Collect By Default + +Current design intent: + +- no secret values in health endpoints +- no hidden AI-only decisioning +- no private multi-workspace data mixing + +Current limitation: + +- there is not yet a self-service export or deletion UI + +## How Data Is Used + +Collected data is used to: + +- determine readiness state +- explain blockers and missing sign-offs +- generate a launch brief canvas +- answer follow-up questions about an existing launch +- show recent launches, workspace settings, and audit events in App Home + +## External Services + +Depending on deployment choices, GoSignal may rely on: + +- Slack APIs for message, search, canvas, and App Home interactions +- Render Web Service and Render Postgres if hosted there +- Cerebras only when optional LLM summarization is enabled + +If the LLM layer is disabled, launch summaries remain deterministic. + +## Retention And Deletion + +Current behavior depends on storage mode: + +- In-memory mode: launch data disappears when the process restarts. +- Postgres mode: launch data remains until rows are removed manually. + +See [DATA_RETENTION.md](DATA_RETENTION.md) for the current deletion flow. + +## Current Privacy Gaps Before Final Submission + +- no self-service data deletion action in the product +- no dedicated privacy-only settings screen +- no deletion audit trail yet beyond the general operator ledger +- final hosted policy URLs still need to be referenced in the submission + +## Support + +- General support: `https://github.com/BitTriad/GoSignal/issues` +- Repository home: `https://github.com/BitTriad/GoSignal` diff --git a/PROOF_CAPTURE.md b/PROOF_CAPTURE.md new file mode 100644 index 0000000..eedd2ca --- /dev/null +++ b/PROOF_CAPTURE.md @@ -0,0 +1,106 @@ +# Proof Capture + +This file closes the remaining manual proof gaps as far as the repository can +help. It does not pretend to prove things the repo cannot prove on its own. +Instead, it gives you a repeatable way to capture the final Track 3 evidence +before submission. + +## 1. Hosted Deployment Proof + +Use the hosted-proof command against the final public app URL: + +```bash +npm run proof:hosted -- https://your-final-app-url +``` + +Or set `PUBLIC_BASE_URL` in `.env` and run: + +```bash +npm run proof:hosted +``` + +Paste the output into your submission notes or save it as a screenshot. The +command verifies: + +- `GET /` returns `200` +- `GET /healthz` returns `200` +- `/healthz` responds with a GoSignal-shaped JSON payload + +If you follow the current recommended hosting path, this will be your Render +service URL. + +### Hosted Proof Record + +- Hosted public URL: +- Proof captured at: +- Root status: +- Health status: +- Health payload screenshot or pasted JSON: + +## 2. Marketplace Submission Proof + +Track 3 still needs real Marketplace or distribution evidence captured by a +human. Fill this out after you submit the production app. + +### Marketplace Record + +- Final production App ID: +- Submission date: +- Submission time: +- Submission path: +- Screenshot path or link: +- Support URL: +- Privacy policy URL: +- Data retention URL: +- Interactivity request URL: +- Event request URL: +- OAuth redirect URL: + +## 3. Five Active Workspace Proof + +This is the most important remaining manual gap for the Organizations track. +Use one row per real workspace where GoSignal is installed and tested. + +| Workspace | Installed by | Install date | Public launch channel | Analyzed launch name | Screenshot or note | +| --- | --- | --- | --- | --- | --- | +| Workspace 1 | | | | | | +| Workspace 2 | | | | | | +| Workspace 3 | | | | | | +| Workspace 4 | | | | | | +| Workspace 5 | | | | | | + +Minimum standard for each row: + +- the app is installed +- the app is invited to a public channel +- one launch thread is analyzed +- App Home shows only that workspace's launches and audit history + +## 4. Demo Evidence + +Capture these before submission: + +- the yellow-to-green thread flow +- one example of live search adding evidence from outside the thread +- the launch brief canvas +- App Home watchlist and workspace settings +- owner assignment or reminder flow +- launch history modal +- export brief modal + +## 5. What Is Still Manual + +The repository now gives you: + +- a hosted verification command +- Marketplace/readiness docs +- a submission checklist +- the proof pack structure + +The repository cannot auto-prove: + +- a real Marketplace submission +- five real active workspaces +- screenshots from your final production app + +Those must still be captured by you before the final hackathon submission. diff --git a/README.md b/README.md index c7a6624..9b23f7c 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,213 @@ # GoSignal -GoSignal is a Slack-native launch readiness agent for the Slack Agent Builder Challenge Track 3. This repository implements the hackathon MVP: analyze a launch thread, pull public Slack evidence in real time, build a durable markdown launch brief canvas, and drive one next action from a deterministic readiness engine. - -## What ships in v1 - -- Thread-first readiness analysis using public Slack context. -- Deterministic classification for blockers, rollback risk, sign-offs, dependencies, and ambiguity. -- Markdown canvas generation for the durable launch brief. -- Block Kit message rendering for readiness boards, blockers, and actions. -- Optional Cerebras-backed natural summaries and DM answers with deterministic fallback. -- App Home listing of recent launches and rerun actions. -- In-memory storage by default with a Postgres adapter for hosted deployment. -- Azure-ready Postgres configuration with optional TLS settings. - -## Project structure - -- `manifest.json`: Slack app manifest tuned for the agent messaging experience. -- `src/app.ts`: Bolt app bootstrap and dependency wiring. -- `src/handlers/`: Slack event, shortcut, and action handlers. -- `src/domain/`: Launch models and deterministic readiness rules. -- `src/services/`: Launch orchestration, retrieval, canvas, and home rendering. -- `src/repositories/`: In-memory and Postgres persistence layers. -- `test/`: Unit and integration-style tests for the MVP flow. - -## Local development +GoSignal is an organization-ready Slack launch readiness agent. It turns a messy +launch thread plus live workspace evidence into a durable go/no-go brief: +blockers, missing sign-offs, rollback risk, dependencies, evidence-backed +reasoning, and one accountable next action. + +Built for Slack Agent Builder Challenge Track 3: Organizations. + +## 30-Second Judge Quickstart + +1. Install GoSignal in a workspace and invite it to a public demo channel. +2. Open a seeded launch thread in that channel. +3. Use the `Analyze launch readiness` message shortcut or mention `@GoSignal` + in the thread. +4. Review the readiness board and open the launch brief canvas. +5. Add the missing sign-off and click `Re-run readiness`. +6. Open App Home to confirm the launch history, workspace settings, and audit + history are scoped to the current workspace. + +## Submission Snapshot + +- Public repo: `BitTriad/GoSignal` +- Slack App ID: `A0BH56L2ETS` + - This is the current Slack CLI-bound development app from `.slack/apps.dev.json`. + - If the final submission uses a different production app, replace this value + everywhere in the proof pack. +- Current install model: manifest import or Slack CLI-managed development app +- Current runtime: Bolt JS with Slack app capabilities, canvases, App Home, + message shortcut, agent view, and optional Cerebras summarization +- Current storage: in-memory by default, Postgres adapter available +- Current deploy target name: `gosignal` on Render +- Hosted public URL: pending live verification +- Marketplace submission proof: pending manual capture +- Five active workspace proof: pending manual capture + +## Why GoSignal Fits Track 3 + +- It solves an organization workflow that already lives in Slack: release and + launch go/no-go decisions. +- It creates a durable artifact, not just a chat answer: a markdown launch + brief canvas backed by evidence. +- It stays explainable: deterministic readiness logic decides blockers, + approvals, ambiguity, and overall state. +- It is designed for workspace-safe installation: launch data is keyed by + `workspace_id` and thread identity in both memory and Postgres storage paths. +- It has a real operator surface: thread actions, App Home, rerun flow, and + sign-off request flow. +- It now supports configurable launch profiles, owner assignment/reminders, + launch history, and export-ready briefs inside Slack. + +## Proof Pack + +- [Marketplace readiness](MARKETPLACE.md) +- [Proof capture](PROOF_CAPTURE.md) +- [Render setup](RENDER_SETUP.md) +- [Architecture](ARCHITECTURE.md) +- [Security](SECURITY.md) +- [Privacy](PRIVACY.md) +- [Data retention](DATA_RETENTION.md) +- [Demo runbook](DEMO_RUNBOOK.md) +- [Submission checklist](SUBMISSION_CHECKLIST.md) +- [Contributing](CONTRIBUTING.md) +- [License](LICENSE) + +## Repo-Verified Status + +- Verified in repo: + - App manifest includes agent view, App Home, message shortcut, canvases, and + `org_deploy_enabled: true`. + - The app serves `GET /` and `GET /healthz` from the production artifact. + - `npm run verify` passes. + - `npm run smoke` passes. + - Workspace-scoped persistence exists for both in-memory and Postgres storage. + - App Home publishes recent launches, workspace settings, and audit events + for the current workspace. + - Optional Cerebras summarization is integrated with deterministic fallback. +- Still requires external submission evidence: + - Marketplace submission timestamp or screenshot + - Final hosted URL and healthy `/healthz` response from the deployed app + - Final install URL or OAuth path + - Proof of installation in five active workspaces + - Final demo video under three minutes + +## Core Product Flow + +1. A user triggers GoSignal from a launch thread using the message shortcut or + an app mention. +2. GoSignal reads the current thread and, when Slack provides an action token, + queries public workspace context through Slack search. If live search is not + available, the thread reply and launch brief show that diagnostic explicitly. +3. The deterministic readiness engine classifies approvals, blockers, + ambiguity, rollback status, dependencies, profile-specific evidence, and + next action. +4. GoSignal posts a readiness board back into the thread, including evidence + counts, live search diagnostics, and cross-channel evidence receipts. +5. GoSignal creates or updates a markdown launch brief canvas. +6. App Home shows recent launches, at-risk launches, missing sign-offs, + workspace settings, and operator audit events for the current workspace. +7. A user can rerun readiness, request the missing sign-off, assign an owner, + remind that owner, open launch history, or export the brief from Slack. + +## Project Structure + +- `manifest.json`: Slack app manifest tuned for the agent messaging experience +- `src/app.ts`: Bolt app bootstrap, routes, and dependency wiring +- `src/handlers/`: Slack event, shortcut, and action handlers +- `src/domain/`: launch models and deterministic readiness rules +- `src/services/`: launch orchestration, workspace admin controls, retrieval, + canvas, and home rendering +- `src/repositories/`: in-memory and Postgres persistence for launches, + workspace settings, and audit events +- `src/http/customRoutes.ts`: production health endpoints +- `src/scripts/`: LLM and production smoke verification utilities +- `test/`: unit and integration-style tests for the MVP flow + +## Local Development 1. Copy `.env.example` to `.env` and fill in Slack credentials. 2. Install dependencies with `npm install`. -3. Run `npm run dev` for a one-shot local process, or `npm run dev:watch` while iterating on TypeScript files. -4. Use `slack run` if you want Slack CLI-managed local development. This repository now includes the `.slack/` project files and a CLI hook that starts GoSignal through `npm run dev:watch`. -5. Import `manifest.json` into Slack or use Slack CLI to wire the app to your workspace. -6. Create an app-level token and keep `Socket Mode` enabled for the easiest local setup. -7. Before Marketplace-style distribution, switch the app to HTTP mode with request URLs instead of Socket Mode. - -### Optional LLM mode +3. Run `npm run dev` for a one-shot local process, or `npm run dev:watch` + while iterating on TypeScript files. +4. Use `slack run` if you want Slack CLI-managed local development. This + repository includes the `.slack/` project files and a CLI hook that starts + GoSignal through `npm run dev:watch`. +5. Import `manifest.json` into Slack or use Slack CLI to bind the app to your + workspace. +6. Create an app-level token and keep Socket Mode enabled for the easiest local + setup. +7. Run `npm run verify` before pushing changes. +8. Run `npm run smoke` after `npm run build` to verify the production artifact + serves `GET /` and `GET /healthz`. + +## Optional LLM Mode - Keep `ENABLE_LLM_SUMMARIES=false` to use the deterministic provider only. - To enable more natural summaries and DM answers, set: - `ENABLE_LLM_SUMMARIES=true` - `LLM_PROVIDER=cerebras` - `CEREBRAS_API_KEY` - - Optional `CEREBRAS_MODEL`, `CEREBRAS_BASE_URL`, and `CEREBRAS_REASONING_EFFORT` -- Recommended for GoSignal: `CEREBRAS_REASONING_EFFORT=none`. This app needs concise, grounded launch summaries, not long hidden reasoning traces that can consume the whole completion budget. -- Run `npm run check:llm` to verify the exact Cerebras path GoSignal uses in production. The check fails if GoSignal falls back to deterministic output. -- The deterministic readiness engine still decides launch state, blockers, and approvals. The LLM only rewrites those results into more natural language and answers follow-up questions from the stored launch record. - -### Slack CLI notes - -- `.slack/config.json` binds this checkout to the current Slack CLI project. -- `.slack/hooks.json` overrides the CLI start hook so `slack run` launches the real TypeScript GoSignal app instead of a sample `app.js`. -- If you want to point this repo at a different Slack app later, reinitialize the project with Slack CLI rather than editing the generated project files by hand. - -## Azure App Service deployment - -GoSignal is now set up to deploy cleanly to Azure App Service from GitHub Actions. - -### Database choice for Azure - -Use Azure Database for PostgreSQL Flexible Server for GoSignal. - -- GoSignal persists launches through `pg` in [src/repositories/postgresLaunchRepository.ts](/Users/anshul/Documents/Slack Project/src/repositories/postgresLaunchRepository.ts). -- The current repository schema uses PostgreSQL tables, a unique index, and `JSONB` payload storage in [src/repositories/schema.sql](/Users/anshul/Documents/Slack Project/src/repositories/schema.sql). -- If the Azure wizard is offering Cosmos DB API for MongoDB, do not use that option for this codebase unless you plan to rewrite the persistence layer. - -### What changed for deployment - -- `npm run build` now compiles app code only into `dist/`. -- `npm start` runs the production build through the root `server.js` entrypoint so Azure App Service can detect it as a Node app. -- `GET /` and `GET /healthz` are available for smoke tests and App Service health checks. -- The GitHub Actions workflow runs type-checks, tests, builds a deployable bundle, and deploys to Azure on every push to `main`. -- Database tables are created automatically on app startup when `DATABASE_URL` is set. - -### One-time Azure setup - -1. Create an Azure App Service web app for Node.js 22. -2. In Azure App Service `Configuration`, add these app settings: + - Optional `CEREBRAS_MODEL`, `CEREBRAS_BASE_URL`, and + `CEREBRAS_REASONING_EFFORT` +- Recommended for GoSignal: `CEREBRAS_REASONING_EFFORT=none`. +- Run `npm run check:llm` to verify the exact Cerebras path GoSignal uses in + production. +- The deterministic readiness engine remains the source of truth. The LLM only + rewrites grounded results into more natural language and answers follow-up + questions from the stored launch record. + +## Render Deployment + +GoSignal is set up to deploy to Render with GitHub Actions handling CI and the +Render deploy hook handling production deployment. + +### Database Choice For Render + +Use Render Postgres or another managed PostgreSQL provider for GoSignal. + +- GoSignal persists launches through `pg` in + `src/repositories/postgresLaunchRepository.ts`. +- The current schema uses PostgreSQL tables, a unique index, and `JSONB` + payload storage in `src/repositories/schema.sql`. +- Do not use a MongoDB-only datastore for this codebase unless you plan to + rewrite the persistence layer. + +### Deployment Notes + +- `render.yaml` describes the Render-friendly service setup that belongs with + this repository. +- `.node-version` pins the major Node runtime for Render. +- `npm run build` compiles the production app into `dist/` using + `tsconfig.build.json`. +- `npm start` runs the production build through the root `server.js` entrypoint. +- `GET /` and `GET /healthz` are available for smoke tests and Render health + checks. +- `npm run verify` checks types, runs tests, builds the production bundle, and + validates `server.js`. +- `npm run smoke` starts the built app with safe local test settings and + verifies both production health routes. +- The GitHub Actions workflow runs verification and smoke tests before + triggering Render on pushes to `main`. + +### One-Time Render Setup + +1. Create a Render web service from this repository. +2. Create a PostgreSQL database and set `DATABASE_URL` on the web service. +3. In the Render service, add: - `SLACK_SIGNING_SECRET` - `SLACK_BOT_TOKEN` - - `USE_SOCKET_MODE=true` if you want to keep the current Socket Mode setup - - `SLACK_APP_TOKEN` if `USE_SOCKET_MODE=true` - - `DATABASE_URL` for your PostgreSQL connection string - - `DATABASE_SSL_MODE=require` for Azure Database for PostgreSQL - - `DATABASE_CA_CERT_PATH` only if you later choose certificate validation with a custom CA bundle - - `ENABLE_LLM_SUMMARIES=false` unless you add an LLM provider later -3. Download the App Service publish profile from Azure. -4. In GitHub, add: - - Repository variable `AZURE_WEBAPP_NAME` - - Repository secret `AZURE_WEBAPP_PUBLISH_PROFILE` -5. Push to `main`. GitHub Actions will test, package, and deploy GoSignal automatically. - -### Recommended runtime modes - -- Easiest first deployment: keep `USE_SOCKET_MODE=true` and reuse your existing Slack app setup. -- More production-like later: switch to `USE_SOCKET_MODE=false` and point Slack event subscriptions and interactivity to `https://.azurewebsites.net/slack/events`. -- Easiest database rollout: create the web app and Azure Database for PostgreSQL separately, then set `DATABASE_URL` in App Service. - -### Useful checks after deploy - -- Open `https://.azurewebsites.net/` for a plain-text service check. -- Open `https://.azurewebsites.net/healthz` for JSON health output. -- In Azure Log Stream, confirm the app starts with `GoSignal listening on port ...`. -- In Azure Log Stream, confirm you do not see database connection errors after adding `DATABASE_URL`. -- If App Service still falls back to a static page, set the Startup Command to `npm start` in the App Service configuration. - -## Runtime notes - -- v1 is intentionally public-first for retrieval. Real-time Search results are only used when a user-triggered action provides an action token and the context is public-channel safe. -- The canvas is markdown-only by design. Interactive state stays in messages and App Home. -- The default provider is deterministic and evidence-backed. Cerebras is optional for natural summaries and DM answers. + - `SLACK_APP_TOKEN` + - `DATABASE_URL` + - `USE_SOCKET_MODE=true` + - `SLACK_TOKEN_VERIFICATION_ENABLED=true` + - `ENABLE_LLM_SUMMARIES=false` unless you enable the optional LLM layer +4. Create a Render deploy hook for the web service. +5. In GitHub, add the repository secret `RENDER_DEPLOY_HOOK_URL`. +6. Push to `main`. GitHub Actions will verify the repo and then trigger Render. +7. Follow [RENDER_SETUP.md](RENDER_SETUP.md) for the exact walkthrough. + +## Known Limitations + +- The current default install flow is Socket Mode for development. Final + Marketplace-style distribution should switch to HTTP request URLs. +- Search usage is public-first and currently strongest when invoked by a user + action that provides an action token. +- The repo does not yet include final Marketplace submission proof, five active + workspace proof, or a verified hosted health URL. +- Required-role overrides, channel allowlists, and self-service deletion are + still not implemented. +- There is still no self-service deletion UI or automated purge flow. The + current deletion path is documented separately. +- The current proof pack is honest by design: anything still pending manual + capture is marked as pending instead of being presented as complete. diff --git a/RENDER_SETUP.md b/RENDER_SETUP.md new file mode 100644 index 0000000..5247a90 --- /dev/null +++ b/RENDER_SETUP.md @@ -0,0 +1,106 @@ +# Render Setup + +This is the recommended non-Azure hosting path for GoSignal. + +## What This Setup Uses + +- Render Web Service for the Slack app +- Render Postgres or another PostgreSQL provider for persistence +- GitHub Actions for CI +- A Render deploy hook for automatic production deploys after CI passes + +## Files Already Added To This Repo + +- `render.yaml` +- `.node-version` +- `.github/workflows/render-deploy.yml` + +## One-Time Render Setup + +1. Push the current repository to GitHub. +2. In Render, create a new Web Service from the GitHub repo. +3. When Render detects the repo: + - Runtime: `Node` + - Build command: `npm ci && npm run verify` + - Start command: `npm start` + - Health check path: `/healthz` +4. In the same Render workspace, create a PostgreSQL database. +5. Copy the database's internal connection string and set it as + `DATABASE_URL` for the web service. + +## Required Render Environment Variables + +Set these in the Render Dashboard for the GoSignal web service: + +- `SLACK_SIGNING_SECRET` +- `SLACK_BOT_TOKEN` +- `SLACK_APP_TOKEN` +- `DATABASE_URL` +- `USE_SOCKET_MODE=true` +- `SLACK_TOKEN_VERIFICATION_ENABLED=true` +- `ENABLE_LLM_SUMMARIES=false` + +Optional if you want Cerebras summaries: + +- `ENABLE_LLM_SUMMARIES=true` +- `LLM_PROVIDER=cerebras` +- `CEREBRAS_API_KEY` +- `CEREBRAS_MODEL` +- `CEREBRAS_BASE_URL` +- `CEREBRAS_REASONING_EFFORT=none` + +## GitHub Actions Deployment Setup + +The workflow in `.github/workflows/render-deploy.yml` already: + +- runs `npm run verify` +- runs `npm run smoke` +- triggers Render only after CI succeeds + +To connect it: + +1. In Render, open the GoSignal service. +2. Find the service's deploy hook URL. +3. In GitHub, add a repository secret named `RENDER_DEPLOY_HOOK_URL`. +4. Push to `main`. + +After that: + +- every pull request runs CI only +- every successful push to `main` runs CI and then triggers a Render deploy + +## Important Render Setting + +To avoid duplicate deploys, set the Render service's automatic Git deploy +behavior to off if you are using the deploy hook workflow. + +If you prefer Render-native deploys instead of the deploy hook: + +- remove the deploy-hook secret +- set Render to deploy from GitHub automatically +- optionally configure Render to deploy only after CI checks pass + +## Post-Deploy Checks + +After the first Render deploy: + +1. Open the service URL. +2. Visit `/healthz`. +3. Run: + +```bash +npm run proof:hosted -- https://your-render-service.onrender.com +``` + +4. Confirm the Slack bot still responds in your workspace. + +## Recommended First Render Pass + +For an initial demo or hackathon preview: + +- start with the web service plus Postgres +- keep Socket Mode enabled +- keep `ENABLE_LLM_SUMMARIES=false` until the base deploy is healthy + +Once the hosted app is stable, you can re-enable Cerebras and capture the final +proof pack artifacts. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..68f84ba --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,97 @@ +# Security + +This document describes the current security posture of the GoSignal repository +and the remaining gaps before a full production Marketplace posture. + +## Current Controls Implemented In The Repo + +- Secrets are loaded from environment variables, not hardcoded into the app. +- `.env` is gitignored. +- Production health endpoints do not expose tokens, workspace IDs, or secret + values. +- Launch records are stored with workspace-scoped keys. +- Repository queries are workspace-scoped for recent launches, DM lookup, and + name search. +- The readiness engine is deterministic and explainable. +- The optional LLM layer is used for phrasing only, with deterministic + fallback if the provider fails. + +## Slack Data Scope + +Current repository behavior: + +- Public launch thread analysis is the primary supported path. +- Public search evidence is the current search target. +- App DMs are used to ask follow-up questions about already stored launches. + +Current limitations: + +- There is no dedicated UI yet for channel allowlists. +- There is no install-time permissions review surface inside the app. +- Final Marketplace least-privilege scope trimming still needs a manual pass. + +## Token Handling + +Expected environment variables include: + +- `SLACK_SIGNING_SECRET` +- `SLACK_BOT_TOKEN` +- `SLACK_APP_TOKEN` when Socket Mode is enabled +- `DATABASE_URL` when Postgres is enabled +- optional Cerebras settings for the LLM layer + +Current repository truth: + +- Token rotation is still disabled in `manifest.json`. +- There is no encrypted installation store yet. +- Production OAuth redirect management is not yet documented as complete. + +## Storage And Tenant Isolation + +Launch persistence is keyed by workspace and thread identity. + +Current storage paths: + +- In-memory repository for development +- Postgres repository for hosted deployment +- Workspace settings and audit storage for admin-facing controls + +Current known gaps: + +- No dedicated installations table yet +- No self-service workspace deletion UI yet + +## LLM And External Providers + +If Cerebras is enabled: + +- launch summaries and DM answers are generated from structured launch data +- deterministic status remains authoritative +- if the provider fails, GoSignal falls back to deterministic output + +Current limitation: + +- There is no separate policy enforcement layer yet that validates cited + evidence IDs in model output + +## Incident And Support Handling + +Current support path: + +- General support: `https://github.com/BitTriad/GoSignal/issues` + +Security-specific gap: + +- A dedicated private security disclosure contact is still needed before final + production Marketplace submission. +- Until that exists, do not post secrets or live credentials in public issues. + +## Security Gaps Still Open Before Final Submission + +- Dedicated security contact or disclosure email +- Token rotation review +- Encrypted installation store +- Admin deletion path inside the product +- Channel allowlist settings +- Explicit private-channel and unknown-visibility handling surface +- Private security disclosure flow outside public GitHub issues diff --git a/SUBMISSION_CHECKLIST.md b/SUBMISSION_CHECKLIST.md new file mode 100644 index 0000000..60320f7 --- /dev/null +++ b/SUBMISSION_CHECKLIST.md @@ -0,0 +1,65 @@ +# Submission Checklist + +Use this file as the final pre-submit gate for the Track 3 package. + +## Repo-Verified Today + +- [x] `npm run verify` passes +- [x] `npm run smoke` passes +- [x] Production entrypoint starts from `server.js` +- [x] `GET /` exists +- [x] `GET /healthz` exists +- [x] App manifest includes agent view, App Home, message shortcut, canvases, + and `org_deploy_enabled: true` +- [x] Workspace-scoped launch persistence exists +- [x] Optional Cerebras summarization is wired with deterministic fallback +- [x] Track 3 proof-pack docs exist in the repository +- [x] Hosted proof capture command exists +- [x] Launch profiles, owner assignment, history, and export flows exist + +## Manual Evidence Still Required + +- [ ] Marketplace submission screenshot or timestamp +- [ ] Final production App ID if different from `A0BH56L2ETS` +- [ ] Final hosted public URL +- [ ] Verified hosted `/healthz` response +- [ ] Final event request URL +- [ ] Final interactivity request URL +- [ ] Final OAuth redirect URL if used +- [ ] Install proof across five active workspaces +- [ ] Three-minute demo video + +## Judge-Facing Assets + +- [x] README with judge quickstart +- [x] `PROOF_CAPTURE.md` +- [x] `MARKETPLACE.md` +- [x] `ARCHITECTURE.md` +- [x] `SECURITY.md` +- [x] `PRIVACY.md` +- [x] `DATA_RETENTION.md` +- [x] `DEMO_RUNBOOK.md` +- [x] `LICENSE` +- [x] `CONTRIBUTING.md` + +## Recommended Final Submission Bundle + +- public repository link +- App ID +- architecture diagram +- demo video under three minutes +- Marketplace proof +- hosted URL +- Slack sandbox or install path +- honest known limitations + +## Final Preflight Commands + +Run these before shipping a new code revision: + +```bash +npm run verify +npm run smoke +npm run check:llm +npm run proof:hosted -- https://your-final-app-url +``` diff --git a/package.json b/package.json index a8bd461..343aebe 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,12 @@ "dev": "node --experimental-strip-types src/index.ts", "dev:watch": "node --watch --experimental-strip-types src/index.ts", "check:llm": "node --experimental-strip-types src/scripts/checkLlm.ts", + "proof:hosted": "node --experimental-strip-types src/scripts/verifyHosted.ts", "build": "tsc -p tsconfig.build.json", "start": "node server.js", "check": "tsc -p tsconfig.json --noEmit", + "verify": "npm run check && npm test && npm run build && node --check server.js", + "smoke": "node --experimental-strip-types src/scripts/smoke.ts", "test": "node --experimental-strip-types --test test/**/*.test.ts" }, "dependencies": { diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..109aa1c --- /dev/null +++ b/render.yaml @@ -0,0 +1,28 @@ +services: + - type: web + name: gosignal + runtime: node + healthCheckPath: /healthz + buildCommand: npm ci && npm run verify + startCommand: npm start + envVars: + - key: NODE_VERSION + value: "22" + - key: USE_SOCKET_MODE + value: "true" + - key: SLACK_TOKEN_VERIFICATION_ENABLED + value: "true" + - key: ENABLE_LLM_SUMMARIES + value: "false" + - key: LLM_PROVIDER + value: deterministic + - key: DATABASE_URL + sync: false + - key: SLACK_SIGNING_SECRET + sync: false + - key: SLACK_BOT_TOKEN + sync: false + - key: SLACK_APP_TOKEN + sync: false + - key: CEREBRAS_API_KEY + sync: false diff --git a/src/app.ts b/src/app.ts index 73f6a36..2031443 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,58 +1,73 @@ import { App } from "@slack/bolt"; -import type { AppConfig } from "./config.ts"; +import { describeResponseMode, type AppConfig } from "./config.ts"; import { MemoryContextStore } from "./repositories/contextStore.ts"; import { MemoryLaunchRepository } from "./repositories/memoryLaunchRepository.ts"; import { PostgresLaunchRepository } from "./repositories/postgresLaunchRepository.ts"; import type { LaunchRepository } from "./repositories/launchRepository.ts"; +import { + MemoryWorkspaceAdminRepository, + PostgresWorkspaceAdminRepository +} from "./repositories/workspaceAdminRepository.ts"; import { registerHandlers } from "./handlers/registerHandlers.ts"; import { HomeService } from "./services/homeService.ts"; import { LaunchService } from "./services/launchService.ts"; import { CerebrasLLMProvider, DeterministicSummaryProvider, type LLMProvider } from "./services/llmProvider.ts"; +import { buildCustomRoutes } from "./http/customRoutes.ts"; import { SlackCanvasGateway, SlackSearchSource, SlackThreadSource } from "./services/slackSources.ts"; +import { WorkspaceAdminService } from "./services/workspaceAdminService.ts"; export async function createGoSignalApp(config: AppConfig): Promise<{ app: App; repository: LaunchRepository }> { const repository = config.databaseUrl ? new PostgresLaunchRepository(config.databaseUrl) : new MemoryLaunchRepository(); + const workspaceAdminRepository = config.databaseUrl + ? new PostgresWorkspaceAdminRepository(config.databaseUrl) + : new MemoryWorkspaceAdminRepository(); if (repository instanceof PostgresLaunchRepository) { await repository.initialize(); } + if (workspaceAdminRepository instanceof PostgresWorkspaceAdminRepository) { + await workspaceAdminRepository.initialize(); + } if (config.useSocketMode && !config.slackAppToken) { throw new Error("SLACK_APP_TOKEN is required when USE_SOCKET_MODE=true"); } const summaryProvider = createSummaryProvider(config); + const workspaceAdminService = new WorkspaceAdminService({ + settingsRepository: workspaceAdminRepository, + auditRepository: workspaceAdminRepository + }); - const appOptions = { + const app = new App({ token: config.slackBotToken, signingSecret: config.slackSigningSecret, - socketMode: config.useSocketMode - } as { - token: string; - signingSecret: string; - socketMode: boolean; - appToken?: string; - }; - if (config.slackAppToken) { - appOptions.appToken = config.slackAppToken; - } - - const app = new App(appOptions); + socketMode: config.useSocketMode, + tokenVerificationEnabled: config.slackTokenVerificationEnabled, + customRoutes: buildCustomRoutes(config), + ...(config.slackAppToken ? { appToken: config.slackAppToken } : {}) + }); const launchService = new LaunchService({ repository, threadSource: new SlackThreadSource(), searchSource: new SlackSearchSource(), canvasGateway: new SlackCanvasGateway(), - summaryProvider + summaryProvider, + workspaceAdminService }); registerHandlers(app, { contextStore: new MemoryContextStore(), launchService, - homeService: new HomeService(launchService) + homeService: new HomeService({ + launchService, + workspaceAdminService, + responseMode: describeResponseMode(config) + }), + workspaceAdminService }); return { diff --git a/src/config.ts b/src/config.ts index ab6c483..c9486a1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,7 @@ export interface AppConfig { slackSigningSecret: string; slackBotToken: string; slackAppToken: string | undefined; + slackTokenVerificationEnabled: boolean; databaseUrl: string | undefined; useSocketMode: boolean; enableLlmSummaries: boolean; @@ -156,6 +157,7 @@ export function loadConfig(): AppConfig { slackSigningSecret: requireEnv("SLACK_SIGNING_SECRET"), slackBotToken: requireEnv("SLACK_BOT_TOKEN"), slackAppToken: process.env.SLACK_APP_TOKEN, + slackTokenVerificationEnabled: parseBoolean(process.env.SLACK_TOKEN_VERIFICATION_ENABLED, true), databaseUrl: process.env.DATABASE_URL, useSocketMode: parseBoolean(process.env.USE_SOCKET_MODE, false), enableLlmSummaries: parseBoolean(process.env.ENABLE_LLM_SUMMARIES, false), diff --git a/src/domain/constants.ts b/src/domain/constants.ts index 0a31f2e..faded54 100644 --- a/src/domain/constants.ts +++ b/src/domain/constants.ts @@ -1,4 +1,4 @@ -import type { ConfidenceLevel, ReadinessState } from "./types.ts"; +import type { ConfidenceLevel, LaunchProfileId, ReadinessState, Severity } from "./types.ts"; export const READINESS_CATEGORIES = [ "Engineering", @@ -9,12 +9,149 @@ export const READINESS_CATEGORIES = [ "Dependencies" ] as const; -export const REQUIRED_APPROVAL_ROLES = [ - "engineering lead", - "qa lead", - "ops lead", - "support readiness" -] as const; +export const DEFAULT_LAUNCH_PROFILE: LaunchProfileId = "saas_release"; + +export interface ProfileEvidenceRequirementDefinition { + id: string; + label: string; + categoryName: typeof READINESS_CATEGORIES[number]; + severity: Severity; + matchPhrases: readonly string[]; +} + +export interface LaunchProfileDefinition { + id: LaunchProfileId; + label: string; + description: string; + requiredApprovalRoles: readonly string[]; + requiredEvidence: readonly ProfileEvidenceRequirementDefinition[]; + heuristicHints: readonly string[]; +} + +const ROLLBACK_PLAN_REQUIREMENT: ProfileEvidenceRequirementDefinition = { + id: "rollback_plan", + label: "Rollback plan", + categoryName: "Operations", + severity: "high", + matchPhrases: ["rollback documented", "rollback posted", "rollback confirmed", "rollback plan is ready", "rollback is ready"] +}; + +const RELEASE_NOTES_REQUIREMENT: ProfileEvidenceRequirementDefinition = { + id: "release_notes", + label: "Release notes", + categoryName: "Comms", + severity: "medium", + matchPhrases: ["release notes ready", "release notes posted", "release notes shared", "release note is ready"] +}; + +const ON_CALL_OWNER_REQUIREMENT: ProfileEvidenceRequirementDefinition = { + id: "owner_on_call", + label: "On-call owner", + categoryName: "Operations", + severity: "medium", + matchPhrases: ["on-call owner", "owner on call", "primary on call", "pager duty owner", "pagerduty owner"] +}; + +const MONITORING_DASHBOARD_REQUIREMENT: ProfileEvidenceRequirementDefinition = { + id: "monitoring_dashboard", + label: "Monitoring dashboard", + categoryName: "Operations", + severity: "medium", + matchPhrases: ["monitoring dashboard", "dashboard ready", "metrics dashboard", "observability dashboard"] +}; + +const MIGRATION_WINDOW_REQUIREMENT: ProfileEvidenceRequirementDefinition = { + id: "migration_window", + label: "Migration window", + categoryName: "Dependencies", + severity: "medium", + matchPhrases: ["migration window", "cutover window", "maintenance window", "migration scheduled"] +}; + +const CUSTOMER_COMMUNICATION_REQUIREMENT: ProfileEvidenceRequirementDefinition = { + id: "customer_communication", + label: "Customer communication", + categoryName: "Comms", + severity: "medium", + matchPhrases: ["customer communication", "customer email sent", "customer notice posted", "customer update ready"] +}; + +const SUPPORT_RUNBOOK_REQUIREMENT: ProfileEvidenceRequirementDefinition = { + id: "support_runbook", + label: "Support runbook", + categoryName: "Comms", + severity: "medium", + matchPhrases: ["support runbook", "support playbook", "support macro ready", "support faq ready"] +}; + +const INCIDENT_WATCH_REQUIREMENT: ProfileEvidenceRequirementDefinition = { + id: "incident_watch", + label: "Incident watch", + categoryName: "Operations", + severity: "medium", + matchPhrases: ["incident watch", "war room ready", "watch rotation", "incident room ready"] +}; + +const APP_STORE_ROLLOUT_REQUIREMENT: ProfileEvidenceRequirementDefinition = { + id: "app_store_rollout", + label: "App store rollout plan", + categoryName: "Dependencies", + severity: "medium", + matchPhrases: ["app store rollout", "play store rollout", "app store review approved", "rollout percentage set"] +}; + +export const LAUNCH_PROFILE_DEFINITIONS: Record = { + saas_release: { + id: "saas_release", + label: "SaaS release", + description: "General web or backend release with engineering, QA, ops, and support coordination.", + requiredApprovalRoles: ["engineering lead", "qa lead", "ops lead", "support readiness"], + requiredEvidence: [ROLLBACK_PLAN_REQUIREMENT, RELEASE_NOTES_REQUIREMENT, ON_CALL_OWNER_REQUIREMENT], + heuristicHints: ["release", "rollout", "checkout", "saas", "web"] + }, + mobile_release: { + id: "mobile_release", + label: "Mobile release", + description: "Mobile app rollout with QA, support, and app store coordination.", + requiredApprovalRoles: ["engineering lead", "qa lead", "ops lead", "support readiness", "marketing"], + requiredEvidence: [ROLLBACK_PLAN_REQUIREMENT, RELEASE_NOTES_REQUIREMENT, APP_STORE_ROLLOUT_REQUIREMENT, ON_CALL_OWNER_REQUIREMENT], + heuristicHints: ["mobile", "android", "ios", "app store", "play store"] + }, + infrastructure_migration: { + id: "infrastructure_migration", + label: "Infrastructure migration", + description: "Migration or cutover that needs operations, security, and monitoring readiness.", + requiredApprovalRoles: ["engineering lead", "ops lead", "security", "support readiness"], + requiredEvidence: [ROLLBACK_PLAN_REQUIREMENT, MONITORING_DASHBOARD_REQUIREMENT, MIGRATION_WINDOW_REQUIREMENT, ON_CALL_OWNER_REQUIREMENT], + heuristicHints: ["migration", "cutover", "infra", "database", "cluster"] + }, + customer_migration: { + id: "customer_migration", + label: "Customer migration", + description: "Customer-facing migration with communication and support readiness requirements.", + requiredApprovalRoles: ["engineering lead", "ops lead", "support readiness", "customer success"], + requiredEvidence: [ROLLBACK_PLAN_REQUIREMENT, CUSTOMER_COMMUNICATION_REQUIREMENT, MIGRATION_WINDOW_REQUIREMENT, SUPPORT_RUNBOOK_REQUIREMENT], + heuristicHints: ["customer migration", "tenant migration", "data migration", "customer move"] + }, + security_patch: { + id: "security_patch", + label: "Security patch", + description: "Security fix or hot patch that must show security review and incident watch readiness.", + requiredApprovalRoles: ["engineering lead", "ops lead", "security"], + requiredEvidence: [ROLLBACK_PLAN_REQUIREMENT, MONITORING_DASHBOARD_REQUIREMENT, INCIDENT_WATCH_REQUIREMENT], + heuristicHints: ["security patch", "cve", "hotfix", "vulnerability", "security release"] + }, + marketing_launch: { + id: "marketing_launch", + label: "Marketing launch", + description: "Campaign or announcement launch centered on comms, support, and release collateral.", + requiredApprovalRoles: ["engineering lead", "marketing", "support readiness"], + requiredEvidence: [CUSTOMER_COMMUNICATION_REQUIREMENT, RELEASE_NOTES_REQUIREMENT, SUPPORT_RUNBOOK_REQUIREMENT], + heuristicHints: ["campaign", "marketing", "announcement", "press", "landing page"] + } +}; + +export const REQUIRED_APPROVAL_ROLES = LAUNCH_PROFILE_DEFINITIONS[DEFAULT_LAUNCH_PROFILE].requiredApprovalRoles; export function titleCaseRole(roleName: string): string { return roleName diff --git a/src/domain/readiness.ts b/src/domain/readiness.ts index 314863b..98142d0 100644 --- a/src/domain/readiness.ts +++ b/src/domain/readiness.ts @@ -1,8 +1,11 @@ import { randomUUID } from "node:crypto"; import { buildApprovalReplyExample, + DEFAULT_LAUNCH_PROFILE, + LAUNCH_PROFILE_DEFINITIONS, READINESS_CATEGORIES, - REQUIRED_APPROVAL_ROLES, + type LaunchProfileDefinition, + type ProfileEvidenceRequirementDefinition, STATE_PRIORITY, summarizeConfidence } from "./constants.ts"; @@ -15,9 +18,12 @@ import type { EvidenceFreshness, EvidenceItem, LaunchKey, + LaunchProfileId, LaunchRecord, + LaunchRequirementCheck, ReadinessCategory, ReadinessState, + RoleOwnerAssignment, SearchEvidenceRecord, SlackMessageRecord } from "./types.ts"; @@ -26,6 +32,7 @@ interface EvaluateLaunchInput { key: LaunchKey; name: string; createdByUserId: string; + launchProfile: LaunchProfileId; threadMessages: SlackMessageRecord[]; searchEvidence: SearchEvidenceRecord[]; existingLaunch?: LaunchRecord; @@ -58,8 +65,12 @@ function summarizeText(text: string): string { return `${trimmed.slice(0, 137)}...`; } +function normalizeForMatch(text: string): string { + return text.toLowerCase().replace(/\s+/g, " ").trim(); +} + function looksLikeGoSignalOutput(text: string): boolean { - const normalized = text.replace(/\s+/g, " ").trim().toLowerCase(); + const normalized = normalizeForMatch(text); return ( normalized.startsWith("gosignal readiness:") || normalized.startsWith("gosignal still needs") || @@ -142,6 +153,7 @@ function normalizeEvidence( summary: summarizeText(result.text), permalink: result.permalink, channelId: result.channelId, + channelName: result.channelName, messageTs: result.messageTs, createdAt: result.createdAt, categoryName, @@ -155,8 +167,8 @@ function normalizeEvidence( return items.sort((left, right) => right.score - left.score); } -function buildApprovals(evidence: EvidenceItem[]): ApprovalRequirement[] { - return REQUIRED_APPROVAL_ROLES.map((roleName) => { +function buildApprovals(evidence: EvidenceItem[], requiredApprovalRoles: readonly string[]): ApprovalRequirement[] { + return requiredApprovalRoles.map((roleName) => { const positive = evidence.find((item) => item.signals.positiveApprovalRoles.includes(roleName)); if (positive) { return { @@ -188,7 +200,46 @@ function buildApprovals(evidence: EvidenceItem[]): ApprovalRequirement[] { }); } -function buildBlockers(evidence: EvidenceItem[]): Blocker[] { +function buildRequirementChecks( + evidence: EvidenceItem[], + profile: LaunchProfileDefinition +): LaunchRequirementCheck[] { + return profile.requiredEvidence.map((requirement) => { + const matchingEvidence = evidence.filter((item) => matchesRequirement(item, requirement)); + if (matchingEvidence.length > 0) { + return { + requirementId: requirement.id, + label: requirement.label, + categoryName: requirement.categoryName, + state: "met", + reason: `${requirement.label} is explicitly present in Slack evidence.`, + evidenceIds: matchingEvidence.map((item) => item.id), + severity: requirement.severity + } satisfies LaunchRequirementCheck; + } + + return { + requirementId: requirement.id, + label: requirement.label, + categoryName: requirement.categoryName, + state: "missing", + reason: `${requirement.label} is required for the ${profile.label.toLowerCase()} profile but no explicit evidence was found.`, + evidenceIds: [], + severity: requirement.severity + } satisfies LaunchRequirementCheck; + }); +} + +function matchesRequirement(item: EvidenceItem, requirement: ProfileEvidenceRequirementDefinition): boolean { + const text = normalizeForMatch(`${item.title} ${item.text}`); + if (requirement.id === "rollback_plan") { + return item.signals.rollbackReady || requirement.matchPhrases.some((phrase) => text.includes(phrase)); + } + + return requirement.matchPhrases.some((phrase) => text.includes(phrase)); +} + +function buildBlockers(evidence: EvidenceItem[], requirementChecks: LaunchRequirementCheck[]): Blocker[] { const blockers: Blocker[] = []; const seenTitles = new Set(); @@ -224,6 +275,25 @@ function buildBlockers(evidence: EvidenceItem[]): Blocker[] { }); } + for (const requirement of requirementChecks.filter((check) => check.state === "missing")) { + const title = `${requirement.label} missing`; + const key = `${requirement.categoryName}:${title}`; + if (seenTitles.has(key)) { + continue; + } + seenTitles.add(key); + + blockers.push({ + id: `blocker-${randomUUID()}`, + categoryName: requirement.categoryName, + title, + description: requirement.reason, + severity: requirement.severity, + status: "open", + evidenceIds: requirement.evidenceIds + }); + } + return blockers.sort( (left, right) => STATE_PRIORITY.indexOf(mapSeverityToState(left.severity)) - STATE_PRIORITY.indexOf(mapSeverityToState(right.severity)) ); @@ -245,12 +315,16 @@ function determineCategoryState( categoryName: string, relevantEvidence: EvidenceItem[], blockers: Blocker[], - approvals: ApprovalRequirement[] + approvals: ApprovalRequirement[], + requirementChecks: LaunchRequirementCheck[] ): ReadinessCategory { const openBlockers = blockers.filter((blocker) => blocker.categoryName === categoryName && blocker.status === "open"); const missingApprovals = categoryName === "Approvals" ? approvals.filter((approval) => approval.state !== "approved").map((approval) => approval.roleName) : []; + const missingRequirements = requirementChecks + .filter((requirement) => requirement.categoryName === categoryName && requirement.state !== "met") + .map((requirement) => requirement.label); const hasAmbiguity = relevantEvidence.some((item) => item.signals.ambiguous); const hasFreshEvidence = relevantEvidence.some((item) => item.freshness === "fresh"); @@ -259,6 +333,8 @@ function determineCategoryState( state = "red"; } else if (missingApprovals.length > 0) { state = hasAmbiguity ? "needs_review" : "yellow"; + } else if (missingRequirements.length > 0) { + state = "yellow"; } else if (hasAmbiguity) { state = "needs_review"; } else if (relevantEvidence.length === 0) { @@ -273,7 +349,8 @@ function determineCategoryState( (hasFreshEvidence ? 40 : 20) + Math.min(relevantEvidence.length * 15, 45) - (hasAmbiguity ? 20 : 0) - - (missingApprovals.length > 0 ? 10 : 0); + (missingApprovals.length > 0 ? 10 : 0) - + (missingRequirements.length > 0 ? 10 : 0); const confidence = summarizeConfidence(confidenceScore); @@ -285,7 +362,7 @@ function determineCategoryState( blockerCount: openBlockers.length, missingApprovalRoles: missingApprovals, evidenceIds: relevantEvidence.map((item) => item.id), - summary: summarizeCategory(categoryName, state, openBlockers, relevantEvidence, missingApprovals) + summary: summarizeCategory(categoryName, state, openBlockers, relevantEvidence, missingApprovals, missingRequirements) }; } @@ -294,7 +371,8 @@ function summarizeCategory( state: ReadinessState, blockers: Blocker[], evidence: EvidenceItem[], - missingApprovals: string[] + missingApprovals: string[], + missingRequirements: string[] ): string { if (blockers.length > 0) { return `${categoryName} is ${state} because ${blockers[0]?.title.toLowerCase()}.`; @@ -302,6 +380,9 @@ function summarizeCategory( if (missingApprovals.length > 0) { return `${categoryName} is ${state} because ${missingApprovals.join(", ")} is still missing.`; } + if (missingRequirements.length > 0) { + return `${categoryName} is ${state} because ${missingRequirements.join(", ")} still needs explicit evidence.`; + } if (evidence.length === 0) { return `${categoryName} needs review because no explicit evidence was found.`; } @@ -311,7 +392,12 @@ function summarizeCategory( return `${categoryName} is ${state} based on explicit Slack evidence.`; } -function determineOverallState(allCategories: ReadinessCategory[], approvals: ApprovalRequirement[], blockers: Blocker[]): ReadinessState { +function determineOverallState( + allCategories: ReadinessCategory[], + approvals: ApprovalRequirement[], + blockers: Blocker[], + requirementChecks: LaunchRequirementCheck[] +): ReadinessState { const openHighBlocker = blockers.some((blocker) => blocker.status === "open" && (blocker.severity === "critical" || blocker.severity === "high")); if (openHighBlocker || allCategories.some((category) => category.state === "red")) { return "red"; @@ -319,7 +405,11 @@ function determineOverallState(allCategories: ReadinessCategory[], approvals: Ap if (allCategories.some((category) => category.state === "needs_review")) { return "needs_review"; } - if (approvals.some((approval) => approval.state !== "approved") || allCategories.some((category) => category.state === "yellow")) { + if ( + approvals.some((approval) => approval.state !== "approved") || + requirementChecks.some((requirement) => requirement.state !== "met") || + allCategories.some((category) => category.state === "yellow") + ) { return "yellow"; } return "green"; @@ -328,10 +418,15 @@ function determineOverallState(allCategories: ReadinessCategory[], approvals: Ap function determineRecommendation( overallState: ReadinessState, blockers: Blocker[], - approvals: ApprovalRequirement[] + approvals: ApprovalRequirement[], + requirementChecks: LaunchRequirementCheck[], + ownerAssignments: RoleOwnerAssignment[] ): { recommendation: string; nextAction: string; rationale: string[] } { const topBlocker = blockers.find((blocker) => blocker.status === "open"); const missingApproval = approvals.find((approval) => approval.state !== "approved"); + const missingRequirement = requirementChecks.find((requirement) => requirement.state !== "met"); + const missingApprovalOwner = + missingApproval ? ownerAssignments.find((assignment) => assignment.roleName === missingApproval.roleName) : undefined; if (overallState === "red" && topBlocker) { return { @@ -359,10 +454,25 @@ function determineRecommendation( if (missingApproval) { return { recommendation: "Proceed with caution.", - nextAction: `Request ${missingApproval.roleName} before recommending green.`, + nextAction: missingApprovalOwner + ? `Remind <@${missingApprovalOwner.userId}> to secure ${missingApproval.roleName}.` + : `Request ${missingApproval.roleName} before recommending green.`, rationale: [ `${missingApproval.roleName} is still missing.`, - "The launch appears close, but sign-off is incomplete." + missingApprovalOwner + ? `${missingApproval.roleName} is assigned to <@${missingApprovalOwner.userId}> for follow-up.` + : "The launch appears close, but sign-off is incomplete." + ] + }; + } + + if (missingRequirement) { + return { + recommendation: "Proceed with caution.", + nextAction: `Add explicit evidence for ${missingRequirement.label.toLowerCase()} and re-run readiness.`, + rationale: [ + `${missingRequirement.label} is required for the selected launch profile.`, + "The launch is missing profile-specific evidence even though no hard blocker was detected." ] }; } @@ -383,7 +493,7 @@ function determineRecommendation( recommendation: "Ready to launch.", nextAction: "Keep monitoring the thread and re-run if the context changes.", rationale: [ - "All required approvals were found in Slack evidence.", + "All required approvals and profile-specific evidence checks are satisfied.", "No open blockers remain in the monitored launch thread." ] }; @@ -394,10 +504,12 @@ function buildSummary( overallState: ReadinessState, confidence: ConfidenceLevel, blockers: Blocker[], - approvals: ApprovalRequirement[] + approvals: ApprovalRequirement[], + requirementChecks: LaunchRequirementCheck[] ): string { const topBlocker = blockers.find((blocker) => blocker.status === "open"); const missingApproval = approvals.find((approval) => approval.state !== "approved"); + const missingRequirement = requirementChecks.find((requirement) => requirement.state !== "met"); if (overallState === "red" && topBlocker) { return `${name} is ${overallState} with ${confidence} confidence because ${topBlocker.title.toLowerCase()} is still unresolved.`; @@ -409,12 +521,15 @@ function buildSummary( if (missingApproval) { return `${name} is yellow with ${confidence} confidence because ${missingApproval.roleName} is still missing.`; } + if (missingRequirement) { + return `${name} is yellow with ${confidence} confidence because ${missingRequirement.label.toLowerCase()} still needs explicit evidence.`; + } if (topBlocker) { return `${name} is yellow with ${confidence} confidence because ${topBlocker.title.toLowerCase()} still needs follow-up.`; } return `${name} is yellow with ${confidence} confidence because one or more readiness areas are still incomplete.`; } - return `${name} is green with ${confidence} confidence based on explicit approvals and no open blockers.`; + return `${name} is green with ${confidence} confidence based on explicit approvals and profile-ready evidence.`; } function findLaunchLabel(threadMessages: SlackMessageRecord[], fallback?: string): string { @@ -431,6 +546,25 @@ function findLaunchLabel(threadMessages: SlackMessageRecord[], fallback?: string return fallback ?? threadMessages[0]?.text ?? "Untitled launch"; } +function normalizeProfileLabel(value: string): string { + return normalizeForMatch(value) + .replace(/launch profile:\s*/g, "") + .replace(/profile:\s*/g, ""); +} + +function resolveProfileFromText(value: string): LaunchProfileId | undefined { + const normalized = normalizeProfileLabel(value); + for (const [profileId, profile] of Object.entries(LAUNCH_PROFILE_DEFINITIONS) as Array<[LaunchProfileId, LaunchProfileDefinition]>) { + if (normalized === normalizeForMatch(profile.label) || normalized === profileId.replace(/_/g, " ")) { + return profileId; + } + if (profile.heuristicHints.some((hint) => normalized.includes(normalizeForMatch(hint)))) { + return profileId; + } + } + return undefined; +} + export function deriveLaunchName(threadMessages: SlackMessageRecord[], fallback?: string): string { const root = findLaunchLabel(threadMessages, fallback); const normalized = root.replace(/\s+/g, " ").trim(); @@ -440,29 +574,69 @@ export function deriveLaunchName(threadMessages: SlackMessageRecord[], fallback? return `${normalized.slice(0, 67)}...`; } -export function buildSearchQuery(name: string): string { - return `Find public Slack evidence for ${name}. Look for blockers, rollback status, QA regressions, dependency risks, and explicit approvals.`; +export function deriveLaunchProfile( + threadMessages: SlackMessageRecord[], + defaultProfile: LaunchProfileId = DEFAULT_LAUNCH_PROFILE, + fallback?: LaunchProfileId +): LaunchProfileId { + for (const message of threadMessages) { + const lines = message.text.split("\n"); + for (const line of lines) { + const explicitMatch = line.match(/^\s*(?:launch\s+)?profile:\s*(.+?)\s*$/i); + if (explicitMatch?.[1]) { + return resolveProfileFromText(explicitMatch[1]) ?? fallback ?? defaultProfile; + } + } + } + + const combinedText = normalizeForMatch(threadMessages.map((message) => message.text).join("\n")); + for (const [profileId, profile] of Object.entries(LAUNCH_PROFILE_DEFINITIONS) as Array<[LaunchProfileId, LaunchProfileDefinition]>) { + if (profile.heuristicHints.some((hint) => combinedText.includes(normalizeForMatch(hint)))) { + return profileId; + } + } + + return fallback ?? defaultProfile; +} + +export function buildSearchQuery(name: string, launchProfile: LaunchProfileId = DEFAULT_LAUNCH_PROFILE): string { + const profile = LAUNCH_PROFILE_DEFINITIONS[launchProfile]; + return ( + `Find public Slack evidence for ${name}. ` + + `This is a ${profile.label.toLowerCase()} launch. ` + + `Look for blockers, profile-specific requirements, rollback status, dependency risks, and explicit approvals.` + ); } export function evaluateLaunchReadiness(input: EvaluateLaunchInput): LaunchRecord { const now = input.now ?? new Date(); + const profile = LAUNCH_PROFILE_DEFINITIONS[input.launchProfile]; const evidence = normalizeEvidence(input.threadMessages, input.searchEvidence, now); - const approvals = buildApprovals(evidence); - const blockers = buildBlockers(evidence); + const approvals = buildApprovals(evidence, profile.requiredApprovalRoles); + const requirementChecks = buildRequirementChecks(evidence, profile); + const ownerAssignments = input.existingLaunch?.ownerAssignments ?? []; + const blockers = buildBlockers(evidence, requirementChecks); const categories = READINESS_CATEGORIES.map((categoryName) => determineCategoryState( categoryName, evidence.filter((item) => item.categoryName === categoryName), blockers, - approvals + approvals, + requirementChecks ) ); - const overallState = determineOverallState(categories, approvals, blockers); + const overallState = determineOverallState(categories, approvals, blockers, requirementChecks); const confidenceScore = categories.reduce((total, category) => total + (category.confidence === "high" ? 30 : category.confidence === "medium" ? 20 : 10), 0) / Math.max(categories.length, 1); const confidence = summarizeConfidence(confidenceScore); - const { recommendation, nextAction, rationale } = determineRecommendation(overallState, blockers, approvals); + const { recommendation, nextAction, rationale } = determineRecommendation( + overallState, + blockers, + approvals, + requirementChecks, + ownerAssignments + ); const decision: DecisionSnapshot = { id: `decision-${randomUUID()}`, @@ -470,9 +644,9 @@ export function evaluateLaunchReadiness(input: EvaluateLaunchInput): LaunchRecor overallState, confidence, recommendation, - summary: buildSummary(input.name, overallState, confidence, blockers, approvals), + summary: buildSummary(input.name, overallState, confidence, blockers, approvals, requirementChecks), nextAction, - rationale, + rationale: [`Profile: ${profile.label}.`, ...rationale], blockerIds: blockers.filter((blocker) => blocker.status === "open").map((blocker) => blocker.id) }; @@ -484,10 +658,13 @@ export function evaluateLaunchReadiness(input: EvaluateLaunchInput): LaunchRecor name: input.name, createdByUserId: input.createdByUserId, status: overallState === "green" ? "ready" : overallState === "red" ? "hold" : "active", + launchProfile: input.launchProfile, canvasId: input.existingLaunch?.canvasId, canvasLinkLabel: input.existingLaunch?.canvasLinkLabel, categories, approvals, + requirementChecks, + ownerAssignments, blockers, evidence, decision, diff --git a/src/domain/textSignals.ts b/src/domain/textSignals.ts index cd9be2c..fcd8d20 100644 --- a/src/domain/textSignals.ts +++ b/src/domain/textSignals.ts @@ -16,7 +16,9 @@ const ROLE_ALIASES: Record = { "qa lead": ["qa lead", "qa", "quality lead", "quality", "test lead", "test"], "ops lead": ["ops lead", "ops", "operations lead", "operations", "sre lead", "sre"], "support readiness": ["support readiness", "support", "cx", "customer support"], - marketing: ["marketing lead", "marketing", "comms lead", "comms", "communications"] + marketing: ["marketing lead", "marketing", "comms lead", "comms", "communications"], + security: ["security lead", "security", "sec lead", "security review"], + "customer success": ["customer success", "cs lead", "success lead", "account team"] }; function includesAny(text: string, phrases: string[]): boolean { diff --git a/src/domain/types.ts b/src/domain/types.ts index 7bf520a..b6659bb 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -4,6 +4,28 @@ export type EvidenceFreshness = "fresh" | "stale" | "unknown"; export type EvidenceSourceType = "thread_message" | "search_message" | "search_file" | "search_channel"; export type ApprovalState = "approved" | "missing" | "blocked" | "needs_review"; export type Severity = "low" | "medium" | "high" | "critical"; +export type SearchEvidenceStatus = "used" | "empty" | "unavailable"; +export type WorkspaceSearchMode = "public_only" | "thread_only"; +export type LaunchStatus = "draft" | "active" | "hold" | "ready"; +export type LaunchProfileId = + | "saas_release" + | "mobile_release" + | "infrastructure_migration" + | "customer_migration" + | "security_patch" + | "marketing_launch"; +export type RequirementState = "met" | "missing" | "needs_review"; +export type AuditEventType = + | "launch_analyzed" + | "launch_rerun" + | "signoff_requested" + | "launch_opened" + | "canvas_opened" + | "workspace_settings_updated" + | "owner_assigned" + | "owner_reminded" + | "launch_exported" + | "launch_history_viewed"; export interface LaunchKey { workspaceId: string; @@ -29,11 +51,47 @@ export interface SearchEvidenceRecord { text: string; permalink?: string; channelId?: string; + channelName?: string; messageTs?: string; createdAt?: string; rawScore?: number; } +export interface SearchDiagnostics { + status: SearchEvidenceStatus; + note: string; + resultCount: number; + messageCount: number; + fileCount: number; + channelCount: number; +} + +export interface SearchContextResult { + evidence: SearchEvidenceRecord[]; + diagnostics: SearchDiagnostics; +} + +export interface WorkspaceSettingsRecord { + workspaceId: string; + searchMode: WorkspaceSearchMode; + auditRetentionDays: number; + defaultLaunchProfile: LaunchProfileId; + updatedByUserId: string; + createdAt: string; + updatedAt: string; +} + +export interface AuditEventRecord { + id: string; + workspaceId: string; + actorUserId: string; + eventType: AuditEventType; + summary: string; + launchId?: string; + metadata?: Record; + createdAt: string; +} + export interface EvidenceSignalSet { explicitHold: boolean; blocker: boolean; @@ -56,6 +114,7 @@ export interface EvidenceItem { summary: string; permalink?: string; channelId?: string; + channelName?: string; messageTs?: string; createdAt?: string; categoryName: string; @@ -65,6 +124,25 @@ export interface EvidenceItem { signals: EvidenceSignalSet; } +export interface LaunchRequirementCheck { + requirementId: string; + label: string; + categoryName: string; + state: RequirementState; + reason: string; + evidenceIds: string[]; + severity: Severity; +} + +export interface RoleOwnerAssignment { + roleName: string; + userId: string; + assignedByUserId: string; + assignedAt: string; + lastRemindedAt?: string; + reminderCount: number; +} + export interface ApprovalRequirement { roleName: string; state: ApprovalState; @@ -111,15 +189,19 @@ export interface LaunchRecord extends LaunchKey { id: string; name: string; createdByUserId: string; - status: "draft" | "active" | "hold" | "ready"; + status: LaunchStatus; + launchProfile: LaunchProfileId; canvasId?: string; canvasLinkLabel?: string; categories: ReadinessCategory[]; approvals: ApprovalRequirement[]; + requirementChecks: LaunchRequirementCheck[]; + ownerAssignments: RoleOwnerAssignment[]; blockers: Blocker[]; evidence: EvidenceItem[]; decision: DecisionSnapshot; searchQuery?: string; + searchDiagnostics?: SearchDiagnostics; createdAt: string; updatedAt: string; } diff --git a/src/handlers/registerHandlers.ts b/src/handlers/registerHandlers.ts index 0403912..4036231 100644 --- a/src/handlers/registerHandlers.ts +++ b/src/handlers/registerHandlers.ts @@ -3,12 +3,21 @@ import type { ContextStore } from "../repositories/contextStore.ts"; import type { LaunchService } from "../services/launchService.ts"; import type { HomeService } from "../services/homeService.ts"; import { buildDmReplyBlocks, buildLaunchBlocks, buildSignoffRequestText } from "../ui/blocks.ts"; -import type { AppContextSnapshot, LaunchRecord } from "../domain/types.ts"; +import { buildWorkspaceSettingsModal } from "../ui/appHome.ts"; +import { LAUNCH_PROFILE_DEFINITIONS } from "../domain/constants.ts"; +import type { AppContextSnapshot, LaunchProfileId, LaunchRecord, WorkspaceSearchMode } from "../domain/types.ts"; +import type { WorkspaceAdminService } from "../services/workspaceAdminService.ts"; +import { + buildLaunchExportModal, + buildLaunchHistoryModal, + buildOwnerAssignmentModal +} from "../ui/launchModals.ts"; interface GoSignalHandlersDependencies { contextStore: ContextStore; launchService: LaunchService; homeService: HomeService; + workspaceAdminService: WorkspaceAdminService; } function extractActionToken(payload: Record): string | undefined { @@ -77,6 +86,24 @@ function extractActionThreadTarget(payload: Record): { channelI }; } +function parseActionValue(value: unknown): { launchId?: string; roleName?: string } { + if (typeof value !== "string") { + return {}; + } + + try { + const parsed = JSON.parse(value) as Record; + return { + launchId: typeof parsed.launchId === "string" ? parsed.launchId : undefined, + roleName: typeof parsed.roleName === "string" ? parsed.roleName : undefined + }; + } catch { + return { + launchId: value + }; + } +} + async function resolveLaunchFromAction( client: any, payload: Record, @@ -84,7 +111,7 @@ async function resolveLaunchFromAction( launchService: LaunchService ): Promise { const action = Array.isArray(payload.actions) ? payload.actions[0] as Record | undefined : undefined; - const launchId = action && typeof action.value === "string" ? action.value : undefined; + const launchId = action ? parseActionValue(action.value).launchId : undefined; if (launchId) { const existingLaunch = await launchService.getLaunchById(launchId); @@ -111,6 +138,73 @@ async function resolveLaunchFromAction( }); } +function extractWorkspaceId(payload: Record, fallback: string | undefined): string | undefined { + if (fallback) { + return fallback; + } + + const team = payload.team; + if (team && typeof team === "object" && typeof (team as Record).id === "string") { + return (team as Record).id as string; + } + + return undefined; +} + +function extractUserId(payload: Record): string | undefined { + const user = payload.user; + if (user && typeof user === "object" && typeof (user as Record).id === "string") { + return (user as Record).id as string; + } + return undefined; +} + +function parseWorkspaceSettingsSubmission(values: Record): { + searchMode?: WorkspaceSearchMode; + auditRetentionDays?: number; + defaultLaunchProfile?: LaunchProfileId; +} { + const searchModeBlock = values.search_mode as Record | undefined; + const searchModeValue = searchModeBlock?.value as Record | undefined; + const selectedOption = searchModeValue?.selected_option as Record | undefined; + const searchMode = + selectedOption?.value === "thread_only" || selectedOption?.value === "public_only" + ? (selectedOption.value as WorkspaceSearchMode) + : undefined; + + const retentionBlock = values.audit_retention_days as Record | undefined; + const retentionValue = retentionBlock?.value as Record | undefined; + const rawRetentionDays = typeof retentionValue?.value === "string" ? retentionValue.value.trim() : ""; + const auditRetentionDays = rawRetentionDays ? Number(rawRetentionDays) : undefined; + const profileBlock = values.default_launch_profile as Record | undefined; + const profileValue = profileBlock?.value as Record | undefined; + const selectedProfile = profileValue?.selected_option as Record | undefined; + const defaultLaunchProfile = + typeof selectedProfile?.value === "string" && selectedProfile.value in LAUNCH_PROFILE_DEFINITIONS + ? (selectedProfile.value as LaunchProfileId) + : undefined; + + return { + searchMode, + auditRetentionDays, + defaultLaunchProfile + }; +} + +function parseOwnerAssignmentSubmission(values: Record): { roleName?: string; ownerUserId?: string } { + const roleBlock = values.role_name as Record | undefined; + const roleValue = roleBlock?.value as Record | undefined; + const selectedRole = roleValue?.selected_option as Record | undefined; + + const ownerBlock = values.owner_user as Record | undefined; + const ownerValue = ownerBlock?.value as Record | undefined; + + return { + roleName: typeof selectedRole?.value === "string" ? selectedRole.value : undefined, + ownerUserId: typeof ownerValue?.selected_user === "string" ? ownerValue.selected_user : undefined + }; +} + export function registerHandlers(app: App, dependencies: GoSignalHandlersDependencies): void { app.event("app_home_opened", async (args) => { const { event, client, context, logger } = args as any; @@ -254,10 +348,11 @@ export function registerHandlers(app: App, dependencies: GoSignalHandlersDepende await ack(); try { const payload = body as Record; + const actionToken = extractActionToken(payload); const action = Array.isArray(body.actions) ? body.actions[0] : undefined; const launchId = action && typeof action.value === "string" ? action.value : undefined; const launch = launchId - ? (await dependencies.launchService.rerunLaunch(client, launchId)) ?? + ? (await dependencies.launchService.rerunLaunch(client, launchId, actionToken)) ?? (await resolveLaunchFromAction(client, payload, context.teamId, dependencies.launchService)) : await resolveLaunchFromAction(client, payload, context.teamId, dependencies.launchService); if (!launch) { @@ -279,7 +374,10 @@ export function registerHandlers(app: App, dependencies: GoSignalHandlersDepende const { ack, body, client, logger, context } = args as any; await ack(); try { - const launch = await resolveLaunchFromAction(client, body as Record, context.teamId, dependencies.launchService); + const payload = body as Record; + const workspaceId = extractWorkspaceId(payload, context.teamId); + const userId = extractUserId(payload); + const launch = await resolveLaunchFromAction(client, payload, workspaceId, dependencies.launchService); if (!launch) { return; } @@ -289,6 +387,16 @@ export function registerHandlers(app: App, dependencies: GoSignalHandlersDepende thread_ts: launch.sourceThreadTs, text: buildSignoffRequestText(launch) }); + + if (workspaceId && userId) { + await dependencies.workspaceAdminService.recordEvent({ + workspaceId, + actorUserId: userId, + eventType: "signoff_requested", + summary: `Requested the missing sign-off for ${launch.name}.`, + launchId: launch.id + }); + } } catch (error) { logger.error(error); } @@ -298,7 +406,10 @@ export function registerHandlers(app: App, dependencies: GoSignalHandlersDepende const { ack, body, client, logger, context } = args as any; await ack(); try { - const launch = await resolveLaunchFromAction(client, body as Record, context.teamId, dependencies.launchService); + const payload = body as Record; + const workspaceId = extractWorkspaceId(payload, context.teamId); + const userId = extractUserId(payload); + const launch = await resolveLaunchFromAction(client, payload, workspaceId, dependencies.launchService); if (!launch) { return; } @@ -310,6 +421,16 @@ export function registerHandlers(app: App, dependencies: GoSignalHandlersDepende ? `Launch brief ready: ${launch.canvasLinkLabel ?? launch.canvasId}` : "The launch brief canvas has not been created yet." }); + + if (workspaceId && userId) { + await dependencies.workspaceAdminService.recordEvent({ + workspaceId, + actorUserId: userId, + eventType: "canvas_opened", + summary: `Opened the launch brief for ${launch.name}.`, + launchId: launch.id + }); + } } catch (error) { logger.error(error); } @@ -319,7 +440,10 @@ export function registerHandlers(app: App, dependencies: GoSignalHandlersDepende const { ack, body, client, logger, context } = args as any; await ack(); try { - const launch = await resolveLaunchFromAction(client, body as Record, context.teamId, dependencies.launchService); + const payload = body as Record; + const workspaceId = extractWorkspaceId(payload, context.teamId); + const userId = extractUserId(payload); + const launch = await resolveLaunchFromAction(client, payload, workspaceId, dependencies.launchService); if (!launch) { return; } @@ -329,8 +453,304 @@ export function registerHandlers(app: App, dependencies: GoSignalHandlersDepende text: launch.decision.summary, blocks: buildLaunchBlocks(launch) as never }); + + if (workspaceId && userId) { + await dependencies.workspaceAdminService.recordEvent({ + workspaceId, + actorUserId: userId, + eventType: "launch_opened", + summary: `Opened the latest readiness board for ${launch.name}.`, + launchId: launch.id + }); + } + } catch (error) { + logger.error(error); + } + }); + + app.action("gosignal_assign_owner", async (args) => { + const { ack, body, client, logger, context } = args as any; + await ack(); + try { + const payload = body as Record; + const workspaceId = extractWorkspaceId(payload, context.teamId); + const triggerId = typeof body.trigger_id === "string" ? body.trigger_id : undefined; + if (!workspaceId || !triggerId) { + return; + } + + const launch = await resolveLaunchFromAction(client, payload, workspaceId, dependencies.launchService); + if (!launch) { + return; + } + + await client.views.open({ + trigger_id: triggerId, + view: buildOwnerAssignmentModal(launch) + }); + } catch (error) { + logger.error(error); + } + }); + + app.action("gosignal_remind_owner", async (args) => { + const { ack, body, client, logger, context } = args as any; + await ack(); + try { + const payload = body as Record; + const workspaceId = extractWorkspaceId(payload, context.teamId); + const userId = extractUserId(payload); + const action = Array.isArray(payload.actions) ? payload.actions[0] as Record | undefined : undefined; + const actionValue = parseActionValue(action?.value); + const launch = await resolveLaunchFromAction(client, payload, workspaceId, dependencies.launchService); + if (!launch) { + return; + } + + const roleName = + actionValue.roleName ?? launch.approvals.find((approval) => approval.state !== "approved")?.roleName; + if (!roleName) { + return; + } + + const reminderText = dependencies.launchService.buildOwnerReminderText(launch, roleName); + await client.chat.postMessage({ + channel: launch.sourceChannelId, + thread_ts: launch.sourceThreadTs, + text: reminderText + }); + + if (userId) { + await dependencies.launchService.recordOwnerReminder(client, launch.id, roleName, userId); + } + + if (workspaceId && userId) { + await dependencies.homeService.publish(client, workspaceId, userId); + } } catch (error) { logger.error(error); } }); + + app.action("gosignal_view_history", async (args) => { + const { ack, body, client, logger, context } = args as any; + await ack(); + try { + const payload = body as Record; + const workspaceId = extractWorkspaceId(payload, context.teamId); + const userId = extractUserId(payload); + const triggerId = typeof body.trigger_id === "string" ? body.trigger_id : undefined; + if (!workspaceId || !userId || !triggerId) { + return; + } + + const launchHistory = await dependencies.launchService.getLaunchHistory( + parseActionValue((Array.isArray(payload.actions) ? payload.actions[0] : undefined)?.value).launchId ?? "" + ); + if (!launchHistory) { + return; + } + + await client.views.open({ + trigger_id: triggerId, + view: buildLaunchHistoryModal(launchHistory.launch, launchHistory.events) + }); + + await dependencies.workspaceAdminService.recordEvent({ + workspaceId, + actorUserId: userId, + eventType: "launch_history_viewed", + summary: `Viewed launch history for ${launchHistory.launch.name}.`, + launchId: launchHistory.launch.id + }); + await dependencies.homeService.publish(client, workspaceId, userId); + } catch (error) { + logger.error(error); + } + }); + + app.action("gosignal_export_brief", async (args) => { + const { ack, body, client, logger, context } = args as any; + await ack(); + try { + const payload = body as Record; + const workspaceId = extractWorkspaceId(payload, context.teamId); + const userId = extractUserId(payload); + const triggerId = typeof body.trigger_id === "string" ? body.trigger_id : undefined; + if (!workspaceId || !userId || !triggerId) { + return; + } + + const action = Array.isArray(payload.actions) ? payload.actions[0] as Record | undefined : undefined; + const exportPayload = await dependencies.launchService.buildLaunchExport(parseActionValue(action?.value).launchId ?? ""); + if (!exportPayload) { + return; + } + + await client.views.open({ + trigger_id: triggerId, + view: buildLaunchExportModal(exportPayload.launch, exportPayload.markdown) + }); + + await dependencies.workspaceAdminService.recordEvent({ + workspaceId, + actorUserId: userId, + eventType: "launch_exported", + summary: `Opened export brief for ${exportPayload.launch.name}.`, + launchId: exportPayload.launch.id + }); + await dependencies.homeService.publish(client, workspaceId, userId); + } catch (error) { + logger.error(error); + } + }); + + app.action("gosignal_refresh_home", async (args) => { + const { ack, body, client, logger, context } = args as any; + await ack(); + try { + const payload = body as Record; + const workspaceId = extractWorkspaceId(payload, context.teamId); + const userId = extractUserId(payload); + if (!workspaceId || !userId) { + return; + } + + await dependencies.homeService.publish(client, workspaceId, userId); + } catch (error) { + logger.error(error); + } + }); + + app.action("gosignal_open_settings", async (args) => { + const { ack, body, client, logger, context } = args as any; + await ack(); + try { + const payload = body as Record; + const workspaceId = extractWorkspaceId(payload, context.teamId); + const userId = extractUserId(payload); + const triggerId = typeof body.trigger_id === "string" ? body.trigger_id : undefined; + if (!workspaceId || !userId || !triggerId) { + return; + } + + const settings = await dependencies.workspaceAdminService.getSettings(workspaceId, userId); + await client.views.open({ + trigger_id: triggerId, + view: buildWorkspaceSettingsModal(settings) + }); + } catch (error) { + logger.error(error); + } + }); + + app.view("gosignal_assign_owner_submit", async (args) => { + const { ack, body, client, logger } = args as any; + let acknowledged = false; + try { + const payload = body as Record; + const view = payload.view as Record | undefined; + const launchId = typeof view?.private_metadata === "string" ? view.private_metadata : undefined; + const userId = extractUserId(payload); + const state = view?.state as Record | undefined; + const values = state?.values as Record | undefined; + const submission = values ? parseOwnerAssignmentSubmission(values) : {}; + + if (!launchId || !userId || !submission.roleName || !submission.ownerUserId) { + await ack({ + response_action: "errors", + errors: { + role_name: "Choose the missing sign-off to assign.", + owner_user: "Choose an owner to follow up on it." + } + }); + acknowledged = true; + return; + } + + await ack(); + acknowledged = true; + + const updatedLaunch = await dependencies.launchService.assignOwner( + client, + launchId, + submission.roleName, + submission.ownerUserId, + userId + ); + if (!updatedLaunch) { + return; + } + + await client.chat.postMessage({ + channel: updatedLaunch.sourceChannelId, + thread_ts: updatedLaunch.sourceThreadTs, + text: `Assigned ${submission.roleName} to <@${submission.ownerUserId}> for ${updatedLaunch.name}.`, + blocks: buildLaunchBlocks( + updatedLaunch, + `${updatedLaunch.decision.summary} Assigned ${submission.roleName} to <@${submission.ownerUserId}> for follow-up.` + ) as never + }); + await dependencies.homeService.publish(client, updatedLaunch.workspaceId, userId); + } catch (error) { + if (!acknowledged) { + await ack(); + } + logger.error(error); + } + }); + + app.view("gosignal_workspace_settings_submit", async (args) => { + const { ack, body, client, logger } = args as any; + let acknowledged = false; + try { + const payload = body as Record; + const view = payload.view as Record | undefined; + const workspaceId = typeof view?.private_metadata === "string" ? view.private_metadata : undefined; + const userId = extractUserId(payload); + const state = view?.state as Record | undefined; + const values = state?.values as Record | undefined; + const submission = values ? parseWorkspaceSettingsSubmission(values) : {}; + + if (!workspaceId || !userId || !submission.searchMode || !submission.auditRetentionDays || !submission.defaultLaunchProfile) { + await ack({ + response_action: "errors", + errors: { + search_mode: "Choose a search mode for this workspace.", + default_launch_profile: "Choose a default launch profile for this workspace.", + audit_retention_days: "Enter a retention value between 1 and 365 days." + } + }); + acknowledged = true; + return; + } + + if (!Number.isInteger(submission.auditRetentionDays) || submission.auditRetentionDays < 1 || submission.auditRetentionDays > 365) { + await ack({ + response_action: "errors", + errors: { + audit_retention_days: "Audit retention must be a whole number between 1 and 365." + } + }); + acknowledged = true; + return; + } + + await ack(); + acknowledged = true; + await dependencies.workspaceAdminService.updateSettings({ + workspaceId, + updatedByUserId: userId, + searchMode: submission.searchMode, + auditRetentionDays: submission.auditRetentionDays, + defaultLaunchProfile: submission.defaultLaunchProfile + }); + await dependencies.homeService.publish(client, workspaceId, userId); + } catch (error) { + if (!acknowledged) { + await ack(); + } + logger.error(error); + } + }); } diff --git a/src/http/customRoutes.ts b/src/http/customRoutes.ts new file mode 100644 index 0000000..d3bef07 --- /dev/null +++ b/src/http/customRoutes.ts @@ -0,0 +1,60 @@ +import type { CustomRoute } from "@slack/bolt"; +import type { AppConfig } from "../config.ts"; + +interface HealthResponse { + ok: true; + service: "gosignal"; + version: string; + storage: "memory" | "postgres"; + slack: "configured"; + socketMode: boolean; + llm: "disabled" | "deterministic" | "cerebras"; +} + +export function buildCustomRoutes(config: AppConfig): CustomRoute[] { + return [ + { + path: "/", + method: ["GET"], + handler: (_req, res) => { + sendText( + res, + "GoSignal is running.\nUse /healthz for a machine-readable status check.\n" + ); + } + }, + { + path: "/healthz", + method: ["GET"], + handler: (_req, res) => { + sendJson(res, { + ok: true, + service: "gosignal", + version: process.env.npm_package_version ?? "0.1.0", + storage: config.databaseUrl ? "postgres" : "memory", + slack: "configured", + socketMode: config.useSocketMode, + llm: resolveLlmMode(config) + } satisfies HealthResponse); + } + } + ]; +} + +function resolveLlmMode(config: AppConfig): HealthResponse["llm"] { + if (!config.enableLlmSummaries) { + return "disabled"; + } + + return config.llmProvider; +} + +function sendJson(res: { writeHead: (statusCode: number, headers: Record) => void; end: (body: string) => void }, body: HealthResponse): void { + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(body)); +} + +function sendText(res: { writeHead: (statusCode: number, headers: Record) => void; end: (body: string) => void }, body: string): void { + res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); + res.end(body); +} diff --git a/src/repositories/schema.sql b/src/repositories/schema.sql index b16d9c1..2888469 100644 --- a/src/repositories/schema.sql +++ b/src/repositories/schema.sql @@ -14,3 +14,23 @@ CREATE TABLE IF NOT EXISTS launches ( CREATE UNIQUE INDEX IF NOT EXISTS launches_thread_key ON launches (workspace_id, source_channel_id, source_thread_ts); + +CREATE TABLE IF NOT EXISTS workspace_settings ( + workspace_id TEXT PRIMARY KEY, + updated_by_user_id TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + payload JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS audit_events ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + event_type TEXT NOT NULL, + launch_id TEXT, + actor_user_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + payload JSONB NOT NULL +); + +CREATE INDEX IF NOT EXISTS audit_events_workspace_created_at +ON audit_events (workspace_id, created_at DESC); diff --git a/src/repositories/workspaceAdminRepository.ts b/src/repositories/workspaceAdminRepository.ts new file mode 100644 index 0000000..ad1c8e0 --- /dev/null +++ b/src/repositories/workspaceAdminRepository.ts @@ -0,0 +1,194 @@ +import { Pool } from "pg"; +import type { AuditEventRecord, WorkspaceSettingsRecord } from "../domain/types.ts"; + +export interface WorkspaceSettingsRepository { + get(workspaceId: string): Promise; + save(settings: WorkspaceSettingsRecord): Promise; +} + +export interface AuditRepository { + append(event: AuditEventRecord): Promise; + listRecentForWorkspace(workspaceId: string, limit?: number): Promise; + listRecentForLaunch(workspaceId: string, launchId: string, limit?: number): Promise; +} + +interface WorkspaceSettingsRow { + workspace_id: string; + payload: WorkspaceSettingsRecord; +} + +interface AuditEventRow { + id: string; + workspace_id: string; + payload: AuditEventRecord; +} + +export class MemoryWorkspaceAdminRepository implements WorkspaceSettingsRepository, AuditRepository { + private settingsByWorkspaceId = new Map(); + private auditEvents: AuditEventRecord[] = []; + + async get(workspaceId: string): Promise { + return this.settingsByWorkspaceId.get(workspaceId); + } + + async save(settings: WorkspaceSettingsRecord): Promise { + this.settingsByWorkspaceId.set(settings.workspaceId, settings); + return settings; + } + + async append(event: AuditEventRecord): Promise { + this.auditEvents.push(event); + return event; + } + + async listRecentForWorkspace(workspaceId: string, limit = 10): Promise { + return this.auditEvents + .filter((event) => event.workspaceId === workspaceId) + .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)) + .slice(0, limit); + } + + async listRecentForLaunch(workspaceId: string, launchId: string, limit = 10): Promise { + return this.auditEvents + .filter((event) => event.workspaceId === workspaceId && event.launchId === launchId) + .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)) + .slice(0, limit); + } +} + +export class PostgresWorkspaceAdminRepository implements WorkspaceSettingsRepository, AuditRepository { + private pool: Pool; + + constructor(connectionString: string) { + this.pool = new Pool({ connectionString }); + } + + async initialize(): Promise { + await this.pool.query(` + CREATE TABLE IF NOT EXISTS workspace_settings ( + workspace_id TEXT PRIMARY KEY, + updated_by_user_id TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + payload JSONB NOT NULL + ); + `); + + await this.pool.query(` + CREATE TABLE IF NOT EXISTS audit_events ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + event_type TEXT NOT NULL, + launch_id TEXT, + actor_user_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + payload JSONB NOT NULL + ); + `); + + await this.pool.query(` + CREATE INDEX IF NOT EXISTS audit_events_workspace_created_at + ON audit_events (workspace_id, created_at DESC); + `); + } + + async get(workspaceId: string): Promise { + const result = await this.pool.query( + ` + SELECT workspace_id, payload + FROM workspace_settings + WHERE workspace_id = $1 + LIMIT 1; + `, + [workspaceId] + ); + + return result.rows[0]?.payload; + } + + async save(settings: WorkspaceSettingsRecord): Promise { + const result = await this.pool.query( + ` + INSERT INTO workspace_settings ( + workspace_id, + updated_by_user_id, + updated_at, + payload + ) + VALUES ($1, $2, $3, $4::jsonb) + ON CONFLICT (workspace_id) + DO UPDATE SET + updated_by_user_id = EXCLUDED.updated_by_user_id, + updated_at = EXCLUDED.updated_at, + payload = EXCLUDED.payload + RETURNING workspace_id, payload; + `, + [ + settings.workspaceId, + settings.updatedByUserId, + settings.updatedAt, + JSON.stringify(settings) + ] + ); + + return result.rows[0]?.payload ?? settings; + } + + async append(event: AuditEventRecord): Promise { + const result = await this.pool.query( + ` + INSERT INTO audit_events ( + id, + workspace_id, + event_type, + launch_id, + actor_user_id, + created_at, + payload + ) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) + RETURNING id, workspace_id, payload; + `, + [ + event.id, + event.workspaceId, + event.eventType, + event.launchId ?? null, + event.actorUserId, + event.createdAt, + JSON.stringify(event) + ] + ); + + return result.rows[0]?.payload ?? event; + } + + async listRecentForWorkspace(workspaceId: string, limit = 10): Promise { + const result = await this.pool.query( + ` + SELECT id, workspace_id, payload + FROM audit_events + WHERE workspace_id = $1 + ORDER BY created_at DESC + LIMIT $2; + `, + [workspaceId, limit] + ); + + return result.rows.map((row) => row.payload); + } + + async listRecentForLaunch(workspaceId: string, launchId: string, limit = 10): Promise { + const result = await this.pool.query( + ` + SELECT id, workspace_id, payload + FROM audit_events + WHERE workspace_id = $1 AND launch_id = $2 + ORDER BY created_at DESC + LIMIT $3; + `, + [workspaceId, launchId, limit] + ); + + return result.rows.map((row) => row.payload); + } +} diff --git a/src/scripts/checkLlm.ts b/src/scripts/checkLlm.ts index 88b768e..927a590 100644 --- a/src/scripts/checkLlm.ts +++ b/src/scripts/checkLlm.ts @@ -75,6 +75,7 @@ function buildSampleLaunch(): LaunchRecord { name: "Mobile v3 checkout rollout", createdByUserId: "U-check", status: "active", + launchProfile: "saas_release", categories: [ buildCategory("Engineering", "green", "high", "Engineering lead confirmed rollout readiness."), buildCategory("Quality", "green", "high", "QA signed off after final regression pass."), @@ -105,6 +106,36 @@ function buildSampleLaunch(): LaunchRecord { reason: "Support readiness approval has not been explicitly posted yet." } ], + requirementChecks: [ + { + requirementId: "rollback_plan", + label: "Rollback plan", + categoryName: "Operations", + state: "met", + reason: "Rollback documented in-thread.", + evidenceIds: ["ev-eng-1"], + severity: "high" + }, + { + requirementId: "release_notes", + label: "Release notes", + categoryName: "Comms", + state: "met", + reason: "Release notes are ready for the launch.", + evidenceIds: ["ev-qa-1"], + severity: "medium" + }, + { + requirementId: "owner_on_call", + label: "On-call owner", + categoryName: "Operations", + state: "met", + reason: "Primary on-call owner was assigned.", + evidenceIds: ["ev-support-1"], + severity: "medium" + } + ], + ownerAssignments: [], blockers: [], evidence: [ buildEvidence("ev-eng-1", "Engineering", "Engineering lead approved the rollout for launch.", 92), diff --git a/src/scripts/smoke.ts b/src/scripts/smoke.ts new file mode 100644 index 0000000..9441093 --- /dev/null +++ b/src/scripts/smoke.ts @@ -0,0 +1,141 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { createServer } from "node:net"; +import { setTimeout as delay } from "node:timers/promises"; + +async function main(): Promise { + const port = await getAvailablePort(); + const child = spawn(process.execPath, ["server.js"], { + cwd: process.cwd(), + env: { + ...process.env, + PORT: String(port), + SLACK_SIGNING_SECRET: "smoke-signing-secret", + SLACK_BOT_TOKEN: "xoxb-smoke-token", + USE_SOCKET_MODE: "false", + SLACK_TOKEN_VERIFICATION_ENABLED: "false", + ENABLE_LLM_SUMMARIES: "false", + DATABASE_URL: "" + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + const logs = { + stdout: "", + stderr: "" + }; + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk) => { + logs.stdout += chunk; + }); + child.stderr?.on("data", (chunk) => { + logs.stderr += chunk; + }); + + try { + await waitForHttpReady(port, child, logs); + + const rootResponse = await fetch(`http://127.0.0.1:${port}/`); + if (!rootResponse.ok) { + throw new Error(`Expected GET / to return 200, received ${rootResponse.status}.`); + } + const rootBody = await rootResponse.text(); + if (!rootBody.includes("GoSignal")) { + throw new Error(`Expected GET / to mention GoSignal. Received: ${rootBody}`); + } + + const healthResponse = await fetch(`http://127.0.0.1:${port}/healthz`); + if (!healthResponse.ok) { + throw new Error(`Expected GET /healthz to return 200, received ${healthResponse.status}.`); + } + + const healthPayload = await healthResponse.json() as { + ok?: boolean; + service?: string; + storage?: string; + slack?: string; + }; + + if (healthPayload.ok !== true || healthPayload.service !== "gosignal") { + throw new Error(`Unexpected /healthz payload: ${JSON.stringify(healthPayload)}`); + } + + if (healthPayload.storage !== "memory" || healthPayload.slack !== "configured") { + throw new Error(`Unexpected operational mode from /healthz: ${JSON.stringify(healthPayload)}`); + } + + console.log("Smoke test passed."); + } finally { + child.kill("SIGTERM"); + await Promise.race([ + once(child, "exit"), + delay(2_000).then(() => { + if (!child.killed) { + child.kill("SIGKILL"); + } + }) + ]); + } +} + +async function waitForHttpReady( + port: number, + child: ReturnType, + logs: { stdout: string; stderr: string } +): Promise { + const deadline = Date.now() + 15_000; + + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error( + `Production smoke server exited early with code ${child.exitCode}.\nSTDOUT:\n${logs.stdout}\nSTDERR:\n${logs.stderr}` + ); + } + + try { + const response = await fetch(`http://127.0.0.1:${port}/healthz`); + if (response.ok) { + return; + } + } catch { + // Keep polling until the server is ready or the deadline is hit. + } + + await delay(250); + } + + throw new Error( + `Timed out waiting for the production smoke server on port ${port}.\nSTDOUT:\n${logs.stdout}\nSTDERR:\n${logs.stderr}` + ); +} + +async function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Unable to allocate an ephemeral port for smoke testing.")); + return; + } + + const { port } = address; + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + }); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/src/scripts/verifyHosted.ts b/src/scripts/verifyHosted.ts new file mode 100644 index 0000000..efa8fd7 --- /dev/null +++ b/src/scripts/verifyHosted.ts @@ -0,0 +1,102 @@ +import "dotenv/config"; + +interface HealthPayload { + ok?: boolean; + service?: string; + version?: string; + storage?: string; + slack?: string; + socketMode?: boolean; + llm?: string; +} + +function resolveBaseUrl(): string { + const direct = process.argv[2]?.trim(); + const fromEnv = process.env.PUBLIC_BASE_URL?.trim(); + const candidate = direct || fromEnv; + + if (!candidate) { + throw new Error("Pass the hosted base URL as the first argument or set PUBLIC_BASE_URL."); + } + + return candidate.replace(/\/$/, ""); +} + +function summarizeRoot(text: string): string { + const trimmed = text.trim(); + if (trimmed.length <= 300) { + return trimmed; + } + return `${trimmed.slice(0, 297)}...`; +} + +async function fetchText(url: string): Promise<{ status: number; body: string }> { + const response = await fetch(url); + const body = await response.text(); + if (!response.ok) { + throw new Error(`Request to ${url} failed with ${response.status}. Response body: ${body}`); + } + + return { + status: response.status, + body + }; +} + +async function fetchHealth(url: string): Promise<{ status: number; payload: HealthPayload }> { + const response = await fetch(url); + const body = await response.text(); + if (!response.ok) { + throw new Error(`Request to ${url} failed with ${response.status}. Response body: ${body}`); + } + + let payload: HealthPayload; + try { + payload = JSON.parse(body) as HealthPayload; + } catch { + throw new Error(`Health response from ${url} was not valid JSON. Raw body: ${body}`); + } + + if (payload.ok !== true || payload.service !== "gosignal") { + throw new Error(`Health response from ${url} did not look like GoSignal: ${body}`); + } + + return { + status: response.status, + payload + }; +} + +async function main(): Promise { + const baseUrl = resolveBaseUrl(); + const capturedAt = new Date().toISOString(); + const rootUrl = `${baseUrl}/`; + const healthUrl = `${baseUrl}/healthz`; + + const root = await fetchText(rootUrl); + const health = await fetchHealth(healthUrl); + + console.log("# Hosted Proof Capture"); + console.log(""); + console.log(`- Captured at: ${capturedAt}`); + console.log(`- Base URL: ${baseUrl}`); + console.log(`- Root URL: ${rootUrl}`); + console.log(`- Health URL: ${healthUrl}`); + console.log(`- Root status: ${root.status}`); + console.log(`- Health status: ${health.status}`); + console.log(""); + console.log("## Root response"); + console.log("```text"); + console.log(summarizeRoot(root.body)); + console.log("```"); + console.log(""); + console.log("## Health response"); + console.log("```json"); + console.log(JSON.stringify(health.payload, null, 2)); + console.log("```"); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/src/services/homeService.ts b/src/services/homeService.ts index e3ba61d..ab05f76 100644 --- a/src/services/homeService.ts +++ b/src/services/homeService.ts @@ -1,19 +1,38 @@ import type { WebClient } from "@slack/web-api"; import type { LaunchService } from "./launchService.ts"; import { buildAppHomeView } from "../ui/appHome.ts"; +import { WorkspaceAdminService } from "./workspaceAdminService.ts"; + +interface HomeServiceDependencies { + launchService: LaunchService; + workspaceAdminService: WorkspaceAdminService; + responseMode: string; +} export class HomeService { private readonly launchService: LaunchService; + private readonly workspaceAdminService: WorkspaceAdminService; + private readonly responseMode: string; - constructor(launchService: LaunchService) { - this.launchService = launchService; + constructor(dependencies: HomeServiceDependencies) { + this.launchService = dependencies.launchService; + this.workspaceAdminService = dependencies.workspaceAdminService; + this.responseMode = dependencies.responseMode; } async publish(client: WebClient, workspaceId: string, userId: string): Promise { - const launches = await this.launchService.listRecentLaunches(workspaceId, 5); + const launches = await this.launchService.listRecentLaunches(workspaceId, 8); + const settings = await this.workspaceAdminService.getSettings(workspaceId, userId); + const auditEvents = await this.workspaceAdminService.listRecentAuditEvents(workspaceId, 5); + await client.views.publish({ user_id: userId, - view: buildAppHomeView(launches) + view: buildAppHomeView({ + launches, + settings, + auditEvents, + responseMode: this.responseMode + }) }); } } diff --git a/src/services/launchService.ts b/src/services/launchService.ts index 6940366..de8b1bd 100644 --- a/src/services/launchService.ts +++ b/src/services/launchService.ts @@ -1,17 +1,18 @@ import type { WebClient } from "@slack/web-api"; -import { buildSearchQuery, deriveLaunchName, evaluateLaunchReadiness } from "../domain/readiness.ts"; +import { buildSearchQuery, deriveLaunchName, deriveLaunchProfile, evaluateLaunchReadiness } from "../domain/readiness.ts"; import type { AnalyzeThreadInput, AppContextSnapshot, DmResolutionInput, LaunchRecord, - SearchEvidenceRecord, - SlackMessageRecord + RoleOwnerAssignment, + SearchContextResult } from "../domain/types.ts"; import type { LaunchRepository } from "../repositories/launchRepository.ts"; import type { LLMProvider } from "./llmProvider.ts"; import type { CanvasGateway, SearchSource, ThreadSource } from "./slackSources.ts"; import { buildLaunchCanvasMarkdown } from "../ui/canvas.ts"; +import { WorkspaceAdminService } from "./workspaceAdminService.ts"; export interface LaunchServiceDependencies { repository: LaunchRepository; @@ -19,6 +20,7 @@ export interface LaunchServiceDependencies { searchSource: SearchSource; canvasGateway: CanvasGateway; summaryProvider: LLMProvider; + workspaceAdminService: WorkspaceAdminService; } export class LaunchService { @@ -30,40 +32,60 @@ export class LaunchService { async analyzeThread(client: WebClient, input: AnalyzeThreadInput): Promise { const existingLaunch = await this.dependencies.repository.findByThread(input); + const workspaceSettings = await this.dependencies.workspaceAdminService.getSettings(input.workspaceId, input.userId); const threadMessages = await this.dependencies.threadSource.fetchThread(client, input.sourceChannelId, input.sourceThreadTs); const launchName = deriveLaunchName(threadMessages, existingLaunch?.name); - const searchQuery = buildSearchQuery(launchName); - const searchEvidence = await this.dependencies.searchSource.searchPublicContext(client, { - actionToken: input.actionToken, - channelId: input.sourceChannelId, - threadTs: input.sourceThreadTs, - query: searchQuery - }); + const launchProfile = deriveLaunchProfile( + threadMessages, + workspaceSettings.defaultLaunchProfile, + existingLaunch?.launchProfile + ); + const searchQuery = buildSearchQuery(launchName, launchProfile); + const searchContext = + workspaceSettings.searchMode === "thread_only" + ? buildThreadOnlySearchContext() + : await this.dependencies.searchSource.searchPublicContext(client, { + actionToken: input.actionToken, + channelId: input.sourceChannelId, + threadTs: input.sourceThreadTs, + query: searchQuery + }); const launch = evaluateLaunchReadiness({ key: input, name: launchName, createdByUserId: input.userId, + launchProfile, threadMessages, - searchEvidence, + searchEvidence: searchContext.evidence, existingLaunch, searchQuery }); + launch.searchDiagnostics = searchContext.diagnostics; launch.decision.summary = await this.dependencies.summaryProvider.summarizeLaunch(launch); - const markdown = buildLaunchCanvasMarkdown(launch); - const canvas = await this.dependencies.canvasGateway.createOrUpdate( - client, - launch.id, - launch.canvasId, - markdown, - `Launch brief: ${launch.name}` - ); - - launch.canvasId = canvas.canvasId; - launch.canvasLinkLabel = canvas.label; + let savedLaunch = await this.persistLaunchWithCanvas(client, launch); + try { + await this.dependencies.workspaceAdminService.recordEvent({ + workspaceId: input.workspaceId, + actorUserId: input.userId, + eventType: existingLaunch ? "launch_rerun" : "launch_analyzed", + summary: + `${existingLaunch ? "Re-ran" : "Analyzed"} launch readiness for ${savedLaunch.name} ` + + `(${savedLaunch.decision.overallState}, ${workspaceSettings.searchMode === "thread_only" ? "thread-only" : "live search enabled"}).`, + launchId: savedLaunch.id, + metadata: { + overallState: savedLaunch.decision.overallState, + searchMode: workspaceSettings.searchMode, + searchStatus: savedLaunch.searchDiagnostics?.status ?? "not_captured" + } + }); + savedLaunch = await this.persistLaunchWithCanvas(client, savedLaunch); + } catch (error) { + console.warn("[GoSignal warning] Failed to write audit event for launch analysis.", error); + } - return this.dependencies.repository.save(launch); + return savedLaunch; } async resolveLaunchForDmQuery(input: DmResolutionInput): Promise { @@ -84,7 +106,7 @@ export class LaunchService { return this.dependencies.summaryProvider.answerLaunchQuestion(launch, question); } - async rerunLaunch(client: WebClient, launchId: string): Promise { + async rerunLaunch(client: WebClient, launchId: string, actionToken?: string): Promise { const launch = await this.dependencies.repository.findById(launchId); if (!launch) { return undefined; @@ -94,7 +116,8 @@ export class LaunchService { workspaceId: launch.workspaceId, sourceChannelId: launch.sourceChannelId, sourceThreadTs: launch.sourceThreadTs, - userId: launch.createdByUserId + userId: launch.createdByUserId, + actionToken }); } @@ -106,6 +129,155 @@ export class LaunchService { return this.dependencies.repository.findById(launchId); } + async assignOwner( + client: WebClient, + launchId: string, + roleName: string, + ownerUserId: string, + assignedByUserId: string + ): Promise { + const launch = await this.dependencies.repository.findById(launchId); + if (!launch) { + return undefined; + } + + const now = new Date().toISOString(); + const ownerAssignments = launch.ownerAssignments.filter((assignment) => assignment.roleName !== roleName); + ownerAssignments.push({ + roleName, + userId: ownerUserId, + assignedByUserId, + assignedAt: now, + reminderCount: 0 + } satisfies RoleOwnerAssignment); + + const updatedLaunch: LaunchRecord = { + ...launch, + ownerAssignments, + updatedAt: now + }; + + let savedLaunch = await this.persistLaunchWithCanvas(client, updatedLaunch); + try { + await this.dependencies.workspaceAdminService.recordEvent({ + workspaceId: savedLaunch.workspaceId, + actorUserId: assignedByUserId, + eventType: "owner_assigned", + summary: `Assigned ${roleName} to <@${ownerUserId}> for ${savedLaunch.name}.`, + launchId: savedLaunch.id, + metadata: { + roleName, + ownerUserId + } + }); + savedLaunch = await this.persistLaunchWithCanvas(client, savedLaunch); + } catch (error) { + console.warn("[GoSignal warning] Failed to write owner assignment audit event.", error); + } + + return savedLaunch; + } + + async recordOwnerReminder( + client: WebClient, + launchId: string, + roleName: string, + actorUserId: string + ): Promise { + const launch = await this.dependencies.repository.findById(launchId); + if (!launch) { + return undefined; + } + + const now = new Date().toISOString(); + const ownerAssignments = launch.ownerAssignments.map((assignment) => + assignment.roleName === roleName + ? { + ...assignment, + lastRemindedAt: now, + reminderCount: assignment.reminderCount + 1 + } + : assignment + ); + + const updatedLaunch: LaunchRecord = { + ...launch, + ownerAssignments, + updatedAt: now + }; + let savedLaunch = await this.persistLaunchWithCanvas(client, updatedLaunch); + + const ownerAssignment = savedLaunch.ownerAssignments.find((assignment) => assignment.roleName === roleName); + try { + await this.dependencies.workspaceAdminService.recordEvent({ + workspaceId: savedLaunch.workspaceId, + actorUserId, + eventType: "owner_reminded", + summary: ownerAssignment + ? `Reminded <@${ownerAssignment.userId}> about ${roleName} for ${savedLaunch.name}.` + : `Attempted to remind an owner for ${roleName} on ${savedLaunch.name}.`, + launchId: savedLaunch.id, + metadata: { + roleName, + ownerUserId: ownerAssignment?.userId + } + }); + savedLaunch = await this.persistLaunchWithCanvas(client, savedLaunch); + } catch (error) { + console.warn("[GoSignal warning] Failed to write owner reminder audit event.", error); + } + + return savedLaunch; + } + + async buildLaunchExport(launchId: string): Promise<{ launch: LaunchRecord; markdown: string } | undefined> { + const launch = await this.dependencies.repository.findById(launchId); + if (!launch) { + return undefined; + } + + const auditEvents = await this.dependencies.workspaceAdminService.listRecentAuditEventsForLaunch( + launch.workspaceId, + launch.id, + 8 + ); + + return { + launch, + markdown: buildLaunchCanvasMarkdown(launch, auditEvents) + }; + } + + buildOwnerReminderText(launch: LaunchRecord, roleName: string): string { + const ownerAssignment = launch.ownerAssignments.find((assignment) => assignment.roleName === roleName); + const approval = launch.approvals.find((item) => item.roleName === roleName); + if (!ownerAssignment) { + return `GoSignal has not assigned an owner for *${roleName}* on *${launch.name}* yet. Assign an owner first, then re-run the reminder.`; + } + + return ( + `Reminder for <@${ownerAssignment.userId}>: GoSignal still needs *${roleName}* on *${launch.name}*. ` + + `${approval?.reason ?? "Please reply in this thread with a clear sign-off or update the status."}` + ); + } + + async getLaunchHistory(launchId: string, limit = 8) { + const launch = await this.dependencies.repository.findById(launchId); + if (!launch) { + return undefined; + } + + const events = await this.dependencies.workspaceAdminService.listRecentAuditEventsForLaunch( + launch.workspaceId, + launch.id, + limit + ); + return { + launch, + events + }; + } + private async findLaunchFromContext( workspaceId: string, context: AppContextSnapshot | undefined @@ -120,4 +292,42 @@ export class LaunchService { sourceThreadTs: context.threadTs }); } + + private async refreshCanvas(client: WebClient, launch: LaunchRecord): Promise { + const auditEvents = await this.dependencies.workspaceAdminService.listRecentAuditEventsForLaunch( + launch.workspaceId, + launch.id, + 8 + ); + const markdown = buildLaunchCanvasMarkdown(launch, auditEvents); + const canvas = await this.dependencies.canvasGateway.createOrUpdate( + client, + launch.id, + launch.canvasId, + markdown, + `Launch brief: ${launch.name}` + ); + + launch.canvasId = canvas.canvasId; + launch.canvasLinkLabel = canvas.label; + } + + private async persistLaunchWithCanvas(client: WebClient, launch: LaunchRecord): Promise { + await this.refreshCanvas(client, launch); + return this.dependencies.repository.save(launch); + } +} + +function buildThreadOnlySearchContext(): SearchContextResult { + return { + evidence: [], + diagnostics: { + status: "unavailable", + note: "Live search is disabled for this workspace, so GoSignal used thread evidence only.", + resultCount: 0, + messageCount: 0, + fileCount: 0, + channelCount: 0 + } + }; } diff --git a/src/services/llmProvider.ts b/src/services/llmProvider.ts index d32b65c..20543f2 100644 --- a/src/services/llmProvider.ts +++ b/src/services/llmProvider.ts @@ -1,3 +1,4 @@ +import { LAUNCH_PROFILE_DEFINITIONS, titleCaseRole } from "../domain/constants.ts"; import type { LaunchRecord } from "../domain/types.ts"; export interface LLMProvider { @@ -141,8 +142,9 @@ export class CerebrasLLMProvider implements LLMProvider { } function buildDeterministicSummary(launch: LaunchRecord): string { - const topBlocker = launch.blockers.find((blocker) => blocker.status === "open"); + const topBlocker = launch.blockers.find((blocker) => blocker.status === "open"); const pendingApprovals = launch.approvals.filter((approval) => approval.state !== "approved"); + const missingRequirements = launch.requirementChecks.filter((requirement) => requirement.state !== "met"); const nonGreenCategories = launch.categories.filter((category) => category.state !== "green"); const lines = [`${launch.name} is ${launch.decision.overallState}.`, launch.decision.recommendation]; @@ -151,6 +153,8 @@ function buildDeterministicSummary(launch: LaunchRecord): string { lines.push(`Top blocker: ${topBlocker.title}.`); } else if (pendingApprovals.length > 0) { lines.push(`Still waiting on ${joinHumanList(pendingApprovals.map((approval) => approval.roleName))}.`); + } else if (missingRequirements.length > 0) { + lines.push(`Still missing ${joinHumanList(missingRequirements.map((requirement) => requirement.label.toLowerCase()))}.`); } else if (nonGreenCategories.length > 0) { lines.push(`Remaining watch areas: ${joinHumanList(nonGreenCategories.map((category) => category.name))}.`); } @@ -164,6 +168,7 @@ function buildDeterministicAnswer(launch: LaunchRecord, question: string): strin const normalizedQuestion = question.toLowerCase(); const topBlocker = launch.blockers.find((blocker) => blocker.status === "open"); const pendingApprovals = launch.approvals.filter((approval) => approval.state !== "approved"); + const missingRequirements = launch.requirementChecks.filter((requirement) => requirement.state !== "met"); const nonGreenCategories = launch.categories.filter((category) => category.state !== "green"); if (/(sign-?off|approval|approve|missing|who.*(approve|sign))/i.test(normalizedQuestion)) { @@ -175,9 +180,12 @@ function buildDeterministicAnswer(launch: LaunchRecord, question: string): strin } const firstPendingApproval = pendingApprovals[0]!; + const ownerAssignment = launch.ownerAssignments.find((assignment) => assignment.roleName === firstPendingApproval.roleName); return normalizeWhitespace( `${launch.name} is still waiting on ${joinHumanList(pendingApprovals.map((approval) => approval.roleName))}. ` + - `${firstPendingApproval.reason} Next action: ${launch.decision.nextAction}` + `${firstPendingApproval.reason} ` + + (ownerAssignment ? `Assigned owner: <@${ownerAssignment.userId}>. ` : "") + + `Next action: ${launch.decision.nextAction}` ); } @@ -196,6 +204,13 @@ function buildDeterministicAnswer(launch: LaunchRecord, question: string): strin ); } + if (missingRequirements.length > 0) { + return normalizeWhitespace( + `${launch.name} is still missing profile evidence for ${joinHumanList(missingRequirements.map((requirement) => requirement.label.toLowerCase()))}. ` + + `Current state is ${launch.decision.overallState}. Next action: ${launch.decision.nextAction}` + ); + } + return normalizeWhitespace( `GoSignal does not see an explicit open blocker for ${launch.name} right now. ` + `${launch.decision.recommendation} Next action: ${launch.decision.nextAction}` @@ -230,6 +245,15 @@ function buildLaunchContext(launch: LaunchRecord): string { const approvals = launch.approvals .map((approval) => `- ${approval.roleName}: ${approval.state} — ${approval.reason}`) .join("\n"); + const requirementChecks = launch.requirementChecks + .map((requirement) => `- ${requirement.label}: ${requirement.state} — ${requirement.reason}`) + .join("\n"); + const ownerAssignments = launch.ownerAssignments + .map( + (assignment) => + `- ${titleCaseRole(assignment.roleName)} owner: <@${assignment.userId}> (reminders ${assignment.reminderCount})` + ) + .join("\n"); const blockers = launch.blockers.length > 0 ? launch.blockers @@ -240,19 +264,30 @@ function buildLaunchContext(launch: LaunchRecord): string { .slice() .sort((left, right) => right.score - left.score) .slice(0, 6) - .map((item) => `- ${item.categoryName}: ${truncate(item.summary || item.text, 220)}`) + .map( + (item) => + `- ${item.categoryName}: [${sourceLabel(item.sourceType)} | ${item.freshness} | ${channelLabel(item)} | ${ageLabel(item.createdAt, launch.updatedAt)}] ` + + `${truncate(item.summary || item.text, 220)}` + ) .join("\n"); return [ `Launch: ${launch.name}`, + `Profile: ${LAUNCH_PROFILE_DEFINITIONS[launch.launchProfile].label}`, + `Workflow status: ${launch.status}`, `Overall state: ${launch.decision.overallState}`, `Confidence: ${launch.decision.confidence}`, `Recommendation: ${launch.decision.recommendation}`, `Next action: ${launch.decision.nextAction}`, + `Live search: ${launch.searchDiagnostics?.status ?? "not captured"} — ${launch.searchDiagnostics?.note ?? "No live search diagnostics captured."}`, "Categories:", categories || "- None", "Approvals:", approvals || "- None", + "Profile checks:", + requirementChecks || "- None", + "Owner assignments:", + ownerAssignments || "- None", "Blockers:", blockers, "Top evidence:", @@ -260,6 +295,53 @@ function buildLaunchContext(launch: LaunchRecord): string { ].join("\n"); } +function sourceLabel(sourceType: LaunchRecord["evidence"][number]["sourceType"]): string { + switch (sourceType) { + case "thread_message": + return "thread"; + case "search_message": + return "live search"; + case "search_file": + return "file"; + case "search_channel": + return "channel"; + } +} + +function channelLabel(item: LaunchRecord["evidence"][number]): string { + if (item.channelName) { + return `#${item.channelName}`; + } + if (item.sourceType === "thread_message") { + return "current thread"; + } + return item.channelId ?? "channel unknown"; +} + +function ageLabel(createdAt: string | undefined, referenceAt: string): string { + if (!createdAt) { + return "age unknown"; + } + + const createdTime = Date.parse(createdAt); + const referenceTime = Date.parse(referenceAt); + if (Number.isNaN(createdTime) || Number.isNaN(referenceTime)) { + return "age unknown"; + } + + const ageMinutes = Math.max(Math.floor((referenceTime - createdTime) / 60_000), 0); + if (ageMinutes < 60) { + return `${Math.max(ageMinutes, 1)}m old`; + } + + const ageHours = Math.floor(ageMinutes / 60); + if (ageHours < 48) { + return `${ageHours}h old`; + } + + return `${Math.floor(ageHours / 24)}d old`; +} + function extractContent(content: unknown): string | undefined { if (typeof content === "string") { return content; diff --git a/src/services/slackSources.ts b/src/services/slackSources.ts index e77ad14..90175f1 100644 --- a/src/services/slackSources.ts +++ b/src/services/slackSources.ts @@ -1,12 +1,18 @@ import type { WebClient } from "@slack/web-api"; -import type { SearchEvidenceRecord, SearchRequest, SlackMessageRecord } from "../domain/types.ts"; +import type { + SearchContextResult, + SearchDiagnostics, + SearchEvidenceRecord, + SearchRequest, + SlackMessageRecord +} from "../domain/types.ts"; export interface ThreadSource { fetchThread(client: WebClient, channelId: string, threadTs: string): Promise; } export interface SearchSource { - searchPublicContext(client: WebClient, request: SearchRequest): Promise; + searchPublicContext(client: WebClient, request: SearchRequest): Promise; } export interface CanvasGateway { @@ -69,9 +75,16 @@ function flattenSearchCollections(payload: Record): unknown[] { } export class SlackSearchSource implements SearchSource { - async searchPublicContext(client: WebClient, request: SearchRequest): Promise { + async searchPublicContext(client: WebClient, request: SearchRequest): Promise { if (!request.actionToken) { - return []; + return { + evidence: [], + diagnostics: buildDiagnostics( + "unavailable", + "Live search unavailable for this run because Slack did not provide an action token. Use the thread shortcut or mention GoSignal in-thread to add cross-channel evidence.", + [] + ) + }; } try { @@ -80,12 +93,34 @@ export class SlackSearchSource implements SearchSource { action_token: request.actionToken })) as unknown as Record; const collections = flattenSearchCollections(response); - - return collections + const evidence = collections .map((item, index) => normalizeSearchItem(item, index)) .filter((item): item is SearchEvidenceRecord => item !== undefined); + + return { + evidence, + diagnostics: + evidence.length > 0 + ? buildDiagnostics( + "used", + `Live search added ${evidence.length} public evidence item${evidence.length === 1 ? "" : "s"} from outside the current thread.`, + evidence + ) + : buildDiagnostics( + "empty", + "Live search ran for this thread but did not find additional public Slack evidence for the current launch.", + evidence + ) + }; } catch { - return []; + return { + evidence: [], + diagnostics: buildDiagnostics( + "unavailable", + "Live search was unavailable for this run, so GoSignal fell back to thread evidence only.", + [] + ) + }; } } } @@ -100,8 +135,21 @@ function normalizeSearchItem(item: unknown, index: number): SearchEvidenceRecord const permalink = typeof record.permalink === "string" ? record.permalink : undefined; const channel = record.channel as Record | undefined; const channelId = typeof channel?.id === "string" ? channel.id : undefined; - const title = typeof record.title === "string" ? record.title : `Search evidence ${index + 1}`; + const channelName = + typeof channel?.name === "string" + ? channel.name + : typeof record.channel_name === "string" + ? record.channel_name + : undefined; const sourceType = typeof record.filetype === "string" ? "search_file" : channelId ? "search_message" : "search_channel"; + const title = + typeof record.title === "string" + ? record.title + : sourceType === "search_message" + ? `Live search message ${index + 1}` + : sourceType === "search_file" + ? `Live search file ${index + 1}` + : `Live search channel ${index + 1}`; return { id: typeof record.id === "string" ? record.id : `search-${index}`, @@ -110,12 +158,51 @@ function normalizeSearchItem(item: unknown, index: number): SearchEvidenceRecord text, permalink, channelId, - messageTs: typeof record.ts === "string" ? record.ts : undefined, - createdAt: typeof record.created_at === "string" ? record.created_at : undefined, + channelName, + messageTs: normalizeSearchTimestamp(record.ts), + createdAt: normalizeSearchTimestamp(record.created_at), rawScore: typeof record.score === "number" ? record.score : undefined }; } +function normalizeSearchTimestamp(value: unknown): string | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return new Date(value * (value > 10_000_000_000 ? 1 : 1_000)).toISOString(); + } + + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + const numericValue = Number(trimmed); + if (!Number.isNaN(numericValue)) { + return new Date(numericValue * (trimmed.includes(".") || numericValue < 10_000_000_000 ? 1_000 : 1)).toISOString(); + } + + const parsed = Date.parse(trimmed); + return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString(); +} + +function buildDiagnostics(status: SearchDiagnostics["status"], note: string, evidence: SearchEvidenceRecord[]): SearchDiagnostics { + const messageCount = evidence.filter((item) => item.sourceType === "search_message").length; + const fileCount = evidence.filter((item) => item.sourceType === "search_file").length; + const channelCount = evidence.filter((item) => item.sourceType === "search_channel").length; + + return { + status, + note, + resultCount: evidence.length, + messageCount, + fileCount, + channelCount + }; +} + export class SlackCanvasGateway implements CanvasGateway { async createOrUpdate( client: WebClient, diff --git a/src/services/workspaceAdminService.ts b/src/services/workspaceAdminService.ts new file mode 100644 index 0000000..ad3afe7 --- /dev/null +++ b/src/services/workspaceAdminService.ts @@ -0,0 +1,150 @@ +import { randomUUID } from "node:crypto"; +import { DEFAULT_LAUNCH_PROFILE, LAUNCH_PROFILE_DEFINITIONS } from "../domain/constants.ts"; +import type { + AuditEventRecord, + AuditEventType, + LaunchProfileId, + WorkspaceSearchMode, + WorkspaceSettingsRecord +} from "../domain/types.ts"; +import type { AuditRepository, WorkspaceSettingsRepository } from "../repositories/workspaceAdminRepository.ts"; + +interface WorkspaceAdminServiceDependencies { + settingsRepository: WorkspaceSettingsRepository; + auditRepository: AuditRepository; + now?: () => Date; +} + +export interface WorkspaceSettingsUpdateInput { + workspaceId: string; + updatedByUserId: string; + searchMode: WorkspaceSearchMode; + auditRetentionDays: number; + defaultLaunchProfile: LaunchProfileId; +} + +export interface AuditEventInput { + workspaceId: string; + actorUserId: string; + eventType: AuditEventType; + summary: string; + launchId?: string; + metadata?: Record; +} + +const DEFAULT_AUDIT_RETENTION_DAYS = 30; + +export class WorkspaceAdminService { + private readonly settingsRepository: WorkspaceSettingsRepository; + private readonly auditRepository: AuditRepository; + private readonly now: () => Date; + + constructor(dependencies: WorkspaceAdminServiceDependencies) { + this.settingsRepository = dependencies.settingsRepository; + this.auditRepository = dependencies.auditRepository; + this.now = dependencies.now ?? (() => new Date()); + } + + async getSettings(workspaceId: string, fallbackUserId = "system"): Promise { + return (await this.settingsRepository.get(workspaceId)) ?? buildDefaultSettings(workspaceId, fallbackUserId, this.now()); + } + + async updateSettings(input: WorkspaceSettingsUpdateInput): Promise { + if (!Number.isInteger(input.auditRetentionDays) || input.auditRetentionDays < 1 || input.auditRetentionDays > 365) { + throw new Error("Audit retention days must be an integer between 1 and 365."); + } + + const now = this.now(); + const existing = await this.getSettings(input.workspaceId, input.updatedByUserId); + const updatedSettings: WorkspaceSettingsRecord = { + workspaceId: input.workspaceId, + searchMode: input.searchMode, + auditRetentionDays: input.auditRetentionDays, + defaultLaunchProfile: input.defaultLaunchProfile, + updatedByUserId: input.updatedByUserId, + createdAt: existing.createdAt, + updatedAt: now.toISOString() + }; + + const savedSettings = await this.settingsRepository.save(updatedSettings); + try { + await this.recordEvent({ + workspaceId: input.workspaceId, + actorUserId: input.updatedByUserId, + eventType: "workspace_settings_updated", + summary: + `Workspace settings updated: ${describeSearchMode(savedSettings.searchMode)} mode, ` + + `${LAUNCH_PROFILE_DEFINITIONS[savedSettings.defaultLaunchProfile].label} default profile, ` + + `${savedSettings.auditRetentionDays}-day audit retention.`, + metadata: { + previousSearchMode: existing.searchMode, + searchMode: savedSettings.searchMode, + previousAuditRetentionDays: existing.auditRetentionDays, + auditRetentionDays: savedSettings.auditRetentionDays, + previousDefaultLaunchProfile: existing.defaultLaunchProfile, + defaultLaunchProfile: savedSettings.defaultLaunchProfile + } + }); + } catch (error) { + console.warn("[GoSignal warning] Failed to write audit event for workspace settings update.", error); + } + + return savedSettings; + } + + async listRecentAuditEvents(workspaceId: string, limit = 5): Promise { + const settings = await this.getSettings(workspaceId); + const candidates = await this.auditRepository.listRecentForWorkspace(workspaceId, Math.max(limit * 5, limit)); + const cutoff = this.now().getTime() - settings.auditRetentionDays * 86_400_000; + + return candidates + .filter((event) => { + const timestamp = Date.parse(event.createdAt); + return Number.isNaN(timestamp) ? true : timestamp >= cutoff; + }) + .slice(0, limit); + } + + async listRecentAuditEventsForLaunch(workspaceId: string, launchId: string, limit = 8): Promise { + const settings = await this.getSettings(workspaceId); + const candidates = await this.auditRepository.listRecentForLaunch(workspaceId, launchId, Math.max(limit * 5, limit)); + const cutoff = this.now().getTime() - settings.auditRetentionDays * 86_400_000; + + return candidates + .filter((event) => { + const timestamp = Date.parse(event.createdAt); + return Number.isNaN(timestamp) ? true : timestamp >= cutoff; + }) + .slice(0, limit); + } + + async recordEvent(input: AuditEventInput): Promise { + return this.auditRepository.append({ + id: `audit-${randomUUID()}`, + workspaceId: input.workspaceId, + actorUserId: input.actorUserId, + eventType: input.eventType, + summary: input.summary, + launchId: input.launchId, + metadata: input.metadata, + createdAt: this.now().toISOString() + }); + } +} + +export function describeSearchMode(searchMode: WorkspaceSearchMode): string { + return searchMode === "thread_only" ? "thread-only" : "thread + live public search"; +} + +function buildDefaultSettings(workspaceId: string, updatedByUserId: string, now: Date): WorkspaceSettingsRecord { + const timestamp = now.toISOString(); + return { + workspaceId, + searchMode: "public_only", + auditRetentionDays: DEFAULT_AUDIT_RETENTION_DAYS, + defaultLaunchProfile: DEFAULT_LAUNCH_PROFILE, + updatedByUserId, + createdAt: timestamp, + updatedAt: timestamp + }; +} diff --git a/src/ui/appHome.ts b/src/ui/appHome.ts index 144ac60..c331d8e 100644 --- a/src/ui/appHome.ts +++ b/src/ui/appHome.ts @@ -1,15 +1,79 @@ import type { View } from "@slack/types"; -import type { LaunchRecord } from "../domain/types.ts"; +import { LAUNCH_PROFILE_DEFINITIONS, titleCaseRole } from "../domain/constants.ts"; +import type { + AuditEventRecord, + LaunchProfileId, + LaunchRecord, + WorkspaceSearchMode, + WorkspaceSettingsRecord +} from "../domain/types.ts"; +import { describeSearchMode } from "../services/workspaceAdminService.ts"; + +interface AppHomeViewInput { + launches: LaunchRecord[]; + settings: WorkspaceSettingsRecord; + auditEvents: AuditEventRecord[]; + responseMode: string; +} + +function formatTimestamp(isoString: string): string { + const date = new Date(isoString); + return Number.isNaN(date.getTime()) ? isoString : date.toLocaleString(); +} + +function summarizeLaunches(launches: LaunchRecord[]): { ready: number; hold: number; active: number; draft: number } { + return launches.reduce( + (counts, launch) => { + counts[launch.status] += 1; + return counts; + }, + { + ready: 0, + hold: 0, + active: 0, + draft: 0 + } + ); +} + +function launchProfileLabel(profileId: LaunchProfileId): string { + return LAUNCH_PROFILE_DEFINITIONS[profileId].label; +} function launchSection(launch: LaunchRecord): Record[] { + const missingApprovals = launch.approvals.filter((approval) => approval.state !== "approved"); + return [ { type: "section", text: { type: "mrkdwn", - text: `*${launch.name}*\nState: *${launch.decision.overallState}* · Confidence: *${launch.decision.confidence}*\n${launch.decision.summary}` + text: + `*${launch.name}*\n` + + `State: *${launch.decision.overallState}* · Confidence: *${launch.decision.confidence}* · Profile: *${launchProfileLabel(launch.launchProfile)}*\n` + + `${launch.decision.summary}` } }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `Updated ${formatTimestamp(launch.updatedAt)}` + }, + { + type: "mrkdwn", + text: `Search: ${launch.searchDiagnostics?.status ?? "not captured"}` + }, + { + type: "mrkdwn", + text: + missingApprovals.length > 0 + ? `Missing: ${missingApprovals.map((approval) => titleCaseRole(approval.roleName)).join(", ")}` + : "Missing: none" + } + ] + }, { type: "actions", elements: [ @@ -30,6 +94,24 @@ function launchSection(launch: LaunchRecord): Record[] { text: "Re-run" }, value: launch.id + }, + { + type: "button", + action_id: "gosignal_view_history", + text: { + type: "plain_text", + text: "View history" + }, + value: launch.id + }, + { + type: "button", + action_id: "gosignal_export_brief", + text: { + type: "plain_text", + text: "Export brief" + }, + value: launch.id } ] }, @@ -39,7 +121,95 @@ function launchSection(launch: LaunchRecord): Record[] { ]; } -export function buildAppHomeView(launches: LaunchRecord[]): View { +function auditSection(event: AuditEventRecord): Record { + return { + type: "section", + text: { + type: "mrkdwn", + text: `*${humanizeAuditEventType(event.eventType)}*\n${event.summary}` + }, + accessory: { + type: "button", + action_id: event.launchId ? "gosignal_open_launch" : "gosignal_refresh_home", + text: { + type: "plain_text", + text: event.launchId ? "Open launch" : "Refresh" + }, + value: event.launchId ?? "refresh_home" + } + }; +} + +function humanizeAuditEventType(eventType: AuditEventRecord["eventType"]): string { + return eventType + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function searchModeOption(value: WorkspaceSearchMode, label: string, description: string): Record { + return { + text: { + type: "plain_text", + text: label + }, + value, + description: { + type: "plain_text", + text: description + } + }; +} + +function launchProfileOption(profileId: LaunchProfileId): Record { + const profile = LAUNCH_PROFILE_DEFINITIONS[profileId]; + return { + text: { + type: "plain_text", + text: profile.label + }, + value: profile.id, + description: { + type: "plain_text", + text: profile.description.length > 75 ? `${profile.description.slice(0, 72)}...` : profile.description + } + }; +} + +function buildAtRiskLaunchText(launches: LaunchRecord[]): string { + const atRiskLaunches = launches.filter((launch) => launch.decision.overallState !== "green").slice(0, 3); + if (atRiskLaunches.length === 0) { + return "No at-risk launches in the recent workspace history."; + } + + return atRiskLaunches + .map((launch) => `• *${launch.name}* — ${launch.decision.overallState} · ${launch.decision.nextAction}`) + .join("\n"); +} + +function buildMissingApprovalsText(launches: LaunchRecord[]): string { + const items = launches + .flatMap((launch) => + launch.approvals + .filter((approval) => approval.state !== "approved") + .map((approval) => ({ + launchName: launch.name, + roleName: approval.roleName + })) + ) + .slice(0, 5); + + if (items.length === 0) { + return "No missing sign-offs across the recent workspace launches."; + } + + return items + .map((item) => `• *${item.launchName}* — ${titleCaseRole(item.roleName)}`) + .join("\n"); +} + +export function buildAppHomeView(input: AppHomeViewInput): View { + const counts = summarizeLaunches(input.launches); const blocks: Record[] = [ { type: "header", @@ -52,15 +222,135 @@ export function buildAppHomeView(launches: LaunchRecord[]): View { type: "section", text: { type: "mrkdwn", - text: "Analyze public launch threads, build a durable launch brief, and keep your go/no-go decision grounded in evidence." + text: + "Analyze public launch threads, build a durable launch brief, and keep your go/no-go decision grounded in evidence." + } + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `*Response mode*\n${input.responseMode}` + }, + { + type: "mrkdwn", + text: `*Live search mode*\n${describeSearchMode(input.settings.searchMode)}` + }, + { + type: "mrkdwn", + text: `*Default profile*\n${launchProfileLabel(input.settings.defaultLaunchProfile)}` + }, + { + type: "mrkdwn", + text: `*Audit retention*\n${input.settings.auditRetentionDays} days` + }, + { + type: "mrkdwn", + text: `*Settings updated*\n${formatTimestamp(input.settings.updatedAt)}` + } + ] + }, + { + type: "actions", + elements: [ + { + type: "button", + action_id: "gosignal_open_settings", + text: { + type: "plain_text", + text: "Workspace settings" + }, + value: input.settings.workspaceId + }, + { + type: "button", + action_id: "gosignal_refresh_home", + text: { + type: "plain_text", + text: "Refresh" + }, + value: input.settings.workspaceId + } + ] + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `*At-risk launches*\n${input.launches.filter((launch) => launch.decision.overallState !== "green").length}` + }, + { + type: "mrkdwn", + text: + `*Open sign-off gaps*\n${ + input.launches.reduce( + (count, launch) => count + launch.approvals.filter((approval) => approval.state !== "approved").length, + 0 + ) + }` + }, + { + type: "mrkdwn", + text: `*Recent holds*\n${counts.hold}` + }, + { + type: "mrkdwn", + text: `*Recent launches tracked*\n${input.launches.length}` + } + ] + }, + { + type: "divider" + }, + { + type: "header", + text: { + type: "plain_text", + text: "Operator Watchlist" + } + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `*Recent ready*\n${counts.ready}` + }, + { + type: "mrkdwn", + text: `*Recent active*\n${counts.active}` + } + ] + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `*At-risk launches*\n${buildAtRiskLaunchText(input.launches)}` + } + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `*Missing sign-offs*\n${buildMissingApprovalsText(input.launches)}` } }, { type: "divider" + }, + { + type: "header", + text: { + type: "plain_text", + text: "Recent Launches" + } } ]; - if (launches.length === 0) { + if (input.launches.length === 0) { blocks.push({ type: "section", text: { @@ -69,13 +359,146 @@ export function buildAppHomeView(launches: LaunchRecord[]): View { } }); } else { - for (const launch of launches) { + for (const launch of input.launches) { blocks.push(...launchSection(launch)); } } + blocks.push( + { + type: "header", + text: { + type: "plain_text", + text: "Recent Audit Events" + } + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `Showing the most recent workspace events within the current ${input.settings.auditRetentionDays}-day audit window.` + } + ] + } + ); + + if (input.auditEvents.length === 0) { + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: "No operator events recorded yet for this workspace." + } + }); + } else { + for (const event of input.auditEvents) { + blocks.push( + auditSection(event), + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `Actor: <@${event.actorUserId}> · ${formatTimestamp(event.createdAt)}` + } + ] + }, + { + type: "divider" + } + ); + } + } + return { type: "home", blocks } as unknown as View; } + +export function buildWorkspaceSettingsModal(settings: WorkspaceSettingsRecord): View { + return { + type: "modal", + callback_id: "gosignal_workspace_settings_submit", + private_metadata: settings.workspaceId, + title: { + type: "plain_text", + text: "Workspace settings" + }, + submit: { + type: "plain_text", + text: "Save" + }, + close: { + type: "plain_text", + text: "Cancel" + }, + blocks: [ + { + type: "input", + block_id: "search_mode", + label: { + type: "plain_text", + text: "Evidence search mode" + }, + element: { + type: "radio_buttons", + action_id: "value", + initial_option: searchModeOption( + settings.searchMode, + settings.searchMode === "public_only" ? "Thread + live search" : "Thread only", + settings.searchMode === "public_only" + ? "Use the launch thread plus public Slack search when Slack provides an action token." + : "Use only the current thread and explicitly disable live Slack search for this workspace." + ), + options: [ + searchModeOption( + "public_only", + "Thread + live search", + "Use the thread plus public Slack search when Slack provides an action token." + ), + searchModeOption( + "thread_only", + "Thread only", + "Disable live Slack search and rely only on the current thread." + ) + ] + } + }, + { + type: "input", + block_id: "default_launch_profile", + label: { + type: "plain_text", + text: "Default launch profile" + }, + element: { + type: "static_select", + action_id: "value", + initial_option: launchProfileOption(settings.defaultLaunchProfile), + options: Object.keys(LAUNCH_PROFILE_DEFINITIONS).map((profileId) => + launchProfileOption(profileId as LaunchProfileId) + ) + } + }, + { + type: "input", + block_id: "audit_retention_days", + label: { + type: "plain_text", + text: "Audit retention (days)" + }, + element: { + type: "plain_text_input", + action_id: "value", + initial_value: String(settings.auditRetentionDays), + placeholder: { + type: "plain_text", + text: "30" + } + } + } + ] + } as unknown as View; +} diff --git a/src/ui/blocks.ts b/src/ui/blocks.ts index b2bd4a0..ba80387 100644 --- a/src/ui/blocks.ts +++ b/src/ui/blocks.ts @@ -1,5 +1,5 @@ -import { buildApprovalReplyExample } from "../domain/constants.ts"; -import type { LaunchRecord, ReadinessState } from "../domain/types.ts"; +import { buildApprovalReplyExample, LAUNCH_PROFILE_DEFINITIONS, titleCaseRole } from "../domain/constants.ts"; +import type { EvidenceItem, EvidenceSourceType, LaunchRecord, ReadinessState, SearchEvidenceStatus } from "../domain/types.ts"; type SlackBlock = Record; @@ -20,6 +20,135 @@ function stateLabel(state: ReadinessState): string { return `${stateEmoji(state)} ${state.replace("_", " ")}`; } +function searchStatusLabel(status: SearchEvidenceStatus | undefined): string { + switch (status) { + case "used": + return "Used"; + case "empty": + return "No extra results"; + case "unavailable": + return "Unavailable"; + default: + return "Not captured"; + } +} + +function requirementStateLabel(state: LaunchRecord["requirementChecks"][number]["state"]): string { + switch (state) { + case "met": + return ":white_check_mark: met"; + case "missing": + return ":warning: missing"; + case "needs_review": + return ":white_circle: needs review"; + } +} + +function sourceLabel(sourceType: EvidenceSourceType): string { + switch (sourceType) { + case "thread_message": + return "thread"; + case "search_message": + return "live search"; + case "search_file": + return "file"; + case "search_channel": + return "channel"; + } +} + +function channelLabel(item: Pick): string { + if (item.channelName) { + return `#${item.channelName}`; + } + if (item.sourceType === "thread_message") { + return "current thread"; + } + if (item.channelId) { + return item.channelId; + } + return "channel unknown"; +} + +function ageLabel(createdAt: string | undefined, referenceAt: string): string { + if (!createdAt) { + return "age unknown"; + } + + const createdTime = Date.parse(createdAt); + const referenceTime = Date.parse(referenceAt); + if (Number.isNaN(createdTime) || Number.isNaN(referenceTime)) { + return "age unknown"; + } + + const ageMinutes = Math.max(Math.floor((referenceTime - createdTime) / 60_000), 0); + if (ageMinutes < 60) { + return `${Math.max(ageMinutes, 1)}m old`; + } + + const ageHours = Math.floor(ageMinutes / 60); + if (ageHours < 48) { + return `${ageHours}h old`; + } + + return `${Math.floor(ageHours / 24)}d old`; +} + +function truncate(text: string, limit: number): string { + if (text.length <= limit) { + return text; + } + return `${text.slice(0, limit - 3).trimEnd()}...`; +} + +function evidenceLink(item: EvidenceItem): string { + if (!item.permalink) { + return item.title; + } + return `<${item.permalink}|${item.title}>`; +} + +function evidenceMetadata(item: EvidenceItem, referenceAt: string): string { + return `${sourceLabel(item.sourceType)} | ${item.freshness} | ${channelLabel(item)} | ${ageLabel(item.createdAt, referenceAt)}`; +} + +function formatEvidenceReceipt(item: EvidenceItem, referenceAt: string): string { + return `• ${evidenceLink(item)} - ${evidenceMetadata(item, referenceAt)} - ${truncate(item.summary, 110)}`; +} + +function collectEvidenceCounts(launch: LaunchRecord): { + threadCount: number; + searchMessageCount: number; + fileCount: number; + channelCount: number; +} { + return launch.evidence.reduce( + (counts, item) => { + switch (item.sourceType) { + case "thread_message": + counts.threadCount += 1; + break; + case "search_message": + counts.searchMessageCount += 1; + break; + case "search_file": + counts.fileCount += 1; + break; + case "search_channel": + counts.channelCount += 1; + break; + } + return counts; + }, + { + threadCount: 0, + searchMessageCount: 0, + fileCount: 0, + channelCount: 0 + } + ); +} + function button(actionId: string, text: string, value: string, style?: "primary" | "danger"): SlackBlock { return { type: "button", @@ -33,9 +162,44 @@ function button(actionId: string, text: string, value: string, style?: "primary" }; } +function encodeActionValue(payload: Record): string { + return JSON.stringify(payload); +} + export function buildLaunchBlocks(launch: LaunchRecord, responseText = launch.decision.summary): SlackBlock[] { const topBlocker = launch.blockers.find((blocker) => blocker.status === "open"); const missingApproval = launch.approvals.find((approval) => approval.state !== "approved"); + const missingApprovalOwner = missingApproval + ? launch.ownerAssignments.find((assignment) => assignment.roleName === missingApproval.roleName) + : undefined; + const nonMetRequirements = launch.requirementChecks.filter((requirement) => requirement.state !== "met"); + const evidenceCounts = collectEvidenceCounts(launch); + const searchReceipts = launch.evidence.filter((item) => item.sourceType !== "thread_message").slice(0, 3); + const evidenceById = new Map(launch.evidence.map((item) => [item.id, item])); + const topBlockerEvidence = topBlocker ? evidenceById.get(topBlocker.evidenceIds[0] ?? "") : undefined; + const missingApprovalEvidence = missingApproval ? evidenceById.get(missingApproval.evidenceIds[0] ?? "") : undefined; + const primaryActions: SlackBlock[] = [ + button("gosignal_rerun", "Re-run readiness", launch.id, "primary"), + ...(missingApproval ? [button("gosignal_request_signoff", "Request sign-off", launch.id)] : []), + button("gosignal_open_canvas", "Open launch brief", launch.id), + ...(missingApproval ? [button("gosignal_assign_owner", "Assign owner", launch.id)] : []), + button("gosignal_view_history", "View history", launch.id) + ].slice(0, 5); + const secondaryActions: SlackBlock[] = [ + button("gosignal_export_brief", "Export brief", launch.id), + ...(missingApprovalOwner + ? [ + button( + "gosignal_remind_owner", + "Remind owner", + encodeActionValue({ + launchId: launch.id, + roleName: missingApprovalOwner.roleName + }) + ) + ] + : []) + ]; return [ { @@ -65,6 +229,27 @@ export function buildLaunchBlocks(launch: LaunchRecord, responseText = launch.de text: `*Recommendation:* ${launch.decision.recommendation}\n*Summary:* ${responseText}\n*Next action:* ${launch.decision.nextAction}` } }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `*Launch profile*\n${LAUNCH_PROFILE_DEFINITIONS[launch.launchProfile].label}` + }, + { + type: "mrkdwn", + text: `*Workflow status*\n${launch.status}` + }, + { + type: "mrkdwn", + text: `*Confidence*\n${launch.decision.confidence}` + }, + { + type: "mrkdwn", + text: `*Live search*\n${searchStatusLabel(launch.searchDiagnostics?.status)}` + } + ] + }, { type: "section", fields: launch.categories.map((category) => ({ @@ -72,13 +257,75 @@ export function buildLaunchBlocks(launch: LaunchRecord, responseText = launch.de text: `*${category.name}*\n${stateLabel(category.state)}` })) }, + { + type: "section", + text: { + type: "mrkdwn", + text: + `*Profile checks*\n${ + launch.requirementChecks.length > 0 + ? launch.requirementChecks + .map( + (requirement) => + `• ${requirement.label} — ${requirementStateLabel(requirement.state)} · ${requirement.reason}` + ) + .join("\n") + : "No profile-specific evidence checks were recorded." + }` + } + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `*Thread evidence*\n${evidenceCounts.threadCount}` + }, + { + type: "mrkdwn", + text: `*Live search messages*\n${evidenceCounts.searchMessageCount}` + }, + { + type: "mrkdwn", + text: `*File evidence*\n${evidenceCounts.fileCount}` + }, + { + type: "mrkdwn", + text: `*Channel evidence*\n${evidenceCounts.channelCount}` + } + ] + }, + { + type: "section", + text: { + type: "mrkdwn", + text: + `*Live search diagnostics*\n` + + `Status: *${searchStatusLabel(launch.searchDiagnostics?.status)}*\n` + + `${launch.searchDiagnostics?.note ?? "GoSignal did not capture live search diagnostics for this run."}` + + (launch.searchQuery ? `\n_Query:_ \`${launch.searchQuery}\`` : "") + } + }, + ...(searchReceipts.length > 0 + ? [ + { + type: "section", + text: { + type: "mrkdwn", + text: `*Live search receipts*\n${searchReceipts.map((item) => formatEvidenceReceipt(item, launch.updatedAt)).join("\n")}` + } + } satisfies SlackBlock + ] + : []), ...(topBlocker ? [ { type: "section", text: { type: "mrkdwn", - text: `*Top blocker:* ${topBlocker.title}\n${topBlocker.description}` + text: + `*Top blocker:* ${topBlocker.title}\n${topBlocker.description}` + + (topBlockerEvidence ? `\n_Evidence:_ ${evidenceMetadata(topBlockerEvidence, launch.updatedAt)}` : "") } } satisfies SlackBlock ] @@ -89,19 +336,48 @@ export function buildLaunchBlocks(launch: LaunchRecord, responseText = launch.de type: "section", text: { type: "mrkdwn", - text: `*Missing sign-off:* ${missingApproval.roleName}\n${missingApproval.reason}\n*Reply template:* \`${buildApprovalReplyExample(missingApproval.roleName)}\`` + text: + `*Missing sign-off:* ${missingApproval.roleName}\n${missingApproval.reason}` + + (missingApprovalEvidence ? `\n_Evidence:_ ${evidenceMetadata(missingApprovalEvidence, launch.updatedAt)}` : "") + + (missingApprovalOwner + ? `\n*Assigned owner:* <@${missingApprovalOwner.userId}> · reminders sent: ${missingApprovalOwner.reminderCount}` + : "") + + `\n*Reply template:* \`${buildApprovalReplyExample(missingApproval.roleName)}\`` + } + } satisfies SlackBlock + ] + : []), + ...(launch.ownerAssignments.length > 0 + ? [ + { + type: "section", + text: { + type: "mrkdwn", + text: + `*Owner assignments*\n` + + launch.ownerAssignments + .map( + (assignment) => + `• ${titleCaseRole(assignment.roleName)} → <@${assignment.userId}> ` + + `(assigned by <@${assignment.assignedByUserId}>, reminders ${assignment.reminderCount})` + ) + .join("\n") } } satisfies SlackBlock ] : []), { type: "actions", - elements: [ - button("gosignal_rerun", "Re-run readiness", launch.id, "primary"), - button("gosignal_request_signoff", "Request sign-off", launch.id), - button("gosignal_open_canvas", "Open launch brief", launch.id) - ] - } + elements: primaryActions + }, + ...(secondaryActions.length > 0 + ? [ + { + type: "actions", + elements: secondaryActions + } satisfies SlackBlock + ] + : []) ]; } @@ -128,6 +404,14 @@ export function buildDmReplyBlocks(launch: LaunchRecord, responseText = launch.d { type: "mrkdwn", text: `*Confidence*\n${launch.decision.confidence}` + }, + { + type: "mrkdwn", + text: `*Profile*\n${LAUNCH_PROFILE_DEFINITIONS[launch.launchProfile].label}` + }, + { + type: "mrkdwn", + text: `*Live search*\n${searchStatusLabel(launch.searchDiagnostics?.status)}` } ] }, @@ -146,5 +430,13 @@ export function buildSignoffRequestText(launch: LaunchRecord): string { if (!missingApproval) { return `GoSignal could not find a missing sign-off for ${launch.name}.`; } - return `GoSignal still needs *${missingApproval.roleName}* before recommending green for *${launch.name}*. Please ask the owner to reply in this thread with a clear sign-off like: "${buildApprovalReplyExample(missingApproval.roleName)}"`; + + const ownerAssignment = launch.ownerAssignments.find((assignment) => assignment.roleName === missingApproval.roleName); + return ( + `GoSignal still needs *${missingApproval.roleName}* before recommending green for *${launch.name}*. ` + + (ownerAssignment + ? `Assigned owner: <@${ownerAssignment.userId}>. ` + : "") + + `Please ask the owner to reply in this thread with a clear sign-off like: "${buildApprovalReplyExample(missingApproval.roleName)}"` + ); } diff --git a/src/ui/canvas.ts b/src/ui/canvas.ts index 15ea690..7860a88 100644 --- a/src/ui/canvas.ts +++ b/src/ui/canvas.ts @@ -1,4 +1,5 @@ -import type { LaunchRecord } from "../domain/types.ts"; +import { LAUNCH_PROFILE_DEFINITIONS, titleCaseRole } from "../domain/constants.ts"; +import type { AuditEventRecord, EvidenceItem, EvidenceSourceType, LaunchRecord } from "../domain/types.ts"; function markdownLink(label: string, href: string | undefined): string { if (!href) { @@ -7,7 +8,131 @@ function markdownLink(label: string, href: string | undefined): string { return `[${label}](${href})`; } -export function buildLaunchCanvasMarkdown(launch: LaunchRecord): string { +function sourceLabel(sourceType: EvidenceSourceType): string { + switch (sourceType) { + case "thread_message": + return "thread"; + case "search_message": + return "live search"; + case "search_file": + return "file"; + case "search_channel": + return "channel"; + } +} + +function channelLabel(item: Pick): string { + if (item.channelName) { + return `#${item.channelName}`; + } + if (item.sourceType === "thread_message") { + return "current thread"; + } + if (item.channelId) { + return item.channelId; + } + return "channel unknown"; +} + +function ageLabel(createdAt: string | undefined, referenceAt: string): string { + if (!createdAt) { + return "age unknown"; + } + + const createdTime = Date.parse(createdAt); + const referenceTime = Date.parse(referenceAt); + if (Number.isNaN(createdTime) || Number.isNaN(referenceTime)) { + return "age unknown"; + } + + const ageMinutes = Math.max(Math.floor((referenceTime - createdTime) / 60_000), 0); + if (ageMinutes < 60) { + return `${Math.max(ageMinutes, 1)}m old`; + } + + const ageHours = Math.floor(ageMinutes / 60); + if (ageHours < 48) { + return `${ageHours}h old`; + } + + return `${Math.floor(ageHours / 24)}d old`; +} + +function truncate(text: string, limit: number): string { + if (text.length <= limit) { + return text; + } + return `${text.slice(0, limit - 3).trimEnd()}...`; +} + +function collectEvidenceCounts(launch: LaunchRecord): { + threadCount: number; + searchMessageCount: number; + fileCount: number; + channelCount: number; +} { + return launch.evidence.reduce( + (counts, item) => { + switch (item.sourceType) { + case "thread_message": + counts.threadCount += 1; + break; + case "search_message": + counts.searchMessageCount += 1; + break; + case "search_file": + counts.fileCount += 1; + break; + case "search_channel": + counts.channelCount += 1; + break; + } + return counts; + }, + { + threadCount: 0, + searchMessageCount: 0, + fileCount: 0, + channelCount: 0 + } + ); +} + +function evidenceMetadata(item: EvidenceItem, referenceAt: string): string { + return `${sourceLabel(item.sourceType)} | ${item.freshness} | ${channelLabel(item)} | ${ageLabel(item.createdAt, referenceAt)}`; +} + +function formatEvidenceLine(item: EvidenceItem, referenceAt: string): string { + return `- ${markdownLink(item.title, item.permalink)} — ${evidenceMetadata(item, referenceAt)} — ${truncate(item.summary, 160)}`; +} + +function approvalLine(launch: LaunchRecord, approval: LaunchRecord["approvals"][number]): string { + const ownerAssignment = launch.ownerAssignments.find((assignment) => assignment.roleName === approval.roleName); + const approver = approval.approverUserId ? ` · approver <@${approval.approverUserId}>` : ""; + const owner = ownerAssignment + ? ` · owner <@${ownerAssignment.userId}> (reminders ${ownerAssignment.reminderCount})` + : ""; + return `- **${titleCaseRole(approval.roleName)}:** ${approval.state}${approver}${owner} — ${approval.reason}`; +} + +function requirementLine(requirement: LaunchRecord["requirementChecks"][number]): string { + return `- **${requirement.label}:** ${requirement.state} — ${requirement.reason}`; +} + +function ownerAssignmentLine(assignment: LaunchRecord["ownerAssignments"][number]): string { + return ( + `- **${titleCaseRole(assignment.roleName)}:** <@${assignment.userId}> ` + + `assigned by <@${assignment.assignedByUserId}> on ${assignment.assignedAt}` + + (assignment.lastRemindedAt ? ` · last reminded ${assignment.lastRemindedAt}` : "") + + ` · reminders ${assignment.reminderCount}` + ); +} + +function auditLine(event: AuditEventRecord): string { + return `- **${event.eventType.replace(/_/g, " ")}:** ${event.summary} (${event.createdAt}, <@${event.actorUserId}>)`; +} + +export function buildLaunchCanvasMarkdown(launch: LaunchRecord, auditEvents: AuditEventRecord[] = []): string { const categoryLines = launch.categories .map((category) => `- **${category.name}:** ${category.state} — ${category.summary}`) .join("\n"); @@ -17,17 +142,26 @@ export function buildLaunchCanvasMarkdown(launch: LaunchRecord): string { .map((blocker) => `- **${blocker.title}** (${blocker.severity}) — ${blocker.description}`) .join("\n") : "- No open blockers."; - const approvalLines = launch.approvals - .map((approval) => `- **${approval.roleName}:** ${approval.state} — ${approval.reason}`) + const approvalLines = launch.approvals.map((approval) => approvalLine(launch, approval)).join("\n"); + const requirementLines = launch.requirementChecks.map((requirement) => requirementLine(requirement)).join("\n"); + const ownerAssignmentLines = launch.ownerAssignments.map((assignment) => ownerAssignmentLine(assignment)).join("\n"); + const evidenceCounts = collectEvidenceCounts(launch); + const searchReceiptLines = launch.evidence + .filter((item) => item.sourceType !== "thread_message") + .slice(0, 5) + .map((item) => formatEvidenceLine(item, launch.updatedAt)) .join("\n"); const evidenceLines = launch.evidence - .slice(0, 10) - .map((item) => `- ${markdownLink(item.title, item.permalink)} — ${item.summary}`) + .slice(0, 12) + .map((item) => formatEvidenceLine(item, launch.updatedAt)) .join("\n"); + const auditLines = auditEvents.map((event) => auditLine(event)).join("\n"); return [ `# Launch brief: ${launch.name}`, "", + `- **Workflow status:** ${launch.status}`, + `- **Launch profile:** ${LAUNCH_PROFILE_DEFINITIONS[launch.launchProfile].label}`, `- **Overall state:** ${launch.decision.overallState}`, `- **Confidence:** ${launch.decision.confidence}`, `- **Recommendation:** ${launch.decision.recommendation}`, @@ -36,15 +170,38 @@ export function buildLaunchCanvasMarkdown(launch: LaunchRecord): string { "## Status by area", categoryLines, "", + "## Evidence used", + `- **Thread evidence:** ${evidenceCounts.threadCount}`, + `- **Live search messages:** ${evidenceCounts.searchMessageCount}`, + `- **File evidence:** ${evidenceCounts.fileCount}`, + `- **Channel evidence:** ${evidenceCounts.channelCount}`, + "", + "## Live search diagnostics", + `- **Status:** ${launch.searchDiagnostics?.status ?? "not captured"}`, + `- **Note:** ${launch.searchDiagnostics?.note ?? "GoSignal did not capture live search diagnostics for this run."}`, + `- **Query:** ${launch.searchQuery ?? "Not recorded"}`, + "", + "## Live search receipts", + searchReceiptLines || "- No live search receipts were captured on this run.", + "", "## Open blockers", blockerLines, "", "## Required approvals", approvalLines, "", + "## Profile checks", + requirementLines || "- No profile-specific evidence checks were recorded.", + "", + "## Owner assignments", + ownerAssignmentLines || "- No owners have been assigned yet.", + "", "## Why GoSignal said this", launch.decision.rationale.map((line) => `- ${line}`).join("\n"), "", + "## Recent audit trail", + auditLines || "- No launch-specific audit events recorded yet.", + "", "## Evidence", evidenceLines || "- No evidence captured." ].join("\n"); diff --git a/src/ui/launchModals.ts b/src/ui/launchModals.ts new file mode 100644 index 0000000..1b31409 --- /dev/null +++ b/src/ui/launchModals.ts @@ -0,0 +1,257 @@ +import type { View } from "@slack/types"; +import { LAUNCH_PROFILE_DEFINITIONS, titleCaseRole } from "../domain/constants.ts"; +import type { AuditEventRecord, LaunchRecord } from "../domain/types.ts"; + +type SlackBlock = Record; + +function formatTimestamp(isoString: string): string { + const date = new Date(isoString); + return Number.isNaN(date.getTime()) ? isoString : date.toLocaleString(); +} + +function humanizeAuditEventType(eventType: AuditEventRecord["eventType"]): string { + return eventType + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function chunkText(text: string, limit = 2_600): string[] { + if (text.length <= limit) { + return [text]; + } + + const chunks: string[] = []; + let cursor = 0; + while (cursor < text.length) { + const end = Math.min(cursor + limit, text.length); + chunks.push(text.slice(cursor, end)); + cursor = end; + } + return chunks; +} + +function ownerAssignmentSummary(launch: LaunchRecord): string { + if (launch.ownerAssignments.length === 0) { + return "No owner assignments recorded yet."; + } + + return launch.ownerAssignments + .map( + (assignment) => + `• ${titleCaseRole(assignment.roleName)} → <@${assignment.userId}> ` + + `(assigned ${formatTimestamp(assignment.assignedAt)}, reminders ${assignment.reminderCount})` + ) + .join("\n"); +} + +export function buildOwnerAssignmentModal(launch: LaunchRecord): View { + const roleOptions = launch.approvals + .filter((approval) => approval.state !== "approved") + .map((approval) => ({ + text: { + type: "plain_text", + text: titleCaseRole(approval.roleName) + }, + value: approval.roleName, + description: { + type: "plain_text", + text: approval.reason.slice(0, 75) + } + })); + + const fallbackOptions = + roleOptions.length > 0 + ? roleOptions + : launch.approvals.map((approval) => ({ + text: { + type: "plain_text", + text: titleCaseRole(approval.roleName) + }, + value: approval.roleName, + description: { + type: "plain_text", + text: approval.reason.slice(0, 75) + } + })); + + return { + type: "modal", + callback_id: "gosignal_assign_owner_submit", + private_metadata: launch.id, + title: { + type: "plain_text", + text: "Assign owner" + }, + submit: { + type: "plain_text", + text: "Assign" + }, + close: { + type: "plain_text", + text: "Cancel" + }, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: + `*${launch.name}*\n` + + `Profile: *${LAUNCH_PROFILE_DEFINITIONS[launch.launchProfile].label}* · ` + + `State: *${launch.decision.overallState}*` + } + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `*Current owner assignments*\n${ownerAssignmentSummary(launch)}` + } + }, + { + type: "input", + block_id: "role_name", + label: { + type: "plain_text", + text: "Missing sign-off" + }, + element: { + type: "static_select", + action_id: "value", + initial_option: fallbackOptions[0], + options: fallbackOptions + } + }, + { + type: "input", + block_id: "owner_user", + label: { + type: "plain_text", + text: "Owner" + }, + element: { + type: "users_select", + action_id: "value", + placeholder: { + type: "plain_text", + text: "Choose a user" + } + } + } + ] + } as unknown as View; +} + +export function buildLaunchHistoryModal(launch: LaunchRecord, events: AuditEventRecord[]): View { + const blocks: SlackBlock[] = [ + { + type: "section", + text: { + type: "mrkdwn", + text: + `*${launch.name}*\n` + + `Profile: *${LAUNCH_PROFILE_DEFINITIONS[launch.launchProfile].label}* · ` + + `State: *${launch.decision.overallState}*` + } + } + ]; + + if (events.length === 0) { + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: "No launch-specific audit history has been recorded yet." + } + }); + } else { + for (const event of events) { + blocks.push( + { + type: "section", + text: { + type: "mrkdwn", + text: `*${humanizeAuditEventType(event.eventType)}*\n${event.summary}` + } + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `Actor: <@${event.actorUserId}> · ${formatTimestamp(event.createdAt)}` + } + ] + }, + { + type: "divider" + } + ); + } + } + + return { + type: "modal", + callback_id: "gosignal_launch_history", + title: { + type: "plain_text", + text: "Launch history" + }, + close: { + type: "plain_text", + text: "Close" + }, + blocks + } as unknown as View; +} + +export function buildLaunchExportModal(launch: LaunchRecord, markdown: string): View { + const blocks: SlackBlock[] = [ + { + type: "section", + text: { + type: "mrkdwn", + text: + `*${launch.name} export*\n` + + `Profile: *${LAUNCH_PROFILE_DEFINITIONS[launch.launchProfile].label}* · ` + + `State: *${launch.decision.overallState}*` + } + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: launch.canvasId + ? `Canvas reference: ${launch.canvasLinkLabel ?? launch.canvasId}` + : "Canvas reference: not created yet" + } + ] + } + ]; + + for (const chunk of chunkText(markdown)) { + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: `\`\`\`\n${chunk}\n\`\`\`` + } + }); + } + + return { + type: "modal", + callback_id: "gosignal_launch_export", + title: { + type: "plain_text", + text: "Export brief" + }, + close: { + type: "plain_text", + text: "Close" + }, + blocks + } as unknown as View; +} diff --git a/test/appHome.test.ts b/test/appHome.test.ts new file mode 100644 index 0000000..3aa424e --- /dev/null +++ b/test/appHome.test.ts @@ -0,0 +1,85 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildAppHomeView, buildWorkspaceSettingsModal } from "../src/ui/appHome.ts"; +import { evaluateLaunchReadiness } from "../src/domain/readiness.ts"; +import type { AuditEventRecord, WorkspaceSettingsRecord } from "../src/domain/types.ts"; + +function sampleLaunch() { + const launch = evaluateLaunchReadiness({ + key: { + workspaceId: "T123", + sourceChannelId: "C123", + sourceThreadTs: "1000.0001" + }, + name: "Checkout release", + createdByUserId: "U123", + launchProfile: "saas_release", + threadMessages: [ + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0001", + text: "Engineering lead approved. <@UENG>", + createdAt: "2026-07-09T09:00:00Z" + } + ], + searchEvidence: [], + now: new Date("2026-07-11T12:00:00Z") + }); + + launch.searchDiagnostics = { + status: "empty", + note: "Live search ran but found no extra evidence.", + resultCount: 0, + messageCount: 0, + fileCount: 0, + channelCount: 0 + }; + + return launch; +} + +const settings: WorkspaceSettingsRecord = { + workspaceId: "T123", + searchMode: "public_only", + auditRetentionDays: 30, + defaultLaunchProfile: "saas_release", + updatedByUserId: "UADMIN", + createdAt: "2026-07-11T11:00:00Z", + updatedAt: "2026-07-11T12:00:00Z" +}; + +const auditEvents: AuditEventRecord[] = [ + { + id: "audit-1", + workspaceId: "T123", + actorUserId: "UADMIN", + eventType: "workspace_settings_updated", + summary: "Workspace settings updated: thread + live public search mode, 30-day audit retention.", + createdAt: "2026-07-11T12:00:00Z" + } +]; + +test("buildAppHomeView includes workspace controls and recent audit events", () => { + const view = buildAppHomeView({ + launches: [sampleLaunch()], + settings, + auditEvents, + responseMode: "deterministic (LLM summaries disabled)" + }); + + const blocks = (view as { blocks?: Array<{ type?: string; text?: { text?: string }; fields?: Array<{ text?: string }> }> }).blocks ?? []; + assert.ok(blocks.some((block) => block.text?.text?.includes("Recent Audit Events"))); + assert.ok(blocks.some((block) => block.fields?.some((field) => field.text?.includes("Live search mode")))); + assert.ok(blocks.some((block) => block.fields?.some((field) => field.text?.includes("Audit retention")))); + assert.ok(blocks.some((block) => block.fields?.some((field) => field.text?.includes("Default profile")))); +}); + +test("buildWorkspaceSettingsModal preloads the current workspace settings", () => { + const modal = buildWorkspaceSettingsModal(settings); + const blocks = (modal as { blocks?: Array<{ block_id?: string; element?: { initial_value?: string } }> }).blocks ?? []; + const retentionInput = blocks.find((block) => block.block_id === "audit_retention_days"); + + assert.equal((modal as { private_metadata?: string }).private_metadata, "T123"); + assert.equal(retentionInput?.element?.initial_value, "30"); +}); diff --git a/test/canvas.test.ts b/test/canvas.test.ts index 052a91b..9fb7d52 100644 --- a/test/canvas.test.ts +++ b/test/canvas.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { buildLaunchCanvasMarkdown } from "../src/ui/canvas.ts"; import { evaluateLaunchReadiness } from "../src/domain/readiness.ts"; -test("renders key launch sections into markdown canvas output", () => { +test("renders evidence usage and live search diagnostics into canvas markdown", () => { const launch = evaluateLaunchReadiness({ key: { workspaceId: "T123", @@ -12,6 +12,7 @@ test("renders key launch sections into markdown canvas output", () => { }, name: "Checkout release", createdByUserId: "U123", + launchProfile: "saas_release", threadMessages: [ { channelId: "C123", @@ -22,13 +23,37 @@ test("renders key launch sections into markdown canvas output", () => { permalink: "https://example.com/eng" } ], - searchEvidence: [], + searchEvidence: [ + { + id: "search-1", + sourceType: "search_message", + title: "Support blocker", + text: "Support readiness is still missing sign-off for this launch.", + channelId: "C444", + channelName: "support-readiness", + permalink: "https://example.com/support", + createdAt: "2026-07-09T11:30:00Z" + } + ], now: new Date("2026-07-09T12:00:00Z") }); + launch.searchQuery = "Find public Slack evidence for Checkout release"; + launch.searchDiagnostics = { + status: "used", + note: "Live search added public evidence from outside the current thread.", + resultCount: 1, + messageCount: 1, + fileCount: 0, + channelCount: 0 + }; + const markdown = buildLaunchCanvasMarkdown(launch); assert.match(markdown, /# Launch brief: Checkout release/); assert.match(markdown, /## Status by area/); - assert.match(markdown, /## Required approvals/); + assert.match(markdown, /## Evidence used/); + assert.match(markdown, /## Live search diagnostics/); + assert.match(markdown, /## Live search receipts/); + assert.match(markdown, /support-readiness/); assert.match(markdown, /## Evidence/); }); diff --git a/test/launchService.test.ts b/test/launchService.test.ts index b0aee4f..b320fcf 100644 --- a/test/launchService.test.ts +++ b/test/launchService.test.ts @@ -5,7 +5,9 @@ import { LaunchService } from "../src/services/launchService.ts"; import { MemoryLaunchRepository } from "../src/repositories/memoryLaunchRepository.ts"; import { DeterministicSummaryProvider } from "../src/services/llmProvider.ts"; import type { CanvasGateway, SearchSource, ThreadSource } from "../src/services/slackSources.ts"; -import type { SearchEvidenceRecord, SlackMessageRecord } from "../src/domain/types.ts"; +import type { SearchContextResult, SearchEvidenceRecord, SearchRequest, SlackMessageRecord } from "../src/domain/types.ts"; +import { MemoryWorkspaceAdminRepository } from "../src/repositories/workspaceAdminRepository.ts"; +import { WorkspaceAdminService } from "../src/services/workspaceAdminService.ts"; class FakeThreadSource implements ThreadSource { private readonly messages: SlackMessageRecord[]; @@ -20,14 +22,31 @@ class FakeThreadSource implements ThreadSource { } class FakeSearchSource implements SearchSource { + readonly requests: SearchRequest[] = []; private readonly evidence: SearchEvidenceRecord[]; constructor(evidence: SearchEvidenceRecord[]) { this.evidence = evidence; } - async searchPublicContext(): Promise { - return this.evidence; + async searchPublicContext(_client: WebClient, request: SearchRequest): Promise { + this.requests.push(request); + + return { + evidence: this.evidence, + diagnostics: { + status: this.evidence.length > 0 ? "used" : request.actionToken ? "empty" : "unavailable", + note: this.evidence.length > 0 + ? "Live search added public evidence." + : request.actionToken + ? "Live search ran but found no extra evidence." + : "Live search unavailable because no action token was provided.", + resultCount: this.evidence.length, + messageCount: this.evidence.filter((item) => item.sourceType === "search_message").length, + fileCount: this.evidence.filter((item) => item.sourceType === "search_file").length, + channelCount: this.evidence.filter((item) => item.sourceType === "search_channel").length + } + }; } } @@ -40,53 +59,115 @@ class FakeCanvasGateway implements CanvasGateway { } } -test("analyzeThread persists a launch and resolveLaunchForDmQuery finds it from context", async () => { +function createThreadMessages(): SlackMessageRecord[] { + return [ + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0001", + text: "Engineering lead approved. <@UENG>", + createdAt: "2026-07-09T09:00:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0002", + text: "QA lead signed off. <@UQA>", + createdAt: "2026-07-09T09:01:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0003", + text: "Ops lead approved and rollback documented. <@UOPS>", + createdAt: "2026-07-09T09:02:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0004", + text: "Support readiness approved. <@USUP>", + createdAt: "2026-07-09T09:03:00Z" + } + ]; +} + +function createThreadMessagesMissingSupport(): SlackMessageRecord[] { + return [ + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0001", + text: "Engineering lead approved. <@UENG>", + createdAt: "2026-07-09T09:00:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0002", + text: "QA lead signed off. <@UQA>", + createdAt: "2026-07-09T09:01:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0003", + text: "Ops lead approved and rollback documented. <@UOPS>", + createdAt: "2026-07-09T09:02:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0004", + text: "Release notes ready and shared for the launch.", + createdAt: "2026-07-09T09:03:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0005", + text: "Primary on call owner is <@UONCALL> for this release.", + createdAt: "2026-07-09T09:04:00Z" + } + ]; +} + +function createWorkspaceAdminService(): WorkspaceAdminService { + const repository = new MemoryWorkspaceAdminRepository(); + return new WorkspaceAdminService({ + settingsRepository: repository, + auditRepository: repository, + now: () => new Date("2026-07-11T12:00:00Z") + }); +} + +test("analyzeThread persists a launch, stores search diagnostics, and resolves it from context", async () => { const repository = new MemoryLaunchRepository(); + const workspaceAdminService = createWorkspaceAdminService(); + const searchSource = new FakeSearchSource([]); const launchService = new LaunchService({ repository, - threadSource: new FakeThreadSource([ - { - channelId: "C123", - threadTs: "1000.0001", - messageTs: "1000.0001", - text: "Engineering lead approved. <@UENG>", - createdAt: "2026-07-09T09:00:00Z" - }, - { - channelId: "C123", - threadTs: "1000.0001", - messageTs: "1000.0002", - text: "QA lead signed off. <@UQA>", - createdAt: "2026-07-09T09:01:00Z" - }, - { - channelId: "C123", - threadTs: "1000.0001", - messageTs: "1000.0003", - text: "Ops lead approved and rollback documented. <@UOPS>", - createdAt: "2026-07-09T09:02:00Z" - }, - { - channelId: "C123", - threadTs: "1000.0001", - messageTs: "1000.0004", - text: "Support readiness approved. <@USUP>", - createdAt: "2026-07-09T09:03:00Z" - } - ]), - searchSource: new FakeSearchSource([]), + threadSource: new FakeThreadSource(createThreadMessages()), + searchSource, canvasGateway: new FakeCanvasGateway(), - summaryProvider: new DeterministicSummaryProvider() + summaryProvider: new DeterministicSummaryProvider(), + workspaceAdminService }); const launch = await launchService.analyzeThread({} as WebClient, { workspaceId: "T123", sourceChannelId: "C123", sourceThreadTs: "1000.0001", - userId: "U123" + userId: "U123", + actionToken: "search-token" }); assert.equal(launch.canvasId, "F123CANVAS"); + assert.equal(launch.searchDiagnostics?.status, "empty"); + assert.equal(searchSource.requests[0]?.actionToken, "search-token"); + + const auditEvents = await workspaceAdminService.listRecentAuditEvents("T123"); + assert.equal(auditEvents[0]?.eventType, "launch_analyzed"); const resolved = await launchService.resolveLaunchForDmQuery({ workspaceId: "T123", @@ -104,3 +185,110 @@ test("analyzeThread persists a launch and resolveLaunchForDmQuery finds it from assert.ok(resolved); assert.equal(resolved?.id, launch.id); }); + +test("rerunLaunch preserves the latest action token for live search", async () => { + const repository = new MemoryLaunchRepository(); + const searchSource = new FakeSearchSource([]); + const launchService = new LaunchService({ + repository, + threadSource: new FakeThreadSource(createThreadMessages()), + searchSource, + canvasGateway: new FakeCanvasGateway(), + summaryProvider: new DeterministicSummaryProvider(), + workspaceAdminService: createWorkspaceAdminService() + }); + + const launch = await launchService.analyzeThread({} as WebClient, { + workspaceId: "T123", + sourceChannelId: "C123", + sourceThreadTs: "1000.0001", + userId: "U123" + }); + + await launchService.rerunLaunch({} as WebClient, launch.id, "rerun-action-token"); + + assert.equal(searchSource.requests.at(-1)?.actionToken, "rerun-action-token"); +}); + +test("thread-only workspace settings disable live search and record that choice", async () => { + const repository = new MemoryLaunchRepository(); + const workspaceAdminService = createWorkspaceAdminService(); + await workspaceAdminService.updateSettings({ + workspaceId: "T123", + updatedByUserId: "UADMIN", + searchMode: "thread_only", + auditRetentionDays: 14, + defaultLaunchProfile: "saas_release" + }); + + const searchSource = new FakeSearchSource([ + { + id: "search-1", + sourceType: "search_message", + title: "Would have been found", + text: "A cross-channel blocker exists.", + createdAt: "2026-07-11T11:00:00Z" + } + ]); + + const launchService = new LaunchService({ + repository, + threadSource: new FakeThreadSource(createThreadMessages()), + searchSource, + canvasGateway: new FakeCanvasGateway(), + summaryProvider: new DeterministicSummaryProvider(), + workspaceAdminService + }); + + const launch = await launchService.analyzeThread({} as WebClient, { + workspaceId: "T123", + sourceChannelId: "C123", + sourceThreadTs: "1000.0001", + userId: "U123", + actionToken: "search-token" + }); + + assert.equal(searchSource.requests.length, 0); + assert.equal(launch.searchDiagnostics?.status, "unavailable"); + assert.match(launch.searchDiagnostics?.note ?? "", /disabled for this workspace/i); + assert.equal(launch.evidence.every((item) => item.sourceType === "thread_message"), true); +}); + +test("assignOwner stores an accountable follow-up and export/history include it", async () => { + const repository = new MemoryLaunchRepository(); + const workspaceAdminService = createWorkspaceAdminService(); + const launchService = new LaunchService({ + repository, + threadSource: new FakeThreadSource(createThreadMessagesMissingSupport()), + searchSource: new FakeSearchSource([]), + canvasGateway: new FakeCanvasGateway(), + summaryProvider: new DeterministicSummaryProvider(), + workspaceAdminService + }); + + const launch = await launchService.analyzeThread({} as WebClient, { + workspaceId: "T123", + sourceChannelId: "C123", + sourceThreadTs: "1000.0001", + userId: "U123", + actionToken: "search-token" + }); + + const updatedLaunch = await launchService.assignOwner( + {} as WebClient, + launch.id, + "support readiness", + "UOWNER", + "UADMIN" + ); + + assert.equal(updatedLaunch?.ownerAssignments[0]?.userId, "UOWNER"); + assert.equal(updatedLaunch?.ownerAssignments[0]?.roleName, "support readiness"); + + const history = await launchService.getLaunchHistory(launch.id); + assert.ok(history?.events.some((event) => event.eventType === "owner_assigned")); + + const exportPayload = await launchService.buildLaunchExport(launch.id); + assert.match(exportPayload?.markdown ?? "", /## Owner assignments/); + assert.match(exportPayload?.markdown ?? "", /support readiness/i); +}); diff --git a/test/llmProvider.test.ts b/test/llmProvider.test.ts index 6350ce6..0b7fefc 100644 --- a/test/llmProvider.test.ts +++ b/test/llmProvider.test.ts @@ -11,6 +11,7 @@ const sampleLaunch = evaluateLaunchReadiness({ }, name: "Mobile v3 launch", createdByUserId: "U123", + launchProfile: "saas_release", threadMessages: [ { channelId: "C123", @@ -32,6 +33,20 @@ const sampleLaunch = evaluateLaunchReadiness({ messageTs: "1000.0003", text: "Ops lead approved and rollback documented. <@UOPS>", createdAt: "2026-07-09T09:02:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0004", + text: "Release notes ready and shared for launch.", + createdAt: "2026-07-09T09:03:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0005", + text: "Primary on call owner is <@UONCALL> for the rollout.", + createdAt: "2026-07-09T09:04:00Z" } ], searchEvidence: [], diff --git a/test/readinessEngine.test.ts b/test/readinessEngine.test.ts index d6bac96..f04d6ae 100644 --- a/test/readinessEngine.test.ts +++ b/test/readinessEngine.test.ts @@ -10,11 +10,36 @@ const key: LaunchKey = { }; function evaluate(messages: SlackMessageRecord[], searchEvidence: SearchEvidenceRecord[] = []) { + const profileEvidence: SlackMessageRecord[] = [ + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0097", + text: "Rollback documented and confirmed for launch.", + createdAt: "2026-07-09T09:57:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0098", + text: "Release notes ready and shared for launch.", + createdAt: "2026-07-09T09:58:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0099", + text: "Primary on call owner is <@UONCALL> for this release.", + createdAt: "2026-07-09T09:59:00Z" + } + ]; + return evaluateLaunchReadiness({ key, name: "Mobile v3 launch", createdByUserId: "U123", - threadMessages: messages, + launchProfile: "saas_release", + threadMessages: [...messages, ...profileEvidence], searchEvidence, now: new Date("2026-07-09T12:00:00Z") }); @@ -259,6 +284,136 @@ test("keeps the summary yellow when a dependency risk remains after approvals", assert.match(launch.decision.summary, /is yellow with/i); }); +test("live search evidence can turn an otherwise green launch red", () => { + const launch = evaluate( + [ + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0001", + text: "PM: Launch: Mobile v3 checkout rollout", + createdAt: "2026-07-09T09:00:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0002", + text: "Engineering lead approved for launch. <@UENG>", + createdAt: "2026-07-09T09:01:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0003", + text: "QA lead signed off. <@UQA>", + createdAt: "2026-07-09T09:02:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0004", + text: "Ops lead approved and rollback documented. <@UOPS>", + createdAt: "2026-07-09T09:03:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0005", + text: "Support readiness approved for launch. <@USUP>", + createdAt: "2026-07-09T09:04:00Z" + } + ], + [ + { + id: "search-1", + sourceType: "search_message", + title: "Support escalation", + text: "Migration vendor is blocked and the launch is not ready until the dependency is unblocked.", + channelId: "C777", + channelName: "support-war-room", + createdAt: "2026-07-09T11:45:00Z" + } + ] + ); + + assert.equal(launch.decision.overallState, "red"); + assert.equal(launch.blockers[0]?.title, "Blocking issue detected"); + assert.equal(launch.evidence.find((item) => item.sourceType === "search_message")?.channelName, "support-war-room"); +}); + +test("stale live search evidence lowers confidence for the affected category", () => { + const launch = evaluate( + [ + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0001", + text: "PM: Launch: Mobile v3 checkout rollout", + createdAt: "2026-07-09T09:00:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0002", + text: "Engineering lead approved for launch. <@UENG>", + createdAt: "2026-07-09T09:01:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0003", + text: "QA lead signed off. <@UQA>", + createdAt: "2026-07-09T09:02:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0004", + text: "Ops lead approved and rollback documented. <@UOPS>", + createdAt: "2026-07-09T09:03:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0005", + text: "Support readiness approved for launch. <@USUP>", + createdAt: "2026-07-09T09:04:00Z" + } + ], + [ + { + id: "search-2", + sourceType: "search_message", + title: "Vendor update", + text: "Vendor dependency is still pending for this rollout.", + channelId: "C555", + channelName: "launch-dependencies", + createdAt: "2026-07-01T08:00:00Z" + } + ] + ); + + assert.equal(launch.decision.overallState, "yellow"); + assert.equal(launch.categories.find((category) => category.name === "Dependencies")?.confidence, "low"); +}); + +test("ambiguous live search evidence forces needs_review instead of a false green", () => { + const launch = evaluate([], [ + { + id: "search-3", + sourceType: "search_message", + title: "QA channel note", + text: "QA says this is probably okay, but I think we should double-check before launch.", + channelId: "C888", + channelName: "qa-release", + createdAt: "2026-07-09T11:50:00Z" + } + ]); + + assert.equal(launch.decision.overallState, "needs_review"); + assert.equal(launch.categories.find((category) => category.name === "Quality")?.state, "needs_review"); +}); + test("ignores prior GoSignal replies when re-analyzing a thread", () => { const launch = evaluate([ { @@ -316,3 +471,71 @@ test("ignores prior GoSignal replies when re-analyzing a thread", () => { assert.equal(launch.decision.overallState, "green"); assert.equal(launch.blockers.length, 0); }); + +test("mobile profile stays yellow until app store rollout evidence is present", () => { + const launch = evaluateLaunchReadiness({ + key, + name: "Mobile v3 launch", + createdByUserId: "U123", + launchProfile: "mobile_release", + threadMessages: [ + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0001", + text: "Engineering lead approved for launch. <@UENG>", + createdAt: "2026-07-09T09:00:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0002", + text: "QA lead signed off. <@UQA>", + createdAt: "2026-07-09T09:01:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0003", + text: "Ops lead approved and rollback documented. <@UOPS>", + createdAt: "2026-07-09T09:02:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0004", + text: "Support readiness approved for launch. <@USUP>", + createdAt: "2026-07-09T09:03:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0005", + text: "Marketing approved for launch. <@UMKT>", + createdAt: "2026-07-09T09:04:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0006", + text: "Release notes ready and shared for the mobile rollout.", + createdAt: "2026-07-09T09:05:00Z" + }, + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0007", + text: "Primary on call owner is <@UONCALL> for launch day.", + createdAt: "2026-07-09T09:06:00Z" + } + ], + searchEvidence: [], + now: new Date("2026-07-09T12:00:00Z") + }); + + assert.equal(launch.decision.overallState, "yellow"); + assert.equal( + launch.requirementChecks.find((requirement) => requirement.requirementId === "app_store_rollout")?.state, + "missing" + ); +}); diff --git a/test/ui.test.ts b/test/ui.test.ts index 0bc1da5..75fc485 100644 --- a/test/ui.test.ts +++ b/test/ui.test.ts @@ -3,38 +3,84 @@ import assert from "node:assert/strict"; import { evaluateLaunchReadiness } from "../src/domain/readiness.ts"; import { buildDmReplyBlocks, buildLaunchBlocksWithResponse, buildSignoffRequestText } from "../src/ui/blocks.ts"; -const launch = evaluateLaunchReadiness({ - key: { - workspaceId: "T123", - sourceChannelId: "C123", - sourceThreadTs: "1000.0001" - }, - name: "Mobile v3 launch", - createdByUserId: "U123", - threadMessages: [ - { - channelId: "C123", - threadTs: "1000.0001", - messageTs: "1000.0001", - text: "Ops lead approved and rollback documented. <@UOPS>", - createdAt: "2026-07-09T09:00:00Z" - } - ], - searchEvidence: [], - now: new Date("2026-07-09T12:00:00Z") -}); +const launch = (() => { + const result = evaluateLaunchReadiness({ + key: { + workspaceId: "T123", + sourceChannelId: "C123", + sourceThreadTs: "1000.0001" + }, + name: "Mobile v3 launch", + createdByUserId: "U123", + launchProfile: "saas_release", + threadMessages: [ + { + channelId: "C123", + threadTs: "1000.0001", + messageTs: "1000.0001", + text: "Ops lead approved and rollback documented. <@UOPS>", + createdAt: "2026-07-09T09:00:00Z" + } + ], + searchEvidence: [ + { + id: "search-1", + sourceType: "search_message", + title: "Support escalation", + text: "Support readiness is still missing sign-off for this launch.", + channelId: "C555", + channelName: "support-readiness", + permalink: "https://example.com/search/1", + createdAt: "2026-07-09T11:50:00Z" + } + ], + now: new Date("2026-07-09T12:00:00Z") + }); + + result.searchQuery = "Find public Slack evidence for Mobile v3 launch"; + result.searchDiagnostics = { + status: "used", + note: "Live search added public evidence from outside the current thread.", + resultCount: 1, + messageCount: 1, + fileCount: 0, + channelCount: 0 + }; + + return result; +})(); -test("buildLaunchBlocks includes action buttons", () => { +test("buildLaunchBlocks includes action buttons and live search receipts", () => { const blocks = buildLaunchBlocksWithResponse(launch, "Custom answer"); const actions = blocks.find((block) => block.type === "actions"); + const liveSearchDiagnostics = blocks.find( + (block) => + block.type === "section" && + typeof (block as { text?: { text?: string } }).text?.text === "string" && + (block as { text?: { text?: string } }).text?.text?.includes("Live search diagnostics") + ); + const liveSearchReceipts = blocks.find( + (block) => + block.type === "section" && + typeof (block as { text?: { text?: string } }).text?.text === "string" && + (block as { text?: { text?: string } }).text?.text?.includes("Live search receipts") + ); + assert.ok(actions); + assert.ok(liveSearchDiagnostics); + assert.ok(liveSearchReceipts); }); -test("buildDmReplyBlocks includes summary text", () => { +test("buildDmReplyBlocks includes summary text and live search status", () => { const blocks = buildDmReplyBlocks(launch, "Natural reply"); const section = blocks.find((block) => block.type === "section"); + const fieldsSection = blocks.find( + (block) => block.type === "section" && Array.isArray((block as { fields?: unknown[] }).fields) + ) as { fields?: Array<{ text?: string }> } | undefined; + assert.ok(section); assert.match(String((section as { text?: { text?: string } }).text?.text), /Natural reply/); + assert.ok(fieldsSection?.fields?.some((field) => field.text?.includes("Live search"))); }); test("buildSignoffRequestText includes a concrete reply template", () => { diff --git a/test/workspaceAdminService.test.ts b/test/workspaceAdminService.test.ts new file mode 100644 index 0000000..ab4b7be --- /dev/null +++ b/test/workspaceAdminService.test.ts @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { MemoryWorkspaceAdminRepository } from "../src/repositories/workspaceAdminRepository.ts"; +import { WorkspaceAdminService } from "../src/services/workspaceAdminService.ts"; + +test("workspace admin service returns defaults and records settings updates in the audit ledger", async () => { + const repository = new MemoryWorkspaceAdminRepository(); + let now = new Date("2026-07-11T12:00:00Z"); + const service = new WorkspaceAdminService({ + settingsRepository: repository, + auditRepository: repository, + now: () => now + }); + + const defaults = await service.getSettings("T123", "UADMIN"); + assert.equal(defaults.searchMode, "public_only"); + assert.equal(defaults.auditRetentionDays, 30); + assert.equal(defaults.defaultLaunchProfile, "saas_release"); + + now = new Date("2026-07-11T12:05:00Z"); + const updated = await service.updateSettings({ + workspaceId: "T123", + updatedByUserId: "UADMIN", + searchMode: "thread_only", + auditRetentionDays: 14, + defaultLaunchProfile: "mobile_release" + }); + + assert.equal(updated.searchMode, "thread_only"); + assert.equal(updated.auditRetentionDays, 14); + assert.equal(updated.defaultLaunchProfile, "mobile_release"); + + const auditEvents = await service.listRecentAuditEvents("T123", 5); + assert.equal(auditEvents[0]?.eventType, "workspace_settings_updated"); + assert.match(auditEvents[0]?.summary ?? "", /Mobile release default profile/i); + assert.match(auditEvents[0]?.summary ?? "", /14-day audit retention/i); +}); + +test("audit listing respects the workspace retention window", async () => { + const repository = new MemoryWorkspaceAdminRepository(); + let now = new Date("2026-07-11T12:00:00Z"); + const service = new WorkspaceAdminService({ + settingsRepository: repository, + auditRepository: repository, + now: () => now + }); + + await service.updateSettings({ + workspaceId: "T123", + updatedByUserId: "UADMIN", + searchMode: "public_only", + auditRetentionDays: 7, + defaultLaunchProfile: "saas_release" + }); + + await service.recordEvent({ + workspaceId: "T123", + actorUserId: "U123", + eventType: "launch_analyzed", + summary: "Fresh event" + }); + + now = new Date("2026-07-20T12:00:00Z"); + await service.recordEvent({ + workspaceId: "T123", + actorUserId: "U123", + eventType: "launch_rerun", + summary: "Recent event" + }); + + const events = await service.listRecentAuditEvents("T123", 10); + assert.equal(events.length, 1); + assert.equal(events[0]?.eventType, "launch_rerun"); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..2e16d57 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false + }, + "include": ["src/**/*.ts"], + "exclude": ["test", "dist", "node_modules"] +} From 394ae696be6d4722ef45741932507370527eccb2 Mon Sep 17 00:00:00 2001 From: Anshul Kushwaha <137977998+sudo-anshul@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:47:07 +0530 Subject: [PATCH 2/2] chore: use Render free web service plan --- render.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/render.yaml b/render.yaml index 109aa1c..d755657 100644 --- a/render.yaml +++ b/render.yaml @@ -2,6 +2,7 @@ services: - type: web name: gosignal runtime: node + plan: free healthCheckPath: /healthz buildCommand: npm ci && npm run verify startCommand: npm start