diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td deleted file mode 100644 index 73376110d9a..00000000000 --- a/.github/VOUCHED.td +++ /dev/null @@ -1,35 +0,0 @@ -# Trust list for this repository. -# -# External contributors listed here are treated as trusted by the vouch -# workflow. Collaborators with write access are automatically trusted and -# do not need to be duplicated in this file. -# -# Syntax: -# github:username -# -github:username reason for denouncement -# -# Keep entries sorted alphabetically. -github:adityavardhansharma -github:binbandit -github:chuks-qua -github:cursoragent -github:gbarros-dev -github:github-actions[bot] -github:hwanseoc -github:jamesx0416 -github:jasonLaster -github:JoeEverest -github:maria-rcks -github:nmggithub -github:Noojuno -github:notkainoa -github:PatrickBauer -github:realAhmedRoach -github:shiroyasha9 -github:Yash-Singh1 -github:eggfriedrice24 -github:Ymit24 -github:shivamhwp -github:jappyjan -github:justsomelegs -github:UtkarshUsername diff --git a/.github/codex/prompts/pr-babysit.md b/.github/codex/prompts/pr-babysit.md index 4264f434e3c..93c8125039f 100644 --- a/.github/codex/prompts/pr-babysit.md +++ b/.github/codex/prompts/pr-babysit.md @@ -13,6 +13,8 @@ Rules: - Do not rewrite history. - Do not merge the pull request. - Do not change repository secrets, workflow credentials, or branch protection. +- Do not comment to trigger Greptile; Greptile reviews automatically on PR creation and new commits. +- Do not comment to request Codex review unless the maintainer explicitly asks for a manual retry. - Prefer small, reviewable fixes over broad refactors. - If a reviewer comment is incorrect, leave a concise PR comment explaining why instead of changing unrelated code. - Before finishing, summarize the files changed, checks run, and any reviewer comments that remain unaddressed. diff --git a/.github/scripts/greptile-status.mjs b/.github/scripts/greptile-status.mjs new file mode 100644 index 00000000000..c46f2c9153c --- /dev/null +++ b/.github/scripts/greptile-status.mjs @@ -0,0 +1,184 @@ +import * as NodeChildProcess from "node:child_process"; + +const repository = process.env.GITHUB_REPOSITORY ?? ""; +const prNumber = Number(process.env.PR_NUMBER ?? process.argv[2] ?? ""); +const triggerCommentId = process.env.GREPTILE_TRIGGER_COMMENT_ID ?? process.argv[3] ?? ""; + +if (!repository.includes("/")) { + throw new Error("GITHUB_REPOSITORY must be set to owner/repo."); +} +if (!Number.isInteger(prNumber) || prNumber <= 0) { + throw new Error("Pass a PR number as argv[2] or PR_NUMBER."); +} + +const ghJson = (args) => + JSON.parse(NodeChildProcess.execFileSync("gh", ["api", ...args], { encoding: "utf8" })); + +const ghJsonPages = (path, extraArgs = []) => { + const items = []; + for (let page = 1; ; page += 1) { + const separator = path.includes("?") ? "&" : "?"; + const pageItems = ghJson([`${path}${separator}per_page=100&page=${page}`, ...extraArgs]); + if (!Array.isArray(pageItems)) { + throw new Error(`Expected ${path} page ${page} to return a JSON array.`); + } + + items.push(...pageItems); + if (pageItems.length < 100) { + return items; + } + } +}; + +const ghJsonObjectPages = (path, arrayKey, extraArgs = []) => { + const items = []; + for (let page = 1; ; page += 1) { + const separator = path.includes("?") ? "&" : "?"; + const pageJson = ghJson([`${path}${separator}per_page=100&page=${page}`, ...extraArgs]); + const pageItems = pageJson[arrayKey]; + if (!Array.isArray(pageItems)) { + throw new Error(`Expected ${path} page ${page} to return a '${arrayKey}' JSON array.`); + } + + items.push(...pageItems); + if (pageItems.length < 100) { + return items; + } + } +}; + +const [owner, repo] = repository.split("/", 2); +const pr = ghJson([`repos/${owner}/${repo}/pulls/${prNumber}`]); +const comments = ghJsonPages(`repos/${owner}/${repo}/issues/${prNumber}/comments`); +const reviews = ghJsonPages(`repos/${owner}/${repo}/pulls/${prNumber}/reviews`); + +const isGreptile = (login) => login === "greptile-apps" || login === "greptile-apps[bot]"; +const scoreOf = (body) => { + const match = body.match( + /\b(?:confidence\s+score|score|grade)\s*:?\s*(\d+(?:\.\d+)?)\s*\/\s*5\b/i, + ); + return match ? Number(match[1]) : null; +}; +const reviewedCommitOf = (body) => + body.match(/github\.com\/[^/]+\/[^/]+\/commit\/([0-9a-f]{7,40})/i)?.[1] ?? null; + +const signals = [ + ...comments + .filter((comment) => isGreptile(comment.user?.login)) + .map((comment) => ({ + kind: "comment", + id: comment.id, + body: comment.body ?? "", + updatedAt: comment.updated_at ?? comment.created_at, + })), + ...reviews + .filter((review) => isGreptile(review.user?.login)) + .map((review) => ({ + kind: "review", + id: review.id, + body: review.body ?? "", + state: review.state ?? null, + reviewedCommit: review.commit_id ?? reviewedCommitOf(review.body ?? ""), + updatedAt: review.submitted_at, + })), +] + .map((signal) => ({ + ...signal, + reviewedCommit: signal.reviewedCommit ?? reviewedCommitOf(signal.body), + score: scoreOf(signal.body), + timestamp: Date.parse(signal.updatedAt ?? ""), + })) + .filter((signal) => signal.body.trim().length > 0 || signal.state === "APPROVED") + .sort((left, right) => { + const leftTimestamp = Number.isNaN(left.timestamp) ? 0 : left.timestamp; + const rightTimestamp = Number.isNaN(right.timestamp) ? 0 : right.timestamp; + return rightTimestamp - leftTimestamp; + }); + +let triggerAccepted = false; +if (triggerCommentId) { + const reactions = ghJsonPages( + `repos/${owner}/${repo}/issues/comments/${triggerCommentId}/reactions`, + ["-H", "Accept: application/vnd.github+json"], + ); + triggerAccepted = reactions.some( + (reaction) => isGreptile(reaction.user?.login) && reaction.content === "+1", + ); +} + +const headSha = pr.head?.sha ?? ""; +const timeline = ghJsonPages(`repos/${owner}/${repo}/issues/${prNumber}/timeline`, [ + "-H", + "Accept: application/vnd.github+json", +]); +const headRefReachedEventTimestamp = (event) => { + if (event.event === "head_ref_force_pushed" && event.commit_id === headSha) { + return Date.parse(event.created_at ?? ""); + } + + return Number.NaN; +}; +const headCheckRuns = ghJsonObjectPages( + `repos/${owner}/${repo}/commits/${headSha}/check-runs?filter=all`, + "check_runs", + ["-H", "Accept: application/vnd.github+json"], +); +const earliestHeadCheckRunTimestamp = headCheckRuns + .map((checkRun) => Date.parse(checkRun.started_at ?? checkRun.completed_at ?? "")) + .filter((timestamp) => !Number.isNaN(timestamp)) + .reduce( + (earliest, timestamp) => (earliest === null || timestamp < earliest ? timestamp : earliest), + null, + ); +// Normal `committed` timeline events expose commit author dates, not PR arrival +// times. Check runs are the safest available proxy; without them, fail closed. +const headRefReachedTimestamps = timeline + .map(headRefReachedEventTimestamp) + .filter((timestamp) => !Number.isNaN(timestamp)); +if (earliestHeadCheckRunTimestamp !== null) { + headRefReachedTimestamps.push(earliestHeadCheckRunTimestamp); +} +const headRefReachedTimestamp = headRefReachedTimestamps.reduce( + (latest, timestamp) => (latest === null || timestamp > latest ? timestamp : latest), + null, +); +const commitMatchesHead = (reviewedCommit) => + typeof reviewedCommit === "string" && + reviewedCommit.length >= 7 && + headSha.startsWith(reviewedCommit); +const appliesToHead = (signal) => + commitMatchesHead(signal.reviewedCommit) || + (signal.kind === "comment" && + signal.score !== null && + signal.reviewedCommit === null && + headRefReachedTimestamp !== null && + signal.timestamp >= headRefReachedTimestamp); +const latestForHead = signals.find(appliesToHead); +const latestForHeadApproved = latestForHead?.state === "APPROVED"; +const latestScored = signals.find((signal) => signal.score !== null); +const stale = + latestScored !== undefined && + latestScored.reviewedCommit !== headSha && + !appliesToHead(latestScored); +const state = latestForHeadApproved + ? "approved" + : latestForHead !== undefined + ? "current" + : triggerAccepted + ? "trigger-accepted" + : stale + ? "stale" + : "unknown"; + +const result = { + pr: prNumber, + headSha, + state, + score: latestForHead?.score ?? latestScored?.score ?? null, + approved: latestForHeadApproved, + latestReviewedCommit: latestForHead?.reviewedCommit ?? latestScored?.reviewedCommit ?? null, + triggerAccepted, + latestGreptileSignalAt: latestForHead?.updatedAt ?? latestScored?.updatedAt ?? null, +}; + +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); diff --git a/.github/scripts/pr-babysitter-state.mjs b/.github/scripts/pr-babysitter-state.mjs index 758198a4871..1b4162a6974 100644 --- a/.github/scripts/pr-babysitter-state.mjs +++ b/.github/scripts/pr-babysitter-state.mjs @@ -1,5 +1,5 @@ -import { execFileSync } from "node:child_process"; -import { appendFileSync } from "node:fs"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; const prNumber = Number(process.env.PR_NUMBER ?? ""); const repository = process.env.GITHUB_REPOSITORY ?? ""; @@ -15,33 +15,21 @@ if (!repository.includes("/")) { const [owner, repo] = repository.split("/", 2); const query = ` -query($owner: String!, $repo: String!, $number: Int!) { +query($owner: String!, $repo: String!, $number: Int!, $reviewThreadsAfter: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { isDraft + headRefOid mergeStateStatus reviewDecision - comments(last: 100) { - nodes { - body - createdAt - updatedAt - author { login } - } - } - reviews(last: 100) { - nodes { - body - createdAt - submittedAt - updatedAt - state - author { login } + reviewThreads(first: 100, after: $reviewThreadsAfter) { + pageInfo { + hasNextPage + endCursor } - } - reviewThreads(first: 100) { nodes { isResolved + isOutdated comments(first: 20) { nodes { body @@ -54,28 +42,118 @@ query($owner: String!, $repo: String!, $number: Int!) { } }`; -const response = execFileSync( - "gh", - [ - "api", - "graphql", - "-f", - `query=${query}`, - "-F", - `owner=${owner}`, - "-F", - `repo=${repo}`, - "-F", - `number=${prNumber}`, - ], - { encoding: "utf8" }, -); +const ghJson = (args) => + JSON.parse(NodeChildProcess.execFileSync("gh", ["api", ...args], { encoding: "utf8" })); + +const ghJsonPages = (path, extraArgs = []) => { + const items = []; + for (let page = 1; ; page += 1) { + const separator = path.includes("?") ? "&" : "?"; + const pageItems = ghJson([`${path}${separator}per_page=100&page=${page}`, ...extraArgs]); + if (!Array.isArray(pageItems)) { + throw new Error(`Expected ${path} page ${page} to return a JSON array.`); + } + + items.push(...pageItems); + if (pageItems.length < 100) { + return items; + } + } +}; + +const ghJsonObjectPages = (path, arrayKey, extraArgs = []) => { + const items = []; + for (let page = 1; ; page += 1) { + const separator = path.includes("?") ? "&" : "?"; + const pageJson = ghJson([`${path}${separator}per_page=100&page=${page}`, ...extraArgs]); + const pageItems = pageJson[arrayKey]; + if (!Array.isArray(pageItems)) { + throw new Error(`Expected ${path} page ${page} to return a '${arrayKey}' JSON array.`); + } + + items.push(...pageItems); + if (pageItems.length < 100) { + return items; + } + } +}; + +const ghGraphql = (graphqlQuery, variables) => { + const args = ["graphql", "-f", `query=${graphqlQuery}`]; + for (const [name, value] of Object.entries(variables)) { + if (value !== null && value !== undefined) { + args.push("-F", `${name}=${value}`); + } + } + + return ghJson(args); +}; -const parsed = JSON.parse(response); -const pr = parsed.data?.repository?.pullRequest; -if (!pr) { - throw new Error(`Pull request #${prNumber} was not found.`); +const fetchPullRequestState = () => { + let prState = null; + let reviewThreadsAfter = null; + const reviewThreads = []; + + do { + const parsed = ghGraphql(query, { owner, repo, number: prNumber, reviewThreadsAfter }); + const pagePr = parsed.data?.repository?.pullRequest; + if (!pagePr) { + throw new Error(`Pull request #${prNumber} was not found.`); + } + + prState = { + isDraft: pagePr.isDraft, + headRefOid: pagePr.headRefOid, + mergeStateStatus: pagePr.mergeStateStatus, + reviewDecision: pagePr.reviewDecision, + }; + + const pageInfo = pagePr.reviewThreads?.pageInfo; + reviewThreads.push(...(pagePr.reviewThreads?.nodes ?? [])); + reviewThreadsAfter = pageInfo?.hasNextPage ? pageInfo.endCursor : null; + } while (reviewThreadsAfter); + + return { ...prState, reviewThreads }; +}; + +const pr = fetchPullRequestState(); +const timeline = ghJsonPages(`repos/${owner}/${repo}/issues/${prNumber}/timeline`, [ + "-H", + "Accept: application/vnd.github+json", +]); +const headRefReachedEventTimestamp = (event) => { + if (event.event === "head_ref_force_pushed" && event.commit_id === pr.headRefOid) { + return Date.parse(event.created_at ?? ""); + } + + return Number.NaN; +}; +const headCheckRuns = ghJsonObjectPages( + `repos/${owner}/${repo}/commits/${pr.headRefOid}/check-runs?filter=all`, + "check_runs", + ["-H", "Accept: application/vnd.github+json"], +); +const earliestHeadCheckRunTimestamp = headCheckRuns + .map((checkRun) => Date.parse(checkRun.started_at ?? checkRun.completed_at ?? "")) + .filter((timestamp) => !Number.isNaN(timestamp)) + .reduce( + (earliest, timestamp) => (earliest === null || timestamp < earliest ? timestamp : earliest), + null, + ); +// Normal `committed` timeline events expose commit author dates, not PR arrival +// times. Check runs are the safest available proxy; without them, fail closed. +const headRefReachedTimestamps = timeline + .map(headRefReachedEventTimestamp) + .filter((timestamp) => !Number.isNaN(timestamp)); +if (earliestHeadCheckRunTimestamp !== null) { + headRefReachedTimestamps.push(earliestHeadCheckRunTimestamp); } +const headRefReachedTimestamp = headRefReachedTimestamps.reduce( + (latest, timestamp) => (latest === null || timestamp > latest ? timestamp : latest), + null, +); +const comments = ghJsonPages(`repos/${owner}/${repo}/issues/${prNumber}/comments`); +const reviews = ghJsonPages(`repos/${owner}/${repo}/pulls/${prNumber}/reviews`); const codexAuthorLogins = new Set(["chatgpt-codex-connector", "chatgpt-codex-connector[bot]"]); const greptileAuthorLogins = new Set(["greptile-apps", "greptile-apps[bot]"]); @@ -84,65 +162,95 @@ const isCodex = (login) => codexAuthorLogins.has(login ?? ""); const isGreptile = (login) => greptileAuthorLogins.has(login ?? ""); const readGreptileScore = (body) => { const scoreMatch = body.match( - /\b(?:confidence\s+score|score|grade)\s*:?\s*(\d+(?:\.\d+)?)\s*(?:\/\s*5)?\b/i, + /\b(?:confidence\s+score|score|grade)\s*:?\s*(\d+(?:\.\d+)?)\s*\/\s*5\b/i, ); return scoreMatch ? Number(scoreMatch[1]) : null; }; +const readReviewedCommit = (body) => + body.match(/github\.com\/[^/]+\/[^/]+\/commit\/([0-9a-f]{7,40})/i)?.[1] ?? null; +const commitMatchesHead = (reviewedCommit) => + typeof reviewedCommit === "string" && + reviewedCommit.length >= 7 && + pr.headRefOid.startsWith(reviewedCommit); -const unresolvedCodexThreads = pr.reviewThreads.nodes.filter( +const unresolvedCodexThreads = pr.reviewThreads.filter( (thread) => - !thread.isResolved && thread.comments.nodes.some((comment) => isCodex(comment.author?.login)), + !thread.isResolved && + !thread.isOutdated && + (thread.comments?.nodes ?? []).some((comment) => isCodex(comment.author?.login)), ); const greptileSignals = [ - ...pr.comments.nodes - .filter((comment) => isGreptile(comment.author?.login)) + ...comments + .filter((comment) => isGreptile(comment.user?.login)) .map((comment) => ({ - body: comment.body, - score: readGreptileScore(comment.body), - timestamp: Date.parse(comment.updatedAt ?? comment.createdAt ?? ""), + kind: "comment", + body: comment.body ?? "", + state: null, + score: readGreptileScore(comment.body ?? ""), + reviewedCommit: readReviewedCommit(comment.body ?? ""), + timestamp: Date.parse(comment.updated_at ?? comment.created_at ?? ""), })), - ...pr.reviews.nodes - .filter((review) => isGreptile(review.author?.login)) + ...reviews + .filter((review) => isGreptile(review.user?.login)) .map((review) => ({ - body: review.body, - score: readGreptileScore(review.body), - timestamp: Date.parse(review.updatedAt ?? review.submittedAt ?? review.createdAt ?? ""), + kind: "review", + body: review.body ?? "", + state: review.state ?? null, + score: readGreptileScore(review.body ?? ""), + reviewedCommit: review.commit_id ?? readReviewedCommit(review.body ?? ""), + timestamp: Date.parse(review.updated_at ?? review.submitted_at ?? ""), })), ] - .filter((signal) => signal.body.trim().length > 0) + .filter((signal) => signal.body.trim().length > 0 || signal.state === "APPROVED") .map((signal) => ({ ...signal, normalizedTimestamp: Number.isNaN(signal.timestamp) ? 0 : signal.timestamp, })); -const latestScoredGreptileSignal = greptileSignals - .filter((signal) => signal.score !== null) - .reduce( - (latest, signal) => - latest === null || signal.normalizedTimestamp > latest.normalizedTimestamp ? signal : latest, - null, - ); +const greptileSignalAppliesToHead = (signal) => + commitMatchesHead(signal.reviewedCommit) || + (signal.kind === "comment" && + signal.score !== null && + signal.reviewedCommit === null && + headRefReachedTimestamp !== null && + signal.normalizedTimestamp >= headRefReachedTimestamp); + +const currentGreptileSignals = greptileSignals.filter(greptileSignalAppliesToHead); + +const scoredOrApprovedGreptileSignals = currentGreptileSignals.filter( + (signal) => signal.score !== null || (signal.kind === "review" && signal.state === "APPROVED"), +); + +const latestGreptileSignal = scoredOrApprovedGreptileSignals.reduce( + (latest, signal) => + latest === null || signal.normalizedTimestamp > latest.normalizedTimestamp ? signal : latest, + null, +); -const greptileScore = latestScoredGreptileSignal?.score ?? null; -const greptilePassed = greptileScore !== null && greptileScore >= 5; -const hasGreptileSignal = latestScoredGreptileSignal !== null; +const greptileScore = latestGreptileSignal?.score ?? null; +const greptileApproved = + latestGreptileSignal?.kind === "review" && latestGreptileSignal.state === "APPROVED"; +const greptilePassed = greptileApproved || (greptileScore !== null && greptileScore >= 5); +const hasGreptileSignal = latestGreptileSignal !== null; const readyToMerge = !pr.isDraft && unresolvedCodexThreads.length === 0 && hasGreptileSignal && greptilePassed; const summary = [ `draft=${pr.isDraft}`, + `head=${pr.headRefOid}`, `mergeState=${pr.mergeStateStatus}`, `reviewDecision=${pr.reviewDecision ?? "UNKNOWN"}`, `unresolvedCodexThreads=${unresolvedCodexThreads.length}`, `greptileSignal=${hasGreptileSignal}`, `greptileScore=${greptileScore ?? "UNKNOWN"}`, + `greptileApproved=${greptileApproved}`, `greptilePassed=${greptilePassed}`, ].join(" "); process.stdout.write(`${summary}\n`); if (outputPath) { - appendFileSync(outputPath, `ready_to_merge=${readyToMerge ? "true" : "false"}\n`); - appendFileSync(outputPath, `summary=${summary}\n`); + NodeFS.appendFileSync(outputPath, `ready_to_merge=${readyToMerge ? "true" : "false"}\n`); + NodeFS.appendFileSync(outputPath, `summary=${summary}\n`); } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21fbce026f5..cd2823699a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: jobs: check: name: Check - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -42,7 +42,7 @@ jobs: test: name: Test - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -63,7 +63,7 @@ jobs: mobile_native_static_analysis: name: Mobile Native Static Analysis - runs-on: blacksmith-12vcpu-macos-26 + runs-on: macos-15 timeout-minutes: 10 steps: - name: Checkout @@ -84,7 +84,7 @@ jobs: release_smoke: name: Release Smoke - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml deleted file mode 100644 index 94d4af17e41..00000000000 --- a/.github/workflows/deploy-relay.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Deploy T3 Connect relay - -on: - push: - branches: - - main - -permissions: - contents: read - id-token: none - statuses: write - -concurrency: - group: relay-production - cancel-in-progress: false - -jobs: - deploy_relay: - name: Deploy production relay - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 15 - environment: - name: production - env: - CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }} - AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }} - RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} - RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} - RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }} - CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} - CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }} - APNS_ENVIRONMENT: ${{ vars.APNS_ENVIRONMENT }} - APNS_TEAM_ID: ${{ vars.APNS_TEAM_ID }} - APNS_KEY_ID: ${{ vars.APNS_KEY_ID }} - APNS_BUNDLE_ID: ${{ vars.APNS_BUNDLE_ID }} - ALCHEMY_TELEMETRY_DISABLED: "1" - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: true - - - name: Deploy production relay stage - id: deploy - run: vp run --filter t3code-relay deploy --stage prod --yes --github-output - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} - PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} - AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }} - - - name: Publish relay deploy commit status - uses: actions/github-script@v8 - with: - script: | - const result = "${{ steps.deploy.outputs.result }}"; - const changed = "${{ steps.deploy.outputs.changed }}" === "true"; - const description = changed - ? "Relay production deploy applied infrastructure changes." - : result === "noop" - ? "Relay production deploy was a no-op." - : `Relay production deploy completed with result: ${result}.`; - - await github.rest.repos.createCommitStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - sha: context.sha, - state: "success", - context: "Relay deploy / production", - description, - target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, - }); diff --git a/.github/workflows/issue-labels.yml b/.github/workflows/issue-labels.yml deleted file mode 100644 index d6571d65d45..00000000000 --- a/.github/workflows/issue-labels.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Issue Labels - -on: - push: - branches: - - main - paths: - - .github/ISSUE_TEMPLATE/** - - .github/workflows/issue-labels.yml - workflow_dispatch: - -permissions: - issues: write - -jobs: - sync: - name: Sync issue labels - runs-on: ubuntu-24.04 - steps: - - name: Ensure managed issue labels exist - uses: actions/github-script@v7 - with: - script: | - const managedLabels = [ - { - name: "bug", - color: "d73a4a", - description: "Something is broken or behaving incorrectly.", - }, - { - name: "enhancement", - color: "a2eeef", - description: "Requested improvement or new capability.", - }, - { - name: "needs-triage", - color: "fbca04", - description: "Issue needs maintainer review and initial categorization.", - }, - ]; - - for (const label of managedLabels) { - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - }); - - if ( - existing.color !== label.color || - (existing.description ?? "") !== label.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } - } diff --git a/.github/workflows/main-artifacts-release.yml b/.github/workflows/main-artifacts-release.yml index 34c4310b5dd..0d9363aa5f0 100644 --- a/.github/workflows/main-artifacts-release.yml +++ b/.github/workflows/main-artifacts-release.yml @@ -28,14 +28,88 @@ jobs: echo "release_name=T3 Code main ${short_sha}" >> "$GITHUB_OUTPUT" echo "release_version=0.0.0-main.${{ github.run_number }}" >> "$GITHUB_OUTPUT" + public_config: + name: Resolve artifact public config + runs-on: ubuntu-24.04 + timeout-minutes: 5 + environment: + name: production + outputs: + clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ steps.public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ steps.public_config.outputs.relay_url }} + env: + T3CODE_RELAY_URL: ${{ vars.T3CODE_RELAY_URL }} + RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} + RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} + CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} + CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }} + CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} + steps: + - id: public_config + name: Resolve build-time cloud config + shell: bash + run: | + set -euo pipefail + + relay_url="${T3CODE_RELAY_URL:-}" + if [[ -z "$relay_url" ]]; then + relay_domain="${RELAY_DOMAIN:-}" + if [[ -z "$relay_domain" && -n "${RELAY_API_ZONE_NAME:-}" ]]; then + relay_domain="relay.$RELAY_API_ZONE_NAME" + fi + if [[ -n "$relay_domain" ]]; then + relay_url="https://$relay_domain" + fi + fi + + required=( + relay_url + CLERK_PUBLISHABLE_KEY + CLERK_JWT_TEMPLATE + CLERK_CLI_OAUTH_CLIENT_ID + ) + missing=() + for name in "${required[@]}"; do + if [[ -z "${!name:-}" ]]; then + missing+=("$name") + fi + done + if (( ${#missing[@]} > 0 )); then + printf 'Missing required artifact public configuration: %s\n' "${missing[*]}" >&2 + exit 1 + fi + if [[ ! "$relay_url" =~ ^https:// ]]; then + printf 'Artifact relay_url must be an HTTPS URL: %s\n' "$relay_url" >&2 + exit 1 + fi + + echo "clerk_publishable_key=$CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT" + echo "clerk_jwt_template=$CLERK_JWT_TEMPLATE" >> "$GITHUB_OUTPUT" + echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" + echo "relay_url=$relay_url" >> "$GITHUB_OUTPUT" + publish_artifacts: name: Build and publish artifacts - needs: metadata + needs: [metadata, public_config] uses: ./.github/workflows/reusable-build-release-artifacts.yml with: ref: ${{ github.sha }} release_tag: ${{ needs.metadata.outputs.release_tag }} release_name: ${{ needs.metadata.outputs.release_name }} release_version: ${{ needs.metadata.outputs.release_version }} + android_required: false + android_profile: preview + android_environment: preview + android_app_variant: preview + android_artifact_name: t3-code-preview-android.apk + android_mobile_version_policy: fingerprint + android_public_config: false + clerk_publishable_key: ${{ needs.public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.public_config.outputs.relay_url }} prerelease: true + windows_signing: true secrets: inherit diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml deleted file mode 100644 index a16763cb141..00000000000 --- a/.github/workflows/mobile-eas-preview.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Mobile EAS Preview - -on: - pull_request: - types: [opened, reopened, synchronize, labeled, unlabeled] - -jobs: - preview: - name: EAS Preview - if: contains(github.event.pull_request.labels.*.name, '๐Ÿš€ Mobile Continuous Deployment') - runs-on: blacksmith-8vcpu-ubuntu-2404 - permissions: - contents: read - pull-requests: write - env: - APP_VARIANT: preview - NODE_OPTIONS: --max-old-space-size=8192 - MOBILE_VERSION_POLICY: fingerprint - steps: - - id: expo-token - name: Check for EXPO_TOKEN - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: | - if [ -n "$EXPO_TOKEN" ]; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - echo "EXPO_TOKEN is not available; skipping EAS preview." - fi - - - name: Checkout - if: steps.expo-token.outputs.present == 'true' - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Setup Vite+ - if: steps.expo-token.outputs.present == 'true' - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: true - - - name: Expose pnpm - if: steps.expo-token.outputs.present == 'true' - run: | - pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" - vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" - echo "$vp_pnpm_bin" >> "$GITHUB_PATH" - "$vp_pnpm_bin/pnpm" --version - - - name: Setup EAS - if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action@v8 - with: - eas-version: latest - token: ${{ secrets.EXPO_TOKEN }} - packager: pnpm - - - name: Pull preview environment variables - if: steps.expo-token.outputs.present == 'true' - working-directory: apps/mobile - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: eas env:pull preview --non-interactive - - - name: Deploy with fingerprint check - if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action/continuous-deploy-fingerprint@main - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - with: - profile: preview:dev - branch: pr-${{ github.event.pull_request.number }} - platform: all - environment: preview - working-directory: apps/mobile - github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-access-gate.yml b/.github/workflows/pr-access-gate.yml new file mode 100644 index 00000000000..d41baaadccf --- /dev/null +++ b/.github/workflows/pr-access-gate.yml @@ -0,0 +1,40 @@ +name: PR Access Gate + +on: + pull_request_target: + types: [opened, reopened, ready_for_review, synchronize] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + gate: + name: Close unauthorized PRs + runs-on: ubuntu-24.04 + steps: + - name: Enforce allowed PR authors + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + REPOSITORY: ${{ github.repository }} + ALLOWED_PR_AUTHORS: ${{ vars.ALLOWED_PR_AUTHORS }} + shell: bash + run: | + set -euo pipefail + + allowed="adamfgr,wizzoapp[bot],wizzobot,wizzobot[bot],${ALLOWED_PR_AUTHORS:-}" + + IFS=',' read -r -a allowed_authors <<< "$allowed" + for allowed_author in "${allowed_authors[@]}"; do + if [[ -n "$allowed_author" && "$PR_AUTHOR" == "$allowed_author" ]]; then + echo "PR author '$PR_AUTHOR' is allowed." + exit 0 + fi + done + + echo "::warning::Closing PR #$PR_NUMBER from unauthorized author '$PR_AUTHOR'." + gh pr comment "$PR_NUMBER" --repo "$REPOSITORY" --body "This fork only accepts pull requests from maintainers or approved automation." + gh pr close "$PR_NUMBER" --repo "$REPOSITORY" diff --git a/.github/workflows/pr-babysitter.yml b/.github/workflows/pr-babysitter.yml index 230059d810c..2356a797668 100644 --- a/.github/workflows/pr-babysitter.yml +++ b/.github/workflows/pr-babysitter.yml @@ -7,14 +7,12 @@ on: description: "Pull request number to maintain" required: true type: number - issue_comment: - types: [created] pull_request_review: types: [submitted] permissions: contents: write - issues: write + issues: read pull-requests: write actions: read checks: read @@ -33,16 +31,11 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} DISPATCH_PR: ${{ github.event.inputs.pr_number }} - ISSUE_PR_URL: ${{ github.event.issue.pull_request.url }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - ISSUE_COMMENT_BODY: ${{ github.event.comment.body }} REVIEW_PR_NUMBER: ${{ github.event.pull_request.number }} REVIEW_AUTHOR: ${{ github.event.review.user.login }} REVIEW_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} REPOSITORY: ${{ github.repository }} REPOSITORY_OWNER: ${{ github.repository_owner }} - COMMENT_AUTHOR: ${{ github.event.comment.user.login }} - COMMENT_AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} TRUSTED_BABYSITTER_ACTORS: ${{ vars.CODEX_BABYSITTER_ACTORS }} run: | set -euo pipefail @@ -82,11 +75,6 @@ jobs: if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then should_run=true pr_number="$DISPATCH_PR" - elif [[ "$EVENT_NAME" == "issue_comment" && -n "${ISSUE_PR_URL:-}" ]]; then - if [[ "$ISSUE_COMMENT_BODY" == *"/codex-babysit"* ]] && actor_is_trusted "$COMMENT_AUTHOR" "$COMMENT_AUTHOR_ASSOCIATION"; then - should_run=true - pr_number="$ISSUE_NUMBER" - fi elif [[ "$EVENT_NAME" == "pull_request_review" ]]; then if [[ "$REVIEW_HEAD_REPO" == "$REPOSITORY" ]] && reviewer_is_trusted "$REVIEW_AUTHOR"; then should_run=true diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml deleted file mode 100644 index af557dff62d..00000000000 --- a/.github/workflows/pr-size.yml +++ /dev/null @@ -1,295 +0,0 @@ -name: PR Size - -on: - pull_request_target: - types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] - -permissions: - contents: read - -jobs: - prepare-config: - name: Prepare PR size config - runs-on: ubuntu-24.04 - outputs: - labels_json: ${{ steps.config.outputs.labels_json }} - steps: - - id: config - name: Build PR size label config - uses: actions/github-script@v8 - with: - result-encoding: string - script: | - const managedLabels = [ - { - name: "size:XS", - color: "0e8a16", - description: "0-9 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:S", - color: "5ebd3e", - description: "10-29 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:M", - color: "fbca04", - description: "30-99 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:L", - color: "fe7d37", - description: "100-499 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:XL", - color: "d93f0b", - description: "500-999 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:XXL", - color: "b60205", - description: "1,000+ effective changed lines (test files excluded in mixed PRs).", - }, - ]; - - core.setOutput("labels_json", JSON.stringify(managedLabels)); - sync-label-definitions: - name: Sync PR size label definitions - needs: prepare-config - if: github.event_name != 'pull_request_target' - runs-on: ubuntu-24.04 - permissions: - contents: read - issues: write - steps: - - name: Ensure PR size labels exist - uses: actions/github-script@v8 - env: - PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} - with: - script: | - const managedLabels = JSON.parse(process.env.PR_SIZE_LABELS_JSON ?? "[]"); - - for (const label of managedLabels) { - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - }); - - if ( - existing.color !== label.color || - (existing.description ?? "") !== label.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } - } - label: - name: Label PR size - needs: prepare-config - if: github.event_name == 'pull_request_target' - runs-on: ubuntu-24.04 - permissions: - contents: read - issues: read - pull-requests: write - concurrency: - group: pr-size-${{ github.event.pull_request.number }} - cancel-in-progress: true - steps: - # This pull_request_target job may fetch untrusted PR commits only as passive - # git data. Do not add dependency installs, build/test scripts, or cache - # actions here; use pull_request plus workflow_run for that pattern instead. - - name: Checkout base repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Sync PR size label - uses: actions/github-script@v8 - env: - PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} - with: - script: | - const { execFileSync } = require("node:child_process"); - - const issueNumber = context.payload.pull_request.number; - const baseSha = context.payload.pull_request.base.sha; - const headSha = context.payload.pull_request.head.sha; - const headTrackingRef = `refs/remotes/pr-size/${issueNumber}`; - const managedLabels = JSON.parse(process.env.PR_SIZE_LABELS_JSON ?? "[]"); - const managedLabelNames = new Set(managedLabels.map((label) => label.name)); - // Keep this aligned with the repo's test entrypoints and test-only support files. - const testExcludePathspecs = [ - ":(glob,exclude)**/__tests__/**", - ":(glob,exclude)**/test/**", - ":(glob,exclude)**/tests/**", - ":(glob,exclude)apps/server/integration/**", - ":(glob,exclude)**/*.test.*", - ":(glob,exclude)**/*.spec.*", - ":(glob,exclude)**/*.browser.*", - ":(glob,exclude)**/*.integration.*", - ]; - - const sumNumstat = (text) => - text - .split("\n") - .filter(Boolean) - .reduce((total, line) => { - const [insertionsRaw = "0", deletionsRaw = "0"] = line.split("\t"); - const additions = - insertionsRaw === "-" ? 0 : Number.parseInt(insertionsRaw, 10) || 0; - const deletions = - deletionsRaw === "-" ? 0 : Number.parseInt(deletionsRaw, 10) || 0; - - return total + additions + deletions; - }, 0); - - const resolveSizeLabel = (totalChangedLines) => { - if (totalChangedLines < 10) { - return "size:XS"; - } - - if (totalChangedLines < 30) { - return "size:S"; - } - - if (totalChangedLines < 100) { - return "size:M"; - } - - if (totalChangedLines < 500) { - return "size:L"; - } - - if (totalChangedLines < 1000) { - return "size:XL"; - } - - return "size:XXL"; - }; - - execFileSync("git", ["fetch", "--no-tags", "origin", baseSha], { - stdio: "inherit", - }); - - execFileSync( - "git", - ["fetch", "--no-tags", "origin", `+refs/pull/${issueNumber}/head:${headTrackingRef}`], - { - stdio: "inherit", - }, - ); - - const resolvedHeadSha = execFileSync("git", ["rev-parse", headTrackingRef], { - encoding: "utf8", - }).trim(); - - if (resolvedHeadSha !== headSha) { - core.warning( - `Fetched head SHA ${resolvedHeadSha} does not match pull request head SHA ${headSha}; using fetched ref for sizing.`, - ); - } - - execFileSync("git", ["cat-file", "-e", `${baseSha}^{commit}`], { - stdio: "inherit", - }); - - const diffArgs = [ - "diff", - "--numstat", - "--ignore-all-space", - "--ignore-blank-lines", - `${baseSha}...${resolvedHeadSha}`, - ]; - - const totalChangedLines = sumNumstat( - execFileSync( - "git", - diffArgs, - { encoding: "utf8" }, - ), - ); - const nonTestChangedLines = sumNumstat( - execFileSync("git", [...diffArgs, "--", ".", ...testExcludePathspecs], { - encoding: "utf8", - }), - ); - const testChangedLines = Math.max(0, totalChangedLines - nonTestChangedLines); - - const changedLines = nonTestChangedLines === 0 ? testChangedLines : nonTestChangedLines; - const nextLabelName = resolveSizeLabel(changedLines); - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - - for (const label of currentLabels) { - if (!managedLabelNames.has(label.name) || label.name === nextLabelName) { - continue; - } - - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: label.name, - }); - } catch (removeError) { - if (removeError.status !== 404) { - throw removeError; - } - } - } - - if (!currentLabels.some((label) => label.name === nextLabelName)) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [nextLabelName], - }); - } - - const classification = - nonTestChangedLines === 0 - ? testChangedLines > 0 - ? "test-only PR" - : "no line changes" - : testChangedLines > 0 - ? "test lines excluded" - : "all non-test changes"; - - core.info( - `PR #${issueNumber}: ${nonTestChangedLines} non-test lines, ${testChangedLines} test lines, ${changedLines} effective lines -> ${nextLabelName} (${classification})`, - ); diff --git a/.github/workflows/pr-vouch.yml b/.github/workflows/pr-vouch.yml deleted file mode 100644 index c4abb08b727..00000000000 --- a/.github/workflows/pr-vouch.yml +++ /dev/null @@ -1,199 +0,0 @@ -name: PR Vouch - -on: - pull_request_target: - types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] - issue_comment: - types: [created] - push: - branches: - - main - paths: - - .github/VOUCHED.td - - .github/workflows/pr-vouch.yml - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - collect-targets: - name: Collect PR targets - runs-on: ubuntu-24.04 - outputs: - targets: ${{ steps.collect.outputs.targets }} - steps: - - id: collect - uses: actions/github-script@v8 - with: - script: | - if (context.eventName === "pull_request_target") { - const pr = context.payload.pull_request; - core.setOutput("targets", JSON.stringify([{ number: pr.number, user: pr.user.login }])); - return; - } - - if (context.eventName === "issue_comment") { - const issue = context.payload.issue; - const body = context.payload.comment?.body ?? ""; - if (!issue?.pull_request || !body.includes("/recheck-vouch")) { - core.setOutput("targets", "[]"); - return; - } - - core.setOutput( - "targets", - JSON.stringify([{ number: issue.number, user: issue.user.login }]), - ); - return; - } - - const pulls = await github.paginate(github.rest.pulls.list, { - owner: context.repo.owner, - repo: context.repo.repo, - state: "open", - per_page: 100, - }); - - const targets = pulls.map((pull) => ({ - number: pull.number, - user: pull.user.login, - })); - core.setOutput("targets", JSON.stringify(targets)); - - label: - name: Label PR ${{ matrix.target.number }} - needs: collect-targets - if: ${{ needs.collect-targets.outputs.targets != '[]' }} - runs-on: ubuntu-24.04 - concurrency: - group: pr-vouch-${{ matrix.target.number }} - cancel-in-progress: true - strategy: - fail-fast: false - matrix: - target: ${{ fromJson(needs.collect-targets.outputs.targets) }} - steps: - - id: vouch - name: Check PR author trust - uses: mitchellh/vouch/action/check-user@v1 - with: - user: ${{ matrix.target.user }} - allow-fail: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Sync PR labels - uses: actions/github-script@v8 - env: - PR_NUMBER: ${{ matrix.target.number }} - VOUCH_STATUS: ${{ steps.vouch.outputs.status }} - with: - script: | - const issueNumber = Number(process.env.PR_NUMBER); - const status = process.env.VOUCH_STATUS; - const managedLabels = [ - { - name: "vouch:trusted", - color: "1f883d", - description: "PR author is trusted by repo permissions or the VOUCHED list.", - }, - { - name: "vouch:unvouched", - color: "fbca04", - description: "PR author is not yet trusted in the VOUCHED list.", - }, - { - name: "vouch:denounced", - color: "d1242f", - description: "PR author is explicitly blocked by the VOUCHED list.", - }, - ]; - - const managedLabelNames = new Set(managedLabels.map((label) => label.name)); - - for (const label of managedLabels) { - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - }); - - if ( - existing.color !== label.color || - (existing.description ?? "") !== label.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } - } - - const nextLabelName = - status === "denounced" - ? "vouch:denounced" - : ["bot", "collaborator", "vouched"].includes(status) - ? "vouch:trusted" - : "vouch:unvouched"; - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - - for (const label of currentLabels) { - if (!managedLabelNames.has(label.name) || label.name === nextLabelName) { - continue; - } - - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: label.name, - }); - } catch (removeError) { - if (removeError.status !== 404) { - throw removeError; - } - } - } - - if (!currentLabels.some((label) => label.name === nextLabelName)) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [nextLabelName], - }); - } - - core.info(`PR #${issueNumber}: ${status} -> ${nextLabelName}`); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12ac0370c3b..841f3ea50a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,84 +1,45 @@ -name: Release +name: Stable Artifact Release on: push: tags: - "v*.*.*" - - "!v*-nightly.*" - schedule: - - cron: "0 */3 * * *" - workflow_dispatch: - inputs: - channel: - description: "Release channel" - required: false - default: stable - type: choice - options: - - stable - - nightly - version: - description: "Release version (for example 1.2.3 or v1.2.3)" - required: false - type: string + - "!v*-*" + - "!v*+*" permissions: - contents: read - id-token: none + contents: write jobs: - check_changes: - name: Check for changes since last nightly - if: github.event_name == 'schedule' - runs-on: blacksmith-8vcpu-ubuntu-2404 + metadata: + name: Resolve release metadata + runs-on: ubuntu-24.04 outputs: - has_changes: ${{ steps.check.outputs.has_changes }} + release_tag: ${{ steps.meta.outputs.release_tag }} + release_name: ${{ steps.meta.outputs.release_name }} + release_version: ${{ steps.meta.outputs.release_version }} steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - id: check - name: Compare HEAD to last nightly tag + - id: meta + name: Resolve stable release version + shell: bash + env: + TAG_NAME: ${{ github.ref_name }} run: | - last_nightly_tag=$(git tag --list 'v*-nightly.*' 'nightly-v*' --sort=-creatordate | head -n 1) - if [[ -z "$last_nightly_tag" ]]; then - echo "No previous nightly tag found. Proceeding with release." - echo "has_changes=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - last_nightly_sha=$(git rev-parse "$last_nightly_tag^{commit}") - head_sha=$(git rev-parse HEAD) - - if [[ "$last_nightly_sha" == "$head_sha" ]]; then - echo "No changes on main since last nightly release ($last_nightly_tag). Skipping." - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "Changes detected on main since $last_nightly_tag ($last_nightly_sha โ†’ $head_sha). Proceeding." - echo "has_changes=true" >> "$GITHUB_OUTPUT" + set -euo pipefail + if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Stable artifact releases require plain semver tags like v1.2.3." + exit 1 fi + version="${TAG_NAME#v}" + echo "release_tag=$TAG_NAME" >> "$GITHUB_OUTPUT" + echo "release_name=T3 Code $version" >> "$GITHUB_OUTPUT" + echo "release_version=$version" >> "$GITHUB_OUTPUT" preflight: name: Preflight - needs: [check_changes] - if: | - !failure() && !cancelled() && - (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - outputs: - release_channel: ${{ steps.release_meta.outputs.release_channel }} - version: ${{ steps.release_meta.outputs.version }} - tag: ${{ steps.release_meta.outputs.tag }} - release_name: ${{ steps.release_meta.outputs.name }} - short_sha: ${{ steps.release_meta.outputs.short_sha }} - previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} - cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} - is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} - make_latest: ${{ steps.release_meta.outputs.make_latest }} - ref: ${{ github.sha }} + needs: metadata + runs-on: ubuntu-24.04 + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6 @@ -95,60 +56,6 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron - - id: release_meta - name: Resolve release version - shell: bash - env: - DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} - DISPATCH_VERSION: ${{ github.event.inputs.version }} - NIGHTLY_DATE: ${{ github.run_started_at }} - NIGHTLY_SHA: ${{ github.sha }} - NIGHTLY_RUN_NUMBER: ${{ github.run_number }} - run: | - if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then - nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" - - node scripts/resolve-nightly-release.ts \ - --date "$nightly_date" \ - --run-number "$NIGHTLY_RUN_NUMBER" \ - --sha "$NIGHTLY_SHA" \ - --github-output - - echo "release_channel=nightly" >> "$GITHUB_OUTPUT" - echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - echo "make_latest=false" >> "$GITHUB_OUTPUT" - else - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - raw="${DISPATCH_VERSION}" - if [[ -z "$raw" ]]; then - echo "workflow_dispatch stable releases require the version input." >&2 - exit 1 - fi - else - raw="${GITHUB_REF_NAME}" - fi - - version="${raw#v}" - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then - echo "Invalid release version: $raw" >&2 - exit 1 - fi - - echo "release_channel=stable" >> "$GITHUB_OUTPUT" - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=v$version" >> "$GITHUB_OUTPUT" - echo "name=T3 Code v$version" >> "$GITHUB_OUTPUT" - echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT" - if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "is_prerelease=false" >> "$GITHUB_OUTPUT" - echo "make_latest=true" >> "$GITHUB_OUTPUT" - else - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - echo "make_latest=false" >> "$GITHUB_OUTPUT" - fi - fi - - name: Check run: vp check @@ -158,19 +65,10 @@ jobs: - name: Test run: vp run test - - id: previous_tag - name: Resolve previous release tag - run: | - node scripts/resolve-previous-release-tag.ts \ - --channel "${{ steps.release_meta.outputs.release_channel }}" \ - --current-tag "${{ steps.release_meta.outputs.tag }}" \ - --github-output - - relay_public_config: - name: Resolve T3 Connect public config + public_config: + name: Resolve artifact public config needs: preflight - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 environment: name: production @@ -180,58 +78,32 @@ jobs: clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ steps.public_config.outputs.relay_url }} env: - CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + T3CODE_RELAY_URL: ${{ vars.T3CODE_RELAY_URL }} RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }} CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3code-relay... - - - id: relay_state - name: Read production relay tracing config - shell: bash - run: | - vp run --filter t3code-relay deploy \ - --stage prod \ - --read-state \ - --github-output \ - --github-env-file "$RUNNER_TEMP/relay-client-tracing.env" - - - name: Upload relay client tracing config - uses: actions/upload-artifact@v7 - with: - name: relay-client-tracing-config - path: ${{ runner.temp }}/relay-client-tracing.env - if-no-files-found: error - retention-days: 1 - - id: public_config - name: Resolve production relay public config + name: Resolve build-time cloud config shell: bash run: | set -euo pipefail - relay_domain="${RELAY_DOMAIN:-}" - if [[ -z "$relay_domain" && -n "${RELAY_API_ZONE_NAME:-}" ]]; then - relay_domain="relay.$RELAY_API_ZONE_NAME" + relay_url="${T3CODE_RELAY_URL:-}" + if [[ -z "$relay_url" ]]; then + relay_domain="${RELAY_DOMAIN:-}" + if [[ -z "$relay_domain" && -n "${RELAY_API_ZONE_NAME:-}" ]]; then + relay_domain="relay.$RELAY_API_ZONE_NAME" + fi + if [[ -n "$relay_domain" ]]; then + relay_url="https://$relay_domain" + fi fi + required=( - relay_domain + relay_url CLERK_PUBLISHABLE_KEY CLERK_JWT_TEMPLATE CLERK_CLI_OAUTH_CLIENT_ID @@ -243,780 +115,41 @@ jobs: fi done if (( ${#missing[@]} > 0 )); then - printf 'Missing required relay deployment configuration: %s\n' "${missing[*]}" >&2 + printf 'Missing required artifact public configuration: %s\n' "${missing[*]}" >&2 + exit 1 + fi + if [[ ! "$relay_url" =~ ^https:// ]]; then + printf 'Artifact relay_url must be an HTTPS URL: %s\n' "$relay_url" >&2 exit 1 fi echo "clerk_publishable_key=$CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT" echo "clerk_jwt_template=$CLERK_JWT_TEMPLATE" >> "$GITHUB_OUTPUT" echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" - echo "relay_url=https://$relay_domain" >> "$GITHUB_OUTPUT" - - # node-pty publishes no Linux prebuilt and the WSL backend runs under the - # distro's own (Linux) Node, which can't load the Windows/Electron binary. We - # build the Linux pty.node here, on Linux, and hand it to the Windows packaging - # job โ€” the Windows artifact then ships a ready WSL backend binary with no - # cross-compiling and no first-launch compiler/node-gyp/network on the user's - # machine. node-pty is N-API, so one binary works across all WSL Node versions. - build_wsl_node_pty: - name: Build WSL node-pty (linux-x64) - needs: [preflight] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - fetch-depth: 0 - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: true - - - name: Build node-pty linux-x64 prebuild - shell: bash - run: | - set -euo pipefail - # Resolve node-pty from apps/server (where it's a dependency) and build - # its native binary from source for Linux. node-addon-api resolves from - # node-pty's own dependency tree, so node-gyp has everything it needs. - pty_pkg="$(node -e "console.log(require.resolve('node-pty/package.json', { paths: ['$GITHUB_WORKSPACE/apps/server'] }))")" - pty_dir="$(dirname "$pty_pkg")" - ( cd "$pty_dir" && npx --yes node-gyp rebuild ) - mkdir -p wsl-prebuild - cp "$pty_dir/build/Release/pty.node" wsl-prebuild/pty.node - file wsl-prebuild/pty.node - - - name: Upload node-pty linux-x64 prebuild - uses: actions/upload-artifact@v7 - with: - name: wsl-node-pty-x64 - path: wsl-prebuild/pty.node - if-no-files-found: error - - build: - name: Build ${{ matrix.label }} - # build_wsl_node_pty stays in `needs` so it runs first and its artifact is - # available to download, but only the Windows matrix entry consumes it. We - # therefore gate the job on preflight + relay (must succeed) WITHOUT requiring - # build_wsl_node_pty, so a failed Linux prebuild doesn't skip the macOS/Linux - # builds. `!cancelled()` (not `!failure()`) lets the job run even when - # build_wsl_node_pty failed; the Windows-only download step below then fails - # that single platform if the prebuild is missing. - needs: [preflight, relay_public_config, build_wsl_node_pty] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - env: - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} - T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} - strategy: - fail-fast: false - matrix: - include: - - label: macOS arm64 - runner: blacksmith-12vcpu-macos-26 - platform: mac - target: dmg - arch: arm64 - - label: macOS x64 - runner: blacksmith-12vcpu-macos-26 - platform: mac - target: dmg - arch: x64 - - label: Linux x64 - runner: blacksmith-32vcpu-ubuntu-2404 - platform: linux - target: AppImage - arch: x64 - - label: Windows x64 - runner: blacksmith-32vcpu-windows-2025 - platform: win - target: nsis - arch: x64 - # - label: Windows arm64 - # runner: windows-11-arm - # platform: win - # target: nsis - # arch: arm64 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - fetch-depth: 0 - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: true - - - name: Download relay client tracing config - uses: actions/download-artifact@v8 - with: - name: relay-client-tracing-config - path: ${{ runner.temp }}/relay-client-tracing - - - name: Load relay client tracing config - shell: bash - run: | - config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" - tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" - echo "::add-mask::$tracing_token" - cat "$config_path" >> "$GITHUB_ENV" - - - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - - name: Download WSL node-pty prebuild - if: matrix.platform == 'win' - uses: actions/download-artifact@v7 - with: - name: wsl-node-pty-x64 - path: wsl-prebuild - - - name: Install Spectre-mitigated MSVC libs - if: matrix.platform == 'win' - shell: pwsh - run: | - $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - $installPath = & $vswhere -products * -latest -property installationPath - $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" - $proc = Start-Process -FilePath $setupExe ` - -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` - "Microsoft.VisualStudio.Component.VC.Tools.x86.x64.Spectre", "--quiet", "--norestart" ` - -Wait -PassThru -NoNewWindow - if ($null -eq $proc -or $proc.ExitCode -ne 0) { - $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } - Write-Error "Visual Studio Installer failed with exit code $code" - exit $code - } - - - name: Install ImageMagick - if: matrix.platform == 'linux' - shell: bash - run: | - if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y imagemagick - fi - - if command -v magick >/dev/null 2>&1; then - magick -version - else - convert -version - fi - - - name: Prepare Azure Trusted Signing - if: matrix.platform == 'win' - shell: pwsh - env: - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} - run: | - $ErrorActionPreference = "Stop" - - $requiredSecrets = @( - $env:AZURE_TENANT_ID, - $env:AZURE_CLIENT_ID, - $env:AZURE_CLIENT_SECRET, - $env:AZURE_TRUSTED_SIGNING_ENDPOINT, - $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME, - $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME, - $env:AZURE_TRUSTED_SIGNING_PUBLISHER_NAME - ) - if ($requiredSecrets | Where-Object { [string]::IsNullOrWhiteSpace($_) }) { - Write-Host "Azure Trusted Signing disabled; skipping TrustedSigning module preparation." - exit 0 - } - - try { - Install-PackageProvider ` - -Name NuGet ` - -MinimumVersion 2.8.5.201 ` - -Force ` - -Scope CurrentUser ` - -ErrorAction Stop - } catch { - Write-Warning "Could not bootstrap NuGet package provider. Continuing because the runner may already have a usable provider. $($_.Exception.Message)" - } - - Install-Module ` - -Name TrustedSigning ` - -MinimumVersion 0.5.0 ` - -Force ` - -AllowClobber ` - -Repository PSGallery ` - -Scope CurrentUser ` - -ErrorAction Stop - - Import-Module TrustedSigning -MinimumVersion 0.5.0 -Force - Get-Command Invoke-TrustedSigning -ErrorAction Stop - - $moduleRoots = @( - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "PowerShell", "Modules"), - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "WindowsPowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "PowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "WindowsPowerShell", "Modules") - ) - $modulePathEntries = @($moduleRoots + ($env:PSModulePath -split ";")) | - Where-Object { $_ -and (Test-Path $_) } | - Select-Object -Unique - "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV - - - name: Build desktop artifact - shell: bash - env: - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} - MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} - T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} - run: | - args=( - --platform "${{ matrix.platform }}" - --target "${{ matrix.target }}" - --arch "${{ matrix.arch }}" - --build-version "${{ needs.preflight.outputs.version }}" - --verbose - ) - - has_all() { - for value in "$@"; do - if [[ -z "$value" ]]; then - return 1 - fi - done - return 0 - } - - if [[ "${{ matrix.platform }}" == "mac" ]]; then - if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then - if ! has_all "$APPLE_TEAM_ID" "$MACOS_PROVISIONING_PROFILE"; then - echo "macOS signing is configured, but APPLE_TEAM_ID or MACOS_PROVISIONING_PROFILE is missing." >&2 - exit 1 - fi - - key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" - - profile_path="$RUNNER_TEMP/t3code.provisionprofile" - printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" - security cms -D -i "$profile_path" >/dev/null - export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" - export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" - - echo "macOS signing enabled." - args+=(--signed) - else - echo "macOS signing disabled (missing one or more Apple signing secrets)." - fi - elif [[ "${{ matrix.platform }}" == "win" ]]; then - # Bundle the Linux node-pty binary built by the build_wsl_node_pty job - # so the packaged WSL backend ships a ready binary (no first-launch - # compile). Required for a working WSL backend on Windows. - args+=(--wsl-prebuild "$GITHUB_WORKSPACE/wsl-prebuild/pty.node") - if has_all \ - "$AZURE_TENANT_ID" \ - "$AZURE_CLIENT_ID" \ - "$AZURE_CLIENT_SECRET" \ - "$AZURE_TRUSTED_SIGNING_ENDPOINT" \ - "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" \ - "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \ - "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then - echo "Windows signing enabled (Azure Trusted Signing)." - args+=(--signed) - else - echo "Windows signing disabled (missing one or more Azure Trusted Signing secrets)." - fi - else - echo "Signing disabled for ${{ matrix.platform }}." - fi - - vp run dist:desktop:artifact "${args[@]}" - - - name: Collect release assets - shell: bash - run: | - set -euo pipefail - mkdir -p release-publish - - shopt -s nullglob - for pattern in \ - "release/*.dmg" \ - "release/*.zip" \ - "release/*.AppImage" \ - "release/*.exe" \ - "release/*.blockmap" \ - "release/*.yml"; do - for file in $pattern; do - cp "$file" release-publish/ - done - done - - if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then - shopt -s nullglob - for manifest in release-publish/*-mac.yml; do - mv "$manifest" "${manifest%.yml}-${{ matrix.arch }}.yml" - done - fi - - # Enable if Windows arm64 builds are enabled. - # Windows updater metadata is channel-specific (for example - # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the - # release job can merge matching arm64/x64 manifests back into one - # canonical manifest per channel. - # if [[ "${{ matrix.platform }}" == "win" ]]; then - # shopt -s nullglob - # for manifest in release-publish/*.yml; do - # mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml" - # done - # fi - - - name: Upload build artifacts - uses: actions/upload-artifact@v7 - with: - name: desktop-${{ matrix.platform }}-${{ matrix.arch }} - path: release-publish/* - if-no-files-found: error - - publish_cli: - name: Publish CLI to npm - needs: [preflight, relay_public_config, build] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build.result == 'success' }} - runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - permissions: - contents: read - id-token: write - env: - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} - T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3... - - --filter=@t3tools/web... - - --filter=@t3tools/scripts... - - - name: Download relay client tracing config - uses: actions/download-artifact@v8 - with: - name: relay-client-tracing-config - path: ${{ runner.temp }}/relay-client-tracing - - - name: Load relay client tracing config - shell: bash - run: | - config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" - tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" - echo "::add-mask::$tracing_token" - cat "$config_path" >> "$GITHUB_ENV" - - - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - - name: Build web package - run: vp run --filter @t3tools/web build - - - name: Build CLI package - run: vp run --filter t3 build - - - name: Publish CLI package - run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose - - release: - name: Publish GitHub Release - needs: [preflight, build, publish_cli] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - steps: - - id: app_token - name: Mint release app token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ secrets.RELEASE_APP_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - - name: Download all desktop artifacts - uses: actions/download-artifact@v8 - with: - pattern: desktop-* - merge-multiple: true - path: release-assets - - - name: Merge macOS updater manifests - run: | - shopt -s nullglob - for x64_manifest in release-assets/*-mac-x64.yml; do - arm64_manifest="${x64_manifest%-x64.yml}.yml" - if [[ -f "$arm64_manifest" ]]; then - node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" - rm -f "$x64_manifest" - fi - done - - # - name: Merge Windows updater manifests - # run: | - # shopt -s nullglob - # found_windows_manifest=false - # for x64_manifest in release-assets/*-win-x64.yml; do - # if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then - # continue - # fi - - # arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}" - # output_manifest="${x64_manifest/-win-x64.yml/.yml}" - # if [[ ! -f "$arm64_manifest" ]]; then - # echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 - # exit 1 - # fi - - # found_windows_manifest=true - # node scripts/merge-update-manifests.ts --platform win \ - # "$arm64_manifest" \ - # "$x64_manifest" \ - # "$output_manifest" - # rm -f "$arm64_manifest" "$x64_manifest" - # done - - # if [[ "$found_windows_manifest" != true ]]; then - # echo "No Windows updater manifests found to merge." >&2 - # exit 1 - # fi - - - name: Publish release - if: needs.preflight.outputs.previous_tag != '' - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ needs.preflight.outputs.tag }} - target_commitish: ${{ needs.preflight.outputs.ref }} - name: ${{ needs.preflight.outputs.release_name }} - generate_release_notes: true - previous_tag: ${{ needs.preflight.outputs.previous_tag }} - prerelease: ${{ needs.preflight.outputs.is_prerelease }} - make_latest: ${{ needs.preflight.outputs.make_latest }} - files: | - release-assets/*.dmg - release-assets/*.zip - release-assets/*.AppImage - release-assets/*.exe - release-assets/*.blockmap - release-assets/*.yml - fail_on_unmatched_files: true - token: ${{ steps.app_token.outputs.token }} - - - name: Publish first release - if: needs.preflight.outputs.previous_tag == '' - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ needs.preflight.outputs.tag }} - target_commitish: ${{ needs.preflight.outputs.ref }} - name: ${{ needs.preflight.outputs.release_name }} - generate_release_notes: true - prerelease: ${{ needs.preflight.outputs.is_prerelease }} - make_latest: ${{ needs.preflight.outputs.make_latest }} - files: | - release-assets/*.dmg - release-assets/*.zip - release-assets/*.AppImage - release-assets/*.exe - release-assets/*.blockmap - release-assets/*.yml - fail_on_unmatched_files: true - token: ${{ steps.app_token.outputs.token }} - - deploy_web: - name: Deploy hosted web app - needs: [preflight, relay_public_config, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - env: - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} - T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }} - T3CODE_WEB_LATEST_DOMAIN: ${{ vars.T3CODE_WEB_LATEST_DOMAIN }} - T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }} - VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/web... - - - name: Download relay client tracing config - uses: actions/download-artifact@v8 - with: - name: relay-client-tracing-config - path: ${{ runner.temp }}/relay-client-tracing - - - name: Load relay client tracing config - shell: bash - run: | - config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" - tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" - echo "::add-mask::$tracing_token" - cat "$config_path" >> "$GITHUB_ENV" - - - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - - name: Refresh release lockfile - run: vp install --lockfile-only --ignore-scripts - - - name: Deploy and alias channel - shell: bash - run: | - set -euo pipefail - - if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then - echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2 - exit 1 - fi - - router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}" - latest_domain="${T3CODE_WEB_LATEST_DOMAIN:-latest.app.t3.codes}" - nightly_domain="${T3CODE_WEB_NIGHTLY_DOMAIN:-nightly.app.t3.codes}" - router_domain="${router_url#http://}" - router_domain="${router_domain#https://}" - router_domain="${router_domain%%/*}" - - if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then - channel_domain="$latest_domain" - channel_name="latest" - else - channel_domain="$nightly_domain" - channel_name="nightly" - fi - - vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" - vercel_scope_args=(--scope "$vercel_scope") - - echo "Deploying hosted web app for $channel_name channel." - deployment_url="$( - vp dlx vercel@53.1.1 deploy \ - --prod \ - --skip-domain \ - --yes \ - --token "$VERCEL_TOKEN" \ - "${vercel_scope_args[@]}" \ - --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ - --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \ - --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \ - --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \ - --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=${T3CODE_RELAY_CLIENT_OTLP_TRACES_URL:-}" \ - --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=${T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET:-}" \ - --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=${T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN:-}" \ - --build-env "VITE_HOSTED_APP_URL=$router_url" \ - --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" - )" - - echo "Aliasing $deployment_url to $channel_domain." - vp dlx vercel@53.1.1 alias set "$deployment_url" "$channel_domain" \ - --token "$VERCEL_TOKEN" \ - "${vercel_scope_args[@]}" - - if [[ "$channel_name" == "latest" && -n "$router_domain" && "$router_domain" != "$channel_domain" ]]; then - echo "Aliasing $deployment_url to router domain $router_domain." - vp dlx vercel@53.1.1 alias set "$deployment_url" "$router_domain" \ - --token "$VERCEL_TOKEN" \ - "${vercel_scope_args[@]}" - fi - - finalize: - name: Finalize release - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} - needs: [preflight, release] - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - steps: - - id: app_token - name: Mint release app token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ secrets.RELEASE_APP_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - - name: Checkout - uses: actions/checkout@v6 - with: - ref: main - fetch-depth: 0 - token: ${{ steps.app_token.outputs.token }} - persist-credentials: true - - - id: app_bot - name: Resolve GitHub App bot identity - env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} - APP_SLUG: ${{ steps.app_token.outputs.app-slug }} - run: | - user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" - echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" - echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT" - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/oxlint-plugin-t3code... - - - id: update_versions - name: Update version strings - env: - RELEASE_VERSION: ${{ needs.preflight.outputs.version }} - run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output - - - name: Format package.json files - if: steps.update_versions.outputs.changed == 'true' - run: vp fmt apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json - - - name: Refresh lockfile - if: steps.update_versions.outputs.changed == 'true' - run: vp install --lockfile-only --ignore-scripts - - - name: Commit and push version bump - if: steps.update_versions.outputs.changed == 'true' - shell: bash - env: - RELEASE_TAG: ${{ needs.preflight.outputs.tag }} - run: | - if git diff --quiet -- apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml; then - echo "No version changes to commit." - exit 0 - fi - - git config user.name "${{ steps.app_bot.outputs.name }}" - git config user.email "${{ steps.app_bot.outputs.email }}" - - git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml - git commit -m "chore(release): prepare $RELEASE_TAG" - git push origin HEAD:main - - announce_discord: - name: Announce release on Discord - if: | - always() && !cancelled() && - needs.preflight.result == 'success' && - needs.relay_public_config.result == 'success' && - needs.release.result == 'success' && - needs.deploy_web.result == 'success' && - (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') - needs: [preflight, relay_public_config, release, deploy_web, finalize] - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - - name: Announce prerelease on Discord - if: needs.preflight.outputs.is_prerelease == 'true' - continue-on-error: true - env: - DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_NIGHTLY_ROLE_ID }} - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} - run: | - node scripts/notify-discord-release.ts prerelease \ - --role-id "$DISCORD_MENTION_ROLE_ID" \ - --release-name "${{ needs.preflight.outputs.release_name }}" \ - --release-version "${{ needs.preflight.outputs.version }}" \ - --tag "${{ needs.preflight.outputs.tag }}" \ - --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" - - - name: Announce latest release on Discord - if: needs.preflight.outputs.make_latest == 'true' - continue-on-error: true - env: - DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_LATEST_ROLE_ID }} - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} - run: | - node scripts/notify-discord-release.ts latest \ - --role-id "$DISCORD_MENTION_ROLE_ID" \ - --release-name "${{ needs.preflight.outputs.release_name }}" \ - --release-version "${{ needs.preflight.outputs.version }}" \ - --tag "${{ needs.preflight.outputs.tag }}" \ - --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" + echo "relay_url=$relay_url" >> "$GITHUB_OUTPUT" + + publish_artifacts: + name: Build and publish artifacts + needs: [metadata, preflight, public_config] + uses: ./.github/workflows/reusable-build-release-artifacts.yml + # This fork intentionally publishes Android APK and Windows x64 artifacts only. + with: + ref: ${{ github.sha }} + release_tag: ${{ needs.metadata.outputs.release_tag }} + release_name: ${{ needs.metadata.outputs.release_name }} + release_version: ${{ needs.metadata.outputs.release_version }} + android_required: true + android_profile: production-apk + android_environment: production + android_app_variant: production + android_artifact_name: t3-code-android.apk + android_mobile_version_policy: appVersion + android_app_version: ${{ needs.metadata.outputs.release_version }} + clerk_publishable_key: ${{ needs.public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.public_config.outputs.relay_url }} + prerelease: false + make_latest: true + windows_signing: true + secrets: inherit diff --git a/.github/workflows/reusable-build-release-artifacts.yml b/.github/workflows/reusable-build-release-artifacts.yml index f6d4c689c1b..3d07056bdba 100644 --- a/.github/workflows/reusable-build-release-artifacts.yml +++ b/.github/workflows/reusable-build-release-artifacts.yml @@ -19,31 +19,154 @@ on: description: "Application version passed into packaged artifacts" required: true type: string + android_required: + description: "Whether a missing Android APK should fail the release" + required: false + default: false + type: boolean + android_profile: + description: "EAS build profile used for the Android APK" + required: false + default: preview + type: string + android_environment: + description: "EAS environment pulled before the Android APK build" + required: false + default: preview + type: string + android_app_variant: + description: "APP_VARIANT passed to the Expo app config" + required: false + default: preview + type: string + android_artifact_name: + description: "Downloaded APK filename to publish" + required: false + default: t3-code-preview-android.apk + type: string + android_mobile_version_policy: + description: "MOBILE_VERSION_POLICY passed to the Expo app config" + required: false + default: fingerprint + type: string + android_app_version: + description: "Expo app version passed to the mobile app config" + required: false + default: "" + type: string + android_public_config: + description: "Whether to inject reusable workflow public config into the Android EAS environment" + required: false + default: true + type: boolean prerelease: description: "Whether the GitHub Release is a prerelease" required: false default: true type: boolean + make_latest: + description: "Whether this release should become the repository latest release" + required: false + default: false + type: boolean + windows_signing: + description: "Enable Windows signing when Azure Trusted Signing secrets are configured" + required: false + default: false + type: boolean + clerk_publishable_key: + description: "Clerk publishable key embedded into desktop/web bundles" + required: false + default: "" + type: string + clerk_jwt_template: + description: "Clerk JWT template embedded into web/mobile cloud config" + required: false + default: "" + type: string + clerk_cli_oauth_client_id: + description: "Clerk OAuth client ID embedded into the desktop server bundle" + required: false + default: "" + type: string + relay_url: + description: "T3 Connect relay URL embedded into desktop/web bundles" + required: false + default: "" + type: string secrets: EXPO_TOKEN: description: "Expo token used for non-interactive EAS APK builds" - required: true + required: false + AZURE_TENANT_ID: + required: false + AZURE_CLIENT_ID: + required: false + AZURE_CLIENT_SECRET: + required: false + AZURE_TRUSTED_SIGNING_ENDPOINT: + required: false + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: + required: false + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: + required: false + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: + required: false permissions: contents: read jobs: + android_preflight: + name: Check Android release credentials + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + expo_token_present: ${{ steps.expo.outputs.present }} + steps: + - id: expo + name: Validate EXPO_TOKEN + env: + ANDROID_REQUIRED: ${{ inputs.android_required }} + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + shell: bash + run: | + set -euo pipefail + if [[ -n "${EXPO_TOKEN:-}" ]]; then + echo "present=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "present=false" >> "$GITHUB_OUTPUT" + if [[ "$ANDROID_REQUIRED" == "true" ]]; then + echo "::error::EXPO_TOKEN is required for stable Android APK release artifacts." + echo "Create a repository secret named EXPO_TOKEN with access to the EAS production project used by apps/mobile." + exit 1 + fi + + { + echo "::warning::EXPO_TOKEN is required to build the Android APK release artifact." + echo "Create a repository secret named EXPO_TOKEN with access to the EAS project used by apps/mobile." + echo "Also verify EXPO_OWNER / EXPO_PROJECT_ID repository variables if the defaults are not correct." + } + android_apk: name: Build Android APK + needs: android_preflight + if: needs.android_preflight.outputs.expo_token_present == 'true' runs-on: ubuntu-24.04 timeout-minutes: 45 env: - APP_VARIANT: preview - MOBILE_VERSION_POLICY: fingerprint + APP_VARIANT: ${{ inputs.android_app_variant }} + MOBILE_APP_VERSION: ${{ inputs.android_app_version }} + MOBILE_VERSION_POLICY: ${{ inputs.android_mobile_version_policy }} NODE_OPTIONS: --max-old-space-size=8192 EXPO_OWNER: ${{ vars.EXPO_OWNER }} EXPO_PROJECT_ID: ${{ vars.EXPO_PROJECT_ID }} EXPO_UPDATES_URL: ${{ vars.EXPO_UPDATES_URL }} + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ inputs.android_public_config && (inputs.clerk_publishable_key || vars.CLERK_PUBLISHABLE_KEY) || '' }} + T3CODE_CLERK_JWT_TEMPLATE: ${{ inputs.android_public_config && (inputs.clerk_jwt_template || vars.CLERK_JWT_TEMPLATE) || '' }} + T3CODE_RELAY_URL: ${{ inputs.android_public_config && (inputs.relay_url || vars.T3CODE_RELAY_URL) || '' }} steps: - name: Checkout uses: actions/checkout@v6 @@ -73,20 +196,40 @@ jobs: token: ${{ secrets.EXPO_TOKEN }} packager: pnpm - - name: Pull preview environment variables + - name: Pull Android environment variables working-directory: apps/mobile env: + EAS_ENVIRONMENT: ${{ inputs.android_environment }} EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: eas env:pull preview --non-interactive + run: eas env:pull "$EAS_ENVIRONMENT" --non-interactive + + - name: Materialize Android app version + if: inputs.android_app_version != '' + working-directory: apps/mobile + env: + MOBILE_APP_VERSION: ${{ inputs.android_app_version }} + shell: bash + run: | + set -euo pipefail + node <<'NODE' + const fs = require("node:fs"); + const version = process.env.MOBILE_APP_VERSION?.trim(); + if (!version) { + throw new Error("MOBILE_APP_VERSION is required when materializing release-version.json."); + } + fs.writeFileSync("release-version.json", `${JSON.stringify({ version }, null, 2)}\n`); + NODE - name: Build APK with EAS working-directory: apps/mobile env: + APK_ARTIFACT_NAME: ${{ inputs.android_artifact_name }} + EAS_PROFILE: ${{ inputs.android_profile }} EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} shell: bash run: | set -euo pipefail - eas build --profile preview --platform android --non-interactive --wait --json > eas-build.json + eas build --profile "$EAS_PROFILE" --platform android --non-interactive --wait --json > eas-build.json node - <<'NODE' const fs = require("node:fs"); const path = require("node:path"); @@ -105,7 +248,7 @@ jobs: fs.writeFileSync(path.resolve("release-assets", "apk-url.txt"), `${url}\n`); NODE apk_url="$(cat release-assets/apk-url.txt)" - curl --fail --location "$apk_url" --output "release-assets/t3-code-preview-android.apk" + curl --fail --location "$apk_url" --output "release-assets/$APK_ARTIFACT_NAME" - name: Upload APK artifact uses: actions/upload-artifact@v7 @@ -116,6 +259,8 @@ jobs: build_wsl_node_pty: name: Build WSL node-pty linux-x64 + needs: android_preflight + if: needs.android_preflight.result == 'success' runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -152,14 +297,15 @@ jobs: windows_x64: name: Build Windows x64 - needs: build_wsl_node_pty + needs: [android_preflight, build_wsl_node_pty] + if: needs.android_preflight.result == 'success' && needs.build_wsl_node_pty.result == 'success' runs-on: windows-2025 timeout-minutes: 45 env: - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} - T3CODE_CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }} - T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} - T3CODE_RELAY_URL: ${{ vars.T3CODE_RELAY_URL }} + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ inputs.clerk_publishable_key || vars.CLERK_PUBLISHABLE_KEY }} + T3CODE_CLERK_JWT_TEMPLATE: ${{ inputs.clerk_jwt_template || vars.CLERK_JWT_TEMPLATE }} + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ inputs.clerk_cli_oauth_client_id || vars.CLERK_CLI_OAUTH_CLIENT_ID }} + T3CODE_RELAY_URL: ${{ inputs.relay_url || vars.T3CODE_RELAY_URL }} steps: - name: Checkout uses: actions/checkout@v6 @@ -183,17 +329,116 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron + - name: Align package versions to release version + run: node scripts/update-release-package-versions.ts "${{ inputs.release_version }}" + + - name: Prepare Azure Trusted Signing + if: inputs.windows_signing + shell: pwsh + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} + run: | + $ErrorActionPreference = "Stop" + + $requiredSecrets = @( + $env:AZURE_TENANT_ID, + $env:AZURE_CLIENT_ID, + $env:AZURE_CLIENT_SECRET, + $env:AZURE_TRUSTED_SIGNING_ENDPOINT, + $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME, + $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME, + $env:AZURE_TRUSTED_SIGNING_PUBLISHER_NAME + ) + if ($requiredSecrets | Where-Object { [string]::IsNullOrWhiteSpace($_) }) { + Write-Host "Azure Trusted Signing disabled; missing one or more signing secrets." + exit 0 + } + + try { + Install-PackageProvider ` + -Name NuGet ` + -MinimumVersion 2.8.5.201 ` + -Force ` + -Scope CurrentUser ` + -ErrorAction Stop + } catch { + Write-Warning "Could not bootstrap NuGet package provider. Continuing because the runner may already have a usable provider. $($_.Exception.Message)" + } + + Install-Module ` + -Name TrustedSigning ` + -MinimumVersion 0.5.0 ` + -Force ` + -AllowClobber ` + -Repository PSGallery ` + -Scope CurrentUser ` + -ErrorAction Stop + + Import-Module TrustedSigning -MinimumVersion 0.5.0 -Force + Get-Command Invoke-TrustedSigning -ErrorAction Stop + + $moduleRoots = @( + [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "PowerShell", "Modules"), + [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "WindowsPowerShell", "Modules"), + [System.IO.Path]::Combine($env:ProgramFiles, "PowerShell", "Modules"), + [System.IO.Path]::Combine($env:ProgramFiles, "WindowsPowerShell", "Modules") + ) + $modulePathEntries = @($moduleRoots + ($env:PSModulePath -split ";")) | + Where-Object { $_ -and (Test-Path $_) } | + Select-Object -Unique + "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV + - name: Build Windows desktop artifact shell: bash + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} run: | set -euo pipefail - vp run dist:desktop:artifact \ + args=( --platform win \ --target nsis \ --arch x64 \ --build-version "${{ inputs.release_version }}" \ --wsl-prebuild "$GITHUB_WORKSPACE/wsl-prebuild/pty.node" \ --verbose + ) + + if [[ "${{ inputs.windows_signing }}" == "true" ]]; then + missing=() + for name in \ + AZURE_TENANT_ID \ + AZURE_CLIENT_ID \ + AZURE_CLIENT_SECRET \ + AZURE_TRUSTED_SIGNING_ENDPOINT \ + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME \ + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME \ + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME; do + if [[ -z "${!name:-}" ]]; then + missing+=("$name") + fi + done + + if (( ${#missing[@]} == 0 )); then + echo "Windows signing enabled (Azure Trusted Signing)." + args+=(--signed) + else + printf 'Windows signing disabled; missing secrets: %s\n' "${missing[*]}" + fi + fi + + vp run dist:desktop:artifact "${args[@]}" - name: Collect Windows release assets shell: bash @@ -216,7 +461,21 @@ jobs: publish_release: name: Publish GitHub Release - needs: [android_apk, windows_x64] + needs: [android_preflight, android_apk, windows_x64] + if: >- + ${{ + always() && + needs.android_preflight.result == 'success' && + needs.windows_x64.result == 'success' && + ( + ( + !inputs.android_required && + needs.android_preflight.outputs.expo_token_present == 'false' && + needs.android_apk.result == 'skipped' + ) || + needs.android_apk.result == 'success' + ) + }} runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -229,6 +488,26 @@ jobs: merge-multiple: true path: release-assets + - id: release_files + name: Resolve release files + shell: bash + run: | + set -euo pipefail + files="$( + find release-assets -maxdepth 1 -type f \ + \( -name '*.apk' -o -name '*.exe' -o -name '*.blockmap' -o -name '*.yml' \) \ + | sort + )" + if [[ -z "$files" ]]; then + echo "::error::No release assets were downloaded." + exit 1 + fi + { + echo "files<> "$GITHUB_OUTPUT" + - name: Publish release uses: softprops/action-gh-release@v2 with: @@ -236,11 +515,7 @@ jobs: target_commitish: ${{ inputs.ref }} name: ${{ inputs.release_name }} prerelease: ${{ inputs.prerelease }} - make_latest: false - files: | - release-assets/*.apk - release-assets/*.exe - release-assets/*.blockmap - release-assets/*.yml + make_latest: ${{ inputs.make_latest }} + files: ${{ steps.release_files.outputs.files }} fail_on_unmatched_files: true token: ${{ github.token }} diff --git a/.github/workflows/reusable-pr-babysitter.yml b/.github/workflows/reusable-pr-babysitter.yml index 3367936de3c..87a57e5d857 100644 --- a/.github/workflows/reusable-pr-babysitter.yml +++ b/.github/workflows/reusable-pr-babysitter.yml @@ -17,7 +17,7 @@ on: permissions: contents: write - issues: write + issues: read pull-requests: write actions: read checks: read @@ -146,12 +146,6 @@ jobs: git commit -m "fix: address automated review feedback" git push - - name: Ask reviewers to re-check - if: steps.state_before.outputs.ready_to_merge != 'true' && steps.openai_key.outputs.present == 'true' && steps.base_prompt.outputs.available == 'true' - shell: bash - run: | - gh pr comment "$PR_NUMBER" --body $'@codex review\n\n@greptileai review' - - name: Upload Codex babysitter output if: always() && steps.state_before.outputs.ready_to_merge != 'true' uses: actions/upload-artifact@v7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b734a99bbb..213169a4ec6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,9 +8,7 @@ You can still open an issue or PR, but please do so knowing there is a high chan If that sounds annoying, that is because it is. This project is still early and we are trying to keep scope, quality, and direction under control. -PRs are automatically labeled with a `vouch:*` trust status and a `size:*` diff size based on changed lines. - -If you are an external contributor, expect `vouch:unvouched` until we explicitly add you to [.github/VOUCHED.td](.github/VOUCHED.td). +Untrusted drive-by PRs may be closed without review. The maintainers keep review budget for work from repository collaborators and explicitly requested contributors. ## What We Are Most Likely To Accept diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index d7f40bae827..a1b50470f54 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,4 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off - Expo config runs in Node and reads a generated release version file. import type { ExpoConfig } from "expo/config"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; @@ -59,13 +62,37 @@ const firstNonEmpty = (...values: ReadonlyArray): string | u const easProjectId = firstNonEmpty(repoEnv.EXPO_PROJECT_ID, repoEnv.EXPO_PUBLIC_EAS_PROJECT_ID) ?? "d763fcb8-d37c-41ea-a773-b54a0ab4a454"; +const mobileAppVersion = + firstNonEmpty( + repoEnv.MOBILE_APP_VERSION, + repoEnv.EXPO_PUBLIC_APP_VERSION, + readReleaseVersion(), + ) ?? "0.1.0"; + +function readReleaseVersion(): string | undefined { + const releaseVersionPath = NodePath.resolve(process.cwd(), "release-version.json"); + + try { + const parsed: unknown = JSON.parse(NodeFS.readFileSync(releaseVersionPath, "utf8")); + const version = + typeof parsed === "object" && parsed !== null && "version" in parsed + ? parsed.version + : undefined; + return typeof version === "string" && version.trim().length > 0 ? version.trim() : undefined; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return undefined; + } + throw error; + } +} const config: ExpoConfig = { name: variant.appName, slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "0.1.0", + version: mobileAppVersion, runtimeVersion: { policy: process.env.MOBILE_VERSION_POLICY ?? "appVersion", }, diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 177a1008bed..08545b9f13f 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -44,6 +44,13 @@ "channel": "production", "environment": "production", "autoIncrement": true + }, + "production-apk": { + "extends": "production", + "distribution": "internal", + "android": { + "buildType": "apk" + } } }, "submit": { diff --git a/apps/mobile/release-version.json b/apps/mobile/release-version.json new file mode 100644 index 00000000000..edd86747a4e --- /dev/null +++ b/apps/mobile/release-version.json @@ -0,0 +1,3 @@ +{ + "version": null +} diff --git a/apps/mobile/src/connection/catalog-store.ts b/apps/mobile/src/connection/catalog-store.ts index b5bda400670..279a31bb1a7 100644 --- a/apps/mobile/src/connection/catalog-store.ts +++ b/apps/mobile/src/connection/catalog-store.ts @@ -22,22 +22,25 @@ function catalogError(operation: string, cause: unknown) { }); } +const decodeConnectionCatalogDocument = Schema.decodeUnknownResult(ConnectionCatalogDocument); +const encodeConnectionCatalogDocument = Schema.encodeUnknownResult(ConnectionCatalogDocument); + const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) { const parsed = yield* Effect.try({ try: () => JSON.parse(raw) as unknown, catch: (cause) => catalogError("decode", cause), }); - return yield* Effect.fromResult( - Schema.decodeUnknownResult(ConnectionCatalogDocument)(parsed), - ).pipe(Effect.mapError((cause) => catalogError("decode", cause))); + return yield* Effect.fromResult(decodeConnectionCatalogDocument(parsed)).pipe( + Effect.mapError((cause) => catalogError("decode", cause)), + ); }); const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* ( catalog: ConnectionCatalogDocumentType, ) { - const encoded = yield* Effect.fromResult( - Schema.encodeUnknownResult(ConnectionCatalogDocument)(catalog), - ).pipe(Effect.mapError((cause) => catalogError("encode", cause))); + const encoded = yield* Effect.fromResult(encodeConnectionCatalogDocument(catalog)).pipe( + Effect.mapError((cause) => catalogError("encode", cause)), + ); return JSON.stringify(encoded); }); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 276ea3c5c08..abeba0def0e 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -55,6 +55,12 @@ const LegacyStoredShellSnapshot = Schema.Struct({ snapshot: OrchestrationShellSnapshot, }); +const decodeStoredShellSnapshot = Schema.decodeUnknownResult(StoredShellSnapshot); +const encodeStoredShellSnapshot = Schema.encodeUnknownResult(StoredShellSnapshot); +const decodeStoredThreadSnapshot = Schema.decodeUnknownResult(StoredThreadSnapshot); +const encodeStoredThreadSnapshot = Schema.encodeUnknownResult(StoredThreadSnapshot); +const decodeLegacyStoredShellSnapshot = Schema.decodeUnknownResult(LegacyStoredShellSnapshot); + function catalogError(operation: string, cause: unknown) { return new ConnectionTransientError({ reason: "remote-unavailable", @@ -289,9 +295,9 @@ export const connectionStorageLayer = Layer.effectContext( try: () => JSON.parse(raw) as unknown, catch: (cause) => shellPersistenceError("load-shell", cause), }); - const stored = yield* Effect.fromResult( - Schema.decodeUnknownResult(StoredShellSnapshot)(parsed), - ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); + const stored = yield* Effect.fromResult(decodeStoredShellSnapshot(parsed)).pipe( + Effect.mapError((cause) => shellPersistenceError("load-shell", cause)), + ); return stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(); @@ -310,7 +316,7 @@ export const connectionStorageLayer = Layer.effectContext( catch: (cause) => shellPersistenceError("load-shell", cause), }); const legacyStored = yield* Effect.fromResult( - Schema.decodeUnknownResult(LegacyStoredShellSnapshot)(legacyParsed), + decodeLegacyStoredShellSnapshot(legacyParsed), ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); return legacyStored.environmentId === environmentId ? Option.some(legacyStored.snapshot) @@ -324,9 +330,9 @@ export const connectionStorageLayer = Layer.effectContext( environmentId, snapshot, } as const; - const encoded = yield* Effect.fromResult( - Schema.encodeUnknownResult(StoredShellSnapshot)(stored), - ).pipe(Effect.mapError((cause) => shellPersistenceError("save-shell", cause))); + const encoded = yield* Effect.fromResult(encodeStoredShellSnapshot(stored)).pipe( + Effect.mapError((cause) => shellPersistenceError("save-shell", cause)), + ); yield* Effect.try({ try: () => { if (!file.exists) { @@ -351,9 +357,9 @@ export const connectionStorageLayer = Layer.effectContext( try: () => JSON.parse(raw) as unknown, catch: (cause) => shellPersistenceError("load-thread", cause), }); - const stored = yield* Effect.fromResult( - Schema.decodeUnknownResult(StoredThreadSnapshot)(parsed), - ).pipe(Effect.mapError((cause) => shellPersistenceError("load-thread", cause))); + const stored = yield* Effect.fromResult(decodeStoredThreadSnapshot(parsed)).pipe( + Effect.mapError((cause) => shellPersistenceError("load-thread", cause)), + ); return stored.environmentId === environmentId && stored.threadId === threadId ? Option.some(stored.thread) : Option.none(); @@ -362,7 +368,7 @@ export const connectionStorageLayer = Layer.effectContext( Effect.gen(function* () { const file = yield* threadSnapshotFile(environmentId, thread.id, "save-thread"); const encoded = yield* Effect.fromResult( - Schema.encodeUnknownResult(StoredThreadSnapshot)({ + encodeStoredThreadSnapshot({ schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, environmentId, threadId: thread.id, diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index fba032c0369..5cfd490a676 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -1,3 +1,4 @@ +/* oxlint-disable react/no-unstable-nested-components -- Existing navigation header render callbacks are outside this CI hardening change. */ import Stack from "expo-router/stack"; import { SymbolView } from "expo-symbols"; import { useLocalSearchParams, useRouter } from "expo-router"; diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 92203c0ed4e..314d8237b54 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -1,3 +1,4 @@ +/* oxlint-disable react/no-unstable-nested-components -- Existing render callbacks are outside this CI hardening change. */ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useLocalSearchParams } from "expo-router"; import Stack from "expo-router/stack"; diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 8e9a47a58b5..99be3794dbf 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -1,3 +1,4 @@ +/* oxlint-disable react/no-unstable-nested-components -- Existing navigation header render callbacks are outside this CI hardening change. */ import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import { SymbolView } from "expo-symbols"; diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index f8c916974e5..464bc339b1d 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,3 +1,4 @@ +/* oxlint-disable react/no-unstable-nested-components -- Existing navigation header render callbacks are outside this CI hardening change. */ import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useMemo, useState } from "react"; import * as Option from "effect/Option"; diff --git a/apps/server/e2e/assert.mjs b/apps/server/e2e/assert.mjs index 0d84b5d54d3..d50f6e69a15 100644 --- a/apps/server/e2e/assert.mjs +++ b/apps/server/e2e/assert.mjs @@ -12,11 +12,11 @@ // close(db) // // node:sqlite is the same SQLite the server build uses (node v22 --experimental). -import { DatabaseSync } from "node:sqlite"; +import * as NodeSqlite from "node:sqlite"; export function openState(dbPath) { // readOnly so the gate can never mutate the restored copy it is validating. - return new DatabaseSync(dbPath, { readOnly: true }); + return new NodeSqlite.DatabaseSync(dbPath, { readOnly: true }); } export function childrenOf(db, parentThreadId) { diff --git a/apps/server/src/cli/cos.ts b/apps/server/src/cli/cos.ts index d824f9e0cea..7bc5c852aa7 100644 --- a/apps/server/src/cli/cos.ts +++ b/apps/server/src/cli/cos.ts @@ -143,14 +143,12 @@ const dispatchLiveOrchestrationCommand = ( const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const initial = createEmptyReadModel(DateTime.formatIso(yield* DateTime.now)); - return yield* orchestrationEngine - .readEvents(0) - .pipe( - Stream.runFoldEffect( - () => initial, - (model, event) => projectEvent(model, event), - ), - ); + return yield* orchestrationEngine.readEvents(0).pipe( + Stream.runFoldEffect( + () => initial, + (model, event) => projectEvent(model, event), + ), + ); }); const tryResolveLiveCosExecutionMode = Effect.fn("tryResolveLiveCosExecutionMode")(function* ( @@ -371,11 +369,15 @@ const cosLinkParentCommand = Command.make("link-parent", { const child = flags.childThreadId.trim(); const parent = flags.parentThreadId.trim(); if (child.length === 0 || parent.length === 0) { - return yield* new CosCommandError({ message: "Child and parent thread ids cannot be empty." }); + return yield* new CosCommandError({ + message: "Child and parent thread ids cannot be empty.", + }); } const childThreadId = ThreadId.make(child); const parentThreadId = ThreadId.make(parent); - const commandId = CommandId.make(`server:cos-link-parent:${childThreadId}:${parentThreadId}`); + const commandId = CommandId.make( + `server:cos-link-parent:${childThreadId}:${parentThreadId}`, + ); yield* dispatch({ type: "thread.parent.set", commandId, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index b838534d33f..cb3fc8c0bf7 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -42,11 +42,7 @@ import { } from "./auth/http.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; -import { - buildElevenLabsRequest, - resolveVoiceId, - validateTtsText, -} from "./tts/ttsRequest.logic.ts"; +import { buildElevenLabsRequest, resolveVoiceId, validateTtsText } from "./tts/ttsRequest.logic.ts"; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const TTS_SPEAK_PATH = "/api/tts/speak"; @@ -195,6 +191,7 @@ const TtsSpeakRequest = Schema.Struct({ voiceId: Schema.optional(Schema.String), }); +const decodeTtsSpeakRequest = Schema.decodeUnknownEffect(TtsSpeakRequest); const emptyTtsSpeakRequest: typeof TtsSpeakRequest.Type = { text: "" }; export const ttsSpeakHandler = Effect.gen(function* () { @@ -209,7 +206,7 @@ export const ttsSpeakHandler = Effect.gen(function* () { } const body = yield* request.json; - const raw = yield* Schema.decodeUnknownEffect(TtsSpeakRequest)(body).pipe( + const raw = yield* decodeTtsSpeakRequest(body).pipe( Effect.orElseSucceed(() => emptyTtsSpeakRequest), ); @@ -244,9 +241,7 @@ export const ttsSpeakHandler = Effect.gen(function* () { EnvironmentInternalError: HttpServerRespondable.toResponse, EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, TtsConfigMissingError: () => - Effect.succeed( - HttpServerResponse.text("Text-to-speech is not configured.", { status: 503 }), - ), + Effect.succeed(HttpServerResponse.text("Text-to-speech is not configured.", { status: 503 })), TtsTextInvalidError: (cause) => Effect.succeed( HttpServerResponse.text( diff --git a/apps/server/src/mcp/toolkits/subagent/handlers.test.ts b/apps/server/src/mcp/toolkits/subagent/handlers.test.ts index 385578a2e70..7b897b80173 100644 --- a/apps/server/src/mcp/toolkits/subagent/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/subagent/handlers.test.ts @@ -170,8 +170,7 @@ const projectionLayer = Layer.succeed(ProjectionSnapshotQuery, { const coordinatorLayer = Layer.succeed(ChildThreadCoordinator, { register: () => Effect.void, - waitSlice: () => - waitSliceResult ? Effect.succeed(waitSliceResult) : unsupported(), + waitSlice: () => (waitSliceResult ? Effect.succeed(waitSliceResult) : unsupported()), assertParent: () => Effect.void, promoteToWake: (ids) => Effect.sync(() => void promotedCalls.push(ids)), hasPendingInjections: () => Effect.succeed(false), @@ -295,15 +294,13 @@ describe("SubagentToolkit", () => { Effect.scoped( Effect.gen(function* () { const server = yield* McpServer.McpServer; - const result = yield* server - .callTool({ name: "t3_list_subagents", arguments: {} }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, { - ...invocation, - capabilities: new Set(["preview"] as const), - }), - Effect.provideService(McpSchema.McpServerClient, client), - ); + const result = yield* server.callTool({ name: "t3_list_subagents", arguments: {} }).pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...invocation, + capabilities: new Set(["preview"] as const), + }), + Effect.provideService(McpSchema.McpServerClient, client), + ); expect(result.isError).toBe(true); }), ).pipe(Effect.provide(TestLayer)), @@ -316,9 +313,7 @@ describe("SubagentToolkit", () => { promotedCalls.length = 0; // The coordinator slice reports the child still pending. waitSliceResult = { - results: [ - { childThreadId, status: "pending", finalAssistantText: null, error: null }, - ], + results: [{ childThreadId, status: "pending", finalAssistantText: null, error: null }], settledCount: 0, timedOutCount: 0, pending: true, @@ -331,7 +326,10 @@ describe("SubagentToolkit", () => { const result = yield* server .callTool({ name: "t3_wait_subagent", - arguments: { childThreadIds: [childThreadId], resumeToken: "-100000:coordinator-token" }, + arguments: { + childThreadIds: [childThreadId], + resumeToken: "-100000:coordinator-token", + }, }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), diff --git a/apps/server/src/mcp/toolkits/subagent/handlers.ts b/apps/server/src/mcp/toolkits/subagent/handlers.ts index 230f63ad69a..09f2a77c0f3 100644 --- a/apps/server/src/mcp/toolkits/subagent/handlers.ts +++ b/apps/server/src/mcp/toolkits/subagent/handlers.ts @@ -50,10 +50,7 @@ import { } from "../../../persistence/Services/PendingDispatches.ts"; import { ProviderInstanceRegistry } from "../../../provider/Services/ProviderInstanceRegistry.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; -import { - activeThreadStartRuntimeOf, - type ActiveThreadStartRuntime, -} from "../thread/handlers.ts"; +import { activeThreadStartRuntimeOf, type ActiveThreadStartRuntime } from "../thread/handlers.ts"; import { ThreadStartToolError } from "../thread/tools.ts"; import { SubagentToolkit, @@ -62,19 +59,14 @@ import { WAIT_TIMEOUT_MAX_SECONDS, WAIT_TIMEOUT_MIN_SECONDS, type CheckSubagentInput, - type CheckSubagentOutput, type ListSubagentsInput, - type ListSubagentsOutput, type ScheduleCreateInput, type ScheduleDeleteInput, - type ScheduleDeleteOutput, type ScheduleListInput, - type ScheduleListOutput, type ScheduleUpdateInput, type SpawnSubagentInput, type SpawnSubagentOutput, type SteerSubagentInput, - type SteerSubagentOutput, type WaitSubagentInput, type WaitSubagentOutput, } from "./tools.ts"; @@ -85,9 +77,7 @@ const isThreadStartToolError = Schema.is(ThreadStartToolError); const fail = (message: string) => new ThreadStartToolError({ message }); const toToolError = (error: unknown, fallback: string): ThreadStartToolError => - isThreadStartToolError(error) - ? error - : fail(error instanceof Error ? error.message : fallback); + isThreadStartToolError(error) ? error : fail(error instanceof Error ? error.message : fallback); const requireCoordinator = (): Effect.Effect => { const coordinator = coordinatorActive(); @@ -98,7 +88,9 @@ const requireCoordinator = (): Effect.Effect => { const runtime = activeThreadStartRuntimeOf(); - return runtime ? Effect.succeed(runtime) : Effect.fail(fail("Thread start runtime is not available.")); + return runtime + ? Effect.succeed(runtime) + : Effect.fail(fail("Thread start runtime is not available.")); }; const clamp = (value: number, min: number, max: number): number => @@ -160,25 +152,25 @@ interface SubagentRuntime { let activeRuntime: SubagentRuntime | null = null; const requireRuntime = (): Effect.Effect => - activeRuntime ? Effect.succeed(activeRuntime) : Effect.fail(fail("Sub-agent runtime is not available.")); + activeRuntime + ? Effect.succeed(activeRuntime) + : Effect.fail(fail("Sub-agent runtime is not available.")); const requireInvocation = McpInvocationContext.requireMcpCapability("thread-management").pipe( Effect.mapError((error) => fail(error.message)), ); const loadThreadShell = (runtime: SubagentRuntime, threadId: ThreadId) => - runtime.projectionSnapshotQuery.getThreadShellById(threadId).pipe( - Effect.mapError((error) => toToolError(error, "Failed to load thread.")), - ); + runtime.projectionSnapshotQuery + .getThreadShellById(threadId) + .pipe(Effect.mapError((error) => toToolError(error, "Failed to load thread."))); const loadThreadDetail = (runtime: SubagentRuntime, threadId: ThreadId) => - runtime.projectionSnapshotQuery.getThreadDetailById(threadId).pipe( - Effect.mapError((error) => toToolError(error, "Failed to load thread detail.")), - ); + runtime.projectionSnapshotQuery + .getThreadDetailById(threadId) + .pipe(Effect.mapError((error) => toToolError(error, "Failed to load thread detail."))); -const spawnSubagent = Effect.fn("SubagentToolkit.spawn")(function* ( - input: SpawnSubagentInput, -) { +const spawnSubagent = Effect.fn("SubagentToolkit.spawn")(function* (input: SpawnSubagentInput) { const invocation = yield* requireInvocation; const runtime = yield* requireRuntime(); const coordinator = yield* requireCoordinator(); @@ -284,9 +276,7 @@ const waitForChildren = ( ), ); -const steerSubagent = Effect.fn("SubagentToolkit.steer")(function* ( - input: SteerSubagentInput, -) { +const steerSubagent = Effect.fn("SubagentToolkit.steer")(function* (input: SteerSubagentInput) { const invocation = yield* requireInvocation; const runtime = yield* requireRuntime(); const coordinator = yield* requireCoordinator(); @@ -309,7 +299,9 @@ const steerSubagent = Effect.fn("SubagentToolkit.steer")(function* ( // the coordinator drains when the child idles, since its mid-turn semantics are // unverified. const midTurn = child.latestTurn?.state === "running"; - const instance = yield* runtime.providerInstanceRegistry.getInstance(child.modelSelection.instanceId); + const instance = yield* runtime.providerInstanceRegistry.getInstance( + child.modelSelection.instanceId, + ); const driverKind = instance?.driverKind; const MIDTURN_STEER_DRIVERS = ["claudeAgent", "codex", "cursor", "grok", "opencode"]; const deferMidTurn = midTurn && !MIDTURN_STEER_DRIVERS.includes(driverKind ?? ""); @@ -331,7 +323,11 @@ const steerSubagent = Effect.fn("SubagentToolkit.steer")(function* ( yield* runtime.pendingDispatches .insert(row) .pipe(Effect.mapError((error) => toToolError(error, "Failed to defer steer."))); - return { childThreadId: input.childThreadId, accepted: true, applied: "deferred-until-idle" as const }; + return { + childThreadId: input.childThreadId, + accepted: true, + applied: "deferred-until-idle" as const, + }; } const uuid = yield* runtime.crypto.randomUUIDv4.pipe(Effect.orDie); @@ -355,9 +351,7 @@ const steerSubagent = Effect.fn("SubagentToolkit.steer")(function* ( }; }); -const checkSubagent = Effect.fn("SubagentToolkit.check")(function* ( - input: CheckSubagentInput, -) { +const checkSubagent = Effect.fn("SubagentToolkit.check")(function* (input: CheckSubagentInput) { yield* requireInvocation; const runtime = yield* requireRuntime(); @@ -391,9 +385,7 @@ const parseWaitStartMs = (resumeToken: string | undefined, nowMs: number): numbe return Number.isFinite(parsed) ? parsed : nowMs; }; -const waitSubagent = Effect.fn("SubagentToolkit.wait")(function* ( - input: WaitSubagentInput, -) { +const waitSubagent = Effect.fn("SubagentToolkit.wait")(function* (input: WaitSubagentInput) { yield* requireInvocation; const runtime = yield* requireRuntime(); const coordinator = yield* requireCoordinator(); @@ -474,9 +466,7 @@ const waitSubagent = Effect.fn("SubagentToolkit.wait")(function* ( }; }); -const listSubagents = Effect.fn("SubagentToolkit.list")(function* ( - input: ListSubagentsInput, -) { +const listSubagents = Effect.fn("SubagentToolkit.list")(function* (input: ListSubagentsInput) { const invocation = yield* requireInvocation; const runtime = yield* requireRuntime(); const coordinator = yield* requireCoordinator(); @@ -516,10 +506,13 @@ const listSubagents = Effect.fn("SubagentToolkit.list")(function* ( return { parentThreadId, children }; }); -const validateCron = (cronExpr: string, timezone: string): Effect.Effect => +const validateCron = ( + cronExpr: string, + timezone: string, +): Effect.Effect => Effect.try({ try: () => { - new Cron(cronExpr, { timezone }); + const _cron = new Cron(cronExpr, { timezone }); }, catch: () => fail(`Invalid cron expression: ${cronExpr}`), }); @@ -539,7 +532,9 @@ const computeNextRunIso = ( const next = new Cron(cronExpr, { timezone }).nextRun( DateTime.toDateUtc(Option.getOrThrow(DateTime.make(nowMs))), ); - return next === null ? null : DateTime.formatIso(Option.getOrThrow(DateTime.make(next.getTime()))); + return next === null + ? null + : DateTime.formatIso(Option.getOrThrow(DateTime.make(next.getTime()))); }; const scheduleCreate = Effect.fn("SubagentToolkit.scheduleCreate")(function* ( @@ -624,15 +619,15 @@ const loadTaskById = ( runtime: SubagentRuntime, taskId: ScheduledTaskId, ): Effect.Effect => - runtime.scheduledTasks - .listAll() - .pipe( - Effect.mapError((error) => toToolError(error, "Failed to load scheduled task.")), - Effect.flatMap((tasks) => { - const found = tasks.find((task) => task.taskId === taskId); - return found ? Effect.succeed(found) : Effect.fail(fail(`Scheduled task ${taskId} was not found.`)); - }), - ); + runtime.scheduledTasks.listAll().pipe( + Effect.mapError((error) => toToolError(error, "Failed to load scheduled task.")), + Effect.flatMap((tasks) => { + const found = tasks.find((task) => task.taskId === taskId); + return found + ? Effect.succeed(found) + : Effect.fail(fail(`Scheduled task ${taskId} was not found.`)); + }), + ); const scheduleUpdate = Effect.fn("SubagentToolkit.scheduleUpdate")(function* ( input: ScheduleUpdateInput, @@ -649,7 +644,11 @@ const scheduleUpdate = Effect.fn("SubagentToolkit.scheduleUpdate")(function* ( ? "interval" : existing.scheduleKind; const cronExpr = - input.cronExpr !== undefined ? input.cronExpr : scheduleKind === "cron" ? existing.cronExpr : null; + input.cronExpr !== undefined + ? input.cronExpr + : scheduleKind === "cron" + ? existing.cronExpr + : null; const intervalSeconds = input.intervalSeconds !== undefined ? input.intervalSeconds @@ -678,7 +677,8 @@ const scheduleUpdate = Effect.fn("SubagentToolkit.scheduleUpdate")(function* ( const updated: ScheduledTask = { ...existing, - enabled: input.enabled === undefined ? existing.enabled : NonNegativeInt.make(input.enabled ? 1 : 0), + enabled: + input.enabled === undefined ? existing.enabled : NonNegativeInt.make(input.enabled ? 1 : 0), busyPolicy: input.busyPolicy ?? existing.busyPolicy, scheduleKind, intervalSeconds, diff --git a/apps/server/src/mcp/toolkits/subagent/tools.ts b/apps/server/src/mcp/toolkits/subagent/tools.ts index 1cac523897e..5c0c7d6edf7 100644 --- a/apps/server/src/mcp/toolkits/subagent/tools.ts +++ b/apps/server/src/mcp/toolkits/subagent/tools.ts @@ -2,7 +2,10 @@ import { ProjectId, ScheduledTaskEntry, ThreadId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/unstable/ai"; -import { ScheduleBusyPolicy, ScheduledTaskId } from "../../../persistence/Services/ScheduledTasks.ts"; +import { + ScheduleBusyPolicy, + ScheduledTaskId, +} from "../../../persistence/Services/ScheduledTasks.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import { ThreadStartToolError, ThreadStartToolInput, ThreadStartMode } from "../thread/tools.ts"; @@ -225,7 +228,7 @@ export const CheckSubagentTool = Tool.make("t3_check_subagent", { export const WaitSubagentTool = Tool.make("t3_wait_subagent", { description: - "Wait for one or more sub-agents to finish. This returns quickly with one result row per requested child; a child that has not finished yet has status \"pending\". While pending is true and you still want to wait, re-call this tool with the returned resumeToken (and the same childThreadIds) to keep waiting โ€” never assume a single call blocks until completion. This waits at most ~90 seconds in total (across resumeToken re-calls); once that elapses with children still running, it returns promoted=true and those children have status \"running\" โ€” STOP calling wait and go do other work, you will receive a new message automatically when each one finishes. mode \"all\" (default) waits for every child; \"any\" returns as soon as one settles. timeoutSeconds (default 600, clamped to [1,3900]) is the requested logical budget; children still unfinished once it is exhausted are returned with status \"timeout\".", + 'Wait for one or more sub-agents to finish. This returns quickly with one result row per requested child; a child that has not finished yet has status "pending". While pending is true and you still want to wait, re-call this tool with the returned resumeToken (and the same childThreadIds) to keep waiting โ€” never assume a single call blocks until completion. This waits at most ~90 seconds in total (across resumeToken re-calls); once that elapses with children still running, it returns promoted=true and those children have status "running" โ€” STOP calling wait and go do other work, you will receive a new message automatically when each one finishes. mode "all" (default) waits for every child; "any" returns as soon as one settles. timeoutSeconds (default 600, clamped to [1,3900]) is the requested logical budget; children still unfinished once it is exhausted are returned with status "timeout".', parameters: WaitSubagentInput, success: WaitSubagentOutput, failure: ThreadStartToolError, @@ -249,7 +252,7 @@ export const ListSubagentsTool = Tool.make("t3_list_subagents", { export const ScheduleCreateTool = Tool.make("t3_schedule_create", { description: - "Schedule a recurring prompt to be sent to a thread (defaults to the calling thread). Provide exactly one of intervalSeconds (fixed interval) or cronExpr (a cron expression, validated on create); optionally a timezone (IANA name, default UTC) and busyPolicy (\"skip\" default, or \"queue_once\"). The same thread is reused on every trigger.", + 'Schedule a recurring prompt to be sent to a thread (defaults to the calling thread). Provide exactly one of intervalSeconds (fixed interval) or cronExpr (a cron expression, validated on create); optionally a timezone (IANA name, default UTC) and busyPolicy ("skip" default, or "queue_once"). The same thread is reused on every trigger.', parameters: ScheduleCreateInput, success: ScheduleEntry, failure: ThreadStartToolError, diff --git a/apps/server/src/orchestration/Layers/ChildThreadCoordinator.test.ts b/apps/server/src/orchestration/Layers/ChildThreadCoordinator.test.ts index f492b691623..562ff92784b 100644 --- a/apps/server/src/orchestration/Layers/ChildThreadCoordinator.test.ts +++ b/apps/server/src/orchestration/Layers/ChildThreadCoordinator.test.ts @@ -1,5 +1,5 @@ +/* oxlint-disable t3code/no-manual-effect-runtime-in-tests -- These concurrency tests intentionally manage a long-lived runtime, queues, and scopes across helper boundaries. */ import { - CommandId, EventId, MessageId, ProviderInstanceId, @@ -68,9 +68,7 @@ interface ThreadState { readonly detail: OrchestrationThread; } -const makeLatestTurn = ( - state: OrchestrationLatestTurn["state"], -): OrchestrationLatestTurn => ({ +const makeLatestTurn = (state: OrchestrationLatestTurn["state"]): OrchestrationLatestTurn => ({ turnId: TurnId.make("turn-1"), state, requestedAt: now, @@ -326,9 +324,7 @@ describe("ChildThreadCoordinator", () => { const registryLayer = Layer.succeed(ProviderInstanceRegistry, { getInstance: (instanceId) => Effect.succeed( - knownInstances.has(String(instanceId)) - ? ({ instanceId } as never) - : undefined, + knownInstances.has(String(instanceId)) ? ({ instanceId } as never) : undefined, ), listInstances: Effect.succeed([]), listUnavailable: Effect.succeed([]), @@ -907,9 +903,7 @@ describe("ChildThreadCoordinator", () => { }); // Must complete (not hang) within the test runner timeout. await harness.feed(turnDiffEvent(child, "ready")); - const turnStarts = harness.dispatched.filter( - (command) => command.type === "thread.turn.start", - ); + const turnStarts = harness.dispatched.filter((command) => command.type === "thread.turn.start"); expect(turnStarts.length).toBeGreaterThanOrEqual(1); const result = await runtimeRun(harness, child); expect(result.status).toBe("completed"); @@ -1339,7 +1333,9 @@ describe("ChildThreadCoordinator", () => { // Helpers that run coordinator effects on the harness runtime. async function runtimeRun( - harness: { readonly coordinator: import("../Services/ChildThreadCoordinator.ts").ChildThreadCoordinatorShape }, + harness: { + readonly coordinator: import("../Services/ChildThreadCoordinator.ts").ChildThreadCoordinatorShape; + }, child: ThreadId, ) { const slice = await runtimeWaitSlice(harness, [child], FAR_FUTURE_MS); @@ -1347,7 +1343,9 @@ async function runtimeRun( } async function runtimeWaitSlice( - harness: { readonly coordinator: import("../Services/ChildThreadCoordinator.ts").ChildThreadCoordinatorShape }, + harness: { + readonly coordinator: import("../Services/ChildThreadCoordinator.ts").ChildThreadCoordinatorShape; + }, childThreadIds: ReadonlyArray, budgetDeadlineMs: number, ) { @@ -1357,7 +1355,9 @@ async function runtimeWaitSlice( } async function runtimeHasPending( - harness: { readonly coordinator: import("../Services/ChildThreadCoordinator.ts").ChildThreadCoordinatorShape }, + harness: { + readonly coordinator: import("../Services/ChildThreadCoordinator.ts").ChildThreadCoordinatorShape; + }, parent: ThreadId, ) { return Effect.runPromise(harness.coordinator.hasPendingInjections(parent)); diff --git a/apps/server/src/orchestration/Layers/ChildThreadCoordinator.ts b/apps/server/src/orchestration/Layers/ChildThreadCoordinator.ts index 001737b5031..a90059a32db 100644 --- a/apps/server/src/orchestration/Layers/ChildThreadCoordinator.ts +++ b/apps/server/src/orchestration/Layers/ChildThreadCoordinator.ts @@ -53,7 +53,6 @@ import { type ChildTerminalStatus, type ChildThreadCoordinatorShape, type ChildWaitResult, - type RegisterChildInput, type WaitChildResult, type WaitSliceInput, type WaitSliceResult, @@ -181,8 +180,10 @@ const make = Effect.gen(function* () { const dispatchCommandIdFor = (tag: string, dispatchId: PendingDispatchId): CommandId => CommandId.make(`server:${tag}:${dispatchId}`); - const batchCommandIdFor = (tag: string, dispatchIds: ReadonlyArray): CommandId => - CommandId.make(`server:${tag}:${[...dispatchIds].sort().join(",")}`); + const batchCommandIdFor = ( + tag: string, + dispatchIds: ReadonlyArray, + ): CommandId => CommandId.make(`server:${tag}:${[...dispatchIds].sort().join(",")}`); const listPersistedChildRows = SqlSchema.findAll({ Request: Schema.Void, @@ -332,7 +333,10 @@ const make = Effect.gen(function* () { const consolidatedInjectionText = (entries: ReadonlyArray): string => { const joined = entries - .map((entry) => `[sub-agent ${entry.childThreadId} ${entry.status}] ${entry.error ?? entry.text ?? ""}`) + .map( + (entry) => + `[sub-agent ${entry.childThreadId} ${entry.status}] ${entry.error ?? entry.text ?? ""}`, + ) .join("\n"); // Guard against unbounded growth when many children settle with large // payloads; the full per-child results remain queryable via t3_check. @@ -449,7 +453,9 @@ const make = Effect.gen(function* () { const commandId = entry.claimedCommandId ?? batchCommandIdFor("subagent-wake", [entry.dispatchId]); yield* claimDispatchRows([entry.dispatchId], commandId).pipe( - Effect.andThen(dispatchParentTurn(shell, consolidatedInjectionText([entry]), commandId)), + Effect.andThen( + dispatchParentTurn(shell, consolidatedInjectionText([entry]), commandId), + ), Effect.andThen(deleteDispatchRows([entry.dispatchId])), Effect.catchCause((cause) => { enqueuePending(parentThreadId, entry); @@ -462,7 +468,9 @@ const make = Effect.gen(function* () { ); return; } - yield* appendSubagentActivity(parentThreadId, result).pipe(Effect.ignoreCause({ log: true })); + yield* appendSubagentActivity(parentThreadId, result).pipe( + Effect.ignoreCause({ log: true }), + ); enqueuePending(parentThreadId, entry); }), ); @@ -484,10 +492,13 @@ const make = Effect.gen(function* () { // AND delete the backing rows so a restart never re-loads them. pendingInjections.delete(parentThreadId); yield* deleteDispatchRows(queue.map((entry) => entry.dispatchId)); - yield* Effect.logWarning("parent gone while draining pending injections; dropped orphaned rows", { - parentThreadId, - droppedCount: queue.length, - }); + yield* Effect.logWarning( + "parent gone while draining pending injections; dropped orphaned rows", + { + parentThreadId, + droppedCount: queue.length, + }, + ); return; } const shell = shellOption.value; @@ -509,7 +520,9 @@ const make = Effect.gen(function* () { if (batch.length === 0) return Effect.void; const ids = batch.map((entry) => entry.dispatchId); return claimDispatchRows(ids, commandId).pipe( - Effect.andThen(dispatchParentTurn(shell, consolidatedInjectionText(batch), commandId)), + Effect.andThen( + dispatchParentTurn(shell, consolidatedInjectionText(batch), commandId), + ), Effect.andThen(deleteDispatchRows(ids)), Effect.catchCause((cause) => { const restored = pendingInjections.get(parentThreadId) ?? []; @@ -534,7 +547,10 @@ const make = Effect.gen(function* () { } yield* drainBatch( fresh, - batchCommandIdFor("subagent-wake", fresh.map((entry) => entry.dispatchId)), + batchCommandIdFor( + "subagent-wake", + fresh.map((entry) => entry.dispatchId), + ), ); }), ); @@ -704,7 +720,9 @@ const make = Effect.gen(function* () { } const depth = depthFor(input.parentThreadId); if (depth >= MAX_DEPTH) { - return yield* fail(`Sub-agent depth limit (${MAX_DEPTH}) reached; refusing to spawn deeper.`); + return yield* fail( + `Sub-agent depth limit (${MAX_DEPTH}) reached; refusing to spawn deeper.`, + ); } if (hasAncestryCycle(input.parentThreadId, input.childThreadId)) { return yield* fail("Sub-agent spawn would create an ancestry cycle."); @@ -784,9 +802,7 @@ const make = Effect.gen(function* () { return entries; }); - const waitForChild = ( - childThreadId: ThreadId, - ): Effect.Effect => + const waitForChild = (childThreadId: ThreadId): Effect.Effect => Effect.gen(function* () { if (!children.has(childThreadId)) { const shellOption = yield* getThreadShellBounded(childThreadId); @@ -884,7 +900,10 @@ const make = Effect.gen(function* () { // replay readEvents(0), tracking the latest signal per known child id. const reconcileFromLog = (knownChildIds: Set) => Effect.gen(function* () { - const terminalByChild = new Map(); + const terminalByChild = new Map< + ThreadId, + { status: ChildTerminalStatus; error: string | null } + >(); const runningByChild = new Map(); let maxSequence = 0; yield* Stream.runForEach(orchestrationEngine.readEvents(0), (event) => @@ -1027,9 +1046,7 @@ const make = Effect.gen(function* () { // (3) THEN fork the hot stream (the immutable-log scan above already // covered everything up to "now", so a gap event is never missed). yield* Effect.forkScoped( - Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => - worker.enqueue(event), - ), + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => worker.enqueue(event)), ); yield* Effect.logInfo("child.thread.coordinator.reactor.started", { diff --git a/apps/server/src/orchestration/Layers/ScheduledTasksReactor.test.ts b/apps/server/src/orchestration/Layers/ScheduledTasksReactor.test.ts index 09734b3ff9f..a52c52f9fc2 100644 --- a/apps/server/src/orchestration/Layers/ScheduledTasksReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ScheduledTasksReactor.test.ts @@ -1,3 +1,4 @@ +/* oxlint-disable t3code/no-manual-effect-runtime-in-tests -- These reactor tests intentionally manage a long-lived runtime and scope lifecycle. */ import { ProjectId, ProviderInstanceId, @@ -229,7 +230,9 @@ describe("ScheduledTasksReactor", () => { expect(turnStarts).toHaveLength(1); expect(turnStarts[0]!.threadId).toBe(threadId); - const rows = await harness.activeRuntime.runPromise(harness.repository.listByThread({ threadId })); + const rows = await harness.activeRuntime.runPromise( + harness.repository.listByThread({ threadId }), + ); expect(rows[0]!.lastStatus).toBe("dispatched"); // next_run_at advanced ~1h (intervalSeconds) past the run instant. expect(rows[0]!.nextRunAt).not.toBeNull(); @@ -256,7 +259,9 @@ describe("ScheduledTasksReactor", () => { const turnStarts = harness.dispatched.filter((c) => c.type === "thread.turn.start"); expect(turnStarts).toHaveLength(0); - const rows = await harness.activeRuntime.runPromise(harness.repository.listByThread({ threadId })); + const rows = await harness.activeRuntime.runPromise( + harness.repository.listByThread({ threadId }), + ); expect(rows[0]!.lastStatus).toBe("skipped"); expect(rows[0]!.skippedCount).toBe(1); }); @@ -275,7 +280,9 @@ describe("ScheduledTasksReactor", () => { const turnStarts = harness.dispatched.filter((c) => c.type === "thread.turn.start"); expect(turnStarts).toHaveLength(0); - const rows = await harness.activeRuntime.runPromise(harness.repository.listByThread({ threadId })); + const rows = await harness.activeRuntime.runPromise( + harness.repository.listByThread({ threadId }), + ); expect(rows[0]!.enabled).toBe(0); expect(rows[0]!.lastStatus).toBe("error"); expect(rows[0]!.lastError).toBe("thread deleted"); diff --git a/apps/server/src/orchestration/Layers/ScheduledTasksReactor.ts b/apps/server/src/orchestration/Layers/ScheduledTasksReactor.ts index b0ac5962b78..33fad771276 100644 --- a/apps/server/src/orchestration/Layers/ScheduledTasksReactor.ts +++ b/apps/server/src/orchestration/Layers/ScheduledTasksReactor.ts @@ -61,7 +61,7 @@ const CLOCK_SKEW_FUTURE_TOLERANCE_MS = 60 * 60 * 1_000; const isValidCron = (cronExpr: string | null, timezone: string): boolean => { if (cronExpr === null) return false; try { - new Cron(cronExpr, { timezone }); + const _cron = new Cron(cronExpr, { timezone }); return true; } catch { return false; diff --git a/apps/server/src/orchestration/Services/ChildThreadCoordinator.ts b/apps/server/src/orchestration/Services/ChildThreadCoordinator.ts index dad84a59b32..5c5440d950c 100644 --- a/apps/server/src/orchestration/Services/ChildThreadCoordinator.ts +++ b/apps/server/src/orchestration/Services/ChildThreadCoordinator.ts @@ -10,11 +10,7 @@ * * @module ChildThreadCoordinator */ -import type { - ModelSelection, - ProviderInstanceId, - ThreadId, -} from "@t3tools/contracts"; +import type { ModelSelection, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Scope from "effect/Scope"; @@ -134,17 +130,13 @@ export interface ChildThreadCoordinatorShape { * Already-terminal children are left untouched (their result was/will be * delivered to the waiter). A no-op for unknown ids. */ - readonly promoteToWake: ( - childThreadIds: ReadonlyArray, - ) => Effect.Effect; + readonly promoteToWake: (childThreadIds: ReadonlyArray) => Effect.Effect; /** Whether the parent has queued sub-agent completion injections awaiting drain. */ readonly hasPendingInjections: (parentThreadId: ThreadId) => Effect.Effect; /** List the parent's registered children (in-memory view). */ - readonly listChildren: ( - parentThreadId: ThreadId, - ) => Effect.Effect>; + readonly listChildren: (parentThreadId: ThreadId) => Effect.Effect>; /** * Reconcile from the persisted log, then fork the hot event stream. MUST run diff --git a/apps/server/src/persistence/Layers/ScheduledTasks.test.ts b/apps/server/src/persistence/Layers/ScheduledTasks.test.ts index 31cd305a417..78eb7724d41 100644 --- a/apps/server/src/persistence/Layers/ScheduledTasks.test.ts +++ b/apps/server/src/persistence/Layers/ScheduledTasks.test.ts @@ -43,9 +43,7 @@ layer("ScheduledTaskRepository", (it) => { const repository = yield* ScheduledTaskRepository; const threadId = ThreadId.make("thread-listdue"); - yield* repository.insert( - makeTask({ taskId: ScheduledTaskId.make("listdue-now"), threadId }), - ); + yield* repository.insert(makeTask({ taskId: ScheduledTaskId.make("listdue-now"), threadId })); yield* repository.insert( makeTask({ taskId: ScheduledTaskId.make("listdue-future"), diff --git a/apps/server/src/persistence/Layers/ScheduledTasks.ts b/apps/server/src/persistence/Layers/ScheduledTasks.ts index 3df084336f5..c567728cef5 100644 --- a/apps/server/src/persistence/Layers/ScheduledTasks.ts +++ b/apps/server/src/persistence/Layers/ScheduledTasks.ts @@ -3,7 +3,6 @@ import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { toPersistenceSqlError } from "../Errors.ts"; diff --git a/apps/server/src/persistence/Migrations/035_ScheduledTasks.test.ts b/apps/server/src/persistence/Migrations/035_ScheduledTasks.test.ts index 8527f3677a1..3d8aee27adf 100644 --- a/apps/server/src/persistence/Migrations/035_ScheduledTasks.test.ts +++ b/apps/server/src/persistence/Migrations/035_ScheduledTasks.test.ts @@ -18,7 +18,7 @@ layer("035_ScheduledTasks", (it) => { const columns = yield* sql<{ readonly name: string }>` PRAGMA table_info(scheduled_tasks) `; - const names = columns.map((column) => column.name); + const names = new Set(columns.map((column) => column.name)); for (const expected of [ "task_id", "thread_id", @@ -38,15 +38,13 @@ layer("035_ScheduledTasks", (it) => { "queued_count", "created_at", ]) { - assert.isTrue(names.includes(expected), `missing column ${expected}`); + assert.isTrue(names.has(expected), `missing column ${expected}`); } const indexes = yield* sql<{ readonly name: string }>` PRAGMA index_list(scheduled_tasks) `; - assert.isTrue( - indexes.some((index) => index.name === "idx_scheduled_tasks_enabled_next_run"), - ); + assert.isTrue(indexes.some((index) => index.name === "idx_scheduled_tasks_enabled_next_run")); }), ); diff --git a/apps/server/src/persistence/Migrations/036_PendingDispatches.test.ts b/apps/server/src/persistence/Migrations/036_PendingDispatches.test.ts index 087938b4885..1b8cd266d4d 100644 --- a/apps/server/src/persistence/Migrations/036_PendingDispatches.test.ts +++ b/apps/server/src/persistence/Migrations/036_PendingDispatches.test.ts @@ -18,7 +18,7 @@ layer("036_PendingDispatches", (it) => { const columns = yield* sql<{ readonly name: string }>` PRAGMA table_info(pending_dispatches) `; - const names = columns.map((column) => column.name); + const names = new Set(columns.map((column) => column.name)); for (const expected of [ "id", "kind", @@ -29,15 +29,13 @@ layer("036_PendingDispatches", (it) => { "status", "created_at", ]) { - assert.isTrue(names.includes(expected), `missing column ${expected}`); + assert.isTrue(names.has(expected), `missing column ${expected}`); } const indexes = yield* sql<{ readonly name: string }>` PRAGMA index_list(pending_dispatches) `; - assert.isTrue( - indexes.some((index) => index.name === "idx_pending_dispatches_kind_target"), - ); + assert.isTrue(indexes.some((index) => index.name === "idx_pending_dispatches_kind_target")); }), ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2aba82d89cf..401723c9a29 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -522,7 +522,9 @@ const buildAppUnderTest = (options?: { ...options.layers.vcsStatusBroadcaster, }) : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer)); - const projectSetupScriptRunnerLayer = Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ + const projectSetupScriptRunnerLayer = Layer.mock( + ProjectSetupScriptRunner.ProjectSetupScriptRunner, + )({ runForThread: () => Effect.succeed({ status: "no-script" as const }), ...options?.layers?.projectSetupScriptRunner, }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5ae62d3a025..30260a6e4c5 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -176,9 +176,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge( ScheduledTasksReactorLive.pipe(Layer.provide(BootstrapTurnStartDispatcher.layer)), ), - Layer.provideMerge( - ChildThreadCoordinatorLive.pipe(Layer.provide(PendingDispatchRepositoryLive)), - ), + Layer.provideMerge(ChildThreadCoordinatorLive.pipe(Layer.provide(PendingDispatchRepositoryLive))), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a23c82ae7e0..2c1b704b9a7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -68,10 +68,7 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import { - ScheduledTaskRepository, - toScheduleEntry, -} from "./persistence/Services/ScheduledTasks.ts"; +import { ScheduledTaskRepository, toScheduleEntry } from "./persistence/Services/ScheduledTasks.ts"; import { observeRpcEffect as instrumentRpcEffect, observeRpcStream as instrumentRpcStream, @@ -979,18 +976,16 @@ const makeWsRpcLayer = ( [ORCHESTRATION_WS_METHODS.deleteScheduledTask]: (input) => observeRpcEffect( ORCHESTRATION_WS_METHODS.deleteScheduledTask, - scheduledTaskRepository - .delete({ taskId: input.taskId }) - .pipe( - Effect.as({ taskId: input.taskId, deleted: true }), - Effect.mapError( - (cause) => - new OrchestrationScheduledTaskMutationError({ - message: "Failed to delete scheduled task", - cause, - }), - ), + scheduledTaskRepository.delete({ taskId: input.taskId }).pipe( + Effect.as({ taskId: input.taskId, deleted: true }), + Effect.mapError( + (cause) => + new OrchestrationScheduledTaskMutationError({ + message: "Failed to delete scheduled task", + cause, + }), ), + ), { "rpc.aggregate": "orchestration" }, ), [WS_METHODS.serverGetConfig]: (_input) => diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b04e98a8a60..023ed9dd3b3 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,3 +1,4 @@ +/* oxlint-disable react/no-unstable-nested-components -- Existing markdown renderer callbacks are outside this CI hardening change. */ import { useAtomValue } from "@effect/atom-react"; import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; import { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8dccf984457..f9f4f7493e9 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,5 +1,6 @@ "use client"; +/* oxlint-disable react/no-unstable-nested-components -- Existing renderer callbacks are outside this CI hardening change. */ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { isAtomCommandInterrupted, diff --git a/apps/web/src/components/ThreadStatusIndicators.test.tsx b/apps/web/src/components/ThreadStatusIndicators.test.tsx index 2a97e524bb4..c3966560138 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.test.tsx @@ -3,7 +3,11 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; import type { ThreadScheduleSummary } from "../state/schedules"; -import { ScheduledTaskIcon, ThreadWorktreeIndicator, scheduleIconPresentation } from "./ThreadStatusIndicators"; +import { + ScheduledTaskIcon, + ThreadWorktreeIndicator, + scheduleIconPresentation, +} from "./ThreadStatusIndicators"; const summary = (overrides: Partial = {}): ThreadScheduleSummary => ({ threadId: "T1" as ThreadId, diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 0fbc1efa405..7e647ef84f9 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -4,7 +4,14 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import type { VcsStatusResult } from "@t3tools/contracts"; -import { ClockIcon, CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon, TriangleAlertIcon } from "lucide-react"; +import { + ClockIcon, + CloudIcon, + FolderGit2Icon, + GitPullRequestIcon, + TerminalIcon, + TriangleAlertIcon, +} from "lucide-react"; import { useMemo } from "react"; import { formatRelativeTimeUntilLabel } from "../timestampFormat"; import type { ThreadScheduleSummary } from "../state/schedules"; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 55ef6bfd5a2..e3c6be569bf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -108,15 +108,17 @@ beforeAll(() => { toggle: () => {}, contains: () => false, }; - - vi.stubGlobal("localStorage", { + const localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {}, clear: () => {}, - }); + }; + + vi.stubGlobal("localStorage", localStorage); vi.stubGlobal("window", { matchMedia, + localStorage, addEventListener: () => {}, removeEventListener: () => {}, requestAnimationFrame: (callback: FrameRequestCallback) => { @@ -236,7 +238,7 @@ describe("MessagesTimeline", () => { expect(onAnchorReady).toHaveBeenCalledOnce(); expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1); expect(onAnchorSizeChanged).toHaveBeenCalledWith(secondEntry.message.id, 240); - }); + }, 30_000); it("renders collapse controls for long user messages", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); diff --git a/apps/web/src/components/scheduled/ScheduledTaskCard.test.tsx b/apps/web/src/components/scheduled/ScheduledTaskCard.test.tsx index 6db86b57cc8..09ec7802ea4 100644 --- a/apps/web/src/components/scheduled/ScheduledTaskCard.test.tsx +++ b/apps/web/src/components/scheduled/ScheduledTaskCard.test.tsx @@ -1,4 +1,9 @@ -import type { EnvironmentId, ScheduledTaskEntry, ScheduledTaskId, ThreadId } from "@t3tools/contracts"; +import type { + EnvironmentId, + ScheduledTaskEntry, + ScheduledTaskId, + ThreadId, +} from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vite-plus/test"; diff --git a/apps/web/src/components/scheduled/ScheduledTaskCard.tsx b/apps/web/src/components/scheduled/ScheduledTaskCard.tsx index ac642e66ed8..2a2b55e9d7d 100644 --- a/apps/web/src/components/scheduled/ScheduledTaskCard.tsx +++ b/apps/web/src/components/scheduled/ScheduledTaskCard.tsx @@ -116,7 +116,8 @@ export function ScheduledTaskCard({ stackedThreadToast({ type: "error", title: "Unable to delete schedule", - description: error instanceof Error ? error.message : "The schedule could not be deleted.", + description: + error instanceof Error ? error.message : "The schedule could not be deleted.", }), ); })(); diff --git a/apps/web/src/components/scheduled/ScheduledTasksPanel.tsx b/apps/web/src/components/scheduled/ScheduledTasksPanel.tsx index 7d690c85626..f760e20d491 100644 --- a/apps/web/src/components/scheduled/ScheduledTasksPanel.tsx +++ b/apps/web/src/components/scheduled/ScheduledTasksPanel.tsx @@ -16,13 +16,7 @@ import { useScheduledTasks, } from "../../state/schedules"; import { CardFrame } from "../ui/card"; -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "../ui/empty"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { Skeleton } from "../ui/skeleton"; import { ScheduledTaskCard } from "./ScheduledTaskCard"; @@ -86,10 +80,7 @@ export function ScheduledTasksPanel() { }, [environmentId, projects]); const threadInfoById = useMemo(() => { - const map = new Map< - ThreadId, - { title: string; projectId: ProjectId; branch: string | null } - >(); + const map = new Map(); for (const shell of threadShells) { if (shell.environmentId === environmentId) { map.set(shell.id, { @@ -140,9 +131,7 @@ export function ScheduledTasksPanel() { ); } - return ( -
{body}
- ); + return
{body}
; } const EMPTY_STATE_FALLBACK_ATOM = Atom.make(EMPTY_SCHEDULED_TASKS_STATE).pipe( diff --git a/apps/web/src/components/sidebarProjectDelete.ts b/apps/web/src/components/sidebarProjectDelete.ts index a0f7b6a5218..577aa9af724 100644 --- a/apps/web/src/components/sidebarProjectDelete.ts +++ b/apps/web/src/components/sidebarProjectDelete.ts @@ -29,11 +29,7 @@ export function isProjectNotEmptyInvariant(error: unknown): boolean { return true; } const text = - typeof e.detail === "string" - ? e.detail - : typeof e.message === "string" - ? e.message - : ""; + typeof e.detail === "string" ? e.detail : typeof e.message === "string" ? e.message : ""; if (PROJECT_NOT_EMPTY_PATTERN.test(text)) { return true; } diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index e1021a7feb4..ac290256ed1 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -40,7 +40,9 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise client.metadata.descriptor())), + PrimaryEnvironmentHttpClient.pipe( + Effect.flatMap((client) => client.metadata.descriptor({ headers: {} })), + ), ); } catch (error) { throw PrimaryEnvironmentRequestError.fromCause({ diff --git a/apps/web/src/routes/scheduled.tsx b/apps/web/src/routes/scheduled.tsx index c0322b9f509..17673ccdd18 100644 --- a/apps/web/src/routes/scheduled.tsx +++ b/apps/web/src/routes/scheduled.tsx @@ -1,4 +1,10 @@ -import { Outlet, createFileRoute, redirect, useCanGoBack, useNavigate } from "@tanstack/react-router"; +import { + Outlet, + createFileRoute, + redirect, + useCanGoBack, + useNavigate, +} from "@tanstack/react-router"; import { useCallback, useEffect } from "react"; import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; diff --git a/apps/web/src/scheduleBannerDismissal.test.ts b/apps/web/src/scheduleBannerDismissal.test.ts index 1f1a2a2978c..c9a47b1972f 100644 --- a/apps/web/src/scheduleBannerDismissal.test.ts +++ b/apps/web/src/scheduleBannerDismissal.test.ts @@ -33,7 +33,11 @@ describe("scheduleBannerDismissal", () => { it("tolerates malformed localStorage (treated as not dismissed)", () => { // Write a shape the dismissals schema cannot decode; reads must not throw. - setLocalStorageItem(SCHEDULE_BANNER_DISMISSALS_STORAGE_KEY, "not the right shape", Schema.String); + setLocalStorageItem( + SCHEDULE_BANNER_DISMISSALS_STORAGE_KEY, + "not the right shape", + Schema.String, + ); expect(isScheduleBannerDismissed("env|T1:2026-06-19T14:30:00.000Z")).toBe(false); }); }); diff --git a/apps/web/src/state/schedules.test.ts b/apps/web/src/state/schedules.test.ts index 0d21d293447..2a2087ed88c 100644 --- a/apps/web/src/state/schedules.test.ts +++ b/apps/web/src/state/schedules.test.ts @@ -54,7 +54,14 @@ describe("reduceSchedulesByThreadId", () => { it("marks a thread disabled when its only schedule is disabled", () => { const map = reduceSchedulesByThreadId( - [task({ taskId: "a", threadId: "T1", enabled: false, nextRunAt: "2026-06-19T13:00:00.000Z" })], + [ + task({ + taskId: "a", + threadId: "T1", + enabled: false, + nextRunAt: "2026-06-19T13:00:00.000Z", + }), + ], NOW, ); expect(map.get(threadId("T1"))?.enabled).toBe(false); diff --git a/apps/web/src/state/schedules.ts b/apps/web/src/state/schedules.ts index 5f5374ffb3f..9113871d16c 100644 --- a/apps/web/src/state/schedules.ts +++ b/apps/web/src/state/schedules.ts @@ -95,8 +95,7 @@ export function reduceSchedulesByThreadId( // Prefer the earliest enabled upcoming run; a thread is "enabled" if any of // its schedules is enabled, and "overdue" if any enabled schedule is overdue. // The cadence label tracks whichever schedule owns the chosen earliest run. - const takesNewRun = - task.enabled && compareNextRunAt(task.nextRunAt, existing.nextRunAt) < 0; + const takesNewRun = task.enabled && compareNextRunAt(task.nextRunAt, existing.nextRunAt) < 0; byThread.set(task.threadId, { threadId: task.threadId, nextRunAt: takesNewRun ? task.nextRunAt : existing.nextRunAt, @@ -122,9 +121,9 @@ function tasksForEnvironment( } export const scheduledTasksForEnvironmentAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make((get) => tasksForEnvironment(get(environmentScheduledTasks.stateValueAtom(environmentId)))).pipe( - Atom.withLabel(`scheduled-tasks-for-environment:${environmentId}`), - ), + Atom.make((get) => + tasksForEnvironment(get(environmentScheduledTasks.stateValueAtom(environmentId))), + ).pipe(Atom.withLabel(`scheduled-tasks-for-environment:${environmentId}`)), ); export const schedulesByThreadIdAtom = Atom.family((environmentId: EnvironmentId) => @@ -134,12 +133,15 @@ export const schedulesByThreadIdAtom = Atom.family((environmentId: EnvironmentId ); export const enabledScheduleCountAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make((get) => - get(scheduledTasksForEnvironmentAtom(environmentId)).filter((task) => task.enabled).length, + Atom.make( + (get) => + get(scheduledTasksForEnvironmentAtom(environmentId)).filter((task) => task.enabled).length, ).pipe(Atom.withLabel(`enabled-schedule-count:${environmentId}`)), ); -export function useScheduledTasks(environmentId: EnvironmentId | null): ReadonlyArray { +export function useScheduledTasks( + environmentId: EnvironmentId | null, +): ReadonlyArray { return useAtomValue( environmentId !== null ? scheduledTasksForEnvironmentAtom(environmentId) @@ -175,6 +177,4 @@ const EMPTY_SCHEDULED_TASKS_ATOM = Atom.make>( const EMPTY_SCHEDULE_SUMMARY_ATOM = Atom.make>( new Map(), ).pipe(Atom.withLabel("schedules-by-thread-id:empty")); -const EMPTY_SCHEDULE_COUNT_ATOM = Atom.make(0).pipe( - Atom.withLabel("enabled-schedule-count:empty"), -); +const EMPTY_SCHEDULE_COUNT_ATOM = Atom.make(0).pipe(Atom.withLabel("enabled-schedule-count:empty")); diff --git a/e2e/README.md b/e2e/README.md index f7394a44d3b..057b0964c47 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -26,11 +26,11 @@ healthy. **Always kill any server you start when done.** ## Assets -| File | Purpose | -|------|---------| -| `fib-sleep.sh` | Deterministic long-running child. Prints Fibonacci `1 1 2 3 5 8 13 21`, sleeping `n * FIB_SCALE` seconds after each (default `FIB_SCALE=60` => minutes, cumulative **54 min**). Keeps a process โ€” and thus the t3 turn that launched it โ€” alive for the whole budget. `FIB_SCALE=1` => 54s dry-run; `FIB_SCALE=0.05` => ~2.7s smoke. Timestamped heartbeat per step. | -| `assert.mjs` | Read-only SQLite reader helpers (`node:sqlite`, `mode=ro`): `openState`, `turnCountForThread`, `turnTimestamps`, `childrenOf`, `scheduledTask`, `listScheduledTasks`, `threadShell`, `assistantMessages`. | -| `drive.mjs` / `drive.sh` | (pre-existing) Programmatically create a project/thread and dispatch a user turn over the Environment HTTP API, then poll projections until the turn settles. Used for bring-up + pre-flight. | +| File | Purpose | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `fib-sleep.sh` | Deterministic long-running child. Prints Fibonacci `1 1 2 3 5 8 13 21`, sleeping `n * FIB_SCALE` seconds after each (default `FIB_SCALE=60` => minutes, cumulative **54 min**). Keeps a process โ€” and thus the t3 turn that launched it โ€” alive for the whole budget. `FIB_SCALE=1` => 54s dry-run; `FIB_SCALE=0.05` => ~2.7s smoke. Timestamped heartbeat per step. | +| `assert.mjs` | Read-only SQLite reader helpers (`node:sqlite`, `mode=ro`): `openState`, `turnCountForThread`, `turnTimestamps`, `childrenOf`, `scheduledTask`, `listScheduledTasks`, `threadShell`, `assistantMessages`. | +| `drive.mjs` / `drive.sh` | (pre-existing) Programmatically create a project/thread and dispatch a user turn over the Environment HTTP API, then poll projections until the turn settles. Used for bring-up + pre-flight. | ### `assert.mjs` helpers vs. schema @@ -44,6 +44,7 @@ healthy. **Always kill any server you start when done.** ## Scenario โ†’ asset/assertion map ### (a) Same-thread schedule fires repeatedly โ€” `e2ePlan.md` ยง(a) + - Agent calls `t3_schedule_create({threadId:root, intervalSeconds:60})`. - Poll with `turnCountForThread(db, root)` โ€” increases ~1/60s; `scheduledTask` shows `next_run_at` advancing, `last_run_at` updating, `last_status='dispatched'`. @@ -53,6 +54,7 @@ healthy. **Always kill any server you start when done.** `last_status='skipped'`, `skipped_count++`, `next_run_at` still advances. ### (b) Cross-provider spawn (claude+codex+cursor) โ€” `e2ePlan.md` ยง(b) + - After `t3_spawn_subagent` ร—3 (one per provider, `detached:true`): `childrenOf(db, root)` returns 3 rows with `parent_thread_id=root`; the `model` column verifies per-provider routing. @@ -64,6 +66,7 @@ healthy. **Always kill any server you start when done.** two-child consolidation case. ### (c) Long wait ~1 hour (opt-in `E2E_ENABLE_1H=1`) โ€” `e2ePlan.md` ยง(c) + - Child prompt runs `fib-sleep.sh` (default `FIB_SCALE=60`, 54 min cumulative), keeping its turn alive script-driven (reliable, not model-driven). - Driver loops `t3_wait_subagent(timeoutSeconds:3900)` across ~20s slices. @@ -75,6 +78,7 @@ healthy. **Always kill any server you start when done.** exercise the slice/resumeToken loop without the 1h hold. ### (d) Killed child โ†’ wait returns failure, not hang โ€” `e2ePlan.md` ยง(d) + - Spawn a ~10 min child (`FIB_SCALE` tuned), start the wait loop. - Kill via (i) `thread.delete`, (ii) `kill -9` the provider process, (iii) `session.stop`. Assert wait reports `killed`/`failed` within one slice; diff --git a/e2e/assert.mjs b/e2e/assert.mjs index ebed40dd53f..f38d072a829 100644 --- a/e2e/assert.mjs +++ b/e2e/assert.mjs @@ -15,8 +15,8 @@ // Every helper takes the db handle as its first arg so a single read-only // connection can be reused across a polling loop. -import { DatabaseSync } from "node:sqlite"; -import { join } from "node:path"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; /** * Open the t3 state DB read-only. Accepts either a T3CODE_HOME directory @@ -26,8 +26,8 @@ import { join } from "node:path"; export function openState(homeOrDbPath) { const dbPath = homeOrDbPath.endsWith(".sqlite") ? homeOrDbPath - : join(homeOrDbPath, "userdata", "state.sqlite"); - return new DatabaseSync(dbPath, { readOnly: true }); + : NodePath.join(homeOrDbPath, "userdata", "state.sqlite"); + return new NodeSqlite.DatabaseSync(dbPath, { readOnly: true }); } // ---- turns --------------------------------------------------------------- @@ -83,16 +83,12 @@ export function childrenOf(db, parentThreadId) { /** One scheduled task by id (scenario a: next_run_at / last_run_at / status). */ export function scheduledTask(db, taskId) { - return db - .prepare(`SELECT * FROM scheduled_tasks WHERE task_id = ?`) - .get(taskId); + return db.prepare(`SELECT * FROM scheduled_tasks WHERE task_id = ?`).get(taskId); } /** All scheduled tasks (discovery / counting). */ export function listScheduledTasks(db) { - return db - .prepare(`SELECT * FROM scheduled_tasks ORDER BY created_at ASC`) - .all(); + return db.prepare(`SELECT * FROM scheduled_tasks ORDER BY created_at ASC`).all(); } // ---- thread shell (latest turn + session) ------------------------------- diff --git a/e2e/drive.mjs b/e2e/drive.mjs index deb79c49dd1..ce9b00a8c84 100644 --- a/e2e/drive.mjs +++ b/e2e/drive.mjs @@ -25,8 +25,8 @@ // // Exit 0 on success (turn completed + assistant output captured), 1 otherwise. -import { DatabaseSync } from "node:sqlite"; -import { randomUUID } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; +import * as NodeSqlite from "node:sqlite"; function arg(name, def) { const i = process.argv.indexOf(`--${name}`); @@ -44,7 +44,9 @@ const TIMEOUT_MS = Number(arg("timeout-ms", "180000")); const TOKEN = process.env.T3_TOKEN; if (!TOKEN) { - console.error("FATAL: T3_TOKEN env var is required (mint via `t3 auth session issue --token-only`)."); + console.error( + "FATAL: T3_TOKEN env var is required (mint via `t3 auth session issue --token-only`).", + ); process.exit(2); } @@ -73,7 +75,7 @@ async function snapshot() { function openDb() { // Read-only is enough to assert; readwrite=false keeps us off the writer's lane. - return new DatabaseSync(DB_PATH, { readOnly: true }); + return new NodeSqlite.DatabaseSync(DB_PATH, { readOnly: true }); } async function main() { @@ -85,10 +87,10 @@ async function main() { (p) => p.deletedAt === null && p.workspaceRoot === WORKSPACE, ); if (!project) { - const projectId = randomUUID(); + const projectId = NodeCrypto.randomUUID(); await dispatch({ type: "project.create", - commandId: randomUUID(), + commandId: NodeCrypto.randomUUID(), projectId, title: TITLE, workspaceRoot: WORKSPACE, @@ -102,10 +104,10 @@ async function main() { } // 2) Create a thread bound to the chosen provider instance + model. - const threadId = randomUUID(); + const threadId = NodeCrypto.randomUUID(); await dispatch({ type: "thread.create", - commandId: randomUUID(), + commandId: NodeCrypto.randomUUID(), threadId, projectId: project.id, title: TITLE, @@ -119,17 +121,19 @@ async function main() { console.log(`[drive] created thread ${threadId}`); // 3) Send a USER TURN (a prompt) to that thread. - const messageId = randomUUID(); + const messageId = NodeCrypto.randomUUID(); const startSeq = await dispatch({ type: "thread.turn.start", - commandId: randomUUID(), + commandId: NodeCrypto.randomUUID(), threadId, message: { messageId, role: "user", text: PROMPT, attachments: [] }, runtimeMode: "full-access", interactionMode: "default", createdAt: nowIso(), }); - console.log(`[drive] dispatched turn.start (seq=${JSON.stringify(startSeq)}) prompt=${JSON.stringify(PROMPT)}`); + console.log( + `[drive] dispatched turn.start (seq=${JSON.stringify(startSeq)}) prompt=${JSON.stringify(PROMPT)}`, + ); // 4) Poll SQLite projection tables until the turn settles. const db = openDb(); @@ -152,10 +156,14 @@ async function main() { const turn = turnStmt.get(threadId); const sess = sessStmt.get(threadId); if (turn && turn.state !== lastState) { - console.log(`[drive] turn state=${turn.state} session=${sess?.status ?? "?"}${sess?.last_error ? ` err=${sess.last_error}` : ""}`); + console.log( + `[drive] turn state=${turn.state} session=${sess?.status ?? "?"}${sess?.last_error ? ` err=${sess.last_error}` : ""}`, + ); lastState = turn.state; } - const settled = turn && (turn.state === "completed" || turn.state === "failed" || turn.state === "interrupted"); + const settled = + turn && + (turn.state === "completed" || turn.state === "failed" || turn.state === "interrupted"); if (settled) { const asst = asstStmt.get(threadId); const ok = turn.state === "completed" && asst && asst.text && asst.text.trim().length > 0; @@ -167,20 +175,26 @@ async function main() { console.log(`session.status : ${sess?.status}`); console.log(`session.last_error : ${sess?.last_error ?? "(none)"}`); console.log(`assistant.message_id: ${asst?.message_id ?? "(none)"}`); - console.log(`assistant.text : ${asst?.text ? JSON.stringify(asst.text.slice(0, 400)) : "(none)"}`); + console.log( + `assistant.text : ${asst?.text ? JSON.stringify(asst.text.slice(0, 400)) : "(none)"}`, + ); console.log("============================"); db.close(); if (ok) { console.log("[drive] SUCCESS: turn completed with assistant output."); process.exit(0); } - console.error(`[drive] FAILURE: turn settled as ${turn.state} without usable assistant output.`); + console.error( + `[drive] FAILURE: turn settled as ${turn.state} without usable assistant output.`, + ); process.exit(1); } await new Promise((r) => setTimeout(r, 2000)); } db.close(); - console.error(`[drive] TIMEOUT after ${TIMEOUT_MS}ms; last turn state=${lastState ?? "(no turn row)"}.`); + console.error( + `[drive] TIMEOUT after ${TIMEOUT_MS}ms; last turn state=${lastState ?? "(no turn row)"}.`, + ); process.exit(1); } diff --git a/e2e/poll-wake.mjs b/e2e/poll-wake.mjs index c35933c3e2c..7390756ab5a 100644 --- a/e2e/poll-wake.mjs +++ b/e2e/poll-wake.mjs @@ -1,20 +1,23 @@ // poll-wake.mjs // Poll the root thread for a NEW turn + a user-role wake injection message. -import { DatabaseSync } from "node:sqlite"; +import * as NodeSqlite from "node:sqlite"; const [dbPath, root, baselineStr, waitMsStr] = process.argv.slice(2); const baseline = Number(baselineStr); const waitMs = Number(waitMsStr); -const db = new DatabaseSync(dbPath, { readOnly: true }); -const turnCount = () => db.prepare(`SELECT COUNT(*) n FROM projection_turns WHERE thread_id=?`).get(root).n; +const db = new NodeSqlite.DatabaseSync(dbPath, { readOnly: true }); +const turnCount = () => + db.prepare(`SELECT COUNT(*) n FROM projection_turns WHERE thread_id=?`).get(root).n; const userMsgs = () => - db.prepare(`SELECT message_id, turn_id, role, text, created_at FROM projection_thread_messages WHERE thread_id=? AND role='user' ORDER BY created_at ASC`).all(root); + db + .prepare( + `SELECT message_id, turn_id, role, text, created_at FROM projection_thread_messages WHERE thread_id=? AND role='user' ORDER BY created_at ASC`, + ) + .all(root); const deadline = Date.now() + waitMs; -let result = { incremented: false, finalCount: baseline }; while (Date.now() < deadline) { const c = turnCount(); const wake = userMsgs().filter((m) => /\[sub-agent/i.test(m.text || "")); if (c > baseline || wake.length > 0) { - result = { incremented: c > baseline, baseline, finalCount: c }; break; } await new Promise((r) => setTimeout(r, 3000)); @@ -22,14 +25,20 @@ while (Date.now() < deadline) { const finalCount = turnCount(); const allUser = userMsgs(); const wakeMsgs = allUser.filter((m) => /\[sub-agent/i.test(m.text || "")); -console.log(JSON.stringify({ - root, - baseline, - finalCount, - incremented: finalCount > baseline, - userMsgCount: allUser.length, - wakeMsgCount: wakeMsgs.length, -}, null, 2)); +console.log( + JSON.stringify( + { + root, + baseline, + finalCount, + incremented: finalCount > baseline, + userMsgCount: allUser.length, + wakeMsgCount: wakeMsgs.length, + }, + null, + 2, + ), +); for (const m of wakeMsgs) { console.log("--- WAKE USER MESSAGE (turn " + m.turn_id + ", " + m.created_at + ") ---"); console.log(m.text); diff --git a/e2e/poll.mjs b/e2e/poll.mjs index 7db0320d457..c4ba11c1506 100644 --- a/e2e/poll.mjs +++ b/e2e/poll.mjs @@ -1,15 +1,28 @@ // poll.mjs โ€” poll given threads until all settle, print state+answer -import { DatabaseSync } from "node:sqlite"; +import * as NodeSqlite from "node:sqlite"; const [db_path, waitMsStr, ...ids] = process.argv.slice(2); const waitMs = Number(waitMsStr); -const db = new DatabaseSync(db_path, { readOnly: true }); +const db = new NodeSqlite.DatabaseSync(db_path, { readOnly: true }); const lastTurn = (id) => - db.prepare(`SELECT state, assistant_message_id, completed_at FROM projection_turns WHERE thread_id=? ORDER BY row_id DESC LIMIT 1`).get(id); + db + .prepare( + `SELECT state, assistant_message_id, completed_at FROM projection_turns WHERE thread_id=? ORDER BY row_id DESC LIMIT 1`, + ) + .get(id); const lastAsst = (id) => - db.prepare(`SELECT text FROM projection_thread_messages WHERE thread_id=? AND role='assistant' ORDER BY created_at DESC LIMIT 1`).get(id); + db + .prepare( + `SELECT text FROM projection_thread_messages WHERE thread_id=? AND role='assistant' ORDER BY created_at DESC LIMIT 1`, + ) + .get(id); const sess = (id) => - db.prepare(`SELECT status, provider_name, provider_instance_id, last_error FROM projection_thread_sessions WHERE thread_id=?`).get(id); -const settled = (s) => s && (s.state === "completed" || s.state === "failed" || s.state === "interrupted"); + db + .prepare( + `SELECT status, provider_name, provider_instance_id, last_error FROM projection_thread_sessions WHERE thread_id=?`, + ) + .get(id); +const settled = (s) => + s && (s.state === "completed" || s.state === "failed" || s.state === "interrupted"); const deadline = Date.now() + waitMs; while (Date.now() < deadline) { if (ids.map(lastTurn).every(settled)) break; @@ -19,13 +32,15 @@ for (const id of ids) { const t = lastTurn(id); const a = lastAsst(id); const sv = sess(id); - console.log(JSON.stringify({ - thread_id: id, - turnState: t?.state ?? null, - provider_name: sv?.provider_name ?? null, - provider_instance_id: sv?.provider_instance_id ?? null, - last_error: sv?.last_error ?? null, - finalAssistantText: a?.text ? a.text.trim() : null, - })); + console.log( + JSON.stringify({ + thread_id: id, + turnState: t?.state ?? null, + provider_name: sv?.provider_name ?? null, + provider_instance_id: sv?.provider_instance_id ?? null, + last_error: sv?.last_error ?? null, + finalAssistantText: a?.text ? a.text.trim() : null, + }), + ); } db.close(); diff --git a/e2e/report-children.mjs b/e2e/report-children.mjs index 20ddd62d77c..79803b30864 100644 --- a/e2e/report-children.mjs +++ b/e2e/report-children.mjs @@ -1,25 +1,39 @@ // report-children.mjs