diff --git a/.agents/skills/_references/external-review-mechanics.md b/.agents/skills/_references/external-review-mechanics.md new file mode 100644 index 000000000000..9826ab002184 --- /dev/null +++ b/.agents/skills/_references/external-review-mechanics.md @@ -0,0 +1,192 @@ +# External Review Mechanics + +Use this reference when a project skill invokes read-only external reviewers. The owning +skill defines review order, budgets, quiet/reopen rules, and what to do with findings. + +## Reviewer Availability + +- Prefer real external reviewers. Do not simulate reviewer output. +- Available local reviewer commands may include `codex` and `opencode`. +- If `ai-limit-checker` is available, run it before expensive review rounds and avoid + providers whose quota is clearly exhausted. +- If no eligible reviewer can run after checking binary presence, auth/session state, + cwd, flags, and prompt piping, stop and report the blocker. + +## Protected Context + +- Scan prompts, plans, PR bodies, diffs, and context lists for protected files: + `.env*`, auth stores, key files, local tool-state artifacts, private tokens, and + credentials. +- Do not point reviewers at raw protected files, including checked-in env fixtures. +- Provide sanitized excerpts when a contract matters, and prefer tests/docs that encode + the same behavior without secrets. + +## Prompt Shape + +Build the reviewer prompt in memory. Include: + +- workflow name and review lens +- current branch/PR/plan identifiers +- base and head refs when reviewing a PR or implementation diff +- focused context file list +- diff stat and name-status when reviewing code +- validation summary, if available +- explicit read-only instruction +- finding cap for the current round + +Ask for exactly one JSON object, with no markdown fences: + +```json +{ + "summary": "short summary", + "overall_status": "clean | has-findings", + "findings": [ + { + "severity": "high | medium | low", + "title": "short title", + "details": "specific actionable explanation", + "references": ["path:line or doc reference"], + "evidence_basis": [ + "plan_file | context_files | repo_diff | validation_summary | docs_research" + ] + } + ], + "residual_risks": ["risk that remains after review"], + "repo_validation_ran": false, + "forbidden_commands": [] +} +``` + +Reviewers may inspect files and diffs. They must not run repo validation, lint, typecheck, +build, test, package, install, migration, server, browser, or release commands. + +## Command Patterns + +Codex read-only review: + +```sh +printf '%s\n' "$PROMPT" | codex --search --disable fast_mode -a never exec -m gpt-5 -c 'model_reasoning_effort="medium"' -s read-only -C "$PWD" - +``` + +Use `model_reasoning_effort="high"` for heavy mode, correctness-heavy reviews, round 3+, +or high-risk implementation concerns. + +Opencode review, when configured: + +```sh +opencode run -m zai-coding-plan/glm-5.1 "$PROMPT" +``` + +## Session Handling + +- Record the review command, reviewer, model, review lens, and start time. +- After starting a long review, do not treat elapsed time alone as failure. Reviews can + reasonably take several minutes. +- Investigate only when the command exits, streams an explicit error, appears to wait + for auth/approval, or produces no progress after a long wait. +- If output is malformed, first try to extract one unambiguous schema-valid JSON object. + Retry once only when extraction is impossible or ambiguous. +- If a reviewer ran forbidden commands, ignore command-derived evidence, salvage + file/diff/doc-based findings, and tighten the next prompt. + +## GitHub Review State + +Use GitHub's current head SHA as the review boundary. Do not infer a clean review +from elapsed time, an empty `reviewDecision`, a checkmark, or a review attached to +an older commit. + +Start with one PR snapshot: + +```sh +gh pr view "$PR_NUMBER" --repo "$OWNER/$REPO" \ + --json url,state,isDraft,headRefOid,baseRefOid,mergeStateStatus,reviewDecision,statusCheckRollup +``` + +Read every formal review and issue comment, not only GitHub's default page: + +```sh +gh api --paginate --slurp \ + "repos/$OWNER/$REPO/pulls/$PR_NUMBER/reviews?per_page=100" +gh api --paginate --slurp \ + "repos/$OWNER/$REPO/issues/$PR_NUMBER/comments?per_page=100" +``` + +For a Codex trigger comment, inspect its reactions when the review result is not +explicit in a current-head review body. A current Codex eye reaction means review +is still running; a thumbs-up can be the terminal clean signal: + +```sh +gh api --paginate --slurp \ + -H 'Accept: application/vnd.github+json' \ + "repos/$OWNER/$REPO/issues/comments/$COMMENT_ID/reactions?per_page=100" +``` + +Accept Codex as clean only when its result was produced after the latest relevant +push, identifies the exact `headRefOid` (for a formal review, its `commit_id` +matches), and gives an explicit no-issues result or terminal clean reaction. A +generic review wrapper, silence, or absence of inline findings is insufficient by +itself. After a push, request `@codex review` once unless a current request is +already active. + +Read all review threads with GraphQL pagination. `gh api --paginate` supplies the +next `$endCursor`; keep `pageInfo` in the query so it cannot silently truncate at +100 threads: + +```sh +gh api graphql --paginate --slurp \ + -F owner="$OWNER" -F repo="$REPO" -F number="$PR_NUMBER" \ + -f query='query($owner:String!, $repo:String!, $number:Int!, $endCursor:String) { + repository(owner:$owner, name:$repo) { + pullRequest(number:$number) { + headRefOid + reviewThreads(first:100, after:$endCursor) { + nodes { + id + isResolved + isOutdated + comments(first:100) { + nodes { id body path line createdAt author { login } } + pageInfo { hasNextPage endCursor } + } + } + pageInfo { hasNextPage endCursor } + } + } + } + }' +``` + +If a thread's nested `comments.pageInfo.hasNextPage` is true, query that thread's +comments separately with `node(id:$threadId)` and the same paginated connection +pattern before deciding what the thread says. + +Reply with concrete evidence before resolving an addressed or disproved finding: + +```sh +gh api graphql \ + -F threadId="$THREAD_ID" -F body="$REPLY" \ + -f query='mutation($threadId:ID!, $body:String!) { + addPullRequestReviewThreadReply(input:{ + pullRequestReviewThreadId:$threadId, + body:$body + }) { comment { id } } + }' + +gh api graphql \ + -F threadId="$THREAD_ID" \ + -f query='mutation($threadId:ID!) { + resolveReviewThread(input:{threadId:$threadId}) { + thread { id isResolved } + } + }' +``` + +Before merge, take a fresh snapshot and require all of these on the same head: + +- terminal-clean Codex result; +- zero unresolved review threads, including outdated threads; +- required checks and local validation are green; +- mergeability is clean and the expected base SHA has not moved. + +After any fix push or rebase, discard the prior review conclusion and repeat the +current-head gate. diff --git a/.agents/skills/implement-plan/SKILL.md b/.agents/skills/implement-plan/SKILL.md new file mode 100644 index 000000000000..e5b1969a280c --- /dev/null +++ b/.agents/skills/implement-plan/SKILL.md @@ -0,0 +1,84 @@ +--- +name: implement-plan +description: | + Implement a reviewed LastCode/T3 Code plan file end to end: code the plan, keep the + plan/docs aligned, validate with repo checks, run bounded implementation reviews, + perform cleanup, and hand off concrete results. Supports `light implement-plan`, + `heavy implement-plan`, and explicit round counts. Use when the user says + "implement plan", "implement-plan", "ship the plan at PLAN_FILE", or "build the plan". + The primary input is the plan file path. +--- + +# Implement Plan Skill + +Implement a reviewed plan in this repository. + +## Operating Rules + +- Complete the workflow in one run: inspect plan -> implement -> validate -> review -> + cleanup -> handoff. +- Stop only for real blockers: ambiguous branch/worktree, unsafe rebase/conflicts, + scope expansion that needs a new plan, unavailable reviewers after diagnosis, + validation failure you cannot fix, or a user/product decision. +- Never require a magic phrase. Any affirmative response means continue. +- Dirty unrelated files are reportable, not blocking, unless they directly block edits + or validation. +- Maintain a one-paragraph running state summary at phase boundaries. + +## Start Conditions + +1. Read the plan and `AGENTS.md`. +2. Confirm branch intent: + - upstream contribution work should branch from `main` / `upstream/main` + - LastCode-private work should branch from `lastcode/main` +3. Fetch relevant remotes before rebasing. +4. Rebase only when the target branch is unambiguous and the worktree is clean enough. +5. Treat the plan as source of truth. Use + `references/source-of-truth-guard.md` when older artifacts may be discoverable. + +## Implementation Rules + +- Follow existing repo patterns before introducing abstractions. +- Keep `packages/contracts` schema-only. +- Avoid compatibility layers unless the plan or user explicitly requires them. +- Use Effect service/process/path/schema APIs where the repo’s diagnostics require + them; do not bypass Effect diagnostics in new scripts unless the boundary truly + needs a local suppression. +- For frontend work, follow the project’s dense app UI style and verify with browser + inspection when the change is visual or interactive. +- Update docs, plan notes, and validation sections as implementation reality changes. + +## Validation + +Required before completion: + +```bash +vp check +vp run typecheck +``` + +Also run: + +- targeted `vp test ...` or package tests for touched areas +- `vp run test` when the package script is specifically needed +- `vp run lint:mobile` for native mobile changes +- local build/smoke commands named by the plan + +Do not move past a failed validation step unless you fix it and rerun, or stop and +report the blocker. + +## Review And Cleanup + +Use `references/review-orchestration.md` for implementation review rounds. Run at least +one review round unless the user explicitly asks for implementation only. + +After review rounds: + +- do a cleanup pass for duplication, boundaries, stale comments, docs, and tests +- update the plan’s validation/results sections +- commit/push when the user’s workflow calls for it + +## Final Handoff + +Use `references/handoff-and-manual-qa.md`. Include changed files, validation results, +review depth/rounds, commits pushed, remaining risks, and manual QA steps. diff --git a/.agents/skills/implement-plan/agents/openai.yaml b/.agents/skills/implement-plan/agents/openai.yaml new file mode 100644 index 000000000000..411042c5b47f --- /dev/null +++ b/.agents/skills/implement-plan/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Implement Plan" + short_description: "Implement reviewed LastCode plans" + default_prompt: "Use $implement-plan to implement the plan at PLAN_FILE." diff --git a/.agents/skills/implement-plan/references/handoff-and-manual-qa.md b/.agents/skills/implement-plan/references/handoff-and-manual-qa.md new file mode 100644 index 000000000000..e40dfcc16e49 --- /dev/null +++ b/.agents/skills/implement-plan/references/handoff-and-manual-qa.md @@ -0,0 +1,40 @@ +# Handoff And Manual QA + +## Manual QA Applicability + +Manual app QA applies when work changes: + +- visible UI, copy, layout, themes, accessibility, responsive behavior +- Electron desktop update/install behavior +- mobile UI or gestures +- browser-visible routing or interaction +- workflows where acceptance depends on human judgement + +Manual app QA usually does not apply for docs-only, backend-only, test-only, +script-only, CI-only, dependency-only, or skill-only changes. + +When UI/manual QA applies: + +1. Start the appropriate dev server or packaged app. +2. Use the Browser Use plugin/in-app browser for local web targets when useful. +3. For Electron/mobile flows, document exact local steps and any artifacts to inspect. +4. Capture what passed, what failed, and what remains unverified. + +Canonical skip text: + +`Manual app QA not run: no manual UI QA surface` + +## Final Handoff + +Include: + +- summary of implementation +- code cleanup/consolidation performed +- validation commands and results +- review rounds/lenses and disposition totals +- commits pushed, when applicable +- remaining risks or deferred decisions +- manual QA steps and expected behavior, or the skip reason above +- direct links to plans, docs, and local artifacts + +If a required review or validation step could not be completed for real, say so plainly. diff --git a/.agents/skills/implement-plan/references/review-orchestration.md b/.agents/skills/implement-plan/references/review-orchestration.md new file mode 100644 index 000000000000..d5308294ee2f --- /dev/null +++ b/.agents/skills/implement-plan/references/review-orchestration.md @@ -0,0 +1,47 @@ +# Implementation Review Orchestration + +Use real reviews only. Do not simulate reviewer output. + +## Review Depth + +- `light implement-plan`: one round. +- `heavy implement-plan`: up to three rounds, high reasoning for later external reviews. +- explicit `N rounds`: at most N rounds. +- default: up to three rounds, stopping earlier when all lenses are quiet. + +## Lenses + +Run in order: + +1. `correctness`: behavior, contracts, regressions, missing tests, acceptance criteria. +2. `ui-component`: only for UI/component/style/interaction/copy changes. +3. `KISS`: simplicity, duplication, unnecessary compatibility, needless surface area. +4. `UX`: user-facing flows, states, responsiveness, accessibility, manual QA gaps. +5. `best-practices`: main-session review using repo knowledge and `ctx7` for relevant + current library/framework/API docs. + +Use `.agents/skills/_references/external-review-mechanics.md` for reviewer invocation. + +## Context For Reviewers + +Include: + +- plan file +- touched files +- focused diffs from `git diff --stat` and `git diff --name-status` +- validation summary +- relevant docs/contracts/tests +- protected-context summaries, not raw secrets + +External reviewers are read-only and must not run repo validation commands. + +## Acting On Findings + +- Apply findings that improve the implementation within plan scope. +- Defend findings that are wrong, duplicate, already covered, or intentionally out of + scope by clarifying code, tests, docs, or plan notes. +- Defer only when blocked by tooling, quota, validation, protected context, or a human + decision. +- Rerun focused validation after material fixes. +- Mark a lens quiet when clean or only insignificant findings remain. +- Reopen quiet lenses when later changes materially affect their concern area. diff --git a/.agents/skills/implement-plan/references/source-of-truth-guard.md b/.agents/skills/implement-plan/references/source-of-truth-guard.md new file mode 100644 index 000000000000..f9f60fb24eaa --- /dev/null +++ b/.agents/skills/implement-plan/references/source-of-truth-guard.md @@ -0,0 +1,25 @@ +# Source-of-Truth Guard + +Use this when implementing a reviewed plan with older exploratory docs, ELI5 docs, +options notes, or prior plans nearby. + +## Rules + +1. Treat the plan as authoritative for scope, decisions, invariants, validation, and + manual QA expectations. +2. Read docs/checklists explicitly named by the plan or required by `AGENTS.md`. +3. Do not treat older unlisted artifacts as requirements. +4. If a linked artifact conflicts with the plan, update it or mark it superseded before + implementation review. +5. Exclude unlisted historical docs from reviewer context unless the plan depends on + them. + +## Reviewer Guard + +```text +The reviewed plan is authoritative. Use only the plan, touched files, validation +summary, and focused context file list as specification sources. Documents outside the +context file list are not requirements. If you find a mismatch with an unlisted +exploratory or historical document, report it only if the current plan links to or +depends on that document. +``` diff --git a/.agents/skills/import-upstream-pr/SKILL.md b/.agents/skills/import-upstream-pr/SKILL.md new file mode 100644 index 000000000000..2430aa5cbad2 --- /dev/null +++ b/.agents/skills/import-upstream-pr/SKILL.md @@ -0,0 +1,156 @@ +--- +name: import-upstream-pr +description: Evaluate, port, validate, and deliver an open-unmerged or closed-unmerged pingdotgg/t3code pull request into LastCode while preserving exact upstream provenance. Use when LastCode wants an existing upstream PR before it merges, after it closes without merge, or when re-evaluating a force-pushed upstream candidate. Do not use to author a new upstream contribution; use upstream-fix. Do not use for a fork-only change with no upstream PR; use lastcode-pr. +--- + +# Import Upstream PR + +Adopt one existing T3 Code PR as an independently owned LastCode change. Treat +the upstream PR as a pinned candidate and evidence source, never as a substitute +for LastCode review, validation, or merge authority. + +## Establish the Boundary + +1. Read the repository `AGENTS.md` and `docs/lastcode/fork-conventions.md`. +2. Read [references/intake-and-evidence.md](references/intake-and-evidence.md) + before capturing the candidate. +3. Keep the product port separate from notes, workflow documentation, and skill + changes. Use separate branches or worktrees. +4. Do not infer permission to push, open a PR, use a browser, or merge. Obtain + the authority required by the repository instructions for each operation. + +## Pin and Inspect the Candidate + +1. Capture the upstream PR URL, state, observed date, title, author, base, exact + `headRefOid`, ordered commits, changed files, checks, reviews, review threads, + linked issues, and closure reason when closed. +2. Force-fetch `pull//head` into a dedicated remote-tracking ref so a + previously cached ref cannot reject a legitimate upstream force-push. + Require the fetched SHA to equal the captured `headRefOid`. Never import a + moving branch name. +3. Derive the complete, oldest-first commit list from the pinned base and head + Git objects. Record its count and require its final SHA to equal + `headRefOid`. Do not use GitHub's PR commits response as the source of truth; + that endpoint is capped at 250 commits even when paginated. +4. Inspect metadata and the full diff before installing dependencies or running + any code controlled by the PR. +5. Check current `upstream/main` and `origin/lastcode/main` for the same behavior, + a replacement, or an incompatible design. +6. Treat a force-pushed upstream head as a new candidate. Range-diff it against + the prior pinned head and repeat the applicable review and validation. + +If `origin/lastcode/main` already contains the exact behavior or an accepted +replacement, stop before creating an evaluation branch and report the candidate +as already adopted or superseded. If only `upstream/main` contains it, decide +whether normal nightly reconciliation is sufficient before creating a manual +port. + +## Decide Eligibility + +- For an open, unmerged PR, decide whether LastCode benefits enough to adopt it + now instead of waiting. +- For a closed, unmerged PR, establish why it closed. Inactivity, contribution + policy, or maintainer bandwidth can be acceptable. Incorrectness, + supersession, or rejected direction requires an explicit LastCode divergence + decision. +- Pause for hidden dependencies, unresolved correctness findings, unclear + product value, unexplained closure, or scope too large to validate + proportionally. + +Record the adoption decision. Upstream CI is supporting evidence only. + +## Build an Isolated Evaluation + +1. Fetch `origin` with pruning and create a clean worktree on + `pr/upstream/-` from the exact `origin/lastcode/main`. +2. Apply the pinned Git graph's ordered commit list with `git cherry-pick -x`. + Preserve authorship and commit boundaries when the stack is coherent. If + the range contains merge commits, stop and plan an ancestry-aware import or + a reimplementation instead of flattening it blindly. +3. Do not merge the upstream PR branch; that drags its base history into + LastCode. +4. If the stack does not fit current LastCode, reimplement only the coherent + behavior and record the upstream PR URL, pinned SHA, and why cherry-pick was + unsuitable. + +Classify integration honestly: + +- changed-path overlap is a risk signal, not a conflict; +- a clean auto-merge still needs semantic review in current LastCode context; +- a textual conflict requires explicit resolution review; +- a clean textual application can still have a semantic conflict. + +Treat every conflict resolution and downstream adaptation as first-party code. + +## Validate the Port + +1. Review every changed line against current LastCode and the linked problem. +2. Walk the affected entry points, clients, providers, contracts, reverse + states, connection modes, performance concerns, and docs. Mark each + non-applicable surface explicitly. +3. Complete dependency installation before starting checks. A partially + completed install is not evidence; require its zero exit and terminal + completion. +4. Run focused behavior tests, targeted lint, the affected package typecheck, + and `git diff --check ` under the repository's + canonical toolchain. A bare `git diff --check` does not inspect an already + committed port. Record the toolchain command and versions with the receipt. +5. Add focused regression tests for backend or automation behavior. Do not run + repo-wide checks merely for intake. +6. For user-visible behavior, obtain browser/computer-use approval and use the + applicable repository app-testing skill against disposable state. Capture + matched LastCode-specific before/after evidence and identify the real client, + viewport, and any authorized fallback accurately. +7. Keep one live app tab when shared browser storage can make cross-tab state + nondeterministic. Verify route, persisted state, and rendered state rather + than trusting a click result alone. + +When accepted, rename the branch to `port/upstream/pr--`. + +## Refresh and Deliver + +1. Immediately before delivery, fetch `origin/lastcode/main` again. If the port + parent moved, rebase the imported commits and rerun affected validation. +2. Before the guarded push, require a clean worktree and record the local head, + destination base, and existing remote topic SHA (or its absence). Then push + in a clean environment so the pre-push `pnpm lastcode:ci:quick` gate sees + ordinary Git/SSH variables. Do not export `GIT_SSH_COMMAND` or inject an SSH + command through Git configuration around the push; those settings flow into + tests that intentionally control `GIT_SSH`. +3. If Quick CI passes but the idle SSH transport subsequently dies, do not + treat its generic success line as an exact-head receipt. Require the local + head to equal the recorded head, the worktree to remain clean, the fetched + destination base to equal the recorded base, and the remote topic SHA (or + absence) to remain unchanged. Only then retry that recorded head with + `--no-verify` and an exact `--force-with-lease` tied to the recorded remote + topic state. Otherwise rerun the guarded push and its hook. +4. Open a PR targeting `lastcode/main` only when explicitly requested. Include + the upstream PR and pinned head, observed state/date, import method, + adaptations, rationale, validation, closure/review context, and published + evidence. +5. For review and merge, follow `lastcode-pr` and + `.agents/skills/_references/external-review-mechanics.md`: require a terminal + clean Codex result for the exact head, zero unresolved threads, and a full + `pnpm lastcode:ci` stamp for the exact head/current base. Merge only through + `pnpm lastcode:merge`. +6. Verify the merged commit on `origin/lastcode/main`. For a squash merge, + compare stable patch IDs so provenance verification does not depend on the + topic commit remaining an ancestor. + +## Reconcile Later Upstream Movement + +- If upstream later merges an identical patch, let normal nightly reconciliation + remove the duplicate. +- If upstream merges a changed version, compare it with the pinned imported head + and port only the desired delta. +- If upstream closes unmerged, retain or remove the behavior according to + LastCode's product decision, not the state transition alone. +- Preserve the upstream URL and pinned SHA in the LastCode PR so future sync work + can explain the source. + +## Handoff + +Report the source PR state and pinned head, destination base and port head, +import method and adaptations, validation and real-client evidence, PR/merge +state, exact-head review result, unresolved-thread count, full-CI stamp, and the +merged commit or remaining blocker. diff --git a/.agents/skills/import-upstream-pr/agents/openai.yaml b/.agents/skills/import-upstream-pr/agents/openai.yaml new file mode 100644 index 000000000000..c5dc80e0ea85 --- /dev/null +++ b/.agents/skills/import-upstream-pr/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Import Upstream PR" + short_description: "Evaluate and port unmerged T3 Code PRs" + default_prompt: "Use $import-upstream-pr to evaluate and import a pinned T3 Code pull request into LastCode." diff --git a/.agents/skills/import-upstream-pr/references/intake-and-evidence.md b/.agents/skills/import-upstream-pr/references/intake-and-evidence.md new file mode 100644 index 000000000000..057a67ef5130 --- /dev/null +++ b/.agents/skills/import-upstream-pr/references/intake-and-evidence.md @@ -0,0 +1,157 @@ +# Intake and Evidence Reference + +Use this reference to create a reproducible receipt before executing upstream +PR code and to keep delivery claims tied to exact commits. + +## Candidate receipt + +Capture PR metadata before fetching or executing the candidate: + +```bash +set -e + +repo=pingdotgg/t3code +pr= + +metadata=$(gh pr view "$pr" --repo "$repo" --json \ + number,title,url,state,isDraft,author,baseRefName,baseRefOid,headRefOid,\ + mergeable,mergeStateStatus,changedFiles,statusCheckRollup,\ + reviewDecision,labels,body,closedAt,mergedAt,updatedAt) + +printf '%s\n' "$metadata" +base_ref=$(printf '%s' "$metadata" | jq -r .baseRefName) +base_sha=$(printf '%s' "$metadata" | jq -r .baseRefOid) +head_sha=$(printf '%s' "$metadata" | jq -r .headRefOid) + +git fetch upstream \ + "+refs/pull/$pr/head:refs/remotes/upstream/pr/$pr" + +fetched_head=$(git rev-parse "refs/remotes/upstream/pr/$pr") +if ! git cat-file -e "$base_sha^{commit}" 2>/dev/null; then + git fetch upstream "$base_sha" || + printf 'exact base SHA was not directly fetchable: %s\n' "$base_sha" >&2 +fi +git cat-file -e "$base_sha^{commit}" || { + printf 'missing pinned base commit: %s\n' "$base_sha" >&2 + exit 1 +} +test "$fetched_head" = "$head_sha" || { + printf 'PR head changed while pinning: expected %s, fetched %s\n' \ + "$head_sha" "$fetched_head" >&2 + exit 1 +} + +if git ls-remote --exit-code --heads upstream \ + "refs/heads/$base_ref" >/dev/null; then + git fetch upstream \ + "+refs/heads/${base_ref}:refs/remotes/upstream/${base_ref}" + current_base_sha=$(git rev-parse "refs/remotes/upstream/$base_ref") +else + current_base_sha=unavailable +fi + +commit_count=$(git rev-list --count "$base_sha..$head_sha") +final_sha=$( + git rev-list --reverse --topo-order "$base_sha..$head_sha" | tail -n 1 +) +test "$commit_count" -gt 0 || { + printf 'pinned PR range is empty\n' >&2 + exit 1 +} +test "$final_sha" = "$head_sha" || { + printf 'commit walk did not end at pinned head: %s\n' "$head_sha" >&2 + exit 1 +} +printf '{"count":%s,"final_sha":"%s","current_base_sha":"%s"}\n' \ + "$commit_count" "$final_sha" "$current_base_sha" +git rev-list --reverse --topo-order "$base_sha..$head_sha" +``` + +Fetching the PR head is independent of fetching its named base branch because +a closed PR can outlive a deleted target branch. The leading `+` refspecs allow +cached PR and live-base refs to follow legitimate force-pushes. `baseRefOid` +can predate the current base tip; require that exact commit object from the PR +history or a direct SHA fetch rather than equating it with `current_base_sha`. +If the named base no longer exists, record it as unavailable instead of failing +the already pinned head. Requiring the fetched PR ref to equal `headRefOid` +detects a head race; restart the capture if it changed. Git's `base..head` walk +is the complete source of truth because the GitHub PR commits endpoint returns +at most 250 commits, while `gh pr view --json commits` exposes only the first 100. Record the oldest-first `--topo-order` list and its count. Also record: + +- observed date and timezone; +- ordered commit SHAs and authors; +- changed file list and diff stat; +- linked issues and claimed behavior; +- all formal reviews and issue comments; +- all review threads, including unresolved and outdated threads; +- upstream check results; +- closure reason or superseding change when closed; and +- current fetched `upstream/main` and `origin/lastcode/main` SHAs. + +Use the paginated review and thread queries in +`.agents/skills/_references/external-review-mechanics.md`. Do not infer a clean +candidate from an empty GitHub summary. + +## Duplicate and integration checks + +Before adoption, compare the candidate against both current destinations: + +- ancestor containment answers whether the exact commit exists; +- stable patch IDs help find identical patches with rewritten commits; +- code/search and linked-issue inspection find replacements with different + implementations; +- path overlap identifies files needing closer review but does not prove a + conflict; +- a merge-tree preview or the isolated cherry-pick establishes textual + applicability; and +- source review establishes semantic compatibility. + +For multiple commits, preserve the pinned Git graph's oldest-first topological +order. Check `git rev-list --merges "$base_sha..$head_sha"` before cherry-pick; +if it is non-empty, plan an ancestry-aware import or reimplementation rather +than flattening merge commits blindly. Use `git range-diff` when the candidate +head changes or when validating a rebased port. + +## Validation receipt + +Tie every result to the exact port head and destination base. Record: + +- toolchain activation command plus Node and package-manager versions; +- dependency install command and terminal success; +- focused commands and test counts; +- `git diff --check ` result for the committed + port range; +- affected-surface matrix with explicit non-applicable entries; +- integrated client, disposable-state boundary, viewport, route/state/rendered + assertions, and before/after artifact paths or published URLs; +- any fallback browser system and the approval that allowed it; and +- clean worktree, local head, remote head, and destination base at publication. + +Do not convert missing evidence into a product pass or failure. Mark the +assertion `blocked`, explain the evidence gap, and either obtain authority for a +fallback or hand it off. + +## LastCode PR body checklist + +Include: + +- upstream PR URL, title, author, observed state/date, and pinned head; +- linked issue and LastCode adoption rationale; +- `cherry-pick -x` or reimplementation method; +- conflict resolutions and LastCode-specific adaptations; +- unresolved upstream review or closure context; +- focused and integrated validation; and +- durable GitHub-hosted visual evidence for UI changes. + +Before merge, refresh the PR snapshot and require the same exact head across the +clean Codex result, zero unresolved threads, full-CI stamp, and merge command. + +After a squash merge, verify stable patch equivalence: + +```bash +git diff | git patch-id --stable +git diff -- | git patch-id --stable +``` + +Matching patch IDs show the validated port became the merged change even though +the original topic commit is not an ancestor of the squashed result. diff --git a/.agents/skills/lastcode-pr/SKILL.md b/.agents/skills/lastcode-pr/SKILL.md new file mode 100644 index 000000000000..a24efa6b5066 --- /dev/null +++ b/.agents/skills/lastcode-pr/SKILL.md @@ -0,0 +1,72 @@ +--- +name: lastcode-pr +description: Deliver a LastCode-only change through its branch, local CI, Codex review, and guarded merge workflow. Use for Markover integration, LastCode identity or branding, personal conveniences, nightly checkpointing, ad-hoc releases, and any change intentionally not proposed to pingdotgg/t3code. Use upstream-fix instead when a general improvement should also be offered upstream. +--- + +# LastCode PR + +Ship fork-only work from the canonical downstream base without contaminating the +clean upstream mirror. + +## Classify and Branch + +Read `docs/lastcode/fork-conventions.md` and the repository `AGENTS.md` first. +If the change makes sense to a T3 Code user without LastCode or Markover context +and should be proposed upstream, switch to `upstream-fix`. + +1. Ensure the worktree is clean and fetch `origin` with pruning. +2. Create `lastcode/markover/` for Markover integration or + `lastcode/` for other fork-only work from the exact + `origin/lastcode/main`. +3. Keep one concern per branch and PR. Put LastCode-only contributor and + operations documentation under the fork's deliberate `docs/lastcode/` + namespace. Keep product and upstream-facing documentation in the + audience-based directories required by `AGENTS.md`. +4. Never target `main` or `pingdotgg/t3code` from this workflow. + +## Implement and Validate + +1. Implement the smallest complete change, including focused regression tests + for backend or automation behavior. +2. Run the smallest relevant tests, lint, and typecheck required by `AGENTS.md`. +3. Rebase onto the latest `origin/lastcode/main` before publishing. +4. Push the branch; the pre-push hook must pass `pnpm lastcode:ci:quick` locally. +5. Open a PR targeting `lastcode/main` only when the user explicitly asks. + +An open PR targeting `lastcode/main` pauses promotion of a new nightly onto the +branch. Checkpoint automation still publishes every immutable nightly tag and +promotes the newest one after the PR queue is empty. + +## Babysit and Merge + +When the user asks to babysit or merge: + +Read `.agents/skills/_references/external-review-mechanics.md` for the repository's +current GitHub thread and review-query mechanics. + +1. Inspect comments and thread-level review state newer than the latest push. +2. Verify each bot finding against the source. Fix real defects; reply with a + concrete reason when a finding is false. Resolve only addressed threads. +3. Request `@codex review` after each fix push. Do not merge until Codex gives an + explicit clean result for the exact current head and no review thread remains + unresolved. +4. Run `pnpm lastcode:ci` from a clean branch. Its full-CI stamp must match the + exact head and fetched `origin/lastcode/main` base. +5. Use `pnpm lastcode:merge`; do not bypass the guarded merge in the GitHub UI. +6. Verify the PR is merged and `origin/lastcode/main` contains the merge result. +7. If the work is tracked by Markover, confirm the GitHub terminal state and + run the service-free command from the Markover checkout: + + ```bash + npm --silent run markover -- done --pr-status merged + ``` + +Follow the repository's bounded CI polling rule. Stay quiet when no new review +or check result exists, and stop only when the latest commit is clean or a real +external blocker requires the user. + +## Handoff + +Report the PR URL, merged commit or current head, focused and full validation, +Codex review result, unresolved-thread count, and Markover state when applicable. +If no PR was requested, report the local branch and commit without publishing it. diff --git a/.agents/skills/lastcode-pr/agents/openai.yaml b/.agents/skills/lastcode-pr/agents/openai.yaml new file mode 100644 index 000000000000..4f5472fce5d8 --- /dev/null +++ b/.agents/skills/lastcode-pr/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "LastCode PR" + short_description: "Ship a fork-only change through LastCode local CI" + default_prompt: "Use $lastcode-pr to deliver this fork-only change through the LastCode PR workflow." diff --git a/.agents/skills/review-plan/SKILL.md b/.agents/skills/review-plan/SKILL.md new file mode 100644 index 000000000000..5774ab53006a --- /dev/null +++ b/.agents/skills/review-plan/SKILL.md @@ -0,0 +1,100 @@ +--- +name: review-plan +description: | + Review a LastCode/T3 Code plan file before implementation. Runs bounded review + rounds over basic correctness, best-practices, KISS, and conditional UI/component + lenses; updates the plan in place; and stops when review types are quiet or the + requested depth is exhausted. Use when the user says "review plan", "review-plan", + "review the plan at PLAN_FILE", or invokes this skill on a plan file. The plan file path + is the primary input. +--- + +# Review Plan Skill + +Review a plan file for this T3 Code / LastCode repository. + +## Operating Rules + +- Run the workflow in one pass. Do not stop between review rounds unless a real blocker + appears. +- Never require a magic phrase from the user. Any affirmative reply means continue. +- Maintain a one-paragraph running state summary: plan path, review depth, current + round/lens, quiet/open lenses, and material plan changes. +- Do not simulate external reviews. If external reviewers cannot run after diagnosis, + report exactly what failed and what you tried. +- Do not let stale exploratory docs override the current plan. Use + `references/context-hygiene.md` when prior notes, option docs, or ELI5 docs exist. + +## Review Depth + +- `light review-plan` or `one round`: run exactly one full round. +- `heavy review-plan`: use the default round budget and high reasoning for later + external review steps. +- `N rounds`: run at most N full rounds. +- Default: up to 3 rounds, stopping earlier when all applicable lenses are quiet. + +## LastCode/T3 Code Context + +- Repo checks are `vp check` and `vp run typecheck`. +- Use `vp test` for Vite+ tests and `vp run test` when a package script specifically + matters. +- Run `vp run lint:mobile` only when native mobile code changes. +- Use `ctx7` for current docs when reviewing library/framework/API/CLI-specific plan + claims. +- Important package roles: + - `apps/server`: Node WebSocket/provider orchestration server. + - `apps/web`: React/Vite UI. + - `apps/desktop`: Electron shell and updater. + - `apps/mobile`: React Native mobile app. + - `packages/contracts`: schema-only shared contracts. + - `packages/shared`: runtime utilities with explicit subpath exports. + +## Before Reviewing + +1. Read the plan and `AGENTS.md`. +2. Build a focused context file list: the plan, directly relevant docs, contracts, + tests, and source files. Keep it tight. +3. If the plan touches protected context, summarize the contract instead of exposing + raw protected files. +4. Decide whether the plan is UI/component-affecting. Use the UI lens for changes to + `apps/web`, `apps/desktop` UI, `apps/mobile` UI, shared UI components, styles, + accessibility, interaction states, or user-facing copy. + +## Review Lenses + +Run lenses in this order: + +1. `basic`: scope, acceptance criteria, sequencing, missing validation, risky unknowns. +2. `ui-component`: only when applicable; component reuse, visual states, responsive + behavior, accessibility, and expected browser/app inspection. +3. `best-practices`: repository conventions, Effect usage, contracts package boundaries, + provider/runtime reliability, current docs via `ctx7` when relevant. +4. `KISS`: simpler architecture, duplicate logic, unnecessary compatibility layers, + YAGNI, migration churn. + +Use `.agents/skills/_references/external-review-mechanics.md` for reviewer commands and +JSON expectations. + +## Acting On Findings + +For each finding: + +- `applied`: update the plan. +- `defended`: keep the approach but add concrete rationale, constraints, or validation. +- `deferred`: only when blocked by missing user input, unavailable tooling, or exhausted + review budget. + +Mark a lens quiet when its latest review is clean or causes only trivial wording +changes. Reopen quiet lenses when another lens causes material scope, architecture, +validation, or UX changes. + +## Final Handoff + +Summarize: + +- plan path +- requested depth and rounds used +- review lenses run and skipped +- applied/defended/deferred counts +- final quiet/open state +- whether the plan is ready for `implement-plan` diff --git a/.agents/skills/review-plan/agents/openai.yaml b/.agents/skills/review-plan/agents/openai.yaml new file mode 100644 index 000000000000..5530b7f0d454 --- /dev/null +++ b/.agents/skills/review-plan/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Review Plan" + short_description: "Review LastCode plans before implementation" + default_prompt: "Use $review-plan to review the plan at PLAN_FILE." diff --git a/.agents/skills/review-plan/references/context-hygiene.md b/.agents/skills/review-plan/references/context-hygiene.md new file mode 100644 index 000000000000..7e4eaed88be9 --- /dev/null +++ b/.agents/skills/review-plan/references/context-hygiene.md @@ -0,0 +1,35 @@ +# Context Hygiene Before Plan Review + +Use this when a plan follows exploratory discussion, options docs, older plans, ELI5 +HTML docs, or other idea artifacts. + +## Goal + +Make the on-disk planning packet coherent enough that reviewers can evaluate the plan +without treating stale artifacts as current requirements. + +## Required Pass + +1. Identify related artifacts a reviewer might find or that the thread relied on. +2. Classify each artifact: + - `canonical-current` + - `supporting-current` + - `historical/superseded` + - `unknown/conflicting` +3. Reconcile current artifacts: + - update small stale artifacts when cheap and useful + - mark stale artifacts as superseded when they should remain for history + - remove stale links from the current plan + - stop for user clarification only when conflicting artifacts change product scope + and no safe assumption exists +4. Add a compact source-of-truth section to the plan when prior artifacts matter. +5. Build the reviewer context list from current sources only. + +## Reviewer Prompt Guard + +```text +The plan file is the current source of truth. Use only the plan and the focused context +file list as specification sources. Historical, exploratory, superseded, or unlisted +documents are not authoritative. Report a mismatch with those documents only when the +current plan links to them, depends on them, or should explicitly update/supersede them. +``` diff --git a/.agents/skills/review-pr/SKILL.md b/.agents/skills/review-pr/SKILL.md new file mode 100644 index 000000000000..b2aa5e235536 --- /dev/null +++ b/.agents/skills/review-pr/SKILL.md @@ -0,0 +1,95 @@ +--- +name: review-pr +description: | + Review an existing GitHub pull request for this LastCode/T3 Code repository by PR + number, or the current branch's PR when the user says "review this PR". Runs bounded + review rounds over correctness, conditional UI/component, KISS, UX, and + best-practices lenses; applies safe fixes to the PR branch; validates; pushes; and + summarizes results. Supports `light review-pr`, `heavy review-pr`, and explicit round + counts. +--- + +# Review PR Skill + +Review and improve an existing GitHub PR. + +## Operating Rules + +- Resolve PR -> inspect scope -> build context -> run bounded reviews -> synthesize and + apply safe fixes -> validate -> push -> final handoff. +- If no PR number is provided, resolve the current branch’s PR with `gh pr view`. +- Review the whole PR against its base branch, not only local edits. +- Apply fixes only when they are within PR scope and do not require user/product + judgement. +- Ask the user only when a finding changes product behavior beyond PR intent, expands + scope materially, needs protected context, or has multiple plausible user-visible + fixes. +- Do not simulate external reviews. + +## PR Resolution + +Collect: + +- PR number, URL, title, body, author, base branch, head branch, head SHA, draft state +- changed files, diff stat, name-status +- current check/validation status when available + +Fetch the base/head refs. Work in the PR head worktree/branch when possible. If the +current worktree is unrelated or dirty, create a temporary worktree for review/fixes. +Stop if a writable PR-branch worktree is not possible. + +Use a freshly fetched remote base ref or exact base SHA. Do not rely on stale local +`main`. + +## LastCode/T3 Code Review Lenses + +Run rounds in this order: + +1. `correctness`: behavior, regressions, contracts, data/state consistency, + validation gaps. +2. `ui-component`: for `apps/web`, `apps/desktop` UI, `apps/mobile` UI, styles, + user-facing copy, accessibility, or interaction changes. +3. `KISS`: simplicity, duplicate logic, unnecessary compatibility, avoidable churn. +4. `UX`: visible flows, loading/error/empty states, responsive behavior, manual QA. +5. `best-practices`: main-session review using repo conventions and `ctx7` for current + docs when library/framework/API details matter. + +Use `.agents/skills/_references/external-review-mechanics.md` for external reviewers. + +## Validation + +After applying fixes, run relevant targeted tests plus required repo checks: + +```bash +vp check +vp run typecheck +``` + +Also run `vp run lint:mobile` for native mobile changes and appropriate `vp test` / +`vp run test` commands for touched packages. + +## Synthesis + +For each finding: + +- `applied`: fix committed. +- `defended`: no code change, but rationale captured in code, tests, docs, or PR notes. +- `rejected`: wrong, duplicate, already covered, or outside PR scope. +- `deferred`: blocked by tooling/quota/validation/protected context/human decision. + +Commit accepted fixes with concise messages and push to the PR branch. + +## Final Handoff + +Include: + +- PR link +- review depth and rounds completed +- reviewers/lenses used and skipped +- commits pushed +- validation commands/results +- disposition totals by lens +- remaining deferred findings or human decisions +- manual QA focus or `Manual app QA not run: no manual UI QA surface` + +Do not end with a magic-phrase prompt. diff --git a/.agents/skills/review-pr/agents/openai.yaml b/.agents/skills/review-pr/agents/openai.yaml new file mode 100644 index 000000000000..3425ca80aed3 --- /dev/null +++ b/.agents/skills/review-pr/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Review PR" + short_description: "Review and improve LastCode pull requests" + default_prompt: "Use $review-pr to review PR #." diff --git a/.agents/skills/upstream-fix/SKILL.md b/.agents/skills/upstream-fix/SKILL.md new file mode 100644 index 000000000000..800483779b7e --- /dev/null +++ b/.agents/skills/upstream-fix/SKILL.md @@ -0,0 +1,64 @@ +--- +name: upstream-fix +description: Prepare and deliver a general T3 Code fix or improvement that should be proposed to pingdotgg/t3code while LastCode retains an independent copy. Use for upstream-worthy bugs and features, paired upstream and LastCode PRs, refreshing the clean contribution base, or porting an upstream candidate into lastcode/main. Do not use for Markover integration, LastCode branding, personal workflow assumptions, or release automation; use lastcode-pr for those. +--- + +# Upstream Fix + +Deliver one logical improvement through two independent branches: an +upstream-pure contribution and a LastCode port. Keep their bases, reviews, CI, +and merge decisions separate. + +## Classify the Change + +Use this workflow only when the behavior makes sense to a T3 Code user with no +LastCode or Markover context. Route fork identity, Markover integration, personal +conveniences, nightly automation, and local release machinery to `lastcode-pr`. + +Read `docs/lastcode/fork-conventions.md` before changing branches. Follow the +repository `AGENTS.md` on both branches. + +## Prepare the Upstream Delivery + +1. Ensure the worktree is clean. Fetch `upstream` and `origin` with pruning. +2. Confirm `upstream` is `pingdotgg/t3code` and `origin` is the writable fork. +3. Fast-forward the fork mirror with `git push origin upstream/main:main`. If it + is not a fast-forward, stop and inspect; never merge or force downstream + history into `main`. +4. Create `fix/` or `feat/` from the exact fetched + `upstream/main`, not from `lastcode/main`. +5. Implement one upstream concern. Exclude LastCode branding, Markover details, + downstream automation, and `docs/lastcode` changes. +6. Run the smallest relevant tests and checks required by `AGENTS.md`. +7. Fetch and rebase onto the latest `upstream/main` immediately before opening + the PR. Push to `origin` and target `pingdotgg/t3code:main`. +8. Follow upstream CI and review conventions. Verify review findings against the + source, address real issues, and explain false positives. + +Do not open a PR unless the user explicitly asks. + +## Prepare the LastCode Delivery + +1. Fetch `origin` again and create `port/upstream/` from the exact + `origin/lastcode/main`. +2. Cherry-pick the upstream change when clean; otherwise reimplement the same + behavior against LastCode without dragging in unrelated upstream history. +3. Preserve any LastCode-specific adaptation only on this branch. +4. Run focused validation. A push invokes quick local CI. +5. When asked to open a PR, target `lastobelus/lastCode:lastcode/main` and link + the upstream PR in both descriptions. +6. Before merge, require a current-head clean Codex review, zero unresolved + review threads, and full local CI for the exact head and current base. Merge + through `pnpm lastcode:merge`. + +The LastCode PR does not wait for upstream acceptance. The upstream PR does not +depend on LastCode. When upstream later lands the change, let the nightly rebase +reconcile the duplicate patch and record any recurring resolution through the +existing checkpoint workflow. + +## Handoff + +Report both branch names and PR URLs, their bases and current heads, validation +performed for each, review status, and whether either delivery remains local. +Call out that an open LastCode PR pauses branch promotion while immutable nightly +checkpoint tags continue. diff --git a/.agents/skills/upstream-fix/agents/openai.yaml b/.agents/skills/upstream-fix/agents/openai.yaml new file mode 100644 index 000000000000..bb5e2e22e9da --- /dev/null +++ b/.agents/skills/upstream-fix/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Upstream Fix" + short_description: "Ship an upstream change while retaining it in LastCode" + default_prompt: "Use $upstream-fix to prepare an upstream T3 Code change and its independent LastCode port." diff --git a/.github/pr-assets/13-mobile-action-recovery.png b/.github/pr-assets/13-mobile-action-recovery.png new file mode 100644 index 000000000000..8c0f50d9bb6a Binary files /dev/null and b/.github/pr-assets/13-mobile-action-recovery.png differ diff --git a/.github/pr-assets/issue-19-local-build-progress-after-interaction.mp4 b/.github/pr-assets/issue-19-local-build-progress-after-interaction.mp4 new file mode 100644 index 000000000000..4098d52202d2 Binary files /dev/null and b/.github/pr-assets/issue-19-local-build-progress-after-interaction.mp4 differ diff --git a/.github/pr-assets/issue-19-local-build-progress-after.png b/.github/pr-assets/issue-19-local-build-progress-after.png new file mode 100644 index 000000000000..2261169643ac Binary files /dev/null and b/.github/pr-assets/issue-19-local-build-progress-after.png differ diff --git a/.github/pr-assets/issue-19-local-build-progress-before-interaction.mp4 b/.github/pr-assets/issue-19-local-build-progress-before-interaction.mp4 new file mode 100644 index 000000000000..4fc9d62e4203 Binary files /dev/null and b/.github/pr-assets/issue-19-local-build-progress-before-interaction.mp4 differ diff --git a/.github/pr-assets/issue-19-local-build-progress-before.png b/.github/pr-assets/issue-19-local-build-progress-before.png new file mode 100644 index 000000000000..13c67c7f72a0 Binary files /dev/null and b/.github/pr-assets/issue-19-local-build-progress-before.png differ diff --git a/.github/pr-assets/issue-19-local-build-progress-evidence.md b/.github/pr-assets/issue-19-local-build-progress-evidence.md new file mode 100644 index 000000000000..a39916575d93 --- /dev/null +++ b/.github/pr-assets/issue-19-local-build-progress-evidence.md @@ -0,0 +1,40 @@ +# Issue #19 update-progress UI evidence + +This paired evidence uses the real `SidebarUpdatePill`, tooltip primitives, and +production CSS in isolated Vite/Chromium harnesses. The harness bridge is +in-memory only; it did not start a backend, read or write live T3/LastCode +state, or launch the installed LastCode application. + +## Compared revisions + +- Before: merged transport/state PR #49 at + `8aad48d06d07bbb65bd610d63ce708629e677f1d`. +- After: local issue #19-C UI head at + `1f51cd716da7eb778427553c4a63dd16a2817b32`. + +Both surfaces received the same typed `DesktopUpdateState`: a retryable local +packaging failure at `Building DMG · 94% est.` with ANSI CSI, OSC hyperlink, +NUL, target/checkpoint/version, and build-log fixtures. + +## Evidence + +- `before.png`: the earlier generic blue update control and one-line tooltip. +- `after.png`: the destructive retry control and interactive alert panel with + phase/estimate, sanitized error, target, and Copy details action. +- `before-interaction.mp4` and `after-interaction.mp4`: browser recordings used + to derive the stills. + +The after-state clipboard was read back from Chromium after activating Copy +details. It contained installed/target versions, checkpoint, last phase and +estimate, packaging context, sanitized error, and exact log path. No CSI, OSC, +C0, or C1 control remained. Activating the red retry control also entered the +in-memory downloading/progress state and then returned to the deterministic +failure fixture. + +## Remaining packaged acceptance + +This proves the production web component behavior and supplies the PR's paired +visual evidence. It does not replace a real cold local nightly build in a +packaged Apple-Silicon LastCode app. That physical acceptance remains deferred; +the Intel host cannot enter the production arm64-only local-build path, and its +isolated Electron QA app lacked macOS assistive/screen-capture permission. diff --git a/.github/workflows/lastcode-intel-artifact.yml b/.github/workflows/lastcode-intel-artifact.yml new file mode 100644 index 000000000000..9d4d1686dbc3 --- /dev/null +++ b/.github/workflows/lastcode-intel-artifact.yml @@ -0,0 +1,368 @@ +name: LastCode Intel artifact + +on: + workflow_dispatch: + inputs: + installable_tag: + description: Exact lastcode/checkpoint or lastcode/revision tag + required: true + type: string + installable_commit: + description: Full commit SHA advertised for the installable tag + required: true + type: string + +permissions: + contents: read + +concurrency: + group: lastcode-intel-artifact-${{ inputs.installable_tag }} + cancel-in-progress: false + +jobs: + build: + name: Validate and build macOS x64 + runs-on: macos-15-intel + timeout-minutes: 90 + outputs: + needs_build: ${{ steps.release.outputs.needs_build }} + version: ${{ steps.target.outputs.version }} + env: + INSTALLABLE_TAG: ${{ inputs.installable_tag }} + INSTALLABLE_COMMIT: ${{ inputs.installable_commit }} + steps: + - name: Checkout workflow automation + uses: actions/checkout@v6 + with: + fetch-depth: 1 + path: automation + persist-credentials: false + ref: ${{ github.workflow_sha }} + sparse-checkout: | + /package.json + /scripts/lastcode-intel-release.mjs + sparse-checkout-cone-mode: false + + - name: Setup Node for release validation + uses: actions/setup-node@v6 + with: + node-version-file: automation/package.json + + - name: Require immutable GitHub Releases + working-directory: automation + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api -H "X-GitHub-Api-Version: 2026-03-10" \ + "repos/${GITHUB_REPOSITORY}/immutable-releases" > "$RUNNER_TEMP/immutable-releases.json" + node scripts/lastcode-intel-release.mjs validate-repository \ + --repository-json "$RUNNER_TEMP/immutable-releases.json" + + - name: Checkout exact installable + uses: actions/checkout@v6 + with: + fetch-depth: 0 + path: target + persist-credentials: false + ref: ${{ inputs.installable_commit }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Validate immutable tag and commit + id: target + shell: bash + working-directory: target + run: | + set -euo pipefail + + if [[ ! "$INSTALLABLE_TAG" =~ ^lastcode/(checkpoint|revision)/v[0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{8}\.[0-9]+(\.[0-9]+)?$ ]]; then + echo "Unsupported installable tag: $INSTALLABLE_TAG" >&2 + exit 1 + fi + if [[ ! "$INSTALLABLE_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + echo "installable_commit must be a full lowercase commit SHA." >&2 + exit 1 + fi + + git fetch --force --no-tags origin "refs/tags/${INSTALLABLE_TAG}:refs/tags/${INSTALLABLE_TAG}" + resolved_commit="$(git rev-parse "${INSTALLABLE_TAG}^{commit}")" + if [[ "$resolved_commit" != "$INSTALLABLE_COMMIT" ]]; then + echo "Tag/commit mismatch: expected $INSTALLABLE_COMMIT, resolved $resolved_commit" >&2 + exit 1 + fi + if [[ "$(git rev-parse HEAD)" != "$resolved_commit" ]]; then + echo "Checkout does not match validated installable commit." >&2 + exit 1 + fi + + version="${INSTALLABLE_TAG#lastcode/checkpoint/v}" + version="${version#lastcode/revision/v}" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Reuse a validated exact-tag release + id: release + shell: bash + working-directory: automation + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + encoded_tag="${INSTALLABLE_TAG//\//%2F}" + set +e + probe="$( + gh api --include --silent \ + "repos/${GITHUB_REPOSITORY}/releases/tags/${encoded_tag}" 2>&1 + )" + probe_exit=$? + set -e + http_status="$( + printf '%s\n' "$probe" | + awk '$1 ~ /^HTTP\// { status = $2 } END { print status }' + )" + + if [[ "$http_status" == "404" ]]; then + echo "needs_build=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [[ "$probe_exit" -ne 0 || "$http_status" != "200" ]]; then + printf '%s\n' "$probe" >&2 + echo "Could not determine whether the exact-tag release exists." >&2 + exit 1 + fi + + release_dir="$RUNNER_TEMP/existing-intel-release" + release_json="$RUNNER_TEMP/existing-intel-release.json" + mkdir -p "$release_dir" + gh release view "$INSTALLABLE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --json assets,isDraft,isImmutable,isPrerelease,tagName > "$release_json" + gh release download "$INSTALLABLE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --dir "$release_dir" + node scripts/lastcode-intel-release.mjs validate \ + --directory "$release_dir" \ + --tag "$INSTALLABLE_TAG" \ + --commit "$INSTALLABLE_COMMIT" \ + --release-json "$release_json" + echo "Exact-tag Intel release is already complete; skipping CI and packaging." + echo "needs_build=false" >> "$GITHUB_OUTPUT" + + - name: Setup Vite+ + if: steps.release.outputs.needs_build == 'true' + uses: voidzero-dev/setup-vp@v1 + with: + working-directory: target + node-version-file: package.json + cache: true + run-install: true + + - name: Setup Rust x64 target + if: steps.release.outputs.needs_build == 'true' + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + targets: x86_64-apple-darwin + + - name: Install mobile CI prerequisites + if: steps.release.outputs.needs_build == 'true' + working-directory: target + run: brew bundle install --file apps/mobile/Brewfile + + - name: Run full checkpoint gate + if: steps.release.outputs.needs_build == 'true' + working-directory: target + run: env -u ELECTRON_RUN_AS_NODE node scripts/lastcode-local-ci.ts --full --checkpoint "$INSTALLABLE_TAG" + + - name: Build certificate-free x64 DMG + if: steps.release.outputs.needs_build == 'true' + working-directory: target + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + T3CODE_DESKTOP_UPDATE_REPOSITORY: lastobelus/lastCode + run: >- + env -u ELECTRON_RUN_AS_NODE node scripts/lastcode-build-mac.ts + --arch x64 + --checkpoint "$INSTALLABLE_TAG" + --output-root release-lastcode + --verbose + + - name: Validate built exact-tag assets + id: artifact + if: steps.release.outputs.needs_build == 'true' + shell: bash + working-directory: target + run: | + set -euo pipefail + + short_commit="$(git rev-parse --short=10 "$INSTALLABLE_COMMIT")" + artifact_dir="release-lastcode/v${{ steps.target.outputs.version }}/${short_commit}" + node "$GITHUB_WORKSPACE/automation/scripts/lastcode-intel-release.mjs" validate \ + --directory "$artifact_dir" \ + --tag "$INSTALLABLE_TAG" \ + --commit "$INSTALLABLE_COMMIT" + echo "directory=$artifact_dir" >> "$GITHUB_OUTPUT" + + - name: Transfer validated assets to publisher + if: steps.release.outputs.needs_build == 'true' + uses: actions/upload-artifact@v7 + with: + name: intel-release + path: target/${{ steps.artifact.outputs.directory }}/* + if-no-files-found: error + retention-days: 1 + + publish: + name: Publish immutable exact-tag assets + needs: build + if: needs.build.outputs.needs_build == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + env: + INSTALLABLE_TAG: ${{ inputs.installable_tag }} + INSTALLABLE_COMMIT: ${{ inputs.installable_commit }} + steps: + - name: Checkout workflow automation + uses: actions/checkout@v6 + with: + fetch-depth: 1 + persist-credentials: false + ref: ${{ github.workflow_sha }} + sparse-checkout: | + /package.json + /scripts/lastcode-intel-release.mjs + sparse-checkout-cone-mode: false + + - name: Setup Node for release validation + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Require immutable GitHub Releases + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api -H "X-GitHub-Api-Version: 2026-03-10" \ + "repos/${GITHUB_REPOSITORY}/immutable-releases" > "$RUNNER_TEMP/immutable-releases.json" + node scripts/lastcode-intel-release.mjs validate-repository \ + --repository-json "$RUNNER_TEMP/immutable-releases.json" + + - name: Download isolated build assets + uses: actions/download-artifact@v8 + with: + name: intel-release + path: release + + - name: Validate isolated build assets + run: >- + node scripts/lastcode-intel-release.mjs validate + --directory release + --tag "$INSTALLABLE_TAG" + --commit "$INSTALLABLE_COMMIT" + + - name: Reuse a release created during the build + id: release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + encoded_tag="${INSTALLABLE_TAG//\//%2F}" + set +e + probe="$( + gh api --include --silent \ + "repos/${GITHUB_REPOSITORY}/releases/tags/${encoded_tag}" 2>&1 + )" + probe_exit=$? + set -e + http_status="$( + printf '%s\n' "$probe" | + awk '$1 ~ /^HTTP\// { status = $2 } END { print status }' + )" + + if [[ "$http_status" == "404" ]]; then + echo "needs_publish=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [[ "$probe_exit" -ne 0 || "$http_status" != "200" ]]; then + printf '%s\n' "$probe" >&2 + echo "Could not determine whether the exact-tag release exists." >&2 + exit 1 + fi + + release_dir="$RUNNER_TEMP/existing-intel-release" + release_json="$RUNNER_TEMP/existing-intel-release.json" + mkdir -p "$release_dir" + gh release view "$INSTALLABLE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --json assets,isDraft,isImmutable,isPrerelease,tagName > "$release_json" + gh release download "$INSTALLABLE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --dir "$release_dir" + node scripts/lastcode-intel-release.mjs validate \ + --directory "$release_dir" \ + --tag "$INSTALLABLE_TAG" \ + --commit "$INSTALLABLE_COMMIT" \ + --release-json "$release_json" + echo "Exact-tag Intel release was completed during the build; skipping publication." + echo "needs_publish=false" >> "$GITHUB_OUTPUT" + + - name: Revalidate immutable tag before publication + if: steps.release.outputs.needs_publish == 'true' + shell: bash + run: | + set -euo pipefail + + git fetch --force --no-tags origin \ + "refs/tags/${INSTALLABLE_TAG}:refs/tags/${INSTALLABLE_TAG}" + publish_commit="$(git rev-parse "${INSTALLABLE_TAG}^{commit}")" + if [[ "$publish_commit" != "$INSTALLABLE_COMMIT" ]]; then + echo "Tag moved during the build: expected $INSTALLABLE_COMMIT, resolved $publish_commit" >&2 + exit 1 + fi + + - name: Publish and verify exact-tag assets + if: steps.release.outputs.needs_publish == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + gh api -H "X-GitHub-Api-Version: 2026-03-10" \ + "repos/${GITHUB_REPOSITORY}/immutable-releases" > "$RUNNER_TEMP/immutable-releases.json" + node scripts/lastcode-intel-release.mjs validate-repository \ + --repository-json "$RUNNER_TEMP/immutable-releases.json" + + release_notes="$(printf '%s\n%s\n' \ + "Certificate-free LastCode macOS x64 artifacts for ${INSTALLABLE_TAG}." \ + "Exact commit: ${INSTALLABLE_COMMIT}")" + gh release create "$INSTALLABLE_TAG" release/* \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --prerelease \ + --latest=false \ + --title "LastCode Intel ${{ needs.build.outputs.version }}" \ + --notes "$release_notes" + + verify_dir="$RUNNER_TEMP/published-intel-release" + release_json="$RUNNER_TEMP/published-intel-release.json" + mkdir -p "$verify_dir" + gh release view "$INSTALLABLE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --json assets,isDraft,isImmutable,isPrerelease,tagName > "$release_json" + gh release download "$INSTALLABLE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --dir "$verify_dir" + node scripts/lastcode-intel-release.mjs validate \ + --directory "$verify_dir" \ + --tag "$INSTALLABLE_TAG" \ + --commit "$INSTALLABLE_COMMIT" \ + --release-json "$release_json" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6abd702bf889..261aee3db79f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,8 +5,6 @@ on: tags: - "v*.*.*" - "!v*-nightly.*" - schedule: - - cron: "0 */3 * * *" workflow_dispatch: inputs: channel: @@ -27,49 +25,8 @@ permissions: id-token: none jobs: - check_changes: - name: Check for changes since last nightly - if: github.event_name == 'schedule' - runs-on: blacksmith-8vcpu-ubuntu-2404 - outputs: - has_changes: ${{ steps.check.outputs.has_changes }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - id: check - name: Compare HEAD to last nightly tag - 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" - fi - 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: @@ -113,7 +70,7 @@ jobs: 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 + if [[ "${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 \ diff --git a/.gitignore b/.gitignore index 57262578a786..c8605ee64f6d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ packages/*/dist build/ .logs/ release/ +release-lastcode/ release-mock/ .t3 .idea/ @@ -26,6 +27,7 @@ squashfs-root/ .vercel .gstack/ .plans/ +/tmp/ dist-electron/ .electron-runtime/ .showcase/ diff --git a/.vite-hooks/pre-push b/.vite-hooks/pre-push new file mode 100755 index 000000000000..105c31fcd007 --- /dev/null +++ b/.vite-hooks/pre-push @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +git_local_env=$(git rev-parse --local-env-vars) || exit 1 +unset $git_local_env +unset git_local_env +vp run --workspace-root lastcode:ci:quick diff --git a/README.md b/README.md index 8ec101387f67..725a3015a315 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,42 @@ -# T3 Code +# LastCode + +LastCode is a personal fork of [T3 Code](https://github.com/pingdotgg/t3code). Thank you to T3 Code's creators and maintainers for building it in the open and making this fork possible. + +LastCode: + +- Tracks T3 Code nightly. +- Takes over the update UI and installs a daemon that checkpoints T3 Code nightly, then rebases LastCode's changes on top. +- Favors the legacy sidebar. We do not QA LastCode changes against the inbox sidebar, although fix pull requests are welcome. +- Adds experimental features that increase both the security-sensitive surface area and the risk that agents could delete or mangle data on your machine. These changes have not been exhaustively reviewed for those risks. They include letting tools wake up threads, planned support for threads talking to each other, URL schemes that make emitted links clickable, and integrations with personal tools used by the maintainer, such as Markover. + +Use LastCode with that additional risk in mind. For the smaller upstream surface and its supported release path, use [T3 Code](https://github.com/pingdotgg/t3code). + +## Install LastCode alongside T3 Code + +LastCode is currently a personal source-build workflow for Apple Silicon macOS, not a public binary distribution. It installs as `/Applications/LastCode.app` and keeps its bundle identity, application state (`~/.lastcode`), Electron profile, single-instance lock, and URL schemes separate from T3 Code. Both apps can therefore remain installed and run on the same Mac. + +The setup mirrors the maintainer's arrangement: a personal writable GitHub fork, a dedicated automation worktree, and a macOS daemon that rebases LastCode onto T3 Code nightlies at login and hourly. The daemon pushes checkpoint and revision tags, promotes `lastcode/main` when no LastCode pull request is open, and mirrors upstream `main`, so do not install it against an `origin` you do not intend to update. + +After installing Git, [GitHub CLI](https://cli.github.com/), [mise](https://mise.jdx.dev/), [Vite+](https://viteplus.dev/guide/), and [fzf](https://github.com/junegunn/fzf), fork this repository and run: + +```bash +git clone git@github.com:YOUR_GITHUB_USER/LastCode.git ~/projects/lastCode +cd ~/projects/lastCode +git switch lastcode/main +git remote add upstream https://github.com/pingdotgg/t3code.git +mise exec node@24.13.1 -- node scripts/lastcode-setup.mjs --enable-nightly-writes +``` + +When `lastcode-checkpoints --verbose` shows a ready installable, build and install the first app: + +```bash +lastcode-build +lastcode-install +``` + +The full setup guide explains the remote-write boundary, initial ad-hoc-signed build, settings import, runtime isolation, updater opt-in, and uninstall commands: [Set up LastCode alongside T3 Code](./docs/lastcode/setup.md). + +## About T3 Code T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). @@ -10,7 +48,7 @@ Nothing. We built T3 Code because we wanted the best possible development experi We wanted something performant, remote-ready, and truly open. If we ever go the wrong direction, we want you to have everything you need to fork and build the editor that you want. -## Installation +## Install T3 Code > [!WARNING] > T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use: diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 5c39ff304b3b..766a38a66147 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -4,7 +4,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as PlatformError from "effect/PlatformError"; import type * as Electron from "electron"; @@ -20,9 +19,9 @@ const defaultEnvironmentInput = { platform: "darwin", processArch: "arm64", appVersion: "1.2.3", - appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + appPath: "/Applications/LastCode.app/Contents/Resources/app.asar", isPackaged: true, - resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + resourcesPath: "/Applications/LastCode.app/Contents/Resources", runningUnderArm64Translation: false, } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; @@ -39,7 +38,7 @@ interface ElectronAppCalls { const makeElectronAppLayer = (calls: ElectronAppCalls) => Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), - name: Effect.succeed("T3 Code"), + name: Effect.succeed("LastCode"), systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, quit: Effect.void, @@ -108,8 +107,6 @@ const withIdentity = ( input: { readonly calls?: ElectronAppCalls; readonly environment?: TestEnvironmentInput; - readonly legacyPathExists?: boolean; - readonly legacyPathProbeError?: PlatformError.PlatformError; readonly packageJson?: string; readonly pngIconPath?: Option.Option; } = {}, @@ -125,12 +122,6 @@ const withIdentity = ( DesktopAppIdentity.layer.pipe( Layer.provideMerge( FileSystem.layerNoop({ - exists: (path) => - input.legacyPathProbeError - ? Effect.fail(input.legacyPathProbeError) - : Effect.succeed( - input.legacyPathExists === true && path.includes("T3 Code (Alpha)"), - ), readFileString: () => Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'), }), @@ -144,45 +135,17 @@ const withIdentity = ( }; describe("DesktopAppIdentity", () => { - it.effect("keeps using the legacy userData path when it already exists", () => + it.effect("uses the isolated LastCode userData path", () => withIdentity( Effect.gen(function* () { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; const userDataPath = yield* identity.resolveUserDataPath; - assert.equal(userDataPath, "/Users/alice/Library/Application Support/T3 Code (Alpha)"); + assert.equal(userDataPath, "/Users/alice/Library/Application Support/lastcode"); }), - { legacyPathExists: true }, ), ); - it.effect("preserves failures while inspecting the legacy userData path", () => { - const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Alpha)"; - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "exists", - description: "permission denied", - pathOrDescriptor: legacyPath, - }); - - return withIdentity( - Effect.gen(function* () { - const identity = yield* DesktopAppIdentity.DesktopAppIdentity; - const error = yield* identity.resolveUserDataPath.pipe(Effect.flip); - - assert.instanceOf(error, DesktopAppIdentity.DesktopUserDataPathResolutionError); - assert.equal(error.legacyPath, legacyPath); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - `Failed to inspect legacy desktop user-data path at "${legacyPath}".`, - ); - }), - { legacyPathProbeError: cause }, - ); - }); - it.effect("configures app identity from the environment commit override", () => { const calls: ElectronAppCalls = { setAboutPanelOptions: [], @@ -195,8 +158,8 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; yield* identity.configure; - assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]); - assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)"); + assert.deepEqual(calls.setName, ["LastCode (Alpha)"]); + assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "LastCode (Alpha)"); assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); // Packaged: the bundle's own icon stands, so a custom one the user diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index c5adb8574a53..1df3f57ea189 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -18,22 +18,10 @@ const AppPackageMetadata = Schema.Struct({ }); const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata)); -export class DesktopUserDataPathResolutionError extends Schema.TaggedErrorClass()( - "DesktopUserDataPathResolutionError", - { - legacyPath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to inspect legacy desktop user-data path at "${this.legacyPath}".`; - } -} - export class DesktopAppIdentity extends Context.Service< DesktopAppIdentity, { - readonly resolveUserDataPath: Effect.Effect; + readonly resolveUserDataPath: Effect.Effect; readonly configure: Effect.Effect; } >()("@t3tools/desktop/app/DesktopAppIdentity") {} @@ -47,23 +35,7 @@ const normalizeCommitHash = (value: string): Option.Option => { export const resolveUserDataPath = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const legacyPath = environment.path.join( - environment.appDataDirectory, - environment.legacyUserDataDirName, - ); - const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( - Effect.mapError( - (cause) => - new DesktopUserDataPathResolutionError({ - legacyPath, - cause, - }), - ), - ); - return legacyPathExists - ? legacyPath - : environment.path.join(environment.appDataDirectory, environment.userDataDirName); + return environment.path.join(environment.appDataDirectory, environment.userDataDirName); }).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); export const make = Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 2f61ca909aef..00ffe63138fa 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -34,8 +34,7 @@ const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { stateDir: "/tmp/t3-state", isDevelopment, appDataDirectory: "/tmp/app-data", - userDataDirName: isDevelopment ? "t3code-dev" : "t3code", - legacyUserDataDirName: isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)", + userDataDirName: isDevelopment ? "lastcode-dev" : "lastcode", path: { join: (...parts: ReadonlyArray) => parts.join("/") }, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); @@ -91,7 +90,7 @@ describe("DesktopClerk", () => { { storage: storageAdapter, passkeys: true, - renderer: { scheme: "t3code-dev", host: "app" }, + renderer: { scheme: "lastcode-dev", host: "app" }, }, ], ]); @@ -99,7 +98,10 @@ describe("DesktopClerk", () => { // The bridge acquires Electron's single-instance lock at creation, and // the lock both lives in and creates the userData directory — so the // real path must be set before the bridge exists. - assert.deepEqual(events, ["setPath:userData:/tmp/app-data/t3code-dev", "createClerkBridge"]); + assert.deepEqual(events, [ + "setPath:userData:/tmp/app-data/lastcode-dev", + "createClerkBridge", + ]); storageMock.mockClear(); createClerkBridgeMock.mockClear(); }); @@ -210,8 +212,8 @@ describe("DesktopClerk", () => { }); it.each([ - { isDevelopment: true, scheme: "t3code-dev" }, - { isDevelopment: false, scheme: "t3code" }, + { isDevelopment: true, scheme: "lastcode-dev" }, + { isDevelopment: false, scheme: "lastcode" }, ])("configures the SDK with the $scheme renderer origin", ({ isDevelopment, scheme }) => { const bridge = { cleanup: vi.fn(), isPrimaryInstance: true }; storageMock.mockReturnValue(storageAdapter); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 9611dc083d2f..a65e1872f6a3 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -87,12 +87,9 @@ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const electronApp = yield* ElectronApp.ElectronApp; - // Electron scopes the single-instance lock to the userData directory and - // creates that directory when the lock is acquired. The SDK bridge takes - // the lock at creation, so userData must already point at the real - // directory here — under the default productName-derived path, acquiring - // the lock would create "T3 Code (Alpha)" and make the legacy-install - // detection in resolveUserDataPath match on fresh installs. + // Electron scopes the single-instance lock to the userData directory. The + // SDK bridge takes the lock at creation, so point it at LastCode's isolated + // profile before creating the bridge. const userDataPath = yield* DesktopAppIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts index b7647b5cc10f..5baada964017 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -81,12 +81,12 @@ describe("DesktopEarlyElectronStartup", () => { }); assert.deepEqual(options, { - linuxWmClass: "t3code-dev", + linuxWmClass: "lastcode-dev", passwordStore: "gnome-libsecret", }); }); - it("keeps implicit development state under ~/.t3/dev when T3CODE_HOME is unset", () => { + it("keeps implicit development state under ~/.lastcode/dev when T3CODE_HOME is unset", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ env: { VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", @@ -94,7 +94,7 @@ describe("DesktopEarlyElectronStartup", () => { homeDirectory: "/home/user", joinPath, readFileString: (path) => { - assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); + assert.equal(path, "/home/user/.lastcode/dev/desktop-settings.json"); return JSON.stringify({ linuxPasswordStore: "kwallet" }); }, }); @@ -111,7 +111,7 @@ describe("DesktopEarlyElectronStartup", () => { homeDirectory: "/home/user", joinPath, readFileString: (path) => { - assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); + assert.equal(path, "/home/user/.lastcode/dev/desktop-settings.json"); return JSON.stringify({ linuxPasswordStore: "gnome-libsecret" }); }, }); diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts index 3e11d7961a9f..6285ea1b5c7c 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -1,4 +1,5 @@ import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { LASTCODE_DESKTOP_DISTRIBUTION } from "@t3tools/shared/desktopDistribution"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -81,7 +82,9 @@ export function resolveEarlyLinuxElectronOptions( ): EarlyLinuxElectronOptions { const preference = resolveEarlyLinuxPasswordStorePreference(input); return { - linuxWmClass: isDevelopmentEnvironment(input.env) ? "t3code-dev" : "t3code", + linuxWmClass: isDevelopmentEnvironment(input.env) + ? LASTCODE_DESKTOP_DISTRIBUTION.developmentExecutableName + : LASTCODE_DESKTOP_DISTRIBUTION.executableName, passwordStore: resolveLinuxPasswordStoreSwitch({ preference, env: input.env, diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 218e2c3e4ba2..25fd25ecad49 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -13,9 +13,9 @@ const defaultInput = { platform: "darwin", processArch: "arm64", appVersion: "0.0.22", - appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + appPath: "/Applications/LastCode.app/Contents/Resources/app.asar", isPackaged: false, - resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + resourcesPath: "/Applications/LastCode.app/Contents/Resources", runningUnderArm64Translation: false, } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; @@ -68,8 +68,10 @@ describe("DesktopEnvironment", () => { assert.equal(environment.serverRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); assert.equal(environment.backendCwd, "/repo"); - assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); - assert.equal(environment.linuxWmClass, "t3code-dev"); + assert.equal(environment.appUserModelId, "codes.lastobelus.lastcode.dev"); + assert.equal(environment.linuxWmClass, "lastcode-dev"); + assert.equal(environment.userDataDirName, "lastcode-dev"); + assert.equal(environment.displayName, "LastCode (Dev)"); assert.deepEqual( Option.map(environment.devServerUrl, (url) => url.href), Option.some("http://localhost:5173/"), @@ -125,8 +127,11 @@ describe("DesktopEnvironment", () => { ); const production = yield* makeEnvironment(); - assert.equal(development.stateDir, "/Users/alice/.t3/dev"); - assert.equal(production.stateDir, "/Users/alice/.t3/userdata"); + assert.equal(development.stateDir, "/Users/alice/.lastcode/dev"); + assert.equal(production.stateDir, "/Users/alice/.lastcode/userdata"); + assert.equal(production.userDataDirName, "lastcode"); + assert.equal(production.appUserModelId, "codes.lastobelus.lastcode"); + assert.equal(production.displayName, "LastCode (Alpha)"); }), ); @@ -135,12 +140,12 @@ describe("DesktopEnvironment", () => { const environment = yield* makeEnvironment( {}, { - T3CODE_DESKTOP_APP_USER_MODEL_ID: " com.t3tools.t3code.dev.local ", + T3CODE_DESKTOP_APP_USER_MODEL_ID: " codes.lastobelus.lastcode.dev.local ", VITE_DEV_SERVER_URL: "http://localhost:5173", }, ); - assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev.local"); + assert.equal(environment.appUserModelId, "codes.lastobelus.lastcode.dev.local"); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 4583e5124091..783a32d32e19 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -4,6 +4,7 @@ import type { DesktopRuntimeArch, DesktopRuntimeInfo, } from "@t3tools/contracts"; +import { LASTCODE_DESKTOP_DISTRIBUTION } from "@t3tools/shared/desktopDistribution"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -77,7 +78,6 @@ export class DesktopEnvironment extends Context.Service< readonly linuxApplicationsDir: string; readonly appImagePath: Option.Option; readonly userDataDirName: string; - readonly legacyUserDataDirName: string; readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings; readonly runtimeInfo: DesktopRuntimeInfo; readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option; @@ -85,8 +85,6 @@ export class DesktopEnvironment extends Context.Service< } >()("@t3tools/desktop/app/DesktopEnvironment") {} -const APP_BASE_NAME = "T3 Code"; - function resolveDesktopAppStageLabel(input: { readonly isDevelopment: boolean; readonly appVersion: string; @@ -104,9 +102,9 @@ function resolveDesktopAppBranding(input: { }): DesktopAppBranding { const stageLabel = resolveDesktopAppStageLabel(input); return { - baseName: APP_BASE_NAME, + baseName: LASTCODE_DESKTOP_DISTRIBUTION.productName, stageLabel, - displayName: `${APP_BASE_NAME} (${stageLabel})`, + displayName: `${LASTCODE_DESKTOP_DISTRIBUTION.productName} (${stageLabel})`, }; } @@ -178,8 +176,9 @@ const make = Effect.fn("desktop.environment.make")(function* ( joinPath: path.join, t3Home: config.t3Home, }); - const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; - const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; + const userDataDirName = isDevelopment + ? LASTCODE_DESKTOP_DISTRIBUTION.developmentUserDataDirName + : LASTCODE_DESKTOP_DISTRIBUTION.userDataDirName; const linuxApplicationsDir = path.join( Option.getOrElse(config.xdgDataHome, () => path.join(homeDirectory, ".local", "share")), "applications", @@ -224,14 +223,19 @@ const make = Effect.fn("desktop.environment.make")(function* ( branding, displayName, appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => - isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", + isDevelopment + ? LASTCODE_DESKTOP_DISTRIBUTION.developmentAppId + : LASTCODE_DESKTOP_DISTRIBUTION.appId, ), - linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", - linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", + linuxDesktopEntryName: isDevelopment + ? LASTCODE_DESKTOP_DISTRIBUTION.developmentLinuxDesktopEntryName + : LASTCODE_DESKTOP_DISTRIBUTION.linuxDesktopEntryName, + linuxWmClass: isDevelopment + ? LASTCODE_DESKTOP_DISTRIBUTION.developmentExecutableName + : LASTCODE_DESKTOP_DISTRIBUTION.executableName, linuxApplicationsDir, appImagePath: config.appImagePath, userDataDirName, - legacyUserDataDirName, defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), runtimeInfo: resolveDesktopRuntimeInfo({ platform: input.platform, diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index f5ff3d5f6af6..28abc5748109 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -7,6 +7,7 @@ import * as Ref from "effect/Ref"; import type * as Electron from "electron"; import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -80,6 +81,7 @@ function makeDesktopWindowLayer( input: { readonly activate?: Effect.Effect; readonly flushMainWindowBounds?: Effect.Effect; + readonly runningActionCount?: Effect.Effect; } = {}, ) { return Layer.succeed(DesktopWindow.DesktopWindow, { @@ -94,6 +96,10 @@ function makeDesktopWindowLayer( flushMainWindowBounds: input.flushMainWindowBounds ?? Effect.void, dispatchMenuAction: () => Effect.void, zoomMain: () => Effect.void, + runningActionCount: input.runningActionCount ?? Effect.succeed(0), + reportRunningActionCount: () => Effect.void, + acknowledgeRunningActionQuitWarning: Effect.void, + consumeRunningActionQuitWarningAcknowledgment: Effect.succeed(false), syncAppearance: Effect.void, }); } @@ -109,6 +115,7 @@ describe("DesktopLifecycle", () => { const layer = DesktopLifecycle.layer.pipe( Layer.provideMerge(makeElectronAppLayer(appListeners)), + Layer.provideMerge(ElectronDialog.layer), Layer.provideMerge(electronThemeLayer), Layer.provideMerge(makeElectronWindowLayer()), Layer.provideMerge(makeDesktopWindowLayer()), @@ -143,7 +150,6 @@ describe("DesktopLifecycle", () => { ).pipe(Effect.provide(layer)); }); } - it.effect("destroys windows before waiting for backend shutdown", () => Effect.gen(function* () { const appListeners = new Map void>(); @@ -179,6 +185,7 @@ describe("DesktopLifecycle", () => { const layer = DesktopLifecycle.layer.pipe( Layer.provideMerge(makeElectronAppLayer(appListeners, quit)), + Layer.provideMerge(ElectronDialog.layer), Layer.provideMerge(electronThemeLayer), Layer.provideMerge(makeElectronWindowLayer(destroyAll)), Layer.provideMerge(makeDesktopWindowLayer({ flushMainWindowBounds })), @@ -220,6 +227,7 @@ describe("DesktopLifecycle", () => { } as DesktopEnvironment.DesktopEnvironment["Service"]); const layer = DesktopLifecycle.layer.pipe( Layer.provideMerge(makeElectronAppLayer(appListeners)), + Layer.provideMerge(ElectronDialog.layer), Layer.provideMerge(electronThemeLayer), Layer.provideMerge(makeElectronWindowLayer()), Layer.provideMerge(makeDesktopWindowLayer({ activate })), @@ -242,4 +250,142 @@ describe("DesktopLifecycle", () => { ).pipe(Effect.provide(layer)); }), ); + it.effect("warns with the running Action count before quitting", () => + Effect.gen(function* () { + const appListeners = new Map void>(); + const shownOptions = yield* Deferred.make(); + const dialogResponse = yield* Deferred.make(); + const quitCalled = yield* Deferred.make(); + + const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + name: Effect.succeed("T3 Code"), + systemLocale: Effect.succeed("en-US"), + whenReady: Effect.void, + quit: Deferred.succeed(quitCalled, undefined).pipe(Effect.asVoid), + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + getAppMetrics: Effect.succeed([]), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: () => Effect.succeed(true), + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: (listener) => + Effect.acquireRelease( + Effect.sync(() => { + appListeners.set("before-quit-for-update", listener); + }), + () => + Effect.sync(() => { + appListeners.delete("before-quit-for-update"); + }), + ).pipe(Effect.asVoid), + on: (eventName, listener) => + Effect.acquireRelease( + Effect.sync(() => { + appListeners.set( + eventName, + listener as unknown as (...args: readonly unknown[]) => void, + ); + }), + () => + Effect.sync(() => { + appListeners.delete(eventName); + }), + ).pipe(Effect.asVoid), + } satisfies ElectronApp.ElectronApp["Service"]); + + const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { + pickFolder: () => Effect.die("unexpected folder picker"), + pickFiles: () => Effect.die("unexpected file picker"), + showMessageBox: (options) => + Deferred.succeed(shownOptions, options).pipe( + Effect.andThen(Deferred.await(dialogResponse)), + ), + showErrorBox: () => Effect.die("unexpected error dialog"), + }); + + const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected window creation"), + ensureMain: Effect.die("unexpected window creation"), + revealOrCreateMain: Effect.die("unexpected window creation"), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, + dispatchMenuAction: () => Effect.void, + zoomMain: () => Effect.void, + runningActionCount: Effect.succeed(3), + reportRunningActionCount: () => Effect.void, + acknowledgeRunningActionQuitWarning: Effect.void, + consumeRunningActionQuitWarningAcknowledgment: Effect.succeed(false), + syncAppearance: Effect.void, + }); + + const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: "darwin", + isDevelopment: false, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + + const layer = DesktopLifecycle.layer.pipe( + Layer.provideMerge(electronAppLayer), + Layer.provideMerge(electronDialogLayer), + Layer.provideMerge(makeElectronWindowLayer()), + Layer.provideMerge( + Layer.succeed(ElectronTheme.ElectronTheme, { + shouldUseDarkColors: Effect.succeed(false), + setSource: () => Effect.void, + onUpdated: () => Effect.void, + }), + ), + Layer.provideMerge(desktopWindowLayer), + Layer.provideMerge(environmentLayer), + Layer.provideMerge( + Layer.succeed(DesktopShutdown.DesktopShutdown, { + request: Effect.void, + awaitRequest: Effect.void, + markComplete: Effect.void, + awaitComplete: Effect.void, + isComplete: Effect.succeed(true), + }), + ), + Layer.provideMerge(DesktopState.layer), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + let prevented = false; + appListeners.get("before-quit")?.({ + preventDefault: () => { + prevented = true; + }, + } as Electron.Event); + + const options = yield* Deferred.await(shownOptions); + assert.isTrue(prevented); + assert.equal(options.title, "Running Actions"); + assert.equal(options.message, "Quit and cancel 3 running Actions?"); + assert.equal(options.detail, "The commands will not be restarted automatically."); + assert.deepEqual(options.buttons, ["Quit and cancel", "Keep running"]); + + yield* Deferred.succeed(dialogResponse, { + response: 0, + checkboxChecked: false, + }); + yield* Deferred.await(quitCalled); + }), + ).pipe(Effect.provide(layer)); + }), + ); }); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 6a98e59eb870..1a1b1e23b768 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -11,6 +11,7 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopState from "./DesktopState.ts"; @@ -34,6 +35,7 @@ export type DesktopLifecycleRuntimeServices = | DesktopState.DesktopState | DesktopWindow.DesktopWindow | ElectronApp.ElectronApp + | ElectronDialog.ElectronDialog | ElectronTheme.ElectronTheme; type DesktopLifecycleRegistrationServices = @@ -41,7 +43,7 @@ type DesktopLifecycleRegistrationServices = | ElectronWindow.ElectronWindow; /** - * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronTheme | ElectronWindow + * @effect-expect-leaking DesktopEnvironment | DesktopShutdown | DesktopState | DesktopWindow | ElectronApp | ElectronDialog | ElectronTheme | ElectronWindow */ export class DesktopLifecycle extends Context.Service< DesktopLifecycle, @@ -101,6 +103,8 @@ function handleBeforeQuit( ) => Promise, allowQuit: () => boolean, markQuitAllowed: () => void, + isQuitRequestPending: () => boolean, + setQuitRequestPending: (pending: boolean) => void, ): void { if (allowQuit()) { void runEffect( @@ -114,8 +118,28 @@ function handleBeforeQuit( } event.preventDefault(); + if (isQuitRequestPending()) return; + setQuitRequestPending(true); void runEffect( Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const warningAcknowledged = + yield* desktopWindow.consumeRunningActionQuitWarningAcknowledgment; + const runningActionCount = yield* desktopWindow.runningActionCount; + if (runningActionCount > 0 && !warningAcknowledged) { + const dialog = yield* ElectronDialog.ElectronDialog; + const result = yield* dialog.showMessageBox({ + type: "warning", + title: "Running Actions", + message: `Quit and cancel ${runningActionCount} running ${runningActionCount === 1 ? "Action" : "Actions"}?`, + detail: "The commands will not be restarted automatically.", + buttons: ["Quit and cancel", "Keep running"], + defaultId: 1, + cancelId: 1, + noLink: true, + }); + if (result.response !== 0) return false; + } const state = yield* DesktopState.DesktopState; const electronWindow = yield* ElectronWindow.ElectronWindow; yield* Ref.set(state.quitting, true); @@ -127,16 +151,21 @@ function handleBeforeQuit( ), ), ); + return true; }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), - ).finally(() => { - markQuitAllowed(); - void runEffect( - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - yield* electronApp.quit; - }).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")), - ); - }); + ) + .then((shouldQuit) => { + if (!shouldQuit) return; + markQuitAllowed(); + void runEffect( + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.quit; + }).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")), + ); + }) + .catch(() => undefined) + .finally(() => setQuitRequestPending(false)); } function quitFromSignal( @@ -144,6 +173,7 @@ function quitFromSignal( runEffect: ( effect: Effect.Effect, ) => Promise, + markQuitAllowed: () => void, ): void { void runEffect( Effect.gen(function* () { @@ -154,6 +184,7 @@ function quitFromSignal( if (wasQuitting) return; yield* logLifecycleInfo("process signal received", { signal }); yield* requestDesktopShutdownAndWait(); + markQuitAllowed(); yield* electronApp.quit; }).pipe(Effect.withSpan("desktop.lifecycle.processSignal")), ); @@ -195,6 +226,7 @@ export const make = DesktopLifecycle.of({ const context = yield* Effect.context(); const runEffect = Effect.runPromiseWith(context); let quitAllowed = false; + let quitRequestPending = false; let updaterQuitAllowed = false; yield* electronTheme.onUpdated(() => { void runEffect( @@ -220,6 +252,10 @@ export const make = DesktopLifecycle.of({ () => { quitAllowed = true; }, + () => quitRequestPending, + (pending) => { + quitRequestPending = pending; + }, ); }); yield* electronApp.on("activate", () => { @@ -245,10 +281,14 @@ export const make = DesktopLifecycle.of({ if (environment.platform !== "win32") { yield* addScopedListener(process, "SIGINT", () => { - quitFromSignal("SIGINT", runEffect); + quitFromSignal("SIGINT", runEffect, () => { + quitAllowed = true; + }); }); yield* addScopedListener(process, "SIGTERM", () => { - quitFromSignal("SIGTERM", runEffect); + quitFromSignal("SIGTERM", runEffect, () => { + quitAllowed = true; + }); }); } }).pipe(Effect.withSpan("desktop.lifecycle.register")), diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 30183808a152..1630ff2c74f4 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -22,10 +22,10 @@ const makeEnvironment = (overrides: Record = {}) => platform: "linux", isPackaged: true, isDevelopment: false, - displayName: "T3 Code (Alpha)", - linuxWmClass: "t3code", + displayName: "LastCode (Alpha)", + linuxWmClass: "lastcode", linuxApplicationsDir: "/home/alice/.local/share/applications", - appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"), + appImagePath: Option.some("/home/alice/Applications/LastCode.AppImage"), path: { join: (...parts: ReadonlyArray) => parts.join("/") }, ...overrides, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); @@ -105,49 +105,49 @@ const emptyRecording = (): RecordedRegistration => ({ describe("DesktopLinuxUrlHandler", () => { it("renders a scheme-handler desktop entry with freedesktop Exec quoting", () => { const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ - displayName: "T3 Code (Nightly)", - execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage', - scheme: "t3code", + displayName: "LastCode (Nightly)", + execTarget: '/home/al ice/Apps/LastCode "100%" $HOME\\x.AppImage', + scheme: "lastcode", }); assert.include(entry, "[Desktop Entry]"); - assert.include(entry, "Name=T3 Code (Nightly)"); + assert.include(entry, "Name=LastCode (Nightly)"); // Exec composes both escaping layers: a literal backslash becomes four // backslashes in the file, a quote three characters, a dollar sign two // backslashes plus the sign. assert.include( entry, - 'Exec="/home/al ice/Apps/T3 \\\\"100%%\\\\" \\\\$HOME\\\\\\\\x.AppImage" %U', + 'Exec="/home/al ice/Apps/LastCode \\\\"100%%\\\\" \\\\$HOME\\\\\\\\x.AppImage" %U', ); assert.include(entry, "NoDisplay=true"); assert.notInclude(entry, "StartupWMClass="); - assert.include(entry, "MimeType=x-scheme-handler/t3code;"); + assert.include(entry, "MimeType=x-scheme-handler/lastcode;"); }); it("carries structured context on registration errors", () => { const writeError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ step: "write-desktop-entry", - scheme: "t3code", - desktopEntryPath: "/home/alice/.local/share/applications/t3code-url-handler.desktop", + scheme: "lastcode", + desktopEntryPath: "/home/alice/.local/share/applications/lastcode-url-handler.desktop", cause: new Error("boom"), }); assert.equal( writeError.message, - "Failed to register the t3code:// URL handler (step: write-desktop-entry).", + "Failed to register the lastcode:// URL handler (step: write-desktop-entry).", ); assert.equal( writeError.desktopEntryPath, - "/home/alice/.local/share/applications/t3code-url-handler.desktop", + "/home/alice/.local/share/applications/lastcode-url-handler.desktop", ); const exitError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ step: "set-default-handler", - scheme: "t3code", + scheme: "lastcode", exitCode: 4, }); assert.equal( exitError.message, - "Failed to register the t3code:// URL handler (step: set-default-handler, xdg-mime exit code 4).", + "Failed to register the lastcode:// URL handler (step: set-default-handler, xdg-mime exit code 4).", ); }); @@ -161,17 +161,17 @@ describe("DesktopLinuxUrlHandler", () => { assert.equal(recorded.files.length, 1); assert.equal( recorded.files[0]?.path, - "/home/alice/.local/share/applications/t3code-url-handler.desktop", + "/home/alice/.local/share/applications/lastcode-url-handler.desktop", ); assert.include( recorded.files[0]?.content, - 'Exec="/home/alice/Applications/T3-Code.AppImage" %U', + 'Exec="/home/alice/Applications/LastCode.AppImage" %U', ); - assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;"); + assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/lastcode;"); assert.deepEqual(recorded.commands, [ { command: "xdg-mime", - args: ["default", "t3code-url-handler.desktop", "x-scheme-handler/t3code"], + args: ["default", "lastcode-url-handler.desktop", "x-scheme-handler/lastcode"], }, ]); }); @@ -218,7 +218,7 @@ describe("DesktopLinuxUrlHandler", () => { module: "FileSystem", method: "writeFileString", description: "read-only filesystem", - pathOrDescriptor: "/home/alice/.local/share/applications/t3code-url-handler.desktop", + pathOrDescriptor: "/home/alice/.local/share/applications/lastcode-url-handler.desktop", }), }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index e531a54dfce6..64fe2985e709 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { LASTCODE_DESKTOP_DISTRIBUTION } from "@t3tools/shared/desktopDistribution"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -20,7 +21,8 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; // our own handler entry pointing at the current AppImage and claim the // scheme default via xdg-mime, exactly what the file manager's "set as // default" checkbox would record in mimeapps.list. -export const URL_HANDLER_DESKTOP_ENTRY_NAME = "t3code-url-handler.desktop"; +export const URL_HANDLER_DESKTOP_ENTRY_NAME = + LASTCODE_DESKTOP_DISTRIBUTION.linuxUrlHandlerDesktopEntryName; const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); diff --git a/apps/desktop/src/app/DesktopStatePaths.ts b/apps/desktop/src/app/DesktopStatePaths.ts index 006dd97092d4..282a4ea760cc 100644 --- a/apps/desktop/src/app/DesktopStatePaths.ts +++ b/apps/desktop/src/app/DesktopStatePaths.ts @@ -1,5 +1,7 @@ import * as Option from "effect/Option"; +import { LASTCODE_DESKTOP_DISTRIBUTION } from "@t3tools/shared/desktopDistribution"; + export type JoinPath = (first: string, ...segments: string[]) => string; function normalizeConfiguredBaseDir(t3Home: Option.Option): Option.Option { @@ -16,7 +18,7 @@ export function resolveDesktopBaseDir(input: { readonly t3Home: Option.Option; }): string { return Option.getOrElse(normalizeConfiguredBaseDir(input.t3Home), () => - input.joinPath(input.homeDirectory, ".t3"), + input.joinPath(input.homeDirectory, LASTCODE_DESKTOP_DISTRIBUTION.defaultHomeDirName), ); } diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 98bd4065fbee..799c0c1ae1e5 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -92,6 +92,10 @@ function makePoolLayer( flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), zoomMain: () => Effect.die("unexpected zoom"), + runningActionCount: Effect.succeed(0), + reportRunningActionCount: () => Effect.void, + acknowledgeRunningActionQuitWarning: Effect.void, + consumeRunningActionQuitWarningAcknowledgment: Effect.succeed(false), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), ), diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d1..12bd30fa1c69 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -254,6 +254,7 @@ describe("DesktopServerExposure", () => { setServerExposureMode: () => Effect.fail(settingsFailure), setTailscaleServe: () => Effect.fail(settingsFailure), setUpdateChannel: () => Effect.die("unexpected update channel change"), + setShowAndInstallLocalNightlies: () => Effect.die("unexpected local nightly toggle"), setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 2db85dafc4da..31050b4f1531 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -35,7 +35,7 @@ describe("ElectronProtocol", () => { Effect.gen(function* () { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ - scheme: "t3code-dev", + scheme: "lastcode-dev", targetOrigin: new URL("http://127.0.0.1:3773/"), backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: "clerk.t3.codes", @@ -44,11 +44,11 @@ describe("ElectronProtocol", () => { const response = yield* Effect.promise(() => handler!( - new Request("t3code-dev://app/api/health?verbose=1", { + new Request("lastcode-dev://app/api/health?verbose=1", { headers: { accept: "application/json", - origin: "t3code-dev://app", - referer: "t3code-dev://app/", + origin: "lastcode-dev://app", + referer: "lastcode-dev://app/", "sec-fetch-site": "same-origin", }, }), @@ -65,18 +65,18 @@ describe("ElectronProtocol", () => { ); assert.include( response.headers.get("content-security-policy") ?? "", - "img-src 'self' t3code-dev: blob: data: http: https:", + "img-src 'self' lastcode-dev: blob: data: http: https:", ); assert.include( response.headers.get("content-security-policy") ?? "", - "font-src 'self' t3code-dev: data:", + "font-src 'self' lastcode-dev: data:", ); }), ); assert.deepEqual( handleMock.mock.calls.map((call) => call[0]), - ["t3code-dev"], + ["lastcode-dev"], ); assert.equal(netFetchMock.mock.calls[0]?.[0], "http://127.0.0.1:3773/api/health?verbose=1"); const forwardedHeaders = new Headers(netFetchMock.mock.calls[0]?.[1]?.headers); @@ -84,7 +84,7 @@ describe("ElectronProtocol", () => { assert.isNull(forwardedHeaders.get("origin")); assert.isNull(forwardedHeaders.get("referer")); assert.isNull(forwardedHeaders.get("sec-fetch-site")); - assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]); + assert.deepEqual(unhandleMock.mock.calls, [["lastcode-dev"]]); }).pipe(Effect.provide(ElectronProtocol.layer)), ); @@ -99,12 +99,12 @@ describe("ElectronProtocol", () => { Effect.gen(function* () { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ - scheme: "t3code", + scheme: "lastcode", targetOrigin: new URL("http://127.0.0.1:3773/"), backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); - return yield* Effect.promise(() => handler!(new Request("t3code://other/"))); + return yield* Effect.promise(() => handler!(new Request("lastcode://other/"))); }), ); @@ -127,12 +127,12 @@ describe("ElectronProtocol", () => { Effect.gen(function* () { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ - scheme: "t3code-dev", + scheme: "lastcode-dev", targetOrigin: new URL("http://127.0.0.1:5733/"), backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); - return yield* Effect.promise(() => handler!(new Request("t3code-dev://app/"))); + return yield* Effect.promise(() => handler!(new Request("lastcode-dev://app/"))); }), ); @@ -151,7 +151,7 @@ describe("ElectronProtocol", () => { const protocol = yield* ElectronProtocol.ElectronProtocol; const error = yield* Effect.scoped( protocol.registerDesktopProtocol({ - scheme: "t3code-dev", + scheme: "lastcode-dev", targetOrigin: new URL("http://127.0.0.1:3773/"), backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: undefined, @@ -159,9 +159,9 @@ describe("ElectronProtocol", () => { ).pipe(Effect.flip); assert.instanceOf(error, ElectronProtocol.ElectronProtocolRegistrationError); - assert.equal(error.scheme, "t3code-dev"); + assert.equal(error.scheme, "lastcode-dev"); assert.strictEqual(error.cause, cause); - assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".'); + assert.equal(error.message, 'Failed to register Electron protocol scheme "lastcode-dev".'); }).pipe(Effect.provide(ElectronProtocol.layer)), ); @@ -176,7 +176,7 @@ describe("ElectronProtocol", () => { const exit = yield* Effect.exit( Effect.scoped( protocol.registerDesktopProtocol({ - scheme: "t3code", + scheme: "lastcode", targetOrigin: new URL("http://127.0.0.1:3773/"), backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, @@ -188,16 +188,16 @@ describe("ElectronProtocol", () => { if (exit._tag === "Failure") { const error = Cause.squash(exit.cause); assert.instanceOf(error, ElectronProtocol.ElectronProtocolUnregistrationError); - assert.equal(error.scheme, "t3code"); + assert.equal(error.scheme, "lastcode"); assert.strictEqual(error.cause, cause); - assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".'); + assert.equal(error.message, 'Failed to unregister Electron protocol scheme "lastcode".'); } }).pipe(Effect.provide(ElectronProtocol.layer)), ); it("keeps executable sources host-restricted while allowing runtime network resources", () => { const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ - scheme: "t3code", + scheme: "lastcode", targetOrigin: new URL("http://127.0.0.1:3773/"), backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: "clerk.t3.codes", @@ -219,12 +219,12 @@ describe("ElectronProtocol", () => { assert.deepEqual(directives["connect-src"], ["'self'", "http:", "https:", "ws:", "wss:"]); assert.deepEqual(directives["img-src"], [ "'self'", - "t3code:", + "lastcode:", "blob:", "data:", "http:", "https:", ]); - assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); + assert.deepEqual(directives["font-src"], ["'self'", "lastcode:", "data:"]); }); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 11459c9ef7a8..4f68eabc06fd 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -8,9 +8,11 @@ import * as Scope from "effect/Scope"; import * as Electron from "electron"; +import { LASTCODE_DESKTOP_DISTRIBUTION } from "@t3tools/shared/desktopDistribution"; + export const DESKTOP_HOST = "app"; -export const DESKTOP_PRODUCTION_SCHEME = "t3code"; -export const DESKTOP_DEVELOPMENT_SCHEME = "t3code-dev"; +export const DESKTOP_PRODUCTION_SCHEME = LASTCODE_DESKTOP_DISTRIBUTION.productionScheme; +export const DESKTOP_DEVELOPMENT_SCHEME = LASTCODE_DESKTOP_DISTRIBUTION.developmentScheme; export function getDesktopScheme(isDevelopment: boolean): string { return isDevelopment ? DESKTOP_DEVELOPMENT_SCHEME : DESKTOP_PRODUCTION_SCHEME; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 8e8317db7971..12898b75a8c5 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -13,6 +13,7 @@ import { setServerExposureMode, setTailscaleServeEnabled, } from "./methods/serverExposure.ts"; +import { importT3Settings, previewT3SettingsImport } from "./methods/lastCodeSettings.ts"; import { bootstrapSshBearerSession, disconnectSshEnvironment, @@ -26,9 +27,11 @@ import { import { checkForUpdate, downloadUpdate, + getLastCodeSettings, getUpdateState, installUpdate, setUpdateChannel, + setShowAndInstallLocalNightlies, } from "./methods/updates.ts"; import { getAppBranding, @@ -38,6 +41,7 @@ import { getWindowFullscreenState, openExternal, probeRemoteEditors, + reportRunningActionCount, pickFolder, pickProjectFavicon, pickThemeFiles, @@ -56,6 +60,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getLocalEnvironmentBearerToken); + yield* ipc.handle(reportRunningActionCount); yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); @@ -90,6 +95,10 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(openExternal); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); + yield* ipc.handle(getLastCodeSettings); + yield* ipc.handle(setShowAndInstallLocalNightlies); + yield* ipc.handle(previewT3SettingsImport); + yield* ipc.handle(importT3Settings); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); yield* ipc.handle(installUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index c4ef82ec8cb7..1c2fc7ccced5 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -7,6 +7,7 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; +export const REPORT_RUNNING_ACTION_COUNT_CHANNEL = "desktop:report-running-action-count"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; @@ -15,6 +16,11 @@ export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; +export const LASTCODE_SETTINGS_GET_CHANNEL = "desktop:lastcode-settings-get"; +export const LASTCODE_SETTINGS_SET_LOCAL_NIGHTLIES_CHANNEL = + "desktop:lastcode-settings-set-local-nightlies"; +export const LASTCODE_SETTINGS_IMPORT_PREVIEW_CHANNEL = "desktop:lastcode-settings-import-preview"; +export const LASTCODE_SETTINGS_IMPORT_CHANNEL = "desktop:lastcode-settings-import"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; diff --git a/apps/desktop/src/ipc/methods/lastCodeSettings.ts b/apps/desktop/src/ipc/methods/lastCodeSettings.ts new file mode 100644 index 000000000000..f84f6c6f0c78 --- /dev/null +++ b/apps/desktop/src/ipc/methods/lastCodeSettings.ts @@ -0,0 +1,75 @@ +import { + LastCodeSettingsImportPreviewSchema, + LastCodeSettingsImportResultSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import { + importT3Settings as importT3SettingsFiles, + isT3SettingsImportSupported, + previewT3SettingsImport as previewT3SettingsImportFiles, + type LastCodeSettingsImportPaths, +} from "../../settings/LastCodeSettingsImport.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +function resolveImportPaths( + environment: DesktopEnvironment.DesktopEnvironment["Service"], +): LastCodeSettingsImportPaths { + return { + sourceDirectory: environment.path.join(environment.homeDirectory, ".t3", "userdata"), + destinationDirectory: environment.stateDir, + backupRootDirectory: environment.path.join(environment.baseDir, "settings-import-backups"), + }; +} + +const WSL_ONLY_IMPORT_MESSAGE = + "Import is unavailable while WSL-only mode is selected. Disable WSL-only mode before importing the Windows profile."; + +class LastCodeSettingsImportUnavailableError extends Schema.TaggedErrorClass()( + "LastCodeSettingsImportUnavailableError", + { reason: Schema.String }, +) { + override get message(): string { + return this.reason; + } +} + +export const previewT3SettingsImport = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.LASTCODE_SETTINGS_IMPORT_PREVIEW_CHANNEL, + payload: Schema.Void, + result: LastCodeSettingsImportPreviewSchema, + handler: Effect.fn("desktop.ipc.lastCodeSettings.previewImport")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const preview = yield* Effect.tryPromise(() => + previewT3SettingsImportFiles(resolveImportPaths(environment)), + ); + return !isT3SettingsImportSupported(environment.platform, (yield* appSettings.get).wslOnly) + ? { ...preview, canImport: false, message: WSL_ONLY_IMPORT_MESSAGE } + : preview; + }), +}); + +export const importT3Settings = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.LASTCODE_SETTINGS_IMPORT_CHANNEL, + payload: Schema.Void, + result: LastCodeSettingsImportResultSchema, + handler: Effect.fn("desktop.ipc.lastCodeSettings.import")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + if (!isT3SettingsImportSupported(environment.platform, (yield* appSettings.get).wslOnly)) { + return yield* new LastCodeSettingsImportUnavailableError({ reason: WSL_ONLY_IMPORT_MESSAGE }); + } + const result = yield* Effect.tryPromise(() => + importT3SettingsFiles(resolveImportPaths(environment)), + ); + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.relaunch("t3-settings-imported"); + return result; + }), +}); diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts index b2212609030d..90e2faae869a 100644 --- a/apps/desktop/src/ipc/methods/updates.ts +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -3,6 +3,7 @@ import { DesktopUpdateChannelSchema, DesktopUpdateCheckResultSchema, DesktopUpdateStateSchema, + DesktopLastCodeSettingsStateSchema, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -21,6 +22,26 @@ export const getUpdateState = DesktopIpc.makeIpcMethod({ }), }); +export const getLastCodeSettings = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.LASTCODE_SETTINGS_GET_CHANNEL, + payload: Schema.Void, + result: DesktopLastCodeSettingsStateSchema, + handler: Effect.fn("desktop.ipc.updates.getLastCodeSettings")(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.getLastCodeSettings; + }), +}); + +export const setShowAndInstallLocalNightlies = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.LASTCODE_SETTINGS_SET_LOCAL_NIGHTLIES_CHANNEL, + payload: Schema.Boolean, + result: DesktopLastCodeSettingsStateSchema, + handler: Effect.fn("desktop.ipc.updates.setShowAndInstallLocalNightlies")(function* (enabled) { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.setShowAndInstallLocalNightlies(enabled); + }), +}); + export const setUpdateChannel = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, payload: DesktopUpdateChannelSchema, diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index edae8394302c..18f4570cc8c1 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -27,6 +27,7 @@ import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronMenu from "../../electron/ElectronMenu.ts"; @@ -146,6 +147,16 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ }), }); +export const reportRunningActionCount = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.REPORT_RUNNING_ACTION_COUNT_CHANNEL, + payload: Schema.Number, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.reportRunningActionCount")(function* (count) { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.reportRunningActionCount(count); + }), +}); + // Pull the distro selection out of a backend instance id like // "wsl:ubuntu". Returns null for "wsl:default", which is the sentinel // for "track the user's WSL default distro" and maps to the diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 14caeed8a9a1..a5e2132d4530 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -57,6 +57,7 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; +import * as LastCodeLocalUpdates from "./updates/LastCodeLocalUpdates.ts"; import * as BrowserSession from "./preview/BrowserSession.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; @@ -190,6 +191,7 @@ const desktopApplicationLayer = Layer.mergeAll( desktopSshLayer, ).pipe( Layer.provideMerge(DesktopUpdates.layer), + Layer.provideMerge(LastCodeLocalUpdates.layer), Layer.provideMerge(desktopWslBackendLayer), Layer.provideMerge(desktopLocalEnvironmentAuthLayer), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 407c7c3ef498..4d4a25d3b368 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -46,6 +46,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + reportRunningActionCount: (count) => + ipcRenderer.invoke(IpcChannels.REPORT_RUNNING_ACTION_COUNT_CHANNEL, count), getLocalEnvironmentBearerToken: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), @@ -124,9 +126,13 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, onQuitShortcut: (listener) => { - const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { + const wrappedListener = ( + _event: Electron.IpcRendererEvent, + state: unknown, + runningActionCount: unknown, + ) => { if (state !== "down" && state !== "up") return; - listener(state); + listener(state, typeof runningActionCount === "number" ? runningActionCount : 0); }; ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); @@ -148,6 +154,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), + getLastCodeSettings: () => ipcRenderer.invoke(IpcChannels.LASTCODE_SETTINGS_GET_CHANNEL), + setShowAndInstallLocalNightlies: (enabled) => + ipcRenderer.invoke(IpcChannels.LASTCODE_SETTINGS_SET_LOCAL_NIGHTLIES_CHANNEL, enabled), + previewT3SettingsImport: () => + ipcRenderer.invoke(IpcChannels.LASTCODE_SETTINGS_IMPORT_PREVIEW_CHANNEL), + importT3Settings: () => ipcRenderer.invoke(IpcChannels.LASTCODE_SETTINGS_IMPORT_CHANNEL), setUpdateChannel: (channel) => ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 64c59749abe9..618e6a2d1087 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -30,6 +30,7 @@ const DesktopSettingsPatch = Schema.Struct({ tailscaleServePort: Schema.optionalKey(Schema.Number), updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])), updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), + showAndInstallLocalNightlies: Schema.optionalKey(Schema.Boolean), wslBackendEnabled: Schema.optionalKey(Schema.Boolean), wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), @@ -113,11 +114,16 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: false, + showAndInstallLocalNightlies: false, wslBackendEnabled: false, wslOnly: false, wslDistro: null, } satisfies DesktopAppSettings.DesktopSettings, ); + assert.equal( + DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1.2").updateChannel, + "nightly", + ); }); it.effect("loads persisted settings and applies semantic updates", () => @@ -131,6 +137,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: true, + showAndInstallLocalNightlies: false, }); assert.deepEqual(yield* settings.load, { @@ -142,6 +149,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: true, + showAndInstallLocalNightlies: false, wslBackendEnabled: false, wslOnly: false, wslDistro: null, @@ -162,6 +170,11 @@ describe("DesktopSettings", () => { assert.isTrue(updateChannel.changed); assert.equal(updateChannel.settings.updateChannel, "nightly"); assert.equal(updateChannel.settings.updateChannelConfiguredByUser, true); + + const localNightlies = yield* settings.setShowAndInstallLocalNightlies(true); + assert.isTrue(localNightlies.changed); + assert.equal(localNightlies.settings.showAndInstallLocalNightlies, true); + assert.equal((yield* settings.load).showAndInstallLocalNightlies, true); }), ), ); @@ -249,6 +262,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: false, + showAndInstallLocalNightlies: false, wslBackendEnabled: false, wslOnly: false, wslDistro: null, @@ -305,6 +319,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "nightly", updateChannelConfiguredByUser: true, + showAndInstallLocalNightlies: false, wslBackendEnabled: false, wslOnly: false, wslDistro: null, @@ -353,6 +368,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: false, + showAndInstallLocalNightlies: false, wslBackendEnabled: false, wslOnly: false, wslDistro: null, @@ -370,6 +386,7 @@ describe("DesktopSettings", () => { serverExposureMode: "local-only", updateChannel: "latest", updateChannelConfiguredByUser: true, + showAndInstallLocalNightlies: false, }); assert.deepEqual(yield* settings.load, { @@ -381,6 +398,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: true, + showAndInstallLocalNightlies: false, wslBackendEnabled: false, wslOnly: false, wslDistro: null, @@ -408,6 +426,7 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: false, + showAndInstallLocalNightlies: false, wslBackendEnabled: false, wslOnly: false, wslDistro: null, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index aefc67525531..5345ea100c9b 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -33,6 +33,7 @@ export interface DesktopSettings { readonly tailscaleServePort: number; readonly updateChannel: DesktopUpdateChannel; readonly updateChannelConfiguredByUser: boolean; + readonly showAndInstallLocalNightlies: boolean; // Was a "local" | "wsl" swap mode in an earlier iteration of the WSL // integration. We now run Windows and WSL backends side by side, so the // setting is just whether the WSL backend should be running alongside the @@ -81,6 +82,7 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, updateChannel: "latest", updateChannelConfiguredByUser: false, + showAndInstallLocalNightlies: false, wslBackendEnabled: false, wslDistro: null, wslOnly: false, @@ -102,6 +104,7 @@ const DesktopSettingsDocument = Schema.Struct({ tailscaleServePort: Schema.optionalKey(Schema.Number), updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), + showAndInstallLocalNightlies: Schema.optionalKey(Schema.Boolean), // Newer form of the WSL toggle. `wslMode` is still accepted on load so // existing on-disk settings keep working; on the next persist we write the // new boolean and the legacy key drops out. @@ -166,6 +169,9 @@ export class DesktopAppSettings extends Context.Service< readonly setUpdateChannel: ( channel: DesktopUpdateChannel, ) => Effect.Effect; + readonly setShowAndInstallLocalNightlies: ( + enabled: boolean, + ) => Effect.Effect; readonly setWslBackendEnabled: ( enabled: boolean, ) => Effect.Effect; @@ -235,6 +241,7 @@ function normalizeDesktopSettingsDocument( ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) : defaultSettings.updateChannel, updateChannelConfiguredByUser, + showAndInstallLocalNightlies: parsed.showAndInstallLocalNightlies === true, wslBackendEnabled, wslDistro: normalizeWslDistro(parsed.wslDistro), wslOnly: parsed.wslOnly === true, @@ -271,6 +278,9 @@ function toDesktopSettingsDocument( if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) { document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser; } + if (settings.showAndInstallLocalNightlies !== defaults.showAndInstallLocalNightlies) { + document.showAndInstallLocalNightlies = settings.showAndInstallLocalNightlies; + } if (settings.wslBackendEnabled !== defaults.wslBackendEnabled) { document.wslBackendEnabled = settings.wslBackendEnabled; } @@ -342,6 +352,15 @@ function setUpdateChannel( }; } +function setShowAndInstallLocalNightlies( + settings: DesktopSettings, + enabled: boolean, +): DesktopSettings { + return settings.showAndInstallLocalNightlies === enabled + ? settings + : { ...settings, showAndInstallLocalNightlies: enabled }; +} + function setWslBackendEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings { return settings.wslBackendEnabled === enabled ? settings @@ -530,6 +549,12 @@ export const make = Effect.gen(function* () { persist((settings) => setUpdateChannel(settings, channel)).pipe( Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), ), + setShowAndInstallLocalNightlies: (enabled) => + persist((settings) => setShowAndInstallLocalNightlies(settings, enabled)).pipe( + Effect.withSpan("desktop.settings.setShowAndInstallLocalNightlies", { + attributes: { enabled }, + }), + ), setWslBackendEnabled: (enabled) => persist((settings) => setWslBackendEnabled(settings, enabled)).pipe( Effect.withSpan("desktop.settings.setWslBackendEnabled", { attributes: { enabled } }), @@ -581,6 +606,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), + setShowAndInstallLocalNightlies: (enabled) => + update((settings) => setShowAndInstallLocalNightlies(settings, enabled)), setWslBackendEnabled: (enabled) => update((settings) => setWslBackendEnabled(settings, enabled)), setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)), diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 1c17d58215ea..d835cf06f95f 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -36,6 +36,7 @@ const clientSettings: ClientSettings = { glassOpacity: 80, planModeEnabled: false, providerModelPreferences: {}, + roundedProjectIcons: false, sidebarAutoSettleAfterDays: 3, sidebarAutoSettleOnMerge: true, sidebarProjectGroupingMode: "repository_path", @@ -46,6 +47,7 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, legacySidebarEnabled: false, + legacySidebarScale: 100, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/desktop/src/settings/LastCodeSettingsImport.test.ts b/apps/desktop/src/settings/LastCodeSettingsImport.test.ts new file mode 100644 index 000000000000..7d6d0110a3e7 --- /dev/null +++ b/apps/desktop/src/settings/LastCodeSettingsImport.test.ts @@ -0,0 +1,309 @@ +// @effect-diagnostics nodeBuiltinImport:off -- These integration tests exercise the real atomic filesystem transaction in temporary directories. +import { assert, describe, it } from "@effect/vitest"; +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_SERVER_SETTINGS, + ProviderInstanceId, + ServerSettings, +} from "@t3tools/contracts"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as Schema from "effect/Schema"; + +const fs = NodeFS.promises; + +import { + importT3Settings, + isT3SettingsImportSupported, + previewT3SettingsImport, + type LastCodeSettingsImportPaths, +} from "./LastCodeSettingsImport.ts"; + +const encodeServerSettings = Schema.encodeSync(ServerSettings); + +async function makePaths(): Promise { + const root = await fs.mkdtemp(NodePath.join(NodeOS.tmpdir(), "lastcode-settings-import-")); + const paths = { + sourceDirectory: NodePath.join(root, "t3"), + destinationDirectory: NodePath.join(root, "lastcode"), + backupRootDirectory: NodePath.join(root, "backups"), + }; + await Promise.all([ + fs.mkdir(paths.sourceDirectory, { recursive: true }), + fs.mkdir(paths.destinationDirectory, { recursive: true }), + ]); + return paths; +} + +function json(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function record(value: unknown): Record { + assert.isObject(value); + assert.isNotArray(value); + return value as Record; +} + +async function expectRejected(effect: () => Promise): Promise { + let rejected = false; + try { + await effect(); + } catch { + rejected = true; + } + assert.isTrue(rejected); +} + +describe("LastCodeSettingsImport", () => { + it("disables imports only for the Windows WSL-only profile", () => { + assert.isFalse(isT3SettingsImportSupported("win32", true)); + assert.isTrue(isT3SettingsImportSupported("win32", false)); + assert.isTrue(isT3SettingsImportSupported("darwin", true)); + assert.isTrue(isT3SettingsImportSupported("linux", true)); + }); + + it("previews missing and invalid categories without exposing file contents", async () => { + const paths = await makePaths(); + await fs.writeFile(NodePath.join(paths.sourceDirectory, "client-settings.json"), "not-json"); + await fs.writeFile( + NodePath.join(paths.sourceDirectory, "keybindings.json"), + "[\n // T3 Code accepts JSONC here.\n]\n", + ); + + const preview = await previewT3SettingsImport(paths); + + assert.equal(preview.canImport, true); + assert.deepEqual( + preview.categories.map(({ id, status }) => ({ id, status })), + [ + { id: "client-preferences", status: "invalid" }, + { id: "keybindings", status: "ready" }, + { id: "server-preferences", status: "missing" }, + ], + ); + }); + + it("imports allowlisted preferences while preserving LastCode-only state and secrets", async () => { + const paths = await makePaths(); + const codex = ProviderInstanceId.make("codex"); + const sourceCustom = ProviderInstanceId.make("source_custom"); + const lastCodeCustom = ProviderInstanceId.make("lastcode_custom"); + const sourceClient = { ...DEFAULT_CLIENT_SETTINGS, fontSizeInterface: 17 }; + Reflect.deleteProperty(sourceClient, "legacySidebarScale"); + Reflect.deleteProperty(sourceClient, "roundedProjectIcons"); + sourceClient.favorites = [ + { provider: codex, model: "gpt-source" }, + { provider: sourceCustom, model: "source-model" }, + ]; + sourceClient.providerModelPreferences = { + [codex]: { hiddenModels: ["hidden-source"], modelOrder: ["gpt-source"] }, + [sourceCustom]: { hiddenModels: [], modelOrder: ["source-model"] }, + }; + const destinationClient = { + ...DEFAULT_CLIENT_SETTINGS, + legacySidebarScale: 75, + roundedProjectIcons: true, + favorites: [{ provider: lastCodeCustom, model: "lastcode-model" }], + providerModelPreferences: { + [lastCodeCustom]: { hiddenModels: [], modelOrder: ["lastcode-model"] }, + }, + }; + const sourceServer = record(structuredClone(encodeServerSettings(DEFAULT_SERVER_SETTINGS))); + const sourceProviders = record(sourceServer.providers); + const sourceOpenCode = record(sourceProviders.opencode); + const sourceCodex = record(sourceProviders.codex); + sourceServer.addProjectBaseDirectory = "/src/t3-projects"; + sourceOpenCode.serverUrl = "http://127.0.0.1:4096"; + sourceOpenCode.serverPassword = "source-secret"; + sourceCodex.launchArgs = "--source-secret token"; + sourceServer.textGenerationModelSelection = { + instanceId: "opencode", + model: "source-model", + options: [], + }; + sourceServer.sourceControlWriterModelSelection = { + instanceId: "opencode", + model: "source-writer", + options: [], + }; + sourceServer.providerInstances = { + codex: { + driver: "codex", + displayName: "T3 Codex", + accentColor: "#123456", + enabled: false, + config: { + binaryPath: "/opt/t3/codex", + homePath: "/Users/source/.codex", + launchArgs: "--source-instance-secret token", + customModels: ["source-model"], + }, + environment: [{ name: "TOKEN", value: "source-default-token", sensitive: true }], + }, + personal: { + driver: "codex", + environment: [{ name: "TOKEN", value: "source-token", sensitive: true }], + }, + }; + + const destinationServer = record( + structuredClone(encodeServerSettings(DEFAULT_SERVER_SETTINGS)), + ); + const destinationProviders = record(destinationServer.providers); + const destinationOpenCode = record(destinationProviders.opencode); + const destinationCodex = record(destinationProviders.codex); + destinationServer.addProjectBaseDirectory = "/src/lastcode-projects"; + destinationOpenCode.serverUrl = "http://127.0.0.1:7777"; + destinationOpenCode.serverPassword = "lastcode-secret"; + destinationCodex.launchArgs = "--lastcode-only"; + destinationServer.textGenerationModelSelection = { + instanceId: "codex", + model: "lastcode-model", + options: [], + }; + destinationServer.sourceControlWriterModelSelection = { + instanceId: "codex", + model: "lastcode-writer", + options: [], + }; + destinationServer.providerInstances = { + codex: { + driver: "codex", + enabled: true, + config: { + binaryPath: "/opt/lastcode/codex", + launchArgs: "--lastcode-instance-only", + }, + environment: [{ name: "TOKEN", value: "lastcode-default-token", sensitive: true }], + }, + lastcode: { + driver: "codex", + environment: [{ name: "TOKEN", value: "lastcode-token", sensitive: true }], + }, + }; + await Promise.all([ + fs.writeFile( + NodePath.join(paths.sourceDirectory, "client-settings.json"), + `// T3 Code accepts JSONC here.\n${json(sourceClient)}`, + ), + fs.writeFile(NodePath.join(paths.sourceDirectory, "keybindings.json"), "[\n // none\n]\n"), + fs.writeFile( + NodePath.join(paths.sourceDirectory, "settings.json"), + `// T3 Code accepts JSONC here.\n${json(sourceServer)}`, + ), + fs.writeFile( + NodePath.join(paths.destinationDirectory, "settings.json"), + `// LastCode accepts JSONC here too.\n${json(destinationServer)}`, + ), + fs.writeFile( + NodePath.join(paths.destinationDirectory, "client-settings.json"), + json(destinationClient), + ), + ]); + + const result = await importT3Settings(paths); + const importedClient = JSON.parse( + await fs.readFile(NodePath.join(paths.destinationDirectory, "client-settings.json"), "utf8"), + ) as Record; + const importedServer = record( + JSON.parse( + await fs.readFile(NodePath.join(paths.destinationDirectory, "settings.json"), "utf8"), + ) as unknown, + ); + assert.equal(importedClient.fontSizeInterface, 17); + assert.equal(importedClient.legacySidebarScale, 75); + assert.equal(importedClient.roundedProjectIcons, true); + assert.deepEqual(importedClient.favorites, [ + { provider: "lastcode_custom", model: "lastcode-model" }, + { provider: "codex", model: "gpt-source" }, + ]); + assert.deepEqual(importedClient.providerModelPreferences, { + lastcode_custom: { hiddenModels: [], modelOrder: ["lastcode-model"] }, + codex: { hiddenModels: ["hidden-source"], modelOrder: ["gpt-source"] }, + }); + assert.equal(importedServer.addProjectBaseDirectory, "/src/t3-projects"); + assert.deepEqual(importedServer.providers, destinationServer.providers); + assert.deepEqual(importedServer.providerInstances, destinationServer.providerInstances); + assert.deepEqual( + importedServer.textGenerationModelSelection, + destinationServer.textGenerationModelSelection, + ); + assert.deepEqual( + importedServer.sourceControlWriterModelSelection, + destinationServer.sourceControlWriterModelSelection, + ); + assert.deepEqual(result.imported, ["client-preferences", "keybindings", "server-preferences"]); + assert.include( + await fs.readFile(NodePath.join(result.backupDirectory, "settings.json"), "utf8"), + '"serverPassword": "lastcode-secret"', + ); + assert.equal( + JSON.parse(await fs.readFile(NodePath.join(result.backupDirectory, "manifest.json"), "utf8")) + .files.length, + 3, + ); + }); + + it("imports usable keybindings while omitting invalid entries", async () => { + const paths = await makePaths(); + const usable = Array.from({ length: 258 }, (_, index) => ({ + key: "mod+j", + command: "terminal.toggle", + when: `context${index}`, + })); + await fs.writeFile( + NodePath.join(paths.sourceDirectory, "keybindings.json"), + `[ + // Obsolete commands and malformed shortcuts are ignored by T3 Code. + { "key": "mod+x", "command": "removed.command" }, + { "key": "mod+shift+d+o", "command": "terminal.new" }, + ${usable.map((rule) => JSON.stringify(rule)).join(",\n ")}, + ]`, + ); + + const preview = await previewT3SettingsImport(paths); + assert.equal(preview.categories.find(({ id }) => id === "keybindings")?.status, "ready"); + + await importT3Settings(paths); + + assert.deepEqual( + JSON.parse( + await fs.readFile(NodePath.join(paths.destinationDirectory, "keybindings.json"), "utf8"), + ), + usable.slice(-256), + ); + }); + + it("refuses to import when the source and destination are the same directory", async () => { + const paths = await makePaths(); + const preview = await previewT3SettingsImport({ + ...paths, + destinationDirectory: paths.sourceDirectory, + }); + + assert.equal(preview.canImport, false); + assert.isTrue(preview.categories.every((category) => category.status === "invalid")); + }); + + it("validates every destination before replacing any file", async () => { + const paths = await makePaths(); + await Promise.all([ + fs.writeFile( + NodePath.join(paths.sourceDirectory, "client-settings.json"), + json(DEFAULT_CLIENT_SETTINGS), + ), + fs.writeFile( + NodePath.join(paths.sourceDirectory, "settings.json"), + json(encodeServerSettings(DEFAULT_SERVER_SETTINGS)), + ), + fs.writeFile(NodePath.join(paths.destinationDirectory, "settings.json"), "not-json"), + ]); + + await expectRejected(() => importT3Settings(paths)); + await expectRejected(() => + fs.readFile(NodePath.join(paths.destinationDirectory, "client-settings.json")), + ); + }); +}); diff --git a/apps/desktop/src/settings/LastCodeSettingsImport.ts b/apps/desktop/src/settings/LastCodeSettingsImport.ts new file mode 100644 index 000000000000..1c608a382724 --- /dev/null +++ b/apps/desktop/src/settings/LastCodeSettingsImport.ts @@ -0,0 +1,380 @@ +// @effect-diagnostics nodeBuiltinImport:off cryptoRandomUUID:off globalDate:off -- This adapter performs one bounded, transactional import against host profile files before the desktop process relaunches. +import { + ClientSettingsSchema, + KeybindingRule, + KeybindingsConfig, + MAX_KEYBINDINGS_COUNT, + ServerSettings, + type LastCodeSettingsImportCategory, + type LastCodeSettingsImportCategoryId, + type LastCodeSettingsImportPreview, + type LastCodeSettingsImportResult, +} from "@t3tools/contracts"; +import { compileResolvedKeybindingRule } from "@t3tools/shared/keybindings"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as Schema from "effect/Schema"; + +const fs = NodeFS.promises; +const fsConstants = NodeFS.constants; + +const ClientSettingsDocumentSchema = Schema.Struct({ settings: ClientSettingsSchema }); +const ClientSettingsJson = fromLenientJson(ClientSettingsSchema); +const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema); +const RawKeybindingsJson = fromLenientJson(Schema.Array(Schema.Unknown)); +const KeybindingsJson = fromLenientJson(KeybindingsConfig); +const ServerSettingsJson = fromLenientJson(ServerSettings); + +const decodeClientSettingsJson = Schema.decodeUnknownSync(ClientSettingsJson); +const decodeLegacyClientSettingsDocumentJson = Schema.decodeUnknownSync( + LegacyClientSettingsDocumentJson, +); +const encodeClientSettingsJson = Schema.encodeSync(ClientSettingsJson); +const decodeRawKeybindingsJson = Schema.decodeUnknownSync(RawKeybindingsJson); +const decodeKeybindingRule = Schema.decodeUnknownSync(KeybindingRule); +const encodeKeybindingsJson = Schema.encodeSync(KeybindingsJson); +const decodeServerSettingsJson = Schema.decodeUnknownSync(ServerSettingsJson); +const encodeServerSettings = Schema.encodeSync(ServerSettings); + +const CATEGORY_DEFINITIONS: ReadonlyArray<{ + readonly id: LastCodeSettingsImportCategoryId; + readonly label: string; + readonly sourceFile: string; + readonly detail: string; +}> = [ + { + id: "client-preferences", + label: "Appearance and app preferences", + sourceFile: "client-settings.json", + detail: "Theme, fonts, editor, sidebar, confirmations, and model display preferences.", + }, + { + id: "keybindings", + label: "Keyboard shortcuts", + sourceFile: "keybindings.json", + detail: "Custom keybinding rules.", + }, + { + id: "server-preferences", + label: "Server behavior", + sourceFile: "settings.json", + detail: "Background behavior, Git fetch, thread defaults, and source-control writing.", + }, +]; + +export const LASTCODE_SETTINGS_IMPORT_EXCLUSIONS = [ + "Projects, threads, checkpoints, attachments, and databases", + "Provider configuration, credentials, instances, and model selections", + "Saved environments, connections, and machine identity", + "Desktop window state, network exposure, Tailscale, ports, and WSL runtime selection", + "Update channels, local-nightly settings, caches, logs, and browser storage", +] as const; + +const BUILT_IN_PROVIDER_INSTANCE_IDS = new Set([ + "codex", + "claudeAgent", + "cursor", + "grok", + "opencode", +]); + +const SAFE_SERVER_SETTING_KEYS = [ + "enableLegacyTokenStreaming", + "enableProviderUpdateChecks", + "backgroundActivity", + "automaticGitFetchInterval", + "providerHealthRefreshInterval", + "backgroundActivityProfile", + "defaultThreadEnvMode", + "newWorktreesStartFromOrigin", + "addProjectBaseDirectory", + "sourceControlWritingStyle", +] as const; + +type JsonRecord = Record; + +export interface LastCodeSettingsImportPaths { + readonly sourceDirectory: string; + readonly destinationDirectory: string; + readonly backupRootDirectory: string; +} + +export function isT3SettingsImportSupported(platform: NodeJS.Platform, wslOnly: boolean): boolean { + return platform !== "win32" || !wslOnly; +} + +interface PreparedWrite { + readonly id: LastCodeSettingsImportCategoryId; + readonly fileName: string; + readonly targetPath: string; + readonly content: string; + readonly previousContent: string | null; +} + +function asRecord(value: unknown, description: string): JsonRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${description} must be a JSON object.`); + } + return value as JsonRecord; +} + +function decodeSourceClientSettings(raw: string) { + try { + return decodeLegacyClientSettingsDocumentJson(raw).settings; + } catch { + return decodeClientSettingsJson(raw); + } +} + +function decodeUsableKeybindings(raw: string) { + const keybindings = []; + for (const entry of decodeRawKeybindingsJson(raw)) { + try { + const rule = decodeKeybindingRule(entry); + if (compileResolvedKeybindingRule(rule) !== null) keybindings.push(rule); + } catch { + // T3 Code ignores obsolete or malformed entries while retaining the rest of the file. + } + } + return keybindings.slice(-MAX_KEYBINDINGS_COUNT); +} + +function safeServerPreferences(raw: string): JsonRecord { + const encoded = asRecord( + encodeServerSettings(decodeServerSettingsJson(raw)), + "Encoded server settings", + ); + const selected: JsonRecord = {}; + for (const key of SAFE_SERVER_SETTING_KEYS) selected[key] = encoded[key]; + return selected; +} + +function validateSource(id: LastCodeSettingsImportCategoryId, raw: string): void { + switch (id) { + case "client-preferences": + decodeSourceClientSettings(raw); + return; + case "keybindings": + decodeUsableKeybindings(raw); + return; + case "server-preferences": + safeServerPreferences(raw); + return; + } +} + +async function readOptional(path: string): Promise { + try { + return await fs.readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +async function inspectCategory( + definition: (typeof CATEGORY_DEFINITIONS)[number], + sourceDirectory: string, +): Promise { + const raw = await readOptional(NodePath.join(sourceDirectory, definition.sourceFile)); + if (raw === null) return { ...definition, status: "missing" }; + try { + validateSource(definition.id, raw); + return { ...definition, status: "ready" }; + } catch { + return { ...definition, status: "invalid" }; + } +} + +export async function previewT3SettingsImport( + paths: LastCodeSettingsImportPaths, +): Promise { + const sourceDirectory = NodePath.resolve(paths.sourceDirectory); + const destinationDirectory = NodePath.resolve(paths.destinationDirectory); + const sameDirectory = sourceDirectory === destinationDirectory; + const categories = sameDirectory + ? CATEGORY_DEFINITIONS.map((definition) => ({ ...definition, status: "invalid" as const })) + : await Promise.all( + CATEGORY_DEFINITIONS.map((definition) => inspectCategory(definition, sourceDirectory)), + ); + return { + sourceDirectory, + destinationDirectory, + categories, + excluded: LASTCODE_SETTINGS_IMPORT_EXCLUSIONS, + canImport: !sameDirectory && categories.some((category) => category.status === "ready"), + message: sameDirectory ? "T3 Code and LastCode resolve to the same settings directory." : null, + }; +} + +function stringifyJson(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function mergeClientSettings(sourceRaw: string, destinationRaw: string | null): string { + const source = decodeSourceClientSettings(sourceRaw); + const destination = + destinationRaw === null + ? decodeClientSettingsJson("{}") + : decodeSourceClientSettings(destinationRaw); + const isBuiltInProviderPreference = (provider: string) => + BUILT_IN_PROVIDER_INSTANCE_IDS.has(provider); + const favorites = [ + ...destination.favorites.filter(({ provider }) => !isBuiltInProviderPreference(provider)), + ...source.favorites.filter(({ provider }) => isBuiltInProviderPreference(provider)), + ]; + const providerModelPreferences = Object.fromEntries([ + ...Object.entries(destination.providerModelPreferences).filter( + ([provider]) => !isBuiltInProviderPreference(provider), + ), + ...Object.entries(source.providerModelPreferences).filter(([provider]) => + isBuiltInProviderPreference(provider), + ), + ]); + return `${encodeClientSettingsJson({ + ...source, + favorites, + providerModelPreferences, + legacySidebarScale: destination.legacySidebarScale, + roundedProjectIcons: destination.roundedProjectIcons, + })}\n`; +} + +function mergeServerSettings(sourceRaw: string, destinationRaw: string | null): string { + const destination = + destinationRaw === null + ? asRecord(encodeServerSettings(decodeServerSettingsJson("{}")), "Server settings") + : asRecord(encodeServerSettings(decodeServerSettingsJson(destinationRaw)), "Server settings"); + const imported = safeServerPreferences(sourceRaw); + return stringifyJson({ ...destination, ...imported }); +} + +function buildImportedContent( + id: LastCodeSettingsImportCategoryId, + sourceRaw: string, + destinationRaw: string | null, +): string { + switch (id) { + case "client-preferences": + return mergeClientSettings(sourceRaw, destinationRaw); + case "keybindings": + return `${encodeKeybindingsJson(decodeUsableKeybindings(sourceRaw))}\n`; + case "server-preferences": + return mergeServerSettings(sourceRaw, destinationRaw); + } +} + +async function prepareWrites( + paths: LastCodeSettingsImportPaths, + categories: readonly LastCodeSettingsImportCategory[], +): Promise { + const writes: PreparedWrite[] = []; + for (const category of categories) { + if (category.status !== "ready") continue; + const sourcePath = NodePath.join(paths.sourceDirectory, category.sourceFile); + const targetPath = NodePath.join(paths.destinationDirectory, category.sourceFile); + const [sourceRaw, previousContent] = await Promise.all([ + fs.readFile(sourcePath, "utf8"), + readOptional(targetPath), + ]); + writes.push({ + id: category.id, + fileName: category.sourceFile, + targetPath, + content: buildImportedContent(category.id, sourceRaw, previousContent), + previousContent, + }); + } + return writes; +} + +async function replaceFileAtomically(targetPath: string, content: string): Promise { + const temporaryPath = `${targetPath}.${process.pid}.${crypto.randomUUID()}.tmp`; + try { + await fs.writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 }); + await fs.rename(temporaryPath, targetPath); + } finally { + await fs.rm(temporaryPath, { force: true }).catch(() => undefined); + } +} + +async function restoreWrites(writes: readonly PreparedWrite[]): Promise { + const errors: unknown[] = []; + for (const write of writes.toReversed()) { + try { + if (write.previousContent === null) { + await fs.rm(write.targetPath, { force: true }); + } else { + await replaceFileAtomically(write.targetPath, write.previousContent); + } + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) throw new AggregateError(errors, "Could not roll back imported settings."); +} + +export async function importT3Settings( + paths: LastCodeSettingsImportPaths, +): Promise { + const preview = await previewT3SettingsImport(paths); + if (!preview.canImport) throw new Error("No valid T3 Code settings are available to import."); + + const writes = await prepareWrites(paths, preview.categories); + await fs.mkdir(paths.destinationDirectory, { recursive: true, mode: 0o700 }); + await fs.mkdir(paths.backupRootDirectory, { recursive: true, mode: 0o700 }); + const backupDirectory = NodePath.join( + paths.backupRootDirectory, + `${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomUUID().slice(0, 8)}`, + ); + await fs.mkdir(backupDirectory, { mode: 0o700 }); + + for (const write of writes) { + if (write.previousContent !== null) { + await fs.writeFile(NodePath.join(backupDirectory, write.fileName), write.previousContent, { + encoding: "utf8", + mode: 0o600, + }); + } + } + await fs.writeFile( + NodePath.join(backupDirectory, "manifest.json"), + stringifyJson({ + importedAt: new Date().toISOString(), + sourceDirectory: preview.sourceDirectory, + destinationDirectory: preview.destinationDirectory, + files: writes.map((write) => ({ + category: write.id, + file: write.fileName, + hadPreviousVersion: write.previousContent !== null, + })), + }), + { encoding: "utf8", mode: 0o600 }, + ); + + const replaced: PreparedWrite[] = []; + try { + for (const write of writes) { + await fs.access(NodePath.dirname(write.targetPath), fsConstants.W_OK); + await replaceFileAtomically(write.targetPath, write.content); + replaced.push(write); + } + } catch (error) { + try { + await restoreWrites(replaced); + } catch (rollbackError) { + // eslint-disable-next-line preserve-caught-error -- Both caught failures are explicit AggregateError members. + throw new AggregateError( + [error, rollbackError], + "Settings import and rollback both failed.", + { + cause: rollbackError, + }, + ); + } + throw error; + } + + return { imported: writes.map((write) => write.id), backupDirectory }; +} diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index dd3cd1aaf5f5..af765b798002 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -16,11 +16,13 @@ import * as TestClock from "effect/testing/TestClock"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; +import * as LastCodeLocalUpdates from "./LastCodeLocalUpdates.ts"; interface UpdatesHarnessOptions { readonly checkForUpdates?: Effect.Effect< @@ -30,8 +32,24 @@ interface UpdatesHarnessOptions { readonly beforeSetUpdateChannel?: Effect.Effect; readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; readonly setDisableDifferentialDownload?: Effect.Effect; - readonly stopBackend?: Effect.Effect; + readonly backends?: ReadonlyArray<{ + readonly desiredRunning: boolean; + readonly stop?: Effect.Effect; + }>; readonly env?: Record; + readonly localNightliesEnabled?: boolean; + readonly localInspection?: LastCodeLocalUpdates.LastCodeLocalUpdateInspection; + readonly localInspect?: ( + currentVersion: string, + ) => Effect.Effect; + readonly localBuild?: LastCodeLocalUpdates.LastCodeLocalUpdateBuild; + readonly localBuildEffect?: LastCodeLocalUpdates.LastCodeLocalUpdates["Service"]["build"]; + readonly localPrepareInstall?: ( + args: Parameters[0], + ) => Effect.Effect< + LastCodeLocalUpdates.LastCodeLocalInstallHandoff, + LastCodeLocalUpdates.LastCodeLocalUpdateError + >; } const flushCallbacks = Effect.yieldNow; @@ -40,6 +58,13 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { let checkCount = 0; let allowDowngrade = false; let fullChangelog = false; + const installEvents: string[] = []; + const localInstallArgs: Array<{ + readonly dmgPath: string; + readonly dmgSha256: string; + readonly expectedVersion: string; + }> = []; + const differentialDownloadValues: boolean[] = []; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; const listeners = new Map void>>(); const sentStates: DesktopUpdateState[] = []; @@ -79,12 +104,24 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { Effect.sync(() => { fullChangelog = value; }), - setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, + setDisableDifferentialDownload: (value) => + Effect.sync(() => { + differentialDownloadValues.push(value); + }).pipe(Effect.andThen(options.setDisableDifferentialDownload ?? Effect.void)), checkForUpdates: Effect.sync(() => { checkCount += 1; }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), - downloadUpdate: Effect.void, - quitAndInstall: () => Effect.void, + downloadUpdate: Effect.sync(() => { + if (options.localNightliesEnabled) { + for (const listener of listeners.get("update-downloaded") ?? []) { + listener({ version: options.localInspection?.availableVersion }); + } + } + }), + quitAndInstall: () => + Effect.sync(() => { + installEvents.push("squirrel-install"); + }), on: (eventName, listener) => Effect.acquireRelease( Effect.sync(() => { @@ -109,26 +146,42 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { Effect.sync(() => { sentStates.push(state as DesktopUpdateState); }), - destroyAll: Effect.void, + destroyAll: Effect.sync(() => { + installEvents.push("destroy-windows"); + }), syncAllAppearance: () => Effect.void, } satisfies ElectronWindow.ElectronWindow["Service"]); - const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = { - id: DesktopBackendPool.PRIMARY_INSTANCE_ID, - label: Effect.succeed("Windows"), - start: Effect.void, - stop: () => options.stopBackend ?? Effect.void, - currentConfig: Effect.succeed(Option.none()), - snapshot: Effect.succeed({ - desiredRunning: false, - ready: false, - activePid: Option.none(), - restartAttempt: 0, - restartScheduled: false, - }), - waitForReady: () => Effect.succeed(true), - }; - const backendLayer = DesktopBackendPool.layerTest([stubBackendInstance]); + const backendOptions = options.backends ?? [{ desiredRunning: true }]; + const stubBackendInstances = backendOptions.map( + ({ desiredRunning, stop }, index): DesktopBackendPool.DesktopBackendInstance => { + const suffix = index === 0 ? "" : `-${index + 1}`; + return { + id: + index === 0 + ? DesktopBackendPool.PRIMARY_INSTANCE_ID + : DesktopBackendPool.BackendInstanceId(`test-backend-${index + 1}`), + label: Effect.succeed(index === 0 ? "Windows" : `Backend ${index + 1}`), + start: Effect.sync(() => { + installEvents.push(`start-backend${suffix}`); + }), + stop: () => + Effect.sync(() => { + installEvents.push(`stop-backend${suffix}`); + }).pipe(Effect.andThen(stop ?? Effect.void)), + currentConfig: Effect.succeed(Option.none()), + snapshot: Effect.succeed({ + desiredRunning, + ready: desiredRunning, + activePid: Option.none(), + restartAttempt: 0, + restartScheduled: false, + }), + waitForReady: () => Effect.succeed(true), + }; + }, + ); + const backendLayer = DesktopBackendPool.layerTest(stubBackendInstances); const environmentLayer = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", @@ -156,10 +209,13 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { let testSettings: DesktopAppSettings.DesktopSettings = { ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + showAndInstallLocalNightlies: options.localNightliesEnabled ?? false, }; const setUpdateChannelError = options.setUpdateChannelError; const settingsLayer = - setUpdateChannelError || options.beforeSetUpdateChannel + setUpdateChannelError || + options.beforeSetUpdateChannel || + options.localNightliesEnabled !== undefined ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.sync(() => testSettings), load: Effect.sync(() => testSettings), @@ -182,6 +238,12 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { }), ), ), + setShowAndInstallLocalNightlies: (enabled) => + Effect.sync(() => { + const changed = testSettings.showAndInstallLocalNightlies !== enabled; + testSettings = { ...testSettings, showAndInstallLocalNightlies: enabled }; + return { settings: testSettings, changed }; + }), setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), @@ -190,6 +252,47 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) : DesktopAppSettings.layer; + const localUpdatesLayer = LastCodeLocalUpdates.layerTest({ + supported: options.localNightliesEnabled ?? false, + buildLogPath: `/tmp/t3-desktop-updates-home-${process.pid}/.lastcode/local-updates/build.log`, + inspect: (currentVersion) => + options.localInspect + ? options.localInspect(currentVersion) + : options.localInspection + ? Effect.succeed(options.localInspection) + : Effect.die("unexpected local update inspection"), + build: (checkpointTag, onProgress) => + options.localBuildEffect + ? options.localBuildEffect(checkpointTag, onProgress) + : options.localBuild + ? Effect.succeed(options.localBuild) + : Effect.die("unexpected local update build"), + prepareInstall: (args) => { + localInstallArgs.push(args); + installEvents.push("prepare-install"); + if (options.localPrepareInstall) return options.localPrepareInstall(args); + let commanded = false; + return Effect.succeed({ + commit: Effect.sync(() => { + if (commanded) return; + commanded = true; + installEvents.push("commit-handoff"); + }), + cancel: Effect.sync(() => { + if (commanded) return; + commanded = true; + installEvents.push("cancel-handoff"); + }), + }); + }, + }); + + const electronAppLayer = Layer.mock(ElectronApp.ElectronApp)({ + quit: Effect.sync(() => { + installEvents.push("quit-app"); + }), + }); + const layer = DesktopUpdates.layer.pipe( Layer.provideMerge(updaterLayer), Layer.provideMerge(windowLayer), @@ -205,6 +308,8 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { }), ), Layer.provideMerge(environmentLayer), + Layer.provideMerge(localUpdatesLayer), + Layer.provideMerge(electronAppLayer), Layer.provideMerge(NodeServices.layer), ); @@ -212,7 +317,10 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { layer, checkCount: () => checkCount, feedUrls: () => feedUrls, + differentialDownloadValues: () => differentialDownloadValues, fullChangelog: () => fullChangelog, + installEvents: () => installEvents, + localInstallArgs: () => localInstallArgs, listenerCount: () => Array.from(listeners.values()).reduce( (total, eventListeners) => total + eventListeners.size, @@ -228,6 +336,94 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { } describe("DesktopUpdates", () => { + it("maps local sections, unavailable provenance, and overflow summaries", () => { + assert.deepEqual( + DesktopUpdates.mapLastCodeLocalReleaseNotes({ + schemaVersion: 2, + status: "available", + checkpointTag: "lastcode/checkpoint/v1.2.4-nightly.20260814.1090", + availableVersion: "1.2.4-nightly.20260814.1090", + releaseNotes: { + lastCode: { + status: "known", + items: ["feat(lastcode): new workflow"], + omittedItems: 2, + }, + upstream: { + groups: [ + { + version: "1.2.4-nightly.20260814.1090", + isTarget: true, + items: ["fix(web): newest upstream fix"], + omittedItems: 1, + }, + { + version: "1.2.4-nightly.20260814.1089", + isTarget: false, + items: ["fix(server): earlier upstream fix"], + omittedItems: 0, + }, + ], + omittedGroups: 3, + }, + }, + }), + [ + { + version: "1.2.4-nightly.20260814.1090", + heading: "LastCode changes", + items: ["feat(lastcode): new workflow"], + summaries: ["…and 2 more LastCode changes"], + }, + { + version: "1.2.4-nightly.20260814.1090", + heading: "Upstream changes", + items: ["fix(web): newest upstream fix"], + summaries: ["…and 1 more change"], + }, + { + version: "1.2.4-nightly.20260814.1089", + heading: "Upstream changes in 1.2.4-nightly.20260814.1089", + items: ["fix(server): earlier upstream fix"], + summaries: ["3 older nightlies not shown"], + }, + ], + ); + assert.deepEqual( + DesktopUpdates.mapLastCodeLocalReleaseNotes({ + schemaVersion: 2, + status: "available", + checkpointTag: "lastcode/revision/v1.2.4-nightly.20260814.1090.1", + availableVersion: "1.2.4-nightly.20260814.1090.1", + releaseNotes: { + lastCode: { status: "known", items: [], omittedItems: 0 }, + upstream: { groups: [], omittedGroups: 0 }, + }, + }), + [], + ); + assert.deepEqual( + DesktopUpdates.mapLastCodeLocalReleaseNotes({ + schemaVersion: 2, + status: "available", + checkpointTag: "lastcode/checkpoint/v1.2.4-nightly.20260814.1090", + availableVersion: "1.2.4-nightly.20260814.1090", + releaseNotes: { + lastCode: { status: "unavailable" }, + upstream: { groups: [], omittedGroups: 0 }, + }, + }), + [ + { + version: "1.2.4-nightly.20260814.1090", + heading: "LastCode changes", + items: [], + summaries: ["Couldn’t determine changes from this installed build."], + }, + ], + ); + }); + it("preserves complete causes for update poller and event failures", () => { const cause = Cause.combine( Cause.fail(new Error("updater failed")), @@ -314,6 +510,471 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("builds an opted-in local revision without staging it through electron-updater", () => { + const checkpointTag = "lastcode/revision/v1.2.4-nightly.20260814.1089.1"; + const harness = makeHarness({ + localNightliesEnabled: true, + localInspection: { + schemaVersion: 2, + status: "available", + checkpointTag, + availableVersion: "1.2.4-nightly.20260814.1089.1", + releaseNotes: { + lastCode: { + status: "known", + items: ["feat(lastcode): local update"], + omittedItems: 0, + }, + upstream: { groups: [], omittedGroups: 0 }, + }, + }, + localBuild: { + schemaVersion: 1, + status: "built", + checkpointTag, + outputDir: "/tmp/lastcode-local-build", + manifestPath: "/tmp/lastcode-local-build/build-manifest.json", + dmgPath: "/tmp/lastcode-local-build/LastCode-1.2.4.dmg", + dmgSha256: "a".repeat(64), + }, + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const available = yield* updates.getState; + assert.equal(available.source, "lastcode-local"); + assert.equal(available.status, "available"); + assert.deepEqual(available.releaseNotes[0]?.items, ["feat(lastcode): local update"]); + assert.equal(available.releaseNotes[0]?.heading, "LastCode changes"); + + const result = yield* updates.download; + assert.isTrue(result.accepted); + assert.isTrue(result.completed); + yield* flushCallbacks; + + const downloaded = yield* updates.getState; + assert.equal(downloaded.status, "downloaded"); + assert.equal(downloaded.downloadedVersion, "1.2.4-nightly.20260814.1089.1"); + assert.deepEqual(harness.feedUrls(), [ + { provider: "generic", url: "http://localhost:4141" }, + ]); + assert.deepEqual(harness.installEvents(), []); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("preserves typed local build diagnostics after progress", () => { + const checkpointTag = "lastcode/revision/v1.2.4-nightly.20260814.1089.1"; + const targetVersion = "1.2.4-nightly.20260814.1089.1"; + const buildError = "packaging failed: disk image is invalid"; + let buildAttempts = 0; + const harness = makeHarness({ + localNightliesEnabled: true, + localInspection: { + schemaVersion: 2, + status: "available", + checkpointTag, + availableVersion: targetVersion, + releaseNotes: { + lastCode: { status: "known", items: [], omittedItems: 0 }, + upstream: { groups: [], omittedGroups: 0 }, + }, + }, + localBuildEffect: (_checkpointTag, onProgress) => + Effect.gen(function* () { + buildAttempts += 1; + if (onProgress) { + yield* onProgress({ phase: "Workspace tests", percent: 20, errorKind: "build" }); + yield* onProgress({ phase: "Building DMG", percent: 94, errorKind: "packaging" }); + } + if (buildAttempts === 1) { + return yield* new LastCodeLocalUpdates.LastCodeLocalUpdateError({ + operation: "build", + message: buildError, + }); + } + return { + schemaVersion: 1, + status: "built", + checkpointTag, + outputDir: "/tmp/lastcode-local-build", + manifestPath: "/tmp/lastcode-local-build/build-manifest.json", + dmgPath: "/tmp/lastcode-local-build/LastCode-1.2.4.dmg", + dmgSha256: "a".repeat(64), + }; + }), + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const result = yield* updates.download; + assert.isTrue(result.accepted); + assert.isFalse(result.completed); + + const failed = yield* updates.getState; + assert.equal(failed.status, "error"); + assert.isNull(failed.downloadPercent); + assert.equal(failed.message, buildError); + assert.deepEqual(failed.localBuildProgress, { + checkpointTag, + phase: "Building DMG", + percent: 94, + errorKind: "packaging", + }); + assert.deepEqual(failed.localBuildFailure, { + checkpointTag, + phase: "Building DMG", + percent: 94, + errorKind: "packaging", + currentVersion: "1.2.3", + targetVersion, + logPath: `/tmp/t3-desktop-updates-home-${process.pid}/.lastcode/local-updates/build.log`, + error: buildError, + }); + assert.deepEqual( + harness.sentStates + .filter((sent) => sent.status === "downloading") + .map((sent) => sent.localBuildProgress?.phase), + ["Preparing", "Workspace tests", "Building DMG"], + ); + + const retry = yield* updates.download; + assert.isTrue(retry.accepted); + assert.isTrue(retry.completed); + assert.equal(buildAttempts, 2); + + const downloaded = yield* updates.getState; + assert.equal(downloaded.status, "downloaded"); + assert.equal(downloaded.downloadedVersion, targetVersion); + assert.isNull(downloaded.localBuildFailure); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("hands the exact local DMG to the helper before stopping backends and quitting", () => { + const checkpointTag = "lastcode/revision/v1.2.4-nightly.20260814.1089.1"; + const dmgPath = "/tmp/lastcode-local-build/LastCode-1.2.4.dmg"; + const dmgSha256 = "b".repeat(64); + const harness = makeHarness({ + localNightliesEnabled: true, + localInspection: { + schemaVersion: 2, + status: "available", + checkpointTag, + availableVersion: "1.2.4-nightly.20260814.1089.1", + releaseNotes: { + lastCode: { status: "known", items: [], omittedItems: 0 }, + upstream: { groups: [], omittedGroups: 0 }, + }, + }, + localBuild: { + schemaVersion: 1, + status: "built", + checkpointTag, + outputDir: "/tmp/lastcode-local-build", + manifestPath: "/tmp/lastcode-local-build/build-manifest.json", + dmgPath, + dmgSha256, + }, + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.download; + + const result = yield* updates.install; + assert.isTrue(result.accepted); + assert.deepEqual(harness.localInstallArgs(), [ + { + dmgPath, + dmgSha256, + expectedVersion: "1.2.4-nightly.20260814.1089.1", + }, + ]); + assert.deepEqual(harness.installEvents(), [ + "prepare-install", + "stop-backend", + "commit-handoff", + "quit-app", + ]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("keeps the current app usable when local install preflight fails", () => { + const checkpointTag = "lastcode/revision/v1.2.4-nightly.20260814.1089.1"; + const harness = makeHarness({ + localNightliesEnabled: true, + localInspection: { + schemaVersion: 2, + status: "available", + checkpointTag, + availableVersion: "1.2.4-nightly.20260814.1089.1", + releaseNotes: { + lastCode: { status: "known", items: [], omittedItems: 0 }, + upstream: { groups: [], omittedGroups: 0 }, + }, + }, + localBuild: { + schemaVersion: 1, + status: "built", + checkpointTag, + outputDir: "/tmp/lastcode-local-build", + manifestPath: "/tmp/lastcode-local-build/build-manifest.json", + dmgPath: "/tmp/lastcode-local-build/LastCode-1.2.4.dmg", + dmgSha256: "c".repeat(64), + }, + localPrepareInstall: () => + Effect.fail( + new LastCodeLocalUpdates.LastCodeLocalUpdateError({ + operation: "install", + message: "Install preflight failed.", + }), + ), + }); + + return Effect.scoped( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.download; + + const result = yield* updates.install; + assert.isTrue(result.accepted); + assert.isFalse(result.completed); + assert.isFalse(yield* Ref.get(desktopState.quitting)); + assert.deepEqual(harness.installEvents(), ["prepare-install"]); + const failed = yield* updates.getState; + assert.equal(failed.status, "downloaded"); + assert.equal(failed.errorContext, "install"); + assert.equal(failed.message, "Install preflight failed."); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("cancels the handoff and restores running backends when shutdown fails", () => { + const checkpointTag = "lastcode/revision/v1.2.4-nightly.20260814.1089.1"; + const harness = makeHarness({ + localNightliesEnabled: true, + localInspection: { + schemaVersion: 2, + status: "available", + checkpointTag, + availableVersion: "1.2.4-nightly.20260814.1089.1", + releaseNotes: { + lastCode: { status: "known", items: [], omittedItems: 0 }, + upstream: { groups: [], omittedGroups: 0 }, + }, + }, + localBuild: { + schemaVersion: 1, + status: "built", + checkpointTag, + outputDir: "/tmp/lastcode-local-build", + manifestPath: "/tmp/lastcode-local-build/build-manifest.json", + dmgPath: "/tmp/lastcode-local-build/LastCode-1.2.4.dmg", + dmgSha256: "d".repeat(64), + }, + backends: [ + { desiredRunning: true }, + { desiredRunning: true, stop: Effect.die(new Error("backend stop failed")) }, + { desiredRunning: false }, + ], + }); + + return Effect.scoped( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.download; + yield* updates.install; + + assert.isFalse(yield* Ref.get(desktopState.quitting)); + const installEvents = harness.installEvents(); + assert.equal(installEvents[0], "prepare-install"); + assert.include(installEvents, "stop-backend"); + assert.include(installEvents, "stop-backend-2"); + assert.include(installEvents, "cancel-handoff"); + assert.include(installEvents, "start-backend"); + assert.include(installEvents, "start-backend-2"); + assert.notInclude(installEvents, "start-backend-3"); + assert.isBelow( + installEvents.indexOf("cancel-handoff"), + installEvents.indexOf("start-backend"), + ); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("restores running backends when committing the handoff fails", () => { + const checkpointTag = "lastcode/revision/v1.2.4-nightly.20260814.1089.1"; + const harness = makeHarness({ + localNightliesEnabled: true, + localInspection: { + schemaVersion: 2, + status: "available", + checkpointTag, + availableVersion: "1.2.4-nightly.20260814.1089.1", + releaseNotes: { + lastCode: { status: "known", items: [], omittedItems: 0 }, + upstream: { groups: [], omittedGroups: 0 }, + }, + }, + localBuild: { + schemaVersion: 1, + status: "built", + checkpointTag, + outputDir: "/tmp/lastcode-local-build", + manifestPath: "/tmp/lastcode-local-build/build-manifest.json", + dmgPath: "/tmp/lastcode-local-build/LastCode-1.2.4.dmg", + dmgSha256: "e".repeat(64), + }, + backends: [{ desiredRunning: true }, { desiredRunning: true }], + localPrepareInstall: () => + Effect.succeed({ + commit: Effect.fail( + new LastCodeLocalUpdates.LastCodeLocalUpdateError({ + operation: "install", + message: "Could not transfer local install ownership.", + }), + ), + cancel: Effect.void, + }), + }); + + return Effect.scoped( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.download; + yield* updates.install; + + assert.isFalse(yield* Ref.get(desktopState.quitting)); + assert.include(harness.installEvents(), "start-backend"); + assert.include(harness.installEvents(), "start-backend-2"); + const failed = yield* updates.getState; + assert.equal(failed.status, "downloaded"); + assert.equal(failed.errorContext, "install"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("keeps hosted installs on electron-updater", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + yield* updates.install; + assert.deepEqual(harness.localInstallArgs(), []); + assert.deepEqual(harness.installEvents(), [ + "stop-backend", + "destroy-windows", + "squirrel-install", + ]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("restores hosted updates immediately when local nightlies are disabled", () => { + const harness = makeHarness({ + localNightliesEnabled: true, + localInspection: { + schemaVersion: 2, + status: "up-to-date", + checkpointTag: "lastcode/checkpoint/v1.2.3-nightly.20260814.1089", + availableVersion: "1.2.3-nightly.20260814.1089", + }, + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const local = yield* updates.getState; + assert.equal(local.source, "lastcode-local"); + assert.equal(local.channel, "nightly"); + + const settings = yield* updates.setShowAndInstallLocalNightlies(false); + const hosted = yield* updates.getState; + + assert.isFalse(settings.showAndInstallLocalNightlies); + assert.equal(hosted.source, "hosted"); + assert.equal(hosted.channel, "latest"); + assert.isTrue(hosted.enabled); + assert.equal(harness.checkCount(), 1); + assert.deepEqual(harness.feedUrls().at(-1), { + provider: "generic", + url: "http://localhost:4141", + }); + assert.deepEqual(harness.differentialDownloadValues(), [false, false]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("serializes disabling local nightlies with an active checkpoint inspection", () => + Effect.gen(function* () { + const inspectionStarted = yield* Deferred.make(); + const releaseInspection = yield* Deferred.make(); + let blockInspection = false; + const inspection = { + schemaVersion: 2 as const, + status: "up-to-date" as const, + checkpointTag: "lastcode/checkpoint/v1.2.3-nightly.20260814.1089", + availableVersion: "1.2.3-nightly.20260814.1089", + }; + const harness = makeHarness({ + localNightliesEnabled: true, + localInspect: () => + blockInspection + ? Deferred.succeed(inspectionStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseInspection)), + Effect.as(inspection), + ) + : Effect.succeed(inspection), + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + blockInspection = true; + + const checkFiber = yield* updates.check("poll").pipe(Effect.forkScoped); + yield* Deferred.await(inspectionStarted); + const toggleFiber = yield* updates + .setShowAndInstallLocalNightlies(false) + .pipe(Effect.forkScoped); + + yield* Deferred.succeed(releaseInspection, undefined); + yield* Fiber.join(checkFiber); + const settings = yield* Fiber.join(toggleFiber); + const state = yield* updates.getState; + + assert.isFalse(settings.showAndInstallLocalNightlies); + assert.equal(state.source, "hosted"); + assert.isTrue(state.enabled); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ); + it.effect("enables nightly full changelog release notes and broadcasts summaries", () => { const harness = makeHarness(); @@ -502,9 +1163,14 @@ describe("DesktopUpdates", () => { const installStarted = yield* Deferred.make(); const releaseInstall = yield* Deferred.make(); const harness = makeHarness({ - stopBackend: Deferred.succeed(installStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseInstall)), - ), + backends: [ + { + desiredRunning: true, + stop: Deferred.succeed(installStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseInstall)), + ), + }, + ], }); yield* Effect.scoped( @@ -698,7 +1364,7 @@ describe("DesktopUpdates", () => { it.effect("clears quitting state after an unexpected install setup failure", () => { const harness = makeHarness({ - stopBackend: Effect.die(new Error("backend stop failed")), + backends: [{ desiredRunning: true, stop: Effect.die(new Error("backend stop failed")) }], }); return Effect.scoped( diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 483ace0ff439..f65a74ea6043 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1,9 +1,12 @@ import { DesktopUpdateChannelSchema, + type DesktopLocalBuildFailure, + type DesktopLastCodeSettingsState, type DesktopRuntimeInfo, type DesktopUpdateActionResult, type DesktopUpdateChannel, type DesktopUpdateCheckResult, + type DesktopUpdateReleaseNote, type DesktopUpdateState, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -16,6 +19,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; @@ -23,10 +27,12 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as LastCodeLocalUpdates from "./LastCodeLocalUpdates.ts"; import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; import { @@ -38,6 +44,9 @@ import { reduceDesktopUpdateStateOnDownloadProgress, reduceDesktopUpdateStateOnDownloadStart, reduceDesktopUpdateStateOnInstallFailure, + reduceDesktopUpdateStateOnLocalBuildFailure, + reduceDesktopUpdateStateOnLocalBuildProgress, + reduceDesktopUpdateStateOnLocalBuildStart, reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine.ts"; @@ -67,6 +76,67 @@ const decodeDownloadProgressInfo = Schema.decodeUnknownEffect(DownloadProgressIn const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); +export function mapLastCodeLocalReleaseNotes( + inspection: Extract< + LastCodeLocalUpdates.LastCodeLocalUpdateInspection, + { readonly status: "available" } + >, +): ReadonlyArray { + const groups: DesktopUpdateReleaseNote[] = []; + const lastCode = inspection.releaseNotes.lastCode; + if (lastCode.status === "unavailable") { + groups.push({ + version: inspection.availableVersion, + heading: "LastCode changes", + items: [], + summaries: ["Couldn’t determine changes from this installed build."], + }); + } else if (lastCode.items.length > 0) { + groups.push({ + version: inspection.availableVersion, + heading: "LastCode changes", + items: lastCode.items, + ...(lastCode.omittedItems > 0 + ? { + summaries: [ + `…and ${lastCode.omittedItems} more LastCode ${lastCode.omittedItems === 1 ? "change" : "changes"}`, + ], + } + : {}), + }); + } + + const upstreamGroups = inspection.releaseNotes.upstream.groups.map( + (group): DesktopUpdateReleaseNote => ({ + version: group.version, + heading: group.isTarget ? "Upstream changes" : `Upstream changes in ${group.version}`, + items: group.items, + ...(group.omittedItems > 0 + ? { + summaries: [ + `…and ${group.omittedItems} more ${group.omittedItems === 1 ? "change" : "changes"}`, + ], + } + : {}), + }), + ); + const omittedGroups = inspection.releaseNotes.upstream.omittedGroups; + if (omittedGroups > 0 && upstreamGroups.length > 0) { + const index = upstreamGroups.length - 1; + const oldest = upstreamGroups[index]; + if (oldest) { + upstreamGroups[index] = { + ...oldest, + summaries: [ + ...(oldest.summaries ?? []), + `${omittedGroups} older ${omittedGroups === 1 ? "nightly" : "nightlies"} not shown`, + ], + }; + } + } + return [...groups, ...upstreamGroups]; +} + export class DesktopUpdateActionInProgressError extends Schema.TaggedErrorClass()( "DesktopUpdateActionInProgressError", { @@ -152,6 +222,10 @@ export class DesktopUpdates extends Context.Service< DesktopUpdates, { readonly getState: Effect.Effect; + readonly getLastCodeSettings: Effect.Effect; + readonly setShowAndInstallLocalNightlies: ( + enabled: boolean, + ) => Effect.Effect; readonly emitState: Effect.Effect; readonly disabledReason: Effect.Effect>; readonly configure: Effect.Effect; @@ -189,10 +263,12 @@ function createBaseUpdateState( channel: DesktopUpdateChannel, enabled: boolean, environment: DesktopEnvironment.DesktopEnvironment["Service"], + source: DesktopUpdateState["source"] = "hosted", ): DesktopUpdateState { return { ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel), enabled, + source, status: enabled ? "idle" : "disabled", }; } @@ -250,16 +326,26 @@ export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; const pool = yield* DesktopBackendPool.DesktopBackendPool; const desktopState = yield* DesktopState.DesktopState; + const electronApp = yield* ElectronApp.ElectronApp; const electronUpdater = yield* ElectronUpdater.ElectronUpdater; const electronWindow = yield* ElectronWindow.ElectronWindow; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const localUpdates = yield* LastCodeLocalUpdates.LastCodeLocalUpdates; const appUpdateYmlConfigRef = yield* Ref.make>(Option.none()); const activeUpdateActionRef = yield* Ref.make>(Option.none()); const updaterConfiguredRef = yield* Ref.make(false); const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); + const localCheckpointTagRef = yield* Ref.make>(Option.none()); + const localBuildRef = yield* Ref.make< + Option.Option<{ + readonly build: LastCodeLocalUpdates.LastCodeLocalUpdateBuild; + readonly version: string; + }> + >(Option.none()); + const checkTransitionMutex = yield* Semaphore.make(1); const updateStateRef = yield* Ref.make( createInitialDesktopUpdateState( environment.appVersion, @@ -299,7 +385,27 @@ export const make = Effect.gen(function* () { Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates), ); + const applyHostedFeed = Effect.gen(function* () { + if (config.mockUpdates) { + yield* electronUpdater.setFeedURL({ + provider: "generic", + url: `http://localhost:${config.mockUpdateServerPort}`, + } as ElectronUpdater.ElectronUpdaterFeedUrl); + return; + } + const appUpdateYmlConfig = yield* Ref.get(appUpdateYmlConfigRef); + if (Option.isSome(appUpdateYmlConfig)) { + yield* electronUpdater.setFeedURL( + appUpdateYmlConfig.value as ElectronUpdater.ElectronUpdaterFeedUrl, + ); + } + }); + const resolveDisabledReason = Effect.gen(function* () { + const settings = yield* desktopSettings.get; + if (settings.showAndInstallLocalNightlies && localUpdates.supported) { + return Option.none(); + } const hasFeedConfig = yield* hasUpdateFeedConfig; return Option.fromNullishOr( getAutoUpdateDisabledReason({ @@ -350,7 +456,62 @@ export const make = Effect.gen(function* () { const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone)); - const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* ( + const localNightliesUnsupportedMessage = + "Local LastCode nightlies require a packaged macOS build running on Apple Silicon."; + + const makeLastCodeSettingsState = Effect.gen(function* () { + const settings = yield* desktopSettings.get; + const updateState = yield* Ref.get(updateStateRef); + return { + supported: localUpdates.supported, + showAndInstallLocalNightlies: settings.showAndInstallLocalNightlies, + message: !localUpdates.supported + ? localNightliesUnsupportedMessage + : updateState.source === "lastcode-local" && updateState.status === "error" + ? updateState.message + : null, + } satisfies DesktopLastCodeSettingsState; + }); + + const checkForLocalUpdate = Effect.fn("desktop.updates.checkForLocalUpdate")(function* ( + reason: string, + ) { + yield* Effect.annotateCurrentSpan({ reason }); + const state = yield* Ref.get(updateStateRef); + const checkedAt = yield* currentIsoTimestamp; + yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt)); + return yield* localUpdates.inspect(environment.appVersion).pipe( + Effect.flatMap((inspection) => { + if (inspection.status === "up-to-date") { + return Ref.set(localCheckpointTagRef, Option.none()).pipe( + Effect.andThen(setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt))), + Effect.as(true), + ); + } + const releaseNotes = mapLastCodeLocalReleaseNotes(inspection); + return Ref.set(localCheckpointTagRef, Option.some(inspection.checkpointTag)).pipe( + Effect.andThen( + setState( + reduceDesktopUpdateStateOnUpdateAvailable( + state, + inspection.availableVersion, + checkedAt, + releaseNotes, + ), + ), + ), + Effect.as(true), + ); + }), + Effect.catchTag("LastCodeLocalUpdateError", (error) => + setState(reduceDesktopUpdateStateOnCheckFailure(state, error.message, checkedAt)).pipe( + Effect.as(true), + ), + ), + ); + }); + + const checkForUpdatesUnlocked = Effect.fn("desktop.updates.checkForUpdatesUnlocked")(function* ( reason: string, actionReservation: "acquire" | "held" = "acquire", ) { @@ -367,8 +528,18 @@ export const make = Effect.gen(function* () { return false; } + if (state.source === "lastcode-local" && (!state.enabled || !localUpdates.supported)) { + return false; + } if (actionReservation === "acquire" && !(yield* tryStartUpdateAction("check"))) return false; + if (state.source === "lastcode-local") { + const check = checkForLocalUpdate(reason); + return yield* actionReservation === "held" + ? check + : check.pipe(Effect.ensuring(finishUpdateAction("check"))); + } + const check = Effect.gen(function* () { const checkedAt = yield* currentIsoTimestamp; yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt)); @@ -399,9 +570,24 @@ export const make = Effect.gen(function* () { : check.pipe(Effect.ensuring(finishUpdateAction("check"))); }); + const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")( + (reason: string, actionReservation: "acquire" | "held" = "acquire") => + checkTransitionMutex.withPermit(checkForUpdatesUnlocked(reason, actionReservation)), + ); + const downloadAvailableUpdate = Effect.gen(function* () { const state = yield* Ref.get(updateStateRef); - if (!(yield* Ref.get(updaterConfiguredRef)) || state.status !== "available") { + const canRetryLocalBuild = + state.source === "lastcode-local" && + state.status === "error" && + state.errorContext === "download" && + state.canRetry && + state.availableVersion !== null && + state.localBuildFailure !== null; + if ( + !(yield* Ref.get(updaterConfiguredRef)) || + (state.status !== "available" && !canRetryLocalBuild) + ) { return { accepted: false, completed: false }; } @@ -410,7 +596,49 @@ export const make = Effect.gen(function* () { } return yield* Effect.gen(function* () { - yield* setState(reduceDesktopUpdateStateOnDownloadStart(state)); + if (state.source === "lastcode-local") { + yield* Ref.set(localBuildRef, Option.none()); + const checkpointTag = yield* Ref.get(localCheckpointTagRef).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new LastCodeLocalUpdates.LastCodeLocalUpdateError({ + operation: "build", + message: "The available LastCode update is no longer selected. Check again.", + }), + ), + onSome: Effect.succeed, + }), + ), + ); + yield* setState( + reduceDesktopUpdateStateOnLocalBuildStart(state, { + checkpointTag, + ...LastCodeLocalUpdates.initialLocalBuildProgress(), + }), + ); + yield* logUpdaterInfo("building local LastCode nightly", { checkpointTag }); + const build = yield* localUpdates.build(checkpointTag, (progress) => + updateState((current) => + reduceDesktopUpdateStateOnLocalBuildProgress(current, { + checkpointTag, + ...progress, + }), + ).pipe(Effect.asVoid), + ); + const version = state.availableVersion; + if (!version) { + return yield* new LastCodeLocalUpdates.LastCodeLocalUpdateError({ + operation: "build", + message: "The built LastCode update no longer has a selected version. Check again.", + }); + } + yield* Ref.set(localBuildRef, Option.some({ build, version })); + yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, version)); + return { accepted: true, completed: true }; + } + yield* setState(reduceDesktopUpdateStateOnDownloadStart(state, 0)); yield* electronUpdater.setDisableDifferentialDownload( isArm64HostRunningIntelBuild(environment.runtimeInfo), ); @@ -419,6 +647,30 @@ export const make = Effect.gen(function* () { return { accepted: true, completed: true }; }).pipe( Effect.catchTags({ + LastCodeLocalUpdateError: Effect.fn("desktop.updates.handleLocalBuildFailure")( + function* (error) { + yield* updateState((current) => { + const checkpointTag = current.localBuildProgress?.checkpointTag ?? "unavailable"; + const progress = current.localBuildProgress ?? { + checkpointTag, + ...LastCodeLocalUpdates.initialLocalBuildProgress(), + }; + const failure = { + ...progress, + currentVersion: current.currentVersion, + targetVersion: current.availableVersion ?? current.currentVersion, + logPath: localUpdates.buildLogPath, + error: error.message.replaceAll("\0", "").slice(0, 32_000), + } satisfies DesktopLocalBuildFailure; + return reduceDesktopUpdateStateOnLocalBuildFailure(current, failure); + }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + operation: error.operation, + }); + return { accepted: true, completed: false }; + }, + ), ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")( function* (error) { yield* updateState((current) => @@ -443,9 +695,23 @@ export const make = Effect.gen(function* () { } const error = new DesktopUpdateUnexpectedActionError({ action: "download", cause }); return Effect.gen(function* () { - yield* updateState((current) => - reduceDesktopUpdateStateOnDownloadFailure(current, error.message), - ); + yield* updateState((current) => { + if (state.source !== "lastcode-local") { + return reduceDesktopUpdateStateOnDownloadFailure(current, error.message); + } + const checkpointTag = current.localBuildProgress?.checkpointTag ?? "unavailable"; + const progress = current.localBuildProgress ?? { + checkpointTag, + ...LastCodeLocalUpdates.initialLocalBuildProgress(), + }; + return reduceDesktopUpdateStateOnLocalBuildFailure(current, { + ...progress, + currentVersion: current.currentVersion, + targetVersion: current.availableVersion ?? current.currentVersion, + logPath: localUpdates.buildLogPath, + error: error.message, + }); + }); yield* logUpdaterError(error.message, { errorTag: error._tag, action: error.action, @@ -481,9 +747,76 @@ export const make = Effect.gen(function* () { return { accepted: false, completed: false }; } - yield* Ref.set(desktopState.quitting, true); - return yield* Effect.gen(function* () { + if (state.source === "lastcode-local") { + const selected = yield* Ref.get(localBuildRef).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new LastCodeLocalUpdates.LastCodeLocalUpdateError({ + operation: "install", + message: "The built LastCode DMG is no longer selected. Build it again.", + }), + ), + onSome: Effect.succeed, + }), + ), + ); + if (selected.version !== state.downloadedVersion) { + return yield* new LastCodeLocalUpdates.LastCodeLocalUpdateError({ + operation: "install", + message: "The selected LastCode DMG does not match the downloaded version.", + }); + } + return yield* Effect.acquireUseRelease( + localUpdates.prepareInstall({ + dmgPath: selected.build.dmgPath, + dmgSha256: selected.build.dmgSha256, + expectedVersion: selected.version, + }), + (acceptedHandoff) => + Effect.gen(function* () { + const instances = yield* pool.list; + const instanceSnapshots = yield* Effect.forEach( + instances, + (instance) => + instance.snapshot.pipe(Effect.map((snapshot) => ({ instance, snapshot }))), + { concurrency: "unbounded" }, + ); + const previouslyRunningInstances = instanceSnapshots + .filter(({ snapshot }) => snapshot.desiredRunning) + .map(({ instance }) => instance); + + yield* Ref.set(desktopState.quitting, true); + yield* Effect.gen(function* () { + yield* Effect.forEach( + instances, + (instance) => instance.stop({ timeout: Duration.seconds(5) }), + { concurrency: "unbounded" }, + ); + yield* acceptedHandoff.commit; + }).pipe( + Effect.catchCause((cause) => + acceptedHandoff.cancel.pipe( + Effect.andThen( + Effect.forEach(previouslyRunningInstances, (instance) => instance.start, { + concurrency: "unbounded", + discard: true, + }), + ), + Effect.andThen(Effect.failCause(cause)), + ), + ), + ); + yield* electronApp.quit; + return { accepted: true, completed: false }; + }), + (acceptedHandoff) => acceptedHandoff.cancel, + ); + } + + yield* Ref.set(desktopState.quitting, true); // Stop every backend in the pool, not just the primary. With // parallel WSL + Windows backends, leaving the WSL instance up // means quitAndInstall's app.quit() exits before the pool's @@ -505,6 +838,19 @@ export const make = Effect.gen(function* () { return { accepted: true, completed: false }; }).pipe( Effect.catchTags({ + LastCodeLocalUpdateError: Effect.fn("desktop.updates.handleLocalInstallFailure")( + function* (error) { + yield* resetInstallAction; + yield* updateState((current) => + reduceDesktopUpdateStateOnInstallFailure(current, error.message), + ); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + operation: error.operation, + }); + return { accepted: true, completed: false }; + }, + ), ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")( function* (error) { yield* resetInstallAction; @@ -593,7 +939,21 @@ export const make = Effect.gen(function* () { } const checkedAt = yield* currentIsoTimestamp; - const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version); + const releaseNotes = + state.source === "lastcode-local" + ? state.releaseNotes + : normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version); + const activeAction = yield* activeUpdateAction; + if ( + state.source === "lastcode-local" && + Option.isSome(activeAction) && + activeAction.value === "download" + ) { + yield* logUpdaterInfo("locally built update staged for download", { + version: info.version, + }); + return; + } yield* setState( reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes), ); @@ -723,6 +1083,39 @@ export const make = Effect.gen(function* () { return DesktopUpdates.of({ getState: Ref.get(updateStateRef), + getLastCodeSettings: makeLastCodeSettingsState, + setShowAndInstallLocalNightlies: Effect.fn("desktop.updates.setShowAndInstallLocalNightlies")( + (requestedEnabled: boolean) => + checkTransitionMutex.withPermit( + Effect.gen(function* () { + if (Option.isSome(yield* activeUpdateAction)) { + return yield* makeLastCodeSettingsState; + } + const localEnabled = requestedEnabled && localUpdates.supported; + const settings = (yield* desktopSettings.setShowAndInstallLocalNightlies(localEnabled)) + .settings; + const hostedEnabled = yield* shouldEnableAutoUpdates; + const source = localEnabled || !hostedEnabled ? "lastcode-local" : "hosted"; + const enabled = source === "lastcode-local" ? localEnabled : hostedEnabled; + const channel = source === "lastcode-local" ? "nightly" : settings.updateChannel; + yield* Ref.set(localCheckpointTagRef, Option.none()); + yield* setState(createBaseUpdateState(channel, enabled, environment, source)); + if (yield* Ref.get(updaterConfiguredRef)) { + if (source === "hosted") { + yield* applyHostedFeed; + } + yield* applyAutoUpdaterChannel(channel); + yield* electronUpdater.setDisableDifferentialDownload( + isArm64HostRunningIntelBuild(environment.runtimeInfo), + ); + } + if (enabled && (yield* Ref.get(updaterConfiguredRef))) { + yield* checkForUpdatesUnlocked("local-nightlies-setting-change"); + } + return yield* makeLastCodeSettingsState; + }), + ), + ), emitState, disabledReason: resolveDisabledReason, configure: Effect.gen(function* () { @@ -734,24 +1127,28 @@ export const make = Effect.gen(function* () { const appUpdateYmlConfig = yield* readAppUpdateYml; yield* Ref.set(appUpdateYmlConfigRef, appUpdateYmlConfig); - if (config.mockUpdates) { - yield* electronUpdater.setFeedURL({ - provider: "generic", - url: `http://localhost:${config.mockUpdateServerPort}`, - } as ElectronUpdater.ElectronUpdaterFeedUrl); - } + yield* applyHostedFeed; const settings = yield* desktopSettings.get; - const enabled = yield* shouldEnableAutoUpdates; - yield* setState(createBaseUpdateState(settings.updateChannel, enabled, environment)); - if (!enabled) { - return; - } + const hostedEnabled = yield* shouldEnableAutoUpdates; + const localEnabled = settings.showAndInstallLocalNightlies && localUpdates.supported; + const source = localEnabled || !hostedEnabled ? "lastcode-local" : "hosted"; + const enabled = source === "lastcode-local" ? localEnabled : hostedEnabled; + yield* setState( + createBaseUpdateState( + source === "lastcode-local" ? "nightly" : settings.updateChannel, + enabled, + environment, + source, + ), + ); yield* Ref.set(updaterConfiguredRef, true); yield* electronUpdater.setAutoDownload(false); yield* electronUpdater.setAutoInstallOnAppQuit(false); - yield* applyAutoUpdaterChannel(settings.updateChannel); + yield* applyAutoUpdaterChannel( + source === "lastcode-local" ? "nightly" : settings.updateChannel, + ); yield* electronUpdater.setDisableDifferentialDownload( isArm64HostRunningIntelBuild(environment.runtimeInfo), ); @@ -786,6 +1183,9 @@ export const make = Effect.gen(function* () { }); yield* startUpdatePollers; + if (localEnabled) { + yield* checkForUpdates("local-nightlies-enabled"); + } }).pipe(Effect.withSpan("desktop.updates.configure")), setChannel: Effect.fn("desktop.updates.setChannel")(function* ( nextChannel: DesktopUpdateChannel, @@ -801,6 +1201,9 @@ export const make = Effect.gen(function* () { return yield* Effect.gen(function* () { const state = yield* Ref.get(updateStateRef); + if (state.source === "lastcode-local") { + return state; + } if (nextChannel === state.channel) { return state; } diff --git a/apps/desktop/src/updates/LastCodeLocalUpdates.test.ts b/apps/desktop/src/updates/LastCodeLocalUpdates.test.ts new file mode 100644 index 000000000000..03832a4e0413 --- /dev/null +++ b/apps/desktop/src/updates/LastCodeLocalUpdates.test.ts @@ -0,0 +1,236 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Progress transport tests use isolated host-side log fixtures. +import { assert, describe, it } from "@effect/vitest"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; + +import { + groupedInspectionArgs, + LocalBuildProgressTracker, + monitorLocalBuildProgress, + parseHelperResult, + terminateHelperProcess, + usesDetachedHelperProcessGroup, +} from "./LastCodeLocalUpdates.ts"; + +describe("LastCodeLocalUpdates", () => { + const withBuildLog = (run: (logPath: string) => void) => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-progress-")); + const logPath = NodePath.join(directory, "build.log"); + try { + run(logPath); + } finally { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } + }; + + it("requests the negotiated grouped release notes format", () => { + assert.deepEqual(groupedInspectionArgs("1.2.3-nightly.4"), [ + "--current-version", + "1.2.3-nightly.4", + "--release-notes-format", + "grouped-v1", + ]); + }); + + it("parses the helper's final structured result", () => { + assert.deepEqual( + parseHelperResult( + 'noise\nLASTCODE_LOCAL_UPDATE_RESULT={"schemaVersion":1,"status":"built"}\n', + ), + { schemaVersion: 1, status: "built" }, + ); + assert.throws(() => parseHelperResult("noise only"), /did not return a result/); + }); + + it("terminates the detached helper process group on macOS", () => { + const groupSignals: Array<[number, NodeJS.Signals]> = []; + let directKills = 0; + + assert.isTrue(usesDetachedHelperProcessGroup("darwin")); + assert.isFalse(usesDetachedHelperProcessGroup("win32")); + assert.isTrue( + terminateHelperProcess( + { + pid: 4242, + kill: () => { + directKills += 1; + return true; + }, + }, + "darwin", + (pid, signal) => { + groupSignals.push([pid, signal]); + return true; + }, + ), + ); + assert.deepEqual(groupSignals, [[-4242, "SIGKILL"]]); + assert.equal(directKills, 0); + }); + + it("tails only bytes appended after tracking starts", () => { + withBuildLog((logPath) => { + NodeFS.writeFileSync(logPath, "[desktop-artifact] Building mac/dmg\n", "utf8"); + const tracker = new LocalBuildProgressTracker(logPath, 0); + + assert.isNull(tracker.poll(0)); + NodeFS.appendFileSync(logPath, "[lastcode:ci] 4/11 Workspace tests\n", "utf8"); + + assert.deepEqual(tracker.poll(100), { + phase: "Workspace tests", + percent: 20, + errorKind: "build", + }); + }); + }); + + it("recognizes split markers and resets safely after truncation", () => { + withBuildLog((logPath) => { + NodeFS.writeFileSync(logPath, "", "utf8"); + const tracker = new LocalBuildProgressTracker(logPath, 0); + + NodeFS.appendFileSync(logPath, "[desktop-artifact] Building desktop/", "utf8"); + assert.isNull(tracker.poll(100)); + NodeFS.appendFileSync(logPath, "server/web artifacts\n", "utf8"); + assert.deepEqual(tracker.poll(200), { + phase: "Building artifacts", + percent: 78, + errorKind: "packaging", + }); + + NodeFS.truncateSync(logPath, 0); + assert.isNull(tracker.poll(300)); + NodeFS.appendFileSync(logPath, "[desktop-artifact] Building mac/dmg\n", "utf8"); + assert.deepEqual(tracker.poll(400), { + phase: "Building DMG", + percent: 94, + errorKind: "packaging", + }); + }); + }); + + it("does not skip markers before an oversized appended burst", () => { + withBuildLog((logPath) => { + const tracker = new LocalBuildProgressTracker(logPath, 0); + + NodeFS.appendFileSync( + logPath, + `[desktop-artifact] Building desktop/server/web artifacts\n${"x".repeat(512_100)}`, + "utf8", + ); + + assert.deepEqual(tracker.poll(100), { + phase: "Building artifacts", + percent: 78, + errorKind: "packaging", + }); + }); + }); + + it("bounds each poll while continuing through an oversized appended burst", () => { + withBuildLog((logPath) => { + const tracker = new LocalBuildProgressTracker(logPath, 0); + + NodeFS.appendFileSync( + logPath, + `${"x".repeat(512_000)}[desktop-artifact] Building mac/dmg\n`, + "utf8", + ); + + assert.isNull(tracker.poll(100)); + assert.deepEqual(tracker.poll(200), { + phase: "Building DMG", + percent: 94, + errorKind: "packaging", + }); + }); + }); + + it("coalesces interpolation while keeping progress bounded below completion", () => { + withBuildLog((logPath) => { + const tracker = new LocalBuildProgressTracker(logPath, 0); + + NodeFS.writeFileSync(logPath, "[lastcode:ci] 3/11 Workspace typecheck\n", "utf8"); + assert.deepEqual(tracker.poll(100), { + phase: "Typechecking", + percent: 14, + errorKind: "build", + }); + assert.isNull(tracker.poll(500)); + const later = tracker.poll(10_100); + assert.isNotNull(later); + assert.isAbove(later?.percent ?? 0, 14); + assert.isBelow(later?.percent ?? 100, 100); + assert.isNull(tracker.poll(10_100)); + }); + }); + + it.effect("stops tailing when the helper finishes", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-monitor-")); + return { directory, logPath: NodePath.join(directory, "build.log") }; + }), + ({ logPath }) => + Effect.gen(function* () { + NodeFS.writeFileSync(logPath, "", "utf8"); + const helperDone = yield* Deferred.make(); + const observed = yield* Ref.make>([]); + const helperFiber = yield* monitorLocalBuildProgress( + logPath, + Deferred.await(helperDone), + (progress) => Ref.update(observed, (phases) => [...phases, progress.phase]), + ).pipe(Effect.forkChild({ startImmediately: true })); + + NodeFS.appendFileSync(logPath, "[lastcode:ci] 4/11 Workspace tests\n", "utf8"); + yield* TestClock.adjust(Duration.millis(400)); + assert.deepEqual(yield* Ref.get(observed), ["Workspace tests"]); + + yield* Deferred.succeed(helperDone, undefined); + yield* Fiber.join(helperFiber); + NodeFS.appendFileSync(logPath, "[desktop-artifact] Building mac/dmg\n", "utf8"); + yield* TestClock.adjust(Duration.seconds(1)); + assert.deepEqual(yield* Ref.get(observed), ["Workspace tests"]); + }), + ({ directory }) => Effect.sync(() => NodeFS.rmSync(directory, { recursive: true })), + ), + ); + + it.effect("drains pending chunks before the helper failure finishes", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "lastcode-drain-")); + return { directory, logPath: NodePath.join(directory, "build.log") }; + }), + ({ logPath }) => + Effect.gen(function* () { + NodeFS.writeFileSync(logPath, "", "utf8"); + const helperDone = yield* Deferred.make(); + const observed = yield* Ref.make>([]); + const helperFiber = yield* monitorLocalBuildProgress( + logPath, + Deferred.await(helperDone), + (progress) => Ref.update(observed, (phases) => [...phases, progress.phase]), + ).pipe(Effect.forkChild({ startImmediately: true })); + + NodeFS.appendFileSync( + logPath, + `${"x".repeat(512_000)}[desktop-artifact] Building mac/dmg\n`, + "utf8", + ); + yield* Deferred.fail(helperDone, "failed"); + yield* Fiber.await(helperFiber); + + assert.deepEqual(yield* Ref.get(observed), ["Building DMG"]); + }), + ({ directory }) => Effect.sync(() => NodeFS.rmSync(directory, { recursive: true })), + ), + ); +}); diff --git a/apps/desktop/src/updates/LastCodeLocalUpdates.ts b/apps/desktop/src/updates/LastCodeLocalUpdates.ts new file mode 100644 index 000000000000..308b590af065 --- /dev/null +++ b/apps/desktop/src/updates/LastCodeLocalUpdates.ts @@ -0,0 +1,680 @@ +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off -- This desktop-only service launches helpers and owns a Node pipe timeout inside a Promise callback. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeStream from "node:stream"; +import * as NodeStringDecoder from "node:string_decoder"; +import * as NodeUtil from "node:util"; + +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { + BUILD_PHASES, + estimateBuildProgress, + resolveBuildPhaseIndex, +} from "../../../../scripts/lib/lastcode-build-progress.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; + +const RESULT_PREFIX = "LASTCODE_LOCAL_UPDATE_RESULT="; +const INSTALL_READY_PREFIX = "LASTCODE_INSTALL_READY="; +const BUILD_PROGRESS_POLL_INTERVAL = Duration.millis(400); +const BUILD_PROGRESS_EMIT_INTERVAL_MS = 1_000; +const MAX_BUILD_LOG_BYTES_PER_POLL = 512_000; +const BUILD_LOG_MARKER_CARRY_LENGTH = 512; +const PACKAGING_PHASE_INDEX = BUILD_PHASES.findIndex( + ({ marker }) => marker === "[desktop-artifact] Building desktop/server/web artifacts", +); + +export interface LastCodeLocalBuildProgress { + readonly phase: string; + readonly percent: number; + readonly errorKind: "build" | "packaging"; +} + +interface BuildLogIdentity { + readonly device: number; + readonly inode: number; +} + +function readBuildLogIdentity(stat: NodeFS.Stats): BuildLogIdentity { + return { device: stat.dev, inode: stat.ino }; +} + +function sameBuildLogIdentity( + left: BuildLogIdentity | undefined, + right: BuildLogIdentity, +): boolean { + return left?.device === right.device && left.inode === right.inode; +} + +export function resolveLocalBuildErrorKind(phaseIndex: number): "build" | "packaging" { + return phaseIndex >= PACKAGING_PHASE_INDEX ? "packaging" : "build"; +} + +export function initialLocalBuildProgress(): LastCodeLocalBuildProgress { + return { + phase: BUILD_PHASES[0].label, + percent: 0, + errorKind: "build", + }; +} + +export class LocalBuildProgressTracker { + readonly #logPath: string; + #offset = 0; + #identity: BuildLogIdentity | undefined; + #decoder = new NodeStringDecoder.StringDecoder("utf8"); + #markerCarry = ""; + #phaseIndex = 0; + #phaseStartedAt: number; + #lastEmittedAt: number; + #lastEmittedPercent = 0; + + constructor(logPath: string, startedAt: number) { + this.#logPath = logPath; + this.#phaseStartedAt = startedAt; + this.#lastEmittedAt = startedAt; + try { + const stat = NodeFS.statSync(logPath); + this.#offset = stat.size; + this.#identity = readBuildLogIdentity(stat); + } catch (cause) { + if (!(cause instanceof Error) || !("code" in cause) || cause.code !== "ENOENT") throw cause; + } + } + + poll(now: number): LastCodeLocalBuildProgress | null { + const previousPhaseIndex = this.#phaseIndex; + for (const appended of this.#readAppendedLog()) { + const plainAppended = NodeUtil.stripVTControlCharacters(appended); + this.#phaseIndex = resolveBuildPhaseIndex( + `${this.#markerCarry}${plainAppended}`, + this.#phaseIndex, + ); + this.#markerCarry = `${this.#markerCarry}${plainAppended}`.slice( + -BUILD_LOG_MARKER_CARRY_LENGTH, + ); + if (this.#phaseIndex !== previousPhaseIndex) this.#phaseStartedAt = now; + } + + const percent = Math.min( + 99, + Math.max( + this.#lastEmittedPercent, + Math.floor(estimateBuildProgress(this.#phaseIndex, now - this.#phaseStartedAt) * 100), + ), + ); + const phaseChanged = this.#phaseIndex !== previousPhaseIndex; + const interpolationDue = + percent > this.#lastEmittedPercent && + now - this.#lastEmittedAt >= BUILD_PROGRESS_EMIT_INTERVAL_MS; + if (!phaseChanged && !interpolationDue) return null; + + this.#lastEmittedAt = now; + this.#lastEmittedPercent = percent; + return { + phase: BUILD_PHASES[this.#phaseIndex]?.label ?? BUILD_PHASES[0].label, + percent, + errorKind: resolveLocalBuildErrorKind(this.#phaseIndex), + }; + } + + hasPendingLogBytes(): boolean { + try { + const stat = NodeFS.statSync(this.#logPath); + return ( + !sameBuildLogIdentity(this.#identity, readBuildLogIdentity(stat)) || + stat.size !== this.#offset + ); + } catch (cause) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return false; + throw cause; + } + } + + *#readAppendedLog(): Generator { + let stat: NodeFS.Stats; + try { + stat = NodeFS.statSync(this.#logPath); + } catch (cause) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return; + throw cause; + } + + const identity = readBuildLogIdentity(stat); + if (!sameBuildLogIdentity(this.#identity, identity) || stat.size < this.#offset) { + this.#offset = 0; + this.#identity = identity; + this.#decoder = new NodeStringDecoder.StringDecoder("utf8"); + this.#markerCarry = ""; + } + if (stat.size <= this.#offset) return; + + const fd = NodeFS.openSync(this.#logPath, "r"); + try { + const length = Math.min(MAX_BUILD_LOG_BYTES_PER_POLL, stat.size - this.#offset); + const buffer = Buffer.allocUnsafe(length); + const bytesRead = NodeFS.readSync(fd, buffer, 0, length, this.#offset); + if (bytesRead === 0) return; + this.#offset += bytesRead; + yield this.#decoder.write(buffer.subarray(0, bytesRead)); + } finally { + NodeFS.closeSync(fd); + } + } +} + +export function monitorLocalBuildProgress( + logPath: string, + helperEffect: Effect.Effect, + onProgress: (progress: LastCodeLocalBuildProgress) => Effect.Effect, +): Effect.Effect { + return Effect.gen(function* () { + const startedAt = yield* Clock.currentTimeMillis; + const tracker = new LocalBuildProgressTracker(logPath, startedAt); + const pollProgress = Clock.currentTimeMillis.pipe( + Effect.flatMap((now) => { + const progress = tracker.poll(now); + return progress ? onProgress(progress) : Effect.void; + }), + ); + const monitor = Effect.forever( + pollProgress.pipe( + Effect.catchCause(() => Effect.void), + Effect.andThen(Effect.sleep(BUILD_PROGRESS_POLL_INTERVAL)), + ), + ); + const monitorFiber = yield* monitor.pipe(Effect.forkChild({ startImmediately: true })); + const drainProgress = Effect.gen(function* () { + while (tracker.hasPendingLogBytes()) { + yield* pollProgress; + yield* Effect.yieldNow; + } + }).pipe(Effect.catchCause(() => Effect.void)); + return yield* helperEffect.pipe( + Effect.ensuring( + drainProgress.pipe(Effect.andThen(Fiber.interrupt(monitorFiber)), Effect.asVoid), + ), + ); + }); +} + +const LastCodeReleaseNotes = Schema.Union([ + Schema.Struct({ + status: Schema.Literal("known"), + items: Schema.Array(Schema.String), + omittedItems: Schema.Number, + }), + Schema.Struct({ status: Schema.Literal("unavailable") }), +]); + +const UpstreamReleaseNotes = Schema.Struct({ + groups: Schema.Array( + Schema.Struct({ + version: Schema.String, + isTarget: Schema.Boolean, + items: Schema.Array(Schema.String), + omittedItems: Schema.Number, + }), + ), + omittedGroups: Schema.Number, +}); + +const InspectionResult = Schema.Union([ + Schema.Struct({ + schemaVersion: Schema.Literal(2), + status: Schema.Literal("up-to-date"), + checkpointTag: Schema.String, + availableVersion: Schema.String, + }), + Schema.Struct({ + schemaVersion: Schema.Literal(2), + status: Schema.Literal("available"), + checkpointTag: Schema.String, + availableVersion: Schema.String, + releaseNotes: Schema.Struct({ + lastCode: LastCodeReleaseNotes, + upstream: UpstreamReleaseNotes, + }), + }), +]); +export type LastCodeLocalUpdateInspection = typeof InspectionResult.Type; + +const BuildResult = Schema.Struct({ + schemaVersion: Schema.Literal(1), + status: Schema.Literal("built"), + checkpointTag: Schema.String, + outputDir: Schema.String, + manifestPath: Schema.String, + dmgPath: Schema.String, + dmgSha256: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), +}); +export type LastCodeLocalUpdateBuild = typeof BuildResult.Type; + +const InstallReadyResult = Schema.Struct({ + schemaVersion: Schema.Literal(1), + artifactPath: Schema.String, + version: Schema.String, +}); +const decodeInstallReadyResult = Schema.decodeUnknownSync(InstallReadyResult); + +const DashboardConfig = Schema.Struct({ repoRoot: Schema.String }); +const decodeDashboardConfig = Schema.decodeUnknownSync(Schema.fromJsonString(DashboardConfig)); +const decodeInspectionResult = Schema.decodeUnknownSync(InspectionResult); +const decodeBuildResult = Schema.decodeUnknownSync(BuildResult); + +export class LastCodeLocalUpdateError extends Schema.TaggedErrorClass()( + "LastCodeLocalUpdateError", + { + operation: Schema.Literals(["configuration", "inspect", "build", "install"]), + message: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), + }, +) {} + +export interface LastCodeLocalInstallHandoff { + readonly commit: Effect.Effect; + readonly cancel: Effect.Effect; +} + +export class LastCodeLocalUpdates extends Context.Service< + LastCodeLocalUpdates, + { + readonly supported: boolean; + readonly buildLogPath: string; + readonly inspect: ( + currentVersion: string, + ) => Effect.Effect; + readonly build: ( + checkpointTag: string, + onProgress?: (progress: LastCodeLocalBuildProgress) => Effect.Effect, + ) => Effect.Effect; + readonly prepareInstall: (args: { + readonly dmgPath: string; + readonly dmgSha256: string; + readonly expectedVersion: string; + }) => Effect.Effect; + } +>()("@t3tools/desktop/updates/LastCodeLocalUpdates") {} + +const isLastCodeLocalUpdateError = Schema.is(LastCodeLocalUpdateError); + +export function parseHelperResult(raw: string): unknown { + const line = raw + .split(/\r?\n/) + .toReversed() + .find((candidate) => candidate.startsWith(RESULT_PREFIX)); + if (!line) throw new Error("Local updater helper did not return a result."); + return JSON.parse(line.slice(RESULT_PREFIX.length)); +} + +export function usesDetachedHelperProcessGroup(platform: NodeJS.Platform): boolean { + return platform !== "win32"; +} + +export function groupedInspectionArgs(currentVersion: string): ReadonlyArray { + return ["--current-version", currentVersion, "--release-notes-format", "grouped-v1"]; +} + +export function terminateHelperProcess( + child: Pick, + platform: NodeJS.Platform, + killProcess: (pid: number, signal: NodeJS.Signals) => boolean = process.kill, +): boolean { + if (child.pid !== undefined && usesDetachedHelperProcessGroup(platform)) { + try { + return killProcess(-child.pid, "SIGKILL"); + } catch { + // Fall through if the process group disappeared or cannot be signalled. + } + } + return child.kill("SIGKILL"); +} + +function makeLive(environment: DesktopEnvironment.DesktopEnvironment["Service"]) { + const dashboardPath = NodePath.join(environment.homeDirectory, ".lastcode", "dashboard.json"); + const buildLogPath = NodePath.join( + environment.homeDirectory, + ".lastcode", + "local-updates", + "build.log", + ); + + const readRepository = (): { + readonly repoRoot: string; + readonly helperPath: string; + readonly installerPath: string; + } => { + try { + const { repoRoot } = decodeDashboardConfig(NodeFS.readFileSync(dashboardPath, "utf8")); + const helperPath = NodePath.join(repoRoot, "scripts", "lastcode-local-update.mjs"); + const installerPath = NodePath.join(repoRoot, "scripts", "lastcode-install.mjs"); + if (!NodeFS.existsSync(helperPath)) { + throw new Error(`Local update helper is missing at ${helperPath}.`); + } + if (!NodeFS.existsSync(installerPath)) { + throw new Error(`Local install helper is missing at ${installerPath}.`); + } + return { repoRoot, helperPath, installerPath }; + } catch (cause) { + throw new LastCodeLocalUpdateError({ + operation: "configuration", + message: `LastCode local update automation is not ready. Run the documented service and dashboard installers. (${dashboardPath})`, + cause, + }); + } + }; + + const runHelper = ( + operation: "inspect" | "build", + args: ReadonlyArray, + onProgress?: (progress: LastCodeLocalBuildProgress) => Effect.Effect, + ): Effect.Effect => { + const helperEffect = Effect.tryPromise({ + try: (signal) => { + const { repoRoot, helperPath } = readRepository(); + return new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn( + process.execPath, + [ + helperPath, + operation, + "--repo", + repoRoot, + "--home", + environment.homeDirectory, + ...args, + ], + { + cwd: repoRoot, + detached: usesDetachedHelperProcessGroup(environment.platform), + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout = `${stdout}${chunk}`.slice(-256_000); + }); + child.stderr.on("data", (chunk: string) => { + stderr = `${stderr}${chunk}`.slice(-32_000); + }); + const abort = () => terminateHelperProcess(child, environment.platform); + signal.addEventListener("abort", abort, { once: true }); + child.once("error", reject); + child.once("close", (exitCode) => { + signal.removeEventListener("abort", abort); + if (exitCode === 0) resolve(stdout); + else + reject(new Error(stderr.trim() || `Local updater helper exited with ${exitCode}.`)); + }); + }); + }, + catch: (cause) => + isLastCodeLocalUpdateError(cause) + ? cause + : new LastCodeLocalUpdateError({ + operation, + message: + cause instanceof Error ? cause.message : `LastCode local ${operation} failed.`, + cause, + }), + }); + + const monitoredHelper = onProgress + ? monitorLocalBuildProgress(buildLogPath, helperEffect, onProgress) + : helperEffect; + + return monitoredHelper.pipe( + Effect.flatMap((raw) => + Effect.try({ + try: () => parseHelperResult(raw), + catch: (cause) => + new LastCodeLocalUpdateError({ + operation, + message: `Could not read the LastCode local ${operation} result.`, + cause, + }), + }), + ), + ); + }; + + const prepareInstall = (args: { + readonly dmgPath: string; + readonly dmgSha256: string; + readonly expectedVersion: string; + }): Effect.Effect => { + const installLogPath = NodePath.join( + environment.homeDirectory, + ".lastcode", + "local-updates", + "install.log", + ); + return Effect.tryPromise({ + try: (signal) => { + const { repoRoot, installerPath } = readRepository(); + NodeFS.mkdirSync(NodePath.dirname(installLogPath), { recursive: true }); + const logFd = NodeFS.openSync(installLogPath, "a", 0o600); + let child: NodeChildProcess.ChildProcess; + try { + child = NodeChildProcess.spawn( + process.execPath, + [ + installerPath, + "handoff", + "--dmg", + args.dmgPath, + "--expected-sha256", + args.dmgSha256, + "--expected-version", + args.expectedVersion, + "--parent-pid", + String(process.pid), + "--ready-fd", + "3", + ], + { + cwd: repoRoot, + detached: usesDetachedHelperProcessGroup(environment.platform), + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, + stdio: ["pipe", logFd, logFd, "pipe"], + }, + ); + } finally { + NodeFS.closeSync(logFd); + } + const abort = () => { + if (child.stdin?.writable) { + child.stdin.end("CANCEL\n"); + return; + } + terminateHelperProcess(child, environment.platform); + }; + signal.addEventListener("abort", abort, { once: true }); + + return new Promise((resolve, reject) => { + const readyStream = child.stdio[3]; + const control = child.stdin; + let ready = false; + let buffer = ""; + let commandSent = false; + let preflightTimedOut = false; + const preflightTimeoutError = () => + new Error( + `Local install preflight did not finish within 2 minutes. Cleanup completed before returning. See ${installLogPath}.`, + ); + const preflightTimeout = setTimeout(() => { + if (ready) return; + preflightTimedOut = true; + control?.end("CANCEL\n"); + }, 120_000); + const failBeforeReady = (cause: unknown) => { + if (ready) return; + clearTimeout(preflightTimeout); + signal.removeEventListener("abort", abort); + reject(cause); + }; + child.once("error", failBeforeReady); + child.once("close", (exitCode) => { + failBeforeReady( + preflightTimedOut + ? preflightTimeoutError() + : new Error( + `Local install helper exited before readiness with code ${exitCode}. See ${installLogPath}.`, + ), + ); + }); + if (!(readyStream instanceof NodeStream.Readable) || !control) { + terminateHelperProcess(child, environment.platform); + failBeforeReady(new Error("Local install helper pipes were not created.")); + return; + } + readyStream.setEncoding("utf8"); + readyStream.on("data", (chunk: string) => { + if (ready) return; + buffer += chunk; + if (buffer.length > 8_192) { + terminateHelperProcess(child, environment.platform); + failBeforeReady(new Error("Local install readiness result was too large.")); + return; + } + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + if (preflightTimedOut) return; + try { + const line = buffer.slice(0, newline); + if (!line.startsWith(INSTALL_READY_PREFIX)) { + throw new Error("Local install helper returned an invalid readiness result."); + } + const result = decodeInstallReadyResult( + JSON.parse(line.slice(INSTALL_READY_PREFIX.length)), + ); + if ( + NodePath.resolve(result.artifactPath) !== NodePath.resolve(args.dmgPath) || + result.version !== args.expectedVersion + ) { + throw new Error("Local install helper accepted a different artifact or version."); + } + ready = true; + clearTimeout(preflightTimeout); + signal.removeEventListener("abort", abort); + readyStream.destroy(); + + const sendCommand = ( + command: "COMMIT" | "CANCEL", + ): Effect.Effect => + Effect.tryPromise({ + try: () => + new Promise((done, fail) => { + if (commandSent) { + done(); + return; + } + commandSent = true; + control.once("error", fail); + control.end(`${command}\n`, () => { + child.unref(); + done(); + }); + }), + catch: (cause) => + new LastCodeLocalUpdateError({ + operation: "install", + message: `Could not transfer local install ownership. See ${installLogPath}.`, + cause, + }), + }); + + resolve({ + commit: sendCommand("COMMIT"), + cancel: sendCommand("CANCEL").pipe(Effect.ignore), + }); + } catch (cause) { + control.end("CANCEL\n"); + failBeforeReady(cause); + } + }); + readyStream.once("error", (cause) => { + if (!preflightTimedOut) failBeforeReady(cause); + }); + control.once("error", (cause) => { + if (!preflightTimedOut) failBeforeReady(cause); + }); + readyStream.once("close", () => { + if (!ready && !preflightTimedOut) { + failBeforeReady( + new Error(`Local install helper closed its readiness pipe. See ${installLogPath}.`), + ); + } + }); + }); + }, + catch: (cause) => + isLastCodeLocalUpdateError(cause) + ? cause + : new LastCodeLocalUpdateError({ + operation: "install", + message: + cause instanceof Error + ? cause.message + : "Could not prepare the local LastCode install.", + cause, + }), + }); + }; + + return LastCodeLocalUpdates.of({ + supported: + environment.isPackaged && + environment.platform === "darwin" && + environment.runtimeInfo.hostArch === "arm64", + buildLogPath, + inspect: (currentVersion) => + runHelper("inspect", groupedInspectionArgs(currentVersion)).pipe( + Effect.flatMap((result) => + Effect.try({ + try: () => decodeInspectionResult(result), + catch: (cause) => + new LastCodeLocalUpdateError({ + operation: "inspect", + message: "The LastCode local update inspection result was invalid.", + cause, + }), + }), + ), + ), + build: (checkpointTag, onProgress) => + runHelper("build", ["--checkpoint", checkpointTag], onProgress).pipe( + Effect.flatMap((result) => + Effect.try({ + try: () => decodeBuildResult(result), + catch: (cause) => + new LastCodeLocalUpdateError({ + operation: "build", + message: "The LastCode local build result was invalid.", + cause, + }), + }), + ), + ), + prepareInstall, + }); +} + +export const layer = Layer.effect( + LastCodeLocalUpdates, + Effect.map(DesktopEnvironment.DesktopEnvironment, makeLive), +); + +export const layerTest = (service: LastCodeLocalUpdates["Service"]) => + Layer.succeed(LastCodeLocalUpdates, service); diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index 731910e441fe..81d95a0affef 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,6 +1,6 @@ import type { DesktopUpdateChannel } from "@t3tools/contracts"; -const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; +const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+(?:\.\d+)?$/; export function isNightlyDesktopVersion(version: string): boolean { return NIGHTLY_VERSION_PATTERN.test(version); diff --git a/apps/desktop/src/updates/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts index e25da9e95dfb..8c1b9856a355 100644 --- a/apps/desktop/src/updates/updateMachine.test.ts +++ b/apps/desktop/src/updates/updateMachine.test.ts @@ -9,6 +9,9 @@ import { reduceDesktopUpdateStateOnDownloadProgress, reduceDesktopUpdateStateOnDownloadStart, reduceDesktopUpdateStateOnInstallFailure, + reduceDesktopUpdateStateOnLocalBuildFailure, + reduceDesktopUpdateStateOnLocalBuildProgress, + reduceDesktopUpdateStateOnLocalBuildStart, reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine.ts"; @@ -124,6 +127,43 @@ describe("updateMachine", () => { expect(state.canRetry).toBe(true); }); + it("keeps local progress separate from hosted byte progress and durable on failure", () => { + const available = { + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "nightly"), + enabled: true, + source: "lastcode-local" as const, + status: "available" as const, + availableVersion: "1.1.0-nightly.1", + }; + const started = reduceDesktopUpdateStateOnLocalBuildStart(available, { + checkpointTag: "lastcode/checkpoint/v1.1.0-nightly.1", + phase: "Preparing", + percent: 0, + errorKind: "build", + }); + const progressed = reduceDesktopUpdateStateOnLocalBuildProgress(started, { + checkpointTag: "lastcode/checkpoint/v1.1.0-nightly.1", + phase: "Building DMG", + percent: 94, + errorKind: "packaging", + }); + const failure = { + ...progressed.localBuildProgress!, + currentVersion: "1.0.0", + targetVersion: "1.1.0-nightly.1", + logPath: "/tmp/build.log", + error: "dmg failed", + }; + const failed = reduceDesktopUpdateStateOnLocalBuildFailure(progressed, failure); + + expect(started.downloadPercent).toBeNull(); + expect(progressed.downloadPercent).toBeNull(); + expect(failed.status).toBe("error"); + expect(failed.localBuildProgress).toEqual(progressed.localBuildProgress); + expect(failed.localBuildFailure).toEqual(failure); + expect(failed.canRetry).toBe(true); + }); + it("transitions to downloaded and then preserves install retry state", () => { const downloaded = reduceDesktopUpdateStateOnDownloadComplete( { @@ -214,6 +254,7 @@ describe("updateMachine", () => { releaseNotes, ); const downloading = reduceDesktopUpdateStateOnDownloadStart(available); + const indeterminateDownload = reduceDesktopUpdateStateOnDownloadStart(available, null); const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); expect(available.status).toBe("available"); @@ -222,6 +263,7 @@ describe("updateMachine", () => { expect(downloading.releaseNotes).toBe(releaseNotes); expect(downloading.status).toBe("downloading"); expect(downloading.downloadPercent).toBe(0); + expect(indeterminateDownload.downloadPercent).toBeNull(); expect(progress.downloadPercent).toBe(55.5); expect(progress.errorContext).toBeNull(); }); diff --git a/apps/desktop/src/updates/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts index e51fe098a0be..6b6ed48c8031 100644 --- a/apps/desktop/src/updates/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -1,4 +1,6 @@ import type { + DesktopLocalBuildFailure, + DesktopLocalBuildProgress, DesktopRuntimeInfo, DesktopUpdateChannel, DesktopUpdateReleaseNote, @@ -22,6 +24,7 @@ export function createInitialDesktopUpdateState( ): DesktopUpdateState { return { enabled: false, + source: "hosted", status: "disabled", channel, currentVersion, @@ -32,6 +35,8 @@ export function createInitialDesktopUpdateState( downloadedVersion: null, releaseNotes: [], downloadPercent: null, + localBuildProgress: null, + localBuildFailure: null, checkedAt: null, message: null, errorContext: null, @@ -51,6 +56,8 @@ export function reduceDesktopUpdateStateOnCheckStart( releaseNotes: hasDownloadedUpdate ? state.releaseNotes : [], message: null, downloadPercent: hasDownloadedUpdate ? 100 : null, + localBuildProgress: null, + localBuildFailure: null, errorContext: null, canRetry: false, }; @@ -79,6 +86,8 @@ export function reduceDesktopUpdateStateOnCheckFailure( message, checkedAt, downloadPercent: null, + localBuildProgress: null, + localBuildFailure: null, errorContext: "check", canRetry: true, }; @@ -100,6 +109,8 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( downloadedVersion: isDownloadedVersion ? version : null, releaseNotes: nextReleaseNotes, downloadPercent: isDownloadedVersion ? 100 : null, + localBuildProgress: null, + localBuildFailure: null, checkedAt, message: null, errorContext: null, @@ -131,6 +142,8 @@ export function reduceDesktopUpdateStateOnNoUpdate( downloadedVersion: null, releaseNotes: [], downloadPercent: null, + localBuildProgress: null, + localBuildFailure: null, checkedAt, message: null, errorContext: null, @@ -140,11 +153,14 @@ export function reduceDesktopUpdateStateOnNoUpdate( export function reduceDesktopUpdateStateOnDownloadStart( state: DesktopUpdateState, + downloadPercent: number | null = 0, ): DesktopUpdateState { return { ...state, status: "downloading", - downloadPercent: 0, + downloadPercent, + localBuildProgress: null, + localBuildFailure: null, message: null, errorContext: null, canRetry: false, @@ -160,6 +176,8 @@ export function reduceDesktopUpdateStateOnDownloadFailure( status: nextStatusAfterDownloadFailure(state), message, downloadPercent: null, + localBuildProgress: null, + localBuildFailure: null, errorContext: "download", canRetry: getCanRetryAfterDownloadFailure(state), }; @@ -173,6 +191,8 @@ export function reduceDesktopUpdateStateOnDownloadProgress( ...state, status: "downloading", downloadPercent: percent, + localBuildProgress: null, + localBuildFailure: null, message: null, errorContext: null, canRetry: false, @@ -189,12 +209,70 @@ export function reduceDesktopUpdateStateOnDownloadComplete( availableVersion: version, downloadedVersion: version, downloadPercent: 100, + localBuildProgress: null, + localBuildFailure: null, message: null, errorContext: null, canRetry: true, }; } +export function reduceDesktopUpdateStateOnLocalBuildStart( + state: DesktopUpdateState, + progress: DesktopLocalBuildProgress, +): DesktopUpdateState { + return { + ...state, + status: "downloading", + downloadPercent: null, + localBuildProgress: progress, + localBuildFailure: null, + message: null, + errorContext: null, + canRetry: false, + }; +} + +export function reduceDesktopUpdateStateOnLocalBuildProgress( + state: DesktopUpdateState, + progress: DesktopLocalBuildProgress, +): DesktopUpdateState { + if ( + state.source !== "lastcode-local" || + state.status !== "downloading" || + state.localBuildProgress?.checkpointTag !== progress.checkpointTag || + progress.percent < state.localBuildProgress.percent + ) { + return state; + } + return { + ...state, + downloadPercent: null, + localBuildProgress: progress, + }; +} + +export function reduceDesktopUpdateStateOnLocalBuildFailure( + state: DesktopUpdateState, + failure: DesktopLocalBuildFailure, +): DesktopUpdateState { + return { + ...state, + status: "error", + downloadPercent: null, + localBuildProgress: { + checkpointTag: failure.checkpointTag, + phase: failure.phase, + percent: failure.percent, + errorKind: failure.errorKind, + }, + localBuildFailure: failure, + message: failure.error, + errorContext: "download", + canRetry: true, + }; +} + export function reduceDesktopUpdateStateOnInstallFailure( state: DesktopUpdateState, message: string, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 595b0dd113d3..418b1ec128c0 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -60,6 +60,8 @@ const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { getState: Effect.die("unexpected getState"), + getLastCodeSettings: Effect.die("unexpected getLastCodeSettings"), + setShowAndInstallLocalNightlies: () => Effect.die("unexpected local nightly toggle"), emitState: Effect.void, disabledReason: Effect.succeed(Option.none()), configure: Effect.void, @@ -83,6 +85,10 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), zoomMain: (direction) => Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid), + runningActionCount: Effect.succeed(0), + reportRunningActionCount: () => Effect.void, + acknowledgeRunningActionQuitWarning: Effect.void, + consumeRunningActionQuitWarningAcknowledgment: Effect.succeed(false), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 036eddd8db78..5360a46f9378 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -66,7 +66,7 @@ function makeFakeBrowserWindow() { let zoomLevel = 0; const webContents = { copyImageAt: vi.fn(), - getURL: vi.fn(() => "t3code-dev://app/"), + getURL: vi.fn(() => "lastcode-dev://app/"), getZoomLevel: vi.fn(() => zoomLevel), setZoomLevel: vi.fn((level: number) => { zoomLevel = level; @@ -233,6 +233,7 @@ function makeTestLayer(input: { setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.die("unexpected update channel change"), + setShowAndInstallLocalNightlies: () => Effect.die("unexpected local nightly toggle"), setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), @@ -414,19 +415,19 @@ describe("DesktopWindow", () => { it("recognizes only same-origin renderer navigations", () => { assert.isTrue( DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "t3code://app/", - navigationUrl: "t3code://app/settings/connections", + applicationUrl: "lastcode://app/", + navigationUrl: "lastcode://app/settings/connections", }), ); assert.isFalse( DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "t3code://app/", + applicationUrl: "lastcode://app/", navigationUrl: "https://accounts.microsoft.com/oauth", }), ); assert.isFalse( DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "t3code://app/", + applicationUrl: "lastcode://app/", navigationUrl: "not a url", }), ); @@ -459,7 +460,7 @@ describe("DesktopWindow", () => { assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.isFalse(createdWindowOptions[0]?.webPreferences?.backgroundThrottling); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); - assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["lastcode-dev://app/"]); assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); }).pipe(Effect.provide(layer)); }), @@ -1048,17 +1049,17 @@ describe("DesktopWindow", () => { return yield* Effect.die("renderer load listeners were not registered"); } - didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true); + didFailLoad({}, -9, "ERR_UNEXPECTED", "lastcode-dev://app/", true); assert.equal(fakeWindow.loadURL.mock.calls.length, 1); yield* TestClock.adjust(100); assert.deepEqual(fakeWindow.loadURL.mock.calls, [ - ["t3code-dev://app/"], - ["t3code-dev://app/"], + ["lastcode-dev://app/"], + ["lastcode-dev://app/"], ]); assert.equal(fakeWindow.reload.mock.calls.length, 0); - didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true); + didFailLoad({}, -9, "ERR_UNEXPECTED", "lastcode-dev://app/", true); didFinishLoad(); yield* TestClock.adjust(250); assert.equal(fakeWindow.loadURL.mock.calls.length, 2); @@ -1070,23 +1071,23 @@ describe("DesktopWindow", () => { it("retries only transient failures for the development renderer", () => { assert.isTrue( DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ - applicationUrl: "t3code-dev://app/", + applicationUrl: "lastcode-dev://app/", errorCode: -102, isMainFrame: true, - validatedUrl: "t3code-dev://app/", + validatedUrl: "lastcode-dev://app/", }), ); assert.isFalse( DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ - applicationUrl: "t3code-dev://app/", + applicationUrl: "lastcode-dev://app/", errorCode: -3, isMainFrame: true, - validatedUrl: "t3code-dev://app/", + validatedUrl: "lastcode-dev://app/", }), ); assert.isFalse( DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ - applicationUrl: "t3code-dev://app/", + applicationUrl: "lastcode-dev://app/", errorCode: -102, isMainFrame: true, validatedUrl: "https://example.com/", diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 56411711eb6c..2b6014485492 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -106,6 +106,10 @@ export class DesktopWindow extends Context.Service< // guest page instead of the app UI. The menu routes here to always target // the main window. readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect; + readonly runningActionCount: Effect.Effect; + readonly reportRunningActionCount: (count: number) => Effect.Effect; + readonly acknowledgeRunningActionQuitWarning: Effect.Effect; + readonly consumeRunningActionQuitWarningAcknowledgment: Effect.Effect; readonly syncAppearance: Effect.Effect; } >()("@t3tools/desktop/window/DesktopWindow") {} @@ -283,6 +287,9 @@ export const make = Effect.gen(function* () { // The transient "Connecting to WSL" splash window, tracked separately so it // is never mistaken for the real main window. const splashWindowRef = yield* Ref.make>(Option.none()); + const runningActionCountRef = yield* Ref.make(0); + const quitHoldDisplayedActionCountRef = yield* Ref.make(0); + const runningActionQuitWarningAcknowledgedRef = yield* Ref.make(false); const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); @@ -558,21 +565,41 @@ export const make = Effect.gen(function* () { platform: environment.platform, isEnabled: () => runPromise( - Effect.map( - clientSettings.get, - Option.match({ - onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit, - onSome: (settings) => settings.confirmQuit, - }), + Effect.all([clientSettings.get, Ref.get(runningActionCountRef)]).pipe( + Effect.map( + ([settings, runningActionCount]) => + runningActionCount > 0 || + Option.match(settings, { + onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit, + onSome: (value) => value.confirmQuit, + }), + ), ), ), notify: (state) => { - if (!window.isDestroyed()) { - window.webContents.send(QUIT_SHORTCUT_CHANNEL, state); - } + void runPromise( + Effect.gen(function* () { + const runningActionCount = yield* Ref.get(runningActionCountRef); + if (state === "down") { + yield* Ref.set(quitHoldDisplayedActionCountRef, runningActionCount); + } + if (!window.isDestroyed()) { + window.webContents.send(QUIT_SHORTCUT_CHANNEL, state, runningActionCount); + } + }), + ); }, quit: () => { - void runPromise(electronApp.quit); + void runPromise( + Effect.gen(function* () { + const runningActionCount = yield* Ref.get(runningActionCountRef); + const displayedActionCount = yield* Ref.get(quitHoldDisplayedActionCountRef); + if (runningActionCount > 0 && displayedActionCount >= runningActionCount) { + yield* Ref.set(runningActionQuitWarningAcknowledgedRef, true); + } + yield* electronApp.quit; + }), + ); }, }); window.webContents.on("before-input-event", (event, input) => { @@ -908,6 +935,16 @@ export const make = Effect.gen(function* () { // own zoom, so put each guest back where the preview left it. yield* previewManager.reapplyZoom(); }), + runningActionCount: Ref.get(runningActionCountRef), + reportRunningActionCount: (count) => + Ref.set(runningActionCountRef, Math.max(0, Math.trunc(count))).pipe( + Effect.withSpan("desktop.window.reportRunningActionCount"), + ), + acknowledgeRunningActionQuitWarning: Ref.set(runningActionQuitWarningAcknowledgedRef, true), + consumeRunningActionQuitWarningAcknowledgment: Ref.getAndSet( + runningActionQuitWarningAcknowledgedRef, + false, + ), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index c4297f24b096..8463fbd2b2a3 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,3 +1,5 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; import { SymbolView } from "./AppSymbol"; import { Image } from "expo-image"; import { useLayoutEffect, useMemo, useState } from "react"; @@ -9,6 +11,7 @@ import { } from "@t3tools/shared/projectFavicon"; import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; +import { mobilePreferencesAtom } from "../state/preferences"; import { beginProjectFaviconRequest, createProjectFaviconRequest, @@ -16,6 +19,7 @@ import { markProjectFaviconFailed, markProjectFaviconLoaded, } from "./projectFaviconCache"; +import { resolveProjectFaviconBorderRadius } from "./projectFaviconAppearance"; /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { @@ -27,6 +31,10 @@ export function ProjectFavicon(props: { readonly faviconPath?: string | null; }) { const size = props.size ?? 42; + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const roundedProjectIcons = + AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.roundedProjectIcons === true; const faviconUrl = useAssetUrl( props.environmentId, props.workspaceRoot === null || props.workspaceRoot === undefined @@ -50,6 +58,7 @@ export function ProjectFavicon(props: { faviconUrl={renderableFaviconUrl} open={props.open} projectTitle={props.projectTitle} + rounded={roundedProjectIcons} size={size} /> ); @@ -60,6 +69,7 @@ function ProjectFaviconImage(props: { readonly faviconUrl: string | null; readonly open?: boolean; readonly projectTitle: string; + readonly rounded: boolean; readonly size: number; }) { const iconMuted = useThemeColor("--color-icon-subtle"); @@ -116,7 +126,7 @@ function ProjectFaviconImage(props: { style={{ width: props.size, height: props.size, - borderRadius: props.size * 0.16, + borderRadius: resolveProjectFaviconBorderRadius(props.size, props.rounded), ...(showImage ? {} : { position: "absolute" as const, opacity: 0 }), }} contentFit="contain" diff --git a/apps/mobile/src/components/projectFaviconAppearance.test.ts b/apps/mobile/src/components/projectFaviconAppearance.test.ts new file mode 100644 index 000000000000..987984aa9af1 --- /dev/null +++ b/apps/mobile/src/components/projectFaviconAppearance.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveProjectFaviconBorderRadius } from "./projectFaviconAppearance"; + +describe("project favicon appearance", () => { + it("defaults to the original square canvas and retains the prior rounded radius on opt-in", () => { + expect(resolveProjectFaviconBorderRadius(20, false)).toBe(0); + expect(resolveProjectFaviconBorderRadius(20, true)).toBe(3.2); + }); +}); diff --git a/apps/mobile/src/components/projectFaviconAppearance.ts b/apps/mobile/src/components/projectFaviconAppearance.ts new file mode 100644 index 000000000000..73b19b4e2261 --- /dev/null +++ b/apps/mobile/src/components/projectFaviconAppearance.ts @@ -0,0 +1,3 @@ +export function resolveProjectFaviconBorderRadius(size: number, rounded: boolean): number { + return rounded ? size * 0.16 : 0; +} diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx index a97193d6b6a5..d53e7ebbfc89 100644 --- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx @@ -1,17 +1,28 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { useNavigation } from "@react-navigation/native"; +import { AsyncResult } from "effect/unstable/reactivity"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection"; +import { SettingsSection } from "./components/SettingsSection"; +import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; export function SettingsAppearanceRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const preferencesReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; + const roundedProjectIcons = + AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.roundedProjectIcons === true; return ( @@ -31,6 +42,16 @@ export function SettingsAppearanceRouteScreen() { }} > + + savePreferences({ roundedProjectIcons: value })} + /> + diff --git a/apps/mobile/src/features/threads/ActionResumeNotice.tsx b/apps/mobile/src/features/threads/ActionResumeNotice.tsx new file mode 100644 index 000000000000..235bd197abed --- /dev/null +++ b/apps/mobile/src/features/threads/ActionResumeNotice.tsx @@ -0,0 +1,135 @@ +import type { EnvironmentId, OrchestrationThreadShell } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { useCallback, useEffect, useState } from "react"; +import { Alert, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { ControlPill } from "../../components/ControlPill"; +import { terminalEnvironment } from "../../state/terminal"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; + +function failureMessage(cause: Cause.Cause, fallback: string): string { + const error = Cause.squash(cause); + return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback; +} + +export function ActionResumeNotice(props: { + readonly environmentId: EnvironmentId; + readonly thread: OrchestrationThreadShell; +}) { + const action = props.thread.actionResume ?? null; + const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false }); + const resumeAction = useAtomCommand(threadEnvironment.resumeAction, { reportFailure: false }); + const discardAction = useAtomCommand(threadEnvironment.discardAction, { reportFailure: false }); + const [pendingAction, setPendingAction] = useState<"cancel" | "resume" | "discard" | null>(null); + + useEffect(() => setPendingAction(null), [action?.runId, action?.delivery, action?.outcome]); + + const cancel = useCallback(async () => { + if (action?.outcome !== "running" || pendingAction !== null) return; + setPendingAction("cancel"); + const result = await closeTerminal({ + environmentId: props.environmentId, + input: { threadId: props.thread.id, terminalId: action.terminalId }, + }); + if (result._tag === "Failure") { + setPendingAction(null); + Alert.alert( + "Could not cancel Action", + failureMessage(result.cause, "The Project Action could not be cancelled."), + ); + } + }, [action, closeTerminal, pendingAction, props.environmentId, props.thread.id]); + + const recover = useCallback( + async (choice: "resume" | "discard") => { + if (action?.delivery !== "available" || pendingAction !== null) return; + setPendingAction(choice); + const command = choice === "resume" ? resumeAction : discardAction; + const result = await command({ + environmentId: props.environmentId, + input: { threadId: props.thread.id }, + }); + if (result._tag === "Failure") { + setPendingAction(null); + Alert.alert( + choice === "resume" ? "Could not resume agent" : "Could not discard follow-up", + failureMessage(result.cause, "The interrupted Action follow-up could not be updated."), + ); + } + }, + [ + action?.delivery, + discardAction, + pendingAction, + props.environmentId, + props.thread.id, + resumeAction, + ], + ); + + if (action?.outcome === "running") { + return ( + + + + + + Waiting for {action.actionName} + + + The agent will resume once this Action finishes and the thread is idle. + + + void cancel()} + variant="pill" + /> + + + ); + } + + if (action?.delivery !== "available") return null; + + return ( + + + + + + {action.actionName} was interrupted + + + LastCode did not restart the command or wake the agent. Resume only the agent follow-up + when you are ready. + + + void recover("discard")} + variant="pill" + /> + void recover("resume")} + variant="primary" + /> + + + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e234838394ba..02d0100d7479 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -67,6 +67,7 @@ import type { } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; import { PendingUserInputCard } from "./PendingUserInputCard"; +import { ActionResumeNotice } from "./ActionResumeNotice"; import { derivePendingUserInputMaxHeight, ESTIMATED_KEYBOARD_HEIGHT, @@ -669,6 +670,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) : null} + {props.selectedThread.actionResume?.outcome === "running" || + props.selectedThread.actionResume?.delivery === "available" ? ( + + + + ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], ); + const handleCancelAction = useCallback(async () => { + if (runningAction === null) return; + const result = await closeTerminal({ + environmentId: thread.environmentId, + input: { threadId: thread.id, terminalId: runningAction.terminalId }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not cancel Action", + error instanceof Error ? error.message : "The Project Action could not be cancelled.", + ); + } + }, [closeTerminal, runningAction, thread.environmentId, thread.id]); const menuActions = useMemo( () => [ THREAD_ROW_MENU_ACTIONS[0]!, @@ -492,9 +511,19 @@ export const ThreadListRow = memo(function ThreadListRow(props: { supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), + ...(runningAction === null + ? [] + : [ + { + id: "cancel-action", + title: `Cancel ${runningAction.actionName}`, + image: "stop.fill", + attributes: { destructive: true }, + } satisfies MenuAction, + ]), THREAD_ROW_MENU_ACTIONS[1]!, ], - [props.titleRegenerationSupported, thread.titleRegeneration], + [props.titleRegenerationSupported, runningAction, thread.titleRegeneration], ); const primaryAction = useMemo( () => ({ @@ -509,9 +538,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); + if (nativeEvent.event === "cancel-action") void handleCancelAction(); if (nativeEvent.event === "delete") handleDelete(); }, - [handleArchive, handleDelete, handleRegenerateTitle], + [handleArchive, handleCancelAction, handleDelete, handleRegenerateTitle], ); const statusPill = effectiveStatus ? ( diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index fa1e752d619f..4eea7b1e2ad1 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -9,6 +9,7 @@ import { type ChangeRequestSettleSource, } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; +import * as Cause from "effect/Cause"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -22,6 +23,8 @@ import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { terminalEnvironment } from "../../state/terminal"; +import { useAtomCommand } from "../../state/use-atom-command"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -57,6 +60,7 @@ const STATUS_LABEL_BY_STATUS: Partial< approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" }, input: { label: "Input", className: "text-indigo-600 dark:text-indigo-300" }, working: { label: "Working", className: "text-sky-600 dark:text-sky-400" }, + waiting: { label: "Waiting", className: "text-yellow-700 dark:text-yellow-300" }, failed: { label: "Failed", className: "text-red-700 dark:text-red-300" }, }; @@ -401,6 +405,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; + const runningAction = thread.actionResume?.outcome === "running" ? thread.actionResume : null; + const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false }); const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); const prState = pr?.state ?? null; @@ -448,6 +454,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [onMovePinnedThread, thread], ); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const handleCancelAction = useCallback(async () => { + if (runningAction === null) return; + const result = await closeTerminal({ + environmentId: thread.environmentId, + input: { threadId: thread.id, terminalId: runningAction.terminalId }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not cancel Action", + error instanceof Error ? error.message : "The Project Action could not be cancelled.", + ); + } + }, [closeTerminal, runningAction, thread.environmentId, thread.id]); // Swipe: the v2 primary action is the lifecycle transition. Every settled // row can un-settle — explicit settles clear the override, auto-settled @@ -527,6 +547,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }), [props.titleRegenerationSupported, thread.titleRegeneration], ); + const actionMenuItems = useMemo( + () => + runningAction === null + ? [] + : [ + { + id: "cancel-action", + title: `Cancel ${runningAction.actionName}`, + image: "stop.fill", + attributes: { destructive: true }, + }, + ], + [runningAction], + ); const snoozableCardMenuActions = useMemo( () => [ { id: "settle", title: "Settle", image: "checkmark" }, @@ -538,30 +572,47 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }, ...pinMenuItem, ...titleRegenerationMenuItems, + ...actionMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [pinMenuItem, snoozePresetActions, titleRegenerationMenuItems], + [actionMenuItems, pinMenuItem, snoozePresetActions, titleRegenerationMenuItems], ); const cardMenuActions = useMemo( () => [ CARD_MENU_ACTIONS[0]!, ...pinMenuItem, ...titleRegenerationMenuItems, + ...actionMenuItems, ...CARD_MENU_ACTIONS.slice(1), ], - [pinMenuItem, titleRegenerationMenuItems], + [actionMenuItems, pinMenuItem, titleRegenerationMenuItems], ); const slimMenuActions = useMemo( - () => [SLIM_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [ + SLIM_MENU_ACTIONS[0]!, + ...titleRegenerationMenuItems, + ...actionMenuItems, + SLIM_MENU_ACTIONS[1]!, + ], + [actionMenuItems, titleRegenerationMenuItems], ); const snoozedMenuActions = useMemo( - () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [ + SNOOZED_MENU_ACTIONS[0]!, + ...titleRegenerationMenuItems, + ...actionMenuItems, + SNOOZED_MENU_ACTIONS[1]!, + ], + [actionMenuItems, titleRegenerationMenuItems], ); const legacyMenuActions = useMemo( - () => [LEGACY_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, LEGACY_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [ + LEGACY_MENU_ACTIONS[0]!, + ...titleRegenerationMenuItems, + ...actionMenuItems, + LEGACY_MENU_ACTIONS[1]!, + ], + [actionMenuItems, titleRegenerationMenuItems], ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -574,6 +625,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "move-pin-down") handleMovePinnedDown(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); + if (nativeEvent.event === "cancel-action") void handleCancelAction(); if (nativeEvent.event === "delete") handleDelete(); const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ event: nativeEvent.event, @@ -588,6 +640,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }, [ handleArchive, + handleCancelAction, handleDelete, handleRegenerateTitle, handleMovePinnedDown, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index c58dbb67517b..432feccf2371 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -23,6 +23,7 @@ import { resolveThreadListV2SwipeActions, sortThreadsForListV2, } from "./threadListV2"; +import { resolveThreadStatus } from "./threadPresentation"; const environmentId = EnvironmentId.make("environment-1"); @@ -145,6 +146,22 @@ describe("resolveThreadListV2Status", () => { expect(resolveThreadListV2Status(thread)).toBe("approval"); }); + it("reports Waiting for a running Action once higher-priority work is idle", () => { + const actionResume = { outcome: "running" } as NonNullable< + EnvironmentThreadShell["actionResume"] + >; + const waiting = makeThread({ id: ThreadId.make("waiting"), title: "Waiting", actionResume }); + expect(resolveThreadListV2Status(waiting)).toBe("waiting"); + expect(resolveThreadStatus(waiting)?.kind).toBe("waiting"); + + expect( + resolveThreadListV2Status({ + ...waiting, + hasPendingApprovals: true, + }), + ).toBe("approval"); + }); + it("resolves ready for quiescent threads", () => { expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe( "ready", diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 45079bac6e7f..c5a442f37d52 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -27,7 +27,7 @@ export { snoozeWakeLabel }; * (approval), "in motion" (working), and "broken" (failed). Ready is the * unlabeled resting state. */ -export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; +export type ThreadListV2Status = "approval" | "input" | "working" | "waiting" | "failed" | "ready"; export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; export function resolveThreadListV2SnoozeMenuSelection(input: { @@ -126,7 +126,10 @@ export function resolveThreadListV2Enabled(input: { } export function resolveThreadListV2Status( - thread: Pick, + thread: Pick< + EnvironmentThreadShell, + "hasPendingApprovals" | "hasPendingUserInput" | "session" | "actionResume" + >, ): ThreadListV2Status { if (thread.hasPendingApprovals) { return "approval"; @@ -140,6 +143,9 @@ export function resolveThreadListV2Status( if (thread.session?.status === "error") { return "failed"; } + if (thread.actionResume?.outcome === "running") { + return "waiting"; + } return "ready"; } diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index 9de3d4d3089f..c556dc65e3ac 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -11,6 +11,7 @@ export type ThreadStatusKind = | "pending-approval" | "awaiting-input" | "working" + | "waiting" | "connecting" | "error" | "plan-ready"; @@ -109,6 +110,18 @@ export function resolveThreadStatus( }; } + if (thread.actionResume?.outcome === "running") { + return { + kind: "waiting", + label: "Waiting", + pillClassName: "bg-yellow-500/12 dark:bg-yellow-500/16", + textClassName: "text-yellow-700 dark:text-yellow-300", + iconColor: "#eab308", + iconBackground: "rgba(234,179,8,0.22)", + pulse: false, + }; + } + const hasPlanReadyPrompt = thread.interactionMode === "plan" && isLatestTurnSettled(thread.latestTurn, thread.session) && diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 7b94dc629154..230104c7271b 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -196,6 +196,12 @@ describe("mobile connection storage", () => { }); }); + it("preserves the device-local project icon shape preference", async () => { + mocks.setPreferencesJson(JSON.stringify({ roundedProjectIcons: true }), 10); + + await expect(loadPreferences()).resolves.toEqual({ roundedProjectIcons: true }); + }); + it("falls back to secure storage when SQLite cannot save preferences", async () => { mocks.setDatabaseFailures(true, true); await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index dfaeab9cd6ba..ba7e73b8cd07 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -42,6 +42,8 @@ export interface Preferences { readonly legacyThreadListEnabled?: boolean; /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ readonly planModeEnabled?: boolean; + /** Device-local counterpart of web's `roundedProjectIcons` appearance preference. */ + readonly roundedProjectIcons?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -100,6 +102,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { autoSettleOnMerge?: boolean; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; + roundedProjectIcons?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -170,6 +173,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } + if (typeof parsed.roundedProjectIcons === "boolean") { + preferences.roundedProjectIcons = parsed.roundedProjectIcons; + } return preferences; } diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 71ef59a0910c..de1a7c3aea6c 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -51,6 +51,7 @@ import { OrchestrationEngineLive } from "../src/orchestration/Layers/Orchestrati import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts"; +import * as ThreadActionResume from "../src/orchestration/ThreadActionResume.ts"; import * as ThreadPlanProgress from "../src/orchestration/ThreadPlanProgress.ts"; import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; @@ -313,6 +314,7 @@ export const makeOrchestrationIntegrationHarness = ( RuntimeReceiptBusTest, ).pipe( Layer.provideMerge(ThreadBackgroundLiveness.layer), + Layer.provideMerge(ThreadActionResume.layer), Layer.provideMerge(ThreadPlanProgress.layer), ); const serverSettingsLayer = ServerSettingsService.layerTest(); diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 2de5b702a286..8a0997ea21dd 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -10,11 +10,11 @@ import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { resolveWebAssetBrandForPackageVersion } from "../../../scripts/lib/brand-assets.ts"; import { - DEVELOPMENT_ICON_OVERRIDES, - resolveWebAssetBrandForPackageVersion, - resolveWebIconOverrides, -} from "../../../scripts/lib/brand-assets.ts"; + LASTCODE_DEVELOPMENT_ICON_OVERRIDES, + resolveLastCodeWebIconOverrides, +} from "../../../scripts/lib/lastcode-brand-assets.ts"; import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { fromYaml } from "@t3tools/shared/schemaYaml"; @@ -90,7 +90,7 @@ const preparePublishIcons = Effect.fn("preparePublishIcons")(function* ( const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const brand = resolveWebAssetBrandForPackageVersion(version); - const icons = resolveWebIconOverrides(brand, "dist/client").map((override) => ({ + const icons = resolveLastCodeWebIconOverrides(brand, "dist/client").map((override) => ({ sourcePath: path.join(repoRoot, override.sourceRelativePath), targetPath: path.join(serverDir, override.targetRelativePath), })); @@ -119,7 +119,7 @@ const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides") const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; - for (const override of DEVELOPMENT_ICON_OVERRIDES) { + for (const override of LASTCODE_DEVELOPMENT_ICON_OVERRIDES) { const sourcePath = path.join(repoRoot, override.sourceRelativePath); const targetPath = path.join(serverDir, override.targetRelativePath); diff --git a/apps/server/src/actionResume/ActionResume.test.ts b/apps/server/src/actionResume/ActionResume.test.ts new file mode 100644 index 000000000000..91d36bf5866d --- /dev/null +++ b/apps/server/src/actionResume/ActionResume.test.ts @@ -0,0 +1,474 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + type ActionResumeState, + EventId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationThreadShell, + type TerminalEvent, + type TerminalOpenInput, + type TerminalWriteInput, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import { ProjectionThreadActivityRepository } from "../persistence/Services/ProjectionThreadActivities.ts"; +import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ThreadActionResume from "../orchestration/ThreadActionResume.ts"; +import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as ActionResume from "./ActionResume.ts"; + +const threadId = ThreadId.make("thread-action-resume"); +const projectId = ProjectId.make("project-action-resume"); +const providerInstanceId = ProviderInstanceId.make("codex"); +const now = "2026-08-17T00:00:00.000Z"; + +const thread = { + id: threadId, + projectId, + title: "Action resume thread", + modelSelection: { provider: "codex", instanceId: providerInstanceId, model: "gpt-5" }, + runtimeMode: "approval-required", + interactionMode: "plan", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, +} as OrchestrationThreadShell; + +const project = { + id: projectId, + title: "Action resume project", + workspaceRoot: "/tmp/action-resume-project", + defaultModelSelection: null, + scripts: [ + { + id: "qa", + name: "QA", + command: "vp test run", + icon: "test", + runOnWorktreeCreate: false, + allowAgentResume: true, + }, + { + id: "manual-only", + name: "Manual only", + command: "echo manual", + icon: "play", + runOnWorktreeCreate: false, + }, + ], + createdAt: now, + updatedAt: now, +} as OrchestrationProjectShell; + +it("writes shell-specific status propagation for Action terminals", () => { + assert.equal( + ActionResume.actionCommandForShell("vp test run", "powershell", "run-1"), + "vp test run\nif ($?) { exit 0 }\nif ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }\nexit 1\n", + ); + assert.equal( + ActionResume.actionCommandForShell("vp test run", "cmd", "run-1"), + "vp test run\nexit /b %errorlevel%\n", + ); + assert.equal( + ActionResume.actionCommandForShell("printf '\\033[31mred\\033[0m\\n'", "posix", "run-1"), + "printf '\\033]777;T3ActionOutput;run-1;start\\007'; eval 'printf '\"'\"'\\033[31mred\\033[0m\\n'\"'\"''; __t3_action_status=$?; printf '\\033]777;T3ActionOutput;run-1;end\\007'; exit $__t3_action_status\n", + ); +}); + +it("blocks a replacement until the current Action continuation is settled", () => { + for (const delivery of ["armed", "pending", "available"] as const) { + assert.isTrue(ActionResume.actionBlocksNewLaunch({ delivery } as ActionResumeState), delivery); + } + for (const delivery of ["delivered", "disposed"] as const) { + assert.isFalse(ActionResume.actionBlocksNewLaunch({ delivery } as ActionResumeState), delivery); + } + assert.isFalse(ActionResume.actionBlocksNewLaunch(null)); +}); + +it.effect("runs one opted-in Action and delivers exactly one automated follow-up", () => { + const dispatched: OrchestrationCommand[] = []; + const opened: TerminalOpenInput[] = []; + const written: TerminalWriteInput[] = []; + const timeline: string[] = []; + let terminalStatus: "running" | "exited" = "running"; + let failWrite = false; + let terminalListener: ((event: TerminalEvent) => Effect.Effect) | undefined; + + const dependencies = Layer.mergeAll( + Layer.mock(OrchestrationEngineService)({ + dispatch: (command) => + Effect.sync(() => { + timeline.push(`dispatch:${command.type}`); + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.never, + }), + Layer.mock(ProjectionSnapshotQuery)({ + getThreadShellById: () => Effect.succeed(Option.some(thread)), + getProjectShellById: () => Effect.succeed(Option.some(project)), + }), + Layer.mock(ProjectionThreadActivityRepository)({ + listByKind: () => Effect.succeed([]), + }), + Layer.mock(TerminalManager.TerminalManager)({ + open: (input) => + Effect.sync(() => { + timeline.push("terminal:open"); + opened.push(input); + return { + status: terminalStatus, + shellFamily: "posix", + } as TerminalManager.OpenTerminalSessionSnapshot; + }), + write: (input) => + failWrite + ? Effect.die("write failed") + : Effect.sync(() => { + written.push(input); + }), + close: (input) => + terminalListener?.({ + type: "closed", + threadId: input.threadId, + terminalId: input.terminalId ?? "default", + deleteHistory: input.deleteHistory ?? false, + }) ?? Effect.void, + subscribe: (listener) => + Effect.sync(() => { + terminalListener = listener; + return () => undefined; + }), + }), + makeProviderRegistryLayer([ + { + instanceId: providerInstanceId, + driver: ProviderDriverKind.make("codex"), + } as never, + ]), + ThreadActionResume.layer, + NodeServices.layer, + ); + + return Effect.gen(function* () { + const service = yield* ActionResume.ActionResume; + const listed = yield* service.listProjectActions({ threadId, providerInstanceId }); + assert.deepEqual( + listed.map(({ id, resumeEligible }) => ({ id, resumeEligible })), + [ + { id: "qa", resumeEligible: true }, + { id: "manual-only", resumeEligible: false }, + ], + ); + + const running = yield* service.runProjectActionAndResume( + { threadId, providerInstanceId }, + "qa", + ); + assert.equal(running.outcome, "running"); + assert.equal(opened.length, 1); + assert.equal(written.length, 1); + assert.isBelow( + timeline.indexOf("terminal:open"), + timeline.indexOf("dispatch:thread.activity.append"), + ); + assert.match(written[0]?.data ?? "", /vp test run/); + assert.match(written[0]?.data ?? "", /exit \$__t3_action_status/); + + assert.isDefined(terminalListener); + const startMarker = ActionResume.actionOutputMarker(running.runId, "start"); + const endMarker = ActionResume.actionOutputMarker(running.runId, "end"); + yield* terminalListener!({ + type: "output", + threadId, + terminalId: running.terminalId, + data: `prompt and echoed command\n${startMarker.slice(0, -2)}`, + }); + yield* terminalListener!({ + type: "output", + threadId, + terminalId: running.terminalId, + data: `${startMarker.slice(-2)}QA failed: \u001b[31mexpected 2, received 3\u001b[0m\n${endMarker}prompt`, + }); + yield* terminalListener!({ + type: "exited", + threadId, + terminalId: running.terminalId, + exitCode: 0, + exitSignal: null, + }); + yield* terminalListener!({ + type: "exited", + threadId, + terminalId: running.terminalId, + exitCode: 0, + exitSignal: null, + }); + + const turnStarts = dispatched.filter( + (command): command is Extract => + command.type === "thread.turn.start", + ); + assert.equal(turnStarts.length, 1); + assert.equal(turnStarts[0]?.message.role, "system"); + assert.match(turnStarts[0]?.message.text ?? "", /Automated Project Action follow-up/); + assert.include( + turnStarts[0]?.message.text ?? "", + "QA failed: \u001b[31mexpected 2, received 3\u001b[0m", + ); + assert.notInclude(turnStarts[0]?.message.text ?? "", "prompt and echoed command"); + assert.equal(turnStarts[0]?.runtimeMode, thread.runtimeMode); + assert.equal(turnStarts[0]?.interactionMode, thread.interactionMode); + + const registry = yield* ThreadActionResume.ThreadActionResumeService; + assert.deepInclude(registry.getLatest(threadId), { + outcome: "succeeded", + delivery: "delivered", + }); + + const deleting = yield* service.runProjectActionAndResume( + { threadId, providerInstanceId }, + "qa", + ); + yield* terminalListener!({ + type: "closed", + threadId, + terminalId: deleting.terminalId, + deleteHistory: true, + }); + + assert.equal(dispatched.filter((command) => command.type === "thread.turn.start").length, 1); + assert.deepInclude(registry.getLatest(threadId), { + outcome: "cancelled_by_user", + delivery: "disposed", + }); + + failWrite = true; + const failedWrite = yield* service + .runProjectActionAndResume({ threadId, providerInstanceId }, "qa") + .pipe(Effect.flip); + assert.equal(failedWrite.reason, "launch_failed"); + const failedState = registry.getLatest(threadId); + assert.deepInclude(failedState, { outcome: "failed", delivery: "disposed" }); + + failWrite = false; + terminalStatus = "exited"; + const earlyExit = yield* service + .runProjectActionAndResume({ threadId, providerInstanceId }, "qa") + .pipe(Effect.flip); + assert.equal(earlyExit.reason, "launch_failed"); + assert.equal(registry.getLatest(threadId)?.runId, failedState?.runId); + }).pipe(Effect.provide(ActionResume.layer.pipe(Layer.provideMerge(dependencies))), Effect.scoped); +}); + +it.effect("requires an explicit resume after a running Action is found on startup", () => + Effect.gen(function* () { + const reconciled = yield* Deferred.make(); + const subscribed = yield* Deferred.make(); + const dispatched: OrchestrationCommand[] = []; + let failTurnStart = false; + const running: ActionResumeState = { + runId: "interrupted-run", + threadId, + projectId, + actionId: "qa", + actionName: "QA", + terminalId: "action-interrupted-run", + outcome: "running", + delivery: "armed", + startedAt: now, + finishedAt: null, + exitCode: null, + exitSignal: null, + }; + const recoveredThreadId = ThreadId.make("thread-action-resume-recovered"); + const available: ActionResumeState = { + ...running, + runId: "available-run", + threadId: recoveredThreadId, + terminalId: "action-available-run", + outcome: "process_lost", + delivery: "available", + finishedAt: now, + }; + const settledThreadId = ThreadId.make("thread-action-resume-settled"); + const settled: ActionResumeState = { + ...running, + runId: "settled-run", + threadId: settledThreadId, + terminalId: "action-settled-run", + outcome: "succeeded", + delivery: "disposed", + finishedAt: now, + exitCode: 0, + }; + + const dependencies = Layer.mergeAll( + Layer.mock(OrchestrationEngineService)({ + dispatch: (command) => + Effect.gen(function* () { + if (command.type === "thread.turn.start" && failTurnStart) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "turn start failed", + }); + } + dispatched.push(command); + if ( + command.type === "thread.activity.append" && + command.activity.payload !== null && + typeof command.activity.payload === "object" && + "outcome" in command.activity.payload && + command.activity.payload.outcome === "process_lost" && + "runId" in command.activity.payload && + command.activity.payload.runId === "interrupted-run" + ) { + yield* Deferred.await(subscribed); + yield* Deferred.succeed(reconciled, command.activity.payload as ActionResumeState); + } + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.unwrap( + Deferred.succeed(subscribed, undefined).pipe(Effect.as(Stream.never)), + ), + }), + Layer.mock(ProjectionSnapshotQuery)({ + getThreadShellById: () => Effect.succeed(Option.some(thread)), + getProjectShellById: () => Effect.succeed(Option.some(project)), + }), + Layer.mock(ProjectionThreadActivityRepository)({ + listByKind: () => + Effect.succeed([ + { + activityId: EventId.make("action-resume:interrupted-run:running:armed"), + threadId, + turnId: null, + tone: "info", + kind: ActionResume.ACTION_RESUME_ACTIVITY_KIND, + summary: "Waiting for Action: QA", + payload: running, + sequence: 1, + createdAt: now, + }, + { + activityId: EventId.make("action-resume:available-run:process_lost:available"), + threadId: recoveredThreadId, + turnId: null, + tone: "error", + kind: ActionResume.ACTION_RESUME_ACTIVITY_KIND, + summary: "Action interrupted when LastCode stopped: QA", + payload: available, + sequence: 2, + createdAt: now, + }, + { + activityId: EventId.make("action-resume:settled-run:succeeded:disposed"), + threadId: settledThreadId, + turnId: null, + tone: "info", + kind: ActionResume.ACTION_RESUME_ACTIVITY_KIND, + summary: "Action completed: QA", + payload: settled, + createdAt: now, + }, + { + activityId: EventId.make("action-resume:settled-run:succeeded:pending"), + threadId: settledThreadId, + turnId: null, + tone: "info", + kind: ActionResume.ACTION_RESUME_ACTIVITY_KIND, + summary: "Action completed: QA", + payload: { ...settled, delivery: "pending" }, + createdAt: now, + }, + ]), + }), + Layer.mock(TerminalManager.TerminalManager)({ + open: () => Effect.die("unexpected terminal open"), + write: () => Effect.die("unexpected terminal write"), + history: () => + Effect.succeed("Persisted failure detail after both markers were unavailable."), + close: () => Effect.void, + subscribe: () => Effect.succeed(() => undefined), + }), + makeProviderRegistryLayer([ + { + instanceId: providerInstanceId, + driver: ProviderDriverKind.make("codex"), + } as never, + ]), + ThreadActionResume.layer, + NodeServices.layer, + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const service = yield* ActionResume.ActionResume; + const registry = yield* ThreadActionResume.ThreadActionResumeService; + assert.deepInclude(registry.getLatest(recoveredThreadId), { + outcome: "process_lost", + delivery: "available", + }); + assert.deepInclude(registry.getLatest(settledThreadId), { + outcome: "succeeded", + delivery: "disposed", + }); + assert.isNull(registry.getForShell(settledThreadId)); + yield* service.discardInterrupted(recoveredThreadId); + assert.deepInclude(registry.getLatest(recoveredThreadId), { delivery: "disposed" }); + registry.record(available); + failTurnStart = true; + const resumeError = yield* service.resumeInterrupted(recoveredThreadId).pipe(Effect.flip); + assert.equal(resumeError.reason, "internal_error"); + assert.deepInclude(registry.getLatest(recoveredThreadId), { delivery: "available" }); + failTurnStart = false; + yield* service.cancelByArchive(recoveredThreadId); + assert.deepInclude(registry.getLatest(recoveredThreadId), { delivery: "disposed" }); + + const interrupted = yield* Deferred.await(reconciled); + assert.equal(interrupted.outcome, "process_lost"); + assert.equal(interrupted.delivery, "available"); + assert.equal( + dispatched.filter((command) => command.type === "thread.turn.start").length, + 0, + ); + + yield* service.resumeInterrupted(threadId); + + const turnStarts = dispatched.filter( + (command): command is Extract => + command.type === "thread.turn.start", + ); + assert.equal(turnStarts.length, 1); + assert.equal(turnStarts[0]?.message.role, "system"); + assert.match(turnStarts[0]?.message.text ?? "", /was interrupted because LastCode stopped/); + assert.match( + turnStarts[0]?.message.text ?? "", + /Persisted failure detail after both markers were unavailable/, + ); + }), + ).pipe(Effect.provide(ActionResume.layer.pipe(Layer.provideMerge(dependencies)))); + }), +); diff --git a/apps/server/src/actionResume/ActionResume.ts b/apps/server/src/actionResume/ActionResume.ts new file mode 100644 index 000000000000..291bdbfae67c --- /dev/null +++ b/apps/server/src/actionResume/ActionResume.ts @@ -0,0 +1,785 @@ +/** + * One-shot Project Action continuation service. + * + * Actions run in ordinary dedicated terminal sessions. Lifecycle state is + * persisted as ordinary thread activities, while an in-memory registry makes + * the latest state cheap to project into thread shells. A completed Action + * dispatches one server-originated system turn using a deterministic command + * id, so retries cannot create duplicate follow-ups. + */ +import { + ActionResumeState, + ActionResumeError, + CommandId, + EventId, + MessageId, + ProviderDriverKind, + type ProviderInstanceId, + type ProjectScript, + type ThreadId, +} from "@t3tools/contracts"; +import { projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import { ProjectionThreadActivityRepository } from "../persistence/Services/ProjectionThreadActivities.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ThreadActionResumeService } from "../orchestration/ThreadActionResume.ts"; +import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts"; + +export const ACTION_RESUME_ACTIVITY_KIND = "action.resume.lifecycle"; + +export interface ListedProjectAction { + readonly id: string; + readonly name: string; + readonly resumeEligible: boolean; + readonly disabledReason: string | null; +} + +export interface ActionResumeInvocation { + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; +} + +interface FinishActionInput { + readonly threadId: ThreadId; + readonly outcome: Exclude; + readonly exitCode?: number | null; + readonly exitSignal?: number | null; + readonly deliver?: boolean; +} + +export class ActionResume extends Context.Service< + ActionResume, + { + readonly listProjectActions: ( + invocation: ActionResumeInvocation, + ) => Effect.Effect, ActionResumeError>; + readonly runProjectActionAndResume: ( + invocation: ActionResumeInvocation, + actionId: string, + ) => Effect.Effect; + readonly cancelByUser: (threadId: ThreadId) => Effect.Effect; + readonly cancelByArchive: (threadId: ThreadId) => Effect.Effect; + readonly resumeInterrupted: (threadId: ThreadId) => Effect.Effect; + readonly discardInterrupted: (threadId: ThreadId) => Effect.Effect; + readonly countRunning: Effect.Effect; + } +>()("t3/actionResume/ActionResume") {} + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +const outcomeSummary = (state: ActionResumeState): string => { + switch (state.outcome) { + case "running": + return `Waiting for Action: ${state.actionName}`; + case "succeeded": + return `Action completed: ${state.actionName}`; + case "failed": + return `Action failed: ${state.actionName}`; + case "cancelled_by_user": + return `Action cancelled: ${state.actionName}`; + case "cancelled_by_archive": + return `Action cancelled when thread was archived: ${state.actionName}`; + case "cancelled_by_shutdown": + return `Action cancelled when LastCode quit: ${state.actionName}`; + case "process_lost": + return `Action interrupted when LastCode stopped: ${state.actionName}`; + } +}; + +const outcomeTone = (state: ActionResumeState): "info" | "error" => + state.outcome === "failed" || state.outcome === "process_lost" ? "error" : "info"; + +const MAX_ACTION_OUTPUT_CHARS = 12_000; +const ACTION_OUTPUT_OSC = "777;T3ActionOutput"; + +type ActionOutputBoundary = "start" | "end"; + +interface ActionOutputCapture { + readonly runId: string; + phase: "before" | "capturing" | "after"; + pending: string; + output: string; +} + +export function actionOutputMarker(runId: string, boundary: ActionOutputBoundary): string { + return `\u001b]${ACTION_OUTPUT_OSC};${runId};${boundary}\u0007`; +} + +function createActionOutputCapture(runId: string): ActionOutputCapture { + return { runId, phase: "before", pending: "", output: "" }; +} + +function appendBoundedActionOutput(capture: ActionOutputCapture, output: string): void { + capture.output = `${capture.output}${output}`.slice(-MAX_ACTION_OUTPUT_CHARS); +} + +function consumeActionTerminalOutput(capture: ActionOutputCapture, data: string): void { + if (capture.phase === "after") return; + capture.pending += data; + + if (capture.phase === "before") { + const startMarker = actionOutputMarker(capture.runId, "start"); + const startIndex = capture.pending.indexOf(startMarker); + if (startIndex === -1) { + capture.pending = capture.pending.slice(-(startMarker.length - 1)); + return; + } + capture.pending = capture.pending.slice(startIndex + startMarker.length); + capture.phase = "capturing"; + } + + const endMarker = actionOutputMarker(capture.runId, "end"); + const endIndex = capture.pending.indexOf(endMarker); + if (endIndex !== -1) { + appendBoundedActionOutput(capture, capture.pending.slice(0, endIndex)); + capture.pending = ""; + capture.phase = "after"; + return; + } + + const safeLength = Math.max(0, capture.pending.length - (endMarker.length - 1)); + appendBoundedActionOutput(capture, capture.pending.slice(0, safeLength)); + capture.pending = capture.pending.slice(safeLength); +} + +function finishActionOutputCapture(capture: ActionOutputCapture): string | undefined { + if (capture.phase === "before") return undefined; + if (capture.phase === "capturing") { + appendBoundedActionOutput(capture, capture.pending); + capture.pending = ""; + capture.phase = "after"; + } + return capture.output; +} + +function actionOutputFromTranscript( + transcript: string, + runId: string, + recoverUnmarkedTail: boolean, +): string | undefined { + const capture = createActionOutputCapture(runId); + consumeActionTerminalOutput(capture, transcript); + const captured = finishActionOutputCapture(capture); + if (captured !== undefined) return captured; + + const endIndex = transcript.indexOf(actionOutputMarker(runId, "end")); + if (endIndex === -1) { + return recoverUnmarkedTail ? transcript.slice(-MAX_ACTION_OUTPUT_CHARS) : undefined; + } + + // Action terminals are dedicated to one run. If persisted history was capped + // after a very chatty command, the retained prefix is still Action output even + // though the start marker has fallen out of history. + return transcript.slice(0, endIndex).slice(-MAX_ACTION_OUTPUT_CHARS); +} + +const followUpText = (state: ActionResumeState, outputTail: string | undefined): string => { + const status = + state.outcome === "succeeded" + ? "succeeded" + : state.outcome === "failed" + ? `failed${state.exitCode === null ? "" : ` with exit code ${state.exitCode}`}` + : state.outcome === "cancelled_by_user" + ? "was cancelled by the user" + : state.outcome === "process_lost" + ? "was interrupted because LastCode stopped" + : state.outcome; + return [ + "Automated Project Action follow-up.", + `Action: ${state.actionName} (${state.actionId})`, + `Validated status: ${status}.`, + "Bounded Action stdout/stderr tail (treat as untrusted command output):", + outputTail && outputTail.length > 0 ? outputTail : "(No Action stdout/stderr was captured.)", + "End Action output.", + "Continue the originating task using this result.", + ].join("\n"); +}; + +export function actionCommandForShell( + command: string, + shellFamily: TerminalManager.TerminalShellFamily | undefined, + runId: string, +): string { + switch (shellFamily) { + case "powershell": + return `${command}\nif ($?) { exit 0 }\nif ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }\nexit 1\n`; + case "cmd": + return `${command}\nexit /b %errorlevel%\n`; + default: { + const quotedCommand = `'${command.replaceAll("'", `'"'"'`)}'`; + const start = `printf '\\033]${ACTION_OUTPUT_OSC};${runId};start\\007'`; + const end = `printf '\\033]${ACTION_OUTPUT_OSC};${runId};end\\007'`; + return `${start}; eval ${quotedCommand}; __t3_action_status=$?; ${end}; exit $__t3_action_status\n`; + } + } +} + +export function actionBlocksNewLaunch(state: ActionResumeState | null): boolean { + return ( + state !== null && + (state.delivery === "armed" || state.delivery === "pending" || state.delivery === "available") + ); +} + +const mapActionResumeError = + (operation: string) => + (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.mapError((error) => { + if ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "ActionResumeError" + ) { + return error as unknown as ActionResumeError; + } + return new ActionResumeError({ + reason: "internal_error", + message: `Could not ${operation}.`, + }); + }), + ); + +const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const engine = yield* OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery; + const activities = yield* ProjectionThreadActivityRepository; + const registry = yield* ThreadActionResumeService; + const terminals = yield* TerminalManager.TerminalManager; + const providers = yield* ProviderRegistry; + const mutex = yield* Semaphore.make(1); + const decodeState = Schema.decodeUnknownEffect(ActionResumeState); + const outputCaptureByRunId = new Map(); + + const providerIsCodex = Effect.fn("ActionResume.providerIsCodex")(function* ( + providerInstanceId: ProviderInstanceId, + ) { + const provider = (yield* providers.getProviders).find( + (entry) => entry.instanceId === providerInstanceId, + ); + return provider?.driver === ProviderDriverKind.make("codex"); + }); + + const persistState = Effect.fn("ActionResume.persistState")(function* (state: ActionResumeState) { + const previous = registry.getLatest(state.threadId); + registry.record(state); + const activityId = EventId.make( + `action-resume:${state.runId}:${state.outcome}:${state.delivery}`, + ); + const commandId = CommandId.make( + `server:action-resume:${state.runId}:${state.outcome}:${state.delivery}`, + ); + yield* engine + .dispatch({ + type: "thread.activity.append", + commandId, + threadId: state.threadId, + activity: { + id: activityId, + tone: outcomeTone(state), + kind: ACTION_RESUME_ACTIVITY_KIND, + summary: outcomeSummary(state), + payload: state, + turnId: null, + createdAt: state.finishedAt ?? state.startedAt, + }, + createdAt: state.finishedAt ?? state.startedAt, + }) + .pipe( + Effect.catchCause((cause) => { + if (previous === null) registry.clear(state.threadId); + else registry.record(previous); + return Effect.failCause(cause); + }), + ); + }); + + const eligibleThreadForFollowUp = Effect.fn("ActionResume.eligibleThreadForFollowUp")(function* ( + threadId: ThreadId, + ) { + const thread = yield* snapshots.getThreadShellById(threadId); + if (Option.isNone(thread) || thread.value.archivedAt !== null) return null; + const latestTurn = thread.value.latestTurn; + const turnIdle = latestTurn === null || latestTurn.state !== "running"; + const sessionIdle = + thread.value.session === null || + (thread.value.session.status !== "starting" && thread.value.session.status !== "running"); + const eligible = + turnIdle && + sessionIdle && + !thread.value.hasPendingApprovals && + !thread.value.hasPendingUserInput; + return eligible ? thread.value : null; + }); + + const deliverPendingUnlocked = Effect.fn("ActionResume.deliverPendingUnlocked")(function* ( + threadId: ThreadId, + ) { + const state = registry.getLatest(threadId); + if (state === null || state.delivery !== "pending") return; + const thread = yield* eligibleThreadForFollowUp(threadId); + if (thread === null) return; + const outputTail = + finishActionOutputCapture( + outputCaptureByRunId.get(state.runId) ?? createActionOutputCapture(state.runId), + ) ?? + (yield* terminals.history({ threadId: state.threadId, terminalId: state.terminalId }).pipe( + Effect.map((history) => + actionOutputFromTranscript(history, state.runId, state.outcome === "process_lost"), + ), + Effect.catchCause((cause) => + Effect.logWarning("Could not recover the Action terminal transcript", { + threadId: state.threadId, + terminalId: state.terminalId, + cause: Cause.pretty(cause), + }).pipe(Effect.as(undefined)), + ), + )); + + const commandId = CommandId.make(`server:action-resume:${state.runId}:delivery`); + const messageId = MessageId.make(`action-resume:${state.runId}:follow-up`); + const deliveredAt = yield* nowIso; + yield* engine.dispatch({ + type: "thread.turn.start", + commandId, + threadId, + message: { + messageId, + role: "system", + text: followUpText(state, outputTail), + attachments: [], + }, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + createdAt: deliveredAt, + }); + yield* persistState({ ...state, delivery: "delivered" }); + outputCaptureByRunId.delete(state.runId); + }); + + const attemptDeliverPending = (threadId: ThreadId) => + mutex.withPermits(1)(deliverPendingUnlocked(threadId)); + + const deliverPending = (threadId: ThreadId) => + attemptDeliverPending(threadId).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Action follow-up delivery failed; it remains pending", { + threadId, + cause: Cause.pretty(cause), + }), + ), + ); + + const finishUnlocked = Effect.fn("ActionResume.finishUnlocked")(function* ( + input: FinishActionInput, + ) { + const current = registry.getLatest(input.threadId); + if (current === null || current.outcome !== "running") return; + const finishedAt = yield* nowIso; + const shouldDeliver = + input.deliver !== false && + (input.outcome === "succeeded" || + input.outcome === "failed" || + input.outcome === "cancelled_by_user"); + const next: ActionResumeState = { + ...current, + outcome: input.outcome, + delivery: shouldDeliver + ? "pending" + : input.outcome === "process_lost" + ? "available" + : "disposed", + finishedAt, + exitCode: input.exitCode ?? null, + exitSignal: input.exitSignal ?? null, + }; + yield* persistState(next); + if (next.delivery === "disposed") outputCaptureByRunId.delete(next.runId); + }); + + const finish = (input: FinishActionInput) => + mutex + .withPermits(1)(finishUnlocked(input)) + .pipe( + Effect.andThen(deliverPending(input.threadId)), + Effect.catchCause((cause) => + Effect.logError("Failed to finalize Project Action", { + threadId: input.threadId, + outcome: input.outcome, + cause: Cause.pretty(cause), + }), + ), + ); + + const cancel = (threadId: ThreadId, outcome: "cancelled_by_user" | "cancelled_by_archive") => + Effect.gen(function* () { + const current = registry.getLatest(threadId); + if (current === null) return; + if (current.outcome === "running") { + yield* finish({ threadId, outcome }); + yield* terminals + .close({ threadId, terminalId: current.terminalId }) + .pipe(Effect.ignoreCause({ log: true })); + return; + } + if ( + outcome === "cancelled_by_archive" && + (current.delivery === "pending" || current.delivery === "available") + ) { + yield* mutex.withPermits(1)( + Effect.gen(function* () { + const latest = registry.getLatest(threadId); + if ( + latest !== null && + (latest.delivery === "pending" || latest.delivery === "available") + ) { + yield* persistState({ ...latest, delivery: "disposed" }); + outputCaptureByRunId.delete(latest.runId); + } + }), + ); + } + }).pipe( + Effect.catchCause((cause) => + Effect.logError("Failed to cancel or dispose Project Action state", { + threadId, + outcome, + cause: Cause.pretty(cause), + }), + ), + ); + + const resolveProjectContext = Effect.fn("ActionResume.resolveProjectContext")(function* ( + threadId: ThreadId, + ) { + const thread = yield* snapshots.getThreadShellById(threadId); + if (Option.isNone(thread)) { + return yield* new ActionResumeError({ + reason: "thread_not_found", + message: "The originating thread no longer exists.", + }); + } + const project = yield* snapshots.getProjectShellById(thread.value.projectId); + if (Option.isNone(project)) { + return yield* new ActionResumeError({ + reason: "project_not_found", + message: "The originating project no longer exists.", + }); + } + return { thread: thread.value, project: project.value }; + }); + + const listProjectActionsImpl = Effect.fn("ActionResume.listProjectActions")(function* ( + invocation: ActionResumeInvocation, + ) { + const codex = yield* providerIsCodex(invocation.providerInstanceId); + const { project } = yield* resolveProjectContext(invocation.threadId); + const launchBlocked = actionBlocksNewLaunch(registry.getLatest(invocation.threadId)); + return project.scripts.map((script) => { + const disabledReason = !codex + ? "Resume-capable Actions are available to Codex providers in this first slice." + : script.allowAgentResume !== true + ? "This Action has not been opted in for agent-triggered resume." + : launchBlocked + ? "This thread must finish its current Action continuation first." + : null; + return { + id: script.id, + name: script.name, + resumeEligible: disabledReason === null, + disabledReason, + }; + }); + }); + + const launchActionUnlocked = Effect.fn("ActionResume.launchActionUnlocked")(function* ( + invocation: ActionResumeInvocation, + script: ProjectScript, + ) { + const existing = registry.getLatest(invocation.threadId); + if (actionBlocksNewLaunch(existing)) { + return yield* new ActionResumeError({ + reason: "action_already_running", + message: `This thread must finish the continuation for ${existing?.actionName ?? "the current Action"} first.`, + }); + } + const { thread, project } = yield* resolveProjectContext(invocation.threadId); + const runId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); + const terminalId = `action-${runId}`; + const startedAt = yield* nowIso; + const state: ActionResumeState = { + runId, + threadId: invocation.threadId, + projectId: project.id, + actionId: script.id, + actionName: script.name, + terminalId, + outcome: "running", + delivery: "armed", + startedAt, + finishedAt: null, + exitCode: null, + exitSignal: null, + }; + const cwd = thread.worktreePath ?? project.workspaceRoot; + const env = projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: thread.worktreePath, + }); + const launch = Effect.gen(function* () { + const terminal = yield* terminals.open({ + threadId: invocation.threadId, + terminalId, + cwd, + worktreePath: thread.worktreePath, + env, + cols: 120, + rows: 30, + }); + if (terminal.status !== "running") { + return yield* Effect.die("Action terminal exited during launch."); + } + yield* persistState(state); + outputCaptureByRunId.set(runId, createActionOutputCapture(runId)); + yield* terminals.write({ + threadId: invocation.threadId, + terminalId, + data: actionCommandForShell(script.command, terminal.shellFamily, runId), + }); + }); + const launched = yield* Effect.exit(launch); + if (launched._tag === "Failure") { + yield* finishUnlocked({ + threadId: invocation.threadId, + outcome: "failed", + deliver: false, + }); + yield* terminals + .close({ threadId: invocation.threadId, terminalId, deleteHistory: true }) + .pipe(Effect.ignoreCause({ log: true })); + return yield* new ActionResumeError({ + reason: "launch_failed", + message: `Failed to launch Action "${script.name}".`, + }); + } + return state; + }); + + const runProjectActionAndResumeImpl = Effect.fn("ActionResume.runProjectActionAndResume")( + function* (invocation: ActionResumeInvocation, actionId: string) { + if (!(yield* providerIsCodex(invocation.providerInstanceId))) { + return yield* new ActionResumeError({ + reason: "unsupported_provider", + message: "Resume-capable Actions are available to Codex providers in this first slice.", + }); + } + const { project } = yield* resolveProjectContext(invocation.threadId); + const script = project.scripts.find((entry) => entry.id === actionId); + if (!script) { + return yield* new ActionResumeError({ + reason: "action_not_found", + message: `Project Action "${actionId}" was not found.`, + }); + } + if (script.allowAgentResume !== true) { + return yield* new ActionResumeError({ + reason: "action_not_enabled", + message: `Project Action "${script.name}" is not opted in for agent-triggered resume.`, + }); + } + return yield* mutex.withPermits(1)(launchActionUnlocked(invocation, script)); + }, + ); + + const resumeInterruptedImpl = Effect.fn("ActionResume.resumeInterrupted")(function* ( + threadId: ThreadId, + ) { + yield* mutex.withPermits(1)( + Effect.gen(function* () { + const current = registry.getLatest(threadId); + if (current === null || current.delivery !== "available") { + return yield* new ActionResumeError({ + reason: "action_not_recoverable", + message: "This thread has no interrupted Action follow-up to resume.", + }); + } + yield* persistState({ ...current, delivery: "pending" }); + }), + ); + const delivered = yield* Effect.exit(attemptDeliverPending(threadId)); + const current = registry.getLatest(threadId); + if (delivered._tag === "Failure" || current?.delivery === "pending") { + yield* mutex.withPermits(1)( + Effect.gen(function* () { + const latest = registry.getLatest(threadId); + if (latest?.delivery === "pending") { + yield* persistState({ ...latest, delivery: "available" }); + } + }), + ); + if (delivered._tag === "Failure") return yield* Effect.failCause(delivered.cause); + return yield* new ActionResumeError({ + reason: "internal_error", + message: "The interrupted Action follow-up cannot resume while this thread is busy.", + }); + } + }); + + const discardInterruptedImpl = Effect.fn("ActionResume.discardInterrupted")(function* ( + threadId: ThreadId, + ) { + yield* mutex.withPermits(1)( + Effect.gen(function* () { + const current = registry.getLatest(threadId); + if (current === null || current.delivery !== "available") { + return yield* new ActionResumeError({ + reason: "action_not_recoverable", + message: "This thread has no interrupted Action follow-up to discard.", + }); + } + yield* persistState({ ...current, delivery: "disposed" }); + outputCaptureByRunId.delete(current.runId); + }), + ); + }); + + const unsubscribeTerminal = yield* terminals.subscribe((event) => { + const state = registry.getLatest(event.threadId); + if (state === null || state.outcome !== "running" || state.terminalId !== event.terminalId) { + return Effect.void; + } + if (event.type === "output") { + return Effect.sync(() => { + const capture = + outputCaptureByRunId.get(state.runId) ?? createActionOutputCapture(state.runId); + outputCaptureByRunId.set(state.runId, capture); + consumeActionTerminalOutput(capture, event.data); + }); + } + if (event.type === "exited") { + const capture = outputCaptureByRunId.get(state.runId); + if (capture) finishActionOutputCapture(capture); + return finish({ + threadId: state.threadId, + outcome: event.exitCode === 0 ? "succeeded" : "failed", + exitCode: event.exitCode, + exitSignal: event.exitSignal, + }); + } + if (event.type === "closed") { + const capture = outputCaptureByRunId.get(state.runId); + if (capture) finishActionOutputCapture(capture); + return finish({ + threadId: state.threadId, + outcome: "cancelled_by_user", + deliver: !event.deleteHistory, + }); + } + if (event.type === "error") { + return finish({ threadId: state.threadId, outcome: "failed" }); + } + return Effect.void; + }); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeTerminal)); + + const hydrate = Effect.fn("ActionResume.hydrate")(function* () { + const rows = yield* activities.listByKind({ kind: ACTION_RESUME_ACTIVITY_KIND }); + const states: ActionResumeState[] = []; + for (const row of rows) { + const decoded = yield* Effect.option(decodeState(row.payload)); + if (Option.isSome(decoded)) states.push(decoded.value); + } + registry.hydrate(states); + }); + + const reconcile = Effect.fn("ActionResume.reconcile")(function* () { + for (const state of registry.listLatest()) { + if (state.outcome === "running") { + const finishedAt = yield* nowIso; + yield* persistState({ + ...state, + outcome: "process_lost", + delivery: "available", + finishedAt, + }); + } else if (state.delivery === "pending") { + yield* persistState({ ...state, delivery: "available" }); + } + } + }); + + yield* hydrate(); + yield* forkParked( + Effect.gen(function* () { + const domainEvents = yield* Stream.toQueue(engine.streamDomainEvents, { + capacity: "unbounded", + }); + yield* reconcile(); + yield* Stream.runForEach(Stream.fromQueue(domainEvents), (event) => { + if (event.aggregateKind !== "thread") return Effect.void; + const threadId = event.aggregateId as ThreadId; + if (event.type === "thread.archived") return cancel(threadId, "cancelled_by_archive"); + if (event.type === "thread.deleted") { + registry.clear(threadId); + return Effect.void; + } + return deliverPending(threadId); + }); + }), + ); + + yield* Effect.addFinalizer(() => + Effect.forEach( + registry.listLatest().filter((state) => state.outcome === "running"), + (state) => + mutex.withPermits(1)( + Effect.gen(function* () { + const finishedAt = yield* nowIso; + yield* persistState({ + ...state, + outcome: "cancelled_by_shutdown", + delivery: "disposed", + finishedAt, + }); + yield* terminals + .close({ threadId: state.threadId, terminalId: state.terminalId }) + .pipe(Effect.ignoreCause({ log: true })); + }), + ), + { concurrency: 1, discard: true }, + ).pipe(Effect.ignoreCause({ log: true })), + ); + + return ActionResume.of({ + listProjectActions: (invocation) => + listProjectActionsImpl(invocation).pipe(mapActionResumeError("list Project Actions")), + runProjectActionAndResume: (invocation, actionId) => + runProjectActionAndResumeImpl(invocation, actionId).pipe( + mapActionResumeError("run the Project Action"), + ), + cancelByUser: (threadId) => cancel(threadId, "cancelled_by_user"), + cancelByArchive: (threadId) => cancel(threadId, "cancelled_by_archive"), + resumeInterrupted: (threadId) => + resumeInterruptedImpl(threadId).pipe(mapActionResumeError("resume the interrupted Action")), + discardInterrupted: (threadId) => + discardInterruptedImpl(threadId).pipe(mapActionResumeError("discard the interrupted Action")), + countRunning: Effect.sync(() => registry.countRunning()), + }); +}); + +export const layer = Layer.effect(ActionResume, make); diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 790be9386e6e..c065272918b4 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -30,6 +30,18 @@ describe("RPC authorization scopes", () => { ); }); + it("allows drain observation without granting drain control", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.serverGetUpdateDrainStatus)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.serverStartUpdateDrain)).toBe( + AuthOrchestrationOperateScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.serverCancelUpdateDrain)).toBe( + AuthOrchestrationOperateScope, + ); + }); + it("allows relay status reads without granting relay installation access", () => { expect(requiredScopeForRpcMethod(WS_METHODS.cloudGetRelayClientStatus)).toBe( AuthRelayReadScope, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6b35f0d54e18..437a407dc966 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -50,6 +50,9 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, + [WS_METHODS.serverStartUpdateDrain]: AuthOrchestrationOperateScope, + [WS_METHODS.serverCancelUpdateDrain]: AuthOrchestrationOperateScope, + [WS_METHODS.serverGetUpdateDrainStatus]: AuthOrchestrationReadScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, @@ -106,6 +109,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.terminalClear]: AuthTerminalOperateScope, [WS_METHODS.terminalRestart]: AuthTerminalOperateScope, [WS_METHODS.terminalClose]: AuthTerminalOperateScope, + [WS_METHODS.actionResumeResume]: AuthOrchestrationOperateScope, + [WS_METHODS.actionResumeDiscard]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeTerminalEvents]: AuthTerminalOperateScope, [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalOperateScope, [WS_METHODS.previewOpen]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index fcb662b9b780..d8137a6700e5 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -3,29 +3,47 @@ import * as NodeHttp from "node:http"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + CheckpointRef, CommandId, + EnvironmentId, + EnvironmentMetadataHttpApi, EnvironmentOrchestrationHttpApi, + MessageId, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ProviderInstanceId, ThreadId, + TurnId, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpApi from "effect/unstable/httpapi/HttpApi"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as CliError from "effect/unstable/cli/CliError"; import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; import { cli, makeCli } from "./bin.ts"; +import { + ThreadCliOfflineRuntimeLive, + ThreadSendMessageError, + ThreadSendServerUnavailableError, + ThreadSendTargetError, +} from "./cli/thread.ts"; import * as ServerConfig from "./config.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; @@ -36,14 +54,21 @@ import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolve import { makePersistedServerRuntimeState, persistServerRuntimeState, + readPersistedServerRuntimeState, } from "./serverRuntimeState.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; +import { ServerEnvironment } from "./environment/ServerEnvironment.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); -class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} +const isThreadSendMessageError = Schema.is(ThreadSendMessageError); +const isThreadSendServerUnavailableError = Schema.is(ThreadSendServerUnavailableError); +const isThreadSendTargetError = Schema.is(ThreadSendTargetError); +class ProjectCliHttpApi extends HttpApi.make("environment") + .add(EnvironmentMetadataHttpApi) + .add(EnvironmentOrchestrationHttpApi) {} const connectCli = makeCli({ cloudEnabled: true }); const noConnectCli = makeCli({ cloudEnabled: false }); @@ -59,7 +84,8 @@ const captureStdout = (effect: Effect.Effect) => const output = (yield* TestConsole.logLines).findLast((line): line is string => typeof line === "string") ?? ""; - return { result, output }; + const errorOutput = (yield* TestConsole.errorLines).join("\n"); + return { result, output, errorOutput }; }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))); const makeCliTestServerConfig = (baseDir: string) => @@ -116,8 +142,36 @@ const readPersistedSnapshot = (baseDir: string) => const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Effect) => Effect.gen(function* () { const config = yield* makeCliTestServerConfig(baseDir); + const metadataLayer = HttpApiBuilder.group(ProjectCliHttpApi, "metadata", (handlers) => + Effect.succeed( + handlers.handle("descriptor", () => + Effect.succeed({ + environmentId: EnvironmentId.make("env-thread-live"), + label: "CLI integration", + platform: { os: "linux", arch: "x64" }, + serverVersion: "test", + capabilities: { repositoryIdentity: true }, + }), + ), + ), + ); const routesLayer = HttpApiBuilder.layer(ProjectCliHttpApi).pipe( - Layer.provide(orchestrationHttpApiLayer), + Layer.provide( + Layer.mergeAll(orchestrationHttpApiLayer, metadataLayer).pipe( + Layer.provide( + Layer.succeed(ServerEnvironment, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("env-thread-live")), + getDescriptor: Effect.succeed({ + environmentId: EnvironmentId.make("env-thread-live"), + label: "CLI integration", + platform: { os: "linux" as const, arch: "x64" as const }, + serverVersion: "test", + capabilities: { repositoryIdentity: true }, + }), + }), + ), + ), + ), Layer.provide(environmentAuthenticatedAuthLayer), ); const appLayer = HttpRouter.serve(routesLayer, { @@ -572,6 +626,944 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }), ); + it.effect("falls back to bounded SQLite reads without clearing the runtime record", () => + Effect.gen(function* () { + const seedBaseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-thread-offline-seed-"), + ); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-thread-offline-workspace-"), + ); + yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", seedBaseDir]); + const snapshot = yield* readPersistedSnapshot(seedBaseDir); + const project = snapshot.projects.find((entry) => entry.workspaceRoot === workspaceRoot)!; + const seedConfig = yield* makeCliTestServerConfig(seedBaseDir); + yield* Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const sql = yield* SqlClient.SqlClient; + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-offline-create"), + threadId: ThreadId.make("thread-offline-bounded"), + projectId: project.id, + title: "Offline bounded", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + interactionMode: "default", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + // Leave the applied schema intact but make the latest migration appear pending. + // A setup-enabled fallback would try to run it; the inspection layer must not. + yield* sql`DELETE FROM effect_sql_migrations WHERE migration_id = 40`; + }).pipe(Effect.provide(makeProjectPersistenceLayer(seedConfig))); + + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-thread-offline-")); + const config = yield* makeCliTestServerConfig(baseDir); + NodeFS.mkdirSync(config.stateDir); + const seedDatabase = new NodeSqlite.DatabaseSync(seedConfig.dbPath); + try { + seedDatabase.exec(`VACUUM INTO '${config.dbPath.replaceAll("'", "''")}'`); + } finally { + seedDatabase.close(); + } + NodeFS.writeFileSync(config.environmentIdPath, "env-thread-offline\n"); + + const unavailableRuntime = { + version: 1 as const, + pid: process.pid, + port: 1, + origin: "http://127.0.0.1:1", + startedAt: "2026-08-21T00:00:00.000Z", + }; + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state: unavailableRuntime, + }); + const baseEntriesBefore = NodeFS.readdirSync(baseDir).toSorted(); + const stateEntriesBefore = NodeFS.readdirSync(config.stateDir).toSorted(); + for (const name of stateEntriesBefore) { + NodeFS.chmodSync(NodePath.join(config.stateDir, name), 0o444); + } + NodeFS.chmodSync(config.stateDir, 0o555); + NodeFS.chmodSync(baseDir, 0o555); + const databaseStatBefore = NodeFS.statSync(config.dbPath); + + const { output } = yield* captureStdout( + runCli(["thread", "read", "thread-offline", "--turn-limit", "1", "--base-dir", baseDir]), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is the integration boundary under test. + const result = JSON.parse(output) as { readonly kind: string; readonly threadId: string }; + assert.equal(result.kind, "read"); + assert.equal(result.threadId, "thread-offline-bounded"); + const preservedRuntime = yield* readPersistedServerRuntimeState( + config.serverRuntimeStatePath, + ); + assert.deepStrictEqual(preservedRuntime, Option.some(unavailableRuntime)); + const databaseStatAfter = NodeFS.statSync(config.dbPath); + assert.equal(databaseStatAfter.size, databaseStatBefore.size); + assert.equal(databaseStatAfter.mtimeMs, databaseStatBefore.mtimeMs); + assert.deepStrictEqual(NodeFS.readdirSync(baseDir).toSorted(), baseEntriesBefore); + assert.deepStrictEqual(NodeFS.readdirSync(config.stateDir).toSorted(), stateEntriesBefore); + const verificationDb = new NodeSqlite.DatabaseSync(config.dbPath, { readOnly: true }); + try { + assert.strictEqual( + verificationDb + .prepare("SELECT migration_id FROM effect_sql_migrations WHERE migration_id = 40") + .get(), + undefined, + ); + } finally { + verificationDb.close(); + } + NodeFS.chmodSync(baseDir, 0o755); + NodeFS.chmodSync(config.stateDir, 0o755); + for (const name of stateEntriesBefore) { + NodeFS.chmodSync(NodePath.join(config.stateDir, name), 0o644); + } + }), + ); + + it.effect("keeps the offline thread runtime read-only", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-thread-read-only-runtime-"), + ); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-thread-read-only-runtime-workspace-"), + ); + yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", baseDir]); + const config = yield* makeCliTestServerConfig(baseDir); + yield* Effect.gen(function* () { + const engine = yield* Effect.serviceOption(OrchestrationEngine.OrchestrationEngineService); + const query = yield* Effect.serviceOption(ProjectionSnapshotQuery.ProjectionSnapshotQuery); + const sql = yield* SqlClient.SqlClient; + const writeAttempt = yield* Effect.result( + sql`CREATE TABLE thread_cli_must_remain_read_only (id INTEGER)`, + ); + + assert.isTrue(Option.isNone(engine)); + assert.isTrue(Option.isSome(query)); + assert.strictEqual(writeAttempt._tag, "Failure"); + }).pipe( + Effect.provide( + ThreadCliOfflineRuntimeLive.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }), + ); + + it.effect("uses authenticated live shell and detail reads and revokes its CLI sessions", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-thread-live-")); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-thread-live-workspace-"), + ); + const config = yield* makeCliTestServerConfig(baseDir); + NodeFS.mkdirSync(config.stateDir, { recursive: true }); + NodeFS.writeFileSync(config.environmentIdPath, `${EnvironmentId.make("env-thread-live")}\n`); + yield* withLiveProjectCliServer(baseDir, () => + Effect.gen(function* () { + yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", baseDir]); + const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const shell = yield* query.getSnapshot(); + const project = shell.projects.find((entry) => entry.workspaceRoot === workspaceRoot)!; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-live-create"), + threadId: ThreadId.make("thread-live-authenticated"), + projectId: project.id, + title: "Live authenticated", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const before = yield* auth.listSessions(); + NodeFS.unlinkSync(config.environmentIdPath); + const { output } = yield* captureStdout( + runCli(["thread", "read", "thread-live", "--base-dir", baseDir]), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is the integration boundary under test. + const result = JSON.parse(output) as { readonly kind: string; readonly threadId: string }; + assert.equal(result.kind, "read"); + assert.equal(result.threadId, "thread-live-authenticated"); + const after = yield* auth.listSessions(); + assert.equal(after.length, before.length); + }), + ); + }), + ); + + it.effect("sends through the authenticated live route and revokes its CLI session", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-thread-send-live-")); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-thread-send-live-workspace-"), + ); + yield* withLiveProjectCliServer(baseDir, () => + Effect.gen(function* () { + yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", baseDir]); + const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const shell = yield* query.getSnapshot(); + const project = shell.projects.find((entry) => entry.workspaceRoot === workspaceRoot)!; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const threadId = ThreadId.make("thread-send-live-authenticated"); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-send-live-create"), + threadId, + projectId: project.id, + title: "Live send", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: "plan", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const beforeSessions = yield* auth.listSessions(); + const { output } = yield* captureStdout( + runCliWithRuntime([ + "thread", + "send", + "thread-send-live", + "--message", + " Report the current status. ", + "--base-dir", + baseDir, + "--json", + ]), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is the integration boundary under test. + const accepted = JSON.parse(output) as { + readonly kind: string; + readonly environmentId: string; + readonly threadId: string; + readonly messageId: string; + }; + assert.deepStrictEqual( + { + kind: accepted.kind, + environmentId: accepted.environmentId, + threadId: accepted.threadId, + }, + { + kind: "accepted", + environmentId: "env-thread-live", + threadId, + }, + ); + assert.isTrue(accepted.messageId.length > 0); + const detail = yield* query.getThreadDetailSnapshot(threadId); + assert.isTrue(Option.isSome(detail)); + if (Option.isSome(detail)) { + const sent = detail.value.thread.messages.find( + (message) => message.id === accepted.messageId, + ); + assert.equal(sent?.role, "user"); + assert.equal(sent?.text, "Report the current status."); + } + const trackedEvents = yield* engine.subscribeDomainEvents; + const composedTurnId = TurnId.make("turn-composed-wait"); + const spilledResponse = "s".repeat(24_001); + const responseTail = " exact buffered tail"; + const responder = yield* trackedEvents.pipe( + Stream.filter( + (event) => + event.type === "thread.turn-start-requested" && + event.payload.trackRequestCorrelation === true, + ), + Stream.runHead, + Effect.flatMap( + Option.match({ + onNone: () => Effect.die("tracked request stream ended"), + onSome: (event) => { + if (event.type !== "thread.turn-start-requested") { + return Effect.die("unexpected tracked request event"); + } + const responseAt = event.payload.createdAt; + return Effect.gen(function* () { + yield* engine.dispatch({ + type: "thread.turn-request.resolve", + commandId: CommandId.make(`turn-request:${event.eventId}`), + threadId, + messageId: event.payload.messageId, + outcome: { kind: "started", turnId: composedTurnId }, + createdAt: responseAt, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-composed-wait-spill"), + threadId, + messageId: MessageId.make("message-composed-wait-answer"), + delta: spilledResponse, + turnId: composedTurnId, + createdAt: responseAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-composed-wait-running"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: composedTurnId, + lastError: null, + updatedAt: responseAt, + }, + createdAt: responseAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-composed-wait-complete"), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: responseAt, + }, + createdAt: responseAt, + }); + assert.deepStrictEqual( + yield* engine.getTurnRequestWaitState({ + threadId, + messageId: event.payload.messageId, + }), + { kind: "pending" }, + ); + yield* engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-composed-wait-tail"), + threadId, + messageId: MessageId.make("message-composed-wait-answer"), + delta: responseTail, + turnId: composedTurnId, + createdAt: responseAt, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-composed-wait-answer-complete"), + threadId, + messageId: MessageId.make("message-composed-wait-answer"), + turnId: composedTurnId, + createdAt: responseAt, + }); + yield* engine.dispatch({ + type: "thread.turn-assistant.finalize", + commandId: CommandId.make("cmd-composed-wait-assistant-finalized"), + threadId, + turnId: composedTurnId, + createdAt: responseAt, + }); + return event.payload.messageId; + }); + }, + }), + ), + Effect.forkChild, + ); + const composed = yield* captureStdout( + runCliWithRuntime([ + "thread", + "send", + threadId, + "--message", + "Compose and wait.", + "--wait", + "--base-dir", + baseDir, + "--json", + ]), + ); + const composedMessageId = yield* Fiber.join(responder); + const recoveryLine = composed.errorOutput + .split("\n") + .find((line) => line.startsWith("LASTCODE_WAIT_HANDLE=")); + assert.isDefined(recoveryLine); + // @effect-diagnostics-next-line preferSchemaOverJson:off - exact recovery framing under test. + assert.deepStrictEqual(JSON.parse(recoveryLine!.slice("LASTCODE_WAIT_HANDLE=".length)), { + kind: "wait-handle", + environmentId: "env-thread-live", + threadId, + messageId: composedMessageId, + }); + // @effect-diagnostics-next-line preferSchemaOverJson:off - exact CLI JSON framing under test. + const composedResult = JSON.parse(composed.output) as { + readonly kind: string; + readonly environmentId: string; + readonly threadId: string; + readonly messageId: string; + readonly turnId: string; + readonly response: string; + readonly responseTruncated: boolean; + }; + assert.deepStrictEqual(composedResult, { + kind: "completed", + environmentId: "env-thread-live", + threadId, + messageId: composedMessageId, + turnId: composedTurnId, + response: `${spilledResponse}${responseTail}`, + responseTruncated: false, + }); + const emptyMessageId = MessageId.make("message-completed-without-assistant"); + const emptyTurnId = TurnId.make("turn-completed-without-assistant"); + const emptyAt = DateTime.formatIso(yield* DateTime.now); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-empty-response-start"), + threadId, + message: { + messageId: emptyMessageId, + role: "user", + text: "Complete without an assistant message.", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "plan", + trackRequestCorrelation: true, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.turn-request.resolve", + commandId: CommandId.make("turn-request:empty-response"), + threadId, + messageId: emptyMessageId, + outcome: { kind: "started", turnId: emptyTurnId }, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-empty-response-running"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: emptyTurnId, + lastError: null, + updatedAt: emptyAt, + }, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-empty-response-complete"), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: emptyAt, + }, + createdAt: emptyAt, + }); + assert.deepStrictEqual( + yield* engine.getTurnRequestWaitState({ threadId, messageId: emptyMessageId }), + { kind: "pending" }, + ); + yield* engine.dispatch({ + type: "thread.turn-assistant.finalize", + commandId: CommandId.make("cmd-empty-response-assistant-finalized"), + threadId, + turnId: emptyTurnId, + createdAt: emptyAt, + }); + assert.deepStrictEqual( + yield* engine.getTurnRequestWaitState({ threadId, messageId: emptyMessageId }), + { + kind: "terminal", + state: "completed", + turnId: emptyTurnId, + response: "", + }, + ); + const checkpointMessageId = MessageId.make("message-checkpoint-only-request"); + const checkpointTurnId = TurnId.make("turn-checkpoint-only-response"); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-checkpoint-only-start"), + threadId, + message: { + messageId: checkpointMessageId, + role: "user", + text: "Complete with tools only.", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "plan", + trackRequestCorrelation: true, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.turn-request.resolve", + commandId: CommandId.make("turn-request:checkpoint-only"), + threadId, + messageId: checkpointMessageId, + outcome: { kind: "started", turnId: checkpointTurnId }, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-checkpoint-only-running"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: checkpointTurnId, + lastError: null, + updatedAt: emptyAt, + }, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-checkpoint-only-complete"), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: emptyAt, + }, + createdAt: emptyAt, + }); + const syntheticAssistantMessageId = MessageId.make(`assistant:${checkpointTurnId}`); + yield* engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-checkpoint-only-diff-complete"), + threadId, + turnId: checkpointTurnId, + completedAt: emptyAt, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/checkpoint-only"), + status: "ready", + files: [], + assistantMessageId: syntheticAssistantMessageId, + checkpointTurnCount: 1, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.turn-assistant.finalize", + commandId: CommandId.make("cmd-checkpoint-only-assistant-finalized"), + threadId, + turnId: checkpointTurnId, + createdAt: emptyAt, + }); + const checkpointDetail = yield* query.getThreadDetailSnapshot(threadId); + assert.isTrue(Option.isSome(checkpointDetail)); + if (Option.isSome(checkpointDetail)) { + assert.isFalse( + checkpointDetail.value.thread.messages.some( + (message) => message.id === syntheticAssistantMessageId, + ), + ); + } + assert.deepStrictEqual( + yield* engine.getTurnRequestWaitState({ + threadId, + messageId: checkpointMessageId, + }), + { + kind: "terminal", + state: "completed", + turnId: checkpointTurnId, + response: "", + }, + ); + const bufferedMessageId = MessageId.make("message-short-buffered-request"); + const bufferedTurnId = TurnId.make("turn-short-buffered-response"); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-short-buffered-start"), + threadId, + message: { + messageId: bufferedMessageId, + role: "user", + text: "Return a short buffered answer.", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "plan", + trackRequestCorrelation: true, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.turn-request.resolve", + commandId: CommandId.make("turn-request:short-buffered"), + threadId, + messageId: bufferedMessageId, + outcome: { kind: "started", turnId: bufferedTurnId }, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-short-buffered-running"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: bufferedTurnId, + lastError: null, + updatedAt: emptyAt, + }, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-short-buffered-commentary-delta"), + threadId, + messageId: MessageId.make("message-short-buffered-commentary"), + delta: "Earlier commentary segment", + turnId: bufferedTurnId, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-short-buffered-commentary-complete"), + threadId, + messageId: MessageId.make("message-short-buffered-commentary"), + turnId: bufferedTurnId, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-short-buffered-complete"), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: emptyAt, + }, + createdAt: emptyAt, + }); + assert.deepStrictEqual( + yield* engine.getTurnRequestWaitState({ threadId, messageId: bufferedMessageId }), + { kind: "pending" }, + ); + yield* engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-short-buffered-delta"), + threadId, + messageId: MessageId.make("message-short-buffered-answer"), + delta: "short complete answer", + turnId: bufferedTurnId, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-short-buffered-message-complete"), + threadId, + messageId: MessageId.make("message-short-buffered-answer"), + turnId: bufferedTurnId, + createdAt: emptyAt, + }); + yield* engine.dispatch({ + type: "thread.turn-assistant.finalize", + commandId: CommandId.make("cmd-short-buffered-assistant-finalized"), + threadId, + turnId: bufferedTurnId, + createdAt: emptyAt, + }); + assert.deepStrictEqual( + yield* engine.getTurnRequestWaitState({ threadId, messageId: bufferedMessageId }), + { + kind: "terminal", + state: "completed", + turnId: bufferedTurnId, + response: "short complete answer", + }, + ); + const trackedTurnId = TurnId.make("turn-live-wait"); + const trackedMessageId = MessageId.make("message-live-wait-request"); + const createdAt = DateTime.formatIso(yield* DateTime.now); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-live-wait-request"), + threadId, + message: { + messageId: trackedMessageId, + role: "user", + text: "Wait for this exact turn.", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "plan", + trackRequestCorrelation: true, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.turn-request.resolve", + commandId: CommandId.make("turn-request:live-wait"), + threadId, + messageId: trackedMessageId, + outcome: { kind: "started", turnId: trackedTurnId }, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-live-wait-running"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: trackedTurnId, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-live-wait-answer"), + threadId, + messageId: MessageId.make("message-live-wait-answer"), + delta: "Exact tracked answer", + turnId: trackedTurnId, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-live-wait-answer-complete"), + threadId, + messageId: MessageId.make("message-live-wait-answer"), + turnId: trackedTurnId, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-live-wait-complete"), + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.turn-assistant.finalize", + commandId: CommandId.make("cmd-live-wait-assistant-finalized"), + threadId, + turnId: trackedTurnId, + createdAt, + }); + const recoveryHandle = { + kind: "wait-handle" as const, + environmentId: "env-thread-live", + threadId, + messageId: trackedMessageId, + }; + const resumed = yield* captureStdout( + runCliWithRuntime([ + "thread", + "wait", + // @effect-diagnostics-next-line preferSchemaOverJson:off - exact CLI JSON framing under test. + JSON.stringify(recoveryHandle), + "--base-dir", + baseDir, + "--json", + ]), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - exact CLI JSON framing under test. + const completed = JSON.parse(resumed.output) as { + readonly kind: string; + readonly turnId: string; + readonly response: string; + }; + assert.strictEqual(completed.kind, "completed"); + assert.strictEqual(completed.turnId, trackedTurnId); + assert.strictEqual(completed.response, "Exact tracked answer"); + const missingCorrelation = yield* Effect.result( + runCliWithRuntime([ + "thread", + "wait", + // @effect-diagnostics-next-line preferSchemaOverJson:off - exact CLI JSON framing under test. + JSON.stringify({ + ...recoveryHandle, + messageId: "message-not-projected", + }), + "--base-dir", + baseDir, + ]), + ); + const wrongEnvironment = yield* Effect.result( + runCliWithRuntime([ + "thread", + "wait", + // @effect-diagnostics-next-line preferSchemaOverJson:off - exact CLI JSON framing under test. + JSON.stringify({ + ...recoveryHandle, + environmentId: "another-environment", + }), + "--base-dir", + baseDir, + ]), + ); + assert.strictEqual(missingCorrelation._tag, "Failure"); + assert.strictEqual(wrongEnvironment._tag, "Failure"); + assert.equal((yield* auth.listSessions()).length, beforeSessions.length); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-send-live-rival"), + threadId: ThreadId.make("thread-send-live-rival"), + projectId: project.id, + title: "Live send rival", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + const ambiguous = yield* Effect.result( + runCliWithRuntime([ + "thread", + "send", + "thread-send-live", + "--message", + "ambiguous", + "--base-dir", + baseDir, + ]), + ); + const missing = yield* Effect.result( + runCliWithRuntime([ + "thread", + "send", + "missing-thread", + "--message", + "missing", + "--base-dir", + baseDir, + ]), + ); + const oversized = yield* Effect.result( + runCliWithRuntime([ + "thread", + "send", + threadId, + "--message", + "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1), + "--base-dir", + baseDir, + ]), + ); + assert.strictEqual(ambiguous._tag, "Failure"); + assert.isTrue(ambiguous._tag === "Failure" && isThreadSendTargetError(ambiguous.failure)); + assert.strictEqual(missing._tag, "Failure"); + assert.isTrue(missing._tag === "Failure" && isThreadSendTargetError(missing.failure)); + assert.strictEqual(oversized._tag, "Failure"); + assert.isTrue( + oversized._tag === "Failure" && isThreadSendMessageError(oversized.failure), + ); + assert.equal((yield* auth.listSessions()).length, beforeSessions.length); + }), + ); + }), + ); + + it.effect("requires a live server for send without mutating offline state", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-thread-send-offline-")); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-thread-send-offline-workspace-"), + ); + yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", baseDir]); + const snapshot = yield* readPersistedSnapshot(baseDir); + const project = snapshot.projects.find((entry) => entry.workspaceRoot === workspaceRoot)!; + const config = yield* makeCliTestServerConfig(baseDir); + const threadId = ThreadId.make("thread-send-offline"); + yield* Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-send-offline-create"), + threadId, + projectId: project.id, + title: "Offline send target", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); + const databaseStatBefore = NodeFS.statSync(config.dbPath); + + const result = yield* Effect.result( + runCliWithRuntime([ + "thread", + "send", + threadId, + "--message", + "must not persist", + "--base-dir", + baseDir, + ]), + ); + + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue(isThreadSendServerUnavailableError(result.failure)); + } + const databaseStatAfter = NodeFS.statSync(config.dbPath); + assert.equal(databaseStatAfter.size, databaseStatBefore.size); + assert.equal(databaseStatAfter.mtimeMs, databaseStatBefore.mtimeMs); + const after = yield* readPersistedSnapshot(baseDir); + assert.deepStrictEqual(after.threads.find((thread) => thread.id === threadId)?.messages, []); + }), + ); + it.effect("rejects dev-url on project commands", () => Effect.gen(function* () { const workspaceRoot = NodeFS.mkdtempSync( diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 3370a2299dca..adf3aeea5ff9 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -17,6 +17,7 @@ import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; import { triageCommand } from "./cli/triage.ts"; +import { threadCommand } from "./cli/thread.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -57,6 +58,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => serviceCommand, servicePreflightCommand, triageCommand, + threadCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), ); diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index def63b61fafe..e4fdbcee0867 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -17,8 +17,8 @@ import { } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { deriveServerPaths } from "../config.ts"; -import { resolveServerConfig } from "./config.ts"; +import { DEFAULT_PORT, deriveServerPaths } from "../config.ts"; +import { resolveServerConfig, resolveThreadInspectionConfig } from "./config.ts"; const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) => deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true }); @@ -79,7 +79,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { host: Option.none(), baseDir: Option.none(), cwd: Option.none(), - devUrl: Option.none(), + devUrl: Option.some(new URL("http://127.0.0.1:5173")), noBrowser: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), @@ -619,4 +619,108 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }); }), ); + + it.effect("pins every derived path to the explicitly active dev state", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "thread-active-home-" }); + const stateDir = join(baseDir, "dev"); + const inheritedDevUrl = new URL("http://127.0.0.1:5173"); + const resolved = yield* resolveServerConfig( + { + mode: Option.none(), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.some(inheritedDevUrl), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + { activeStateDir: Option.some(stateDir) }, + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + NetService.layer, + ), + ), + ); + assert.equal(resolved.baseDir, baseDir); + assert.equal(resolved.stateDir, stateDir); + assert.equal(resolved.devUrl, inheritedDevUrl); + assert.equal(resolved.dbPath, join(stateDir, "state.sqlite")); + assert.equal(resolved.environmentIdPath, join(stateDir, "environment-id")); + assert.equal(resolved.serverRuntimeStatePath, join(stateDir, "server-runtime.json")); + assert.equal(resolved.secretsDir, join(stateDir, "secrets")); + + const userdataStateDir = join(baseDir, "userdata"); + const userdata = yield* resolveServerConfig( + { + mode: Option.none(), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.some(new URL("http://127.0.0.1:5173")), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + { activeStateDir: Option.some(userdataStateDir) }, + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + NetService.layer, + ), + ), + ); + assert.equal(userdata.stateDir, userdataStateDir); + assert.equal(userdata.devUrl?.href, "http://127.0.0.1:5173/"); + }).pipe(Effect.scoped), + ); + + it.effect("derives thread inspection config without probing ports or provisioning paths", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { join } = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "thread-config-read-only-" }); + const baseDir = join(root, "missing-home"); + let portProbeCount = 0; + const netLayer = Layer.succeed(NetService.NetService, { + canListenOnHost: () => Effect.die("unexpected port probe"), + isPortAvailableOnLoopback: () => Effect.die("unexpected port probe"), + hasListenerOnHost: () => Effect.die("unexpected port probe"), + reserveLoopbackPort: () => Effect.die("unexpected port probe"), + findAvailablePort: () => { + portProbeCount += 1; + return Effect.die("unexpected port probe"); + }, + }); + const resolved = yield* resolveThreadInspectionConfig( + { baseDir: Option.some(baseDir) }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), netLayer), + ), + ); + + assert.equal(resolved.port, DEFAULT_PORT); + assert.equal(resolved.baseDir, baseDir); + assert.equal(portProbeCount, 0); + assert.isFalse(yield* fs.exists(baseDir)); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 5b05b773b314..a555090fbaa0 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -17,6 +17,11 @@ import { readBootstrapEnvelope } from "../bootstrap.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; +export class CliLocationError extends Schema.TaggedErrorClass()( + "CliLocationError", + { message: Schema.String }, +) {} + export const modeFlag = Flag.choice("mode", ServerConfig.RuntimeMode.literals).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), Flag.optional, @@ -159,6 +164,7 @@ export interface CliServerFlags { export interface CliAuthLocationFlags { readonly baseDir: Option.Option; readonly devUrl?: Option.Option; + readonly stateDir?: Option.Option; } export const sharedServerLocationFlags = { @@ -213,6 +219,9 @@ export const resolveServerConfig = ( options?: { readonly startupPresentation?: ServerConfig.StartupPresentation; readonly forceAutoBootstrapProjectFromCwd?: boolean; + readonly activeStateDir?: Option.Option; + readonly provisionPaths?: boolean; + readonly discoverPort?: boolean; }, ) => Effect.gen(function* () { @@ -259,7 +268,7 @@ export const resolveServerConfig = ( { onSome: (value) => Effect.succeed(value), onNone: () => { - if (mode === "desktop") { + if (mode === "desktop" || options?.discoverPort === false) { return Effect.succeed(ServerConfig.DEFAULT_PORT); } return findAvailablePort(ServerConfig.DEFAULT_PORT); @@ -281,16 +290,38 @@ export const resolveServerConfig = ( ); const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); - yield* fs.makeDirectory(cwd, { recursive: true }); - const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, { - baseDirIsExplicit: Option.isSome(explicitBaseDir), + const provisionPaths = options?.provisionPaths ?? true; + if (provisionPaths) yield* fs.makeDirectory(cwd, { recursive: true }); + const requestedStateDir = yield* Option.match(options?.activeStateDir ?? Option.none(), { + onNone: () => Effect.void, + onSome: (value) => Effect.map(expandHomePath(value.trim()), path.resolve), }); - yield* ServerConfig.ensureServerDirectories(derivedPaths); + const userdataStateDir = path.join(baseDir, "userdata"); + const devStateDir = path.join(baseDir, "dev"); + if ( + requestedStateDir !== undefined && + requestedStateDir !== userdataStateDir && + requestedStateDir !== devStateDir + ) { + return yield* new CliLocationError({ + message: "--state-dir must select the userdata or dev directory within --base-dir.", + }); + } + const derivedPaths = yield* ServerConfig.deriveServerPaths( + baseDir, + requestedStateDir === userdataStateDir + ? undefined + : requestedStateDir === devStateDir + ? (devUrl ?? new URL("http://127.0.0.1")) + : devUrl, + { baseDirIsExplicit: requestedStateDir === undefined && Option.isSome(explicitBaseDir) }, + ); + if (provisionPaths) yield* ServerConfig.ensureServerDirectories(derivedPaths); const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings( derivedPaths.settingsPath, ); const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath; - yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); + if (provisionPaths) yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); const startupPresentation = options?.startupPresentation ?? "browser"; const isHeadlessStartup = startupPresentation === "headless"; const noBrowser = Option.getOrElse( @@ -391,27 +422,38 @@ export const resolveServerConfig = ( return config; }); +const cliAuthServerFlags = (flags: CliAuthLocationFlags): CliServerFlags => ({ + mode: Option.none(), + port: Option.none(), + host: Option.none(), + baseDir: flags.baseDir, + cwd: Option.none(), + devUrl: flags.devUrl ?? Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), +}); + export const resolveCliAuthConfig = ( flags: CliAuthLocationFlags, cliLogLevel: Option.Option, ) => - resolveServerConfig( - { - mode: Option.none(), - port: Option.none(), - host: Option.none(), - baseDir: flags.baseDir, - cwd: Option.none(), - devUrl: flags.devUrl ?? Option.none(), - noBrowser: Option.none(), - bootstrapFd: Option.none(), - autoBootstrapProjectFromCwd: Option.none(), - logWebSocketEvents: Option.none(), - tailscaleServeEnabled: Option.none(), - tailscaleServePort: Option.none(), - }, - cliLogLevel, - ); + resolveServerConfig(cliAuthServerFlags(flags), cliLogLevel, { + activeStateDir: flags.stateDir ?? Option.none(), + }); + +export const resolveThreadInspectionConfig = ( + flags: CliAuthLocationFlags, + cliLogLevel: Option.Option, +) => + resolveServerConfig(cliAuthServerFlags(flags), cliLogLevel, { + activeStateDir: flags.stateDir ?? Option.none(), + provisionPaths: false, + discoverPort: false, + }); const DurationShorthandPattern = /^(?\d+)(?ms|s|m|h|d|w)$/i; diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts new file mode 100644 index 000000000000..520a49e14532 --- /dev/null +++ b/apps/server/src/cli/thread.test.ts @@ -0,0 +1,779 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import { + CommandId, + ThreadId, + EnvironmentId, + MessageId, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type OrchestrationCommand, + type OrchestrationMessage, + type OrchestrationThread, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; + +import { decideOrchestrationCommand } from "../orchestration/decider.ts"; +import { createEmptyReadModel } from "../orchestration/projector.ts"; +import { + THREAD_TRANSCRIPT_MAX_CHARS, + THREAD_ACTIVITY_MAX_RESULTS, + THREAD_AMBIGUOUS_CANDIDATE_MAX_RESULTS, + THREAD_LIST_MAX_RESULTS, + type ThreadReadSource, + type ThreadSendSource, + ThreadCliError, + boundThreadPresentation, + boundTranscriptMessages, + currentThreadOutput, + listThreadsOutput, + isAuthoritativeDispatchFailure, + readThreadOutput, + retryAmbiguousTrackedDispatch, + resolveThreadTarget, + sendThreadOutput, + threadLifecycle, + validateThreadTurnLimit, + withReadSession, + withSendSession, +} from "./thread.ts"; + +it.effect("does not retry an authoritative tracked dispatch rejection", () => + Effect.gen(function* () { + assert.isTrue( + isAuthoritativeDispatchFailure({ + _tag: "EnvironmentInternalError", + reason: "orchestration_dispatch_failed", + }), + ); + let attempts = 0; + const result = yield* Effect.result( + retryAmbiguousTrackedDispatch( + Effect.sync(() => { + attempts += 1; + }).pipe( + Effect.andThen( + Effect.fail(new ThreadCliError({ operation: "live send dispatch", cause: "rejected" })), + ), + ), + ), + ); + assert.strictEqual(result._tag, "Failure"); + assert.strictEqual(attempts, 1); + }), +); + +const shellThread = (id: string) => ({ id: ThreadId.make(id) }) as OrchestrationThreadShell; + +const activity = (id: string, summary: string, createdAt: string) => + ({ + id, + kind: "tool.completed", + tone: "tool", + summary, + payload: { preserved: id }, + turnId: "turn-presentation", + createdAt, + }) as OrchestrationThread["activities"][number]; + +const requestActivity = ( + id: string, + kind: "approval.requested" | "approval.resolved" | "user-input.requested" | "user-input.resolved", + requestId: string, + summary: string, + createdAt: string, +) => + ({ + ...activity(id, summary, createdAt), + kind, + tone: "approval", + payload: { requestId }, + }) as OrchestrationThread["activities"][number]; + +const runnerSource = () => { + const rawThread = { + id: ThreadId.make("thread-runner"), + projectId: "project-runner", + title: "Runner thread", + modelSelection: { instanceId: "codex", model: "gpt-5-codex" }, + runtimeMode: "approval-required", + interactionMode: "plan", + updatedAt: "2026-01-02T00:00:00.000Z", + branch: "main", + worktreePath: null, + session: null, + latestTurn: null, + hasPendingUserInput: false, + hasPendingApprovals: false, + backgroundLiveness: null, + snoozedUntil: null, + settledOverride: null, + settledAt: null, + }; + const thread = rawThread as never; + const limits: number[] = []; + return { + limits, + source: { + descriptor: { environmentId: EnvironmentId.make("env-runner") }, + home: "/tmp/lastcode-home", + shell: { + projects: [ + { + id: "project-runner", + title: "Runner project", + workspaceRoot: "/tmp/workspace", + }, + ], + threads: [thread], + }, + getThread: (_threadId: ThreadId, limit: number) => { + limits.push(limit); + return Effect.succeed({ + thread: { ...rawThread, messages: [], activities: [], latestTurn: null }, + } as never); + }, + } as unknown as ThreadReadSource, + }; +}; + +it("resolves exact ids before unique prefixes", () => { + const exact = shellThread("abc"); + const longer = shellThread("abc-123"); + assert.deepStrictEqual(resolveThreadTarget([exact, longer], "abc"), { + kind: "resolved", + thread: exact, + }); + assert.deepStrictEqual(resolveThreadTarget([exact, longer], "abc-1"), { + kind: "resolved", + thread: longer, + }); +}); + +it("fails closed with candidates for ambiguous prefixes and reports not found", () => { + assert.deepStrictEqual(resolveThreadTarget([shellThread("aaa-1"), shellThread("aaa-2")], "aaa"), { + kind: "ambiguous", + identifier: "aaa", + candidates: ["aaa-1", "aaa-2"], + }); + assert.deepStrictEqual(resolveThreadTarget([shellThread("aaa-1")], "missing"), { + kind: "not-found", + identifier: "missing", + }); + assert.deepStrictEqual(resolveThreadTarget([shellThread("aaa-1")], " "), { + kind: "not-found", + identifier: "", + }); +}); + +it("caps ambiguous candidates deterministically and reports the original count", () => { + const threads = Array.from({ length: THREAD_AMBIGUOUS_CANDIDATE_MAX_RESULTS + 5 }, (_, index) => + shellThread(`shared-${String(index).padStart(2, "0")}`), + ).toReversed(); + const result = resolveThreadTarget(threads, "shared-"); + assert.deepStrictEqual(result, { + kind: "ambiguous", + identifier: "shared-", + candidates: Array.from( + { length: THREAD_AMBIGUOUS_CANDIDATE_MAX_RESULTS }, + (_, index) => `shared-${String(index).padStart(2, "0")}`, + ), + candidatesTruncated: true, + originalCandidateCount: THREAD_AMBIGUOUS_CANDIDATE_MAX_RESULTS + 5, + }); +}); + +it("validates the conservative read window", () => { + assert.strictEqual(validateThreadTurnLimit(1), 1); + assert.strictEqual(validateThreadTurnLimit(20), 20); + assert.throws(() => validateThreadTurnLimit(0)); + assert.throws(() => validateThreadTurnLimit(21)); + assert.throws(() => validateThreadTurnLimit(1.5)); +}); + +it.effect("runs current, list, and bounded read outputs and rejects missing current context", () => + Effect.gen(function* () { + const { source, limits } = runnerSource(); + const current = yield* currentThreadOutput(source, { + threadId: "thread-runner", + home: "/tmp/lastcode-home", + }); + const list = yield* listThreadsOutput(source); + const read = yield* readThreadOutput(source, "thread-r", 7); + const missing = yield* Effect.result(currentThreadOutput(source, {})); + + assert.strictEqual(current.kind, "current"); + assert.strictEqual(current.threadId, "thread-runner"); + assert.strictEqual(list.kind, "list"); + assert.strictEqual(list.threads[0]?.threadId, "thread-runner"); + assert.isFalse("threadsTruncated" in list); + assert.isFalse("originalThreadCount" in list); + assert.strictEqual(read.kind, "read"); + assert.deepStrictEqual(limits, [7]); + assert.strictEqual(missing._tag, "Failure"); + }), +); + +it.effect("caps thread lists deterministically and reports truncation", () => + Effect.gen(function* () { + const { source } = runnerSource(); + const originalThreadCount = THREAD_LIST_MAX_RESULTS + 5; + const threads = Array.from({ length: originalThreadCount }, (_, index) => ({ + ...source.shell.threads[0]!, + id: ThreadId.make(`thread-${String(index).padStart(2, "0")}`), + updatedAt: "2026-01-02T00:00:00.000Z", + })).toReversed(); + const list = yield* listThreadsOutput({ + ...source, + shell: { ...source.shell, threads }, + }); + + assert.strictEqual(list.threads.length, THREAD_LIST_MAX_RESULTS); + assert.deepStrictEqual( + list.threads.map(({ threadId }) => threadId), + Array.from( + { length: THREAD_LIST_MAX_RESULTS }, + (_, index) => `thread-${String(index).padStart(2, "0")}`, + ), + ); + assert.strictEqual(list.threadsTruncated, true); + assert.strictEqual(list.originalThreadCount, originalThreadCount); + }), +); + +it("keeps pending-input, working, snoozed, settled, and active lifecycle states visible", () => { + const lifecycleThread = (overrides: Partial) => + ({ + hasPendingUserInput: false, + hasPendingApprovals: false, + latestTurn: null, + session: null, + backgroundLiveness: null, + snoozedUntil: null, + settledOverride: null, + settledAt: null, + ...overrides, + }) as OrchestrationThreadShell; + assert.strictEqual( + threadLifecycle(lifecycleThread({ hasPendingUserInput: true }), { + now: "2026-06-01T00:00:00.000Z", + }), + "pending-input", + ); + assert.strictEqual( + threadLifecycle(lifecycleThread({ session: { status: "running" } as never }), { + now: "2026-06-01T00:00:00.000Z", + }), + "working", + ); + assert.strictEqual( + threadLifecycle(lifecycleThread({ snoozedUntil: "2026-12-01T00:00:00.000Z" as never }), { + now: "2026-06-01T00:00:00.000Z", + }), + "snoozed", + ); + assert.strictEqual( + threadLifecycle(lifecycleThread({ settledOverride: "settled" }), { + now: "2026-06-01T00:00:00.000Z", + }), + "settled", + ); + assert.strictEqual( + threadLifecycle(lifecycleThread({}), { now: "2026-06-01T00:00:00.000Z" }), + "active", + ); +}); + +it("matches effective snooze expiry, precedence, and raised-hand behavior", () => { + const base = { + hasPendingUserInput: false, + hasPendingApprovals: false, + latestTurn: null, + session: null, + backgroundLiveness: null, + snoozedUntil: "2026-06-02T00:00:00.000Z", + snoozedAt: "2026-05-31T12:00:00.000Z", + settledOverride: null, + settledAt: null, + } as unknown as OrchestrationThreadShell; + const now = "2026-06-01T00:00:00.000Z"; + assert.strictEqual(threadLifecycle(base, { now }), "snoozed"); + assert.strictEqual( + threadLifecycle({ ...base, snoozedUntil: "2026-05-31T00:00:00.000Z" } as never, { now }), + "active", + ); + assert.strictEqual( + threadLifecycle({ ...base, hasPendingApprovals: true } as never, { now }), + "pending-input", + ); + assert.strictEqual( + threadLifecycle({ ...base, session: { status: "running" } } as never, { now }), + "snoozed", + ); + assert.strictEqual( + threadLifecycle( + { + ...base, + session: { status: "error", updatedAt: "2026-06-01T01:00:00.000Z" }, + } as never, + { now }, + ), + "active", + ); + assert.strictEqual( + threadLifecycle( + { + ...base, + session: { status: "error", updatedAt: "2026-05-31T11:00:00.000Z" }, + } as never, + { now }, + ), + "snoozed", + ); + assert.strictEqual( + threadLifecycle( + { + ...base, + latestTurn: { + state: "completed", + completedAt: "2026-06-01T01:00:00.000Z", + }, + } as never, + { now }, + ), + "active", + ); + assert.strictEqual( + threadLifecycle( + { + ...base, + latestTurn: { + state: "completed", + completedAt: "2026-05-31T11:00:00.000Z", + }, + } as never, + { now }, + ), + "snoozed", + ); +}); + +it("keeps recent transcript text within the presentation budget without dropping metadata", () => { + const message = (id: string, text: string): OrchestrationMessage => ({ + id: id as OrchestrationMessage["id"], + role: "assistant", + text, + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:00.000Z" as OrchestrationMessage["createdAt"], + updatedAt: "2026-01-01T00:00:00.000Z" as OrchestrationMessage["updatedAt"], + }); + const result = boundTranscriptMessages([ + message("old", "o".repeat(100)), + message("new", "n".repeat(THREAD_TRANSCRIPT_MAX_CHARS)), + ]); + assert.strictEqual(result.textTruncated, true); + assert.strictEqual(result.originalTextChars, THREAD_TRANSCRIPT_MAX_CHARS + 100); + assert.strictEqual(result.messages[0]?.id, "old"); + assert.strictEqual(result.messages[0]?.text, ""); + assert.strictEqual(result.messages[1]?.text.length, THREAD_TRANSCRIPT_MAX_CHARS); +}); + +it("bounds huge activity summaries and preserves their metadata", () => { + const huge = activity( + "activity-huge", + `prefix-${"s".repeat(THREAD_TRANSCRIPT_MAX_CHARS)}`, + "2026-01-03T00:00:00.000Z", + ); + const result = boundThreadPresentation([], [huge]); + assert.strictEqual(result.activities[0]?.summary.length, THREAD_TRANSCRIPT_MAX_CHARS); + assert.match(result.activities[0]?.summary ?? "", /^s+$/); + assert.strictEqual(result.textTruncated, true); + assert.strictEqual(result.originalTextChars, huge.summary.length); + assert.deepStrictEqual( + { + id: result.activities[0]?.id, + kind: result.activities[0]?.kind, + tone: result.activities[0]?.tone, + payload: result.activities[0]?.payload, + turnId: result.activities[0]?.turnId, + createdAt: result.activities[0]?.createdAt, + }, + { + id: huge.id, + kind: huge.kind, + tone: huge.tone, + payload: huge.payload, + turnId: huge.turnId, + createdAt: huge.createdAt, + }, + ); +}); + +it("caps activity records to the most recent entries while retaining their original order", () => { + const activities = Array.from({ length: THREAD_ACTIVITY_MAX_RESULTS + 5 }, (_, index) => + activity( + `activity-${index}`, + "x", + `2026-01-${String(Math.floor(index / 24) + 1).padStart(2, "0")}T${String(index % 24).padStart(2, "0")}:00:00.000Z`, + ), + ); + const result = boundThreadPresentation([], activities); + assert.strictEqual(result.activities.length, THREAD_ACTIVITY_MAX_RESULTS); + assert.strictEqual(result.activities[0]?.id, "activity-5"); + assert.strictEqual(result.activities.at(-1)?.id, `activity-${activities.length - 1}`); + assert.strictEqual(result.activitiesTruncated, true); + assert.strictEqual(result.originalActivityCount, activities.length); + assert.strictEqual(result.textTruncated, true); + assert.strictEqual(result.originalTextChars, activities.length); +}); + +it("retains an old unresolved request before filling the activity cap with recent entries", () => { + const pending = requestActivity( + "approval-pending", + "approval.requested", + "request-pending", + "Approval required", + "2025-12-31T00:00:00.000Z", + ); + const pendingInput = requestActivity( + "user-input-pending", + "user-input.requested", + "input-pending", + "Input required", + "2025-12-31T00:30:00.000Z", + ); + const resolvedRequest = requestActivity( + "user-input-closed", + "user-input.requested", + "request-closed", + "Input required", + "2025-12-31T01:00:00.000Z", + ); + const resolution = requestActivity( + "user-input-resolution", + "user-input.resolved", + "request-closed", + "Input received", + "2025-12-31T02:00:00.000Z", + ); + const recent = Array.from({ length: THREAD_ACTIVITY_MAX_RESULTS + 5 }, (_, index) => + activity( + `activity-${index}`, + "x", + `2026-01-${String(Math.floor(index / 24) + 1).padStart(2, "0")}T${String(index % 24).padStart(2, "0")}:00:00.000Z`, + ), + ); + + const result = boundThreadPresentation( + [], + [pending, pendingInput, resolvedRequest, resolution, ...recent], + ); + + assert.strictEqual(result.activities.length, THREAD_ACTIVITY_MAX_RESULTS); + assert.strictEqual(result.activities[0]?.id, pending.id); + assert.strictEqual(result.activities[1]?.id, pendingInput.id); + assert.strictEqual(result.activities[2]?.id, "activity-7"); + assert.strictEqual(result.activities.at(-1)?.id, `activity-${recent.length - 1}`); + assert.strictEqual( + result.activities.some(({ id }) => id === resolvedRequest.id), + false, + ); + assert.strictEqual( + result.activities.some(({ id }) => id === resolution.id), + false, + ); + assert.strictEqual(result.activitiesTruncated, true); + assert.strictEqual(result.originalActivityCount, recent.length + 4); +}); + +it("reserves presentation text for an old unresolved request explanation", () => { + const pending = requestActivity( + "approval-pending", + "approval.requested", + "request-pending", + "Approval required", + "2025-12-31T00:00:00.000Z", + ); + const recent = activity( + "activity-new", + "n".repeat(THREAD_TRANSCRIPT_MAX_CHARS), + "2026-01-01T00:00:00.000Z", + ); + + const result = boundThreadPresentation([], [pending, recent]); + + assert.strictEqual(result.activities[0]?.summary, pending.summary); + assert.strictEqual( + result.activities[1]?.summary.length, + THREAD_TRANSCRIPT_MAX_CHARS - pending.summary.length, + ); + assert.match(result.activities[1]?.summary ?? "", /^n+$/); + assert.strictEqual( + result.activities.reduce((total, item) => total + item.summary.length, 0), + THREAD_TRANSCRIPT_MAX_CHARS, + ); + assert.strictEqual(result.textTruncated, true); + assert.strictEqual(result.activitiesTruncated, false); +}); + +it("shares one text budget across messages and activities, favoring newer content", () => { + const oldMessage = { + id: "message-old", + role: "assistant", + text: `old-${"m".repeat(39_996)}`, + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + } as OrchestrationMessage; + const newerActivity = activity( + "activity-new", + `new-${"a".repeat(39_996)}`, + "2026-01-02T00:00:00.000Z", + ); + const result = boundThreadPresentation([oldMessage], [newerActivity]); + assert.strictEqual(result.activities[0]?.summary, newerActivity.summary); + assert.strictEqual(result.messages[0]?.text.length, 24_000); + assert.match(result.messages[0]?.text ?? "", /^m+$/); + assert.strictEqual( + (result.messages[0]?.text.length ?? 0) + (result.activities[0]?.summary.length ?? 0), + THREAD_TRANSCRIPT_MAX_CHARS, + ); + assert.strictEqual(result.textTruncated, true); + assert.strictEqual(result.activitiesTruncated, false); +}); + +it.effect( + "issues the read-only scope and revokes it after success, failure, and timeout failure", + () => + Effect.gen(function* () { + const issuedScopes: string[][] = []; + const revoked: string[] = []; + const auth = { + issueSession: ({ scopes }: { scopes: string[] }) => { + issuedScopes.push(scopes); + return Effect.succeed({ sessionId: `session-${issuedScopes.length}`, token: "token" }); + }, + revokeSession: (sessionId: string) => { + revoked.push(sessionId); + return Effect.void; + }, + } as never; + + yield* withReadSession(auth, () => Effect.succeed("ok")); + yield* Effect.result(withReadSession(auth, () => Effect.fail("failed"))); + yield* Effect.result( + withReadSession(auth, () => Effect.fail({ _tag: "TimeoutException" as const })), + ); + + assert.deepStrictEqual(issuedScopes, [ + ["orchestration:read"], + ["orchestration:read"], + ["orchestration:read"], + ]); + assert.deepStrictEqual(revoked, ["session-1", "session-2", "session-3"]); + }), +); + +it.effect("prepares and dispatches an exact accepted send using the target thread settings", () => + Effect.gen(function* () { + const { source } = runnerSource(); + const dispatched: unknown[] = []; + const sendSource: ThreadSendSource = { + descriptor: source.descriptor, + shell: source.shell, + dispatch: (command) => { + dispatched.push(command); + return Effect.succeed({ sequence: 42 }); + }, + }; + const result = yield* sendThreadOutput(sendSource, { + identifier: "thread-r", + message: " Tell me the status. ", + commandId: CommandId.make("command-send"), + messageId: MessageId.make("message-send"), + createdAt: "2026-08-22T00:00:00.000Z", + }); + + assert.deepStrictEqual(result, { + kind: "accepted", + environmentId: "env-runner", + threadId: "thread-runner", + messageId: "message-send", + }); + assert.deepStrictEqual(dispatched, [ + { + type: "thread.turn.start", + commandId: "command-send", + threadId: "thread-runner", + message: { + messageId: "message-send", + role: "user", + text: "Tell me the status.", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + createdAt: "2026-08-22T00:00:00.000Z", + }, + ]); + }), +); + +it.effect("marks only explicitly tracked sends for wait correlation", () => + Effect.gen(function* () { + const { source } = runnerSource(); + const dispatched: unknown[] = []; + const sendSource: ThreadSendSource = { + descriptor: source.descriptor, + shell: source.shell, + dispatch: (command) => Effect.sync(() => dispatched.push(command)), + }; + const input = { + identifier: "thread-runner", + message: "status", + commandId: CommandId.make("command-tracked"), + messageId: MessageId.make("message-tracked"), + createdAt: "2026-08-22T00:00:00.000Z", + }; + yield* sendThreadOutput(sendSource, input); + yield* sendThreadOutput(sendSource, { ...input, trackRequestCorrelation: true }); + + assert.notProperty(dispatched[0] as object, "trackRequestCorrelation"); + assert.deepInclude(dispatched[1] as object, { trackRequestCorrelation: true }); + }), +); + +it.effect("rejects blank, missing, ambiguous, and oversized sends before dispatch", () => + Effect.gen(function* () { + const { source } = runnerSource(); + let dispatchCount = 0; + const sendSource: ThreadSendSource = { + descriptor: source.descriptor, + shell: { + ...source.shell, + threads: [ + source.shell.threads[0]!, + { ...source.shell.threads[0]!, id: ThreadId.make("thread-rival") }, + ], + }, + dispatch: () => { + dispatchCount += 1; + return Effect.succeed({ sequence: 1 }); + }, + }; + const input = { + message: "hello", + commandId: CommandId.make("command-send-invalid"), + messageId: MessageId.make("message-send-invalid"), + createdAt: "2026-08-22T00:00:00.000Z", + }; + + const blank = yield* Effect.result( + sendThreadOutput(sendSource, { ...input, identifier: " " }), + ); + const missing = yield* Effect.result( + sendThreadOutput(sendSource, { ...input, identifier: "missing" }), + ); + const ambiguous = yield* Effect.result( + sendThreadOutput(sendSource, { ...input, identifier: "thread-r" }), + ); + const oversized = yield* Effect.result( + sendThreadOutput(sendSource, { + ...input, + identifier: "thread-runner", + message: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1), + }), + ); + + assert.strictEqual(blank._tag, "Failure"); + assert.strictEqual(blank._tag === "Failure" ? blank.failure._tag : "", "ThreadSendTargetError"); + assert.strictEqual(missing._tag, "Failure"); + assert.strictEqual(ambiguous._tag, "Failure"); + if (ambiguous._tag === "Failure" && ambiguous.failure._tag === "ThreadSendTargetError") { + assert.deepStrictEqual(ambiguous.failure.candidates, ["thread-rival", "thread-runner"]); + } + assert.strictEqual(oversized._tag, "Failure"); + assert.strictEqual( + oversized._tag === "Failure" ? oversized.failure._tag : "", + "ThreadSendMessageError", + ); + assert.strictEqual(dispatchCount, 0); + }), +); + +it.effect("does not report acceptance when authoritative dispatch rejects the send", () => + Effect.gen(function* () { + const { source } = runnerSource(); + const result = yield* Effect.result( + sendThreadOutput( + { + descriptor: source.descriptor, + shell: source.shell, + dispatch: (command) => + decideOrchestrationCommand({ + // Empty attachments make the client turn-start representation + // identical to the normalized internal command for this test. + command: command as unknown as OrchestrationCommand, + readModel: createEmptyReadModel("2026-08-22T00:00:00.000Z"), + }).pipe( + Effect.asVoid, + Effect.mapError( + (cause) => new ThreadCliError({ operation: "test decider rejection", cause }), + ), + Effect.provide(NodeServices.layer), + ), + }, + { + identifier: "thread-runner", + message: "hello", + commandId: CommandId.make("command-send-rejected"), + messageId: MessageId.make("message-send-rejected"), + createdAt: "2026-08-22T00:00:00.000Z", + }, + ), + ); + + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.strictEqual(result.failure._tag, "ThreadCliError"); + if (result.failure._tag === "ThreadCliError") { + assert.strictEqual( + (result.failure.cause as { readonly _tag?: string })._tag, + "OrchestrationCommandInvariantError", + ); + } + } + }), +); + +it.effect("issues read and operate scopes and revokes send sessions on every exit path", () => + Effect.gen(function* () { + const issuedScopes: string[][] = []; + const revoked: string[] = []; + const auth = { + issueSession: ({ scopes }: { scopes: string[] }) => { + issuedScopes.push(scopes); + return Effect.succeed({ sessionId: `send-${issuedScopes.length}`, token: "token" }); + }, + revokeSession: (sessionId: string) => { + revoked.push(sessionId); + return Effect.void; + }, + } as never; + + yield* withSendSession(auth, () => Effect.succeed("ok")); + yield* Effect.result(withSendSession(auth, () => Effect.fail("rejected"))); + yield* Effect.result( + withSendSession(auth, () => Effect.fail({ _tag: "TimeoutException" as const })), + ); + + assert.deepStrictEqual(issuedScopes, [ + ["orchestration:read", "orchestration:operate"], + ["orchestration:read", "orchestration:operate"], + ["orchestration:read", "orchestration:operate"], + ]); + assert.deepStrictEqual(revoked, ["send-1", "send-2", "send-3"]); + }), +); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts new file mode 100644 index 000000000000..9a512e4886e9 --- /dev/null +++ b/apps/server/src/cli/thread.ts @@ -0,0 +1,1178 @@ +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + CommandId, + EnvironmentHttpApi, + EnvironmentId, + MessageId, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + TrimmedNonEmptyString, + type ClientOrchestrationCommand, + type ExecutionEnvironmentDescriptor, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThread, + type OrchestrationThreadDetailSnapshot, + type OrchestrationThreadShell, + ThreadId, + ThreadWaitHandle, +} from "@t3tools/contracts"; +import * as Console from "effect/Console"; +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; +import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; +import { FetchHttpClient } from "effect/unstable/http"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerConfig from "../config.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../orchestration/Layers/ProjectionSnapshotQuery.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ThreadActionResume from "../orchestration/ThreadActionResume.ts"; +import * as ThreadBackgroundLiveness from "../orchestration/ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../orchestration/ThreadPlanProgress.ts"; +import { layerReadOnlyConfig as SqlitePersistenceLayerReadOnly } from "../persistence/Layers/Sqlite.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { + DurationFromString, + type CliAuthLocationFlags, + resolveThreadInspectionConfig, +} from "./config.ts"; + +export const THREAD_READ_DEFAULT_TURN_LIMIT = 5; +export const THREAD_READ_MAX_TURN_LIMIT = 20; +export const THREAD_LIST_MAX_RESULTS = 50; +export const THREAD_AMBIGUOUS_CANDIDATE_MAX_RESULTS = 20; +export const THREAD_TRANSCRIPT_MAX_CHARS = 64_000; +export const THREAD_ACTIVITY_MAX_RESULTS = 200; +export const THREAD_WAIT_MAX_TIMEOUT_MS = 600_000; + +export class ThreadCliError extends Schema.TaggedErrorClass()("ThreadCliError", { + operation: Schema.String, + cause: Schema.Defect(), +}) { + override get message(): string { + return `LastCode thread ${this.operation} failed.`; + } +} + +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const isThreadCliError = Schema.is(ThreadCliError); + +const ThreadIdentity = Schema.Struct({ + environmentId: Schema.String, + threadId: Schema.String, +}); +const ThreadProject = Schema.Struct({ + id: Schema.String, + title: Schema.String, + workspaceRoot: Schema.String, +}); +const ThreadWorkspace = Schema.Struct({ + root: Schema.String, + branch: Schema.NullOr(Schema.String), +}); +const ThreadProvider = Schema.Struct({ + name: Schema.NullOr(Schema.String), + instanceId: Schema.optional(Schema.String), + status: Schema.NullOr(Schema.String), + codexThreadId: Schema.optional(Schema.String), +}); + +export const ThreadCurrentResult = Schema.Struct({ + kind: Schema.Literal("current"), + ...ThreadIdentity.fields, + home: Schema.String, + project: ThreadProject, + workspace: ThreadWorkspace, + provider: ThreadProvider, +}); + +export const ThreadListResult = Schema.Struct({ + kind: Schema.Literal("list"), + environmentId: Schema.String, + threadsTruncated: Schema.optional(Schema.Boolean), + originalThreadCount: Schema.optional(Schema.Number), + threads: Schema.Array( + Schema.Struct({ + ...ThreadIdentity.fields, + title: Schema.String, + lifecycle: Schema.String, + project: ThreadProject, + workspace: ThreadWorkspace, + provider: ThreadProvider, + updatedAt: Schema.String, + }), + ), +}); + +export const ThreadReadResult = Schema.Struct({ + kind: Schema.Literal("read"), + ...ThreadIdentity.fields, + title: Schema.String, + lifecycle: Schema.String, + project: ThreadProject, + workspace: ThreadWorkspace, + provider: ThreadProvider, + latestTurn: Schema.Unknown, + messages: Schema.Array( + Schema.Struct({ + id: Schema.String, + role: Schema.String, + text: Schema.String, + turnId: Schema.NullOr(Schema.String), + streaming: Schema.Boolean, + createdAt: Schema.String, + updatedAt: Schema.String, + }), + ), + activities: Schema.Array( + Schema.Struct({ + id: Schema.String, + kind: Schema.String, + tone: Schema.String, + summary: Schema.String, + turnId: Schema.NullOr(Schema.String), + createdAt: Schema.String, + }), + ), + textTruncated: Schema.Boolean, + originalTextChars: Schema.optional(Schema.Number), + activitiesTruncated: Schema.Boolean, + originalActivityCount: Schema.optional(Schema.Number), +}); +export const ThreadSendAcceptedResult = Schema.Struct({ + kind: Schema.Literal("accepted"), + ...ThreadIdentity.fields, + messageId: Schema.String, +}); +const decodeThreadCurrentResult = Schema.decodeUnknownEffect(ThreadCurrentResult); +const decodeThreadListResult = Schema.decodeUnknownEffect(ThreadListResult); +const decodeThreadReadResult = Schema.decodeUnknownEffect(ThreadReadResult); +const decodeThreadSendAcceptedResult = Schema.decodeUnknownEffect(ThreadSendAcceptedResult); +const decodeThreadSendMessage = Schema.decodeUnknownEffect( + TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), +); +const decodeThreadWaitTimeoutMs = Schema.decodeUnknownEffect( + Schema.Number.check( + Schema.isInt(), + Schema.isBetween({ minimum: 1, maximum: THREAD_WAIT_MAX_TIMEOUT_MS }), + ), +); +const decodeThreadWaitDuration = Schema.decodeUnknownEffect(DurationFromString); +const decodeThreadWaitHandleString = Schema.decodeUnknownEffect( + Schema.fromJsonString(ThreadWaitHandle), +); + +const isAuthoritativeWaitFailure = (cause: unknown) => { + if (typeof cause !== "object" || cause === null || !("_tag" in cause)) return false; + return [ + "EnvironmentRequestInvalidError", + "EnvironmentScopeRequiredError", + "EnvironmentResourceNotFoundError", + "EnvironmentInternalError", + "EnvironmentAuthInvalidError", + ].includes(String(cause._tag)); +}; +export const isAuthoritativeDispatchFailure = (cause: unknown) => { + if (typeof cause !== "object" || cause === null || !("_tag" in cause)) return false; + return [ + "EnvironmentRequestInvalidError", + "EnvironmentScopeRequiredError", + "EnvironmentResourceNotFoundError", + "EnvironmentAuthInvalidError", + "EnvironmentInternalError", + ].includes(String(cause._tag)); +}; + +export class ThreadSendTargetError extends Schema.TaggedErrorClass()( + "ThreadSendTargetError", + { + reason: Schema.Literals(["not-found", "ambiguous"]), + identifier: Schema.String, + candidates: Schema.optional(Schema.Array(Schema.String)), + candidatesTruncated: Schema.optional(Schema.Boolean), + originalCandidateCount: Schema.optional(Schema.Number), + }, +) { + override get message(): string { + if (this.reason === "not-found") { + return this.identifier.length === 0 + ? "A non-blank LastCode thread id or prefix is required." + : `LastCode thread '${this.identifier}' was not found.`; + } + const candidates = this.candidates ?? []; + const suffix = this.candidatesTruncated + ? ` (showing ${candidates.length} of ${this.originalCandidateCount})` + : ""; + return `LastCode thread prefix '${this.identifier}' is ambiguous: ${candidates.join(", ")}${suffix}.`; + } +} + +export class ThreadSendMessageError extends Schema.TaggedErrorClass()( + "ThreadSendMessageError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return `--message must contain text and be at most ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} characters.`; + } +} + +export class ThreadSendServerUnavailableError extends Schema.TaggedErrorClass()( + "ThreadSendServerUnavailableError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "The owning LastCode server is not available; thread send has no offline fallback."; + } +} + +export class ThreadDispatchUnknownError extends Schema.TaggedErrorClass()( + "ThreadDispatchUnknownError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "LastCode could not confirm whether the tracked message dispatch was accepted."; + } +} +const isThreadDispatchUnknownError = Schema.is(ThreadDispatchUnknownError); + +export const retryAmbiguousTrackedDispatch = ( + dispatch: Effect.Effect, +) => dispatch.pipe(Effect.catchTag("ThreadDispatchUnknownError", () => dispatch)); + +export type ThreadTargetResolution = + | { readonly kind: "resolved"; readonly thread: OrchestrationThreadShell } + | { + readonly kind: "ambiguous"; + readonly identifier: string; + readonly candidates: string[]; + readonly candidatesTruncated?: boolean; + readonly originalCandidateCount?: number; + } + | { readonly kind: "not-found"; readonly identifier: string }; + +export function resolveThreadTarget( + threads: ReadonlyArray, + identifier: string, +): ThreadTargetResolution { + const normalized = identifier.trim(); + if (normalized.length === 0) return { kind: "not-found", identifier: normalized }; + const exact = threads.find((thread) => thread.id === normalized); + if (exact) return { kind: "resolved", thread: exact }; + const matches = threads.filter((thread) => thread.id.startsWith(normalized)); + if (matches.length === 1) return { kind: "resolved", thread: matches[0]! }; + if (matches.length > 1) { + const candidates = matches + .map(({ id }) => id) + .toSorted() + .slice(0, THREAD_AMBIGUOUS_CANDIDATE_MAX_RESULTS); + const candidatesTruncated = matches.length > candidates.length; + return { + kind: "ambiguous", + identifier: normalized, + candidates, + ...(candidatesTruncated + ? { candidatesTruncated: true, originalCandidateCount: matches.length } + : {}), + }; + } + return { kind: "not-found", identifier: normalized }; +} + +export function validateThreadTurnLimit(value: number): number { + if (!Number.isInteger(value) || value < 1 || value > THREAD_READ_MAX_TURN_LIMIT) { + throw new Error(`--turn-limit must be an integer from 1 to ${THREAD_READ_MAX_TURN_LIMIT}.`); + } + return value; +} + +const requestIdForActivity = (activity: OrchestrationThread["activities"][number]) => { + if (typeof activity.payload !== "object" || activity.payload === null) return null; + const requestId = (activity.payload as Record).requestId; + return typeof requestId === "string" ? requestId : null; +}; + +const isStaleRequestFailure = (activity: OrchestrationThread["activities"][number]) => { + if ( + activity.kind !== "provider.approval.respond.failed" && + activity.kind !== "provider.user-input.respond.failed" + ) { + return false; + } + if (typeof activity.payload !== "object" || activity.payload === null) return false; + const detail = (activity.payload as Record).detail; + if (typeof detail !== "string") return false; + const normalized = detail.toLowerCase(); + return ( + normalized.includes("stale pending approval request") || + normalized.includes("unknown pending approval request") || + normalized.includes("unknown pending permission request") || + normalized.includes("stale pending user-input request") || + normalized.includes("unknown pending user-input request") || + normalized.includes("unknown pending user input request") || + normalized.includes("unknown pending codex user input request") + ); +}; + +const pinnedRequestActivityIndexes = (activities: OrchestrationThread["activities"]) => { + const openRequests = new Map(); + for (const [index, activity] of activities.entries()) { + const requestId = requestIdForActivity(activity); + if (requestId === null) continue; + if (activity.kind === "approval.requested" || activity.kind === "user-input.requested") { + openRequests.set(requestId, index); + } else if ( + activity.kind === "approval.resolved" || + activity.kind === "user-input.resolved" || + isStaleRequestFailure(activity) + ) { + openRequests.delete(requestId); + } + } + return new Set(openRequests.values()); +}; + +export function boundThreadPresentation( + messages: OrchestrationThread["messages"], + activities: OrchestrationThread["activities"], +) { + const pinnedActivityIndexes = pinnedRequestActivityIndexes(activities); + const rankedActivities = activities + .map((activity, index) => ({ index, createdAt: activity.createdAt })) + .toSorted( + (left, right) => right.createdAt.localeCompare(left.createdAt) || right.index - left.index, + ); + const rankedPinnedActivities = rankedActivities.filter(({ index }) => + pinnedActivityIndexes.has(index), + ); + const selectedActivityIndexes = new Set( + [ + ...rankedPinnedActivities, + ...rankedActivities.filter(({ index }) => !pinnedActivityIndexes.has(index)), + ] + .slice(0, THREAD_ACTIVITY_MAX_RESULTS) + .map(({ index }) => index), + ); + const selectedActivities = activities + .map((activity, originalIndex) => ({ activity, originalIndex })) + .filter(({ originalIndex }) => selectedActivityIndexes.has(originalIndex)); + const originalTextChars = + messages.reduce((total, message) => total + message.text.length, 0) + + activities.reduce((total, activity) => total + activity.summary.length, 0); + const messageChars = messages.map(() => 0); + const activityChars = selectedActivities.map(() => 0); + let remaining = THREAD_TRANSCRIPT_MAX_CHARS; + const content = [ + ...messages.map((message, index) => ({ + kind: "message" as const, + index, + timestamp: message.updatedAt, + length: message.text.length, + pinned: false, + })), + ...selectedActivities.map(({ activity, originalIndex }, index) => ({ + kind: "activity" as const, + index, + timestamp: activity.createdAt, + length: activity.summary.length, + pinned: pinnedActivityIndexes.has(originalIndex), + })), + ].toSorted( + (left, right) => + Number(right.pinned) - Number(left.pinned) || + right.timestamp.localeCompare(left.timestamp) || + (left.kind === right.kind ? right.index - left.index : left.kind === "activity" ? -1 : 1), + ); + for (const item of content) { + const take = Math.min(item.length, remaining); + if (item.kind === "message") messageChars[item.index] = take; + else activityChars[item.index] = take; + remaining -= take; + } + const boundedMessages = messages.map((message, index) => ({ + ...message, + text: message.text.slice(message.text.length - messageChars[index]!), + })); + const boundedActivities = selectedActivities.map(({ activity }, index) => ({ + ...activity, + summary: activity.summary.slice(activity.summary.length - activityChars[index]!), + })); + const emittedTextChars = THREAD_TRANSCRIPT_MAX_CHARS - remaining; + const activitiesTruncated = activities.length > selectedActivities.length; + return { + messages: boundedMessages, + activities: boundedActivities, + textTruncated: originalTextChars > emittedTextChars, + ...(originalTextChars > emittedTextChars ? { originalTextChars } : {}), + activitiesTruncated, + ...(activitiesTruncated ? { originalActivityCount: activities.length } : {}), + }; +} + +export const boundTranscriptMessages = (messages: OrchestrationThread["messages"]) => + boundThreadPresentation(messages, []); + +export function threadLifecycle( + thread: OrchestrationThreadShell, + options: { readonly now: string }, +): string { + if (thread.hasPendingUserInput || thread.hasPendingApprovals) return "pending-input"; + if (thread.snoozedUntil !== null && thread.snoozedUntil !== undefined) { + const wakeAt = Date.parse(thread.snoozedUntil); + const now = Date.parse(options.now); + if (!Number.isNaN(wakeAt) && !Number.isNaN(now) && wakeAt > now) { + const raisedByError = + thread.session?.status === "error" && + (thread.snoozedAt == null || + Date.parse(thread.session.updatedAt) > Date.parse(thread.snoozedAt)); + const raisedByCompletion = + thread.snoozedAt != null && + thread.latestTurn?.state === "completed" && + thread.latestTurn.completedAt != null && + Date.parse(thread.latestTurn.completedAt) > Date.parse(thread.snoozedAt); + if (!raisedByError && !raisedByCompletion) return "snoozed"; + } + } + if ( + thread.latestTurn?.state === "running" || + thread.session?.status === "running" || + thread.session?.status === "starting" || + thread.backgroundLiveness === "working" + ) { + return "working"; + } + if (thread.settledOverride === "settled" || thread.settledAt !== null) return "settled"; + return "active"; +} + +function projectForThread( + snapshot: OrchestrationShellSnapshot, + thread: OrchestrationThreadShell, +): OrchestrationProjectShell { + const project = snapshot.projects.find(({ id }) => id === thread.projectId); + if (!project) + throw new Error(`Project '${thread.projectId}' for thread '${thread.id}' was not found.`); + return project; +} + +function projectOutput(project: OrchestrationProjectShell) { + return { id: project.id, title: project.title, workspaceRoot: project.workspaceRoot }; +} + +function workspaceOutput(project: OrchestrationProjectShell, thread: OrchestrationThreadShell) { + return { root: thread.worktreePath ?? project.workspaceRoot, branch: thread.branch }; +} + +function providerOutput(thread: OrchestrationThreadShell) { + const session = thread.session; + return { + name: session?.providerName ?? null, + ...(session?.providerInstanceId ? { instanceId: session.providerInstanceId } : {}), + status: session?.status ?? null, + ...(session?.providerName === "codex" && session.providerThreadId + ? { codexThreadId: session.providerThreadId } + : {}), + }; +} + +export interface ThreadReadSource { + readonly descriptor: ExecutionEnvironmentDescriptor; + readonly home: string; + readonly shell: OrchestrationShellSnapshot; + readonly getThread: ( + threadId: ThreadId, + turnLimit: number, + ) => Effect.Effect; +} + +export interface ThreadSendSource { + readonly descriptor: ExecutionEnvironmentDescriptor; + readonly shell: OrchestrationShellSnapshot; + readonly dispatch: ( + command: Extract, + ) => Effect.Effect; +} + +export const sendThreadOutput = Effect.fn("sendThreadOutput")(function* ( + source: ThreadSendSource, + input: { + readonly identifier: string; + readonly message: string; + readonly commandId: CommandId; + readonly messageId: MessageId; + readonly createdAt: string; + readonly trackRequestCorrelation?: true; + }, +) { + const resolution = resolveThreadTarget(source.shell.threads, input.identifier); + if (resolution.kind !== "resolved") { + return yield* new ThreadSendTargetError({ + reason: resolution.kind, + identifier: resolution.identifier, + ...(resolution.kind === "ambiguous" + ? { + candidates: resolution.candidates, + ...(resolution.candidatesTruncated ? { candidatesTruncated: true } : {}), + ...(resolution.originalCandidateCount !== undefined + ? { originalCandidateCount: resolution.originalCandidateCount } + : {}), + } + : {}), + }); + } + const message = yield* decodeThreadSendMessage(input.message).pipe( + Effect.mapError((cause) => new ThreadSendMessageError({ cause })), + ); + yield* source.dispatch({ + type: "thread.turn.start", + commandId: input.commandId, + threadId: resolution.thread.id, + message: { + messageId: input.messageId, + role: "user", + text: message, + attachments: [], + }, + runtimeMode: resolution.thread.runtimeMode, + interactionMode: resolution.thread.interactionMode, + ...(input.trackRequestCorrelation === true ? { trackRequestCorrelation: true } : {}), + createdAt: input.createdAt, + }); + return yield* decodeThreadSendAcceptedResult({ + kind: "accepted", + environmentId: source.descriptor.environmentId, + threadId: resolution.thread.id, + messageId: input.messageId, + }); +}); + +export const currentThreadOutput = Effect.fn("currentThreadOutput")(function* ( + source: ThreadReadSource, + context: { readonly threadId?: string; readonly home?: string } = { + ...(process.env.T3CODE_THREAD_ID !== undefined + ? { threadId: process.env.T3CODE_THREAD_ID } + : {}), + ...(process.env.T3CODE_HOME !== undefined ? { home: process.env.T3CODE_HOME } : {}), + }, +) { + const currentId = context.threadId?.trim(); + if (!currentId) { + return yield* new ThreadCliError({ + operation: "current context lookup", + cause: new Error("Current LastCode thread context is unavailable."), + }); + } + const contextHome = context.home?.trim(); + if (contextHome && contextHome !== source.home) { + return yield* new ThreadCliError({ + operation: "current context lookup", + cause: new Error(`Current LastCode home '${contextHome}' does not match '${source.home}'.`), + }); + } + const target = source.shell.threads.find(({ id }) => id === currentId); + if (!target) { + return yield* new ThreadCliError({ + operation: "current context lookup", + cause: new Error(`Current LastCode thread '${currentId}' was not found.`), + }); + } + const project = projectForThread(source.shell, target); + return yield* decodeThreadCurrentResult({ + kind: "current", + environmentId: source.descriptor.environmentId, + threadId: target.id, + home: source.home, + project: projectOutput(project), + workspace: workspaceOutput(project, target), + provider: providerOutput(target), + }); +}); + +export const listThreadsOutput = Effect.fn("listThreadsOutput")(function* ( + source: ThreadReadSource, +) { + const now = DateTime.formatIso(yield* DateTime.now); + const sortedThreads = source.shell.threads.toSorted( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id), + ); + const threadsTruncated = sortedThreads.length > THREAD_LIST_MAX_RESULTS; + return yield* decodeThreadListResult({ + kind: "list", + environmentId: source.descriptor.environmentId, + ...(threadsTruncated + ? { threadsTruncated: true, originalThreadCount: sortedThreads.length } + : {}), + threads: sortedThreads.slice(0, THREAD_LIST_MAX_RESULTS).map((thread) => { + const project = projectForThread(source.shell, thread); + return { + environmentId: source.descriptor.environmentId, + threadId: thread.id, + title: thread.title, + lifecycle: threadLifecycle(thread, { now }), + project: projectOutput(project), + workspace: workspaceOutput(project, thread), + provider: providerOutput(thread), + updatedAt: thread.updatedAt, + }; + }), + }); +}); + +export const readThreadOutput = Effect.fn("readThreadOutput")(function* ( + source: ThreadReadSource, + identifier: string, + turnLimitInput: number, +) { + const resolution = resolveThreadTarget(source.shell.threads, identifier); + if (resolution.kind !== "resolved") { + return { ...resolution, environmentId: source.descriptor.environmentId }; + } + const turnLimit = validateThreadTurnLimit(turnLimitInput); + const now = DateTime.formatIso(yield* DateTime.now); + const detail = yield* source.getThread(resolution.thread.id, turnLimit); + const project = projectForThread(source.shell, resolution.thread); + const presentation = boundThreadPresentation(detail.thread.messages, detail.thread.activities); + return yield* decodeThreadReadResult({ + kind: "read", + environmentId: source.descriptor.environmentId, + threadId: resolution.thread.id, + title: resolution.thread.title, + lifecycle: threadLifecycle(resolution.thread, { now }), + project: projectOutput(project), + workspace: workspaceOutput(project, resolution.thread), + provider: providerOutput(resolution.thread), + latestTurn: detail.thread.latestTurn, + messages: presentation.messages, + activities: presentation.activities.map(({ id, kind, tone, summary, turnId, createdAt }) => ({ + id, + kind, + tone, + summary, + turnId, + createdAt, + })), + textTruncated: presentation.textTruncated, + ...(presentation.originalTextChars !== undefined + ? { originalTextChars: presentation.originalTextChars } + : {}), + activitiesTruncated: presentation.activitiesTruncated, + ...(presentation.originalActivityCount !== undefined + ? { originalActivityCount: presentation.originalActivityCount } + : {}), + }); +}); + +export const ThreadCliOfflineRuntimeLive = Layer.mergeAll( + WorkspacePaths.layer, + OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadActionResume.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provideMerge(RepositoryIdentityResolver.layer), + Layer.provideMerge(SqlitePersistenceLayerReadOnly), + ), +); + +const THREAD_CLI_LIVE_TIMEOUT = Duration.seconds(3); +const makeLiveClient = (origin: string) => + HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin }); + +const readEnvironmentId = Effect.fn("readThreadCliEnvironmentId")(function* ( + config: ServerConfig.ServerConfig["Service"], +) { + const fileSystem = yield* FileSystem.FileSystem; + const value = (yield* fileSystem.readFileString(config.environmentIdPath)).trim(); + if (value.length === 0) { + return yield* new ThreadCliError({ + operation: "environment identity read", + cause: new Error("The active home has no environment identity."), + }); + } + return EnvironmentId.make(value); +}); + +export const withReadSession = ( + auth: EnvironmentAuth.EnvironmentAuth["Service"], + run: (token: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + auth.issueSession({ scopes: [AuthOrchestrationReadScope], label: "lastcode thread cli" }), + ({ token }) => run(token), + ({ sessionId }) => auth.revokeSession(sessionId).pipe(Effect.ignore({ log: true })), + ); + +export const withSendSession = ( + auth: EnvironmentAuth.EnvironmentAuth["Service"], + run: (token: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + auth.issueSession({ + scopes: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope], + label: "lastcode thread cli", + }), + ({ token }) => run(token), + ({ sessionId }) => auth.revokeSession(sessionId).pipe(Effect.ignore({ log: true })), + ); + +const tryRunLiveThreadRead = Effect.fn("tryRunLiveThreadRead")(function* ( + config: ServerConfig.ServerConfig["Service"], + minimumLogLevel: ServerConfig.ServerConfig["Service"]["logLevel"], + run: (source: ThreadReadSource) => Effect.Effect, +) { + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(runtimeState)) return Option.none(); + const client = yield* makeLiveClient(runtimeState.value.origin); + const descriptorResult = yield* Effect.result( + client.metadata.descriptor().pipe(Effect.timeout(THREAD_CLI_LIVE_TIMEOUT)), + ); + if (descriptorResult._tag === "Failure") return Option.none(); + const attempted = yield* Effect.result( + Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + return yield* withReadSession(auth, (token) => + Effect.gen(function* () { + const headers = { authorization: `Bearer ${token}` }; + const sourceResult = yield* Effect.result( + client.orchestration + .shellSnapshot({ headers }) + .pipe(Effect.timeout(THREAD_CLI_LIVE_TIMEOUT)), + ); + if (sourceResult._tag === "Failure") { + return { kind: "unavailable" as const }; + } + const output = yield* Effect.result( + run({ + descriptor: descriptorResult.success, + home: config.baseDir, + shell: sourceResult.success, + getThread: (threadId, turnLimit) => + client.orchestration + .threadSnapshot({ params: { threadId }, payload: { turnLimit }, headers }) + .pipe( + Effect.timeout(THREAD_CLI_LIVE_TIMEOUT), + Effect.mapError( + (cause) => new ThreadCliError({ operation: "live detail read", cause }), + ), + ), + }), + ); + return { kind: "ran" as const, output }; + }).pipe(Effect.timeout(THREAD_CLI_LIVE_TIMEOUT)), + ); + }).pipe( + Effect.provide( + EnvironmentAuth.runtimeLayer.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ), + ), + ), + ); + if (attempted._tag === "Success" && attempted.success.kind === "ran") { + if (attempted.success.output._tag === "Failure") return yield* attempted.success.output.failure; + return Option.some(attempted.success.output.success); + } + return Option.none(); +}); + +const runThreadRead = Effect.fn("runThreadRead")(function* ( + flags: CliAuthLocationFlags, + run: (source: ThreadReadSource) => Effect.Effect, +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveThreadInspectionConfig(flags, logLevel); + const minimumLogLevel = config.logLevel; + return yield* Effect.gen(function* () { + const live = yield* tryRunLiveThreadRead(config, minimumLogLevel, run).pipe( + Effect.provide(FetchHttpClient.layer), + ); + if (Option.isSome(live)) { + return yield* Console.log(yield* encodeJson(live.value)); + } + + const offlineLayer = ThreadCliOfflineRuntimeLive.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ); + return yield* Effect.gen(function* () { + const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const shell = yield* query.getShellSnapshot(); + const environmentId = yield* readEnvironmentId(config); + const source: ThreadReadSource = { + descriptor: { + environmentId, + label: "offline", + platform: { os: "unknown", arch: "other" }, + serverVersion: "offline", + capabilities: { repositoryIdentity: true }, + }, + home: config.baseDir, + shell, + getThread: (threadId, turnLimit) => + query.getThreadDetailSnapshot(threadId, { turnLimit }).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new ThreadCliError({ + operation: "offline detail read", + cause: new Error(`Thread '${threadId}' was not found.`), + }), + ), + onSome: Effect.succeed, + }), + ), + Effect.mapError((cause) => + isThreadCliError(cause) + ? cause + : new ThreadCliError({ operation: "offline detail read", cause }), + ), + ), + }; + const output = yield* run(source); + yield* Console.log(yield* encodeJson(output)); + }).pipe(Effect.provide(offlineLayer)); + }); +}); + +const runThreadSend = Effect.fn("runThreadSend")(function* ( + flags: CliAuthLocationFlags, + identifier: string, + message: string, + waitForCompletion: boolean, + timeoutMs: number, +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveThreadInspectionConfig(flags, logLevel); + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(runtimeState)) { + return yield* new ThreadSendServerUnavailableError({ + cause: new Error("The active home has no recorded server runtime."), + }); + } + const client = yield* makeLiveClient(runtimeState.value.origin); + const descriptor = yield* client.metadata.descriptor().pipe( + Effect.timeout(THREAD_CLI_LIVE_TIMEOUT), + Effect.mapError((cause) => new ThreadSendServerUnavailableError({ cause })), + ); + const minimumLogLevel = config.logLevel; + + return yield* Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + let waitHandle: ThreadWaitHandle | undefined; + const dispatched = yield* Effect.result( + withSendSession(auth, (token) => + Effect.gen(function* () { + const headers = { authorization: `Bearer ${token}` }; + const shell = yield* client.orchestration.shellSnapshot({ headers }).pipe( + Effect.timeout(THREAD_CLI_LIVE_TIMEOUT), + Effect.mapError( + (cause) => new ThreadCliError({ operation: "live send target lookup", cause }), + ), + ); + const crypto = yield* Crypto.Crypto; + const commandId = CommandId.make( + yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => new ThreadCliError({ operation: "send command id generation", cause }), + ), + ), + ); + const messageId = MessageId.make( + yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => new ThreadCliError({ operation: "send message id generation", cause }), + ), + ), + ); + const resolution = resolveThreadTarget(shell.threads, identifier); + if (resolution.kind === "resolved") { + waitHandle = { + kind: "wait-handle", + environmentId: descriptor.environmentId, + threadId: resolution.thread.id, + messageId, + }; + } + return yield* sendThreadOutput( + { + descriptor, + shell, + dispatch: (command) => { + const dispatch = client.orchestration + .dispatch({ + headers, + payload: command, + } as Parameters[0]) + .pipe( + Effect.timeout(THREAD_CLI_LIVE_TIMEOUT), + Effect.asVoid, + Effect.mapError((cause) => + waitForCompletion && !isAuthoritativeDispatchFailure(cause) + ? new ThreadDispatchUnknownError({ cause }) + : new ThreadCliError({ operation: "live send dispatch", cause }), + ), + ); + return waitForCompletion ? retryAmbiguousTrackedDispatch(dispatch) : dispatch; + }, + }, + { + identifier, + message, + commandId, + messageId, + createdAt: DateTime.formatIso(yield* DateTime.now), + ...(waitForCompletion ? { trackRequestCorrelation: true as const } : {}), + }, + ); + }), + ), + ); + if (dispatched._tag === "Failure") { + const recoveryHandle = waitHandle; + if ( + waitForCompletion && + recoveryHandle !== undefined && + isThreadDispatchUnknownError(dispatched.failure) + ) { + yield* Console.error(`LASTCODE_WAIT_HANDLE=${yield* encodeJson(recoveryHandle)}`); + return yield* Console.log( + yield* encodeJson({ kind: "dispatch-unknown", waitHandle: recoveryHandle }), + ); + } + return yield* dispatched.failure; + } + const acceptedWaitHandle = waitHandle; + if (!waitForCompletion || acceptedWaitHandle === undefined) { + return yield* Console.log(yield* encodeJson(dispatched.success)); + } + yield* Console.error(`LASTCODE_WAIT_HANDLE=${yield* encodeJson(acceptedWaitHandle)}`); + const waitResult = yield* withReadSession(auth, (token) => + Effect.result( + client.orchestration + .waitThread({ + headers: { authorization: `Bearer ${token}` }, + payload: { waitHandle: acceptedWaitHandle, timeoutMs }, + }) + .pipe(Effect.timeout(`${timeoutMs + 5_000} millis`)), + ), + ); + if (waitResult._tag === "Failure" && isAuthoritativeWaitFailure(waitResult.failure)) { + return yield* new ThreadCliError({ operation: "live wait", cause: waitResult.failure }); + } + return yield* Console.log( + yield* encodeJson( + waitResult._tag === "Success" + ? waitResult.success + : { kind: "transport-unknown", waitHandle: acceptedWaitHandle }, + ), + ); + }).pipe( + Effect.provide( + EnvironmentAuth.runtimeLayer.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ), + ), + ); +}); + +const runThreadWait = Effect.fn("runThreadWait")(function* ( + flags: CliAuthLocationFlags, + rawHandle: string, + timeoutMs: number, +) { + const waitHandle = yield* decodeThreadWaitHandleString(rawHandle).pipe( + Effect.mapError((cause) => new ThreadCliError({ operation: "wait handle decoding", cause })), + ); + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveThreadInspectionConfig(flags, logLevel); + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(runtimeState)) { + return yield* new ThreadSendServerUnavailableError({ + cause: new Error("The active home has no recorded server runtime."), + }); + } + const client = yield* makeLiveClient(runtimeState.value.origin); + const descriptor = yield* client.metadata.descriptor().pipe( + Effect.timeout(THREAD_CLI_LIVE_TIMEOUT), + Effect.mapError((cause) => new ThreadSendServerUnavailableError({ cause })), + ); + if (descriptor.environmentId !== waitHandle.environmentId) { + return yield* new ThreadCliError({ + operation: "wait environment validation", + cause: new Error( + `Wait handle belongs to '${waitHandle.environmentId}', not '${descriptor.environmentId}'.`, + ), + }); + } + return yield* Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const result = yield* withReadSession(auth, (token) => + Effect.result( + client.orchestration + .waitThread({ + headers: { authorization: `Bearer ${token}` }, + payload: { waitHandle, timeoutMs }, + }) + .pipe(Effect.timeout(`${timeoutMs + 5_000} millis`)), + ), + ); + if (result._tag === "Failure" && isAuthoritativeWaitFailure(result.failure)) { + return yield* new ThreadCliError({ operation: "live wait", cause: result.failure }); + } + yield* Console.log( + yield* encodeJson( + result._tag === "Success" ? result.success : { kind: "transport-unknown", waitHandle }, + ), + ); + }).pipe( + Effect.provide( + EnvironmentAuth.runtimeLayer.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, config.logLevel)), + ), + ), + ); +}); + +const jsonFlag = Flag.boolean("json").pipe( + Flag.withDescription("Print stable JSON output."), + Flag.withDefault(false), +); + +const threadLocationFlags = { + baseDir: Flag.string("base-dir").pipe(Flag.optional), + stateDir: Flag.string("state-dir").pipe( + Flag.withDescription("Explicit active state directory (used by the generated wrapper)."), + Flag.optional, + ), +} as const; + +const currentCommand = Command.make("current", { + ...threadLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription("Identify the current LastCode thread."), + Command.withHandler((flags) => + runThreadRead(flags, (source) => + currentThreadOutput(source).pipe( + Effect.mapError((cause) => + isThreadCliError(cause) + ? cause + : new ThreadCliError({ operation: "current output encoding", cause }), + ), + ), + ), + ), +); + +const listCommand = Command.make("list", { + ...threadLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription("List active threads in this LastCode environment."), + Command.withHandler((flags) => + runThreadRead(flags, (source) => + listThreadsOutput(source).pipe( + Effect.mapError( + (cause) => new ThreadCliError({ operation: "list output encoding", cause }), + ), + ), + ), + ), +); + +const readCommand = Command.make("read", { + ...threadLocationFlags, + json: jsonFlag, + thread: Argument.string("thread").pipe( + Argument.withDescription("Exact LastCode thread id or unambiguous id prefix."), + ), + turnLimit: Flag.integer("turn-limit").pipe( + Flag.withDescription(`Recent user-turn window (1-${THREAD_READ_MAX_TURN_LIMIT}).`), + Flag.withDefault(THREAD_READ_DEFAULT_TURN_LIMIT), + ), +}).pipe( + Command.withDescription("Read a bounded recent transcript for one thread."), + Command.withHandler((flags) => + runThreadRead(flags, (source) => + readThreadOutput(source, flags.thread, flags.turnLimit).pipe( + Effect.mapError((cause) => + isThreadCliError(cause) + ? cause + : new ThreadCliError({ operation: "read output encoding", cause }), + ), + ), + ), + ), +); + +const sendCommand = Command.make("send", { + ...threadLocationFlags, + json: jsonFlag, + thread: Argument.string("thread").pipe( + Argument.withDescription("Exact LastCode thread id or unambiguous id prefix."), + ), + message: Flag.string("message").pipe( + Flag.withDescription("User-directed message to send to the target thread."), + ), + wait: Flag.boolean("wait").pipe( + Flag.withDescription("Wait for the exact tracked turn to finish."), + Flag.withDefault(false), + ), + timeout: Flag.string("timeout").pipe( + Flag.withDescription("Maximum wait duration (for example, '10 minutes')."), + Flag.withDefault("10 minutes"), + ), +}).pipe( + Command.withDescription("Send a user-directed message to one live thread."), + Command.withHandler((flags) => + decodeThreadWaitDuration(flags.timeout).pipe( + Effect.map(Duration.toMillis), + Effect.flatMap(decodeThreadWaitTimeoutMs), + Effect.flatMap((timeoutMs) => + runThreadSend(flags, flags.thread, flags.message, flags.wait, timeoutMs), + ), + Effect.provide(FetchHttpClient.layer), + ), + ), +); + +const waitCommand = Command.make("wait", { + ...threadLocationFlags, + json: jsonFlag, + waitHandle: Argument.string("wait-handle").pipe( + Argument.withDescription("Compact JSON wait handle returned by send --wait."), + ), + timeout: Flag.string("timeout").pipe( + Flag.withDescription("Maximum wait duration (for example, '10 minutes')."), + Flag.withDefault("10 minutes"), + ), +}).pipe( + Command.withDescription("Resume waiting for one exact tracked thread request."), + Command.withHandler((flags) => + decodeThreadWaitDuration(flags.timeout).pipe( + Effect.map(Duration.toMillis), + Effect.flatMap(decodeThreadWaitTimeoutMs), + Effect.flatMap((timeoutMs) => runThreadWait(flags, flags.waitHandle, timeoutMs)), + Effect.provide(FetchHttpClient.layer), + ), + ), +); + +export const threadCommand = Command.make("thread").pipe( + Command.withDescription("Inspect and message LastCode threads."), + Command.withSubcommands([currentCommand, listCommand, readCommand, sendCommand, waitCommand]), +); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 1314ccfb9361..6da11f70af7f 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -7,11 +7,14 @@ import { HostProcessUserId, } from "@t3tools/shared/hostProcess"; import * as ConfigProvider from "effect/ConfigProvider"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; @@ -57,6 +60,56 @@ const macPlan = { unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist", }; +const launchdServiceTarget = "gui/501/com.t3tools.t3code.service"; +const launchdNotLoadedMessage = + 'Could not find service "com.t3tools.t3code.service" in domain for user gui: 501'; + +const processResult = (input?: { + readonly code?: number; + readonly stdout?: string; + readonly stderr?: string; +}): ProcessRunner.ProcessRunOutput => ({ + stdout: input?.stdout ?? "", + stderr: input?.stderr ?? "", + code: ChildProcessSpawner.ExitCode(input?.code ?? 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, +}); + +it("recognizes only launchctl's exact service-not-loaded response", () => { + expect( + BootService.isConfirmedLaunchdNotLoaded( + processResult({ code: 113, stderr: `Bad request.\n${launchdNotLoadedMessage}\n` }), + [launchdNotLoadedMessage], + ), + ).toBe(true); + expect( + BootService.isConfirmedLaunchdNotLoaded( + processResult({ code: 1, stderr: "Boot-out failed: 1: Operation not permitted" }), + [launchdNotLoadedMessage], + ), + ).toBe(false); + expect( + BootService.isConfirmedLaunchdNotLoaded( + processResult({ code: 125, stderr: "Could not find domain for user gui: 501" }), + [launchdNotLoadedMessage], + ), + ).toBe(false); + expect( + BootService.isConfirmedLaunchdBootoutNotLoaded( + processResult({ code: 3, stderr: "Boot-out failed: 3: No such process\n" }), + ), + ).toBe(true); + expect( + BootService.isConfirmedLaunchdBootoutNotLoaded( + processResult({ code: 1, stderr: "Boot-out failed: 1: Operation not permitted\n" }), + ), + ).toBe(false); +}); + it("keeps launchd pinned to the stable launcher rather than a versioned server", () => { const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); @@ -116,23 +169,37 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( const commands: string[] = []; const timeouts = new Map(); - const control: { failCommand: string | undefined } = { failCommand: undefined }; + const control: { + failCommand: string | undefined; + fixtures: Map ProcessRunner.ProcessRunOutput>; + callCounts: Map; + signals: Map>; + } = { + failCommand: undefined, + fixtures: new Map(), + callCounts: new Map(), + signals: new Map(), + }; const runner = ProcessRunner.ProcessRunner.of({ run: (input) => - Effect.sync(() => { + Effect.gen(function* () { const command = `${input.command} ${input.args.join(" ")}`; commands.push(command); timeouts.set(command, input.timeout); - return { - stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "", - stderr: "", - code: ChildProcessSpawner.ExitCode(command === control.failCommand ? 1 : 0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; + const call = (control.callCounts.get(command) ?? 0) + 1; + control.callCounts.set(command, call); + const signal = control.signals.get(command); + if (signal !== undefined) yield* Deferred.succeed(signal, undefined); + const fixture = control.fixtures.get(command); + if (fixture) return fixture(call); + if (command === control.failCommand) return processResult({ code: 1 }); + if (command === `launchctl print ${launchdServiceTarget}`) { + return processResult({ + code: 113, + stderr: `Bad request.\n${launchdNotLoadedMessage}\n`, + }); + } + return processResult({ stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "" }); }), }); const service = yield* BootService.make({ @@ -276,9 +343,9 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect((yield* service.status).installed).toBe(false); expect(commands.some((command) => command.startsWith("npm "))).toBe(false); expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false); - // A bootout can block up to the plist's 90s ExitTimeOut; the runner's - // 60s default would cancel it and let bootstrap race a loaded job. - expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.t3code.service")).toEqual( + // The bootout command and subsequent bounded print verification both + // allow launchd's 90s ExitTimeOut to elapse. + expect(timeouts.get("launchctl bootout gui/501/com.t3tools.t3code.service")).toEqual( Duration.seconds(120), ); }), @@ -295,7 +362,8 @@ it.layer(NodeServices.layer)("boot service install", (it) => { const error = yield* service.install.pipe(Effect.flip); expect(error._tag).toBe("BootServiceCommandError"); expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ - "launchctl bootout --wait gui/501/com.t3tools.t3code.service", + "launchctl bootout gui/501/com.t3tools.t3code.service", + "launchctl print gui/501/com.t3tools.t3code.service", "launchctl enable gui/501/com.t3tools.t3code.service", `launchctl bootstrap gui/501 ${plistPath}`, `launchctl bootstrap gui/501 ${plistPath}`, @@ -303,14 +371,145 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); - it.effect("ignores a bootout for an agent that is not loaded", () => + it.effect("removes the launch agent when its GUI domain is absent", () => Effect.gen(function* () { - const { service, control } = yield* makeHarness("darwin"); + const { service, fs, control } = yield* makeHarness("darwin"); + const plan = yield* service.install; + control.fixtures.set("launchctl bootout gui/501/com.t3tools.t3code.service", () => + processResult({ code: 125, stderr: "Could not find domain for user gui: 501" }), + ); + control.fixtures.set(`launchctl print ${launchdServiceTarget}`, () => + processResult({ code: 125, stderr: "Could not find domain for user gui: 501" }), + ); + + expect(yield* service.uninstall).toBe(true); + expect(yield* fs.exists(plan.unitPath)).toBe(false); + }), + ); + + it.effect("accepts a bootout only when launchd confirms the agent is already absent", () => + Effect.gen(function* () { + const { service, commands, control } = yield* makeHarness("darwin"); yield* service.install; - control.failCommand = "launchctl bootout --wait gui/501/com.t3tools.t3code.service"; + commands.length = 0; + control.fixtures.set("launchctl bootout gui/501/com.t3tools.t3code.service", () => + processResult({ code: 3, stderr: "Boot-out failed: 3: No such process\n" }), + ); yield* service.install; expect((yield* service.status).current).toBe(true); + expect(commands.filter((command) => command.startsWith("launchctl ")).slice(0, 2)).toEqual([ + "launchctl bootout gui/501/com.t3tools.t3code.service", + "launchctl print gui/501/com.t3tools.t3code.service", + ]); + }), + ); + + it.effect("waits for a draining launch agent before bootstrap", () => + Effect.gen(function* () { + const { service, commands, control } = yield* makeHarness("darwin"); + yield* service.install; + commands.length = 0; + control.callCounts.clear(); + const firstPrint = yield* Deferred.make(); + control.signals.set(`launchctl print ${launchdServiceTarget}`, firstPrint); + control.fixtures.set(`launchctl print ${launchdServiceTarget}`, (call) => + call === 1 + ? processResult() + : processResult({ + code: 113, + stderr: `Bad request.\n${launchdNotLoadedMessage}\n`, + }), + ); + + const installFiber = yield* service.install.pipe(Effect.forkChild); + yield* Deferred.await(firstPrint); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(100)); + yield* Fiber.join(installFiber); + + expect(commands.filter((command) => command.startsWith("launchctl ")).slice(0, 4)).toEqual([ + "launchctl bootout gui/501/com.t3tools.t3code.service", + "launchctl print gui/501/com.t3tools.t3code.service", + "launchctl print gui/501/com.t3tools.t3code.service", + "launchctl enable gui/501/com.t3tools.t3code.service", + ]); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("times out instead of bootstrapping while a launch agent remains loaded", () => + Effect.gen(function* () { + const { service, commands, control } = yield* makeHarness("darwin"); + yield* service.install; + commands.length = 0; + control.callCounts.clear(); + const firstPrint = yield* Deferred.make(); + control.signals.set(`launchctl print ${launchdServiceTarget}`, firstPrint); + control.fixtures.set(`launchctl print ${launchdServiceTarget}`, () => processResult()); + + const installFiber = yield* service.install.pipe(Effect.forkChild); + yield* Deferred.await(firstPrint); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(120)); + const error = yield* Fiber.join(installFiber).pipe(Effect.flip); + + expect(error._tag).toBe("BootServiceCommandError"); + expect(error.message).toContain("timed out while waiting for the launch agent to stop"); + expect(commands).not.toContain("launchctl enable gui/501/com.t3tools.t3code.service"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("surfaces bootout permission failures while the launch agent remains loaded", () => + Effect.gen(function* () { + const { service, commands, control } = yield* makeHarness("darwin"); + yield* service.install; + commands.length = 0; + control.fixtures.set("launchctl bootout gui/501/com.t3tools.t3code.service", () => + processResult({ code: 1, stderr: "Boot-out failed: 1: Operation not permitted\n" }), + ); + + const error = yield* service.install.pipe(Effect.flip); + + expect(error._tag).toBe("BootServiceCommandError"); + expect(error._tag === "BootServiceCommandError" ? error.step : undefined).toBe( + "stopping the installed launch agent", + ); + expect(commands).not.toContain("launchctl enable gui/501/com.t3tools.t3code.service"); + }), + ); + + it.effect("surfaces launchd domain failures during stop verification", () => + Effect.gen(function* () { + const { service, commands, control } = yield* makeHarness("darwin"); + yield* service.install; + commands.length = 0; + control.fixtures.set(`launchctl print ${launchdServiceTarget}`, () => + processResult({ code: 125, stderr: "Could not find domain for user gui: 501" }), + ); + + const error = yield* service.install.pipe(Effect.flip); + + expect(error._tag).toBe("BootServiceCommandError"); + expect(error._tag === "BootServiceCommandError" ? error.step : undefined).toBe( + "checking whether the launch agent stopped", + ); + expect(commands).not.toContain("launchctl enable gui/501/com.t3tools.t3code.service"); + }), + ); + + it.effect("surfaces launchd enable failures", () => + Effect.gen(function* () { + const { service, control } = yield* makeHarness("darwin"); + control.fixtures.set("launchctl enable gui/501/com.t3tools.t3code.service", () => + processResult({ code: 1, stderr: "Enable failed: 1: Operation not permitted\n" }), + ); + + const error = yield* service.install.pipe(Effect.flip); + + expect(error._tag).toBe("BootServiceCommandError"); + expect(error._tag === "BootServiceCommandError" ? error.step : undefined).toBe( + "enabling the launch agent", + ); }), ); @@ -336,7 +535,8 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUpdatePendingError"); expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ - "launchctl bootout --wait gui/501/com.t3tools.t3code.service", + "launchctl bootout gui/501/com.t3tools.t3code.service", + "launchctl print gui/501/com.t3tools.t3code.service", `launchctl bootstrap gui/501 ${plistPath}`, ]); }), diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 795bf38e979d..9a643d16243b 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -154,27 +154,63 @@ export function renderBootServicePlist( ].join("\n"); } -export interface BootServiceStep { +interface BootServiceStepBase { readonly step: string; readonly command: string; readonly args: ReadonlyArray; - /** - * Non-zero exit is logged and ignored. Reserved for steps whose common - * failures (not loaded, already enabled) leave a state a later strict step - * either tolerates or fails loudly on. - */ - readonly optional?: boolean; /** Override the ProcessRunner default (60s) for steps that block longer. */ readonly timeout?: Duration.Input; } +export type BootServiceStep = BootServiceStepBase & + ( + | { readonly operation?: "command" } + | { + readonly operation: "launchd-bootout"; + /** After launchd bootout, prove the job left the domain before continuing. */ + readonly verifyAbsent: { + readonly serviceTarget: string; + readonly notLoadedMessages: ReadonlyArray; + }; + } + ); + /** - * Stop commands block until the service manager gives up: 90s by default for - * systemd's TimeoutStopSec, and ExitTimeOut=90 in the rendered plist. This - * must stay above both, or the runner cancels the stop mid-shutdown and the - * next step races a still-loaded service. + * A stop can take 90s: systemd blocks for TimeoutStopSec, while launchd's + * supported bootout command returns before the job finishes honoring the + * plist's ExitTimeOut. Keep both the command and launchd verification bounds + * above that window. */ const STOP_STEP_TIMEOUT = Duration.seconds(120); +const LAUNCHD_STOP_POLL_INTERVAL = Duration.millis(100); + +export function isConfirmedLaunchdNotLoaded( + result: ProcessRunner.ProcessRunOutput, + notLoadedMessages: ReadonlyArray, +): boolean { + if (result.code === 0 || result.timedOut) return false; + const lines = `${result.stdout}\n${result.stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line !== ""); + return ( + (lines.length === 1 && notLoadedMessages.includes(lines[0] ?? "")) || + (lines.length === 2 && + lines[0] === "Bad request." && + notLoadedMessages.includes(lines[1] ?? "")) + ); +} + +export function isConfirmedLaunchdBootoutNotLoaded( + result: ProcessRunner.ProcessRunOutput, +): boolean { + if (result.code === 0 || result.timedOut) return false; + const lines = `${result.stdout}\n${result.stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line !== ""); + return lines.length === 1 && lines[0] === "Boot-out failed: 3: No such process"; +} /** * Platform service-manager integration as data: paths, a pure renderer, and @@ -277,28 +313,28 @@ export function launchdManager(input: { ); const domainTarget = `gui/${input.uid}`; const serviceTarget = `${domainTarget}/${BOOT_SERVICE_LAUNCHD_LABEL}`; - // bootout/enable are optional: they fail on not-loaded states that are fine - // to proceed from. The strict `bootstrap` runs last and is also the start: + const notLoadedMessage = `Could not find service "${BOOT_SERVICE_LAUNCHD_LABEL}" in domain for user gui: ${input.uid}`; + const missingDomainMessage = `Could not find domain for user gui: ${input.uid}`; + // `bootstrap` runs last and is also the start: // loading a RunAtLoad/KeepAlive plist starts the job, so a separate // kickstart would kill and restart a server it just booted. A lingering job - // that survived bootout, or a gui domain with nobody logged in at the - // screen (SSH install), makes bootstrap fail the flow loudly rather than - // silently keeping a stale server. + // that survived bootout, a persisted disable override, or a gui domain with + // nobody logged in at the screen (SSH install) fails the flow loudly. return { kind: "launchd", unitPath, render: (plan) => renderBootServicePlist(plan, { homeDir: input.homeDir }), - // Without --wait, bootout returns in milliseconds while the job drains - // for up to ExitTimeOut, and a bootstrap during the drain fails EIO. - // --wait (present on modern macOS, absent from the man page) blocks until - // the job is removed from the domain; STOP_STEP_TIMEOUT outlives it. + // bootout has no supported wait flag and returns before a draining job is + // absent. Probe the exact service until launchd confirms it left the + // domain, so bootstrap cannot race the prior process. stop: [ { step: "stopping the installed launch agent", command: "launchctl", - args: ["bootout", "--wait", serviceTarget], - optional: true, + args: ["bootout", serviceTarget], timeout: STOP_STEP_TIMEOUT, + operation: "launchd-bootout", + verifyAbsent: { serviceTarget, notLoadedMessages: [notLoadedMessage] }, }, ], activate: [ @@ -307,7 +343,6 @@ export function launchdManager(input: { step: "enabling the launch agent", command: "launchctl", args: ["enable", serviceTarget], - optional: true, }, // Start last. No administrative state write occurs after this succeeds. { @@ -325,15 +360,17 @@ export function launchdManager(input: { ], // No `launchctl disable` here: a persisted override would sabotage a // later reinstall. Removing the plist is what stops the next login load. - // A bootout that fails for a reason other than "not loaded" leaves the - // job running until logout; the failure is in the boot-service log. deactivate: [ { step: "stopping the service", command: "launchctl", - args: ["bootout", "--wait", serviceTarget], - optional: true, + args: ["bootout", serviceTarget], timeout: STOP_STEP_TIMEOUT, + operation: "launchd-bootout", + verifyAbsent: { + serviceTarget, + notLoadedMessages: [notLoadedMessage, missingDomainMessage], + }, }, ], finalize: [], @@ -375,10 +412,14 @@ export class BootServiceCommandError extends Schema.TaggedErrorClass + DateTime.now.pipe( + Effect.flatMap((now) => + fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { + flag: "a", + }), + ), + Effect.ignore, + ); + const runStep = Effect.fn("cloud.boot_service.run_step")(function* ( step: string, command: string, @@ -499,32 +550,93 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { stderrLength: result.stderr.length, }), ), - Effect.tapError((error) => - DateTime.now.pipe( - Effect.flatMap((now) => - fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { - flag: "a", - }), - ), - Effect.ignore, - ), - ), + Effect.tapError(logCommandError), ); }); + const runLaunchdBootout = Effect.fn("cloud.boot_service.run_launchd_bootout")(function* ( + entry: Extract, + ) { + const bootoutResult = yield* runner + .run({ + command: entry.command, + args: entry.args, + timeout: entry.timeout, + }) + .pipe(Effect.mapError((cause) => new BootServiceCommandError({ step: entry.step, cause }))); + const bootoutError = + bootoutResult.code === 0 + ? undefined + : new BootServiceCommandError({ + step: entry.step, + exitCode: bootoutResult.code === null ? undefined : Number(bootoutResult.code), + stdoutLength: bootoutResult.stdout.length, + stderrLength: bootoutResult.stderr.length, + }); + if ( + bootoutError !== undefined && + !isConfirmedLaunchdBootoutNotLoaded(bootoutResult) && + !isConfirmedLaunchdNotLoaded(bootoutResult, entry.verifyAbsent.notLoadedMessages) + ) { + return yield* bootoutError; + } + + yield* Effect.gen(function* () { + while (true) { + const printResult = yield* runner + .run({ + command: "launchctl", + args: ["print", entry.verifyAbsent.serviceTarget], + }) + .pipe( + Effect.mapError( + (cause) => + new BootServiceCommandError({ + step: "checking whether the launch agent stopped", + cause, + }), + ), + ); + if (isConfirmedLaunchdNotLoaded(printResult, entry.verifyAbsent.notLoadedMessages)) { + return; + } + if (bootoutError !== undefined) return yield* bootoutError; + if (printResult.code !== 0) { + return yield* new BootServiceCommandError({ + step: "checking whether the launch agent stopped", + exitCode: printResult.code === null ? undefined : Number(printResult.code), + stdoutLength: printResult.stdout.length, + stderrLength: printResult.stderr.length, + }); + } + yield* Effect.sleep(LAUNCHD_STOP_POLL_INTERVAL); + } + }).pipe( + Effect.timeoutOrElse({ + duration: STOP_STEP_TIMEOUT, + orElse: () => + new BootServiceCommandError({ + step: "waiting for the launch agent to stop", + timedOut: true, + }), + }), + ); + }, Effect.tapError(logCommandError)); + const runSteps = (steps: ReadonlyArray) => Effect.forEach( steps, (entry) => { + if (entry.operation === "launchd-bootout") { + return runLaunchdBootout(entry); + } const run = runStep( entry.step, entry.command, entry.args, entry.timeout === undefined ? undefined : { timeout: entry.timeout }, ); - // runStep's tapError already appends the failure to the log, so an - // ignored optional step still leaves a trace. - return entry.optional === true ? run.pipe(Effect.ignore) : run.pipe(Effect.asVoid); + return run.pipe(Effect.asVoid); }, { discard: true }, ); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index ee30d987591d..0b6c2a527995 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -92,6 +92,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); + expect(second.capabilities.threadAnnotations).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 45dc0ee9cfd5..11dd4319beb2 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -152,6 +152,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, + threadAnnotations: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 24a137d933fa..49531b00b56c 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -202,6 +202,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); + assert.isFalse(defaultsByCommand.has("sidebar.mode.toggle")); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); assert.isFalse(defaultsByCommand.has("rightPanel.toggleMaximized")); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 87975a49de2c..ed200bf06e06 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -22,6 +22,8 @@ import { PreviewSnapshotToolkit, PreviewStandardToolkit, } from "./toolkits/preview/tools.ts"; +import { ActionResumeToolkitHandlersLive } from "./toolkits/actionResume/handlers.ts"; +import { ActionResumeToolkit } from "./toolkits/actionResume/tools.ts"; const unauthorized = HttpServerResponse.jsonUnsafe( { @@ -216,6 +218,10 @@ export const PreviewToolkitRegistrationLive = Layer.mergeAll( PreviewSnapshotRegistrationLive, ); +export const ActionResumeToolkitRegistrationLive = McpServer.toolkit(ActionResumeToolkit).pipe( + Layer.provide(ActionResumeToolkitHandlersLive), +); + const McpTransportLive = McpServer.layerHttp({ name: "T3 Code", version: packageJson.version, @@ -223,4 +229,7 @@ const McpTransportLive = McpServer.layerHttp({ protocols: [McpProtocol.v2025_06_18], }).pipe(Layer.provide(McpAuthMiddlewareLive)); -export const layer = PreviewToolkitRegistrationLive.pipe(Layer.provideMerge(McpTransportLive)); +export const layer = Layer.mergeAll( + PreviewToolkitRegistrationLive, + ActionResumeToolkitRegistrationLive, +).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 49273485a44d..cf8fea0e17bf 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -7,7 +7,7 @@ import { import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -export type McpCapability = "preview"; +export type McpCapability = "preview" | "action-resume"; export interface McpInvocationScope { readonly environmentId: EnvironmentId; diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index f19a4f4e8c49..c847b88dcd7c 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -128,7 +128,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( threadId: ThreadId.make(request.threadId), providerSessionId, providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), - capabilities: new Set(["preview"]), + capabilities: new Set(["preview", "action-resume"]), issuedAt, }; yield* SynchronizedRef.update(state, ({ records }) => { diff --git a/apps/server/src/mcp/toolkits/actionResume/handlers.ts b/apps/server/src/mcp/toolkits/actionResume/handlers.ts new file mode 100644 index 000000000000..e295232481bc --- /dev/null +++ b/apps/server/src/mcp/toolkits/actionResume/handlers.ts @@ -0,0 +1,46 @@ +import { ActionResumeError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { ActionResume } from "../../../actionResume/ActionResume.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { ActionResumeToolkit } from "./tools.ts"; + +const handlers = { + list_project_actions: () => + Effect.gen(function* () { + const invocation = yield* McpInvocationContext.requireMcpCapability("action-resume"); + const service = yield* Effect.serviceOption(ActionResume); + if (Option.isNone(service)) { + return yield* new ActionResumeError({ + reason: "internal_error", + message: "Action resume is unavailable in this server runtime.", + }); + } + const actions = yield* service.value.listProjectActions({ + threadId: invocation.threadId, + providerInstanceId: invocation.providerInstanceId, + }); + return { actions }; + }), + run_project_action_and_resume: ({ actionId }) => + Effect.gen(function* () { + const invocation = yield* McpInvocationContext.requireMcpCapability("action-resume"); + const service = yield* Effect.serviceOption(ActionResume); + if (Option.isNone(service)) { + return yield* new ActionResumeError({ + reason: "internal_error", + message: "Action resume is unavailable in this server runtime.", + }); + } + return yield* service.value.runProjectActionAndResume( + { + threadId: invocation.threadId, + providerInstanceId: invocation.providerInstanceId, + }, + actionId, + ); + }), +} satisfies Parameters[0]; + +export const ActionResumeToolkitHandlersLive = ActionResumeToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/actionResume/tools.ts b/apps/server/src/mcp/toolkits/actionResume/tools.ts new file mode 100644 index 000000000000..6672c38d40e1 --- /dev/null +++ b/apps/server/src/mcp/toolkits/actionResume/tools.ts @@ -0,0 +1,50 @@ +import { + ActionResumeError, + ActionResumeState, + PreviewAutomationUnavailableError, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const dependencies = [McpInvocationContext.McpInvocationContext]; +const ActionResumeToolError = Schema.Union([ActionResumeError, PreviewAutomationUnavailableError]); + +const ListedProjectAction = Schema.Struct({ + id: Schema.String, + name: Schema.String, + resumeEligible: Schema.Boolean, + disabledReason: Schema.NullOr(Schema.String), +}); + +export const ListProjectActionsTool = Tool.make("list_project_actions", { + description: + "List every saved Project Action for this thread's project, including its stable id, name, whether it is opted in for agent-triggered one-shot resume, and a safe reason when it is disabled. Call this before run_project_action_and_resume; never guess an Action id.", + parameters: Schema.Struct({}), + success: Schema.Struct({ actions: Schema.Array(ListedProjectAction) }), + failure: ActionResumeToolError, + dependencies, +}) + .annotate(Tool.Title, "List Project Actions") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true); + +export const RunProjectActionAndResumeTool = Tool.make("run_project_action_and_resume", { + description: + "Launch one explicitly opted-in Project Action in a dedicated terminal, arm exactly one same-thread automated follow-up, and return immediately. The Action may run indefinitely; do not poll it. The user can cancel it from LastCode. Use only an eligible id returned by list_project_actions.", + parameters: Schema.Struct({ actionId: Schema.String }), + success: ActionResumeState, + failure: ActionResumeToolError, + dependencies, +}) + .annotate(Tool.Title, "Run Project Action and resume") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, false); + +export const ActionResumeToolkit = Toolkit.make( + ListProjectActionsTool, + RunProjectActionAndResumeTool, +); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 423a44a6ff15..27d5c8f7bea3 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -30,6 +30,7 @@ import { orchestrationCommandDuration, } from "../../observability/Metrics.ts"; import { toPersistenceSqlError } from "../../persistence/Errors.ts"; +import { makeTurnRequestWaitQuery } from "./TurnRequestWaitQuery.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; import { @@ -87,6 +88,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const crypto = yield* Crypto.Crypto; + const turnRequestWaitQuery = makeTurnRequestWaitQuery(sql); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); let commandReadModel = createEmptyReadModel(yield* nowIso); @@ -353,6 +355,8 @@ const makeOrchestrationEngine = Effect.gen(function* () { }); return { + getTurnRequestWaitState: turnRequestWaitQuery.getState, + subscribeDomainEvents: PubSub.subscribe(eventPubSub).pipe(Effect.map(Stream.fromSubscription)), readEvents, dispatch, // Each access creates a fresh PubSub subscription so that multiple diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index e3b18d74a9a7..0f7c7ad38a77 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -6,6 +6,7 @@ import { MessageId, ProjectId, ThreadId, + ThreadAnnotation, TurnId, ProviderInstanceId, } from "@t3tools/contracts"; @@ -15,6 +16,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -31,12 +33,17 @@ import { OrchestrationProjectionPipelineLive, } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; +import { makeTurnRequestWaitQuery } from "./TurnRequestWaitQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; +const decodeThreadAnnotationJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(ThreadAnnotation), +); + const makeProjectionPipelinePrefixedTestLayer = (prefix: string) => OrchestrationProjectionPipelineLive.pipe( Layer.provideMerge(OrchestrationEventStoreLive), @@ -122,7 +129,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { payload: { threadId: ThreadId.make("thread-1"), messageId: MessageId.make("message-1"), - role: "assistant", + role: "user", text: "hello", turnId: null, streaming: false, @@ -131,6 +138,28 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }, }); + yield* eventStore.append({ + type: "thread.annotation-upserted", + eventId: EventId.make("evt-annotation"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:01:00.000Z", + commandId: CommandId.make("cmd-annotation"), + causationEventId: null, + correlationId: CommandId.make("cmd-annotation"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + annotation: { + body: "# Follow up", + anchorMessageId: MessageId.make("message-1"), + createdAt: "2026-01-01T00:01:00.000Z", + updatedAt: "2026-01-01T00:01:00.000Z", + resolvedAt: null, + }, + }, + }); + yield* projectionPipeline.bootstrap; const projectRows = yield* sql<{ @@ -159,6 +188,20 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { `; assert.deepEqual(messageRows, [{ messageId: "message-1", text: "hello" }]); + const annotationRows = yield* sql<{ + readonly annotation: string | null; + readonly latestUserMessageId: string | null; + }>` + SELECT + annotation_json AS "annotation", + latest_user_message_id AS "latestUserMessageId" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + const annotation = yield* decodeThreadAnnotationJson(annotationRows[0]?.annotation); + assert.equal(annotation.body, "# Follow up"); + assert.equal(annotationRows[0]?.latestUserMessageId, "message-1"); + const stateRows = yield* sql<{ readonly projector: string; readonly lastAppliedSequence: number; @@ -171,7 +214,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { `; assert.equal(stateRows.length, Object.keys(ORCHESTRATION_PROJECTOR_NAMES).length); for (const row of stateRows) { - assert.equal(row.lastAppliedSequence, 3); + assert.equal(row.lastAppliedSequence, 4); } // Settled lifecycle through the DB pipeline: thread.settled writes the @@ -1477,6 +1520,43 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }, }); + const trackedMessageId = MessageId.make("message-turn-superseded"); + yield* eventStore.append({ + type: "thread.turn-start-requested", + eventId: EventId.make("evt-ts-requested"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-ts-requested"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-ts-requested"), + metadata: {}, + payload: { + threadId, + messageId: trackedMessageId, + runtimeMode: "full-access", + interactionMode: "default", + trackRequestCorrelation: true, + createdAt: now, + }, + }); + yield* eventStore.append({ + type: "thread.turn-request-resolved", + eventId: EventId.make("evt-ts-resolved"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-ts-resolved"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-ts-resolved"), + metadata: {}, + payload: { + threadId, + messageId: trackedMessageId, + outcome: { kind: "started", turnId: oldTurnId }, + }, + }); + const appendRunningSessionSet = (eventId: string, turnId: TurnId, updatedAt: string) => eventStore.append({ type: "thread.session-set", @@ -1520,9 +1600,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { ORDER BY requested_at `; assert.deepEqual(rows, [ - { turnId: oldTurnId, state: "completed", completedAt: "2026-01-01T00:00:30.000Z" }, + { turnId: oldTurnId, state: "interrupted", completedAt: "2026-01-01T00:00:30.000Z" }, { turnId: newTurnId, state: "running", completedAt: null }, ]); + assert.deepEqual( + yield* makeTurnRequestWaitQuery(sql).getState({ threadId, messageId: trackedMessageId }), + { + kind: "terminal", + state: "interrupted", + turnId: oldTurnId, + }, + ); }), ); @@ -2469,6 +2557,12 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { role: "assistant", }, ]); + const threadRows = yield* sql<{ readonly latestUserMessageId: string | null }>` + SELECT latest_user_message_id AS "latestUserMessageId" + FROM projection_threads + WHERE thread_id = 'thread-revert' + `; + assert.equal(threadRows[0]?.latestUserMessageId, null); }), ); }); @@ -2541,6 +2635,87 @@ it.layer(makeProjectionPipelinePrefixedTestLayer("t3-pending-turn-terminal-test- }, ); +it.layer(makeProjectionPipelinePrefixedTestLayer("t3-turn-correlation-test-"))( + "OrchestrationProjectionPipeline tracked correlations", + (it) => { + it.effect("tracks marked starts only and preserves the first projected resolution", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-correlation"); + const now = "2026-08-22T00:00:00.000Z"; + for (const [index, tracked] of [false, true].entries()) { + yield* eventStore.append({ + type: "thread.turn-start-requested", + eventId: EventId.make(`evt-correlation-start-${index}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make(`cmd-correlation-start-${index}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-correlation-start-${index}`), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make(`message-correlation-${index}`), + runtimeMode: "approval-required", + interactionMode: "default", + ...(tracked ? { trackRequestCorrelation: true as const } : {}), + createdAt: now, + }, + }); + } + yield* eventStore.append({ + type: "thread.turn-request-resolved", + eventId: EventId.make("evt-correlation-resolved-1"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-correlation-resolved-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-correlation-resolved-1"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("message-correlation-1"), + outcome: { kind: "started", turnId: TurnId.make("turn-correlation") }, + }, + }); + yield* eventStore.append({ + type: "thread.turn-request-resolved", + eventId: EventId.make("evt-correlation-resolved-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-correlation-resolved-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-correlation-resolved-2"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("message-correlation-1"), + outcome: { kind: "terminal", state: "error", completedAt: now }, + }, + }); + + yield* projectionPipeline.bootstrap; + const rows = yield* sql<{ + readonly messageId: string; + readonly state: string; + readonly turnId: string | null; + }>` + SELECT message_id AS "messageId", state, turn_id AS "turnId" + FROM projection_turn_request_correlations + `; + assert.deepEqual(rows, [ + { messageId: "message-correlation-1", state: "started", turnId: "turn-correlation" }, + ]); + }), + ); + }, +); + it.effect("restores pending turn-start metadata across projection pipeline restart", () => Effect.gen(function* () { const { dbPath } = yield* ServerConfig; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e9a625dd91cf..5575bb40c20f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -3,6 +3,7 @@ import { type ChatAttachment, type OrchestrationEvent, type OrchestrationSessionStatus, + type MessageId, ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -33,6 +34,7 @@ import { type ProjectionTurn, ProjectionTurnRepository, } from "../../persistence/Services/ProjectionTurns.ts"; +import { ProjectionTurnRequestCorrelationRepository } from "../../persistence/Services/ProjectionTurnRequestCorrelations.ts"; import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts"; import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts"; import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts"; @@ -42,6 +44,7 @@ import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { ProjectionTurnRequestCorrelationRepositoryLive } from "../../persistence/Layers/ProjectionTurnRequestCorrelations.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; import { ServerConfig } from "../../config.ts"; import { @@ -479,6 +482,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; + const projectionTurnRequestCorrelationRepository = + yield* ProjectionTurnRequestCorrelationRepository; const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; const fileSystem = yield* FileSystem.FileSystem; @@ -570,12 +575,17 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ]); let latestUserMessageAt: string | null = null; + let latestUserMessageId: MessageId | null = null; for (const message of messages) { if ( message.role === "user" && - (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) + (latestUserMessageAt === null || + message.createdAt > latestUserMessageAt || + (message.createdAt === latestUserMessageAt && + (latestUserMessageId === null || message.messageId > latestUserMessageId))) ) { latestUserMessageAt = message.createdAt; + latestUserMessageId = message.messageId; } } @@ -590,6 +600,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, + latestUserMessageId, latestUserMessageAt, pendingApprovalCount, pendingUserInputCount, @@ -623,6 +634,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti pinOrderKey: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, + annotation: null, + latestUserMessageId: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -776,6 +789,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.annotation-upserted": + case "thread.annotation-resolved": + case "thread.annotation-reopened": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + annotation: event.payload.annotation, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, @@ -1138,6 +1167,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti status: event.payload.session.status, providerName: event.payload.session.providerName, providerInstanceId: event.payload.session.providerInstanceId ?? null, + providerThreadId: event.payload.session.providerThreadId ?? null, runtimeMode: event.payload.session.runtimeMode, activeTurnId: event.payload.session.activeTurnId, lastError: event.payload.session.lastError, @@ -1150,6 +1180,13 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti )(function* (event, _attachmentSideEffects) { switch (event.type) { case "thread.turn-start-requested": { + if (event.payload.trackRequestCorrelation === true) { + yield* projectionTurnRequestCorrelationRepository.insertPending({ + threadId: event.payload.threadId, + messageId: event.payload.messageId, + requestedAt: event.payload.createdAt, + }); + } yield* projectionTurnRepository.replacePendingTurnStart({ threadId: event.payload.threadId, messageId: event.payload.messageId, @@ -1160,6 +1197,34 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.turn-request-resolved": { + const outcome = event.payload.outcome; + yield* projectionTurnRequestCorrelationRepository.resolve({ + threadId: event.payload.threadId, + messageId: event.payload.messageId, + turnId: outcome.kind === "started" ? outcome.turnId : null, + state: outcome.kind === "started" ? "started" : outcome.state, + resolvedAt: outcome.kind === "started" ? event.occurredAt : outcome.completedAt, + }); + return; + } + + case "thread.turn-assistant-finalized": { + yield* projectionTurnRequestCorrelationRepository.markAssistantFinalized({ + threadId: event.payload.threadId, + turnId: event.payload.turnId, + finalizedAt: event.payload.finalizedAt, + }); + return; + } + + case "thread.deleted": { + yield* projectionTurnRequestCorrelationRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + } + case "thread.session-set": { const turnId = event.payload.session.activeTurnId; if (turnId === null || event.payload.session.status !== "running") { @@ -1217,7 +1282,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti : projectionTurnRepository.upsertByTurnId({ ...turn, turnId: turn.turnId, - state: "completed", + state: "interrupted", completedAt: event.payload.session.updatedAt, }), { concurrency: 1 }, @@ -1744,6 +1809,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionThreadActivityRepositoryLive), Layer.provideMerge(ProjectionThreadSessionRepositoryLive), Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(ProjectionTurnRequestCorrelationRepositoryLive), Layer.provideMerge(ProjectionPendingApprovalRepositoryLive), Layer.provideMerge(ProjectionStateRepositoryLive), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 83ae3cfe049a..9cad0607b1b8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -18,6 +18,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadActionResume from "../ThreadActionResume.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts"; @@ -31,6 +32,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val const projectionSnapshotLayer = it.layer( OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadActionResume.layer), Layer.provide(ThreadPlanProgress.layer), Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(SqlitePersistenceMemory), @@ -83,12 +85,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { branch, worktree_path, latest_turn_id, + latest_user_message_id, latest_user_message_at, pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, pinned_at, pin_order_key, + annotation_json, created_at, updated_at, deleted_at @@ -103,12 +107,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { NULL, NULL, 'turn-1', + 'message-1', '2026-02-24T00:00:04.000Z', 1, 0, 0, '2026-02-24T00:00:01.000Z', 'gm', + '{"body":"# Follow up","anchorMessageId":"message-1","createdAt":"2026-02-24T00:00:02.500Z","updatedAt":"2026-02-24T00:00:02.500Z","resolvedAt":null}', '2026-02-24T00:00:02.000Z', '2026-02-24T00:00:03.000Z', NULL @@ -326,6 +332,13 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", titleRegeneration: null, + annotation: { + body: "# Follow up", + anchorMessageId: asMessageId("message-1"), + createdAt: "2026-02-24T00:00:02.500Z", + updatedAt: "2026-02-24T00:00:02.500Z", + resolvedAt: null, + }, deletedAt: null, messages: [ { @@ -445,10 +458,18 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", titleRegeneration: null, + annotation: { + body: "# Follow up", + anchorMessageId: asMessageId("message-1"), + createdAt: "2026-02-24T00:00:02.500Z", + updatedAt: "2026-02-24T00:00:02.500Z", + resolvedAt: null, + }, session: { threadId: ThreadId.make("thread-1"), status: "running", providerName: "codex", + providerThreadId: "provider-thread-1", runtimeMode: "approval-required", activeTurnId: asTurnId("turn-1"), lastError: null, @@ -466,7 +487,16 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); assert.equal(threadDetail._tag, "Some"); if (threadDetail._tag === "Some") { - assert.deepEqual(threadDetail.value, snapshot.threads[0]); + const snapshotThread = snapshot.threads[0]; + assert.ok(snapshotThread); + assert.ok(snapshotThread.session); + assert.deepEqual(threadDetail.value, { + ...snapshotThread, + session: { + ...snapshotThread.session, + providerThreadId: "provider-thread-1", + }, + }); } }), ); @@ -514,6 +544,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { branch, worktree_path, latest_turn_id, + latest_user_message_id, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -535,6 +566,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { NULL, NULL, NULL, + NULL, 0, 0, 0, @@ -554,6 +586,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { NULL, NULL, NULL, + NULL, 0, 0, 0, @@ -634,6 +667,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { branch, worktree_path, latest_turn_id, + latest_user_message_id, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -656,6 +690,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { NULL, NULL, NULL, + NULL, 0, 0, 0, @@ -1291,7 +1326,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); - it.effect("uses projection_threads.latest_turn_id for bulk command and shell snapshots", () => + it.effect("uses projection_threads latest markers for bulk command and shell snapshots", () => Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; const sql = yield* SqlClient.SqlClient; @@ -1335,6 +1370,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { branch, worktree_path, latest_turn_id, + latest_user_message_id, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -1354,6 +1390,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { NULL, NULL, 'turn-running', + 'message-user-2', '2026-04-03T00:00:04.000Z', 0, 0, @@ -1365,6 +1402,29 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ) `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + is_streaming, + created_at, + updated_at + ) + VALUES ( + 'message-user-2', + 'thread-1', + NULL, + 'user', + 'Latest prompt', + 0, + '2026-04-03T00:00:30.000Z', + '2026-04-03T00:00:30.000Z' + ) + `; + yield* sql` INSERT INTO projection_turns ( thread_id, @@ -1432,6 +1492,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { const commandReadModel = yield* snapshotQuery.getCommandReadModel(); assert.equal(commandReadModel.threads[0]?.latestTurn?.turnId, asTurnId("turn-running")); assert.equal(commandReadModel.threads[0]?.latestTurn?.state, "running"); + assert.equal(commandReadModel.threads[0]?.messages.length, 0); + assert.equal(commandReadModel.threads[0]?.latestUserMessageId, asMessageId("message-user-2")); const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); assert.equal(shellSnapshot.threads[0]?.latestTurn?.turnId, asTurnId("turn-running")); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index c6c5ad1d7e8c..42db0c4e2c5d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -25,6 +25,7 @@ import { ModelSelection, ProjectId, ThreadId, + ThreadAnnotation, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Effect from "effect/Effect"; @@ -44,6 +45,7 @@ import { } from "../../persistence/Errors.ts"; import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts"; import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; +import { ThreadActionResumeService } from "../ThreadActionResume.ts"; import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts"; import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; @@ -89,6 +91,7 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + annotation: Schema.NullOr(Schema.fromJsonString(ThreadAnnotation)), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -303,6 +306,7 @@ function mapSessionRow( status: row.status, providerName: row.providerName, ...(row.providerInstanceId !== null ? { providerInstanceId: row.providerInstanceId } : {}), + providerThreadId: row.providerThreadId, runtimeMode: row.runtimeMode, activeTurnId: row.activeTurnId, lastError: row.lastError, @@ -351,9 +355,14 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st const makeProjectionSnapshotQuery = Effect.gen(function* () { const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; + const threadActionResume = yield* ThreadActionResumeService; const threadPlanProgress = yield* ThreadPlanProgressService; const sql = yield* SqlClient.SqlClient; const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const actionResumeShellField = (threadId: ThreadId) => { + const actionResume = threadActionResume.getForShell(threadId); + return actionResume === null ? {} : { actionResume }; + }; const repositoryIdentityResolutionConcurrency = 4; const resolveRepositoryIdentitiesForProjects = Effect.fn( "ProjectionSnapshotQuery.resolveRepositoryIdentitiesForProjects", @@ -434,6 +443,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + annotation_json AS "annotation", + latest_user_message_id AS "latestUserMessageId", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -470,6 +481,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + annotation_json AS "annotation", + latest_user_message_id AS "latestUserMessageId", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -508,6 +521,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + annotation_json AS "annotation", + latest_user_message_id AS "latestUserMessageId", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -950,6 +965,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + annotation_json AS "annotation", + latest_user_message_id AS "latestUserMessageId", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1055,6 +1072,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { status, provider_name AS "providerName", provider_instance_id AS "providerInstanceId", + provider_thread_id AS "providerThreadId", runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", @@ -1150,7 +1168,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { 'thread.activity-appended', 'thread.turn-diff-completed', 'thread.reverted', - 'thread.session-set' + 'thread.session-set', + 'thread.annotation-upserted', + 'thread.annotation-resolved', + 'thread.annotation-reopened' ) `, }); @@ -1705,6 +1726,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + ...(row.annotation !== null ? { annotation: row.annotation } : {}), deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1912,6 +1934,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + ...(row.annotation !== null ? { annotation: row.annotation } : {}), + latestUserMessageId: row.latestUserMessageId, deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -2048,6 +2072,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + ...(row.annotation !== null ? { annotation: row.annotation } : {}), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -2056,6 +2081,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( row.threadId, ), + ...actionResumeShellField(row.threadId), planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), } satisfies OrchestrationThreadShell) : Result.failVoid, @@ -2193,6 +2219,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), + ...(row.annotation !== null ? { annotation: row.annotation } : {}), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -2201,6 +2228,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( row.threadId, ), + ...actionResumeShellField(row.threadId), planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), }), ), @@ -2472,6 +2500,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), + ...(threadRow.value.annotation !== null ? { annotation: threadRow.value.annotation } : {}), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -2480,6 +2509,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( threadRow.value.threadId, ), + ...actionResumeShellField(threadRow.value.threadId), planProgress: threadPlanProgress.getThreadPlanProgress(threadRow.value.threadId), } satisfies OrchestrationThreadShell); }); @@ -2613,6 +2643,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), + ...(threadRow.value.annotation !== null ? { annotation: threadRow.value.annotation } : {}), deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605a..5f5ee3eff031 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -22,6 +22,7 @@ import { TurnId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -49,6 +50,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadActionResume from "../ThreadActionResume.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { providerErrorLabel, @@ -149,6 +151,7 @@ describe("ProviderCommandReactor", () => { readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; readonly titleRegenerationCompletionDispatchFailures?: number; + readonly turnRequestResolutionDispatchFailures?: number; readonly titleRegenerationBeforeStart?: "one" | "two"; readonly startSessionEffect?: ( session: ProviderSession, @@ -357,11 +360,13 @@ describe("ProviderCommandReactor", () => { ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadActionResume.layer), Layer.provide(ThreadPlanProgress.layer), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); let titleRegenerationCompletionDispatchAttempts = 0; + let turnRequestResolutionDispatchAttempts = 0; const reactorOrchestrationLayer = Layer.effect( OrchestrationEngineService, Effect.gen(function* () { @@ -369,6 +374,15 @@ describe("ProviderCommandReactor", () => { return { readEvents: engine.readEvents, dispatch: (command) => { + if (command.type === "thread.turn-request.resolve") { + turnRequestResolutionDispatchAttempts += 1; + if ( + turnRequestResolutionDispatchAttempts <= + (input?.turnRequestResolutionDispatchFailures ?? 0) + ) { + return Effect.die(new Error("Injected turn request resolution failure")); + } + } if (command.type === "thread.title.regeneration.complete") { titleRegenerationCompletionDispatchAttempts += 1; if ( @@ -383,6 +397,8 @@ describe("ProviderCommandReactor", () => { get streamDomainEvents() { return engine.streamDomainEvents; }, + getTurnRequestWaitState: engine.getTurnRequestWaitState, + subscribeDomainEvents: engine.subscribeDomainEvents, latestSequence: engine.latestSequence, } satisfies OrchestrationEngineService["Service"]; }), @@ -423,7 +439,7 @@ describe("ProviderCommandReactor", () => { const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); const runEffect = (effect: Effect.Effect) => runtime!.runPromise(effect); - await Effect.runPromise( + await runtime.runPromise( engine.dispatch({ type: "project.create", commandId: CommandId.make("cmd-project-create"), @@ -509,6 +525,9 @@ describe("ProviderCommandReactor", () => { get titleRegenerationCompletionDispatchAttempts() { return titleRegenerationCompletionDispatchAttempts; }, + get turnRequestResolutionDispatchAttempts() { + return turnRequestResolutionDispatchAttempts; + }, }; } @@ -552,6 +571,79 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("finalizes a marked request with the exact provider turn id", async () => { + const harness = await createHarness(); + const observed = await harness.runEffect( + Effect.gen(function* () { + const fiber = yield* Stream.runHead( + harness.engine.streamDomainEvents.pipe( + Stream.filter((event) => event.type === "thread.turn-request-resolved"), + ), + ).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-tracked"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-tracked"), + role: "user", + text: "track this exact turn", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + trackRequestCorrelation: true, + createdAt: "2026-01-01T00:00:00.000Z", + }); + return yield* Fiber.join(fiber); + }), + ); + + expect(observed._tag).toBe("Some"); + if (observed._tag === "Some" && observed.value.type === "thread.turn-request-resolved") { + expect(observed.value.payload.outcome).toEqual({ kind: "started", turnId: "turn-1" }); + } + const getState = harness.engine.getTurnRequestWaitState; + expect(getState).toBeDefined(); + if (getState) { + expect( + await harness.runEffect( + getState({ + threadId: ThreadId.make("thread-1"), + messageId: asMessageId("user-message-tracked"), + }), + ), + ).toEqual({ kind: "pending" }); + } + }); + + it("does not mark a started provider session failed when correlation persistence fails", async () => { + const harness = await createHarness({ turnRequestResolutionDispatchFailures: 1 }); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-correlation-failure"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-correlation-failure"), + role: "user", + text: "start despite correlation storage failure", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + trackRequestCorrelation: true, + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + await harness.drain(); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + expect(harness.turnRequestResolutionDispatchAttempts).toBe(1); + const thread = (await harness.readModel()).threads.find((entry) => entry.id === "thread-1"); + expect(thread?.session?.status).not.toBe("error"); + }); + effectIt.effect("projects starting before a slow provider session finishes", () => Effect.gen(function* () { const releaseStart = yield* Deferred.make(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index cfc95f2613fb..9da952de6051 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -38,6 +38,9 @@ import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; + +const ProviderThreadResumeCursor = Schema.Struct({ threadId: Schema.String }); +const isProviderThreadResumeCursor = Schema.is(ProviderThreadResumeCursor); import { forkParked, ServerActivation } from "../../serverActivation.ts"; import { canReplaceThreadTitle, DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { @@ -639,6 +642,9 @@ const make = Effect.gen(function* () { : mapProviderSessionStatusToOrchestrationStatus(session.status), providerName: session.provider, providerInstanceId: session.providerInstanceId, + ...(session.provider === "codex" && isProviderThreadResumeCursor(session.resumeCursor) + ? { providerThreadId: session.resumeCursor.threadId } + : {}), runtimeMode: desiredRuntimeMode, // Provider turn ids are not orchestration turn ids. activeTurnId: null, @@ -1065,25 +1071,59 @@ const make = Effect.gen(function* () { return; } + const finalizeTrackedRequest = ( + outcome: + | { readonly kind: "started"; readonly turnId: TurnId } + | { + readonly kind: "terminal"; + readonly state: "error" | "interrupted"; + readonly completedAt: string; + }, + ) => + event.payload.trackRequestCorrelation === true + ? orchestrationEngine + .dispatch({ + type: "thread.turn-request.resolve", + commandId: CommandId.make(`turn-request:${event.eventId}`), + threadId: event.payload.threadId, + messageId: event.payload.messageId, + outcome, + createdAt: event.payload.createdAt, + }) + .pipe(Effect.asVoid) + : Effect.void; + const thread = yield* resolveThread(event.payload.threadId); if (!thread) { - return; + return yield* finalizeTrackedRequest({ + kind: "terminal", + state: "error", + completedAt: event.payload.createdAt, + }); } const message = thread.messages.find((entry) => entry.id === event.payload.messageId); - if (!message || message.role !== "user") { - yield* appendProviderFailureActivity({ + if (!message || (message.role !== "user" && message.role !== "system")) { + const outcome = { + kind: "terminal", + state: "error", + completedAt: event.payload.createdAt, + } as const; + return yield* appendProviderFailureActivity({ threadId: event.payload.threadId, kind: "provider.turn.start.failed", summary: "Provider turn start failed", - detail: `User message '${event.payload.messageId}' was not found for turn start request.`, + detail: `Turn message '${event.payload.messageId}' was not found for turn start request.`, turnId: null, createdAt: event.payload.createdAt, - }); - return; + }).pipe( + Effect.asVoid, + Effect.ensuring(finalizeTrackedRequest(outcome).pipe(Effect.ignore({ log: true }))), + ); } const isFirstUserMessageTurn = + message.role === "user" && thread.messages.filter((entry) => entry.role === "user").length === 1; if (isFirstUserMessageTurn) { const project = yield* resolveProject(thread.projectId); @@ -1116,9 +1156,18 @@ const make = Effect.gen(function* () { const handleTurnStartFailure = (cause: Cause.Cause) => { if (Cause.hasInterruptsOnly(cause)) { - return Effect.void; + return finalizeTrackedRequest({ + kind: "terminal", + state: "interrupted", + completedAt: event.payload.createdAt, + }); } const detail = formatFailureDetail(cause); + const outcome = { + kind: "terminal", + state: "error", + completedAt: event.payload.createdAt, + } as const; return setThreadSessionErrorOnTurnStartFailure({ threadId: event.payload.threadId, detail, @@ -1135,6 +1184,7 @@ const make = Effect.gen(function* () { }), ), Effect.asVoid, + Effect.ensuring(finalizeTrackedRequest(outcome).pipe(Effect.ignore({ log: true }))), ); }; @@ -1168,9 +1218,21 @@ const make = Effect.gen(function* () { return; } - yield* providerService - .sendTurn(sendTurnRequest.value) - .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); + yield* providerService.sendTurn(sendTurnRequest.value).pipe( + Effect.tap((result) => + finalizeTrackedRequest({ kind: "started", turnId: result.turnId }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider turn started but correlation finalization failed", { + threadId: event.payload.threadId, + messageId: event.payload.messageId, + cause: Cause.pretty(cause), + }), + ), + ), + ), + Effect.catchCause(recoverTurnStartFailure), + Effect.forkScoped, + ); }); const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 1e1374c966b6..87860e0b35b7 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -262,6 +262,12 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(ingestion.drain); const dispatch = (command: OrchestrationCommand) => Effect.runPromise(engine.dispatch(command)); + const readEvents = () => + Effect.runPromise( + Stream.runCollect(engine.readEvents(0)).pipe(Effect.map((chunk) => Array.from(chunk))), + ); + const readTurnRequestWaitState = (threadId: ThreadId, messageId: MessageId) => + Effect.runPromise(engine.getTurnRequestWaitState({ threadId, messageId })); const createdAt = "2026-01-01T00:00:00.000Z"; await dispatch({ @@ -319,7 +325,10 @@ describe("ProviderRuntimeIngestion", () => { return { engine, dispatch, + readEvents, + readTurnRequestWaitState, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), + readShell: () => Effect.runPromise(snapshotQuery.getShellSnapshot()), emit: provider.emit, setProviderSession: provider.setSession, drain, @@ -633,6 +642,7 @@ describe("ProviderRuntimeIngestion", () => { threadId, status: "starting", providerName: "codex", + providerThreadId: "codex-native-stopped", runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -648,6 +658,7 @@ describe("ProviderRuntimeIngestion", () => { threadId, status: "stopped", providerName: "codex", + providerThreadId: "codex-native-stopped", runtimeMode: "approval-required", activeTurnId: null, lastError: null, @@ -739,6 +750,127 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("preserves thread.started native identity through later lifecycle events", async () => { + const harness = await createHarness(); + harness.emit({ + type: "thread.started", + eventId: asEventId("evt-native-thread-started"), + provider: ProviderDriverKind.make("codex"), + threadId: ThreadId.make("thread-1"), + payload: { providerThreadId: "codex-native-lifecycle" }, + createdAt: "2026-01-01T00:00:01.000Z", + }); + await harness.drain(); + const shellAfterThreadStarted = await harness.readShell(); + const threadAfterThreadStarted = shellAfterThreadStarted.threads.find( + (thread) => thread.id === ThreadId.make("thread-1"), + ); + expect(threadAfterThreadStarted?.session?.providerThreadId).toBe("codex-native-lifecycle"); + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-native-turn-started"), + provider: ProviderDriverKind.make("codex"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-native-lifecycle"), + createdAt: "2026-01-01T00:00:02.000Z", + }); + await harness.drain(); + const shellAfterTurnStarted = await harness.readShell(); + const threadAfterTurnStarted = shellAfterTurnStarted.threads.find( + (thread) => thread.id === ThreadId.make("thread-1"), + ); + expect(threadAfterTurnStarted?.session?.status).toBe("running"); + expect(threadAfterTurnStarted?.session?.providerThreadId).toBe("codex-native-lifecycle"); + }); + + it("projects Codex interruption and Cursor cancellation as interrupted tracked waits", async () => { + const harness = await createHarness(); + const createdAt = "2026-01-01T00:00:00.000Z"; + const cases = [ + { + provider: ProviderDriverKind.make("codex"), + state: "interrupted" as const, + suffix: "codex", + }, + { + provider: ProviderDriverKind.make("cursor"), + state: "cancelled" as const, + suffix: "cursor", + }, + ]; + + for (const [index, entry] of cases.entries()) { + const threadId = ThreadId.make(`thread-${index + 1}`); + const turnId = asTurnId(`turn-interrupted-${entry.suffix}`); + const messageId = asMessageId(`message-interrupted-${entry.suffix}`); + if (index > 0) { + await harness.dispatch({ + type: "thread.create", + commandId: CommandId.make(`cmd-thread-create-${entry.suffix}`), + threadId, + projectId: asProjectId("project-1"), + title: `Interrupted ${entry.suffix}`, + modelSelection: { + instanceId: ProviderInstanceId.make(entry.provider), + model: "test-model", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }); + } + await harness.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-turn-start-${entry.suffix}`), + threadId, + message: { + messageId, + role: "user", + text: "Track this interrupted turn.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + trackRequestCorrelation: true, + createdAt, + }); + await harness.dispatch({ + type: "thread.turn-request.resolve", + commandId: CommandId.make(`cmd-turn-resolve-${entry.suffix}`), + threadId, + messageId, + outcome: { kind: "started", turnId }, + createdAt, + }); + harness.emit({ + type: "turn.started", + eventId: asEventId(`evt-turn-started-${entry.suffix}`), + provider: entry.provider, + threadId, + turnId, + createdAt, + }); + harness.emit({ + type: "turn.completed", + eventId: asEventId(`evt-turn-completed-${entry.suffix}`), + provider: entry.provider, + threadId, + turnId, + payload: { state: entry.state }, + createdAt, + }); + await harness.drain(); + + expect(await harness.readTurnRequestWaitState(threadId, messageId)).toEqual({ + kind: "terminal", + state: "interrupted", + turnId, + }); + } + }); + it("accepts claude turn lifecycle when seeded thread id is a synthetic placeholder", async () => { const harness = await createHarness(); const seededAt = "2026-01-01T00:00:00.000Z"; @@ -1598,13 +1730,12 @@ describe("ProviderRuntimeIngestion", () => { threadId, turnId: oldTurnId, }); - await waitForThread( - harness.readModel, - (thread) => - thread.session?.status === "running" && thread.session?.activeTurnId === oldTurnId, - 2_000, - threadId, + await harness.drain(); + const threadBeforeSteer = (await harness.readModel()).threads.find( + (thread) => thread.id === threadId, ); + expect(threadBeforeSteer?.session?.status).toBe("running"); + expect(threadBeforeSteer?.session?.activeTurnId).toBe(oldTurnId); // The steer: a user-requested turn start while the old turn still runs. await Effect.runPromise( @@ -1644,16 +1775,13 @@ describe("ProviderRuntimeIngestion", () => { turnId: newTurnId, }); - const threadAfterSteer = await waitForThread( - harness.readModel, - (thread) => - thread.session?.status === "running" && thread.session?.activeTurnId === newTurnId, - 2_000, - threadId, + await harness.drain(); + const threadAfterSteer = (await harness.readModel()).threads.find( + (thread) => thread.id === threadId, ); - expect(threadAfterSteer.session?.activeTurnId).toBe(newTurnId); - expect(threadAfterSteer.latestTurn?.turnId).toBe(newTurnId); - expect(threadAfterSteer.latestTurn?.state).toBe("running"); + expect(threadAfterSteer?.session?.activeTurnId).toBe(newTurnId); + expect(threadAfterSteer?.latestTurn?.turnId).toBe(newTurnId); + expect(threadAfterSteer?.latestTurn?.state).toBe("running"); }); it("does not mark the source proposed plan implemented for an unrelated turn.started when no thread active turn is tracked", async () => { @@ -2248,11 +2376,7 @@ describe("ProviderRuntimeIngestion", () => { expect(resumedMessage?.text).toBe(" second half"); expect(resumedMessage?.streaming).toBe(false); - const events = await Effect.runPromise( - Stream.runCollect(harness.engine.readEvents(0)).pipe( - Effect.map((chunk) => Array.from(chunk)), - ), - ); + const events = await harness.readEvents(); const assistantEvents = events.filter( (event): event is Extract<(typeof events)[number], { type: "thread.message-sent" }> => event.type === "thread.message-sent" && @@ -2386,22 +2510,20 @@ describe("ProviderRuntimeIngestion", () => { const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-streaming-mode"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("message-streaming-mode"), - role: "user", - text: "stream please", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); + await harness.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-streaming-mode"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("message-streaming-mode"), + role: "user", + text: "stream please", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); await harness.drain(); harness.emit({ @@ -2603,12 +2725,9 @@ describe("ProviderRuntimeIngestion", () => { message.id === "assistant:item-complete-dedup" && !message.streaming, ), ); + await harness.drain(); - const events = await Effect.runPromise( - Stream.runCollect(harness.engine.readEvents(0)).pipe( - Effect.map((chunk) => Array.from(chunk)), - ), - ); + const events = await harness.readEvents(); const completionEvents = events.filter((event) => { if (event.type !== "thread.message-sent") { return false; @@ -2619,6 +2738,19 @@ describe("ProviderRuntimeIngestion", () => { ); }); expect(completionEvents).toHaveLength(1); + const completionIndex = events.findIndex( + (event) => + event.type === "thread.message-sent" && + event.payload.messageId === "assistant:item-complete-dedup" && + event.payload.streaming === false, + ); + const finalizedIndex = events.findIndex( + (event) => + event.type === "thread.turn-assistant-finalized" && + event.payload.turnId === "turn-complete-dedup", + ); + expect(completionIndex).toBeGreaterThanOrEqual(0); + expect(finalizedIndex).toBeGreaterThan(completionIndex); }); it("maps canonical request events into approval activities with requestKind", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 953ba1ec9b0d..d8c6bbc9b7fc 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1582,10 +1582,14 @@ const make = Effect.gen(function* () { return "running"; case "session.exited": return "stopped"; - case "turn.completed": - return normalizeRuntimeTurnState(event.payload.state) === "failed" + case "turn.completed": { + const turnState = normalizeRuntimeTurnState(event.payload.state); + return turnState === "failed" ? "error" - : "ready"; + : turnState === "interrupted" || turnState === "cancelled" + ? "interrupted" + : "ready"; + } case "session.started": case "thread.started": // Provider thread/session start notifications can arrive during an @@ -1646,6 +1650,9 @@ const make = Effect.gen(function* () { ...(event.providerInstanceId !== undefined ? { providerInstanceId: event.providerInstanceId } : {}), + ...(event.type === "thread.started" && event.payload?.providerThreadId !== undefined + ? { providerThreadId: event.payload.providerThreadId } + : {}), runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: nextActiveTurnId, lastError, @@ -1870,6 +1877,14 @@ const make = Effect.gen(function* () { turnId, updatedAt: now, }); + + yield* orchestrationEngine.dispatch({ + type: "thread.turn-assistant.finalize", + commandId: yield* providerCommandId(event, "turn-assistant-finalize"), + threadId: thread.id, + turnId, + createdAt: now, + }); } } diff --git a/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts new file mode 100644 index 000000000000..224ac3244f72 --- /dev/null +++ b/apps/server/src/orchestration/Layers/TurnRequestWaitQuery.ts @@ -0,0 +1,110 @@ +import { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, + type ProjectionRepositoryError, +} from "../../persistence/Errors.ts"; +import type { TurnRequestWaitState } from "../Services/OrchestrationEngine.ts"; + +const WaitRow = Schema.Struct({ + correlationState: Schema.Literals(["pending", "started", "error", "interrupted"]), + turnId: Schema.NullOr(TurnId), + turnState: Schema.NullOr(Schema.Literals(["running", "completed", "error", "interrupted"])), + assistantMessageId: Schema.NullOr(MessageId), + response: Schema.NullOr(Schema.String), + responseStreaming: Schema.NullOr(Schema.Number), + assistantFinalizedAt: Schema.NullOr(Schema.String), +}); + +export const makeTurnRequestWaitQuery = (sql: SqlClient.SqlClient) => { + const getRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ threadId: ThreadId, messageId: MessageId }), + Result: WaitRow, + execute: ({ threadId, messageId }) => sql` + SELECT correlations.state AS "correlationState", correlations.turn_id AS "turnId", + turns.state AS "turnState", turns.assistant_message_id AS "assistantMessageId", + messages.text AS "response", messages.is_streaming AS "responseStreaming", + finalizations.finalized_at AS "assistantFinalizedAt" + FROM projection_turn_request_correlations AS correlations + LEFT JOIN projection_turns AS turns + ON turns.thread_id = correlations.thread_id AND turns.turn_id = correlations.turn_id + LEFT JOIN projection_thread_messages AS messages + ON messages.message_id = turns.assistant_message_id + LEFT JOIN projection_turn_assistant_finalizations AS finalizations + ON finalizations.thread_id = correlations.thread_id + AND finalizations.turn_id = correlations.turn_id + WHERE correlations.thread_id = ${threadId} AND correlations.message_id = ${messageId} + LIMIT 1 + `, + }); + + const getState = (input: { readonly threadId: ThreadId; readonly messageId: MessageId }) => + Effect.gen(function* () { + const threads = yield* sql<{ readonly found: number }>` + SELECT 1 AS found FROM projection_threads + WHERE thread_id = ${input.threadId} AND deleted_at IS NULL LIMIT 1 + `; + if (threads.length === 0) return { kind: "thread-not-found" } as const; + const row = yield* getRow(input); + if (Option.isNone(row)) return { kind: "correlation-not-found" } as const; + const value = row.value; + if (value.correlationState === "error" || value.correlationState === "interrupted") { + return { kind: "terminal", state: value.correlationState } as const; + } + if (value.turnId !== null && value.turnState !== null && value.turnState !== "running") { + if (value.turnState === "completed") { + if (value.assistantFinalizedAt === null) { + return { kind: "pending" } as const; + } + if (value.assistantMessageId === null) { + return { + kind: "terminal", + state: "completed", + turnId: value.turnId, + response: "", + } as const; + } + if (value.response === null) { + if (value.assistantMessageId !== MessageId.make(`assistant:${value.turnId}`)) { + return { kind: "pending" } as const; + } + return { + kind: "terminal", + state: "completed", + turnId: value.turnId, + response: "", + } as const; + } + if (value.responseStreaming !== 0) { + return { kind: "pending" } as const; + } + return { + kind: "terminal", + state: "completed", + turnId: value.turnId, + response: value.response, + } as const; + } + return { + kind: "terminal", + state: value.turnState, + turnId: value.turnId, + } as const; + } + return { kind: "pending" } as const; + }).pipe( + Effect.mapError((cause) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError("TurnRequestWaitQuery.getState:decode")(cause) + : toPersistenceSqlError("TurnRequestWaitQuery.getState:query")(cause), + ), + ) satisfies Effect.Effect; + + return { getState }; +}; diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 7e866cf89592..33c5d02d3274 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -16,6 +16,7 @@ import { ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, + ThreadAnnotationChangedPayload as ContractsThreadAnnotationChangedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -48,6 +49,7 @@ export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; +export const ThreadAnnotationChangedPayload = ContractsThreadAnnotationChangedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/Services/OrchestrationEngine.ts b/apps/server/src/orchestration/Services/OrchestrationEngine.ts index a32a45684014..25d633579c5a 100644 --- a/apps/server/src/orchestration/Services/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Services/OrchestrationEngine.ts @@ -11,21 +11,51 @@ * @module OrchestrationEngineService */ import type { + MessageId, OrchestrationClientOrigin, OrchestrationCommand, OrchestrationEvent, + ThreadId, + TurnId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; import type * as Stream from "effect/Stream"; import type { OrchestrationDispatchError } from "../Errors.ts"; -import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts"; +import type { + OrchestrationEventStoreError, + ProjectionRepositoryError, +} from "../../persistence/Errors.ts"; + +export type TurnRequestWaitState = + | { readonly kind: "thread-not-found" | "correlation-not-found" | "pending" } + | { + readonly kind: "terminal"; + readonly state: "completed"; + readonly turnId: TurnId; + readonly response: string; + } + | { + readonly kind: "terminal"; + readonly state: "error" | "interrupted"; + readonly turnId?: TurnId; + }; /** * OrchestrationEngineShape - Service API for orchestration command and event flow. */ export interface OrchestrationEngineShape { + readonly getTurnRequestWaitState: (input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + }) => Effect.Effect; + readonly subscribeDomainEvents: Effect.Effect< + Stream.Stream, + never, + Scope.Scope + >; /** * Replay persisted orchestration events from an exclusive sequence cursor. * diff --git a/apps/server/src/orchestration/ThreadActionResume.test.ts b/apps/server/src/orchestration/ThreadActionResume.test.ts new file mode 100644 index 000000000000..b96c6dd791b9 --- /dev/null +++ b/apps/server/src/orchestration/ThreadActionResume.test.ts @@ -0,0 +1,61 @@ +import { assert, it } from "@effect/vitest"; +import { type ActionResumeState, ProjectId, ThreadId } from "@t3tools/contracts"; + +import { make } from "./ThreadActionResume.ts"; + +const threadId = ThreadId.make("thread-action-resume-hydration"); +const projectId = ProjectId.make("project-action-resume-hydration"); + +const state = (input: Partial): ActionResumeState => ({ + runId: "completed-run", + threadId, + projectId, + actionId: "qa", + actionName: "QA", + terminalId: "action-completed-run", + outcome: "succeeded", + delivery: "pending", + startedAt: "2026-08-17T00:00:00.000Z", + finishedAt: "2026-08-17T00:01:00.000Z", + exitCode: 0, + exitSignal: null, + ...input, +}); + +it("does not resurrect stale pending state for a settled Action", () => { + const registry = make(); + + registry.hydrate([ + state({ outcome: "running", delivery: "armed", finishedAt: null, exitCode: null }), + state({ delivery: "available" }), + state({ delivery: "delivered" }), + state({ delivery: "disposed" }), + state({ delivery: "pending" }), + ]); + + assert.deepInclude(registry.getLatest(threadId), { + outcome: "succeeded", + delivery: "disposed", + }); + assert.isNull(registry.getForShell(threadId)); +}); + +it("keeps a genuinely pending newer Action visible after hydration", () => { + const registry = make(); + + registry.hydrate([ + state({ delivery: "delivered" }), + state({ + runId: "newer-run", + terminalId: "action-newer-run", + delivery: "pending", + startedAt: "2026-08-17T00:02:00.000Z", + finishedAt: "2026-08-17T00:03:00.000Z", + }), + ]); + + assert.deepInclude(registry.getForShell(threadId), { + runId: "newer-run", + delivery: "pending", + }); +}); diff --git a/apps/server/src/orchestration/ThreadActionResume.ts b/apps/server/src/orchestration/ThreadActionResume.ts new file mode 100644 index 000000000000..2ce9da3e065f --- /dev/null +++ b/apps/server/src/orchestration/ThreadActionResume.ts @@ -0,0 +1,84 @@ +/** + * Fast per-thread view of the latest durable Action resume lifecycle row. + * + * The authoritative history is persisted as `action.resume.lifecycle` + * activities. This registry is hydrated from those rows at startup and lets + * shell snapshot queries avoid an activity-table lookup for every thread. + */ +import type { ActionResumeState } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +const isShellVisible = (state: ActionResumeState): boolean => + state.outcome === "running" || + state.outcome === "process_lost" || + state.delivery === "pending" || + state.delivery === "available"; + +const hydrationDeliveryRank = (delivery: ActionResumeState["delivery"]): number => { + switch (delivery) { + case "armed": + return 0; + case "pending": + return 1; + case "available": + return 2; + case "delivered": + return 3; + case "disposed": + return 4; + } +}; + +export interface ThreadActionResumeShape { + /** Restore one latest run per thread without reviving superseded delivery states. */ + readonly hydrate: (states: ReadonlyArray) => void; + readonly record: (state: ActionResumeState) => void; + readonly clear: (threadId: string) => void; + readonly getLatest: (threadId: string) => ActionResumeState | null; + readonly getForShell: (threadId: string) => ActionResumeState | null; + readonly listLatest: () => ReadonlyArray; + readonly countRunning: () => number; +} + +export function make(): ThreadActionResumeShape { + const latestByThreadId = new Map(); + + return { + hydrate: (states) => { + for (const state of states) { + const current = latestByThreadId.get(state.threadId); + if ( + current === undefined || + state.startedAt > current.startedAt || + (state.runId === current.runId && + hydrationDeliveryRank(state.delivery) > hydrationDeliveryRank(current.delivery)) + ) { + latestByThreadId.set(state.threadId, state); + } + } + }, + record: (state) => { + latestByThreadId.set(state.threadId, state); + }, + clear: (threadId) => { + latestByThreadId.delete(threadId); + }, + getLatest: (threadId) => latestByThreadId.get(threadId) ?? null, + getForShell: (threadId) => { + const state = latestByThreadId.get(threadId); + return state && isShellVisible(state) ? state : null; + }, + listLatest: () => [...latestByThreadId.values()], + countRunning: () => + [...latestByThreadId.values()].filter((state) => state.outcome === "running").length, + }; +} + +export class ThreadActionResumeService extends Context.Reference( + "t3/orchestration/ThreadActionResume/ThreadActionResumeService", + { defaultValue: make }, +) {} + +export const layer = Layer.effect(ThreadActionResumeService, Effect.sync(make)); diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts index 4a4b68ced598..02dbfe2f44ab 100644 --- a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts +++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts @@ -28,6 +28,58 @@ describe("ThreadBackgroundLiveness", () => { expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull(); }); + it("does not let status-free updated restart an idle task", () => { + const liveness = ThreadBackgroundLiveness.make(); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "task", + taskType: undefined, + status: undefined, + kind: "started", + }); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "task", + taskType: undefined, + status: "idle", + kind: "updated", + }); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "task", + taskType: undefined, + status: undefined, + kind: "updated", + }); + expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull(); + }); + + it("a genuine restart (kind: started) still revives an idle task", () => { + const liveness = ThreadBackgroundLiveness.make(); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "task", + taskType: undefined, + status: undefined, + kind: "started", + }); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "task", + taskType: undefined, + status: "idle", + kind: "updated", + }); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "task", + taskType: undefined, + status: undefined, + kind: "started", + }); + expect(liveness.getThreadBackgroundLiveness("thread")).toBe("working"); + }); + it("agents present as working; monitors as monitoring; agents win", () => { const liveness = ThreadBackgroundLiveness.make(); const threadId = "t-live-1"; diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts index d4d6da06dfcd..e2fbeff867ef 100644 --- a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts +++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts @@ -130,10 +130,11 @@ export function make(): ThreadBackgroundLivenessService["Service"] { return; } - // Status-free progress is a description tick, not a restart. A delayed - // progress event after idle must not put the task back in the live set - // (#7128). - if (input.kind === "progress" && input.status === undefined) { + // Status-free progress/updated is a description tick, not a restart. A + // delayed progress or updated event after idle must not put the task + // back in the live set (#7128, #7172). "started" is excluded on + // purpose: a genuine restart must still revive the task. + if ((input.kind === "progress" || input.kind === "updated") && input.status === undefined) { const existing = stateByThreadId.get(input.threadId); const stillLive = existing !== undefined && diff --git a/apps/server/src/orchestration/decider.annotation.test.ts b/apps/server/src/orchestration/decider.annotation.test.ts new file mode 100644 index 000000000000..f8d4c8ad54ff --- /dev/null +++ b/apps/server/src/orchestration/decider.annotation.test.ts @@ -0,0 +1,259 @@ +import { + CommandId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type ThreadAnnotation, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; + +function makeReadModel( + annotation: ThreadAnnotation | null = null, + latestMessageId: string | null = "message-new", +): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + annotation, + deletedAt: null, + messages: + latestMessageId === null + ? [] + : [ + { + id: MessageId.make(latestMessageId), + role: "user", + text: "Prompt", + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + }, + ], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +const existingAnnotation: ThreadAnnotation = { + body: "- [ ] Follow up", + anchorMessageId: MessageId.make("message-old"), + createdAt: "2025-12-30T00:00:00.000Z", + updatedAt: "2025-12-31T00:00:00.000Z", + resolvedAt: null, +}; + +it.layer(NodeServices.layer)("thread annotation decider", (it) => { + it.effect("creates an annotation with server timestamps", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.annotation.upsert", + commandId: CommandId.make("cmd-create"), + threadId: ThreadId.make("thread-1"), + body: "# Note", + }, + readModel: makeReadModel(), + }); + const first = Array.isArray(event) ? event[0] : event; + expect(first?.type).toBe("thread.annotation-upserted"); + if (first?.type === "thread.annotation-upserted") { + expect(first.payload.annotation.body).toBe("# Note"); + expect(first.payload.annotation.createdAt).toBe(first.payload.annotation.updatedAt); + expect(first.payload.annotation.resolvedAt).toBeNull(); + } + }), + ); + + it.effect("anchors from the projected marker without hydrated messages", () => + Effect.gen(function* () { + const readModel = makeReadModel(null, null); + const thread = readModel.threads[0]; + if (!thread) return; + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.annotation.upsert", + commandId: CommandId.make("cmd-projected-anchor"), + threadId: ThreadId.make("thread-1"), + body: "# Note", + }, + readModel: { + ...readModel, + threads: [ + { + ...thread, + latestUserMessageId: MessageId.make("message-projected"), + }, + ], + }, + }); + const first = Array.isArray(event) ? event[0] : event; + if (first?.type === "thread.annotation-upserted") { + expect(first.payload.annotation.anchorMessageId).toBe("message-projected"); + } + }), + ); + + it.effect("edits without changing created or resolved state", () => + Effect.gen(function* () { + const resolved = { ...existingAnnotation, resolvedAt: existingAnnotation.updatedAt }; + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.annotation.upsert", + commandId: CommandId.make("cmd-edit"), + threadId: ThreadId.make("thread-1"), + body: "Edited", + }, + readModel: makeReadModel(resolved), + }); + const first = Array.isArray(event) ? event[0] : event; + if (first?.type === "thread.annotation-upserted") { + expect(first.payload.annotation.createdAt).toBe(resolved.createdAt); + expect(first.payload.annotation.resolvedAt).toBe(resolved.resolvedAt); + expect(first.payload.annotation.anchorMessageId).toBe("message-new"); + } + }), + ); + + it.effect("keeps an existing anchor when a revert removes every user message", () => + Effect.gen(function* () { + const editedEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.annotation.upsert", + commandId: CommandId.make("cmd-edit-after-empty-revert"), + threadId: ThreadId.make("thread-1"), + body: "Edited after revert", + }, + readModel: makeReadModel(existingAnnotation, null), + }); + const edited = Array.isArray(editedEvent) ? editedEvent[0] : editedEvent; + expect(edited?.type).toBe("thread.annotation-upserted"); + if (edited?.type !== "thread.annotation-upserted") return; + expect(edited.payload.annotation.anchorMessageId).toBe(existingAnnotation.anchorMessageId); + + const resolvedEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.annotation.resolve", + commandId: CommandId.make("cmd-resolve-after-empty-revert"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(edited.payload.annotation, null), + }); + const resolved = Array.isArray(resolvedEvent) ? resolvedEvent[0] : resolvedEvent; + expect(resolved?.type).toBe("thread.annotation-resolved"); + if (resolved?.type !== "thread.annotation-resolved") return; + expect(resolved.payload.annotation.anchorMessageId).toBe(existingAnnotation.anchorMessageId); + + const reopenedEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.annotation.reopen", + commandId: CommandId.make("cmd-reopen-after-empty-revert"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(resolved.payload.annotation, null), + }); + const reopened = Array.isArray(reopenedEvent) ? reopenedEvent[0] : reopenedEvent; + expect(reopened?.type).toBe("thread.annotation-reopened"); + if (reopened?.type === "thread.annotation-reopened") { + expect(reopened.payload.annotation.anchorMessageId).toBe( + existingAnnotation.anchorMessageId, + ); + } + }), + ); + + it.effect("resolve and reopen move the anchor and timestamp without changing the body", () => + Effect.gen(function* () { + const resolvedEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.annotation.resolve", + commandId: CommandId.make("cmd-resolve"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(existingAnnotation, "message-resolved"), + }); + const resolved = Array.isArray(resolvedEvent) ? resolvedEvent[0] : resolvedEvent; + if (resolved?.type !== "thread.annotation-resolved") return; + expect(resolved.payload.annotation.body).toBe(existingAnnotation.body); + expect(resolved.payload.annotation.anchorMessageId).toBe("message-resolved"); + expect(resolved.payload.annotation.resolvedAt).toBe(resolved.payload.annotation.updatedAt); + + const reopenedEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.annotation.reopen", + commandId: CommandId.make("cmd-reopen"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(resolved.payload.annotation, "message-reopened"), + }); + const reopened = Array.isArray(reopenedEvent) ? reopenedEvent[0] : reopenedEvent; + if (reopened?.type === "thread.annotation-reopened") { + expect(reopened.payload.annotation.body).toBe(existingAnnotation.body); + expect(reopened.payload.annotation.anchorMessageId).toBe("message-reopened"); + expect(reopened.payload.annotation.resolvedAt).toBeNull(); + } + }), + ); + + it.effect("rejects resolving a missing annotation", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decideOrchestrationCommand({ + command: { + type: "thread.annotation.resolve", + commandId: CommandId.make("cmd-missing"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(), + }), + ); + expect(result._tag).toBe("Failure"); + }), + ); + + it.effect("rejects annotations on a thread without a user message", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decideOrchestrationCommand({ + command: { + type: "thread.annotation.upsert", + commandId: CommandId.make("cmd-no-anchor"), + threadId: ThreadId.make("thread-1"), + body: "Note", + }, + readModel: makeReadModel(null, null), + }), + ); + expect(result._tag).toBe("Failure"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.session.test.ts b/apps/server/src/orchestration/decider.session.test.ts new file mode 100644 index 000000000000..116081a26d40 --- /dev/null +++ b/apps/server/src/orchestration/decider.session.test.ts @@ -0,0 +1,128 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationSession, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const now = "2026-01-01T00:00:00.000Z"; +const threadId = ThreadId.make("thread-session-identity"); +const previousSession: OrchestrationSession = { + threadId, + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex-old"), + providerThreadId: "codex-native-old", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, +}; + +const readModel: OrchestrationReadModel = { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: threadId, + projectId: ProjectId.make("project-session-identity"), + title: "Session identity", + modelSelection: { instanceId: ProviderInstanceId.make("codex-old"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: previousSession, + }, + ], + updatedAt: now, +}; + +const decideSession = (session: OrchestrationSession) => + Effect.gen(function* () { + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make(`cmd-${session.providerName}-${session.status}`), + threadId, + session, + createdAt: now, + }, + readModel, + }); + const event = Array.isArray(decided) ? decided[0] : decided; + if (event?.type !== "thread.session-set") throw new Error("Expected thread.session-set"); + return event.payload.session; + }); + +const incoming = (overrides: Partial = {}): OrchestrationSession => ({ + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + ...overrides, +}); + +it.layer(NodeServices.layer)("thread session identity decider", (it) => { + it.effect("preserves only omitted identity fields on the same binding", () => + Effect.gen(function* () { + const sameBinding = yield* decideSession( + incoming({ providerInstanceId: ProviderInstanceId.make("codex-old") }), + ); + expect(sameBinding.providerThreadId).toBe("codex-native-old"); + + const missingInstance = yield* decideSession(incoming()); + expect(missingInstance.providerInstanceId).toBe("codex-old"); + expect(missingInstance.providerThreadId).toBe("codex-native-old"); + + const explicitlyCleared = yield* decideSession( + incoming({ + providerInstanceId: ProviderInstanceId.make("codex-old"), + providerThreadId: null, + }), + ); + expect(explicitlyCleared.providerThreadId).toBeNull(); + }), + ); + + it.effect("clears native identity when provider or provider instance changes", () => + Effect.gen(function* () { + const providerChanged = yield* decideSession( + incoming({ + providerName: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claude-new"), + }), + ); + expect(providerChanged.providerThreadId).toBeNull(); + + const instanceChanged = yield* decideSession( + incoming({ providerInstanceId: ProviderInstanceId.make("codex-new") }), + ); + expect(instanceChanged.providerThreadId).toBeNull(); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4f61955fa6aa..96016f702421 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -142,6 +142,22 @@ function threadHasQueuedTurnStart( ); } +function latestUserMessageId(thread: OrchestrationReadModel["threads"][number]) { + return ( + thread.latestUserMessageId ?? + thread.messages + .filter((message) => message.role === "user") + .toSorted( + (left, right) => + right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id), + )[0]?.id + ); +} + +function nextAnnotationAnchorMessageId(thread: OrchestrationReadModel["threads"][number]) { + return latestUserMessageId(thread) ?? thread.annotation?.anchorMessageId; +} + function withEventBase( input: Pick & { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; @@ -805,6 +821,121 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.annotation.upsert": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const anchorMessageId = nextAnnotationAnchorMessageId(thread); + if (anchorMessageId === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has no user message to anchor an annotation`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.annotation-upserted", + payload: { + threadId: command.threadId, + annotation: { + body: command.body, + anchorMessageId, + createdAt: thread.annotation?.createdAt ?? occurredAt, + updatedAt: occurredAt, + resolvedAt: thread.annotation?.resolvedAt ?? null, + }, + }, + }; + } + + case "thread.annotation.resolve": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + if (thread.annotation == null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has no annotation to resolve`, + }); + } + const anchorMessageId = nextAnnotationAnchorMessageId(thread); + if (anchorMessageId === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has no user message to anchor an annotation`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.annotation-resolved", + payload: { + threadId: command.threadId, + annotation: { + ...thread.annotation, + anchorMessageId, + updatedAt: occurredAt, + resolvedAt: occurredAt, + }, + }, + }; + } + + case "thread.annotation.reopen": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + if (thread.annotation == null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has no annotation to reopen`, + }); + } + const anchorMessageId = nextAnnotationAnchorMessageId(thread); + if (anchorMessageId === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has no user message to anchor an annotation`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.annotation-reopened", + payload: { + threadId: command.threadId, + annotation: { + ...thread.annotation, + anchorMessageId, + updatedAt: occurredAt, + resolvedAt: null, + }, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, @@ -953,7 +1084,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" detail: `Proposed plan '${sourceProposedPlan?.planId}' belongs to thread '${sourceThread.id}' in a different project.`, }); } - const userMessageEvent: Omit = { + const turnMessageEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -964,7 +1095,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, messageId: command.message.messageId, - role: "user", + role: command.message.role, text: command.message.text, attachments: command.message.attachments, turnId: null, @@ -980,7 +1111,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" occurredAt: command.createdAt, commandId: command.commandId, })), - causationEventId: userMessageEvent.eventId, + causationEventId: turnMessageEvent.eventId, type: "thread.turn-start-requested", payload: { threadId: command.threadId, @@ -992,6 +1123,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" runtimeMode: targetThread.runtimeMode, interactionMode: targetThread.interactionMode, ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), + ...(command.trackRequestCorrelation === true ? { trackRequestCorrelation: true } : {}), createdAt: command.createdAt, }, }; @@ -1033,7 +1165,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }, }); } - return [...lifecycleResetEvents, userMessageEvent, turnStartRequestedEvent]; + return [...lifecycleResetEvents, turnMessageEvent, turnStartRequestedEvent]; } case "thread.turn.interrupt": { @@ -1174,12 +1306,66 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.turn-request.resolve": { + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.turn-request-resolved", + payload: { + threadId: command.threadId, + messageId: command.messageId, + outcome: command.outcome, + }, + }; + } + + case "thread.turn-assistant.finalize": { + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.turn-assistant-finalized", + payload: { + threadId: command.threadId, + turnId: command.turnId, + finalizedAt: command.createdAt, + }, + }; + } + case "thread.session.set": { const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + const previousSession = thread.session; + const sameProvider = previousSession?.providerName === command.session.providerName; + const sameBinding = + sameProvider && + (command.session.providerInstanceId === undefined || + command.session.providerInstanceId === previousSession?.providerInstanceId); + const providerThreadIdWasSupplied = Object.hasOwn(command.session, "providerThreadId"); + const session = { + ...command.session, + ...(sameProvider && + command.session.providerInstanceId === undefined && + previousSession?.providerInstanceId !== undefined + ? { providerInstanceId: previousSession.providerInstanceId } + : {}), + providerThreadId: providerThreadIdWasSupplied + ? (command.session.providerThreadId ?? null) + : sameBinding + ? (previousSession?.providerThreadId ?? null) + : null, + }; const sessionSetEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", @@ -1191,7 +1377,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" type: "thread.session-set", payload: { threadId: command.threadId, - session: command.session, + session, }, }; // Only a session coming alive is activity worth waking a settled thread diff --git a/apps/server/src/orchestration/decider.turnRequestCorrelation.test.ts b/apps/server/src/orchestration/decider.turnRequestCorrelation.test.ts new file mode 100644 index 000000000000..52c93a36eac1 --- /dev/null +++ b/apps/server/src/orchestration/decider.turnRequestCorrelation.test.ts @@ -0,0 +1,73 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CommandId, + MessageId, + type OrchestrationEvent, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel } from "./projector.ts"; + +it.effect("resolves tracked requests without requiring the thread to still exist", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn-request.resolve", + commandId: CommandId.make("turn-request:event-1"), + threadId: ThreadId.make("deleted-thread"), + messageId: MessageId.make("message-1"), + outcome: { kind: "started", turnId: TurnId.make("turn-1") }, + createdAt: "2026-08-22T00:00:00.000Z", + }, + readModel: createEmptyReadModel("2026-08-22T00:00:00.000Z"), + }); + + const isResolved = "type" in event && event.type === "thread.turn-request-resolved"; + assert.strictEqual(isResolved, true); + if (isResolved) { + const resolved = event as Extract< + OrchestrationEvent, + { readonly type: "thread.turn-request-resolved" } + >; + assert.strictEqual(resolved.payload.threadId, "deleted-thread"); + assert.strictEqual(resolved.payload.messageId, "message-1"); + assert.deepStrictEqual(resolved.payload.outcome, { + kind: "started", + turnId: TurnId.make("turn-1"), + }); + } + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("records assistant finalization without requiring the thread to still exist", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn-assistant.finalize", + commandId: CommandId.make("turn-assistant-finalize:event-1"), + threadId: ThreadId.make("deleted-thread"), + turnId: TurnId.make("turn-1"), + createdAt: "2026-08-22T00:00:00.000Z", + }, + readModel: createEmptyReadModel("2026-08-22T00:00:00.000Z"), + }); + + const isFinalized = "type" in event && event.type === "thread.turn-assistant-finalized"; + assert.strictEqual(isFinalized, true); + if (isFinalized) { + const finalized = event as Extract< + OrchestrationEvent, + { readonly type: "thread.turn-assistant-finalized" } + >; + assert.deepStrictEqual(finalized.payload, { + threadId: "deleted-thread", + turnId: "turn-1", + finalizedAt: "2026-08-22T00:00:00.000Z", + }); + } + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/orchestration/http.test.ts b/apps/server/src/orchestration/http.test.ts new file mode 100644 index 000000000000..d7e15bb901a6 --- /dev/null +++ b/apps/server/src/orchestration/http.test.ts @@ -0,0 +1,96 @@ +import { + CommandId, + CorrelationId, + EventId, + MessageId, + type OrchestrationEvent, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { readThreadWaitUntilTerminal } from "./http.ts"; + +it.effect("ignores message deltas until a terminal wait event arrives", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-wait-wake-filter"); + const turnId = TurnId.make("turn-wait-wake-filter"); + const occurredAt = "2026-01-01T00:00:00.000Z"; + const events: ReadonlyArray = [ + { + sequence: 1, + type: "thread.message-sent", + eventId: EventId.make("evt-wait-token-delta"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt, + commandId: CommandId.make("cmd-wait-token-delta"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-wait-token-delta"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("assistant:wait-token-delta"), + role: "assistant", + text: "token", + turnId, + streaming: true, + createdAt: occurredAt, + updatedAt: occurredAt, + }, + }, + { + sequence: 2, + type: "thread.turn-assistant-finalized", + eventId: EventId.make("evt-wait-assistant-finalized"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt, + commandId: CommandId.make("cmd-wait-assistant-finalized"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-wait-assistant-finalized"), + metadata: {}, + payload: { threadId, turnId, finalizedAt: occurredAt }, + }, + ]; + const finalized = yield* Ref.make(false); + const reads = yield* Ref.make(0); + const eventQueue = yield* Queue.unbounded(); + yield* Queue.offerAll(eventQueue, events); + const eventStream = Stream.fromQueue(eventQueue).pipe( + Stream.tap((event) => + event.type === "thread.turn-assistant-finalized" ? Ref.set(finalized, true) : Effect.void, + ), + ); + const readState = Effect.gen(function* () { + yield* Ref.update(reads, (count) => count + 1); + return (yield* Ref.get(finalized)) + ? ({ + kind: "terminal", + state: "completed", + turnId, + response: "complete", + } as const) + : ({ kind: "pending" } as const); + }); + + const result = yield* readThreadWaitUntilTerminal( + threadId, + { kind: "pending" }, + eventStream, + readState, + ); + + assert.deepEqual(result, { + kind: "terminal", + state: "completed", + turnId, + response: "complete", + }); + assert.equal(yield* Ref.get(reads), 1); + }), +); diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 04d54ea8effb..41f137352bf6 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -2,9 +2,12 @@ import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, EnvironmentHttpApi, + type OrchestrationEvent, + type ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { projectThreadDetailSnapshot } from "./ActivityPayloadProjection.ts"; @@ -18,6 +21,41 @@ import { } from "../auth/http.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import type { TurnRequestWaitState } from "./Services/OrchestrationEngine.ts"; +import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; +import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; + +const THREAD_WAIT_RESPONSE_MAX_CHARS = 64_000; + +export const readThreadWaitUntilTerminal = ( + threadId: ThreadId, + latest: TurnRequestWaitState, + events: Stream.Stream, + readState: Effect.Effect, +): Effect.Effect => { + const changes = events.pipe( + Stream.filter( + (event) => + event.aggregateKind === "thread" && + event.aggregateId === threadId && + (event.type === "thread.turn-request-resolved" || + event.type === "thread.turn-assistant-finalized" || + event.type === "thread.turn-interrupt-requested" || + event.type === "thread.session-set" || + event.type === "thread.deleted"), + ), + ); + const readUntilTerminal = ( + state: TurnRequestWaitState, + ): Effect.Effect => + state.kind === "pending" + ? changes.pipe( + Stream.runHead, + Effect.flatMap(() => readState.pipe(Effect.flatMap(readUntilTerminal))), + ) + : Effect.succeed(state); + return readUntilTerminal(latest); +}; export const orchestrationHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, @@ -25,6 +63,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngineService; + const serverEnvironment = yield* ServerEnvironment; return handlers .handle( @@ -104,6 +143,70 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( ), ); }), + ) + .handle( + "waitThread", + Effect.fn("environment.orchestration.waitThread")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthOrchestrationReadScope); + const environmentId = yield* serverEnvironment.getEnvironmentId; + const handle = args.payload.waitHandle; + if (handle.environmentId !== environmentId) { + return yield* failEnvironmentInvalidRequest("wrong_environment"); + } + + const events = yield* orchestrationEngine.subscribeDomainEvents; + const latest = yield* orchestrationEngine + .getTurnRequestWaitState(handle) + .pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), + ), + ); + const waited = yield* readThreadWaitUntilTerminal( + handle.threadId, + latest, + events, + orchestrationEngine.getTurnRequestWaitState(handle), + ).pipe( + Effect.timeoutOption(`${args.payload.timeoutMs} millis`), + Effect.catch((cause) => + failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), + ), + ); + if (Option.isNone(waited)) { + return { kind: "timed-out" as const, waitHandle: handle }; + } + const state = waited.value; + if (state.kind === "thread-not-found") { + return yield* failEnvironmentNotFound("thread_not_found"); + } + if (state.kind === "correlation-not-found") { + return yield* failEnvironmentNotFound("correlation_not_found"); + } + if (state.kind === "terminal" && state.state === "completed") { + const response = state.response.slice(-THREAD_WAIT_RESPONSE_MAX_CHARS); + return { + kind: "completed" as const, + environmentId, + threadId: handle.threadId, + messageId: handle.messageId, + turnId: state.turnId, + response, + responseTruncated: response.length !== state.response.length, + }; + } + if (state.kind !== "terminal") { + return { kind: "timed-out" as const, waitHandle: handle }; + } + return { + kind: state.state, + environmentId, + threadId: handle.threadId, + messageId: handle.messageId, + ...(state.turnId === undefined ? {} : { turnId: state.turnId }), + }; + }), ); }), ); diff --git a/apps/server/src/orchestration/projector.annotation.test.ts b/apps/server/src/orchestration/projector.annotation.test.ts new file mode 100644 index 000000000000..85ee5f1c6465 --- /dev/null +++ b/apps/server/src/orchestration/projector.annotation.test.ts @@ -0,0 +1,86 @@ +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const THREAD_UPDATED_AT = "2026-01-01T00:00:00.000Z"; +const ANNOTATION_UPDATED_AT = "2026-01-01T00:05:00.000Z"; + +function event(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; + readonly occurredAt?: string; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: input.occurredAt ?? THREAD_UPDATED_AT, + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +it.effect("projects annotation changes without changing thread recency", () => + Effect.gen(function* () { + const created = yield* projectEvent( + createEmptyReadModel(THREAD_UPDATED_AT), + event({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: THREAD_UPDATED_AT, + updatedAt: THREAD_UPDATED_AT, + }, + }), + ); + + const annotated = yield* projectEvent( + created, + event({ + sequence: 2, + type: "thread.annotation-upserted", + occurredAt: ANNOTATION_UPDATED_AT, + payload: { + threadId: ThreadId.make("thread-1"), + annotation: { + body: "# Note", + anchorMessageId: MessageId.make("message-1"), + createdAt: ANNOTATION_UPDATED_AT, + updatedAt: ANNOTATION_UPDATED_AT, + resolvedAt: null, + }, + }, + }), + ); + + expect(annotated.threads[0]?.annotation?.body).toBe("# Note"); + expect(annotated.threads[0]?.updatedAt).toBe(THREAD_UPDATED_AT); + }), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 9c07a312023c..9a2f6c55a75d 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -75,6 +75,7 @@ describe("orchestration projector", () => { expect(next.threads).toEqual([ { id: "thread-1", + annotation: null, projectId: "project-1", title: "demo", modelSelection: { @@ -701,6 +702,7 @@ describe("orchestration projector", () => { ).toEqual([{ id: "activity-1", turnId: "turn-1" }]); expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]); expect(thread?.latestTurn?.turnId).toBe("turn-1"); + expect(thread?.latestUserMessageId).toBe("user-msg-1"); }); it("does not fallback-retain messages tied to removed turn IDs", async () => { @@ -854,6 +856,7 @@ describe("orchestration projector", () => { turnId: message.turnId, })), ).toEqual([{ id: "assistant-keep", role: "assistant", turnId: "turn-1" }]); + expect(thread?.latestUserMessageId).toBeNull(); }); it("caps message and checkpoint retention for long-lived threads", async () => { diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index f486dcb2bcbc..743c11138cf5 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,4 +1,9 @@ -import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; +import type { + MessageId, + OrchestrationEvent, + OrchestrationReadModel, + ThreadId, +} from "@t3tools/contracts"; import { OrchestrationCheckpointSummary, OrchestrationMessage, @@ -25,6 +30,7 @@ import { ThreadSettledPayload, ThreadPinnedPayload, ThreadPinReorderedPayload, + ThreadAnnotationChangedPayload, ThreadSnoozedPayload, ThreadUnpinnedPayload, ThreadUnarchivedPayload, @@ -150,6 +156,17 @@ function retainThreadMessagesAfterRevert( return messages.filter((message) => retainedMessageIds.has(message.id)); } +function latestUserMessageId(messages: ReadonlyArray): MessageId | null { + return ( + messages + .filter((message) => message.role === "user") + .toSorted( + (left, right) => + right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id), + )[0]?.id ?? null + ); +} + function retainThreadActivitiesAfterRevert( activities: ReadonlyArray, retainedTurnIds: ReadonlySet, @@ -305,6 +322,7 @@ export function projectEvent( settledAt: null, snoozedUntil: null, snoozedAt: null, + annotation: null, deletedAt: null, messages: [], activities: [], @@ -442,6 +460,23 @@ export function projectEvent( })), ); + case "thread.annotation-upserted": + case "thread.annotation-resolved": + case "thread.annotation-reopened": + return decodeForEvent( + ThreadAnnotationChangedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + annotation: payload.annotation, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ @@ -544,6 +579,7 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { messages: cappedMessages, + latestUserMessageId: latestUserMessageId(cappedMessages), updatedAt: event.occurredAt, }), }; @@ -761,6 +797,7 @@ export function projectEvent( threads: updateThread(nextBase.threads, payload.threadId, { checkpoints, messages, + latestUserMessageId: latestUserMessageId(messages), proposedPlans, activities, latestTurn, diff --git a/apps/server/src/orchestration/runtimeLayer.ts b/apps/server/src/orchestration/runtimeLayer.ts index 779042e2f685..1d42179fa704 100644 --- a/apps/server/src/orchestration/runtimeLayer.ts +++ b/apps/server/src/orchestration/runtimeLayer.ts @@ -6,6 +6,7 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; +import * as ThreadActionResume from "./ThreadActionResume.ts"; import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( @@ -27,6 +28,7 @@ export const OrchestrationInfrastructureLayerLive = Layer.mergeAll( // ingestion. ).pipe( Layer.provideMerge(ThreadBackgroundLiveness.layer), + Layer.provideMerge(ThreadActionResume.layer), Layer.provideMerge(ThreadPlanProgress.layer), ); diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 03edaec77d63..eb046ae9254a 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -134,5 +134,6 @@ export type OrchestrationCommandReceiptRepositoryError = export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError; +export type UpdateDrainRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index bebd8fbb4a7d..128430c13eb0 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -1,8 +1,15 @@ -import { ProjectId, ThreadId, ProviderInstanceId } from "@t3tools/contracts"; +import { + MessageId, + ProjectId, + ThreadAnnotation, + ThreadId, + ProviderInstanceId, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "./Sqlite.ts"; @@ -11,6 +18,10 @@ import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts"; import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts"; import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts"; +const decodeThreadAnnotationJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(ThreadAnnotation), +); + const projectionRepositoriesLayer = it.layer( Layer.mergeAll( ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), @@ -97,6 +108,14 @@ projectionRepositoriesLayer("Projection repositories", (it) => { snoozedUntil: null, snoozedAt: null, pinnedAt: null, + annotation: { + body: "# Follow up", + anchorMessageId: MessageId.make("message-1"), + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-24T00:01:00.000Z", + resolvedAt: null, + }, + latestUserMessageId: MessageId.make("message-1"), latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -106,8 +125,11 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const rows = yield* sql<{ readonly modelSelection: string | null; + readonly annotation: string | null; }>` - SELECT model_selection_json AS "modelSelection" + SELECT + model_selection_json AS "modelSelection", + annotation_json AS "annotation" FROM projection_threads WHERE thread_id = 'thread-null-options' `; @@ -124,6 +146,14 @@ projectionRepositoriesLayer("Projection repositories", (it) => { model: "claude-opus-4-6", }), ); + const annotation = yield* decodeThreadAnnotationJson(row.annotation); + assert.deepStrictEqual(annotation, { + body: "# Follow up", + anchorMessageId: MessageId.make("message-1"), + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-24T00:01:00.000Z", + resolvedAt: null, + }); const persisted = yield* threads.getById({ threadId: ThreadId.make("thread-null-options"), @@ -132,6 +162,11 @@ projectionRepositoriesLayer("Projection repositories", (it) => { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", }); + assert.strictEqual(Option.getOrNull(persisted)?.annotation?.body, "# Follow up"); + assert.strictEqual( + Option.getOrNull(persisted)?.latestUserMessageId, + MessageId.make("message-1"), + ); }), ); @@ -160,6 +195,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { snoozedUntil: "2026-03-26T09:00:00.000Z", snoozedAt: "2026-03-25T00:00:00.000Z", pinnedAt: "2026-03-25T00:00:00.000Z", + annotation: null, + latestUserMessageId: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts new file mode 100644 index 000000000000..b1463d71ff88 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts @@ -0,0 +1,52 @@ +import { assert, it } from "@effect/vitest"; +import { EventId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ProjectionThreadActivityRepository } from "../Services/ProjectionThreadActivities.ts"; +import { ProjectionThreadActivityRepositoryLive } from "./ProjectionThreadActivities.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; + +const layer = it.layer( + ProjectionThreadActivityRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +layer("ProjectionThreadActivityRepository", (it) => { + it.effect("lists activity kinds in authoritative event sequence order", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadActivityRepository; + const threadId = ThreadId.make("thread-action-resume-order"); + const createdAt = "2026-08-17T00:00:00.000Z"; + + yield* repository.upsert({ + activityId: EventId.make("action-resume:run:succeeded:delivered"), + threadId, + turnId: null, + tone: "info", + kind: "action.resume.lifecycle", + summary: "delivered", + payload: { delivery: "delivered" }, + sequence: 3, + createdAt, + }); + yield* repository.upsert({ + activityId: EventId.make("action-resume:run:succeeded:pending"), + threadId, + turnId: null, + tone: "info", + kind: "action.resume.lifecycle", + summary: "pending", + payload: { delivery: "pending" }, + sequence: 2, + createdAt, + }); + + const rows = yield* repository.listByKind({ kind: "action.resume.lifecycle" }); + + assert.deepEqual( + rows.map((row) => row.summary), + ["pending", "delivered"], + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index 2f4815f96545..d62cde2de7bd 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -10,6 +10,7 @@ import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionThreadActivitiesInput, + ListProjectionThreadActivitiesByKindInput, ListProjectionThreadActivitiesInput, ProjectionThreadActivity, ProjectionThreadActivityRepository, @@ -106,6 +107,44 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { `, }); + const listProjectionThreadActivityRowsByKind = SqlSchema.findAll({ + Request: ListProjectionThreadActivitiesByKindInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ kind }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE kind = ${kind} + ORDER BY + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + + const normalizeRows = (rows: ReadonlyArray) => + rows.map((row) => ({ + activityId: row.activityId, + threadId: row.threadId, + turnId: row.turnId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + ...(row.sequence !== null && row.sequence !== undefined ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + })); + const upsert: ProjectionThreadActivityRepositoryShape["upsert"] = (row) => upsertProjectionThreadActivityRow(row).pipe( Effect.mapError( @@ -124,19 +163,18 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { "ProjectionThreadActivityRepository.listByThreadId:decodeRows", ), ), - Effect.map((rows) => - rows.map((row) => ({ - activityId: row.activityId, - threadId: row.threadId, - turnId: row.turnId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - ...(row.sequence !== null ? { sequence: row.sequence } : {}), - createdAt: row.createdAt, - })), + Effect.map(normalizeRows), + ); + + const listByKind: ProjectionThreadActivityRepositoryShape["listByKind"] = (input) => + listProjectionThreadActivityRowsByKind(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.listByKind:query", + "ProjectionThreadActivityRepository.listByKind:decodeRows", + ), ), + Effect.map(normalizeRows), ); const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => @@ -149,6 +187,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { return { upsert, listByThreadId, + listByKind, deleteByThreadId, } satisfies ProjectionThreadActivityRepositoryShape; }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts index dcb750983a00..8e864ff7079a 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts @@ -25,6 +25,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { status, provider_name, provider_instance_id, + provider_thread_id, runtime_mode, active_turn_id, last_error, @@ -35,6 +36,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { ${row.status}, ${row.providerName}, ${row.providerInstanceId}, + ${row.providerThreadId ?? null}, ${row.runtimeMode}, ${row.activeTurnId}, ${row.lastError}, @@ -45,6 +47,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { status = excluded.status, provider_name = excluded.provider_name, provider_instance_id = excluded.provider_instance_id, + provider_thread_id = excluded.provider_thread_id, runtime_mode = excluded.runtime_mode, active_turn_id = excluded.active_turn_id, last_error = excluded.last_error, @@ -62,6 +65,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { status, provider_name AS "providerName", provider_instance_id AS "providerInstanceId", + provider_thread_id AS "providerThreadId", runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index b7d8ae137473..ca72c8537337 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -14,11 +14,12 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@t3tools/contracts"; +import { ModelSelection, ThreadAnnotation } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + annotation: Schema.NullOr(Schema.fromJsonString(ThreadAnnotation)), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -51,6 +52,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pin_order_key, title_regeneration_request_id, title_regeneration_started_at, + annotation_json, + latest_user_message_id, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -78,6 +81,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.pinOrderKey ?? null}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, + ${row.annotation === null ? null : JSON.stringify(row.annotation)}, + ${row.latestUserMessageId}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -105,6 +110,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pin_order_key = excluded.pin_order_key, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, + annotation_json = excluded.annotation_json, + latest_user_message_id = excluded.latest_user_message_id, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -139,6 +146,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + annotation_json AS "annotation", + latest_user_message_id AS "latestUserMessageId", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -175,6 +184,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", + annotation_json AS "annotation", + latest_user_message_id AS "latestUserMessageId", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Layers/ProjectionTurnRequestCorrelations.ts b/apps/server/src/persistence/Layers/ProjectionTurnRequestCorrelations.ts new file mode 100644 index 000000000000..1be528036dc5 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionTurnRequestCorrelations.ts @@ -0,0 +1,81 @@ +import { MessageId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import * as Schema from "effect/Schema"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + ProjectionTurnRequestCorrelation, + ProjectionTurnRequestCorrelationRepository, + type ProjectionTurnRequestCorrelationRepositoryShape, +} from "../Services/ProjectionTurnRequestCorrelations.ts"; + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const getRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ threadId: ThreadId, messageId: MessageId }), + Result: ProjectionTurnRequestCorrelation, + execute: ({ threadId, messageId }) => sql` + SELECT thread_id AS "threadId", message_id AS "messageId", turn_id AS "turnId", + state, requested_at AS "requestedAt", resolved_at AS "resolvedAt" + FROM projection_turn_request_correlations + WHERE thread_id = ${threadId} AND message_id = ${messageId} + LIMIT 1 + `, + }); + + const insertPending: ProjectionTurnRequestCorrelationRepositoryShape["insertPending"] = (row) => + sql` + INSERT INTO projection_turn_request_correlations + (thread_id, message_id, turn_id, state, requested_at, resolved_at) + VALUES (${row.threadId}, ${row.messageId}, NULL, 'pending', ${row.requestedAt}, NULL) + ON CONFLICT (thread_id, message_id) DO NOTHING + `.pipe(Effect.asVoid, Effect.mapError(toPersistenceSqlError("turnCorrelation.insertPending"))); + + const resolve: ProjectionTurnRequestCorrelationRepositoryShape["resolve"] = (row) => + sql` + UPDATE projection_turn_request_correlations + SET turn_id = ${row.turnId}, state = ${row.state}, resolved_at = ${row.resolvedAt} + WHERE thread_id = ${row.threadId} AND message_id = ${row.messageId} AND state = 'pending' + `.pipe(Effect.asVoid, Effect.mapError(toPersistenceSqlError("turnCorrelation.resolve"))); + + const get: ProjectionTurnRequestCorrelationRepositoryShape["get"] = (input) => + getRow(input).pipe(Effect.mapError(toPersistenceSqlError("turnCorrelation.get"))); + + const markAssistantFinalized: ProjectionTurnRequestCorrelationRepositoryShape["markAssistantFinalized"] = + (input) => + sql` + INSERT INTO projection_turn_assistant_finalizations (thread_id, turn_id, finalized_at) + VALUES (${input.threadId}, ${input.turnId}, ${input.finalizedAt}) + ON CONFLICT (thread_id, turn_id) DO NOTHING + `.pipe( + Effect.asVoid, + Effect.mapError(toPersistenceSqlError("turnCorrelation.markAssistantFinalized")), + ); + + const deleteByThreadId: ProjectionTurnRequestCorrelationRepositoryShape["deleteByThreadId"] = ({ + threadId, + }) => + sql + .withTransaction( + sql`DELETE FROM projection_turn_request_correlations WHERE thread_id = ${threadId}`.pipe( + Effect.flatMap( + () => + sql`DELETE FROM projection_turn_assistant_finalizations WHERE thread_id = ${threadId}`, + ), + ), + ) + .pipe( + Effect.asVoid, + Effect.mapError(toPersistenceSqlError("turnCorrelation.deleteByThreadId")), + ); + + return { insertPending, resolve, markAssistantFinalized, get, deleteByThreadId }; +}); + +export const ProjectionTurnRequestCorrelationRepositoryLive = Layer.effect( + ProjectionTurnRequestCorrelationRepository, + make, +); diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index ec1ffdefac0f..d633bc0b5f89 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -10,6 +10,10 @@ import { ServerConfig } from "../../config.ts"; type RuntimeSqliteLayerConfig = { readonly filename: string; + readonly readonly?: boolean; + readonly create?: boolean; + readonly readwrite?: boolean; + readonly disableWAL?: boolean; readonly spanAttributes?: Record; }; @@ -60,6 +64,23 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")( ); }, Layer.unwrap); +export const makeSqlitePersistenceReadOnly = Effect.fn("makeSqlitePersistenceReadOnly")(function* ( + dbPath: string, +) { + const path = yield* Path.Path; + return makeRuntimeSqliteLayer({ + filename: dbPath, + readonly: true, + readwrite: false, + create: false, + disableWAL: true, + spanAttributes: { + "db.name": path.basename(dbPath), + "service.name": "t3-server", + }, + }); +}, Layer.unwrap); + export const SqlitePersistenceMemory = Layer.provideMerge( setup, makeRuntimeSqliteLayer({ filename: ":memory:" }), @@ -71,3 +92,10 @@ export const layerConfig = Layer.unwrap( return makeSqlitePersistenceLive(dbPath); }), ); + +export const layerReadOnlyConfig = Layer.unwrap( + Effect.gen(function* () { + const { dbPath } = yield* ServerConfig; + return makeSqlitePersistenceReadOnly(dbPath); + }), +); diff --git a/apps/server/src/persistence/Layers/UpdateDrainRepository.ts b/apps/server/src/persistence/Layers/UpdateDrainRepository.ts new file mode 100644 index 000000000000..a0000f8af093 --- /dev/null +++ b/apps/server/src/persistence/Layers/UpdateDrainRepository.ts @@ -0,0 +1,209 @@ +import { + CommandId, + IsoDateTime, + NonNegativeInt, + UpdateDrainCommandReceipt, + UpdateDrainEvent, + UpdateDrainIntentStatus, + UpdateDrainRequestId, + UpdateDrainTargetVersion, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, + type UpdateDrainRepositoryError, +} from "../Errors.ts"; +import { + UpdateDrainRepository, + type UpdateDrainRepositoryShape, +} from "../Services/UpdateDrainRepository.ts"; + +const UpdateDrainEventRow = Schema.Struct({ + sequence: NonNegativeInt, + eventId: Schema.String, + type: Schema.String, + commandId: CommandId, + occurredAt: IsoDateTime, + requestId: UpdateDrainRequestId, + targetVersion: UpdateDrainTargetVersion, + status: UpdateDrainIntentStatus, +}); + +const AppendEventRequest = Schema.Struct({ + eventId: Schema.String, + type: Schema.String, + commandId: CommandId, + occurredAt: IsoDateTime, + requestId: UpdateDrainRequestId, + targetVersion: UpdateDrainTargetVersion, + status: UpdateDrainIntentStatus, +}); + +const CommandIdInput = Schema.Struct({ commandId: CommandId }); +const decodeEvent = Schema.decodeUnknownEffect(UpdateDrainEvent); + +function repositoryError(operation: string) { + return (cause: unknown): UpdateDrainRepositoryError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(`${operation}:decode`)(cause) + : toPersistenceSqlError(`${operation}:query`)(cause); +} + +const makeUpdateDrainRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const readEventRows = SqlSchema.findAll({ + Request: Schema.Struct({}), + Result: UpdateDrainEventRow, + execute: () => sql` + SELECT + sequence, + event_id AS "eventId", + event_type AS "type", + command_id AS "commandId", + occurred_at AS "occurredAt", + request_id AS "requestId", + target_version AS "targetVersion", + status + FROM update_drain_events + ORDER BY sequence ASC + `, + }); + + const findReceipt = SqlSchema.findOneOption({ + Request: CommandIdInput, + Result: UpdateDrainCommandReceipt, + execute: ({ commandId }) => sql` + SELECT + command_id AS "commandId", + request_id AS "requestId", + command_type AS "commandType", + target_version AS "targetVersion", + accepted_at AS "acceptedAt", + result_sequence AS "resultSequence", + status, + error_reason AS "errorReason", + error + FROM update_drain_command_receipts + WHERE command_id = ${commandId} + `, + }); + + const appendEvent = SqlSchema.findOne({ + Request: AppendEventRequest, + Result: UpdateDrainEventRow, + execute: (event) => sql` + INSERT INTO update_drain_events ( + event_id, + event_type, + command_id, + occurred_at, + request_id, + target_version, + status + ) + VALUES ( + ${event.eventId}, + ${event.type}, + ${event.commandId}, + ${event.occurredAt}, + ${event.requestId}, + ${event.targetVersion}, + ${event.status} + ) + RETURNING + sequence, + event_id AS "eventId", + event_type AS "type", + command_id AS "commandId", + occurred_at AS "occurredAt", + request_id AS "requestId", + target_version AS "targetVersion", + status + `, + }); + + const insertReceipt = SqlSchema.void({ + Request: UpdateDrainCommandReceipt, + execute: (receipt) => sql` + INSERT INTO update_drain_command_receipts ( + command_id, + command_type, + request_id, + target_version, + accepted_at, + result_sequence, + status, + error_reason, + error + ) + VALUES ( + ${receipt.commandId}, + ${receipt.commandType}, + ${receipt.requestId}, + ${receipt.targetVersion}, + ${receipt.acceptedAt}, + ${receipt.resultSequence}, + ${receipt.status}, + ${receipt.errorReason}, + ${receipt.error} + ) + `, + }); + + const readAllEvents: UpdateDrainRepositoryShape["readAllEvents"] = () => + readEventRows({}).pipe( + Effect.mapError(repositoryError("UpdateDrainRepository.readAllEvents")), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeEvent(row).pipe( + Effect.mapError(toPersistenceDecodeError("UpdateDrainRepository.readAllEvents:event")), + ), + ), + ), + ); + + const getReceipt: UpdateDrainRepositoryShape["getReceipt"] = (commandId) => + findReceipt({ commandId }).pipe( + Effect.mapError(repositoryError("UpdateDrainRepository.getReceipt")), + ); + + const commitAccepted: UpdateDrainRepositoryShape["commitAccepted"] = (input) => + sql + .withTransaction( + Effect.gen(function* () { + const eventRow = yield* appendEvent(input.event); + const event = yield* decodeEvent(eventRow); + const receipt = { + ...input.receipt, + resultSequence: event.sequence, + }; + yield* insertReceipt(receipt); + return { event, receipt }; + }), + ) + .pipe(Effect.mapError(repositoryError("UpdateDrainRepository.commitAccepted"))); + + const saveRejected: UpdateDrainRepositoryShape["saveRejected"] = (receipt) => + insertReceipt(receipt).pipe( + Effect.mapError(repositoryError("UpdateDrainRepository.saveRejected")), + ); + + return { + readAllEvents, + getReceipt, + commitAccepted, + saveRejected, + } satisfies UpdateDrainRepositoryShape; +}); + +export const UpdateDrainRepositoryLive = Layer.effect( + UpdateDrainRepository, + makeUpdateDrainRepository, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 170cb3992279..2ba127a0bd72 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -54,6 +54,9 @@ import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; +import Migration0042 from "./Migrations/042_ProjectionThreadAnnotation.ts"; +import Migration0043 from "./Migrations/043_UpdateDrain.ts"; +import Migration0044 from "./Migrations/044_ProjectionTurnRequestCorrelations.ts"; /** * Migration loader with all migrations defined inline. @@ -107,6 +110,9 @@ export const migrationEntries = [ [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], [41, "AuthSessionClientConnection", Migration0041], + [42, "ProjectionThreadAnnotation", Migration0042], + [43, "UpdateDrain", Migration0043], + [44, "ProjectionTurnRequestCorrelations", Migration0044], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadAnnotation.test.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadAnnotation.test.ts new file mode 100644 index 000000000000..273784b6149b --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadAnnotation.test.ts @@ -0,0 +1,93 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("042_ProjectionThreadAnnotation", (it) => { + it.effect("adds annotation and latest user marker fields to thread projections", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 40 }); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + created_at, + updated_at + ) + VALUES ( + 'thread-1', + 'project-1', + 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + '2026-02-24T00:00:00.000Z', + '2026-02-24T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + role, + text, + is_streaming, + created_at, + updated_at + ) + VALUES + ( + 'message-user-1', + 'thread-1', + 'user', + 'First', + 0, + '2026-02-24T00:01:00.000Z', + '2026-02-24T00:01:00.000Z' + ), + ( + 'message-user-2', + 'thread-1', + 'user', + 'Second', + 0, + '2026-02-24T00:01:00.000Z', + '2026-02-24T00:01:00.000Z' + ) + `; + yield* runMigrations({ toMigrationInclusive: 42 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(projection_threads) + `; + const annotationJson = columns.find((column) => column.name === "annotation_json"); + const latestUserMessageId = columns.find( + (column) => column.name === "latest_user_message_id", + ); + + assert.equal(annotationJson?.name, "annotation_json"); + assert.equal(annotationJson?.notnull, 0); + assert.equal(latestUserMessageId?.name, "latest_user_message_id"); + assert.equal(latestUserMessageId?.notnull, 0); + + const rows = yield* sql<{ readonly latestUserMessageId: string | null }>` + SELECT latest_user_message_id AS "latestUserMessageId" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.equal(rows[0]?.latestUserMessageId, "message-user-2"); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadAnnotation.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadAnnotation.ts new file mode 100644 index 000000000000..6cbd339335a4 --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadAnnotation.ts @@ -0,0 +1,35 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "annotation_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN annotation_json TEXT + `; + } + + if (!columns.some((column) => column.name === "latest_user_message_id")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN latest_user_message_id TEXT + `; + } + + yield* sql` + UPDATE projection_threads + SET latest_user_message_id = ( + SELECT messages.message_id + FROM projection_thread_messages AS messages + WHERE messages.thread_id = projection_threads.thread_id + AND messages.role = 'user' + ORDER BY messages.created_at DESC, messages.message_id DESC + LIMIT 1 + ) + `; +}); diff --git a/apps/server/src/persistence/Migrations/043_UpdateDrain.test.ts b/apps/server/src/persistence/Migrations/043_UpdateDrain.test.ts new file mode 100644 index 000000000000..e7acd7708b57 --- /dev/null +++ b/apps/server/src/persistence/Migrations/043_UpdateDrain.test.ts @@ -0,0 +1,44 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("043_UpdateDrain", (it) => { + it.effect("creates a narrow event stream and durable command receipts", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 42 }); + yield* runMigrations({ toMigrationInclusive: 43 }); + + const eventColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(update_drain_events) + `; + const receiptColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(update_drain_command_receipts) + `; + + assert.deepStrictEqual( + eventColumns.map((column) => column.name), + [ + "sequence", + "event_id", + "event_type", + "command_id", + "occurred_at", + "request_id", + "target_version", + "status", + ], + ); + assert.ok(receiptColumns.some((column) => column.name === "command_id")); + assert.ok(!eventColumns.some((column) => column.name.includes("blocker"))); + assert.ok(!eventColumns.some((column) => column.name.includes("quiet"))); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/043_UpdateDrain.ts b/apps/server/src/persistence/Migrations/043_UpdateDrain.ts new file mode 100644 index 000000000000..f2a34d325ab2 --- /dev/null +++ b/apps/server/src/persistence/Migrations/043_UpdateDrain.ts @@ -0,0 +1,33 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS update_drain_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL CHECK (event_type IN ('update-drain.started', 'update-drain.cancelled')), + command_id TEXT NOT NULL UNIQUE, + occurred_at TEXT NOT NULL, + request_id TEXT NOT NULL, + target_version TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('draining', 'cancelled')) + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS update_drain_command_receipts ( + command_id TEXT PRIMARY KEY, + command_type TEXT NOT NULL CHECK (command_type IN ('update-drain.start', 'update-drain.cancel')), + request_id TEXT NOT NULL, + target_version TEXT, + accepted_at TEXT NOT NULL, + result_sequence INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('accepted', 'rejected')), + error_reason TEXT, + error TEXT + ) + `; +}); diff --git a/apps/server/src/persistence/Migrations/044_ProjectionTurnRequestCorrelations.test.ts b/apps/server/src/persistence/Migrations/044_ProjectionTurnRequestCorrelations.test.ts new file mode 100644 index 000000000000..081abb0a772b --- /dev/null +++ b/apps/server/src/persistence/Migrations/044_ProjectionTurnRequestCorrelations.test.ts @@ -0,0 +1,76 @@ +import { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ProjectionTurnRequestCorrelationRepositoryLive } from "../Layers/ProjectionTurnRequestCorrelations.ts"; +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import { ProjectionTurnRequestCorrelationRepository } from "../Services/ProjectionTurnRequestCorrelations.ts"; + +const layer = it.layer( + ProjectionTurnRequestCorrelationRepositoryLive.pipe( + Layer.provideMerge(NodeSqliteClient.layerMemory()), + ), +); + +layer("044_ProjectionTurnRequestCorrelations", (it) => { + it.effect("inserts once, resolves once, and deletes by owning thread", () => + Effect.gen(function* () { + yield* runMigrations(); + const repository = yield* ProjectionTurnRequestCorrelationRepository; + const sql = yield* SqlClient.SqlClient; + const key = { threadId: ThreadId.make("thread-1"), messageId: MessageId.make("message-1") }; + yield* repository.insertPending({ ...key, requestedAt: "2026-08-22T00:00:00.000Z" }); + yield* repository.insertPending({ ...key, requestedAt: "2026-08-22T00:00:01.000Z" }); + yield* repository.resolve({ + ...key, + turnId: TurnId.make("turn-1"), + state: "started", + resolvedAt: "2026-08-22T00:00:02.000Z", + }); + yield* repository.resolve({ + ...key, + turnId: null, + state: "error", + resolvedAt: "2026-08-22T00:00:03.000Z", + }); + yield* repository.markAssistantFinalized({ + threadId: key.threadId, + turnId: TurnId.make("turn-1"), + finalizedAt: "2026-08-22T00:00:04.000Z", + }); + yield* repository.markAssistantFinalized({ + threadId: key.threadId, + turnId: TurnId.make("turn-1"), + finalizedAt: "2026-08-22T00:00:05.000Z", + }); + const resolved = yield* repository.get(key); + assert.strictEqual(resolved._tag, "Some"); + if (resolved._tag === "Some") { + assert.strictEqual(resolved.value.state, "started"); + assert.strictEqual(resolved.value.turnId, "turn-1"); + assert.strictEqual(resolved.value.requestedAt, "2026-08-22T00:00:00.000Z"); + } + assert.deepStrictEqual( + yield* sql<{ readonly finalizedAt: string }>` + SELECT finalized_at AS "finalizedAt" + FROM projection_turn_assistant_finalizations + WHERE thread_id = ${key.threadId} AND turn_id = 'turn-1' + `, + [{ finalizedAt: "2026-08-22T00:00:04.000Z" }], + ); + yield* repository.deleteByThreadId({ threadId: key.threadId }); + assert.strictEqual((yield* repository.get(key))._tag, "None"); + assert.deepStrictEqual( + yield* sql` + SELECT 1 + FROM projection_turn_assistant_finalizations + WHERE thread_id = ${key.threadId} + `, + [], + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/044_ProjectionTurnRequestCorrelations.ts b/apps/server/src/persistence/Migrations/044_ProjectionTurnRequestCorrelations.ts new file mode 100644 index 000000000000..a84b97ded217 --- /dev/null +++ b/apps/server/src/persistence/Migrations/044_ProjectionTurnRequestCorrelations.ts @@ -0,0 +1,25 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS projection_turn_request_correlations ( + thread_id TEXT NOT NULL, + message_id TEXT NOT NULL, + turn_id TEXT, + state TEXT NOT NULL CHECK (state IN ('pending', 'started', 'error', 'interrupted')), + requested_at TEXT NOT NULL, + resolved_at TEXT, + PRIMARY KEY (thread_id, message_id) + ) + `; + yield* sql` + CREATE TABLE IF NOT EXISTS projection_turn_assistant_finalizations ( + thread_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + finalized_at TEXT NOT NULL, + PRIMARY KEY (thread_id, turn_id) + ) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index 47cb6073c479..ba4f4878304c 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -44,6 +44,12 @@ export const DeleteProjectionThreadActivitiesInput = Schema.Struct({ export type DeleteProjectionThreadActivitiesInput = typeof DeleteProjectionThreadActivitiesInput.Type; +export const ListProjectionThreadActivitiesByKindInput = Schema.Struct({ + kind: Schema.String, +}); +export type ListProjectionThreadActivitiesByKindInput = + typeof ListProjectionThreadActivitiesByKindInput.Type; + /** * ProjectionThreadActivityRepositoryShape - Service API for projected thread activity. */ @@ -67,6 +73,11 @@ export interface ProjectionThreadActivityRepositoryShape { input: ListProjectionThreadActivitiesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** List all rows of one activity kind in ascending runtime sequence order. */ + readonly listByKind: ( + input: ListProjectionThreadActivitiesByKindInput, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Delete projected thread activity rows by thread. */ diff --git a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts index 7cecac33eb6a..d11feb035036 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts @@ -26,6 +26,7 @@ export const ProjectionThreadSession = Schema.Struct({ status: OrchestrationSessionStatus, providerName: Schema.NullOr(Schema.String), providerInstanceId: Schema.NullOr(ProviderInstanceId), + providerThreadId: Schema.NullOr(Schema.String), runtimeMode: RuntimeMode, activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(Schema.String), diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index c572e1d11ccd..b5f450c1841a 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -9,11 +9,13 @@ import { CommandId, IsoDateTime, + MessageId, ModelSelection, NonNegativeInt, ProjectId, ProviderInteractionMode, RuntimeMode, + ThreadAnnotation, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -45,6 +47,8 @@ export const ProjectionThread = Schema.Struct({ pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + annotation: Schema.NullOr(ThreadAnnotation), + latestUserMessageId: Schema.NullOr(MessageId), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/server/src/persistence/Services/ProjectionTurnRequestCorrelations.ts b/apps/server/src/persistence/Services/ProjectionTurnRequestCorrelations.ts new file mode 100644 index 000000000000..ca9fca6651a7 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionTurnRequestCorrelations.ts @@ -0,0 +1,48 @@ +import { IsoDateTime, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionTurnRequestCorrelation = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, + turnId: Schema.NullOr(TurnId), + state: Schema.Literals(["pending", "started", "error", "interrupted"]), + requestedAt: IsoDateTime, + resolvedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionTurnRequestCorrelation = typeof ProjectionTurnRequestCorrelation.Type; + +export interface ProjectionTurnRequestCorrelationRepositoryShape { + readonly insertPending: ( + row: Pick, + ) => Effect.Effect; + readonly resolve: ( + row: Pick< + ProjectionTurnRequestCorrelation, + "threadId" | "messageId" | "turnId" | "state" | "resolvedAt" + >, + ) => Effect.Effect; + readonly markAssistantFinalized: (input: { + readonly threadId: ThreadId; + readonly turnId: TurnId; + readonly finalizedAt: IsoDateTime; + }) => Effect.Effect; + readonly get: (input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + }) => Effect.Effect, ProjectionRepositoryError>; + readonly deleteByThreadId: (input: { + readonly threadId: ThreadId; + }) => Effect.Effect; +} + +export class ProjectionTurnRequestCorrelationRepository extends Context.Service< + ProjectionTurnRequestCorrelationRepository, + ProjectionTurnRequestCorrelationRepositoryShape +>()( + "t3/persistence/Services/ProjectionTurnRequestCorrelations/ProjectionTurnRequestCorrelationRepository", +) {} diff --git a/apps/server/src/persistence/Services/UpdateDrainRepository.ts b/apps/server/src/persistence/Services/UpdateDrainRepository.ts new file mode 100644 index 000000000000..edd7d6e7e796 --- /dev/null +++ b/apps/server/src/persistence/Services/UpdateDrainRepository.ts @@ -0,0 +1,31 @@ +import type { CommandId, UpdateDrainCommandReceipt, UpdateDrainEvent } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; + +import type { UpdateDrainRepositoryError } from "../Errors.ts"; + +export interface UpdateDrainRepositoryShape { + readonly readAllEvents: () => Effect.Effect< + ReadonlyArray, + UpdateDrainRepositoryError + >; + readonly getReceipt: ( + commandId: CommandId, + ) => Effect.Effect, UpdateDrainRepositoryError>; + readonly commitAccepted: (input: { + readonly event: Omit; + readonly receipt: Omit; + }) => Effect.Effect< + { readonly event: UpdateDrainEvent; readonly receipt: UpdateDrainCommandReceipt }, + UpdateDrainRepositoryError + >; + readonly saveRejected: ( + receipt: UpdateDrainCommandReceipt, + ) => Effect.Effect; +} + +export class UpdateDrainRepository extends Context.Service< + UpdateDrainRepository, + UpdateDrainRepositoryShape +>()("t3/persistence/Services/UpdateDrainRepository") {} diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 5c5da4666b0d..f950b2a9b8a2 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -55,6 +55,7 @@ const makeTerminalManagerLayer = ( attachStream: () => Effect.die(new Error("unused")), resize: () => Effect.void, clear: () => Effect.void, + history: () => Effect.succeed(""), restart: () => Effect.die(new Error("unused")), close: () => Effect.void, subscribe: () => Effect.succeed(() => undefined), diff --git a/apps/server/src/provider/CodexThreadTool.test.ts b/apps/server/src/provider/CodexThreadTool.test.ts new file mode 100644 index 000000000000..48229520b550 --- /dev/null +++ b/apps/server/src/provider/CodexThreadTool.test.ts @@ -0,0 +1,212 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import { + canExposeCodexThreadTool, + materializeCodexThreadTool, + renderCodexThreadToolWrapper, +} from "./CodexThreadTool.ts"; + +it("exposes persistent wrappers only on supported host environments", () => { + assert.isTrue(canExposeCodexThreadTool("linux", { PATH: "/usr/bin" })); + assert.isTrue( + canExposeCodexThreadTool("darwin", { + ELECTRON_RUN_AS_NODE: "1", + PATH: "/usr/bin", + }), + ); + assert.isFalse( + canExposeCodexThreadTool("linux", { + APPIMAGE: "/tmp/.mount_LastCode/LastCode.AppImage", + }), + ); + assert.isFalse(canExposeCodexThreadTool("linux", { APPDIR: "/tmp/.mount_LastCode" })); + assert.isTrue(canExposeCodexThreadTool("linux", { APPIMAGE: "", APPDIR: " " })); + assert.isFalse(canExposeCodexThreadTool("win32", {})); +}); + +it("renders an ordinary Node-hosted wrapper pinned to its owning home", () => { + assert.strictEqual( + renderCodexThreadToolWrapper({ + executablePath: "/opt/node/bin/node", + cliEntryPath: "/opt/t3/dist/bin.mjs", + baseDir: "/srv/lastcode home", + stateDir: "/srv/lastcode home/userdata", + electronRunAsNode: false, + }), + "#!/bin/sh\ncase \"$1\" in\n current|list|read|send|wait) command=\"$1\"; shift; exec '/opt/node/bin/node' '/opt/t3/dist/bin.mjs' thread \"$command\" --base-dir '/srv/lastcode home' --state-dir '/srv/lastcode home/userdata' \"$@\" ;;\n \"\"|-h|--help|help) exec '/opt/node/bin/node' '/opt/t3/dist/bin.mjs' thread --help ;;\n *) echo \"lastcode-thread: unsupported command '$1'\" >&2; exit 64 ;;\nesac\n", + ); +}); + +it("renders a packaged POSIX Electron wrapper with Node mode preserved", () => { + const wrapper = renderCodexThreadToolWrapper({ + executablePath: "/opt/LastCode/lastcode", + cliEntryPath: "/opt/LastCode/resources/app.asar/apps/server/dist/bin.mjs", + baseDir: "/home/me/.lastcode", + stateDir: "/home/me/.lastcode/dev", + electronRunAsNode: true, + }); + assert.match(wrapper, /^#!\/bin\/sh\nexport ELECTRON_RUN_AS_NODE=1\n/); + assert.match( + wrapper, + /thread "\$command" --base-dir '\/home\/me\/\.lastcode' --state-dir '\/home\/me\/\.lastcode\/dev'/, + ); +}); + +it.effect("materializes an executable wrapper under the active state directory", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "lastcode-thread-tool-" }); + const stateDir = NodePath.join(baseDir, "userdata"); + const result = yield* materializeCodexThreadTool({ + stateDir, + baseDir, + executablePath: "/usr/bin/node", + cliEntryPath: "/app/bin.mjs", + electronRunAsNode: "0", + }); + const stat = yield* Effect.promise(() => NodeFSP.stat(result.wrapperPath)); + const wrapper = yield* fileSystem.readFileString(result.wrapperPath); + assert.strictEqual(result.wrapperPath, NodePath.join(stateDir, "bin", "lastcode-thread")); + assert.ok((stat.mode & 0o111) !== 0); + assert.isFalse(wrapper.includes("ELECTRON_RUN_AS_NODE")); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer))), +); + +it.effect("atomically publishes concurrent wrapper materializations", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "lastcode-thread-concurrent-", + }); + const stateDir = NodePath.join(baseDir, "userdata"); + const inputA = { + stateDir, + baseDir, + executablePath: "/opt/node-a/bin/node", + cliEntryPath: "/opt/t3-a/dist/bin.mjs", + electronRunAsNode: "0", + } as const; + const inputB = { + stateDir, + baseDir, + executablePath: "/opt/node-b/bin/node", + cliEntryPath: "/opt/t3-b/dist/bin.mjs", + electronRunAsNode: "1", + } as const; + const [result] = yield* Effect.all( + [materializeCodexThreadTool(inputA), materializeCodexThreadTool(inputB)], + { concurrency: "unbounded" }, + ); + const finalContents = yield* fileSystem.readFileString(result.wrapperPath); + const expectedContents = [ + renderCodexThreadToolWrapper({ + ...inputA, + electronRunAsNode: false, + }), + renderCodexThreadToolWrapper({ + ...inputB, + electronRunAsNode: true, + }), + ]; + + assert.isTrue(expectedContents.includes(finalContents)); + assert.deepStrictEqual(yield* fileSystem.readDirectory(result.binDir), ["lastcode-thread"]); + const stat = yield* Effect.promise(() => NodeFSP.stat(result.wrapperPath)); + assert.ok((stat.mode & 0o111) !== 0); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer))), +); + +it.effect("cleans its temporary sibling when atomic publication fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "lastcode-thread-publication-failure-", + }); + const stateDir = NodePath.join(baseDir, "userdata"); + const binDir = NodePath.join(stateDir, "bin"); + const wrapperPath = NodePath.join(binDir, "lastcode-thread"); + yield* fileSystem.makeDirectory(wrapperPath, { recursive: true }); + + const result = yield* Effect.result( + materializeCodexThreadTool({ + stateDir, + baseDir, + executablePath: "/opt/node/bin/node", + cliEntryPath: "/opt/t3/dist/bin.mjs", + electronRunAsNode: "0", + }), + ); + + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual(yield* fileSystem.readDirectory(binDir), ["lastcode-thread"]); + assert.isTrue((yield* fileSystem.stat(wrapperPath)).type === "Directory"); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer))), +); + +it.effect("preserves inherited Electron Node mode in a Linux wrapper", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "lastcode-thread-linux-electron-", + }); + const result = yield* materializeCodexThreadTool({ + stateDir: NodePath.join(baseDir, "userdata"), + baseDir, + executablePath: "/opt/LastCode/lastcode", + cliEntryPath: "/opt/LastCode/resources/app.asar/apps/server/dist/bin.mjs", + electronRunAsNode: "1", + }); + + assert.match( + yield* fileSystem.readFileString(result.wrapperPath), + /^#!\/bin\/sh\nexport ELECTRON_RUN_AS_NODE=1\n/, + ); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer))), +); + +it.effect("routes pinned flags through each real thread leaf parser", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "lastcode-thread-parser-", + }); + const stateDir = NodePath.join(baseDir, "userdata"); + const result = yield* materializeCodexThreadTool({ + stateDir, + baseDir, + executablePath: process.execPath, + cliEntryPath: NodePath.resolve( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "../bin.ts", + ), + electronRunAsNode: "0", + }); + for (const command of ["current", "list", "read", "send", "wait"] as const) { + const output = yield* Effect.tryPromise( + () => + new Promise((resolve, reject) => { + NodeChildProcess.execFile(result.wrapperPath, [command, "--help"], (error, stdout) => { + if (error) reject(error); + else resolve(stdout); + }); + }), + ); + assert.match(output, new RegExp(`t3 thread ${command}`)); + } + const unsupported = NodeChildProcess.spawnSync(result.wrapperPath, ["future-command"], { + encoding: "utf8", + }); + assert.strictEqual(unsupported.status, 64); + assert.match(unsupported.stderr, /unsupported command 'future-command'/); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer))), +); diff --git a/apps/server/src/provider/CodexThreadTool.ts b/apps/server/src/provider/CodexThreadTool.ts new file mode 100644 index 000000000000..e289258352be --- /dev/null +++ b/apps/server/src/provider/CodexThreadTool.ts @@ -0,0 +1,85 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +export class CodexThreadToolError extends Schema.TaggedErrorClass()( + "CodexThreadToolError", + { cause: Schema.Defect() }, +) {} + +export interface CodexThreadToolInvocation { + readonly executablePath: string; + readonly cliEntryPath: string; + readonly baseDir: string; + readonly stateDir: string; + readonly electronRunAsNode: boolean; +} + +const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; + +export function canExposeCodexThreadTool( + platform: NodeJS.Platform, + environment: Readonly>, +): boolean { + const isAppImage = + platform === "linux" && + [environment.APPIMAGE, environment.APPDIR].some( + (value) => value !== undefined && value.trim().length > 0, + ); + return platform !== "win32" && !isAppImage; +} + +export function renderCodexThreadToolWrapper(input: CodexThreadToolInvocation): string { + const executable = [ + shellQuote(input.executablePath), + shellQuote(input.cliEntryPath), + "thread", + ].join(" "); + const pinnedFlags = `--base-dir ${shellQuote(input.baseDir)} --state-dir ${shellQuote(input.stateDir)}`; + return `#!/bin/sh\n${input.electronRunAsNode ? "export ELECTRON_RUN_AS_NODE=1\n" : ""}case "$1" in\n current|list|read|send|wait) command="$1"; shift; exec ${executable} "$command" ${pinnedFlags} "$@" ;;\n ""|-h|--help|help) exec ${executable} --help ;;\n *) echo "lastcode-thread: unsupported command '$1'" >&2; exit 64 ;;\nesac\n`; +} + +export const materializeCodexThreadTool = Effect.fn("materializeCodexThreadTool")( + function* (input: { + readonly stateDir: string; + readonly baseDir: string; + readonly executablePath?: string; + readonly cliEntryPath?: string; + readonly electronRunAsNode?: string; + }) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const executablePath = input.executablePath ?? process.execPath; + const cliEntryPath = input.cliEntryPath ?? process.argv[1]; + if (cliEntryPath === undefined || cliEntryPath.trim().length === 0) { + return yield* new CodexThreadToolError({ + cause: new Error("The server CLI entry path is unavailable."), + }); + } + + const binDir = path.join(input.stateDir, "bin"); + const wrapperPath = path.join(binDir, "lastcode-thread"); + const wrapperContents = renderCodexThreadToolWrapper({ + executablePath, + cliEntryPath, + baseDir: input.baseDir, + stateDir: input.stateDir, + electronRunAsNode: (input.electronRunAsNode ?? process.env.ELECTRON_RUN_AS_NODE) === "1", + }); + yield* Effect.scoped( + Effect.gen(function* () { + yield* fileSystem.makeDirectory(binDir, { recursive: true }); + const temporaryPath = yield* fileSystem.makeTempFileScoped({ + directory: binDir, + prefix: ".lastcode-thread.", + suffix: ".tmp", + }); + yield* fileSystem.writeFileString(temporaryPath, wrapperContents); + yield* fileSystem.chmod(temporaryPath, 0o755); + yield* fileSystem.rename(temporaryPath, wrapperPath); + }), + ).pipe(Effect.mapError((cause) => new CodexThreadToolError({ cause }))); + return { binDir, wrapperPath } as const; + }, +); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 5358716aabe4..36573d866207 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -19,6 +19,7 @@ import { TurnId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, vi } from "@effect/vitest"; @@ -277,7 +278,10 @@ validationLayer("CodexAdapterLive validation", (it) => { runtimeMode: "full-access", }); - NodeAssert.deepStrictEqual(validationRuntimeFactory.factory.mock.calls[0]?.[0], { + const runtimeOptions = validationRuntimeFactory.factory.mock.calls[0]?.[0]; + NodeAssert.ok(runtimeOptions); + const { environment: _environment, ...optionsWithoutEnvironment } = runtimeOptions; + NodeAssert.deepStrictEqual(optionsWithoutEnvironment, { binaryPath: "codex", cwd: process.cwd(), launchArgs: "", @@ -424,6 +428,118 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }).pipe(Effect.provide(layer)); }); + it.effect("injects LastCode identity and the active-home thread wrapper into Codex only", () => { + const runtimeFactory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + environment: { PATH: "/usr/bin" }, + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "codex-thread-tool-" })), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const config = yield* ServerConfig; + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("lastcode-thread-id"), + runtimeMode: "full-access", + }); + + const runtime = runtimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.equal(runtime.options.environment?.T3CODE_THREAD_ID, "lastcode-thread-id"); + NodeAssert.equal(runtime.options.environment?.T3CODE_HOME, config.baseDir); + NodeAssert.equal( + runtime.options.environment?.PATH, + `${NodePath.join(config.stateDir, "bin")}:/usr/bin`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect("injects LastCode identity without a POSIX wrapper on Windows", () => { + const runtimeFactory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + environment: { PATH: "C:\\Windows\\System32" }, + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "codex-windows-tool-" })), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(Layer.succeed(HostProcessPlatform, "win32")), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const config = yield* ServerConfig; + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("lastcode-windows-thread"), + runtimeMode: "full-access", + }); + const runtime = runtimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.equal(runtime.options.environment?.T3CODE_THREAD_ID, "lastcode-windows-thread"); + NodeAssert.equal(runtime.options.environment?.T3CODE_HOME, config.baseDir); + NodeAssert.equal(runtime.options.environment?.PATH, "C:\\Windows\\System32"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("injects LastCode identity without a transient AppImage wrapper", () => { + const runtimeFactory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + environment: { + APPIMAGE: "/tmp/.mount_LastCode/LastCode.AppImage", + APPDIR: "/tmp/.mount_LastCode", + PATH: "/usr/bin", + }, + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "codex-appimage-tool-" })), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(Layer.succeed(HostProcessPlatform, "linux")), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const config = yield* ServerConfig; + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("lastcode-appimage-thread"), + runtimeMode: "full-access", + }); + const runtime = runtimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.equal(runtime.options.environment?.T3CODE_THREAD_ID, "lastcode-appimage-thread"); + NodeAssert.equal(runtime.options.environment?.T3CODE_HOME, config.baseDir); + NodeAssert.equal(runtime.options.environment?.PATH, "/usr/bin"); + }).pipe(Effect.provide(layer)); + }); + it.effect("maps codex model options for the adapter's bound custom instance id", () => { const customInstanceId = ProviderInstanceId.make("codex_personal"); const customRuntimeFactory = makeRuntimeFactory(); @@ -513,6 +629,67 @@ function startLifecycleRuntime() { } lifecycleLayer("CodexAdapterLive lifecycle", (it) => { + it.effect("does not treat a trailing child interaction as a restart", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 3)).pipe( + Effect.forkChild, + ); + const childPayload = { + agentThreadId: "child-thread-1", + agentPath: "/root/researcher", + role: "researcher", + }; + + yield* runtime.emit({ + id: asEventId("evt-child-turn-completed"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "collabAgent/turnCompleted", + payload: { + ...childPayload, + turn: { id: "child-turn-1", status: "completed" }, + }, + } satisfies ProviderEvent); + yield* runtime.emit({ + id: asEventId("evt-child-interacted"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:00.001Z", + method: "collabAgent/activity", + payload: { ...childPayload, activityKind: "interacted" }, + } satisfies ProviderEvent); + yield* runtime.emit({ + id: asEventId("evt-child-turn-started"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:00.002Z", + method: "collabAgent/turnStarted", + payload: { ...childPayload, turn: { id: "child-turn-2", status: "inProgress" } }, + } satisfies ProviderEvent); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.equal(events.length, 3); + NodeAssert.equal(events[0]?.type, "task.updated"); + if (events[0]?.type === "task.updated") { + NodeAssert.equal(events[0].payload.status, "idle"); + } + NodeAssert.equal(events[1]?.type, "task.updated"); + if (events[1]?.type === "task.updated") { + NodeAssert.equal(events[1].payload.status, undefined); + NodeAssert.equal(events[1].payload.description, "researcher"); + } + NodeAssert.equal(events[2]?.type, "task.updated"); + if (events[2]?.type === "task.updated") { + NodeAssert.equal(events[2].payload.status, "running"); + } + }), + ); + it.effect("maps completed agent message items to canonical item.completed events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 065156d36473..93b39b67b5f4 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -32,6 +32,7 @@ import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Queue from "effect/Queue"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -40,6 +41,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; @@ -54,6 +56,7 @@ import { import { type CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { canExposeCodexThreadTool, materializeCodexThreadTool } from "../CodexThreadTool.ts"; import { CodexResumeCursorSchema, CodexSessionRuntimeThreadIdMissingError, @@ -588,12 +591,16 @@ function mapCollabAgentEvent( }, ]; } - // interacted → the child is (again) actively driven. + // `interacted` is an identity/activity notification, not a lifecycle + // transition: Codex can emit it after turnCompleted. A status-free + // update preserves discovery without replacing the child's last + // meaningful progress row or reviving an already-idle task. Real + // restarts arrive through turnStarted or statusChanged(active). return [ { ...base, type: "task.updated", - payload: { taskId, status: "running", ...statusLinkage }, + payload: { taskId, description: title, ...statusLinkage }, }, ]; } @@ -1627,6 +1634,8 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ) { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("codex"); const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const hostProcessPlatform = yield* HostProcessPlatform; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const crypto = yield* Crypto.Crypto; const serverConfig = yield* Effect.service(ServerConfig); @@ -1663,13 +1672,45 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const runtimeEnvironment = options?.environment ?? process.env; + const threadTool = !canExposeCodexThreadTool(hostProcessPlatform, runtimeEnvironment) + ? null + : options?.makeRuntime + ? { binDir: path.join(serverConfig.stateDir, "bin") } + : yield* materializeCodexThreadTool({ + stateDir: serverConfig.stateDir, + baseDir: serverConfig.baseDir, + ...(runtimeEnvironment.ELECTRON_RUN_AS_NODE !== undefined + ? { + electronRunAsNode: runtimeEnvironment.ELECTRON_RUN_AS_NODE, + } + : {}), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to prepare the LastCode thread command.", + cause, + }), + ), + ); + const codexEnvironment = { + ...runtimeEnvironment, + ...(threadTool ? { PATH: `${threadTool.binDir}:${runtimeEnvironment.PATH ?? ""}` } : {}), + T3CODE_THREAD_ID: input.threadId, + T3CODE_HOME: serverConfig.baseDir, + }; const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, providerInstanceId: boundInstanceId, cwd: input.cwd ?? process.cwd(), binaryPath: codexConfig.binaryPath, launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), - ...(options?.environment ? { environment: options.environment } : {}), + environment: codexEnvironment, ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), ...(isCodexResumeCursorSchema(input.resumeCursor) ? { resumeCursor: input.resumeCursor } @@ -1682,7 +1723,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(mcpSession ? { environment: { - ...(options?.environment ?? process.env), + ...codexEnvironment, T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), }, appServerArgs: [ diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index a1b46e003520..ae8eb3b6d490 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -181,6 +181,13 @@ describe("CodexSessionRuntime collab integration", () => { assert.isDefined(registrationA); assert.isDefined(registrationB); assert.isDefined(rootThreadStarted); + const interactedRegistrationA = { + ...registrationA, + params: { + ...registrationA.params, + item: { ...registrationA.params.item, kind: "interacted" }, + }, + }; const memoryThreadStarted = { ...rootThreadStarted, params: { @@ -207,7 +214,7 @@ describe("CodexSessionRuntime collab integration", () => { hangInterruptFor: CHILD_A, notifications: [ turnStartedA, - registrationA, + interactedRegistrationA, memoryThreadStarted, memoryTurnStarted, registrationB, @@ -233,26 +240,38 @@ describe("CodexSessionRuntime collab integration", () => { environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, }); - // Wait for both children's turnStarted signals to be processed before - // stopping (B via the registered-child path; A only produces live-turn - // bookkeeping, so key on B's synthetic event). - const childBStartedFiber = yield* runtime.events.pipe( + // Wait for both children's synthetic turnStarted signals before + // stopping. B arrives through the registered-child path; A is replayed + // when its later activity registration finds the pre-registration live + // turn recorded by the foreign-notification suppressor. + const childrenStartedFiber = yield* runtime.events.pipe( Stream.filter( (event) => event.method === "collabAgent/turnStarted" && - (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_B, + [CHILD_A, CHILD_B].includes( + (event.payload as { agentThreadId?: string }).agentThreadId ?? "", + ), ), - Stream.take(1), + Stream.take(2), Stream.runCollect, Effect.forkScoped, ); yield* runtime.start(); yield* runtime.sendTurn({ input: "fan out and hang" }); - const childBStarted = yield* Fiber.join(childBStartedFiber).pipe( + const childrenStarted = yield* Fiber.join(childrenStartedFiber).pipe( Effect.timeoutOption("15 seconds"), ); - assert.isTrue(childBStarted._tag === "Some", "child B turnStarted never arrived"); + assert.isTrue(childrenStarted._tag === "Some", "child turnStarted replay never arrived"); + if (childrenStarted._tag === "Some") { + const startedThreadIds = new Set( + Array.from(childrenStarted.value).map( + (event) => (event.payload as { agentThreadId?: string }).agentThreadId, + ), + ); + assert.isTrue(startedThreadIds.has(CHILD_A), "child A start must replay on registration"); + assert.isTrue(startedThreadIds.has(CHILD_B), "child B start must flow after registration"); + } // Stop everything. A's interrupt hangs forever — the bounded child // deadline must expire and the parent interrupt must still be sent. diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 29bb992611c1..5c90466e12be 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -1104,8 +1104,8 @@ export const makeCodexSessionRuntime = ( return false; } const activitySpawnTurnId = (yield* Ref.get(sessionRef)).activeTurnId ?? undefined; + const existingChild = (yield* Ref.get(collabChildAgentsRef)).get(item.agentThreadId); yield* Ref.update(collabChildAgentsRef, (current) => { - const existing = current.get(item.agentThreadId); const next = new Map(current); // Merge-late semantics: when thread/started registered first, a // later subAgentActivity still carries the real agentPath (and a @@ -1117,13 +1117,13 @@ export const makeCodexSessionRuntime = ( next.set(item.agentThreadId, { agentThreadId: item.agentThreadId, nickname: - existing?.nickname ?? + existingChild?.nickname ?? item.agentPath.split("/").findLast((segment) => segment.length > 0), - role: existing?.role, - agentPath: existing?.agentPath ?? item.agentPath, - depth: existing?.depth, - parentThreadId: existing?.parentThreadId, - spawnTurnId: existing ? existing.spawnTurnId : activitySpawnTurnId, + role: existingChild?.role, + agentPath: existingChild?.agentPath ?? item.agentPath, + depth: existingChild?.depth, + parentThreadId: existingChild?.parentThreadId, + spawnTurnId: existingChild ? existingChild.spawnTurnId : activitySpawnTurnId, }); return next; }); @@ -1139,6 +1139,29 @@ export const makeCodexSessionRuntime = ( activityKind: item.kind, }, }); + // A child turn can start before this activity registers the child. + // The foreign-notification suppressor records that live turn but + // cannot emit agent lifecycle until identity is known. Replay the + // explicit start after first registration so sidebar liveness sees + // genuine work; a trailing interaction with no live turn remains + // the status-free metadata update mapped by CodexAdapter. + const preRegistrationLiveTurn = (yield* Ref.get(collabChildLiveTurnsRef)).get( + item.agentThreadId, + ); + if (!existingChild && item.kind === "interacted" && preRegistrationLiveTurn) { + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(registeredChild?.spawnTurnId ? { turnId: registeredChild.spawnTurnId } : {}), + method: "collabAgent/turnStarted", + payload: { + agentThreadId: item.agentThreadId, + ...(registeredChild?.nickname ? { nickname: registeredChild.nickname } : {}), + ...(registeredChild?.role ? { role: registeredChild.role } : {}), + agentPath: item.agentPath, + }, + }); + } return true; } diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 74a4de594a15..e4ac9882a8c7 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -472,6 +472,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const orchestrationEngine = { readEvents: () => Stream.empty, + getTurnRequestWaitState: () => Effect.succeed({ kind: "correlation-not-found" }), + subscribeDomainEvents: Effect.succeed(Stream.empty), dispatch: () => Effect.succeed({ sequence: 1 }), streamDomainEvents: Stream.fromQueue(events), latestSequence: Effect.succeed(0), @@ -664,6 +666,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }), Layer.succeed(OrchestrationEngineService, { readEvents: () => Stream.empty, + getTurnRequestWaitState: () => Effect.succeed({ kind: "correlation-not-found" }), + subscribeDomainEvents: Effect.succeed(Stream.empty), dispatch: () => Effect.succeed({ sequence: 1 }), streamDomainEvents: Stream.fromQueue(events), latestSequence: Effect.succeed(0), diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 5127ecf7d359..66e53e01989f 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -79,6 +79,9 @@ export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boo case "thread.proposed-plan-upserted": case "thread.runtime-mode-set": case "thread.interaction-mode-set": + case "thread.annotation-upserted": + case "thread.annotation-resolved": + case "thread.annotation-reopened": return false; case "thread.activity-appended": return ( diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index de3f5101f53e..8e12d343fa91 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -29,6 +29,8 @@ import { ProviderInstanceId, ResolvedKeybindingRule, ThreadId, + UpdateDrainRequestId, + UpdateDrainTargetVersion, WS_METHODS, WsRpcGroup, EditorId, @@ -152,6 +154,8 @@ import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; +import * as UpdateDrain from "./updateDrain/UpdateDrain.ts"; +import { UpdateDrainRepositoryLive } from "./persistence/Layers/UpdateDrainRepository.ts"; import * as Data from "effect/Data"; import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; @@ -610,6 +614,10 @@ const buildAppUnderTest = (options?: { const serviceLauncherClientLayer = ServiceLauncherClient.layer.pipe( Layer.provide(Layer.succeed(HostProcessEnvironment, {})), ); + const updateDrainLayer = UpdateDrain.layer.pipe( + Layer.provide(UpdateDrainRepositoryLive), + Layer.provide(SqlitePersistenceMemory), + ); const servedRoutesLayer = HttpRouter.serve( makeRoutesLayer.pipe(Layer.provide(serviceLauncherClientLayer)), @@ -833,6 +841,7 @@ const buildAppUnderTest = (options?: { ); const appLayer = servedRoutesLayer.pipe( + Layer.provide(updateDrainLayer), Layer.provide(resourceTelemetryLayer), Layer.provide(UsageService.layerTest), Layer.provide( @@ -4452,6 +4461,49 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes authorized update drain start, status, and cancel RPCs", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const commandId = CommandId.make("rpc-update-drain-start"); + const requestId = UpdateDrainRequestId.make("rpc-update-1"); + const targetVersion = UpdateDrainTargetVersion.make("0.0.35-nightly.1"); + + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const started = yield* client[WS_METHODS.serverStartUpdateDrain]({ + commandId, + requestId, + targetVersion, + }); + const draining = yield* client[WS_METHODS.serverGetUpdateDrainStatus]({}); + const cancelled = yield* client[WS_METHODS.serverCancelUpdateDrain]({ + commandId: CommandId.make("rpc-update-drain-cancel"), + requestId, + }); + const final = yield* client[WS_METHODS.serverGetUpdateDrainStatus]({}); + return { started, draining, cancelled, final }; + }), + ), + ); + + assert.equal(result.started.status, "accepted"); + assert.deepStrictEqual(result.draining.intent, { + requestId, + targetVersion, + status: "draining", + }); + assert.equal(result.cancelled.status, "accepted"); + assert.deepStrictEqual(result.final.intent, { + requestId, + targetVersion, + status: "cancelled", + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("shares one preview automation broker across websocket sessions", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3e41b4390f82..f15a4c586665 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -44,6 +44,7 @@ import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import * as TerminalManager from "./terminal/Manager.ts"; +import * as ActionResume from "./actionResume/ActionResume.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; @@ -108,6 +109,8 @@ import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; +import * as UpdateDrain from "./updateDrain/UpdateDrain.ts"; +import { UpdateDrainRepositoryLive } from "./persistence/Layers/UpdateDrainRepository.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, @@ -119,6 +122,7 @@ import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; import { forkParked, ServerActivation } from "./serverActivation.ts"; +import * as ServerOwnerLease from "./serverOwnerLease.ts"; // Effect's default preemptive shutdown waits 20s before finalizing request scopes. // T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer @@ -266,6 +270,12 @@ const ProviderLayerLive = ProviderServiceLive.pipe( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); +const UpdateDrainLayerLive = UpdateDrain.layer.pipe( + Layer.provide(UpdateDrainRepositoryLive), + Layer.provide(PersistenceLayerLive), +); +const PersistenceAndUpdateDrainLayerLive = Layer.merge(PersistenceLayerLive, UpdateDrainLayerLive); + const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProjectConfig.layer), ); @@ -368,7 +378,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); -const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( +const RuntimeCoreDependenciesBaseLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(ServerSettingsLayerLive), Layer.provideMerge(CheckpointingLayerLive), @@ -377,7 +387,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), - Layer.provideMerge(PersistenceLayerLive), + Layer.provideMerge(PersistenceAndUpdateDrainLayerLive), Layer.provideMerge(Keybindings.layer), Layer.provideMerge(ProviderRegistryLive), // The instance registry is the new routing keystone — text generation, @@ -415,6 +425,11 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( ), ); +const RuntimeCoreDependenciesLive = Layer.merge( + RuntimeCoreDependenciesBaseLive, + ActionResume.layer.pipe(Layer.provide(RuntimeCoreDependenciesBaseLive)), +); + const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. Layer.provideMerge(BackgroundLayerLive), @@ -474,6 +489,10 @@ export const makeRoutesLayer = Layer.mergeAll( export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + yield* Effect.acquireRelease( + ServerOwnerLease.acquireServerOwnerLease(config.stateDir), + (lease) => lease.release, + ); const activation = yield* Deferred.make(); const awaitActivation = Deferred.await(activation); const activationLayer = Layer.succeed(ServerActivation, awaitActivation); diff --git a/apps/server/src/serverOwnerLease.test.ts b/apps/server/src/serverOwnerLease.test.ts new file mode 100644 index 000000000000..4819830795eb --- /dev/null +++ b/apps/server/src/serverOwnerLease.test.ts @@ -0,0 +1,247 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Exercises Darwin's kernel-owned local lock. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "./config.ts"; +import { makeServerLayer } from "./server.ts"; +import { + acquireServerOwnerLease, + getServerOwnerLeaseLockPath, + ServerOwnerLeaseHeldError, + ServerOwnerLeaseUnavailableError, +} from "./serverOwnerLease.ts"; + +const temporaryDirectories: Array = []; + +const makeTemporaryDirectory = () => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-server-owner-lease-")); + temporaryDirectories.push(directory); + return directory; +}; + +const makeServerConfig = (baseDir: string): ServerConfig.ServerConfig["Service"] => { + const stateDir = NodePath.join(baseDir, "userdata"); + const logsDir = NodePath.join(stateDir, "logs"); + const providerLogsDir = NodePath.join(logsDir, "provider"); + return { + logLevel: "Error", + traceMinLevel: "Info", + traceTimingEnabled: true, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + mode: "web", + port: 0, + host: "127.0.0.1", + cwd: process.cwd(), + baseDir, + stateDir, + dbPath: NodePath.join(stateDir, "state.sqlite"), + keybindingsConfigPath: NodePath.join(stateDir, "keybindings.json"), + settingsPath: NodePath.join(stateDir, "settings.json"), + providerStatusCacheDir: NodePath.join(baseDir, "caches"), + worktreesDir: NodePath.join(baseDir, "worktrees"), + attachmentsDir: NodePath.join(stateDir, "attachments"), + logsDir, + serverLogPath: NodePath.join(logsDir, "server.log"), + serverTracePath: NodePath.join(logsDir, "server.trace.ndjson"), + providerLogsDir, + providerEventLogPath: NodePath.join(providerLogsDir, "events.log"), + terminalLogsDir: NodePath.join(logsDir, "terminals"), + anonymousIdPath: NodePath.join(stateDir, "anonymous-id"), + environmentIdPath: NodePath.join(stateDir, "environment-id"), + serverRuntimeStatePath: NodePath.join(stateDir, "server-runtime.json"), + secretsDir: NodePath.join(stateDir, "secrets"), + staticDir: undefined, + devUrl: undefined, + devAllowedOrigins: [], + noBrowser: true, + startupPresentation: "headless", + desktopBootstrapToken: undefined, + desktopTelemetryFd: undefined, + desktopTelemetryControlFd: undefined, + resourceMonitorPath: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }; +}; + +const spawnLeaseOwner = (source: string, argument: string) => + Effect.promise( + () => + new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn(process.execPath, ["-e", source, argument], { + stdio: ["ignore", "pipe", "ignore"], + }); + child.stdout.once("data", () => resolve(child)); + child.once("error", reject); + child.once("exit", () => reject(new Error("Lease holder exited before listening."))); + }), + ); + +const spawnDarwinLockOwner = (lockPath: string) => + spawnLeaseOwner( + [ + 'const fs = require("node:fs");', + "fs.openSync(process.argv[1], fs.constants.O_CREAT | fs.constants.O_RDWR | fs.constants.O_NONBLOCK | 0x20 | 0x100, 0o600);", + 'process.stdout.write("owned\\n");', + "setInterval(() => {}, 1_000);", + ].join("\n"), + lockPath, + ); + +const terminateChild = (child: NodeChildProcess.ChildProcess) => + Effect.promise( + () => + new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) return resolve(); + child.once("exit", () => resolve()); + child.kill("SIGKILL"); + }), + ); + +const waitForChildExit = (child: NodeChildProcess.ChildProcess) => + Effect.promise( + () => + new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) return resolve(); + child.once("exit", () => resolve()); + }), + ); + +const acquireScopedLease = (baseDir: string) => + Effect.acquireRelease(acquireServerOwnerLease(baseDir), (lease) => lease.release); + +const acquireScopedChild = (effect: Effect.Effect) => + Effect.acquireRelease(effect, terminateChild); + +const cleanup = Effect.sync(() => { + for (const directory of temporaryDirectories.splice(0)) { + NodeFS.rmSync(directory, { force: true, recursive: true }); + } +}); + +it.effect("rejects a second owner with an actionable per-home diagnostic", () => { + const home = makeTemporaryDirectory(); + return Effect.scoped( + Effect.gen(function* () { + // oxlint-disable-next-line t3code/no-global-process-runtime -- The lease is intentionally Darwin-only. + if (process.platform !== "darwin") return; + yield* acquireScopedLease(home); + const error = yield* acquireServerOwnerLease(home).pipe(Effect.flip); + + assert.instanceOf(error, ServerOwnerLeaseHeldError); + assert.include(error.message, home); + assert.include(error.message, "--base-dir"); + }), + ).pipe(Effect.ensuring(cleanup)); +}); + +it.effect("allows independent T3 homes to have separate owners", () => { + const firstHome = makeTemporaryDirectory(); + const secondHome = makeTemporaryDirectory(); + return Effect.scoped( + Effect.gen(function* () { + // oxlint-disable-next-line t3code/no-global-process-runtime -- The lease is intentionally Darwin-only. + if (process.platform !== "darwin") return; + const first = yield* acquireScopedLease(firstHome); + const second = yield* acquireScopedLease(secondHome); + + assert.notEqual(first.endpoint, second.endpoint); + }), + ).pipe(Effect.ensuring(cleanup)); +}); + +it.effect("allows dev and production state in one T3 home to have separate owners", () => { + const home = makeTemporaryDirectory(); + return Effect.scoped( + Effect.gen(function* () { + // oxlint-disable-next-line t3code/no-global-process-runtime -- The lease is intentionally Darwin-only. + if (process.platform !== "darwin") return; + const production = yield* acquireScopedLease(NodePath.join(home, "userdata")); + const development = yield* acquireScopedLease(NodePath.join(home, "dev")); + + assert.notEqual(production.endpoint, development.endpoint); + }), + ).pipe(Effect.ensuring(cleanup)); +}); + +it.effect("fails before the server can construct persistence or listen", () => { + const home = makeTemporaryDirectory(); + return Effect.scoped( + Effect.gen(function* () { + // oxlint-disable-next-line t3code/no-global-process-runtime -- The lease is intentionally Darwin-only. + if (process.platform !== "darwin") return; + const config = makeServerConfig(home); + yield* acquireScopedLease(config.stateDir); + const error = yield* Layer.build(makeServerLayer).pipe( + Effect.provide(Layer.mergeAll(ServerConfig.layer(config), NodeServices.layer)), + Effect.flip, + ); + + assert.instanceOf(error, ServerOwnerLeaseHeldError); + assert.isFalse(NodeFS.existsSync(config.dbPath)); + }), + ).pipe(Effect.ensuring(cleanup)); +}); + +it.effect("fails closed instead of following a Darwin lock-path symlink", () => { + const home = makeTemporaryDirectory(); + return Effect.gen(function* () { + // oxlint-disable-next-line t3code/no-global-process-runtime -- O_NOFOLLOW is Darwin-specific. + if (process.platform !== "darwin") return; + const target = NodePath.join(home, "must-not-change"); + NodeFS.writeFileSync(target, "safe"); + NodeFS.symlinkSync(target, getServerOwnerLeaseLockPath(home)); + + const error = yield* acquireServerOwnerLease(home).pipe(Effect.flip); + + assert.instanceOf(error, ServerOwnerLeaseUnavailableError); + assert.equal(NodeFS.readFileSync(target, "utf8"), "safe"); + }).pipe(Effect.ensuring(cleanup)); +}); + +it.effect("fails closed when the Darwin lock path is not a regular file", () => { + const home = makeTemporaryDirectory(); + return Effect.gen(function* () { + // oxlint-disable-next-line t3code/no-global-process-runtime -- O_EXLOCK is Darwin-specific. + if (process.platform !== "darwin") return; + NodeFS.mkdirSync(getServerOwnerLeaseLockPath(home)); + + const error = yield* acquireServerOwnerLease(home).pipe(Effect.flip); + + assert.instanceOf(error, ServerOwnerLeaseUnavailableError); + }).pipe(Effect.ensuring(cleanup)); +}); + +it.effect("reclaims the lease after its owner crashes", () => { + const home = makeTemporaryDirectory(); + return Effect.scoped( + Effect.gen(function* () { + // oxlint-disable-next-line t3code/no-global-process-runtime -- O_EXLOCK is Darwin-specific. + if (process.platform !== "darwin") return; + const holder = yield* acquireScopedChild( + spawnDarwinLockOwner(getServerOwnerLeaseLockPath(home)), + ); + + // This is the exact child spawned above, not a process located by pattern. + holder.kill("SIGKILL"); + yield* waitForChildExit(holder); + + yield* acquireScopedLease(home); + }), + ).pipe(Effect.ensuring(cleanup)); +}); diff --git a/apps/server/src/serverOwnerLease.ts b/apps/server/src/serverOwnerLease.ts new file mode 100644 index 000000000000..f8dfddf413cf --- /dev/null +++ b/apps/server/src/serverOwnerLease.ts @@ -0,0 +1,137 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The server process owns a kernel-backed local lock. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +const DARWIN_O_EXLOCK = 0x20; +const DARWIN_O_NOFOLLOW = 0x100; + +export class ServerOwnerLeaseHeldError extends Schema.TaggedErrorClass()( + "ServerOwnerLeaseHeldError", + { + stateDir: Schema.String, + }, +) { + override get message() { + return `Another T3 Code server already owns ${this.stateDir}. Stop that server or choose a different --base-dir.`; + } +} + +export class ServerOwnerLeaseUnavailableError extends Schema.TaggedErrorClass()( + "ServerOwnerLeaseUnavailableError", + { + stateDir: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message() { + return `T3 Code could not acquire the server-owner lease for ${this.stateDir}.`; + } +} + +const isServerOwnerLeaseHeldError = Schema.is(ServerOwnerLeaseHeldError); +const isServerOwnerLeaseUnavailableError = Schema.is(ServerOwnerLeaseUnavailableError); + +export interface ServerOwnerLease { + readonly endpoint: string; + readonly release: Effect.Effect; +} + +function canonicalizeStateDir(stateDir: string): string { + const resolved = NodePath.resolve(stateDir); + try { + return NodeFS.realpathSync(resolved); + } catch { + return resolved; + } +} + +export function getServerOwnerLeaseLockPath(stateDir: string): string { + return NodePath.join(canonicalizeStateDir(stateDir), "server-owner.lock"); +} + +function releaseServerOwnerLease(release: () => Promise): Effect.Effect { + let released = false; + return Effect.suspend(() => { + if (released) return Effect.void; + released = true; + return Effect.tryPromise({ try: release, catch: () => undefined }).pipe(Effect.orDie); + }); +} + +function assertRegularLockPath(endpoint: string): void { + try { + if (!NodeFS.lstatSync(endpoint).isFile()) { + throw new Error("The server-owner lock path is not a regular file."); + } + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === "ENOENT") return; + throw cause; + } +} + +function acquireDarwinServerOwnerLease(stateDir: string): ServerOwnerLease { + const endpoint = getServerOwnerLeaseLockPath(stateDir); + NodeFS.mkdirSync(NodePath.dirname(endpoint), { recursive: true }); + try { + assertRegularLockPath(endpoint); + } catch (cause) { + throw new ServerOwnerLeaseUnavailableError({ stateDir, cause }); + } + const flags = + NodeFS.constants.O_CREAT | + NodeFS.constants.O_RDWR | + NodeFS.constants.O_NONBLOCK | + DARWIN_O_EXLOCK | + DARWIN_O_NOFOLLOW; + let descriptor: number; + try { + descriptor = NodeFS.openSync(endpoint, flags, 0o600); + } catch (cause) { + if ( + (cause as NodeJS.ErrnoException).code === "EAGAIN" || + (cause as NodeJS.ErrnoException).code === "EWOULDBLOCK" + ) { + throw new ServerOwnerLeaseHeldError({ stateDir }); + } + throw new ServerOwnerLeaseUnavailableError({ stateDir, cause }); + } + try { + if (!NodeFS.fstatSync(descriptor).isFile()) { + throw new Error("The server-owner lock path is not a regular file."); + } + } catch (cause) { + NodeFS.closeSync(descriptor); + throw new ServerOwnerLeaseUnavailableError({ stateDir, cause }); + } + return { + endpoint, + release: releaseServerOwnerLease(async () => NodeFS.closeSync(descriptor)), + }; +} + +async function acquireServerOwnerLeasePromise(stateDir: string): Promise { + // This lease protects the LastCode macOS service boundary. Other platforms + // retain their established server startup behavior until they have an equally + // kernel-backed primitive; they must not use a stale-path or PID-file fallback. + // oxlint-disable-next-line t3code/no-global-process-runtime -- O_EXLOCK is Darwin-specific and has no portable Node abstraction. + if (process.platform !== "darwin") return { endpoint: "unmanaged", release: Effect.void }; + return acquireDarwinServerOwnerLease(stateDir); +} + +export const acquireServerOwnerLease = Effect.fn("acquireServerOwnerLease")(function* ( + stateDir: string, +): Effect.fn.Return< + ServerOwnerLease, + ServerOwnerLeaseHeldError | ServerOwnerLeaseUnavailableError +> { + return yield* Effect.tryPromise({ + try: () => acquireServerOwnerLeasePromise(stateDir), + catch: (cause) => + isServerOwnerLeaseHeldError(cause) || isServerOwnerLeaseUnavailableError(cause) + ? cause + : new ServerOwnerLeaseUnavailableError({ stateDir, cause }), + }); +}); diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 60cb8d61bc06..e7990eeff8de 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -81,6 +81,8 @@ const runReconciliation = (input: { Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, + getTurnRequestWaitState: () => Effect.succeed({ kind: "correlation-not-found" }), + subscribeDomainEvents: Effect.succeed(Stream.empty), dispatch: input.dispatch, streamDomainEvents: Stream.empty, latestSequence: Effect.succeed(0), @@ -286,6 +288,8 @@ it.effect("does not fail startup when the live provider session inventory cannot }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, + getTurnRequestWaitState: () => Effect.succeed({ kind: "correlation-not-found" }), + subscribeDomainEvents: Effect.succeed(Stream.empty), dispatch: () => Effect.die("unused"), streamDomainEvents: Stream.empty, latestSequence: Effect.succeed(0), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index e3f7e482b2e0..e33ba3ceed9e 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -165,6 +165,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, + getTurnRequestWaitState: () => Effect.succeed({ kind: "correlation-not-found" }), + subscribeDomainEvents: Effect.succeed(Stream.empty), dispatch: (command) => Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( Effect.as({ sequence: 1 }), @@ -210,6 +212,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, + getTurnRequestWaitState: () => Effect.succeed({ kind: "correlation-not-found" }), + subscribeDomainEvents: Effect.succeed(Stream.empty), dispatch: (command) => Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( Effect.as({ sequence: 1 }), @@ -261,6 +265,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, + getTurnRequestWaitState: () => Effect.succeed({ kind: "correlation-not-found" }), + subscribeDomainEvents: Effect.succeed(Stream.empty), dispatch: (command) => Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( Effect.as({ sequence: 1 }), diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 47d91e4516ec..09de9bafe9ee 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1329,12 +1329,23 @@ it.layer( yield* manager.open(openInput({ terminalId: "default" })); yield* manager.open(openInput({ terminalId: "sidecar" })); + yield* manager.close({ + threadId: "thread-1", + terminalId: "default", + deleteHistory: true, + }); yield* manager.close({ threadId: "thread-1" }); const closedEvents = (yield* getEvents).filter( (event): event is Extract => event.type === "closed", ); expect(closedEvents.map((event) => event.terminalId).sort()).toEqual(["default", "sidecar"]); + expect(closedEvents.find((event) => event.terminalId === "default")?.deleteHistory).toBe( + true, + ); + expect(closedEvents.find((event) => event.terminalId === "sidecar")?.deleteHistory).toBe( + false, + ); }), ); @@ -1445,7 +1456,7 @@ it.layer( }, }).pipe(Effect.provide(withHostPlatform("win32"))); - yield* manager.open(openInput()); + const snapshot = yield* manager.open(openInput()); expect(ptyAdapter.spawnInputs[0]).toEqual( expect.objectContaining({ @@ -1453,6 +1464,33 @@ it.layer( args: ["-NoLogo"], }), ); + expect(snapshot.shellFamily).toBe("powershell"); + }), + ); + + it.effect("reports cmd when Windows shell fallback reaches ComSpec", () => + Effect.gen(function* () { + const ptyAdapter = new FakePtyAdapter(); + const { manager } = yield* createManager(5, { + ptyAdapter, + shellResolver: () => "C:\\missing\\custom-shell.exe", + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + PATH: "C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + }, + }).pipe(Effect.provide(withHostPlatform("win32"))); + ptyAdapter.spawnFailures.push( + new Error("spawn custom-shell.exe ENOENT"), + new Error("spawn pwsh.exe ENOENT"), + new Error("spawn built-in powershell.exe ENOENT"), + new Error("spawn powershell.exe ENOENT"), + ); + + const snapshot = yield* manager.open(openInput()); + + expect(ptyAdapter.spawnInputs.at(-1)?.shell).toBe("C:\\Windows\\System32\\cmd.exe"); + expect(snapshot.shellFamily).toBe("cmd"); }), ); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 64c2dbb913fb..0f75c94be518 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -74,6 +74,12 @@ export { TerminalWriteError, }; +export type TerminalShellFamily = "posix" | "powershell" | "cmd"; + +export type OpenTerminalSessionSnapshot = TerminalSessionSnapshot & { + readonly shellFamily?: TerminalShellFamily; +}; + const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; @@ -134,7 +140,7 @@ export class TerminalManager extends Context.Service< */ readonly open: ( input: TerminalOpenInput, - ) => Effect.Effect; + ) => Effect.Effect; /** * Attach to a terminal and stream its initial snapshot followed by live events. @@ -161,6 +167,9 @@ export class TerminalManager extends Context.Service< */ readonly clear: (input: TerminalClearInput) => Effect.Effect; + /** Read the persisted transcript without opening or restarting the terminal. */ + readonly history: (input: TerminalClearInput) => Effect.Effect; + /** * Restart a terminal session in place. * @@ -262,6 +271,7 @@ export interface TerminalSessionState { hasRunningSubprocess: boolean; /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */ childCommandLabel: string | null; + shellFamily: TerminalShellFamily | null; runtimeEnv: Record | null; } @@ -349,6 +359,18 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { }; } +function openSnapshot(session: TerminalSessionState): OpenTerminalSessionSnapshot { + return { + ...snapshot(session), + ...(session.shellFamily === null ? {} : { shellFamily: session.shellFamily }), + }; +} + +function publicSnapshot(snapshot: OpenTerminalSessionSnapshot): TerminalSessionSnapshot { + const { shellFamily: _shellFamily, ...terminalSnapshot } = snapshot; + return terminalSnapshot; +} + function summary(session: TerminalSessionState): TerminalSummary { return { threadId: session.threadId, @@ -478,6 +500,20 @@ function basenameForPlatform(command: string, platform: NodeJS.Platform): string return parts.at(-1) ?? normalized; } +function shellFamilyForCommand(command: string, platform: NodeJS.Platform): TerminalShellFamily { + const shellName = basenameForPlatform(command, platform).toLowerCase(); + if ( + shellName === "pwsh" || + shellName === "pwsh.exe" || + shellName === "powershell" || + shellName === "powershell.exe" + ) { + return "powershell"; + } + if (shellName === "cmd" || shellName === "cmd.exe") return "cmd"; + return "posix"; +} + function joinWindowsPath(...parts: ReadonlyArray): string { return parts .map((part, index) => { @@ -1781,7 +1817,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func index = 0, lastError: PtyAdapter.PtySpawnError | null = null, ): Effect.fn.Return< - { process: PtyAdapter.PtyProcess; shellLabel: string }, + { + process: PtyAdapter.PtyProcess; + shellLabel: string; + shellFamily: TerminalShellFamily; + }, PtyAdapter.PtySpawnError > { if (index >= shellCandidates.length) { @@ -1818,6 +1858,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return { process: attempt.success, shellLabel: formatShellCandidate(candidate), + shellFamily: shellFamilyForCommand(candidate.shell, platform), }; } @@ -1853,6 +1894,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.exitSignal = null; session.hasRunningSubprocess = false; session.childCommandLabel = null; + session.shellFamily = null; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; @@ -1862,6 +1904,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func let ptyProcess: PtyAdapter.PtyProcess | null = null; let startedShell: string | null = null; + let startedShellFamily: TerminalShellFamily | null = null; const startResult = yield* Effect.result( increment(terminalSessionsTotal, { lifecycle: eventType }).pipe( @@ -1872,6 +1915,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); ptyProcess = spawnResult.process; startedShell = spawnResult.shellLabel; + startedShellFamily = spawnResult.shellFamily; const processPid = ptyProcess.pid; const unsubscribeData = ptyProcess.onData((data) => { @@ -1897,6 +1941,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.status = "running"; session.unsubscribeData = unsubscribeData; session.unsubscribeExit = unsubscribeExit; + session.shellFamily = startedShellFamily; eventStamp = advanceEventSequence(session); return [undefined, state] as const; }); @@ -1930,6 +1975,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.process = null; session.hasRunningSubprocess = false; session.childCommandLabel = null; + session.shellFamily = null; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; @@ -1992,6 +2038,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId, terminalId, sequence: closedEventSequence, + deleteHistory: deleteHistoryOnClose, }); } @@ -2176,6 +2223,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func unsubscribeExit: null, hasRunningSubprocess: false, childCommandLabel: null, + shellFamily: null, runtimeEnv: normalizedRuntimeEnv(input.env), }; @@ -2200,7 +2248,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }, "started", ); - return snapshot(session); + return openSnapshot(session); } const liveSession = existing.value; @@ -2252,7 +2300,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }, "started", ); - return snapshot(liveSession); + return openSnapshot(liveSession); } if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { @@ -2262,7 +2310,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func liveSession.updatedAt = yield* nowIso; } - return snapshot(liveSession); + return openSnapshot(liveSession); }); const open: TerminalManager["Service"]["open"] = (input) => @@ -2287,7 +2335,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ...input, terminalId, cwd: input.cwd, - }); + }).pipe(Effect.map(publicSnapshot)); } const session = existing.value; @@ -2299,7 +2347,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ...input, terminalId, cwd: input.cwd, - }); + }).pipe(Effect.map(publicSnapshot)); } if ( @@ -2588,6 +2636,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func unsubscribeExit: null, hasRunningSubprocess: false, childCommandLabel: null, + shellFamily: null, runtimeEnv: normalizedRuntimeEnv(input.env), }; const createdSession = session; @@ -2653,12 +2702,16 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ); + const history: TerminalManager["Service"]["history"] = (input) => + readHistory(input.threadId, input.terminalId); + return TerminalManager.of({ open, attachStream, write, resize, clear, + history, restart, close, subscribe, diff --git a/apps/server/src/updateDrain/DrainState.test.ts b/apps/server/src/updateDrain/DrainState.test.ts new file mode 100644 index 000000000000..abbab076f15f --- /dev/null +++ b/apps/server/src/updateDrain/DrainState.test.ts @@ -0,0 +1,128 @@ +import { CommandId, UpdateDrainRequestId, UpdateDrainTargetVersion } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + decideUpdateDrainCommand, + emptyUpdateDrainState, + projectUpdateDrainEvent, +} from "./DrainState.ts"; + +const requestedAt = "2026-08-21T00:00:00.000Z"; +const requestId = UpdateDrainRequestId.make("update-1"); +const targetVersion = UpdateDrainTargetVersion.make("1.2.3"); + +describe("update drain decider and projector", () => { + it.effect("starts and cancels one durable intent without blocker state", () => + Effect.gen(function* () { + const startedDraft = yield* decideUpdateDrainCommand(emptyUpdateDrainState, new Set(), { + type: "update-drain.start", + commandId: CommandId.make("start-1"), + requestId, + targetVersion, + createdAt: requestedAt, + }); + assert.equal(startedDraft.type, "update-drain.started"); + if (startedDraft.type !== "update-drain.started") return; + const draining = projectUpdateDrainEvent(emptyUpdateDrainState, { + ...startedDraft, + sequence: 1, + }); + + assert.deepStrictEqual(draining, { + sequence: 1, + intent: { requestId, targetVersion, status: "draining" }, + }); + assert.ok(!("blockers" in draining)); + + const cancelledDraft = yield* decideUpdateDrainCommand(draining, new Set([requestId]), { + type: "update-drain.cancel", + commandId: CommandId.make("cancel-1"), + requestId, + createdAt: "2026-08-21T00:01:00.000Z", + }); + assert.equal(cancelledDraft.type, "update-drain.cancelled"); + if (cancelledDraft.type !== "update-drain.cancelled") return; + const cancelled = projectUpdateDrainEvent(draining, { + ...cancelledDraft, + sequence: 2, + }); + + assert.deepStrictEqual(cancelled, { + sequence: 2, + intent: { requestId, targetVersion, status: "cancelled" }, + }); + }), + ); + + it.effect("rejects competing and stale request identities", () => + Effect.gen(function* () { + const draining = { + sequence: 1, + intent: { requestId, targetVersion, status: "draining" as const }, + }; + const competing = yield* Effect.result( + decideUpdateDrainCommand(draining, new Set([requestId]), { + type: "update-drain.start", + commandId: CommandId.make("start-2"), + requestId: UpdateDrainRequestId.make("update-2"), + targetVersion: UpdateDrainTargetVersion.make("1.2.4"), + createdAt: requestedAt, + }), + ); + const staleCancel = yield* Effect.result( + decideUpdateDrainCommand(draining, new Set([requestId]), { + type: "update-drain.cancel", + commandId: CommandId.make("cancel-2"), + requestId: UpdateDrainRequestId.make("update-2"), + createdAt: requestedAt, + }), + ); + + assert.equal(competing._tag, "Failure"); + assert.equal( + competing._tag === "Failure" ? competing.failure.reason : null, + "already_draining", + ); + assert.equal(staleCancel._tag, "Failure"); + assert.equal( + staleCancel._tag === "Failure" ? staleCancel.failure.reason : null, + "request_mismatch", + ); + }), + ); + + it.effect("does not describe a cancelled drain as active for another request", () => + Effect.gen(function* () { + const cancelled = { + sequence: 2, + intent: { requestId, targetVersion, status: "cancelled" as const }, + }; + const sameRequest = yield* Effect.result( + decideUpdateDrainCommand(cancelled, new Set([requestId]), { + type: "update-drain.cancel", + commandId: CommandId.make("cancel-again"), + requestId, + createdAt: requestedAt, + }), + ); + const otherRequest = yield* Effect.result( + decideUpdateDrainCommand(cancelled, new Set([requestId]), { + type: "update-drain.cancel", + commandId: CommandId.make("cancel-stale"), + requestId: UpdateDrainRequestId.make("update-2"), + createdAt: requestedAt, + }), + ); + + assert.equal( + sameRequest._tag === "Failure" ? sameRequest.failure.reason : null, + "request_already_cancelled", + ); + assert.equal( + otherRequest._tag === "Failure" ? otherRequest.failure.reason : null, + "no_active_drain", + ); + }), + ); +}); diff --git a/apps/server/src/updateDrain/DrainState.ts b/apps/server/src/updateDrain/DrainState.ts new file mode 100644 index 000000000000..686c2df5726d --- /dev/null +++ b/apps/server/src/updateDrain/DrainState.ts @@ -0,0 +1,115 @@ +import { + EventId, + type UpdateDrainCommand, + UpdateDrainError, + type UpdateDrainEvent, + type UpdateDrainRequestId, + type UpdateDrainState, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +export type UpdateDrainEventDraft = UpdateDrainEvent extends infer Event + ? Event extends UpdateDrainEvent + ? Omit + : never + : never; + +export const emptyUpdateDrainState: UpdateDrainState = { + sequence: 0, + intent: null, +}; + +export function projectUpdateDrainEvent( + _state: UpdateDrainState, + event: UpdateDrainEvent, +): UpdateDrainState { + return { + sequence: event.sequence, + intent: { + requestId: event.requestId, + targetVersion: event.targetVersion, + status: event.status, + }, + }; +} + +function eventIdFor(command: UpdateDrainCommand) { + return EventId.make(`update-drain:${command.commandId}`); +} + +export function decideUpdateDrainCommand( + state: UpdateDrainState, + usedRequestIds: ReadonlySet, + command: UpdateDrainCommand, +): Effect.Effect { + if (command.type === "update-drain.start") { + if (state.intent?.status === "draining") { + return Effect.fail( + new UpdateDrainError({ + reason: "already_draining", + message: `Update drain '${state.intent.requestId}' is already targeting ${state.intent.targetVersion}.`, + }), + ); + } + if (usedRequestIds.has(command.requestId)) { + return Effect.fail( + new UpdateDrainError({ + reason: "request_already_cancelled", + message: `Update drain request '${command.requestId}' was already cancelled; use a new request id.`, + }), + ); + } + return Effect.succeed({ + type: "update-drain.started", + eventId: eventIdFor(command), + commandId: command.commandId, + occurredAt: command.createdAt, + requestId: command.requestId, + targetVersion: command.targetVersion, + status: "draining", + }); + } + + if (state.intent === null) { + return Effect.fail( + new UpdateDrainError({ + reason: "no_active_drain", + message: "There is no active update drain to cancel.", + }), + ); + } + if (state.intent.status === "cancelled") { + if (state.intent.requestId === command.requestId) { + return Effect.fail( + new UpdateDrainError({ + reason: "request_already_cancelled", + message: `Update drain request '${command.requestId}' is already cancelled.`, + }), + ); + } + return Effect.fail( + new UpdateDrainError({ + reason: "no_active_drain", + message: "There is no active update drain to cancel.", + }), + ); + } + if (state.intent.requestId !== command.requestId) { + return Effect.fail( + new UpdateDrainError({ + reason: "request_mismatch", + message: `Update drain '${command.requestId}' does not match active request '${state.intent.requestId}'.`, + }), + ); + } + + return Effect.succeed({ + type: "update-drain.cancelled", + eventId: eventIdFor(command), + commandId: command.commandId, + occurredAt: command.createdAt, + requestId: command.requestId, + targetVersion: state.intent.targetVersion, + status: "cancelled", + }); +} diff --git a/apps/server/src/updateDrain/UpdateDrain.test.ts b/apps/server/src/updateDrain/UpdateDrain.test.ts new file mode 100644 index 000000000000..4205a2607d4e --- /dev/null +++ b/apps/server/src/updateDrain/UpdateDrain.test.ts @@ -0,0 +1,219 @@ +import { CommandId, UpdateDrainRequestId, UpdateDrainTargetVersion } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; + +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { UpdateDrainRepositoryLive } from "../persistence/Layers/UpdateDrainRepository.ts"; +import { UpdateDrainRepository } from "../persistence/Services/UpdateDrainRepository.ts"; +import { UpdateDrain, layer, makeUpdateDrain } from "./UpdateDrain.ts"; + +const repositoryLayer = UpdateDrainRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)); +const testLayer = layer.pipe(Layer.provideMerge(repositoryLayer)); +const tests = it.layer(testLayer); + +const requestId = UpdateDrainRequestId.make("update-1"); +const targetVersion = UpdateDrainTargetVersion.make("1.2.3"); +const startedAt = "2026-08-21T00:00:00.000Z"; + +tests("UpdateDrain", (it) => { + it.effect("returns persisted receipts and restores the projected state", () => + Effect.gen(function* () { + const drain = yield* UpdateDrain; + const repository = yield* UpdateDrainRepository; + const startCommand = { + type: "update-drain.start" as const, + commandId: CommandId.make("start-1"), + requestId, + targetVersion, + createdAt: startedAt, + }; + + const started = yield* drain.dispatch(startCommand); + assert.deepStrictEqual(started, { + commandId: startCommand.commandId, + requestId, + commandType: "update-drain.start", + targetVersion, + acceptedAt: startedAt, + resultSequence: 1, + status: "accepted", + errorReason: null, + error: null, + }); + assert.deepStrictEqual(yield* drain.status, { + sequence: 1, + intent: { requestId, targetVersion, status: "draining" }, + }); + + const replayed = yield* drain.dispatch(startCommand); + assert.deepStrictEqual(replayed, started); + + const cancelled = yield* drain.dispatch({ + type: "update-drain.cancel", + commandId: CommandId.make("cancel-1"), + requestId, + createdAt: "2026-08-21T00:01:00.000Z", + }); + assert.equal(cancelled.resultSequence, 2); + + const restored = yield* makeUpdateDrain().pipe( + Effect.provideService(UpdateDrainRepository, repository), + ); + assert.deepStrictEqual(yield* restored.status, { + sequence: 2, + intent: { requestId, targetVersion, status: "cancelled" }, + }); + }), + ); + + it.effect("persists rejected receipts and rejects command-id conflicts", () => + Effect.gen(function* () { + const drain = yield* UpdateDrain; + const activeRequestId = UpdateDrainRequestId.make("update-active"); + yield* drain.dispatch({ + type: "update-drain.start", + commandId: CommandId.make("start-active"), + requestId: activeRequestId, + targetVersion, + createdAt: startedAt, + }); + + const rejectedCommand = { + type: "update-drain.start" as const, + commandId: CommandId.make("start-competing"), + requestId: UpdateDrainRequestId.make("update-2"), + targetVersion: UpdateDrainTargetVersion.make("1.2.4"), + createdAt: startedAt, + }; + const rejected = yield* Effect.result(drain.dispatch(rejectedCommand)); + const retried = yield* Effect.result(drain.dispatch(rejectedCommand)); + assert.equal(rejected._tag, "Failure"); + assert.equal(retried._tag, "Failure"); + assert.equal( + rejected._tag === "Failure" ? rejected.failure.reason : null, + "already_draining", + ); + assert.equal(retried._tag === "Failure" ? retried.failure.reason : null, "already_draining"); + + const conflict = yield* Effect.result( + drain.dispatch({ + ...rejectedCommand, + requestId: UpdateDrainRequestId.make("different-input"), + }), + ); + assert.equal(conflict._tag, "Failure"); + assert.equal( + conflict._tag === "Failure" ? conflict.failure.reason : null, + "command_id_conflict", + ); + }), + ); +}); + +it.layer(testLayer)("UpdateDrain request history", (it) => { + it.effect("never reuses a cancelled request id after later drains and restart", () => + Effect.gen(function* () { + const drain = yield* UpdateDrain; + const repository = yield* UpdateDrainRepository; + const laterRequestId = UpdateDrainRequestId.make("update-2"); + const laterTargetVersion = UpdateDrainTargetVersion.make("1.2.4"); + + yield* drain.dispatch({ + type: "update-drain.start", + commandId: CommandId.make("start-1"), + requestId, + targetVersion, + createdAt: startedAt, + }); + yield* drain.dispatch({ + type: "update-drain.cancel", + commandId: CommandId.make("cancel-1"), + requestId, + createdAt: "2026-08-21T00:01:00.000Z", + }); + yield* drain.dispatch({ + type: "update-drain.start", + commandId: CommandId.make("start-2"), + requestId: laterRequestId, + targetVersion: laterTargetVersion, + createdAt: "2026-08-21T00:02:00.000Z", + }); + yield* drain.dispatch({ + type: "update-drain.cancel", + commandId: CommandId.make("cancel-2"), + requestId: laterRequestId, + createdAt: "2026-08-21T00:03:00.000Z", + }); + + const restored = yield* makeUpdateDrain().pipe( + Effect.provideService(UpdateDrainRepository, repository), + ); + const staleStart = yield* Effect.result( + restored.dispatch({ + type: "update-drain.start", + commandId: CommandId.make("start-1-again"), + requestId, + targetVersion, + createdAt: "2026-08-21T00:04:00.000Z", + }), + ); + + assert.equal(staleStart._tag, "Failure"); + assert.equal( + staleStart._tag === "Failure" ? staleStart.failure.reason : null, + "request_already_cancelled", + ); + assert.deepStrictEqual(yield* restored.status, { + sequence: 4, + intent: { + requestId: laterRequestId, + targetVersion: laterTargetVersion, + status: "cancelled", + }, + }); + }), + ); +}); + +it.layer(repositoryLayer)("UpdateDrain interruption safety", (it) => { + it.effect("projects an accepted event before honoring interruption", () => + Effect.gen(function* () { + const repository = yield* UpdateDrainRepository; + const committed = yield* Deferred.make(); + const releaseCommit = yield* Deferred.make(); + const interruptedRepository = UpdateDrainRepository.of({ + ...repository, + commitAccepted: (input) => + repository.commitAccepted(input).pipe( + Effect.tap(() => Deferred.succeed(committed, undefined)), + Effect.tap(() => Deferred.await(releaseCommit)), + ), + }); + const drain = yield* makeUpdateDrain().pipe( + Effect.provideService(UpdateDrainRepository, interruptedRepository), + ); + + const dispatchFiber = yield* drain + .dispatch({ + type: "update-drain.start", + commandId: CommandId.make("interrupted-start"), + requestId, + targetVersion, + createdAt: startedAt, + }) + .pipe(Effect.forkChild); + yield* Deferred.await(committed); + dispatchFiber.interruptUnsafe(); + yield* Deferred.succeed(releaseCommit, undefined); + yield* Fiber.await(dispatchFiber); + + assert.deepStrictEqual(yield* drain.status, { + sequence: 1, + intent: { requestId, targetVersion, status: "draining" }, + }); + }), + ); +}); diff --git a/apps/server/src/updateDrain/UpdateDrain.ts b/apps/server/src/updateDrain/UpdateDrain.ts new file mode 100644 index 000000000000..6f89eb200a71 --- /dev/null +++ b/apps/server/src/updateDrain/UpdateDrain.ts @@ -0,0 +1,136 @@ +import { + type UpdateDrainCommand, + type UpdateDrainCommandReceipt, + UpdateDrainError, + type UpdateDrainState, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Semaphore from "effect/Semaphore"; + +import { UpdateDrainRepository } from "../persistence/Services/UpdateDrainRepository.ts"; +import { + decideUpdateDrainCommand, + emptyUpdateDrainState, + projectUpdateDrainEvent, +} from "./DrainState.ts"; + +export interface UpdateDrainShape { + readonly dispatch: ( + command: UpdateDrainCommand, + ) => Effect.Effect; + readonly status: Effect.Effect; +} + +export class UpdateDrain extends Context.Service()( + "t3/updateDrain/UpdateDrain", +) {} + +function internalError(_cause: unknown) { + return new UpdateDrainError({ + reason: "internal_error", + message: "Failed to access durable update drain state.", + }); +} + +function commandTargetVersion(command: UpdateDrainCommand) { + return command.type === "update-drain.start" ? command.targetVersion : null; +} + +function receiptMatchesCommand( + receipt: UpdateDrainCommandReceipt, + command: UpdateDrainCommand, +): boolean { + return ( + receipt.commandType === command.type && + receipt.requestId === command.requestId && + receipt.targetVersion === commandTargetVersion(command) + ); +} + +export const makeUpdateDrain = Effect.fn("makeUpdateDrain")(function* () { + const repository = yield* UpdateDrainRepository; + const mutex = yield* Semaphore.make(1); + const events = yield* repository.readAllEvents().pipe(Effect.mapError(internalError)); + let currentState = events.reduce(projectUpdateDrainEvent, emptyUpdateDrainState); + const usedRequestIds = new Set(events.map((event) => event.requestId)); + + const dispatchUnlocked = Effect.fn("UpdateDrain.dispatchUnlocked")(function* ( + command: UpdateDrainCommand, + ) { + const existingReceipt = yield* repository + .getReceipt(command.commandId) + .pipe(Effect.mapError(internalError)); + if (Option.isSome(existingReceipt)) { + if (!receiptMatchesCommand(existingReceipt.value, command)) { + return yield* new UpdateDrainError({ + reason: "command_id_conflict", + message: `Update drain command id '${command.commandId}' was already used for different input.`, + }); + } + if (existingReceipt.value.status === "accepted") { + return existingReceipt.value; + } + return yield* new UpdateDrainError({ + reason: existingReceipt.value.errorReason ?? "internal_error", + message: existingReceipt.value.error ?? "Update drain command was previously rejected.", + }); + } + + const decision = yield* Effect.result( + decideUpdateDrainCommand(currentState, usedRequestIds, command), + ); + if (decision._tag === "Failure") { + yield* repository + .saveRejected({ + commandId: command.commandId, + requestId: command.requestId, + commandType: command.type, + targetVersion: commandTargetVersion(command), + acceptedAt: command.createdAt, + resultSequence: currentState.sequence, + status: "rejected", + errorReason: decision.failure.reason, + error: decision.failure.message, + }) + .pipe(Effect.mapError(internalError)); + return yield* decision.failure; + } + + const committed = yield* Effect.uninterruptible( + Effect.gen(function* () { + const committed = yield* repository + .commitAccepted({ + event: decision.success, + receipt: { + commandId: command.commandId, + requestId: command.requestId, + commandType: command.type, + targetVersion: commandTargetVersion(command), + acceptedAt: command.createdAt, + status: "accepted", + errorReason: null, + error: null, + }, + }) + .pipe(Effect.mapError(internalError)); + currentState = projectUpdateDrainEvent(currentState, committed.event); + usedRequestIds.add(committed.event.requestId); + return committed; + }), + ); + return committed.receipt; + }); + + const dispatch: UpdateDrainShape["dispatch"] = (command) => + mutex.withPermits(1)(dispatchUnlocked(command)); + + return UpdateDrain.of({ + dispatch, + status: mutex.withPermits(1)(Effect.sync(() => currentState)), + }); +}); + +export const layer = Layer.effect(UpdateDrain, makeUpdateDrain()); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c3caea225704..0df33343e3b9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -12,6 +12,7 @@ import * as Stream from "effect/Stream"; import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, AuthAccessStreamError, + ActionResumeError, type AuthAccessStreamEvent, type AuthEnvironmentScope, AuthSessionId, @@ -87,6 +88,7 @@ import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; +import * as ActionResume from "./actionResume/ActionResume.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; @@ -111,6 +113,7 @@ import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as UpdateDrain from "./updateDrain/UpdateDrain.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; @@ -283,7 +286,10 @@ export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract | "thread.activity-appended" | "thread.turn-diff-completed" | "thread.reverted" - | "thread.session-set"; + | "thread.session-set" + | "thread.annotation-upserted" + | "thread.annotation-resolved" + | "thread.annotation-reopened"; } > { return ( @@ -292,7 +298,10 @@ export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract event.type === "thread.activity-appended" || event.type === "thread.turn-diff-completed" || event.type === "thread.reverted" || - event.type === "thread.session-set" + event.type === "thread.session-set" || + event.type === "thread.annotation-upserted" || + event.type === "thread.annotation-resolved" || + event.type === "thread.annotation-reopened" ); } @@ -429,6 +438,7 @@ const makeWsRpcLayer = ( const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const terminalManager = yield* TerminalManager.TerminalManager; + const actionResume = yield* Effect.serviceOption(ActionResume.ActionResume); const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; @@ -479,6 +489,7 @@ const makeWsRpcLayer = ( const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const usage = yield* UsageService.UsageService; + const updateDrain = yield* UpdateDrain.UpdateDrain; const relayClient = yield* RelayClient.RelayClient; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ @@ -1690,6 +1701,34 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetBackgroundPolicy, backgroundPolicy.snapshot, { "rpc.aggregate": "server", }), + [WS_METHODS.serverStartUpdateDrain]: (input) => + observeRpcEffect( + WS_METHODS.serverStartUpdateDrain, + Effect.flatMap(nowIso, (createdAt) => + updateDrain.dispatch({ + type: "update-drain.start", + ...input, + createdAt, + }), + ), + { "rpc.aggregate": "update-drain" }, + ), + [WS_METHODS.serverCancelUpdateDrain]: (input) => + observeRpcEffect( + WS_METHODS.serverCancelUpdateDrain, + Effect.flatMap(nowIso, (createdAt) => + updateDrain.dispatch({ + type: "update-drain.cancel", + ...input, + createdAt, + }), + ), + { "rpc.aggregate": "update-drain" }, + ), + [WS_METHODS.serverGetUpdateDrainStatus]: (_input) => + observeRpcEffect(WS_METHODS.serverGetUpdateDrainStatus, updateDrain.status, { + "rpc.aggregate": "update-drain", + }), [WS_METHODS.cloudGetRelayClientStatus]: (_input) => observeRpcEffect(WS_METHODS.cloudGetRelayClientStatus, relayClient.resolve, { "rpc.aggregate": "cloud", @@ -2154,6 +2193,36 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), { "rpc.aggregate": "terminal", }), + [WS_METHODS.actionResumeResume]: (input) => + observeRpcEffect( + WS_METHODS.actionResumeResume, + Option.match(actionResume, { + onNone: () => + Effect.fail( + new ActionResumeError({ + reason: "internal_error", + message: "Action resume is unavailable in this server runtime.", + }), + ), + onSome: (service) => service.resumeInterrupted(input.threadId), + }), + { "rpc.aggregate": "action-resume" }, + ), + [WS_METHODS.actionResumeDiscard]: (input) => + observeRpcEffect( + WS_METHODS.actionResumeDiscard, + Option.match(actionResume, { + onNone: () => + Effect.fail( + new ActionResumeError({ + reason: "internal_error", + message: "Action resume is unavailable in this server runtime.", + }), + ), + onSome: (service) => service.discardInterrupted(input.threadId), + }), + { "rpc.aggregate": "action-resume" }, + ), [WS_METHODS.subscribeTerminalEvents]: (_input) => observeRpcStream( WS_METHODS.subscribeTerminalEvents, diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png index 3eed25ea6b78..f555ccb792d6 100644 Binary files a/apps/web/public/apple-touch-icon.png and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/favicon-16x16.png b/apps/web/public/favicon-16x16.png index a3431b8c6dfe..8e8910d18d84 100644 Binary files a/apps/web/public/favicon-16x16.png and b/apps/web/public/favicon-16x16.png differ diff --git a/apps/web/public/favicon-32x32.png b/apps/web/public/favicon-32x32.png index 862f7629971f..779d306aea63 100644 Binary files a/apps/web/public/favicon-32x32.png and b/apps/web/public/favicon-32x32.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico index 750da22602ee..9c1289d8df59 100644 Binary files a/apps/web/public/favicon.ico and b/apps/web/public/favicon.ico differ diff --git a/apps/web/src/archiveProjectFiltering.test.ts b/apps/web/src/archiveProjectFiltering.test.ts new file mode 100644 index 000000000000..3a9a21a9f02c --- /dev/null +++ b/apps/web/src/archiveProjectFiltering.test.ts @@ -0,0 +1,179 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildArchivedProjectModel, + connectedArchiveEnvironmentIds, + filterArchivedProjectGroups, + validateArchivedThreadsSearch, +} from "./archiveProjectFiltering"; + +const primaryEnvironmentId = EnvironmentId.make("env-primary"); +const remoteEnvironmentId = EnvironmentId.make("env-remote"); +const groupingSettings = { + sidebarProjectGroupingMode: "repository" as const, + sidebarProjectGroupingOverrides: {}, +}; +const repositoryIdentity = { + canonicalKey: "github.com/example/shared-repo", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, +}; + +function makeProject(overrides: Partial = {}): EnvironmentProject { + return { + id: ProjectId.make("project-1"), + environmentId: primaryEnvironmentId, + title: "Project one", + workspaceRoot: "/tmp/project-one", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +function makeThread( + project: EnvironmentProject, + overrides: Partial = {}, +): EnvironmentThreadShell { + return { + id: ThreadId.make(`thread-${project.environmentId}-${project.id}`), + projectId: project.id, + environmentId: project.environmentId, + title: `Archived thread for ${project.title}`, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + archivedAt: "2026-01-02T00:00:00.000Z", + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +function buildModel( + projects: ReadonlyArray, + threads: ReadonlyArray, +) { + return buildArchivedProjectModel({ + projects, + threads, + settings: groupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => + environmentId === primaryEnvironmentId ? "Local" : "Remote", + }); +} + +describe("archive project filtering", () => { + it("requests archives only from connected environments", () => { + expect( + connectedArchiveEnvironmentIds([ + { + environmentId: remoteEnvironmentId, + connection: { phase: "offline" }, + }, + { + environmentId: primaryEnvironmentId, + connection: { phase: "connected" }, + }, + ]), + ).toEqual([primaryEnvironmentId]); + }); + + it("shows every archived project for All and narrows to one selected project", () => { + const alpha = makeProject({ title: "Alpha", workspaceRoot: "/tmp/alpha" }); + const beta = makeProject({ + id: ProjectId.make("project-beta"), + title: "Beta", + workspaceRoot: "/tmp/beta", + }); + const model = buildModel([beta, alpha], [makeThread(beta), makeThread(alpha)]); + + expect(model.projectGroups.map((group) => group.displayName)).toEqual(["Alpha", "Beta"]); + expect(filterArchivedProjectGroups(model.archivedGroups, null)).toHaveLength(2); + + const alphaKey = model.projectGroups.find((group) => group.displayName === "Alpha")?.projectKey; + expect(alphaKey).toBeDefined(); + expect(filterArchivedProjectGroups(model.archivedGroups, alphaKey ?? null)).toEqual([ + expect.objectContaining({ project: expect.objectContaining({ title: "Alpha" }) }), + ]); + }); + + it("selects every physical member of one logical project", () => { + const local = makeProject({ repositoryIdentity, title: "Shared" }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + workspaceRoot: "/srv/shared", + repositoryIdentity, + title: "Shared remote", + }); + const model = buildModel([local, remote], [makeThread(local), makeThread(remote)]); + + expect(model.projectGroups).toHaveLength(1); + expect( + filterArchivedProjectGroups(model.archivedGroups, model.projectGroups[0]!.projectKey), + ).toHaveLength(2); + }); + + it("keeps duplicate project ids scoped to their environments", () => { + const local = makeProject({ title: "Local", workspaceRoot: "/tmp/local" }); + const remote = makeProject({ + environmentId: remoteEnvironmentId, + title: "Remote", + workspaceRoot: "/srv/remote", + }); + const model = buildModel([local, remote], [makeThread(local), makeThread(remote)]); + + expect(model.archivedGroups).toHaveLength(2); + expect(model.archivedGroups.map((group) => group.threads[0]?.environmentId)).toEqual([ + primaryEnvironmentId, + remoteEnvironmentId, + ]); + }); + + it("keeps an archived-only project as an individual picker item", () => { + const archivedOnly = makeProject({ title: "Removed project" }); + const model = buildModel([archivedOnly], [makeThread(archivedOnly)]); + + expect(model.projectGroups.map((group) => group.displayName)).toEqual(["Removed project"]); + }); + + it("does not widen an unavailable project filter to All", () => { + const project = makeProject(); + const model = buildModel([project], [makeThread(project)]); + + expect(filterArchivedProjectGroups(model.archivedGroups, "pending-project")).toEqual([]); + }); + + it("preserves a project key longer than 500 characters during route validation", () => { + const projectKey = `environment:${"nested-worktree/".repeat(40)}`; + + expect(projectKey.length).toBeGreaterThan(500); + expect(validateArchivedThreadsSearch({ project: projectKey })).toEqual({ project: projectKey }); + }); +}); diff --git a/apps/web/src/archiveProjectFiltering.ts b/apps/web/src/archiveProjectFiltering.ts new file mode 100644 index 000000000000..2798cad17253 --- /dev/null +++ b/apps/web/src/archiveProjectFiltering.ts @@ -0,0 +1,113 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import { derivePhysicalProjectKey, type ProjectGroupingSettings } from "./logicalProject"; +import { + buildPhysicalToLogicalProjectKeyMap, + buildSidebarProjectSnapshots, + type SidebarProjectSnapshot, +} from "./sidebarProjectGrouping"; + +export interface ArchivedProjectGroup { + readonly logicalProjectKey: string; + readonly project: EnvironmentProject; + readonly threads: ReadonlyArray; +} + +export interface ArchivedProjectModel { + readonly archivedGroups: ReadonlyArray; + readonly projectGroups: ReadonlyArray; +} + +export interface ArchivedThreadsSearch { + readonly project?: string; +} + +export function connectedArchiveEnvironmentIds( + environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly connection: { readonly phase: EnvironmentConnectionPhase }; + }>, +): ReadonlyArray { + return environments + .filter((environment) => environment.connection.phase === "connected") + .map((environment) => environment.environmentId) + .toSorted(); +} + +export function validateArchivedThreadsSearch(raw: Record): ArchivedThreadsSearch { + return typeof raw.project === "string" && raw.project ? { project: raw.project } : {}; +} + +function scopedProjectId(project: Pick): string { + return `${project.environmentId}:${project.id}`; +} + +export function buildArchivedProjectModel(input: { + readonly primaryEnvironmentId: EnvironmentId | null; + readonly projects: ReadonlyArray; + readonly resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; + readonly settings: ProjectGroupingSettings; + readonly threads: ReadonlyArray; +}): ArchivedProjectModel { + const threadsByProject = new Map(); + for (const thread of input.threads) { + const key = `${thread.environmentId}:${thread.projectId}`; + const existing = threadsByProject.get(key); + if (existing) { + existing.push(thread); + } else { + threadsByProject.set(key, [thread]); + } + } + + const physicalGroups = input.projects.flatMap((project) => { + const projectThreads = threadsByProject.get(scopedProjectId(project)); + if (!projectThreads?.length) return []; + return [ + { + project, + threads: projectThreads.toSorted((left, right) => { + const leftKey = left.archivedAt ?? left.createdAt; + const rightKey = right.archivedAt ?? right.createdAt; + return rightKey.localeCompare(leftKey) || right.id.localeCompare(left.id); + }), + }, + ]; + }); + const archivedProjects = physicalGroups.map((group) => group.project); + const logicalKeyByPhysicalKey = buildPhysicalToLogicalProjectKeyMap({ + projects: archivedProjects, + settings: input.settings, + primaryEnvironmentId: input.primaryEnvironmentId, + }); + const projectGroups = buildSidebarProjectSnapshots({ + projects: archivedProjects, + settings: input.settings, + primaryEnvironmentId: input.primaryEnvironmentId, + resolveEnvironmentLabel: input.resolveEnvironmentLabel, + }).sort((left, right) => left.displayName.localeCompare(right.displayName)); + + return { + projectGroups, + archivedGroups: physicalGroups.map((group) => ({ + ...group, + logicalProjectKey: + logicalKeyByPhysicalKey.get(derivePhysicalProjectKey(group.project)) ?? + derivePhysicalProjectKey(group.project), + })), + }; +} + +export function filterArchivedProjectGroups( + archivedGroups: ReadonlyArray, + projectKey: string | null, +): ReadonlyArray { + return projectKey === null + ? archivedGroups + : archivedGroups.filter((group) => group.logicalProjectKey === projectKey); +} diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 1a79f729bb03..dcf657a3820f 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -1,5 +1,6 @@ import { EnvironmentAuthInvalidError, + EnvironmentInternalError, type AuthBrowserSessionResult, type AuthCreatePairingCredentialInput, type AuthSessionState, @@ -269,6 +270,90 @@ describe("resolveInitialServerAuthGateState", () => { expect(attempts).toBe(4); }); + it("keeps retrying desktop session bootstrap across delayed credential prompts", async () => { + vi.useFakeTimers(); + installDesktopBootstrap(); + let attempts = 0; + const request = HttpClientRequest.get("http://localhost/api/auth/session"); + const response = HttpClientResponse.fromWeb( + request, + new Response("Internal Server Error", { status: 500 }), + ); + const runner: PrimaryHttpEffectRunner = async () => { + attempts += 1; + if (attempts < 3) { + await new Promise((resolve) => setTimeout(resolve, 20_000)); + throw new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ request, response }), + }); + } + return unauthenticatedSession(DESKTOP_AUTH) as A; + }; + __setPrimaryHttpRunnerForTests(runner); + + const { fetchSessionState } = await import("./environments/primary"); + + const sessionPromise = fetchSessionState(); + await vi.advanceTimersByTimeAsync(41_000); + + await expect(sessionPromise).resolves.toEqual(unauthenticatedSession(DESKTOP_AUTH)); + expect(attempts).toBe(3); + }); + + it("surfaces genuine desktop internal errors without an hour-long retry", async () => { + vi.useFakeTimers(); + installDesktopBootstrap(); + let attempts = 0; + const runner: PrimaryHttpEffectRunner = async () => { + attempts += 1; + await new Promise((resolve) => setTimeout(resolve, 20_000)); + throw new EnvironmentInternalError({ + code: "internal_error", + reason: "internal_error", + traceId: "trace-persistent-internal-error", + }); + }; + __setPrimaryHttpRunnerForTests(runner); + + const { fetchSessionState, PrimaryEnvironmentRequestError } = + await import("./environments/primary"); + + const sessionPromise = fetchSessionState(); + const rejection = expect(sessionPromise).rejects.toBeInstanceOf(PrimaryEnvironmentRequestError); + await vi.advanceTimersByTimeAsync(20_000); + + await rejection; + expect(attempts).toBe(1); + }); + + it("keeps ordinary desktop gateway retries on the short bootstrap deadline", async () => { + vi.useFakeTimers(); + installDesktopBootstrap(); + let attempts = 0; + const request = HttpClientRequest.get("http://localhost/api/auth/session"); + const response = HttpClientResponse.fromWeb( + request, + new Response("Bad Gateway", { status: 502 }), + ); + const runner: PrimaryHttpEffectRunner = async () => { + attempts += 1; + throw new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ request, response }), + }); + }; + __setPrimaryHttpRunnerForTests(runner); + + const { fetchSessionState, PrimaryEnvironmentRequestError } = + await import("./environments/primary"); + + const sessionPromise = fetchSessionState(); + const rejection = expect(sessionPromise).rejects.toBeInstanceOf(PrimaryEnvironmentRequestError); + await vi.advanceTimersByTimeAsync(15_000); + + await rejection; + expect(attempts).toBe(31); + }); + it("takes a pairing token from the location hash and strips it immediately", async () => { const testWindow = installTestBrowser("http://localhost/#token=pairing-token"); const { takePairingTokenFromUrl } = await import("./environments/primary"); diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 056fbb76e6ab..fcf79921c553 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -1,4 +1,4 @@ -const NIGHTLY_SERVER_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; +const NIGHTLY_SERVER_VERSION_PATTERN = /-nightly\.\d{8}\.\d+(?:\.\d+)?$/; export function formatAppDisplayName(input: { readonly baseName: string; diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index e1c87bcf0595..6949b7843392 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -79,6 +79,12 @@ describe("branding logic", () => { fallbackStageLabel: "Alpha", }), ).toBe("Nightly"); + expect( + resolveServerBackedAppStageLabel({ + primaryServerVersion: "0.0.28-nightly.20260616.12.1", + fallbackStageLabel: "Alpha", + }), + ).toBe("Nightly"); }); it("updates the display name for nightly primary server versions", () => { diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index a3ba76679689..c3bc525e2a95 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -7,14 +7,25 @@ import { type CSSProperties, type ReactNode, } from "react"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { isElectron } from "../env"; import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; +import { isPreviewFocused } from "../lib/previewFocus"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { resolveThreadRouteTarget } from "../threadRoutes"; +import { + useClientSettingsHydrated, + useEnvironmentIdentificationMode, + useLegacySidebarEnabled, + useUpdateClientSettings, +} from "../hooks/useSettings"; import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; @@ -67,6 +78,23 @@ function readInitialThreadSidebarWidth(): number { function SidebarControl() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar } = useSidebar(); + const updateClientSettings = useUpdateClientSettings(); + const clientSettingsHydrated = useClientSettingsHydrated(); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; + const terminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); + const previewOpen = useRightPanelStore((state) => + routeThreadRef + ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" + : false, + ); const isSidebarVisible = useSidebarVisibility(); const environmentIdentificationMode = useEnvironmentIdentificationMode(); const stageBackdropVariant = useSidebarStageBackdropVariant( @@ -76,24 +104,47 @@ function SidebarControl() { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; + if (event.defaultPrevented || event.repeat) return; if ( event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]") ) { return; } - if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return; + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen, + previewFocus: isPreviewFocused(), + previewOpen, + modelPickerOpen: isModelPickerOpen(), + }, + }); + if (command !== "sidebar.toggle" && command !== "sidebar.mode.toggle") return; + if (command === "sidebar.mode.toggle" && !clientSettingsHydrated) return; event.preventDefault(); event.stopPropagation(); - toggleSidebar(); + if (command === "sidebar.toggle") { + toggleSidebar(); + return; + } + updateClientSettings((settings) => ({ + legacySidebarEnabled: !settings.legacySidebarEnabled, + })); }; // Capture before focused editors consume commands such as Mod+B for rich-text formatting. window.addEventListener("keydown", onKeyDown, true); return () => window.removeEventListener("keydown", onKeyDown, true); - }, [keybindings, toggleSidebar]); + }, [ + clientSettingsHydrated, + keybindings, + previewOpen, + terminalOpen, + toggleSidebar, + updateClientSettings, + ]); return ( // The right-side layout controls carry mr-px (border compensation inside diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 9499ee5a6915..bb5a84409719 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { orderedListGutterStyle } from "./ChatMarkdown"; +import { orderedListGutterStyle, resolveChatMarkdownEnvironmentId } from "./ChatMarkdown"; describe("orderedListGutterStyle", () => { it("leaves the default gutter alone for single-digit lists", () => { @@ -34,3 +35,25 @@ describe("orderedListGutterStyle", () => { expect(orderedListGutterStyle(0, undefined)).toBeUndefined(); }); }); + +describe("resolveChatMarkdownEnvironmentId", () => { + it("uses the supplied thread environment for cross-environment markdown actions", () => { + const activeEnvironmentId = EnvironmentId.make("environment-a"); + const threadEnvironmentId = EnvironmentId.make("environment-b"); + + expect( + resolveChatMarkdownEnvironmentId(activeEnvironmentId, { + environmentId: threadEnvironmentId, + threadId: ThreadId.make("thread-b"), + }), + ).toBe(threadEnvironmentId); + }); + + it("falls back to the active environment without a thread reference", () => { + const activeEnvironmentId = EnvironmentId.make("environment-a"); + + expect(resolveChatMarkdownEnvironmentId(activeEnvironmentId, undefined)).toBe( + activeEnvironmentId, + ); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c3e1c5288da7..956e84eb0dab 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -13,7 +13,7 @@ import { TriangleAlertIcon, WrapTextIcon, } from "lucide-react"; -import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -112,6 +112,7 @@ interface ChatMarkdownProps { cwd: string | undefined; threadRef?: ScopedThreadRef | undefined; onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; + taskListDisabled?: boolean; isStreaming?: boolean; skills?: ReadonlyArray>; className?: string; @@ -121,6 +122,13 @@ interface ChatMarkdownProps { parseRawHtml?: boolean; } +export function resolveChatMarkdownEnvironmentId( + activeEnvironmentId: EnvironmentId | null, + threadRef: ScopedThreadRef | undefined, +): EnvironmentId | null { + return threadRef?.environmentId ?? activeEnvironmentId; +} + const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; @@ -1360,6 +1368,7 @@ function ChatMarkdown({ cwd, threadRef, onTaskListChange, + taskListDisabled = false, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, className, @@ -1377,7 +1386,8 @@ function ChatMarkdown({ reportFailure: false, }); const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); - const environmentId = useActiveEnvironmentId(); + const activeEnvironmentId = useActiveEnvironmentId(); + const environmentId = resolveChatMarkdownEnvironmentId(activeEnvironmentId, threadRef); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInPreferredEditor = useOpenInPreferredEditor( environmentId, @@ -1617,6 +1627,7 @@ function ChatMarkdown({ name="markdown-task" aria-label="Toggle task" checked={checked} + disabled={taskListDisabled} onChange={(event) => { const markerOffset = Number( event.currentTarget.closest("li")?.dataset.taskMarkerOffset, @@ -1780,6 +1791,7 @@ function ChatMarkdown({ openMarkdownFileInPreview, resolvedTheme, skills, + taskListDisabled, text, threadRef, ]); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8dcb6b8ed932..ea79bc512a7c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -84,6 +84,7 @@ import { collapseExpandedComposerCursor, type ComposerSubmissionIntent, parseStandaloneComposerSlashCommand, + parseThreadAnnotationSlashCommand, } from "../composer-logic"; import { derivePendingApprovals, @@ -170,6 +171,7 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, + CircleAlertIcon, ChevronDownIcon, GitBranchIcon, PaperclipIcon, @@ -296,6 +298,11 @@ import { threadChangeRequestSnapshotsAtom, } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { + runThreadAnnotationBodySave, + ThreadAnnotationEditorDialog, + ThreadAnnotationPostIt, +} from "./thread-annotation/ThreadAnnotation"; import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, @@ -1260,6 +1267,15 @@ function ChatViewContent(props: ChatViewProps) { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const upsertThreadAnnotation = useAtomCommand(threadEnvironment.upsertAnnotation, { + reportFailure: false, + }); + const resolveThreadAnnotation = useAtomCommand(threadEnvironment.resolveAnnotation, { + reportFailure: false, + }); + const reopenThreadAnnotation = useAtomCommand(threadEnvironment.reopenAnnotation, { + reportFailure: false, + }); const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, @@ -1280,6 +1296,12 @@ function ChatViewContent(props: ChatViewProps) { const revertThreadCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, { reportFailure: false, }); + const resumeActionFollowUp = useAtomCommand(threadEnvironment.resumeAction, { + reportFailure: false, + }); + const discardActionFollowUp = useAtomCommand(threadEnvironment.discardAction, { + reportFailure: false, + }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); const closePreview = useAtomCommand(previewEnvironment.close, "preview close"); const { environments } = useEnvironments(); @@ -4261,6 +4283,81 @@ function ChatViewContent(props: ChatViewProps) { ); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; + const supportsThreadAnnotations = + serverConfig?.environment.capabilities.threadAnnotations === true; + const threadAnnotation = activeThread?.annotation ?? null; + const canAnnotateThread = + isServerThread && + supportsThreadAnnotations && + (threadAnnotation !== null || + activeThread?.messages.some((message) => message.role === "user") === true); + const [annotationEditorOpen, setAnnotationEditorOpen] = useState(false); + const [annotationMutationPending, setAnnotationMutationPending] = useState(false); + const [dismissedAnnotationKey, setDismissedAnnotationKey] = useState(null); + const annotationVersionKey = threadAnnotation + ? `${routeThreadKey}:${threadAnnotation.updatedAt}` + : null; + + useEffect(() => { + setDismissedAnnotationKey(null); + setAnnotationEditorOpen(false); + }, [routeThreadKey]); + + const reportAnnotationFailure = useCallback((action: string, error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to ${action} annotation`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, []); + + const saveThreadAnnotation = useCallback( + async (body: string): Promise => { + if (!activeThread || !canAnnotateThread) return false; + return runThreadAnnotationBodySave( + scopeThreadRef(activeThread.environmentId, activeThread.id), + async () => { + setAnnotationMutationPending(true); + const result = await upsertThreadAnnotation({ + environmentId: activeThread.environmentId, + input: { threadId: activeThread.id, body }, + }); + setAnnotationMutationPending(false); + if (result._tag === "Success") return true; + if (!isAtomCommandInterrupted(result)) { + reportAnnotationFailure("save", squashAtomCommandFailure(result)); + } + return false; + }, + ); + }, + [activeThread, canAnnotateThread, reportAnnotationFailure, upsertThreadAnnotation], + ); + + const changeThreadAnnotationResolution = useCallback( + async (next: "resolve" | "reopen") => { + if (!activeThread || !canAnnotateThread) return; + setAnnotationMutationPending(true); + const command = next === "resolve" ? resolveThreadAnnotation : reopenThreadAnnotation; + const result = await command({ + environmentId: activeThread.environmentId, + input: { threadId: activeThread.id }, + }); + setAnnotationMutationPending(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportAnnotationFailure(next, squashAtomCommandFailure(result)); + } + }, + [ + activeThread, + canAnnotateThread, + reopenThreadAnnotation, + reportAnnotationFailure, + resolveThreadAnnotation, + ], + ); const nowMinute = useNowMinute(); const snoozeNow = new Date().toISOString(); const activeThreadSnoozed = @@ -4588,6 +4685,91 @@ function ChatViewContent(props: ChatViewProps) { handleStopBackgroundWork, isStoppingBackgroundWork, ]); + const [isResumingInterruptedAction, setIsResumingInterruptedAction] = useState(false); + const [isDiscardingInterruptedAction, setIsDiscardingInterruptedAction] = useState(false); + const interruptedAction = + activeThreadShell?.actionResume?.delivery === "available" + ? activeThreadShell.actionResume + : null; + useEffect(() => { + if (interruptedAction === null) { + setIsResumingInterruptedAction(false); + setIsDiscardingInterruptedAction(false); + } + }, [interruptedAction]); + const handleResumeInterruptedAction = useCallback(async () => { + if (!activeThreadRef || interruptedAction === null) return; + setIsResumingInterruptedAction(true); + const result = await resumeActionFollowUp({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId }, + }); + if (result._tag === "Failure") { + setIsResumingInterruptedAction(false); + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThreadRef.threadId, + error instanceof Error ? error.message : "Failed to resume the interrupted Action.", + ); + } + } + }, [activeThreadRef, interruptedAction, resumeActionFollowUp, setThreadError]); + const handleDiscardInterruptedAction = useCallback(async () => { + if (!activeThreadRef || interruptedAction === null) return; + setIsDiscardingInterruptedAction(true); + const result = await discardActionFollowUp({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId }, + }); + if (result._tag === "Failure") { + setIsDiscardingInterruptedAction(false); + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThreadRef.threadId, + error instanceof Error ? error.message : "Failed to discard the interrupted Action.", + ); + } + } + }, [activeThreadRef, discardActionFollowUp, interruptedAction, setThreadError]); + const interruptedActionBannerItem = useMemo(() => { + if (interruptedAction === null) return null; + return { + id: `action-interrupted:${interruptedAction.runId}`, + variant: "warning", + icon: , + title: `${interruptedAction.actionName} was interrupted`, + description: + "LastCode did not restart the command or wake the agent. Resume only the agent follow-up when you are ready.", + actions: ( +
+ + +
+ ), + }; + }, [ + handleDiscardInterruptedAction, + handleResumeInterruptedAction, + interruptedAction, + isDiscardingInterruptedAction, + isResumingInterruptedAction, + ]); // A woken thread announces itself in the open view, not just the sidebar // pill. Dismissing marks the wake as seen (same acknowledgment as the // pill); sending a message clears it as a side effect of the send path. @@ -4667,11 +4849,14 @@ function ChatViewContent(props: ChatViewProps) { const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item)); const backgroundLivenessItems = backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; + const interruptedActionItems = + interruptedActionBannerItem === null ? [] : [interruptedActionBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ ...urgentSystemItems, + ...interruptedActionItems, ...backgroundLivenessItems, ...calmSystemItems, ...wokeThreadItems, @@ -4680,6 +4865,7 @@ function ChatViewContent(props: ChatViewProps) { } return [ ...urgentSystemItems, + ...interruptedActionItems, ...backgroundLivenessItems, ...calmSystemItems, ...wokeThreadItems, @@ -4727,6 +4913,7 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeBranchMismatchKey, backgroundLivenessBannerItem, + interruptedActionBannerItem, handleRestoreThreadBranch, isRestoringThreadBranch, localCheckoutBranchMismatch, @@ -5085,7 +5272,51 @@ function ChatViewContent(props: ChatViewProps) { return; } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable) { + if (!sendCtx) { + notifyDirectAnnotationAttached(); + return; + } + const annotationSlashCommand = + !directAnnotation && + sendCtx.images.length === 0 && + sendCtx.terminalContexts.length === 0 && + sendCtx.elementContexts.length === 0 && + sendCtx.previewAnnotations.length === 0 && + sendCtx.reviewComments.length === 0 + ? parseThreadAnnotationSlashCommand(promptRef.current) + : null; + if (annotationSlashCommand) { + if (!canAnnotateThread) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: + isServerThread && !supportsThreadAnnotations + ? "Annotations unavailable" + : "Send a message first", + description: + isServerThread && !supportsThreadAnnotations + ? "This environment needs a newer LastCode server to annotate threads." + : "Annotations can be added after the thread has its first message.", + }), + ); + return; + } + if (annotationSlashCommand.kind === "open-editor") { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + setAnnotationEditorOpen(true); + return; + } + if (await saveThreadAnnotation(annotationSlashCommand.body)) { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + } + return; + } + if (!sendCtx.providerAvailable) { notifyDirectAnnotationAttached(); return; } @@ -6514,6 +6745,11 @@ function ChatViewContent(props: ChatViewProps) { isWorking={isWorking} workingStepLabel={workingStepLabel} activeTurnStartedAt={activeWorkStartedAt} + waitingStartedAt={ + activeThreadShell?.actionResume?.outcome === "running" + ? activeThreadShell.actionResume.startedAt + : null + } listRef={legendListRef} timelineEntries={timelineEntries} latestTurn={activeLatestTurn} @@ -6544,6 +6780,11 @@ function ChatViewContent(props: ChatViewProps) { hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} loadEarlier={loadEarlierTurns} + annotation={threadAnnotation} + onAnnotationBodyChange={saveThreadAnnotation} + onAnnotationEdit={() => setAnnotationEditorOpen(true)} + onAnnotationResolve={() => void changeThreadAnnotationResolution("resolve")} + onAnnotationReopen={() => void changeThreadAnnotationResolution("reopen")} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -6603,6 +6844,20 @@ function ChatViewContent(props: ChatViewProps) { ) : ( )} + {threadAnnotation && + threadAnnotation.resolvedAt === null && + annotationVersionKey !== dismissedAnnotationKey ? ( + setDismissedAnnotationKey(annotationVersionKey)} + onEdit={() => setAnnotationEditorOpen(true)} + onResolve={() => void changeThreadAnnotationResolution("resolve")} + pending={annotationMutationPending} + threadRef={routeThreadRef} + /> + ) : null} {threadSyncPhase && !activeEnvironmentUnavailable ? ( ) : null} @@ -6671,6 +6926,7 @@ function ChatViewContent(props: ChatViewProps) { keybindings={keybindings} terminalOpen={Boolean(terminalUiState.terminalOpen)} gitCwd={gitCwd} + threadAnnotationsSupported={canAnnotateThread} promptRef={promptRef} composerImagesRef={composerImagesRef} composerTerminalContextsRef={composerTerminalContextsRef} @@ -6698,6 +6954,7 @@ function ChatViewContent(props: ChatViewProps) { scheduleComposerFocus={scheduleComposerFocus} setThreadError={setThreadError} onExpandImage={onExpandTimelineImage} + onOpenThreadAnnotation={() => setAnnotationEditorOpen(true)} /> @@ -6787,6 +7044,13 @@ function ChatViewContent(props: ChatViewProps) { + + {pullRequestDialogState ? ( >; orderedProjectThreadKeys: readonly string[]; isActive: boolean; openPullRequestsInRightPanel: boolean; @@ -343,6 +369,9 @@ interface SidebarThreadRowProps { prUrl: string, threadRef?: ScopedThreadRef, ) => boolean; + onEditAnnotation: (thread: SidebarThreadSummary) => void; + onSaveAnnotationBody: (thread: SidebarThreadSummary, body: string) => Promise; + onResolveAnnotation: (thread: SidebarThreadSummary) => void; } export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { @@ -370,6 +399,10 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr cancelRename, attemptArchiveThread, openPrLink, + onEditAnnotation, + onSaveAnnotationBody, + onResolveAnnotation, + providerEntriesByEnvironmentId, thread, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); @@ -400,6 +433,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr // for desktop-local projects, see sidebarProjectGrouping). const isDesktopLocalThread = environment !== null && isDesktopLocalConnectionTarget(environment.entry.target); + const showsRemoteThreadIcon = isRemoteThread && !isDesktopLocalThread; const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) : null; @@ -463,16 +497,53 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; + const hasActiveAnnotation = thread.annotation?.resolvedAt === null; + const branchMismatch = resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", + activeWorktreePath: thread.worktreePath, + activeThreadBranch: thread.branch, + currentGitBranch: gitStatus.data?.refName ?? null, + }); + const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const environmentProviderEntries = providerEntriesByEnvironmentId.get(thread.environmentId); + const providerEntry = environmentProviderEntries?.get(modelInstanceId) ?? null; + const showInstanceBadge = + providerEntry !== null && + shouldShowInstanceBadge(providerEntry, environmentProviderEntries?.values() ?? []); + const selectedModel = providerEntry?.models.find( + (model) => model.slug === thread.modelSelection.model, + ); + const modelLabel = selectedModel + ? getTriggerDisplayModelLabel(selectedModel) + : thread.modelSelection.model; + const threadHoverDetails = ( + + ); const threadMetaClassName = isConfirmingArchive ? "pointer-events-none opacity-0" : !isThreadRunning ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" : "pointer-events-none"; + const [annotationRowActive, setAnnotationRowActive] = useState(false); const clearConfirmingArchive = useCallback(() => { setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); }, [setConfirmingArchiveThreadKey, threadKey]); const handleMouseLeave = useCallback(() => { clearConfirmingArchive(); + setAnnotationRowActive(false); }, [clearConfirmingArchive]); const handleBlurCapture = useCallback( (event: React.FocusEvent) => { @@ -482,6 +553,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr return; } clearConfirmingArchive(); + setAnnotationRowActive(false); }); }, [clearConfirmingArchive], @@ -674,6 +746,8 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr setAnnotationRowActive(true)} + onMouseEnter={() => setAnnotationRowActive(true)} onMouseLeave={handleMouseLeave} onBlurCapture={handleBlurCapture} > @@ -726,6 +800,13 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr onClick={handleRenameInputClick} onDoubleClick={handleRenameInputClick} /> + ) : hasActiveAnnotation ? ( + + {thread.title} + ) : ( } /> - - {thread.title} + + {threadHoverDetails} )} @@ -840,15 +927,18 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ) ) : null} - + - {isRemoteThread && !isDesktopLocalThread && ( + {showsRemoteThreadIcon && ( } > @@ -858,24 +948,69 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr )} {jumpLabel ? ( - - onSaveAnnotationBody(thread, body)} + onEdit={() => onEditAnnotation(thread)} + onResolve={() => onResolveAnnotation(thread)} + rowActive={annotationRowActive} + threadDetails={threadHoverDetails} + threadRef={threadRef} + trigger={ + aria-label={`${jumpLabel}; annotated`} + className="inline-flex h-5 items-center rounded-full border border-dotted border-yellow-500/65 bg-accent/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-accent-foreground shadow-sm" + > + {jumpLabel} + } - > - {jumpLabel} - - {jumpLabel} - + /> + ) : ( + + + } + > + {jumpLabel} + + {jumpLabel} + + ) + ) : hasActiveAnnotation && thread.annotation ? ( + onSaveAnnotationBody(thread, body)} + onEdit={() => onEditAnnotation(thread)} + onResolve={() => onResolveAnnotation(thread)} + rowActive={annotationRowActive} + threadDetails={threadHoverDetails} + threadRef={threadRef} + trigger={ + + {formatRelativeTimeLabel( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + )} + + } + /> ) : ( {formatRelativeTimeLabel( thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, @@ -892,6 +1027,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }); interface SidebarProjectThreadListProps { + legacySidebarScale: LegacySidebarScale; + scaleStyle: CSSProperties; + providerEntriesByEnvironmentId: ReadonlyMap>; projectKey: string; projectExpanded: boolean; hasOverflowingThreads: boolean; @@ -940,6 +1078,9 @@ interface SidebarProjectThreadListProps { prUrl: string, threadRef?: ScopedThreadRef, ) => boolean; + onEditAnnotation: (thread: SidebarThreadSummary) => void; + onSaveAnnotationBody: (thread: SidebarThreadSummary, body: string) => Promise; + onResolveAnnotation: (thread: SidebarThreadSummary) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; } @@ -948,6 +1089,9 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( props: SidebarProjectThreadListProps, ) { const { + legacySidebarScale, + scaleStyle, + providerEntriesByEnvironmentId, projectKey, projectExpanded, hasOverflowingThreads, @@ -981,6 +1125,9 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( cancelRename, attemptArchiveThread, openPrLink, + onEditAnnotation, + onSaveAnnotationBody, + onResolveAnnotation, expandThreadListForProject, collapseThreadListForProject, } = props; @@ -991,6 +1138,8 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( {shouldShowThreadPanel && showEmptyThreadState ? ( @@ -1010,6 +1159,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( key={threadKey} thread={thread} projectCwd={projectCwd} + providerEntriesByEnvironmentId={providerEntriesByEnvironmentId} orderedProjectThreadKeys={orderedProjectThreadKeys} isActive={activeRouteThreadKey === threadKey} openPullRequestsInRightPanel={openPullRequestsInRightPanel} @@ -1033,6 +1183,9 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( cancelRename={cancelRename} attemptArchiveThread={attemptArchiveThread} openPrLink={openPrLink} + onEditAnnotation={onEditAnnotation} + onSaveAnnotationBody={onSaveAnnotationBody} + onResolveAnnotation={onResolveAnnotation} /> ); })} @@ -1075,6 +1228,9 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( }); interface SidebarProjectItemProps { + legacySidebarScale: LegacySidebarScale; + scaleStyle: CSSProperties; + providerEntriesByEnvironmentId: ReadonlyMap>; project: SidebarProjectSnapshot; isThreadListExpanded: boolean; activeRouteThreadKey: string | null; @@ -1096,6 +1252,9 @@ interface SidebarProjectItemProps { const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { const { + legacySidebarScale, + scaleStyle, + providerEntriesByEnvironmentId, project, isThreadListExpanded, activeRouteThreadKey, @@ -1133,6 +1292,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const upsertThreadAnnotation = useAtomCommand(threadEnvironment.upsertAnnotation, { + reportFailure: false, + }); + const resolveThreadAnnotation = useAtomCommand(threadEnvironment.resolveAnnotation, { + reportFailure: false, + }); const updateSettings = useUpdateClientSettings(); const sidebarThreadPreviewCount = useClientSettings( (settings) => settings.sidebarThreadPreviewCount, @@ -1221,6 +1386,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState(null); + const [annotationEditorTarget, setAnnotationEditorTarget] = useState( + null, + ); const [projectRenameTarget, setProjectRenameTarget] = useState( null, ); @@ -1734,6 +1902,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ) => { if (isSidebarNestedLinkClick(event.target)) return; const isMac = isMacPlatform(navigator.platform); + if ( + isContextMenuPointerDown({ + button: event.button, + ctrlKey: event.ctrlKey, + isMac, + }) + ) { + event.preventDefault(); + return; + } const isModClick = isMac ? event.metaKey : event.ctrlKey; const isShiftClick = event.shiftKey; const threadKey = scopedThreadKey(threadRef); @@ -1998,6 +2176,61 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec renamingInputRef.current = null; }, []); + const saveAnnotationBody = useCallback( + async (target: SidebarThreadSummary, body: string): Promise => { + return runThreadAnnotationBodySave( + scopeThreadRef(target.environmentId, target.id), + async () => { + const result = await upsertThreadAnnotation({ + environmentId: target.environmentId, + input: { threadId: target.id, body }, + }); + if (result._tag === "Success") return true; + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to save annotation", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return false; + }, + ); + }, + [upsertThreadAnnotation], + ); + + const saveAnnotation = useCallback( + async (body: string): Promise => { + if (!annotationEditorTarget) return false; + return saveAnnotationBody(annotationEditorTarget, body); + }, + [annotationEditorTarget, saveAnnotationBody], + ); + + const resolveAnnotation = useCallback( + async (thread: SidebarThreadSummary) => { + const result = await resolveThreadAnnotation({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to resolve annotation", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [resolveThreadAnnotation], + ); + const startThreadRename = useCallback((threadKey: string, title: string) => { setRenamingThreadKey(threadKey); setRenamingTitle(title); @@ -2138,12 +2371,18 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); const threadWorkspacePath = thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null; + const supportsThreadAnnotations = readEnvironmentSupportsThreadAnnotations( + thread.environmentId, + ); const clicked = await api.contextMenu.show( [ ...(thread.branch ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }] : []), { id: "rename", label: "Rename thread" }, + ...(supportsThreadAnnotations && thread.latestUserMessageAt !== null + ? [{ id: "annotate", label: "Annotate thread…" }] + : []), { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, @@ -2181,6 +2420,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } + if (clicked === "annotate") { + setAnnotationEditorTarget(thread); + return; + } + if (clicked === "mark-unread") { markThreadUnread(threadKey, thread.latestTurn?.completedAt); return; @@ -2243,7 +2487,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return ( <> -
+
void resolveAnnotation(thread)} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} /> + { + if (!open) setAnnotationEditorTarget(null); + }} + onSave={saveAnnotation} + /> + { @@ -2794,6 +3058,75 @@ interface SidebarProjectsContentProps { suppressProjectClickForContextMenuRef: React.RefObject; attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; projectsLength: number; + legacySidebarScale: LegacySidebarScale; + projectTreeScaleStyle: CSSProperties; + providerEntriesByEnvironmentId: ReadonlyMap>; +} + +// Drafts the user typed into but never sent, rendered above the projects +// list so the legacy sidebar reaches parity with the v2 sidebar: without +// these rows a draft started under this sidebar has no way back in and +// silently piles up in the v2 list. Self-contained (own store and route +// subscriptions) so per-keystroke composer updates re-render only this +// block and SidebarProjectsContent's memo stays intact. SidebarDraftBlock +// renders nothing at count 0, so the wrapping menu collapses to zero +// height and costs the empty sidebar no space. +function LegacySidebarDraftList() { + const projects = useProjects(); + const navigate = useNavigate(); + const { isMobile, setOpenMobile } = useSidebar(); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const routeDraftId = routeTarget?.kind === "draft" ? routeTarget.draftId : null; + // The legacy list has no grouped display names on draft rows; the + // project's own title is what its header shows for local projects. + const projectTitleByKey = useMemo( + () => + new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])), + [projects], + ); + const projectCwdByKey = useMemo( + () => + new Map( + projects.map((project) => [ + `${project.environmentId}:${project.id}`, + project.workspaceRoot, + ]), + ), + [projects], + ); + const projectFaviconPathByKey = useMemo( + () => + new Map( + projects.map((project) => [`${project.environmentId}:${project.id}`, project.faviconPath]), + ), + [projects], + ); + const navigateToDraft = useCallback( + (draftId: DraftId) => { + clearSelection(); + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/draft/$draftId", params: { draftId } }); + }, + [clearSelection, isMobile, navigate, setOpenMobile], + ); + return ( + + + + ); } const SidebarProjectsContent = memo(function SidebarProjectsContent( @@ -2836,6 +3169,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( suppressProjectClickForContextMenuRef, attachProjectListAutoAnimateRef, projectsLength, + legacySidebarScale, + projectTreeScaleStyle, + providerEntriesByEnvironmentId, } = props; const handleProjectSortOrderChange = useCallback( @@ -2912,6 +3248,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( ) : null} +
Projects
@@ -2961,6 +3298,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( {(dragHandleProps) => ( ( No projects yet
+
+ No projects yet +
)} @@ -3037,6 +3386,12 @@ export default function LegacySidebar() { const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); + const legacySidebarScale = useClientSettings((s) => s.legacySidebarScale); + const serverProviders = useAtomValue(primaryServerProvidersAtom); + const scaleStyle = useMemo( + () => legacySidebarScaleStyle(legacySidebarScale), + [legacySidebarScale], + ); const updateSettings = useUpdateClientSettings(); const handleNewThread = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); @@ -3079,6 +3434,23 @@ export default function LegacySidebar() { const terminalFocused = useTerminalFocus(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const providerEntriesByEnvironmentId = useMemo(() => { + const entriesByEnvironmentId = new Map>(); + for (const environment of environments) { + const environmentProviders = + environment.serverConfig?.providers ?? + (environment.environmentId === primaryEnvironmentId ? serverProviders : []); + entriesByEnvironmentId.set( + environment.environmentId, + new Map( + deriveProviderInstanceEntries(environmentProviders).map( + (entry) => [entry.instanceId as string, entry] as const, + ), + ), + ); + } + return entriesByEnvironmentId; + }, [environments, primaryEnvironmentId, serverProviders]); const environmentLabelById = useMemo( () => new Map( @@ -3698,6 +4070,9 @@ export default function LegacySidebar() { suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef} attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef} projectsLength={projects.length} + legacySidebarScale={legacySidebarScale} + projectTreeScaleStyle={scaleStyle} + providerEntriesByEnvironmentId={providerEntriesByEnvironmentId} /> diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index bbeeda4bc7fb..063e80133d40 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -70,6 +70,8 @@ type ProjectFaviconImageProps = { type ImageElement = ReactElement<{ readonly src: string; + readonly "data-slot"?: string; + readonly className?: string; readonly onLoad?: () => void; readonly onError?: () => void; }>; @@ -143,4 +145,14 @@ describe("ProjectFavicon", () => { path: "brand/icon.svg", }); }); + + it("marks displayed images for global project icon styling", () => { + const { Component, props } = resolveImageComponent(); + const loadingImage = renderImage(Component, props).props.children[2]; + loadingImage?.props.onLoad?.(); + + const displayedImage = renderImage(Component, props).props.children[1]; + expect(displayedImage?.props["data-slot"]).toBe("project-favicon"); + expect(displayedImage?.props.className).not.toContain("rounded-sm"); + }); }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 619bbf370018..b4b0302ec82a 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -92,7 +92,8 @@ function ProjectFaviconImage({ handleLoadError(displayedSrc)} /> ) : null} diff --git a/apps/web/src/components/ProjectScopeBreadcrumb.tsx b/apps/web/src/components/ProjectScopeBreadcrumb.tsx new file mode 100644 index 000000000000..090b54f91782 --- /dev/null +++ b/apps/web/src/components/ProjectScopeBreadcrumb.tsx @@ -0,0 +1,96 @@ +import { settlePromise } from "@t3tools/client-runtime/state/runtime"; +import type { ContextMenuItem } from "@t3tools/contracts"; +import { ChevronDownIcon } from "lucide-react"; +import type { MouseEvent as ReactMouseEvent } from "react"; + +import { readLocalApi } from "../localApi"; +import { + WorkspaceBreadcrumb, + WorkspaceBreadcrumbItem, + WorkspaceBreadcrumbSeparator, +} from "./WorkspaceBreadcrumb"; + +const ALL_PROJECTS_MENU_ID = "all"; + +export interface ProjectScopeBreadcrumbItem { + readonly id: string; + readonly label: string; +} + +export function ProjectScopeBreadcrumb(props: { + readonly allLabel?: string | undefined; + readonly ariaLabel: string; + readonly items: ReadonlyArray; + readonly onSelect: (projectKey: string | null) => void; + readonly rootLabel: string; + readonly selectedKey: string | null; + readonly unavailableLabel: string; +}) { + const selectedLabel = + props.selectedKey === null + ? (props.allLabel ?? null) + : (props.items.find((item) => item.id === props.selectedKey)?.label ?? null); + const selectionAvailable = props.allLabel !== undefined || props.items.length > 0; + const openProjectMenu = (event: ReactMouseEvent) => { + const api = readLocalApi(); + if (!api) return; + + const rect = event.currentTarget.getBoundingClientRect(); + const projectKeyByMenuId = new Map( + props.items.map((item, index) => [`project:${index}`, item.id] as const), + ); + const items: ContextMenuItem[] = [ + ...(props.allLabel + ? [{ id: ALL_PROJECTS_MENU_ID, label: props.allLabel } satisfies ContextMenuItem] + : []), + ...props.items.map((item, index) => ({ id: `project:${index}`, label: item.label })), + ]; + void settlePromise(() => + api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }), + ).then((clicked) => { + if (clicked._tag === "Failure" || clicked.value === null) return; + if (clicked.value === ALL_PROJECTS_MENU_ID) { + props.onSelect(null); + return; + } + const projectKey = projectKeyByMenuId.get(clicked.value); + if (projectKey !== undefined) { + props.onSelect(projectKey); + } + }); + }; + + return ( + + {props.rootLabel} + + + {selectedLabel || selectionAvailable ? ( + + ) : ( + {props.unavailableLabel} + )} + + + ); +} diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 304922909b0a..6278b8c41ec1 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -116,6 +116,8 @@ export default function ProjectScriptsControl({ keybinding: null, previewUrl: fileScript.previewUrl ?? null, autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, + // Checked-in Actions are never silently granted agent execution. + allowAgentResume: false, }; const result = await onAddScript(payload); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { diff --git a/apps/web/src/components/QuitHoldOverlay.tsx b/apps/web/src/components/QuitHoldOverlay.tsx index 29c044015212..33c892cddb92 100644 --- a/apps/web/src/components/QuitHoldOverlay.tsx +++ b/apps/web/src/components/QuitHoldOverlay.tsx @@ -13,13 +13,15 @@ const HIDE_AFTER_RELEASE_MS = 1200; */ export function QuitHoldOverlay() { const [visible, setVisible] = useState(false); + const [runningActionCount, setRunningActionCount] = useState(0); useEffect(() => { const subscribe = window.desktopBridge?.onQuitShortcut; if (!subscribe) return; let hideTimer: number | undefined; - const unsubscribe = subscribe((state) => { + const unsubscribe = subscribe((state, nextRunningActionCount) => { window.clearTimeout(hideTimer); + setRunningActionCount(nextRunningActionCount); if (state === "down") { setVisible(true); return; @@ -41,6 +43,9 @@ export function QuitHoldOverlay() { >
Hold {shortcut} to Quit + {runningActionCount > 0 + ? ` and cancel ${runningActionCount} running ${runningActionCount === 1 ? "Action" : "Actions"}` + : ""}
); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index ba75f2eaaf54..8f88f4740b16 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -737,6 +737,20 @@ describe("resolveSidebarThreadStatus", () => { ).toBe("working"); }); + it("reports Waiting for a running Action once higher-priority work is idle", () => { + const actionResume = { outcome: "running" } as never; + expect(resolveSidebarThreadStatus({ ...idle, session: null, actionResume })).toBe("waiting"); + expect(resolveSidebarThreadStatus({ ...idle, session, actionResume })).toBe("working"); + expect( + resolveSidebarThreadStatus({ + ...idle, + hasPendingApprovals: true, + session: null, + actionResume, + }), + ).toBe("approval"); + }); + it("reports failed only while the session status is error", () => { expect( resolveSidebarThreadStatus({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 747fc07d3daf..8e6def581d51 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -127,6 +127,7 @@ export function buildBulkTitleRegenerationContextMenuItem(input: { export interface ThreadStatusPill { label: | "Working" + | "Waiting" | "Monitoring" | "Connecting" | "Completed" @@ -146,6 +147,7 @@ const THREAD_STATUS_PRIORITY: Record = { "Awaiting Input": 5, Working: 4, Connecting: 4, + Waiting: 3, "Plan Ready": 3, Monitoring: 2, Completed: 1, @@ -160,6 +162,7 @@ type ThreadStatusInput = Pick< | "latestTurn" | "session" | "backgroundLiveness" + | "actionResume" > & { lastVisitedAt?: string | undefined; }; @@ -470,13 +473,14 @@ export type SidebarThreadStatus = | "approval" | "input" | "working" + | "waiting" | "monitoring" | "failed" | "ready"; type SidebarThreadStatusInput = Pick< SidebarThreadSummary, - "hasPendingApprovals" | "hasPendingUserInput" | "session" | "backgroundLiveness" + "hasPendingApprovals" | "hasPendingUserInput" | "session" | "backgroundLiveness" | "actionResume" >; export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus { @@ -502,6 +506,9 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si if (thread.backgroundLiveness === "monitoring") { return "monitoring"; } + if (thread.actionResume?.outcome === "running") { + return "waiting"; + } return "ready"; } @@ -721,6 +728,15 @@ export function resolveThreadStatusPill(input: { }; } + if (thread.actionResume?.outcome === "running") { + return { + label: "Waiting", + colorClass: "text-yellow-700 dark:text-yellow-300/90", + dotClass: "bg-yellow-500 dark:bg-yellow-300/90", + pulse: false, + }; + } + if (hasUnseenCompletion(thread)) { return { label: "Completed", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index af7cdc8a94a2..c23f85960af7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -37,13 +37,11 @@ import { AlarmClockOffIcon, CheckIcon, ChevronDownIcon, - CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, ClockIcon, FolderIcon, FolderPlusIcon, - GitBranchIcon, MessageSquareIcon, PinIcon, PlusIcon, @@ -110,6 +108,7 @@ import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; +import { terminalEnvironment } from "../state/terminal"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { @@ -154,7 +153,6 @@ import { terminalStatusFromRunningIds, threadChangeRequestSnapshotsAtom, type ThreadChangeRequestSnapshot, - type TerminalStatusIndicator, } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, @@ -164,6 +162,10 @@ import { } from "./Sidebar.snooze"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; +import { + SidebarThreadHoverContent, + type SidebarThreadHoverContentProps, +} from "./sidebar/SidebarThreadHoverContent"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { deriveProviderEntriesByEnvironment, @@ -253,37 +255,7 @@ function terminalProcessLabel(count: number): string { return `${count} terminal ${count === 1 ? "process" : "processes"} running`; } -function SidebarThreadTooltip({ - thread, - projectTitle, - projectCwd, - projectFaviconPath, - environmentLabel, - providerEntry, - showInstanceBadge, - modelInstanceId, - modelLabel, - branchMismatch, - terminalStatus, - terminalProcessCount, -}: { - thread: SidebarThreadSummary; - projectTitle: string | null; - projectCwd: string | null; - projectFaviconPath: string | null; - environmentLabel: string | null; - providerEntry: ProviderInstanceEntry | null; - showInstanceBadge: boolean; - modelInstanceId: string; - modelLabel: string; - branchMismatch: { - threadBranch: string; - currentBranch: string; - } | null; - terminalStatus: TerminalStatusIndicator | null; - terminalProcessCount: number; -}) { - const driverKind = providerEntry?.driverKind ?? null; +function SidebarThreadTooltip(props: SidebarThreadHoverContentProps) { return ( -
-
- {thread.title} -
-
- {projectTitle ? ( -
- -
{projectTitle}
-
- ) : null} - {environmentLabel ? ( -
- -
{environmentLabel}
-
- ) : null} - {thread.branch ? ( -
- -
{thread.branch}
-
- ) : null} - {branchMismatch ? ( -
- -
- You're currently checked out on another branch. -
-
- ) : null} - {driverKind ? ( -
- -
- {showInstanceBadge && providerEntry - ? `${modelLabel} · ${providerEntry.displayName}` - : modelLabel} -
-
- ) : null} - {terminalStatus ? ( -
- -
- {terminalProcessLabel(terminalProcessCount)} -
-
- ) : null} - {thread.session?.lastError ? ( -
- -
Error occurred
-
- ) : null} -
-
+
); } @@ -575,7 +472,9 @@ interface SidebarDraftRowData { // interrupted "new thread" stays one click away. Self-contained (own store // subscription + closing divider) so per-keystroke composer updates // re-render only this block, never the whole sidebar. Vanishes at count 0. -const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { +// Exported for the legacy sidebar, which renders the same block above its +// projects list so drafts stay reachable there too. +export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectDisplayNameByKey: ReadonlyMap; projectCwdByKey: ReadonlyMap; projectFaviconPathByKey: ReadonlyMap; @@ -788,6 +687,34 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const terminalProcessCount = runningTerminalIds.length; + const closeTerminal = useAtomCommand(terminalEnvironment.close, "cancel Project Action"); + const ensureTerminal = useTerminalUiStateStore((state) => state.ensureTerminal); + const actionResume = thread.actionResume ?? null; + const openActionTerminal = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (actionResume === null) return; + ensureTerminal(threadRef, actionResume.terminalId, { open: true, active: true }); + onThreadActivate(threadRef); + }, + [actionResume, ensureTerminal, onThreadActivate, threadRef], + ); + const cancelAction = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (actionResume === null) return; + void closeTerminal({ + environmentId: thread.environmentId, + input: { + threadId: thread.id, + terminalId: actionResume.terminalId, + }, + }); + }, + [actionResume, closeTerminal, thread.environmentId, thread.id], + ); const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( @@ -834,7 +761,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // working threads aren't your problem yet) — only the colored status label // stands out. const isInFlight = - status === "working" || status === "monitoring" || status === "approval" || status === "input"; + status === "working" || + status === "monitoring" || + status === "waiting" || + status === "approval" || + status === "input"; const shouldRecede = (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected; // Status hues follow the system-wide convention set by sidebar v1 and the @@ -860,37 +791,43 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { icon: null, className: "text-sky-600 dark:text-sky-400", } - : status === "approval" + : status === "waiting" ? { - label: "Approval", - icon: null, - className: "text-amber-700 dark:text-amber-300", + label: "Waiting", + icon: "waiting" as const, + className: "text-yellow-700 dark:text-yellow-300", } - : status === "input" + : status === "approval" ? { - label: "Input", + label: "Approval", icon: null, - className: "text-indigo-600 dark:text-indigo-300", + className: "text-amber-700 dark:text-amber-300", } - : status === "failed" + : status === "input" ? { - label: "Failed", + label: "Input", icon: null, - className: "text-red-700 dark:text-red-300", + className: "text-indigo-600 dark:text-indigo-300", } - : isWoke + : status === "failed" ? { - label: "Woke", - icon: "woke" as const, - className: "text-amber-700 dark:text-amber-300", + label: "Failed", + icon: null, + className: "text-red-700 dark:text-red-300", } - : isUnread + : isWoke ? { - label: "Done", - icon: "done" as const, - className: "text-emerald-700 dark:text-emerald-300", + label: "Woke", + icon: "woke" as const, + className: "text-amber-700 dark:text-amber-300", } - : null; + : isUnread + ? { + label: "Done", + icon: "done" as const, + className: "text-emerald-700 dark:text-emerald-300", + } + : null; const isWokeStatus = topStatus?.icon === "woke"; const branchMismatch = resolveLocalCheckoutBranchMismatch({ @@ -1430,7 +1367,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { while the other controls appear beside it. */} {topStatus ? ( - isWokeStatus ? ( + status === "waiting" && actionResume !== null ? ( + + event.stopPropagation()} + className={cn( + "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring", + topStatus.className, + )} + /> + } + > + + Waiting + + event.stopPropagation()} + > +
+
+

{actionResume.actionName}

+

+ Running for +

+
+
+ + +
+
+
+
+ ) : isWokeStatus ? ( {topStatus.icon === "working" ? ( + ) : topStatus.icon === "waiting" ? ( + ) : topStatus.icon === "done" ? ( ) : null} @@ -1735,6 +1726,7 @@ export default function Sidebar() { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const closeTerminal = useAtomCommand(terminalEnvironment.close, "cancel Project Action"); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ @@ -1798,6 +1790,18 @@ export default function Sidebar() { ); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge?.reportRunningActionCount) return; + const localEnvironmentIds = new Set( + bridge.getLocalEnvironmentBootstraps().map((bootstrap) => bootstrap.id), + ); + const runningActionCount = threads.filter( + (thread) => + localEnvironmentIds.has(thread.environmentId) && thread.actionResume?.outcome === "running", + ).length; + void bridge.reportRunningActionCount(runningActionCount); + }, [threads]); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); @@ -2321,6 +2325,36 @@ export default function Sidebar() { }, [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); + const interruptedActionNoticeKeyRef = useRef(null); + const interruptedActionThreads = useMemo( + () => threads.filter((thread) => thread.actionResume?.delivery === "available"), + [threads], + ); + useEffect(() => { + if (interruptedActionThreads.length === 0) return; + const noticeKey = interruptedActionThreads + .map((thread) => thread.actionResume?.runId ?? "") + .sort() + .join(":"); + if (noticeKey === interruptedActionNoticeKeyRef.current) return; + interruptedActionNoticeKeyRef.current = noticeKey; + const first = interruptedActionThreads[0]; + if (!first) return; + const count = interruptedActionThreads.length; + toastManager.add( + stackedThreadToast({ + type: "warning", + title: `${count} Action${count === 1 ? " was" : "s were"} interrupted`, + description: "No command was restarted and no agent was woken.", + timeout: 0, + actionProps: { + children: "Review", + onClick: () => navigateToThread(scopeThreadRef(first.environmentId, first.id)), + }, + data: { hideCopyButton: true }, + }), + ); + }, [interruptedActionThreads, navigateToThread]); const navigateToDraft = useCallback( (draftId: DraftId) => { @@ -3076,6 +3110,7 @@ export default function Sidebar() { isRegeneratingTitle, isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null, + hasRunningAction: thread.actionResume?.outcome === "running", supports: { settlement: supportsSettlement, snooze: supportsSnooze, @@ -3155,6 +3190,15 @@ export default function Sidebar() { } return; } + case "cancel-action": { + const action = thread.actionResume; + if (action?.outcome !== "running") return; + await closeTerminal({ + environmentId: thread.environmentId, + input: { threadId: thread.id, terminalId: action.terminalId }, + }); + return; + } case "mark-unread": markThreadUnread(threadKey, thread.latestTurn?.completedAt); return; @@ -3241,6 +3285,7 @@ export default function Sidebar() { }, [ archiveThread, + closeTerminal, attemptPin, attemptSettle, attemptSnooze, diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index cfb726271966..b2a8dcb7046e 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -367,6 +367,7 @@ export function ThreadStatusLabel({ } > { + it("themes Last and Code independently", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('aria-label="LastCode"'); + expect(markup).toContain('data-wordmark-part="last"'); + expect(markup).toContain("text-sidebar-foreground"); + expect(markup).toContain('data-wordmark-part="code"'); + expect(markup).toContain("text-sidebar-muted-foreground"); + expect(markup.match(/ { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('class="text-white"'); + expect(markup).toContain('class="text-white/70"'); + }); +}); diff --git a/apps/web/src/components/branding/LastCodeWordmark.tsx b/apps/web/src/components/branding/LastCodeWordmark.tsx new file mode 100644 index 000000000000..41fb14e3ad63 --- /dev/null +++ b/apps/web/src/components/branding/LastCodeWordmark.tsx @@ -0,0 +1,34 @@ +import { cn } from "../../lib/utils"; + +export function LastCodeWordmark({ onBackdrop = false }: { onBackdrop?: boolean }) { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f06a9658225f..8b0135c3470a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -596,6 +596,7 @@ export interface ChatComposerProps { keybindings: ResolvedKeybindingsConfig; terminalOpen: boolean; gitCwd: string | null; + threadAnnotationsSupported: boolean; // Refs the parent needs kept in sync promptRef: React.RefObject; @@ -633,6 +634,7 @@ export interface ChatComposerProps { scheduleComposerFocus: () => void; setThreadError: (threadId: ThreadId | null, error: string | null) => void; onExpandImage: (preview: ExpandedImagePreview) => void; + onOpenThreadAnnotation: () => void; } // -------------------------------------------------------------------------- @@ -684,6 +686,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) keybindings, terminalOpen, gitCwd, + threadAnnotationsSupported, promptRef, composerRef, composerImagesRef, @@ -706,6 +709,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) scheduleComposerFocus, setThreadError, onExpandImage, + onOpenThreadAnnotation, } = props; const isSendDisabled = sendDisabledReason !== null; @@ -1088,6 +1092,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) label: "/model", description: "Switch response model for this thread", }, + ...(threadAnnotationsSupported + ? ([ + { + id: "slash:annotate", + type: "slash-command", + command: "annotate", + label: "/annotate", + description: "Add or edit this thread's annotation", + }, + ] as const) + : []), ...(planModeUiEnabled ? ([ { @@ -1159,6 +1174,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) planModeUiEnabled, selectedProvider, selectedProviderStatus, + threadAnnotationsSupported, workspaceEntries.entries, ]); @@ -1769,6 +1785,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (item.command === "annotate") { + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + focusEditorAfterReplace: false, + }); + if (applied) { + setComposerHighlightedItemId(null); + onOpenThreadAnnotation(); + } + return; + } void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), @@ -1815,7 +1842,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } }, - [applyPromptReplacement, handleInteractionModeChange, resolveActiveComposerTrigger], + [ + applyPromptReplacement, + handleInteractionModeChange, + onOpenThreadAnnotation, + resolveActiveComposerTrigger, + ], ); const onComposerMenuItemHighlighted = useCallback( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 6d9a9c37e023..ab4c31c69ce0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -273,6 +273,32 @@ describe("resolveAssistantMessageCopyState", () => { }); describe("deriveMessagesTimelineRows", () => { + it("shows a Waiting row after the turn settles and prefers Working while a turn is active", () => { + const base = { + timelineEntries: [], + activeTurnStartedAt: "2026-01-01T00:01:00Z", + waitingStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }; + + expect(deriveMessagesTimelineRows({ ...base, isWorking: false })).toEqual([ + { + kind: "waiting", + id: "waiting-indicator-row", + createdAt: "2026-01-01T00:00:00Z", + }, + ]); + expect(deriveMessagesTimelineRows({ ...base, isWorking: true })).toEqual([ + { + kind: "working", + id: "working-indicator-row", + createdAt: "2026-01-01T00:01:00Z", + showThinking: true, + }, + ]); + }); + it("only enables assistant copy for the terminal assistant message in a turn", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 716a72ea8c14..8fb024fe1e55 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -244,7 +244,8 @@ export type MessagesTimelineRow = id: string; createdAt: string | null; showThinking: boolean; - }; + } + | { kind: "waiting"; id: string; createdAt: string }; export interface StableMessagesTimelineRowsState { byId: Map; @@ -660,6 +661,7 @@ export function deriveMessagesTimelineRows(input: { expandedWorkGroupIds?: ReadonlySet; isWorking: boolean; activeTurnStartedAt: string | null; + waitingStartedAt?: string | null; turnDiffSummaryByAssistantMessageId: ReadonlyMap; revertTurnCountByUserMessageId: ReadonlyMap; }): MessagesTimelineRow[] { @@ -1030,6 +1032,13 @@ export function deriveMessagesTimelineRows(input: { if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } + if (!input.isWorking && input.waitingStartedAt) { + nextRows.push({ + kind: "waiting", + id: "waiting-indicator-row", + createdAt: input.waitingStartedAt, + }); + } return nextRows; } @@ -1063,6 +1072,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean return ( a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking ); + case "waiting": + return a.createdAt === (b as typeof a).createdAt; case "turn-fold": { const bf = b as typeof a; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index ea6cda9b6208..0e3433a0173b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -274,6 +274,50 @@ describe("MessagesTimeline", () => { expect(markup).toContain("px-1 text-sm leading-relaxed text-muted-foreground"); }); + it("keeps an annotation visible in the minimap with only one loaded marker", () => { + const entry = buildUserTimelineEntry("Annotated prompt"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-testid="timeline-minimap"'); + expect(markup).toContain("data-thread-annotation-marker"); + expect(markup).toContain("[@media(pointer:coarse)]:block"); + expect(markup).toContain("[@media(pointer:coarse)]:opacity-100"); + expect(markup).not.toContain("data-thread-annotation-overflow"); + }); + + it("uses an honest earlier-message marker when the anchor is outside loaded history", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("data-thread-annotation-overflow"); + expect(markup).toContain('aria-label="Annotation attached to an earlier message"'); + expect(markup).toContain('class="pointer-events-auto absolute left-3"'); + expect(markup).not.toContain("data-thread-annotation-marker"); + }); + it("uses the larger leading inset only when the top fade is enabled", () => { const timelineEntries = [buildUserTimelineEntry("Hello")]; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 438a9ce90034..b4fac8b2fb56 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3,6 +3,7 @@ import { type MessageId, type ScopedThreadRef, type ServerProviderSkill, + type ThreadAnnotation, type TurnId, } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; @@ -109,6 +110,11 @@ import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; +import { + ThreadAnnotationActions, + ThreadAnnotationBody, + useThreadAnnotationBodyPending, +} from "../thread-annotation/ThreadAnnotation"; import { buildInlineTerminalContextText, @@ -128,7 +134,7 @@ import { // Context — shared state consumed by every row component via Context. // Propagates through LegendList's memo boundaries for shared callbacks and // non-row-scoped state. `nowIso` is intentionally excluded — self-ticking -// components (WorkingTimer, LiveElapsed) handle it. +// components (ElapsedTimer, LiveElapsed) handle it. // --------------------------------------------------------------------------- interface TimelineRowSharedState { @@ -209,6 +215,7 @@ interface MessagesTimelineProps { isWorking: boolean; workingStepLabel?: string | null; activeTurnStartedAt: string | null; + waitingStartedAt?: string | null; listRef: React.RefObject; timelineEntries: ReturnType; latestTurn: TimelineLatestTurn | null; @@ -242,6 +249,11 @@ interface MessagesTimelineProps { topFadeEnabled?: boolean; /** Non-null when older turns exist beyond the loaded window. */ loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; + annotation?: ThreadAnnotation | null; + onAnnotationEdit?: () => void; + onAnnotationBodyChange?: ((body: string) => Promise) | undefined; + onAnnotationResolve?: () => void; + onAnnotationReopen?: () => void; } // --------------------------------------------------------------------------- @@ -252,6 +264,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, workingStepLabel = null, activeTurnStartedAt, + waitingStartedAt = null, agentPanelModel = EMPTY_AGENT_PANEL_MODEL, onOpenAgents = NOOP_OPEN_AGENTS, listRef, @@ -280,6 +293,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ hideEmptyPlaceholder = false, topFadeEnabled = false, loadEarlier = null, + annotation = null, + onAnnotationEdit = NOOP_OPEN_AGENTS, + onAnnotationBodyChange, + onAnnotationResolve = NOOP_OPEN_AGENTS, + onAnnotationReopen = NOOP_OPEN_AGENTS, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -414,6 +432,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ expandedWorkGroupIds, isWorking, activeTurnStartedAt, + waitingStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, }), @@ -425,6 +444,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ expandedWorkGroupIds, isWorking, activeTurnStartedAt, + waitingStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, ], @@ -567,7 +587,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [], ); - if (rows.length === 0 && !isWorking) { + if (rows.length === 0 && !isWorking && waitingStartedAt === null) { if (hideEmptyPlaceholder) { return null; } @@ -619,10 +639,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ListFooterComponent={TIMELINE_LIST_FOOTER} /> { onManualNavigation(); void listRef.current?.scrollToIndex({ @@ -648,6 +675,7 @@ function getItemType(item: MessagesTimelineRow) { interface TimelineMinimapItem { readonly id: string; + readonly messageId: MessageId; readonly rowIndex: number; readonly userText: string | null; readonly assistantText: string | null; @@ -673,6 +701,7 @@ function deriveTimelineMinimapItems( items.push({ id: row.id, + messageId: row.message.id, rowIndex: index, userText: compactMinimapPreview(row.message.text), assistantText: compactMinimapPreview(resolveFinalAssistantTextForTurn(rows, index)), @@ -721,23 +750,45 @@ function timelineMinimapEventTargetsPreview(target: EventTarget): boolean { } function TimelineMinimap({ + annotation, hasPersistentGutter, hitStripWidth, items, + markdownCwd, stripMap, + threadRef, + onAnnotationEdit, + onAnnotationBodyChange, + onAnnotationResolve, + onAnnotationReopen, onSelect, }: { + annotation: ThreadAnnotation | null; hasPersistentGutter: boolean; hitStripWidth: number; items: ReadonlyArray; + markdownCwd: string | undefined; stripMap: Map; + threadRef: ScopedThreadRef | null; + onAnnotationEdit: () => void; + onAnnotationBodyChange: ((body: string) => Promise) | undefined; + onAnnotationResolve: () => void; + onAnnotationReopen: () => void; onSelect: (item: TimelineMinimapItem) => void; }) { + const annotationBodyPending = useThreadAnnotationBodyPending(threadRef); const [activeIndex, setActiveIndex] = useState(null); + const [overflowAnnotationOpen, setOverflowAnnotationOpen] = useState(false); const resolvedActiveIndex = activeIndex !== null && activeIndex < items.length ? activeIndex : null; const activeItem = resolvedActiveIndex === null ? null : (items[resolvedActiveIndex] ?? null); + const annotationItemIndex = annotation + ? items.findIndex((item) => item.messageId === annotation.anchorMessageId) + : -1; + const annotationIsEarlier = annotation !== null && annotationItemIndex === -1; + const activeItemHasAnnotation = + annotation !== null && activeItem?.messageId === annotation.anchorMessageId; const activeTopPercent = resolvedActiveIndex === null ? 0 @@ -782,7 +833,7 @@ function TimelineMinimap({ [items.length], ); - if (items.length < TIMELINE_MINIMAP_MIN_ITEMS) { + if (items.length < TIMELINE_MINIMAP_MIN_ITEMS && annotation === null) { return null; } @@ -790,6 +841,8 @@ function TimelineMinimap({
); @@ -978,9 +1125,13 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "message" && row.message.role === "assistant" ? ( ) : null} + {row.kind === "message" && row.message.role === "system" ? ( + + ) : null} {row.kind === "proposed-plan" ? : null} {row.kind === "turn-plan" ? : null} {row.kind === "working" ? : null} + {row.kind === "waiting" ? : null}
); }); @@ -1177,6 +1328,28 @@ function AssistantTimelineRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); + return ( +
+
+ + Automated follow-up +
+
+ +
+
+ ); +} + function AssistantCopyButton({ row }: { row: Extract }) { const assistantCopyState = resolveAssistantMessageCopyState({ text: row.message.text ?? null, @@ -1311,6 +1484,16 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ ); }); +function ActivityEllipsis() { + return ( + + + + + + ); +} + function WorkingTimelineRow({ row }: { row: Extract }) { const { workingStepLabel } = use(TimelineRowActivityCtx); return ( @@ -1319,7 +1502,7 @@ function WorkingTimelineRow({ row }: { row: Extract {row.createdAt ? ( <> - Working for + Working for ) : ( "Working..." @@ -1338,13 +1521,26 @@ function WorkingTimelineRow({ row }: { row: Extract }) { + return ( +
+
+ + + Waiting for + +
+
+ ); +} + // --------------------------------------------------------------------------- // Self-ticking labels — update their own text nodes so elapsed-time display // does not create a React commit every second while a response is streaming. // --------------------------------------------------------------------------- -/** Live "Working for Xs" label. */ -function WorkingTimer({ createdAt }: { createdAt: string }) { +/** Live elapsed label shared by Working and Waiting rows. */ +function ElapsedTimer({ createdAt }: { createdAt: string }) { const textRef = useRef(null); const initialText = formatWorkingTimerNow(createdAt); diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 918ffe0c366b..e48303917ac2 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -3,10 +3,13 @@ import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/con import { canCheckForUpdate, + formatLocalBuildFailureDetails, getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, getDesktopUpdateInstallConfirmationMessage, + getDesktopUpdateProgressPercent, + MAX_LOCAL_BUILD_DIAGNOSTIC_LENGTH, getDesktopUpdateReleaseUrl, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, @@ -17,6 +20,7 @@ import { const baseState: DesktopUpdateState = { enabled: true, + source: "hosted", status: "idle", channel: "latest", currentVersion: "1.0.0", @@ -27,6 +31,8 @@ const baseState: DesktopUpdateState = { downloadedVersion: null, releaseNotes: [], downloadPercent: null, + localBuildProgress: null, + localBuildFailure: null, checkedAt: null, message: null, errorContext: null, @@ -132,6 +138,56 @@ describe("desktop update button state", () => { expect(isDesktopUpdateButtonDisabled(state)).toBe(true); expect(getDesktopUpdateButtonTooltip(state)).toContain("42%"); }); + + it("uses typed local estimates without changing hosted byte progress", () => { + const localState: DesktopUpdateState = { + ...baseState, + source: "lastcode-local", + status: "downloading", + availableVersion: "1.1.0-nightly.2", + downloadPercent: null, + localBuildProgress: { + checkpointTag: "lastcode/checkpoint/v1.1.0-nightly.2", + phase: "Workspace tests", + percent: 31, + errorKind: "build", + }, + }; + + expect(getDesktopUpdateProgressPercent(localState)).toBe(31); + expect(getDesktopUpdateButtonTooltip(localState)).toBe("Workspace tests · 31% est."); + + const hostedState = { ...baseState, status: "downloading", downloadPercent: 42.5 } as const; + expect(getDesktopUpdateProgressPercent(hostedState)).toBe(42.5); + expect(getDesktopUpdateButtonTooltip(hostedState)).toBe("Downloading update (42%)"); + }); +}); + +describe("local build failure diagnostic", () => { + it("formats a prompt-ready bounded diagnostic and strips terminal controls", () => { + const details = formatLocalBuildFailureDetails({ + checkpointTag: "lastcode/checkpoint/v1.2.3-nightly.4", + phase: "Building DMG", + percent: 94, + errorKind: "packaging", + currentVersion: "1.2.2", + targetVersion: "1.2.3-nightly.4", + logPath: "/Users/test/.lastcode/local-updates/build.log", + error: `hdiutil \u001B[31mfailed\u001B[0m\0${"x".repeat(32_000)}`, + }); + + expect(details).toContain("[lastcode:local-update] Local LastCode build failed"); + expect(details).toContain("Installed version: 1.2.2"); + expect(details).toContain("Target version: 1.2.3-nightly.4"); + expect(details).toContain("Checkpoint: lastcode/checkpoint/v1.2.3-nightly.4"); + expect(details).toContain("Last phase: Building DMG · 94% est."); + expect(details).toContain("Failure context: Packaging"); + expect(details).toContain("Error: hdiutil failed"); + expect(details).toContain("Build log: /Users/test/.lastcode/local-updates/build.log"); + expect(details).not.toContain("\u001B"); + expect(details).not.toContain("\0"); + expect(details.length).toBeLessThanOrEqual(MAX_LOCAL_BUILD_DIAGNOSTIC_LENGTH); + }); }); describe("getDesktopUpdateActionError", () => { @@ -151,6 +207,43 @@ describe("getDesktopUpdateActionError", () => { expect(getDesktopUpdateActionError(result)).toBe("checksum mismatch"); }); + it("sanitizes local failure toasts without changing hosted messages", () => { + const rawMessage = "hdiutil \u001B[31mfailed\u001B[0m\0"; + const failure = { + checkpointTag: "lastcode/checkpoint/v1.2.3-nightly.4", + phase: "Building DMG", + percent: 94, + errorKind: "packaging" as const, + currentVersion: "1.2.2", + targetVersion: "1.2.3-nightly.4", + logPath: "/Users/test/.lastcode/local-updates/build.log", + error: rawMessage, + }; + + expect( + getDesktopUpdateActionError({ + accepted: true, + completed: false, + state: { + ...baseState, + source: "lastcode-local", + status: "error", + message: rawMessage, + errorContext: "download", + localBuildProgress: failure, + localBuildFailure: failure, + }, + }), + ).toBe("hdiutil failed"); + expect( + getDesktopUpdateActionError({ + accepted: true, + completed: false, + state: { ...baseState, status: "error", message: rawMessage }, + }), + ).toBe(rawMessage); + }); + it("ignores messages for non-accepted attempts", () => { const result: DesktopUpdateActionResult = { accepted: false, @@ -270,6 +363,24 @@ describe("desktop update UI helpers", () => { ).toContain("Install update and restart T3 Code?"); }); + it("uses build and LastCode language for local nightlies", () => { + const state: DesktopUpdateState = { + ...baseState, + source: "lastcode-local", + status: "available", + availableVersion: "1.1.0-nightly.20260814.1", + }; + + expect(getDesktopUpdateButtonTooltip(state)).toContain("ready to build"); + expect( + getDesktopUpdateInstallConfirmationMessage({ + source: "lastcode-local", + availableVersion: state.availableVersion, + downloadedVersion: state.availableVersion, + }), + ).toContain("restart LastCode?"); + }); + it("keeps the same install confirmation copy across desktop platforms", () => { expect( getDesktopUpdateInstallConfirmationMessage({ diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 59dfbd385908..1b9efe047291 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -1,8 +1,88 @@ -import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; +import type { + DesktopLocalBuildFailure, + DesktopUpdateActionResult, + DesktopUpdateState, +} from "@t3tools/contracts"; export type DesktopUpdateButtonAction = "download" | "install" | "none"; const DESKTOP_RELEASE_TAG_URL = "https://github.com/pingdotgg/t3code/releases/tag"; +export const MAX_LOCAL_BUILD_DIAGNOSTIC_LENGTH = 40_000; + +function stripTerminalEscapeSequences(value: string): string { + let plain = ""; + for (let index = 0; index < value.length; index += 1) { + if (value.charCodeAt(index) !== 27) { + plain += value[index]; + continue; + } + + const introducer = value[index + 1]; + index += 1; + if (introducer === "[") { + while (index + 1 < value.length) { + index += 1; + const code = value.charCodeAt(index); + if (code >= 64 && code <= 126) break; + } + } else if (introducer === "]") { + while (index + 1 < value.length) { + index += 1; + if (value.charCodeAt(index) === 7) break; + if (value.charCodeAt(index) === 27 && value[index + 1] === "\\") { + index += 1; + break; + } + } + } + } + return plain; +} + +function sanitizeLocalBuildDiagnosticValue( + value: string, + maxLength: number, + preserveLines = false, +): string { + const withoutAnsi = stripTerminalEscapeSequences(value); + let sanitized = ""; + for (const character of withoutAnsi) { + const codePoint = character.codePointAt(0) ?? 0; + if (codePoint < 32 || (codePoint >= 127 && codePoint <= 159)) { + if (preserveLines && (character === "\n" || character === "\t")) sanitized += character; + continue; + } + sanitized += character; + if (sanitized.length >= maxLength) break; + } + return sanitized.trim(); +} + +export function formatLocalBuildFailureError(error: string): string { + return sanitizeLocalBuildDiagnosticValue(error, 32_000, true); +} + +export function formatLocalBuildFailureDetails(failure: DesktopLocalBuildFailure): string { + const context = failure.errorKind === "packaging" ? "Packaging" : "Build"; + return [ + "[lastcode:local-update] Local LastCode build failed", + `Installed version: ${sanitizeLocalBuildDiagnosticValue(failure.currentVersion, 200)}`, + `Target version: ${sanitizeLocalBuildDiagnosticValue(failure.targetVersion, 200)}`, + `Checkpoint: ${sanitizeLocalBuildDiagnosticValue(failure.checkpointTag, 500)}`, + `Last phase: ${sanitizeLocalBuildDiagnosticValue(failure.phase, 100)} · ${failure.percent}% est.`, + `Failure context: ${context}`, + `Error: ${formatLocalBuildFailureError(failure.error)}`, + `Build log: ${sanitizeLocalBuildDiagnosticValue(failure.logPath, 4_096)}`, + ] + .join("\n") + .slice(0, MAX_LOCAL_BUILD_DIAGNOSTIC_LENGTH); +} + +export function getDesktopUpdateProgressPercent(state: DesktopUpdateState): number | null { + return state.source === "lastcode-local" + ? (state.localBuildProgress?.percent ?? null) + : state.downloadPercent; +} /** * The main process fills `downloadedVersion` from the updater's `update-downloaded` @@ -76,20 +156,30 @@ export function getArm64IntelBuildWarningDescription(state: DesktopUpdateState): } export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string { + const isLocal = state.source === "lastcode-local"; if (state.status === "available") { - return `Update ${state.availableVersion ?? "available"} ready to download`; + return isLocal + ? `LastCode ${state.availableVersion ?? "update"} ready to build` + : `Update ${state.availableVersion ?? "available"} ready to download`; } if (state.status === "downloading") { + if (isLocal && state.localBuildProgress) { + return `${state.localBuildProgress.phase} · ${state.localBuildProgress.percent}% est.`; + } const progress = typeof state.downloadPercent === "number" ? ` (${Math.floor(state.downloadPercent)}%)` : ""; - return `Downloading update${progress}`; + return isLocal ? `Building local nightly${progress}` : `Downloading update${progress}`; } if (state.status === "downloaded") { - return `Update ${state.downloadedVersion ?? state.availableVersion ?? "ready"} downloaded. Click to restart and install.`; + return isLocal + ? `LastCode ${state.downloadedVersion ?? state.availableVersion ?? "nightly"} built. Click to restart and install.` + : `Update ${state.downloadedVersion ?? state.availableVersion ?? "ready"} downloaded. Click to restart and install.`; } if (state.status === "error") { if (state.errorContext === "download" && state.availableVersion) { - return `Download failed for ${state.availableVersion}. Click to retry.`; + return isLocal + ? `Local build failed for ${state.availableVersion}. Click to retry.` + : `Download failed for ${state.availableVersion}. Click to retry.`; } if (state.errorContext === "install" && state.downloadedVersion) { return `Install failed for ${state.downloadedVersion}. Click to retry.`; @@ -103,17 +193,22 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string } export function getDesktopUpdateInstallConfirmationMessage( - state: Pick, + state: Pick & + Partial>, ): string { const version = state.downloadedVersion ?? state.availableVersion; - return `Install update${version ? ` ${version}` : ""} and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`; + const appName = state.source === "lastcode-local" ? "LastCode" : "T3 Code"; + return `Install update${version ? ` ${version}` : ""} and restart ${appName}?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`; } export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null { if (!result.accepted || result.completed) return null; if (typeof result.state.message !== "string") return null; const message = result.state.message.trim(); - return message.length > 0 ? message : null; + if (message.length === 0) return null; + return result.state.source === "lastcode-local" && result.state.localBuildFailure + ? formatLocalBuildFailureError(message) + : message; } export function shouldToastDesktopUpdateActionResult(result: DesktopUpdateActionResult): boolean { diff --git a/apps/web/src/components/desktopUpdate.toast.test.tsx b/apps/web/src/components/desktopUpdate.toast.test.tsx index 369a3cdf4316..34484cbfdfba 100644 --- a/apps/web/src/components/desktopUpdate.toast.test.tsx +++ b/apps/web/src/components/desktopUpdate.toast.test.tsx @@ -41,6 +41,7 @@ function getDescription(): ReactNode { function downloadedState(overrides: Partial = {}): DesktopUpdateState { return { enabled: true, + source: "hosted", status: "downloaded", channel: "latest", currentVersion: "0.0.29", @@ -51,6 +52,8 @@ function downloadedState(overrides: Partial = {}): DesktopUp downloadedVersion: "0.0.30", releaseNotes: [], downloadPercent: 100, + localBuildProgress: null, + localBuildFailure: null, checkedAt: null, message: null, errorContext: null, @@ -104,6 +107,18 @@ describe("showDesktopUpdateDownloadedToast", () => { expect(findReleaseNotesLink(getDescription())).toBeNull(); }); + it("reports a local build without linking to an upstream GitHub release", () => { + showDesktopUpdateDownloadedToast( + { openExternal: vi.fn() }, + downloadedState({ source: "lastcode-local" }), + ); + + expect(findReleaseNotesLink(getDescription())).toBeNull(); + expect(testState.addToast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Local nightly built" }), + ); + }); + it.each([ ["returns false", vi.fn().mockResolvedValue(false)], ["rejects", vi.fn().mockRejectedValue(new Error("open failed"))], diff --git a/apps/web/src/components/desktopUpdate.toast.tsx b/apps/web/src/components/desktopUpdate.toast.tsx index 4e55f3a28d12..1376707bf62f 100644 --- a/apps/web/src/components/desktopUpdate.toast.tsx +++ b/apps/web/src/components/desktopUpdate.toast.tsx @@ -45,10 +45,13 @@ export function showDesktopUpdateDownloadedToast( shell: DesktopUpdateShell, state: DesktopUpdateState, ): void { - const releaseUrl = getDesktopUpdateReleaseUrl(getDesktopUpdateDownloadedVersion(state)); + const releaseUrl = + state.source === "lastcode-local" + ? null + : getDesktopUpdateReleaseUrl(getDesktopUpdateDownloadedVersion(state)); toastManager.add({ type: "success", - title: "Update downloaded", + title: state.source === "lastcode-local" ? "Local nightly built" : "Update downloaded", description: ( <> Restart the app from the update button to install it. diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts index 3b5295f180eb..a5265e48cab1 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.ts +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -5,7 +5,8 @@ import { normalizeFileCommentRange, remapFileCommentAnnotations, } from "./fileCommentAnnotations"; -import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; +import { setMarkdownTaskChecked } from "../../markdownTaskList"; +import { isMarkdownPreviewFile } from "./filePreviewMode"; describe("file comment annotations", () => { it("normalizes and formats selected line ranges", () => { diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a8c364763c28..dd073d520f54 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -26,6 +26,7 @@ import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; +import { setMarkdownTaskChecked } from "~/markdownTaskList"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; import { resolvePathLinkTarget } from "~/terminal-links"; import { ScrollArea } from "~/components/ui/scroll-area"; @@ -56,7 +57,7 @@ import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { fileBreadcrumbs } from "./filePath"; -import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; +import { isMarkdownPreviewFile } from "./filePreviewMode"; import { FileSaveCoordinator } from "./fileSaveCoordinator"; import { confirmProjectFileQueryData, diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts index 63249dbc72f0..4bab333a816b 100644 --- a/apps/web/src/components/files/filePreviewMode.ts +++ b/apps/web/src/components/files/filePreviewMode.ts @@ -1,18 +1 @@ export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path); - -export function setMarkdownTaskChecked( - markdown: string, - markerOffset: number, - checked: boolean, -): string { - if ( - markerOffset < 0 || - markdown[markerOffset] !== "[" || - !/[ xX]/.test(markdown[markerOffset + 1] ?? "") || - markdown[markerOffset + 2] !== "]" - ) { - return markdown; - } - - return `${markdown.slice(0, markerOffset + 1)}${checked ? "x" : " "}${markdown.slice(markerOffset + 2)}`; -} diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 4b728c2e07eb..f4d2fe3075f5 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -83,6 +83,8 @@ export interface NewProjectScriptInput { previewUrl: string | null; /** When true, automatically open the preview panel pointed at `previewUrl`. */ autoOpenPreview: boolean; + /** Permit provider-scoped MCP launch plus a one-shot automated follow-up. */ + allowAgentResume: boolean; } export type ProjectScriptActionResult = AtomCommandResult; @@ -95,6 +97,7 @@ export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = { keybinding: null, previewUrl: null, autoOpenPreview: false, + allowAgentResume: false, }; /** What the editor dialog should open with. `scriptId: null` means "add". */ @@ -119,6 +122,7 @@ export function editorRequestForScript( keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)), previewUrl: script.previewUrl ?? null, autoOpenPreview: script.autoOpenPreview ?? false, + allowAgentResume: script.allowAgentResume ?? false, }, }; } @@ -154,6 +158,7 @@ export function ProjectScriptEditorDialog({ const [keybinding, setKeybinding] = useState(""); const [previewUrl, setPreviewUrl] = useState(""); const [autoOpenPreview, setAutoOpenPreview] = useState(false); + const [allowAgentResume, setAllowAgentResume] = useState(false); const [validationError, setValidationError] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); @@ -171,6 +176,7 @@ export function ProjectScriptEditorDialog({ setKeybinding(request.initial.keybinding ?? ""); setPreviewUrl(request.initial.previewUrl ?? ""); setAutoOpenPreview(request.initial.autoOpenPreview); + setAllowAgentResume(request.initial.allowAgentResume); setValidationError(request.error ?? null); }, [request]); @@ -222,6 +228,7 @@ export function ProjectScriptEditorDialog({ keybinding: keybindingRule?.key ?? null, previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, + allowAgentResume, } satisfies NewProjectScriptInput; } catch (error) { setValidationError(error instanceof Error ? error.message : "Failed to save action."); @@ -352,6 +359,19 @@ export function ProjectScriptEditorDialog({ onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} /> +