mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
Merge branch 'v0.11.0rc' into claude/issue-469-20260113-1744
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -90,9 +90,8 @@ pnpm-lock.yaml
|
|||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
# Fork-specific workflow files (should never be committed)
|
# Fork-specific workflow files (should never be committed)
|
||||||
DEVELOPMENT_WORKFLOW.md
|
|
||||||
check-sync.sh
|
|
||||||
# API key files
|
# API key files
|
||||||
data/.api-key
|
data/.api-key
|
||||||
data/credentials.json
|
data/credentials.json
|
||||||
data/
|
data/
|
||||||
|
.codex/
|
||||||
|
|||||||
253
DEVELOPMENT_WORKFLOW.md
Normal file
253
DEVELOPMENT_WORKFLOW.md
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# Development Workflow
|
||||||
|
|
||||||
|
This document defines the standard workflow for keeping a branch in sync with the upstream
|
||||||
|
release candidate (RC) and for shipping feature work. It is paired with `check-sync.sh`.
|
||||||
|
|
||||||
|
## Quick Decision Rule
|
||||||
|
|
||||||
|
1. Ask the user to select a workflow:
|
||||||
|
- **Sync Workflow** → you are maintaining the current RC branch with fixes/improvements
|
||||||
|
and will push the same fixes to both origin and upstream RC when you have local
|
||||||
|
commits to publish.
|
||||||
|
- **PR Workflow** → you are starting new feature work on a new branch; upstream updates
|
||||||
|
happen via PR only.
|
||||||
|
2. After the user selects, run:
|
||||||
|
```bash
|
||||||
|
./check-sync.sh
|
||||||
|
```
|
||||||
|
3. Use the status output to confirm alignment. If it reports **diverged**, default to
|
||||||
|
merging `upstream/<TARGET_RC>` into the current branch and preserving local commits.
|
||||||
|
For Sync Workflow, when the working tree is clean and you are behind upstream RC,
|
||||||
|
proceed with the fetch + merge without asking for additional confirmation.
|
||||||
|
|
||||||
|
## Target RC Resolution
|
||||||
|
|
||||||
|
The target RC is resolved dynamically so the workflow stays current as the RC changes.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
|
||||||
|
1. Latest `upstream/v*rc` branch (auto-detected)
|
||||||
|
2. `upstream/HEAD` (fallback)
|
||||||
|
3. If neither is available, you must pass `--rc <branch>`
|
||||||
|
|
||||||
|
Override for a single run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./check-sync.sh --rc <rc-branch>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pre-Flight Checklist
|
||||||
|
|
||||||
|
1. Confirm a clean working tree:
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
2. Confirm the current branch:
|
||||||
|
```bash
|
||||||
|
git branch --show-current
|
||||||
|
```
|
||||||
|
3. Ensure remotes exist (origin + upstream):
|
||||||
|
```bash
|
||||||
|
git remote -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sync Workflow (Upstream Sync)
|
||||||
|
|
||||||
|
Use this flow when you are updating the current branch with fixes or improvements and
|
||||||
|
intend to keep origin and upstream RC in lockstep.
|
||||||
|
|
||||||
|
1. **Check sync status**
|
||||||
|
```bash
|
||||||
|
./check-sync.sh
|
||||||
|
```
|
||||||
|
2. **Update from upstream RC before editing (no pulls)**
|
||||||
|
- **Behind upstream RC** → fetch and merge RC into your branch:
|
||||||
|
```bash
|
||||||
|
git fetch upstream
|
||||||
|
git merge upstream/<TARGET_RC> --no-edit
|
||||||
|
```
|
||||||
|
When the working tree is clean and the user selected Sync Workflow, proceed without
|
||||||
|
an extra confirmation prompt.
|
||||||
|
- **Diverged** → stop and resolve manually.
|
||||||
|
3. **Resolve conflicts if needed**
|
||||||
|
- Handle conflicts intelligently: preserve upstream behavior and your local intent.
|
||||||
|
4. **Make changes and commit (if you are delivering fixes)**
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "type: description"
|
||||||
|
```
|
||||||
|
5. **Build to verify**
|
||||||
|
```bash
|
||||||
|
npm run build:packages
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
6. **Push after a successful merge to keep remotes aligned**
|
||||||
|
- If you only merged upstream RC changes, push **origin only** to sync your fork:
|
||||||
|
```bash
|
||||||
|
git push origin <branch>
|
||||||
|
```
|
||||||
|
- If you have local fixes to publish, push **origin + upstream**:
|
||||||
|
```bash
|
||||||
|
git push origin <branch>
|
||||||
|
git push upstream <branch>:<TARGET_RC>
|
||||||
|
```
|
||||||
|
- Always ask the user which push to perform.
|
||||||
|
- Origin (origin-only sync):
|
||||||
|
```bash
|
||||||
|
git push origin <branch>
|
||||||
|
```
|
||||||
|
- Upstream RC (publish the same fixes when you have local commits):
|
||||||
|
```bash
|
||||||
|
git push upstream <branch>:<TARGET_RC>
|
||||||
|
```
|
||||||
|
7. **Re-check sync**
|
||||||
|
```bash
|
||||||
|
./check-sync.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## PR Workflow (Feature Work)
|
||||||
|
|
||||||
|
Use this flow only for new feature work on a new branch. Do not push to upstream RC.
|
||||||
|
|
||||||
|
1. **Create or switch to a feature branch**
|
||||||
|
```bash
|
||||||
|
git checkout -b <branch>
|
||||||
|
```
|
||||||
|
2. **Make changes and commit**
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "type: description"
|
||||||
|
```
|
||||||
|
3. **Merge upstream RC before shipping**
|
||||||
|
```bash
|
||||||
|
git merge upstream/<TARGET_RC> --no-edit
|
||||||
|
```
|
||||||
|
4. **Build and/or test**
|
||||||
|
```bash
|
||||||
|
npm run build:packages
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
5. **Push to origin**
|
||||||
|
```bash
|
||||||
|
git push -u origin <branch>
|
||||||
|
```
|
||||||
|
6. **Create or update the PR**
|
||||||
|
- Use `gh pr create` or the GitHub UI.
|
||||||
|
7. **Review and follow-up**
|
||||||
|
|
||||||
|
- Apply feedback, commit changes, and push again.
|
||||||
|
- Re-run `./check-sync.sh` if additional upstream sync is needed.
|
||||||
|
|
||||||
|
## Conflict Resolution Checklist
|
||||||
|
|
||||||
|
1. Identify which changes are from upstream vs. local.
|
||||||
|
2. Preserve both behaviors where possible; avoid dropping either side.
|
||||||
|
3. Prefer minimal, safe integrations over refactors.
|
||||||
|
4. Re-run build commands after resolving conflicts.
|
||||||
|
5. Re-run `./check-sync.sh` to confirm status.
|
||||||
|
|
||||||
|
## Build/Test Matrix
|
||||||
|
|
||||||
|
- **Sync Workflow**: `npm run build:packages` and `npm run build`.
|
||||||
|
- **PR Workflow**: `npm run build:packages` and `npm run build` (plus relevant tests).
|
||||||
|
|
||||||
|
## Post-Sync Verification
|
||||||
|
|
||||||
|
1. `git status` should be clean.
|
||||||
|
2. `./check-sync.sh` should show expected alignment.
|
||||||
|
3. Verify recent commits with:
|
||||||
|
```bash
|
||||||
|
git log --oneline -5
|
||||||
|
```
|
||||||
|
|
||||||
|
## check-sync.sh Usage
|
||||||
|
|
||||||
|
- Uses dynamic Target RC resolution (see above).
|
||||||
|
- Override target RC:
|
||||||
|
```bash
|
||||||
|
./check-sync.sh --rc <rc-branch>
|
||||||
|
```
|
||||||
|
- Optional preview limit:
|
||||||
|
```bash
|
||||||
|
./check-sync.sh --preview 10
|
||||||
|
```
|
||||||
|
- The script prints sync status for both origin and upstream and previews recent commits
|
||||||
|
when you are behind.
|
||||||
|
|
||||||
|
## Stop Conditions
|
||||||
|
|
||||||
|
Stop and ask for guidance if any of the following are true:
|
||||||
|
|
||||||
|
- The working tree is dirty and you are about to merge or push.
|
||||||
|
- `./check-sync.sh` reports **diverged** during PR Workflow, or a merge cannot be completed.
|
||||||
|
- The script cannot resolve a target RC and requests `--rc`.
|
||||||
|
- A build fails after sync or conflict resolution.
|
||||||
|
|
||||||
|
## AI Agent Guardrails
|
||||||
|
|
||||||
|
- Always run `./check-sync.sh` before merges or pushes.
|
||||||
|
- Always ask for explicit user approval before any push command.
|
||||||
|
- Do not ask for additional confirmation before a Sync Workflow fetch + merge when the
|
||||||
|
working tree is clean and the user has already selected the Sync Workflow.
|
||||||
|
- Choose Sync vs PR workflow based on intent (RC maintenance vs new feature work), not
|
||||||
|
on the script's workflow hint.
|
||||||
|
- Only use force push when the user explicitly requests a history rewrite.
|
||||||
|
- Ask for explicit approval before dependency installs, branch deletion, or destructive operations.
|
||||||
|
- When resolving merge conflicts, preserve both upstream changes and local intent where possible.
|
||||||
|
- Do not create or switch to new branches unless the user explicitly requests it.
|
||||||
|
|
||||||
|
## AI Agent Decision Guidance
|
||||||
|
|
||||||
|
Agents should provide concrete, task-specific suggestions instead of repeatedly asking
|
||||||
|
open-ended questions. Use the user's stated goal and the `./check-sync.sh` status to
|
||||||
|
propose a default path plus one or two alternatives, and only ask for confirmation when
|
||||||
|
an action requires explicit approval.
|
||||||
|
|
||||||
|
Default behavior:
|
||||||
|
|
||||||
|
- If the intent is RC maintenance, recommend the Sync Workflow and proceed with
|
||||||
|
safe preparation steps (status checks, previews). If the branch is behind upstream RC,
|
||||||
|
fetch and merge without additional confirmation when the working tree is clean, then
|
||||||
|
push to origin to keep the fork aligned. Push upstream only when there are local fixes
|
||||||
|
to publish.
|
||||||
|
- If the intent is new feature work, recommend the PR Workflow and proceed with safe
|
||||||
|
preparation steps (status checks, identifying scope). Ask for approval before merges,
|
||||||
|
pushes, or dependency installs.
|
||||||
|
- If `./check-sync.sh` reports **diverged** during Sync Workflow, merge
|
||||||
|
`upstream/<TARGET_RC>` into the current branch and preserve local commits.
|
||||||
|
- If `./check-sync.sh` reports **diverged** during PR Workflow, stop and ask for guidance
|
||||||
|
with a short explanation of the divergence and the minimal options to resolve it.
|
||||||
|
If the user's intent is RC maintenance, prefer the Sync Workflow regardless of the
|
||||||
|
script hint. When the intent is new feature work, use the PR Workflow and avoid upstream
|
||||||
|
RC pushes.
|
||||||
|
|
||||||
|
Suggestion format (keep it short):
|
||||||
|
|
||||||
|
- **Recommended**: one sentence with the default path and why it fits the task.
|
||||||
|
- **Alternatives**: one or two options with the tradeoff or prerequisite.
|
||||||
|
- **Approval points**: mention any upcoming actions that need explicit approval (exclude sync
|
||||||
|
workflow pushes and merges).
|
||||||
|
|
||||||
|
## Failure Modes and How to Avoid Them
|
||||||
|
|
||||||
|
Sync Workflow:
|
||||||
|
|
||||||
|
- Wrong RC target: verify the auto-detected RC in `./check-sync.sh` output before merging.
|
||||||
|
- Diverged from upstream RC: stop and resolve manually before any merge or push.
|
||||||
|
- Dirty working tree: commit or stash before syncing to avoid accidental merges.
|
||||||
|
- Missing remotes: ensure both `origin` and `upstream` are configured before syncing.
|
||||||
|
- Build breaks after sync: run `npm run build:packages` and `npm run build` before pushing.
|
||||||
|
|
||||||
|
PR Workflow:
|
||||||
|
|
||||||
|
- Branch not synced to current RC: re-run `./check-sync.sh` and merge RC before shipping.
|
||||||
|
- Pushing the wrong branch: confirm `git branch --show-current` before pushing.
|
||||||
|
- Unreviewed changes: always commit and push to origin before opening or updating a PR.
|
||||||
|
- Skipped tests/builds: run the build commands before declaring the PR ready.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Avoid merging with uncommitted changes; commit or stash first.
|
||||||
|
- Prefer merge over rebase for PR branches; rebases rewrite history and often require a force push,
|
||||||
|
which should only be done with an explicit user request.
|
||||||
|
- Use clear, conventional commit messages and split unrelated changes into separate commits.
|
||||||
17
Dockerfile
17
Dockerfile
@@ -59,6 +59,11 @@ FROM node:22-slim AS server
|
|||||||
ARG GIT_COMMIT_SHA=unknown
|
ARG GIT_COMMIT_SHA=unknown
|
||||||
LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}"
|
LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}"
|
||||||
|
|
||||||
|
# Build arguments for user ID matching (allows matching host user for mounted volumes)
|
||||||
|
# Override at build time: docker build --build-arg UID=$(id -u) --build-arg GID=$(id -g) ...
|
||||||
|
ARG UID=1001
|
||||||
|
ARG GID=1001
|
||||||
|
|
||||||
# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch)
|
# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
git curl bash gosu ca-certificates openssh-client \
|
git curl bash gosu ca-certificates openssh-client \
|
||||||
@@ -79,8 +84,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
RUN npm install -g @anthropic-ai/claude-code
|
RUN npm install -g @anthropic-ai/claude-code
|
||||||
|
|
||||||
# Create non-root user with home directory BEFORE installing Cursor CLI
|
# Create non-root user with home directory BEFORE installing Cursor CLI
|
||||||
RUN groupadd -g 1001 automaker && \
|
# Uses UID/GID build args to match host user for mounted volume permissions
|
||||||
useradd -u 1001 -g automaker -m -d /home/automaker -s /bin/bash automaker && \
|
# Use -o flag to allow non-unique IDs (GID 1000 may already exist as 'node' group)
|
||||||
|
RUN groupadd -o -g ${GID} automaker && \
|
||||||
|
useradd -o -u ${UID} -g automaker -m -d /home/automaker -s /bin/bash automaker && \
|
||||||
mkdir -p /home/automaker/.local/bin && \
|
mkdir -p /home/automaker/.local/bin && \
|
||||||
mkdir -p /home/automaker/.cursor && \
|
mkdir -p /home/automaker/.cursor && \
|
||||||
chown -R automaker:automaker /home/automaker && \
|
chown -R automaker:automaker /home/automaker && \
|
||||||
@@ -95,6 +102,12 @@ RUN curl https://cursor.com/install -fsS | bash && \
|
|||||||
ls -la /home/automaker/.local/bin/ && \
|
ls -la /home/automaker/.local/bin/ && \
|
||||||
echo "=== PATH is: $PATH ===" && \
|
echo "=== PATH is: $PATH ===" && \
|
||||||
(which cursor-agent && cursor-agent --version) || echo "cursor-agent installed (may need auth setup)"
|
(which cursor-agent && cursor-agent --version) || echo "cursor-agent installed (may need auth setup)"
|
||||||
|
|
||||||
|
# Install OpenCode CLI (for multi-provider AI model access)
|
||||||
|
RUN curl -fsSL https://opencode.ai/install | bash && \
|
||||||
|
echo "=== Checking OpenCode CLI installation ===" && \
|
||||||
|
ls -la /home/automaker/.local/bin/ && \
|
||||||
|
(which opencode && opencode --version) || echo "opencode installed (may need auth setup)"
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
# Add PATH to profile so it's available in all interactive shells (for login shells)
|
# Add PATH to profile so it's available in all interactive shells (for login shells)
|
||||||
|
|||||||
@@ -27,9 +27,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
# Install Claude CLI globally
|
# Install Claude CLI globally
|
||||||
RUN npm install -g @anthropic-ai/claude-code
|
RUN npm install -g @anthropic-ai/claude-code
|
||||||
|
|
||||||
# Create non-root user
|
# Build arguments for user ID matching (allows matching host user for mounted volumes)
|
||||||
RUN groupadd -g 1001 automaker && \
|
# Override at build time: docker-compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g)
|
||||||
useradd -u 1001 -g automaker -m -d /home/automaker -s /bin/bash automaker && \
|
ARG UID=1001
|
||||||
|
ARG GID=1001
|
||||||
|
|
||||||
|
# Create non-root user with configurable UID/GID
|
||||||
|
# Use -o flag to allow non-unique IDs (GID 1000 may already exist as 'node' group)
|
||||||
|
RUN groupadd -o -g ${GID} automaker && \
|
||||||
|
useradd -o -u ${UID} -g automaker -m -d /home/automaker -s /bin/bash automaker && \
|
||||||
mkdir -p /home/automaker/.local/bin && \
|
mkdir -p /home/automaker/.local/bin && \
|
||||||
mkdir -p /home/automaker/.cursor && \
|
mkdir -p /home/automaker/.cursor && \
|
||||||
chown -R automaker:automaker /home/automaker && \
|
chown -R automaker:automaker /home/automaker && \
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
...(maxThinkingTokens && { maxThinkingTokens }),
|
...(maxThinkingTokens && { maxThinkingTokens }),
|
||||||
// Subagents configuration for specialized task delegation
|
// Subagents configuration for specialized task delegation
|
||||||
...(options.agents && { agents: options.agents }),
|
...(options.agents && { agents: options.agents }),
|
||||||
|
// Pass through outputFormat for structured JSON outputs
|
||||||
|
...(options.outputFormat && { outputFormat: options.outputFormat }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build prompt payload
|
// Build prompt payload
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
getCodexTodoToolName,
|
getCodexTodoToolName,
|
||||||
} from './codex-tool-mapping.js';
|
} from './codex-tool-mapping.js';
|
||||||
import { SettingsService } from '../services/settings-service.js';
|
import { SettingsService } from '../services/settings-service.js';
|
||||||
|
import { createTempEnvOverride } from '../lib/auth-utils.js';
|
||||||
import { checkSandboxCompatibility } from '../lib/sdk-options.js';
|
import { checkSandboxCompatibility } from '../lib/sdk-options.js';
|
||||||
import { CODEX_MODELS } from './codex-models.js';
|
import { CODEX_MODELS } from './codex-models.js';
|
||||||
|
|
||||||
@@ -142,6 +143,7 @@ type CodexExecutionMode = typeof CODEX_EXECUTION_MODE_CLI | typeof CODEX_EXECUTI
|
|||||||
type CodexExecutionPlan = {
|
type CodexExecutionPlan = {
|
||||||
mode: CodexExecutionMode;
|
mode: CodexExecutionMode;
|
||||||
cliPath: string | null;
|
cliPath: string | null;
|
||||||
|
openAiApiKey?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALLOWED_ENV_VARS = [
|
const ALLOWED_ENV_VARS = [
|
||||||
@@ -166,6 +168,22 @@ function buildEnv(): Record<string, string> {
|
|||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveOpenAiApiKey(): Promise<string | null> {
|
||||||
|
const envKey = process.env[OPENAI_API_KEY_ENV];
|
||||||
|
if (envKey) {
|
||||||
|
return envKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settingsService = new SettingsService(getCodexSettingsDir());
|
||||||
|
const credentials = await settingsService.getCredentials();
|
||||||
|
const storedKey = credentials.apiKeys.openai?.trim();
|
||||||
|
return storedKey ? storedKey : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hasMcpServersConfigured(options: ExecuteOptions): boolean {
|
function hasMcpServersConfigured(options: ExecuteOptions): boolean {
|
||||||
return Boolean(options.mcpServers && Object.keys(options.mcpServers).length > 0);
|
return Boolean(options.mcpServers && Object.keys(options.mcpServers).length > 0);
|
||||||
}
|
}
|
||||||
@@ -181,18 +199,21 @@ function isSdkEligible(options: ExecuteOptions): boolean {
|
|||||||
async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<CodexExecutionPlan> {
|
async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<CodexExecutionPlan> {
|
||||||
const cliPath = await findCodexCliPath();
|
const cliPath = await findCodexCliPath();
|
||||||
const authIndicators = await getCodexAuthIndicators();
|
const authIndicators = await getCodexAuthIndicators();
|
||||||
const hasApiKey = Boolean(process.env[OPENAI_API_KEY_ENV]);
|
const openAiApiKey = await resolveOpenAiApiKey();
|
||||||
|
const hasApiKey = Boolean(openAiApiKey);
|
||||||
const cliAuthenticated = authIndicators.hasOAuthToken || authIndicators.hasApiKey || hasApiKey;
|
const cliAuthenticated = authIndicators.hasOAuthToken || authIndicators.hasApiKey || hasApiKey;
|
||||||
const sdkEligible = isSdkEligible(options);
|
const sdkEligible = isSdkEligible(options);
|
||||||
const cliAvailable = Boolean(cliPath);
|
const cliAvailable = Boolean(cliPath);
|
||||||
|
|
||||||
|
if (hasApiKey) {
|
||||||
|
return {
|
||||||
|
mode: CODEX_EXECUTION_MODE_SDK,
|
||||||
|
cliPath,
|
||||||
|
openAiApiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (sdkEligible) {
|
if (sdkEligible) {
|
||||||
if (hasApiKey) {
|
|
||||||
return {
|
|
||||||
mode: CODEX_EXECUTION_MODE_SDK,
|
|
||||||
cliPath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!cliAvailable) {
|
if (!cliAvailable) {
|
||||||
throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED);
|
throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED);
|
||||||
}
|
}
|
||||||
@@ -209,6 +230,7 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<Codex
|
|||||||
return {
|
return {
|
||||||
mode: CODEX_EXECUTION_MODE_CLI,
|
mode: CODEX_EXECUTION_MODE_CLI,
|
||||||
cliPath,
|
cliPath,
|
||||||
|
openAiApiKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +723,14 @@ export class CodexProvider extends BaseProvider {
|
|||||||
|
|
||||||
const executionPlan = await resolveCodexExecutionPlan(options);
|
const executionPlan = await resolveCodexExecutionPlan(options);
|
||||||
if (executionPlan.mode === CODEX_EXECUTION_MODE_SDK) {
|
if (executionPlan.mode === CODEX_EXECUTION_MODE_SDK) {
|
||||||
yield* executeCodexSdkQuery(options, combinedSystemPrompt);
|
const cleanupEnv = executionPlan.openAiApiKey
|
||||||
|
? createTempEnvOverride({ [OPENAI_API_KEY_ENV]: executionPlan.openAiApiKey })
|
||||||
|
: null;
|
||||||
|
try {
|
||||||
|
yield* executeCodexSdkQuery(options, combinedSystemPrompt);
|
||||||
|
} finally {
|
||||||
|
cleanupEnv?.();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,11 +809,16 @@ export class CodexProvider extends BaseProvider {
|
|||||||
'-', // Read prompt from stdin to avoid shell escaping issues
|
'-', // Read prompt from stdin to avoid shell escaping issues
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const envOverrides = buildEnv();
|
||||||
|
if (executionPlan.openAiApiKey && !envOverrides[OPENAI_API_KEY_ENV]) {
|
||||||
|
envOverrides[OPENAI_API_KEY_ENV] = executionPlan.openAiApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
const stream = spawnJSONLProcess({
|
const stream = spawnJSONLProcess({
|
||||||
command: commandPath,
|
command: commandPath,
|
||||||
args,
|
args,
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
env: buildEnv(),
|
env: envOverrides,
|
||||||
abortController: options.abortController,
|
abortController: options.abortController,
|
||||||
timeout: DEFAULT_TIMEOUT_MS,
|
timeout: DEFAULT_TIMEOUT_MS,
|
||||||
stdinData: promptText, // Pass prompt via stdin
|
stdinData: promptText, // Pass prompt via stdin
|
||||||
@@ -971,7 +1005,7 @@ export class CodexProvider extends BaseProvider {
|
|||||||
|
|
||||||
async detectInstallation(): Promise<InstallationStatus> {
|
async detectInstallation(): Promise<InstallationStatus> {
|
||||||
const cliPath = await findCodexCliPath();
|
const cliPath = await findCodexCliPath();
|
||||||
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
|
const hasApiKey = Boolean(await resolveOpenAiApiKey());
|
||||||
const authIndicators = await getCodexAuthIndicators();
|
const authIndicators = await getCodexAuthIndicators();
|
||||||
const installed = !!cliPath;
|
const installed = !!cliPath;
|
||||||
|
|
||||||
@@ -1013,7 +1047,7 @@ export class CodexProvider extends BaseProvider {
|
|||||||
*/
|
*/
|
||||||
async checkAuth(): Promise<CodexAuthStatus> {
|
async checkAuth(): Promise<CodexAuthStatus> {
|
||||||
const cliPath = await findCodexCliPath();
|
const cliPath = await findCodexCliPath();
|
||||||
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
|
const hasApiKey = Boolean(await resolveOpenAiApiKey());
|
||||||
const authIndicators = await getCodexAuthIndicators();
|
const authIndicators = await getCodexAuthIndicators();
|
||||||
|
|
||||||
// Check for API key in environment
|
// Check for API key in environment
|
||||||
|
|||||||
@@ -30,3 +30,11 @@ export { OpencodeProvider } from './opencode-provider.js';
|
|||||||
|
|
||||||
// Provider factory
|
// Provider factory
|
||||||
export { ProviderFactory } from './provider-factory.js';
|
export { ProviderFactory } from './provider-factory.js';
|
||||||
|
|
||||||
|
// Simple query service - unified interface for basic AI queries
|
||||||
|
export { simpleQuery, streamingQuery } from './simple-query-service.js';
|
||||||
|
export type {
|
||||||
|
SimpleQueryOptions,
|
||||||
|
SimpleQueryResult,
|
||||||
|
StreamingQueryOptions,
|
||||||
|
} from './simple-query-service.js';
|
||||||
|
|||||||
254
apps/server/src/providers/simple-query-service.ts
Normal file
254
apps/server/src/providers/simple-query-service.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* Simple Query Service - Simplified interface for basic AI queries
|
||||||
|
*
|
||||||
|
* Use this for routes that need simple text responses without
|
||||||
|
* complex event handling. This service abstracts away the provider
|
||||||
|
* selection and streaming details, providing a clean interface
|
||||||
|
* for common query patterns.
|
||||||
|
*
|
||||||
|
* Benefits:
|
||||||
|
* - No direct SDK imports needed in route files
|
||||||
|
* - Consistent provider routing based on model
|
||||||
|
* - Automatic text extraction from streaming responses
|
||||||
|
* - Structured output support for JSON schema responses
|
||||||
|
* - Eliminates duplicate extractTextFromStream() functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ProviderFactory } from './provider-factory.js';
|
||||||
|
import type {
|
||||||
|
ProviderMessage,
|
||||||
|
ContentBlock,
|
||||||
|
ThinkingLevel,
|
||||||
|
ReasoningEffort,
|
||||||
|
} from '@automaker/types';
|
||||||
|
import { stripProviderPrefix } from '@automaker/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for simple query execution
|
||||||
|
*/
|
||||||
|
export interface SimpleQueryOptions {
|
||||||
|
/** The prompt to send to the AI (can be text or multi-part content) */
|
||||||
|
prompt: string | Array<{ type: string; text?: string; source?: object }>;
|
||||||
|
/** Model to use (with or without provider prefix) */
|
||||||
|
model?: string;
|
||||||
|
/** Working directory for the query */
|
||||||
|
cwd: string;
|
||||||
|
/** System prompt (combined with user prompt for some providers) */
|
||||||
|
systemPrompt?: string;
|
||||||
|
/** Maximum turns for agentic operations (default: 1) */
|
||||||
|
maxTurns?: number;
|
||||||
|
/** Tools to allow (default: [] for simple queries) */
|
||||||
|
allowedTools?: string[];
|
||||||
|
/** Abort controller for cancellation */
|
||||||
|
abortController?: AbortController;
|
||||||
|
/** Structured output format for JSON responses */
|
||||||
|
outputFormat?: {
|
||||||
|
type: 'json_schema';
|
||||||
|
schema: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
/** Thinking level for Claude models */
|
||||||
|
thinkingLevel?: ThinkingLevel;
|
||||||
|
/** Reasoning effort for Codex/OpenAI models */
|
||||||
|
reasoningEffort?: ReasoningEffort;
|
||||||
|
/** If true, runs in read-only mode (no file writes) */
|
||||||
|
readOnly?: boolean;
|
||||||
|
/** Setting sources for CLAUDE.md loading */
|
||||||
|
settingSources?: Array<'user' | 'project' | 'local'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result from a simple query
|
||||||
|
*/
|
||||||
|
export interface SimpleQueryResult {
|
||||||
|
/** The accumulated text response */
|
||||||
|
text: string;
|
||||||
|
/** Structured output if outputFormat was specified and provider supports it */
|
||||||
|
structured_output?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for streaming query execution
|
||||||
|
*/
|
||||||
|
export interface StreamingQueryOptions extends SimpleQueryOptions {
|
||||||
|
/** Callback for each text chunk received */
|
||||||
|
onText?: (text: string) => void;
|
||||||
|
/** Callback for tool use events */
|
||||||
|
onToolUse?: (tool: string, input: unknown) => void;
|
||||||
|
/** Callback for thinking blocks (if available) */
|
||||||
|
onThinking?: (thinking: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default model to use when none specified
|
||||||
|
*/
|
||||||
|
const DEFAULT_MODEL = 'claude-sonnet-4-20250514';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a simple query and return the text result
|
||||||
|
*
|
||||||
|
* Use this for simple, non-streaming queries where you just need
|
||||||
|
* the final text response. For more complex use cases with progress
|
||||||
|
* callbacks, use streamingQuery() instead.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const result = await simpleQuery({
|
||||||
|
* prompt: 'Generate a title for: user authentication',
|
||||||
|
* cwd: process.cwd(),
|
||||||
|
* systemPrompt: 'You are a title generator...',
|
||||||
|
* maxTurns: 1,
|
||||||
|
* allowedTools: [],
|
||||||
|
* });
|
||||||
|
* console.log(result.text); // "Add user authentication"
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function simpleQuery(options: SimpleQueryOptions): Promise<SimpleQueryResult> {
|
||||||
|
const model = options.model || DEFAULT_MODEL;
|
||||||
|
const provider = ProviderFactory.getProviderForModel(model);
|
||||||
|
const bareModel = stripProviderPrefix(model);
|
||||||
|
|
||||||
|
let responseText = '';
|
||||||
|
let structuredOutput: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
// Build provider options
|
||||||
|
const providerOptions = {
|
||||||
|
prompt: options.prompt,
|
||||||
|
model: bareModel,
|
||||||
|
originalModel: model,
|
||||||
|
cwd: options.cwd,
|
||||||
|
systemPrompt: options.systemPrompt,
|
||||||
|
maxTurns: options.maxTurns ?? 1,
|
||||||
|
allowedTools: options.allowedTools ?? [],
|
||||||
|
abortController: options.abortController,
|
||||||
|
outputFormat: options.outputFormat,
|
||||||
|
thinkingLevel: options.thinkingLevel,
|
||||||
|
reasoningEffort: options.reasoningEffort,
|
||||||
|
readOnly: options.readOnly,
|
||||||
|
settingSources: options.settingSources,
|
||||||
|
};
|
||||||
|
|
||||||
|
for await (const msg of provider.executeQuery(providerOptions)) {
|
||||||
|
// Handle error messages
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
const errorMessage = msg.error || 'Provider returned an error';
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text from assistant messages
|
||||||
|
if (msg.type === 'assistant' && msg.message?.content) {
|
||||||
|
for (const block of msg.message.content) {
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
responseText += block.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle result messages
|
||||||
|
if (msg.type === 'result') {
|
||||||
|
if (msg.subtype === 'success') {
|
||||||
|
// Use result text if longer than accumulated text
|
||||||
|
if (msg.result && msg.result.length > responseText.length) {
|
||||||
|
responseText = msg.result;
|
||||||
|
}
|
||||||
|
// Capture structured output if present
|
||||||
|
if (msg.structured_output) {
|
||||||
|
structuredOutput = msg.structured_output;
|
||||||
|
}
|
||||||
|
} else if (msg.subtype === 'error_max_turns') {
|
||||||
|
// Max turns reached - return what we have
|
||||||
|
break;
|
||||||
|
} else if (msg.subtype === 'error_max_structured_output_retries') {
|
||||||
|
throw new Error('Could not produce valid structured output after retries');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: responseText, structured_output: structuredOutput };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a streaming query with event callbacks
|
||||||
|
*
|
||||||
|
* Use this for queries where you need real-time progress updates,
|
||||||
|
* such as when displaying streaming output to a user.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const result = await streamingQuery({
|
||||||
|
* prompt: 'Analyze this project and suggest improvements',
|
||||||
|
* cwd: '/path/to/project',
|
||||||
|
* maxTurns: 250,
|
||||||
|
* allowedTools: ['Read', 'Glob', 'Grep'],
|
||||||
|
* onText: (text) => emitProgress(text),
|
||||||
|
* onToolUse: (tool, input) => emitToolUse(tool, input),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function streamingQuery(options: StreamingQueryOptions): Promise<SimpleQueryResult> {
|
||||||
|
const model = options.model || DEFAULT_MODEL;
|
||||||
|
const provider = ProviderFactory.getProviderForModel(model);
|
||||||
|
const bareModel = stripProviderPrefix(model);
|
||||||
|
|
||||||
|
let responseText = '';
|
||||||
|
let structuredOutput: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
// Build provider options
|
||||||
|
const providerOptions = {
|
||||||
|
prompt: options.prompt,
|
||||||
|
model: bareModel,
|
||||||
|
originalModel: model,
|
||||||
|
cwd: options.cwd,
|
||||||
|
systemPrompt: options.systemPrompt,
|
||||||
|
maxTurns: options.maxTurns ?? 250,
|
||||||
|
allowedTools: options.allowedTools ?? ['Read', 'Glob', 'Grep'],
|
||||||
|
abortController: options.abortController,
|
||||||
|
outputFormat: options.outputFormat,
|
||||||
|
thinkingLevel: options.thinkingLevel,
|
||||||
|
reasoningEffort: options.reasoningEffort,
|
||||||
|
readOnly: options.readOnly,
|
||||||
|
settingSources: options.settingSources,
|
||||||
|
};
|
||||||
|
|
||||||
|
for await (const msg of provider.executeQuery(providerOptions)) {
|
||||||
|
// Handle error messages
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
const errorMessage = msg.error || 'Provider returned an error';
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract content from assistant messages
|
||||||
|
if (msg.type === 'assistant' && msg.message?.content) {
|
||||||
|
for (const block of msg.message.content) {
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
responseText += block.text;
|
||||||
|
options.onText?.(block.text);
|
||||||
|
} else if (block.type === 'tool_use' && block.name) {
|
||||||
|
options.onToolUse?.(block.name, block.input);
|
||||||
|
} else if (block.type === 'thinking' && block.thinking) {
|
||||||
|
options.onThinking?.(block.thinking);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle result messages
|
||||||
|
if (msg.type === 'result') {
|
||||||
|
if (msg.subtype === 'success') {
|
||||||
|
// Use result text if longer than accumulated text
|
||||||
|
if (msg.result && msg.result.length > responseText.length) {
|
||||||
|
responseText = msg.result;
|
||||||
|
}
|
||||||
|
// Capture structured output if present
|
||||||
|
if (msg.structured_output) {
|
||||||
|
structuredOutput = msg.structured_output;
|
||||||
|
}
|
||||||
|
} else if (msg.subtype === 'error_max_turns') {
|
||||||
|
// Max turns reached - return what we have
|
||||||
|
break;
|
||||||
|
} else if (msg.subtype === 'error_max_structured_output_retries') {
|
||||||
|
throw new Error('Could not produce valid structured output after retries');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: responseText, structured_output: structuredOutput };
|
||||||
|
}
|
||||||
@@ -5,15 +5,12 @@
|
|||||||
* (defaults to Sonnet for balanced speed and quality).
|
* (defaults to Sonnet for balanced speed and quality).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
||||||
import * as secureFs from '../../lib/secure-fs.js';
|
import * as secureFs from '../../lib/secure-fs.js';
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
|
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
|
||||||
import { logAuthStatus } from './common.js';
|
|
||||||
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
||||||
import { getAppSpecPath } from '@automaker/platform';
|
import { getAppSpecPath } from '@automaker/platform';
|
||||||
import type { SettingsService } from '../../services/settings-service.js';
|
import type { SettingsService } from '../../services/settings-service.js';
|
||||||
@@ -115,121 +112,30 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
|
|||||||
|
|
||||||
logger.info('Using model:', model);
|
logger.info('Using model:', model);
|
||||||
|
|
||||||
let responseText = '';
|
// Use streamingQuery with event callbacks
|
||||||
let messageCount = 0;
|
const result = await streamingQuery({
|
||||||
|
prompt,
|
||||||
|
model,
|
||||||
|
cwd: projectPath,
|
||||||
|
maxTurns: 250,
|
||||||
|
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||||
|
abortController,
|
||||||
|
thinkingLevel,
|
||||||
|
readOnly: true, // Feature generation only reads code, doesn't write
|
||||||
|
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||||
|
onText: (text) => {
|
||||||
|
logger.debug(`Feature text block received (${text.length} chars)`);
|
||||||
|
events.emit('spec-regeneration:event', {
|
||||||
|
type: 'spec_regeneration_progress',
|
||||||
|
content: text,
|
||||||
|
projectPath: projectPath,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Route to appropriate provider based on model type
|
const responseText = result.text;
|
||||||
if (isCursorModel(model)) {
|
|
||||||
// Use Cursor provider for Cursor models
|
|
||||||
logger.info('[FeatureGeneration] Using Cursor provider');
|
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
logger.info(`Feature stream complete.`);
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
|
||||||
const bareModel = stripProviderPrefix(model);
|
|
||||||
|
|
||||||
// Add explicit instructions for Cursor to return JSON in response
|
|
||||||
const cursorPrompt = `${prompt}
|
|
||||||
|
|
||||||
CRITICAL INSTRUCTIONS:
|
|
||||||
1. DO NOT write any files. Return the JSON in your response only.
|
|
||||||
2. Respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
|
|
||||||
3. Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
|
|
||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
|
||||||
prompt: cursorPrompt,
|
|
||||||
model: bareModel,
|
|
||||||
cwd: projectPath,
|
|
||||||
maxTurns: 250,
|
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
|
||||||
abortController,
|
|
||||||
readOnly: true, // Feature generation only reads code, doesn't write
|
|
||||||
})) {
|
|
||||||
messageCount++;
|
|
||||||
|
|
||||||
if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
for (const block of msg.message.content) {
|
|
||||||
if (block.type === 'text' && block.text) {
|
|
||||||
responseText += block.text;
|
|
||||||
logger.debug(`Feature text block received (${block.text.length} chars)`);
|
|
||||||
events.emit('spec-regeneration:event', {
|
|
||||||
type: 'spec_regeneration_progress',
|
|
||||||
content: block.text,
|
|
||||||
projectPath: projectPath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
|
||||||
// Use result if it's a final accumulated message
|
|
||||||
if (msg.result.length > responseText.length) {
|
|
||||||
responseText = msg.result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use Claude SDK for Claude models
|
|
||||||
logger.info('[FeatureGeneration] Using Claude SDK');
|
|
||||||
|
|
||||||
const options = createFeatureGenerationOptions({
|
|
||||||
cwd: projectPath,
|
|
||||||
abortController,
|
|
||||||
autoLoadClaudeMd,
|
|
||||||
model,
|
|
||||||
thinkingLevel, // Pass thinking level for extended thinking
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
|
|
||||||
logger.info('Calling Claude Agent SDK query() for features...');
|
|
||||||
|
|
||||||
logAuthStatus('Right before SDK query() for features');
|
|
||||||
|
|
||||||
let stream;
|
|
||||||
try {
|
|
||||||
stream = query({ prompt, options });
|
|
||||||
logger.debug('query() returned stream successfully');
|
|
||||||
} catch (queryError) {
|
|
||||||
logger.error('❌ query() threw an exception:');
|
|
||||||
logger.error('Error:', queryError);
|
|
||||||
throw queryError;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('Starting to iterate over feature stream...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const msg of stream) {
|
|
||||||
messageCount++;
|
|
||||||
logger.debug(
|
|
||||||
`Feature stream message #${messageCount}:`,
|
|
||||||
JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (msg.type === 'assistant' && msg.message.content) {
|
|
||||||
for (const block of msg.message.content) {
|
|
||||||
if (block.type === 'text') {
|
|
||||||
responseText += block.text;
|
|
||||||
logger.debug(`Feature text block received (${block.text.length} chars)`);
|
|
||||||
events.emit('spec-regeneration:event', {
|
|
||||||
type: 'spec_regeneration_progress',
|
|
||||||
content: block.text,
|
|
||||||
projectPath: projectPath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
|
|
||||||
logger.debug('Received success result for features');
|
|
||||||
responseText = (msg as any).result || responseText;
|
|
||||||
} else if ((msg as { type: string }).type === 'error') {
|
|
||||||
logger.error('❌ Received error message from feature stream:');
|
|
||||||
logger.error('Error message:', JSON.stringify(msg, null, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (streamError) {
|
|
||||||
logger.error('❌ Error while iterating feature stream:');
|
|
||||||
logger.error('Stream error:', streamError);
|
|
||||||
throw streamError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Feature stream complete. Total messages: ${messageCount}`);
|
|
||||||
logger.info(`Feature response length: ${responseText.length} chars`);
|
logger.info(`Feature response length: ${responseText.length} chars`);
|
||||||
logger.info('========== FULL RESPONSE TEXT ==========');
|
logger.info('========== FULL RESPONSE TEXT ==========');
|
||||||
logger.info(responseText);
|
logger.info(responseText);
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
* (defaults to Opus for high-quality specification generation).
|
* (defaults to Opus for high-quality specification generation).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
||||||
import path from 'path';
|
|
||||||
import * as secureFs from '../../lib/secure-fs.js';
|
import * as secureFs from '../../lib/secure-fs.js';
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import {
|
import {
|
||||||
@@ -16,12 +14,10 @@ import {
|
|||||||
type SpecOutput,
|
type SpecOutput,
|
||||||
} from '../../lib/app-spec-format.js';
|
} from '../../lib/app-spec-format.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
|
|
||||||
import { extractJson } from '../../lib/json-extractor.js';
|
import { extractJson } from '../../lib/json-extractor.js';
|
||||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||||
import { logAuthStatus } from './common.js';
|
|
||||||
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
|
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
|
||||||
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
|
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
|
||||||
import type { SettingsService } from '../../services/settings-service.js';
|
import type { SettingsService } from '../../services/settings-service.js';
|
||||||
@@ -109,21 +105,15 @@ ${getStructuredSpecPromptInstruction()}`;
|
|||||||
logger.info('Using model:', model);
|
logger.info('Using model:', model);
|
||||||
|
|
||||||
let responseText = '';
|
let responseText = '';
|
||||||
let messageCount = 0;
|
|
||||||
let structuredOutput: SpecOutput | null = null;
|
let structuredOutput: SpecOutput | null = null;
|
||||||
|
|
||||||
// Route to appropriate provider based on model type
|
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
|
||||||
if (isCursorModel(model)) {
|
const useStructuredOutput = !isCursorModel(model);
|
||||||
// Use Cursor provider for Cursor models
|
|
||||||
logger.info('[SpecGeneration] Using Cursor provider');
|
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
// Build the final prompt - for Cursor, include JSON schema instructions
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
let finalPrompt = prompt;
|
||||||
const bareModel = stripProviderPrefix(model);
|
if (!useStructuredOutput) {
|
||||||
|
finalPrompt = `${prompt}
|
||||||
// For Cursor, include the JSON schema in the prompt with clear instructions
|
|
||||||
// to return JSON in the response (not write to a file)
|
|
||||||
const cursorPrompt = `${prompt}
|
|
||||||
|
|
||||||
CRITICAL INSTRUCTIONS:
|
CRITICAL INSTRUCTIONS:
|
||||||
1. DO NOT write any files. DO NOT create any files like "project_specification.json".
|
1. DO NOT write any files. DO NOT create any files like "project_specification.json".
|
||||||
@@ -133,153 +123,57 @@ CRITICAL INSTRUCTIONS:
|
|||||||
${JSON.stringify(specOutputSchema, null, 2)}
|
${JSON.stringify(specOutputSchema, null, 2)}
|
||||||
|
|
||||||
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
|
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
|
||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
|
||||||
prompt: cursorPrompt,
|
|
||||||
model: bareModel,
|
|
||||||
cwd: projectPath,
|
|
||||||
maxTurns: 250,
|
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
|
||||||
abortController,
|
|
||||||
readOnly: true, // Spec generation only reads code, we write the spec ourselves
|
|
||||||
})) {
|
|
||||||
messageCount++;
|
|
||||||
|
|
||||||
if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
for (const block of msg.message.content) {
|
|
||||||
if (block.type === 'text' && block.text) {
|
|
||||||
responseText += block.text;
|
|
||||||
logger.info(
|
|
||||||
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
|
|
||||||
);
|
|
||||||
events.emit('spec-regeneration:event', {
|
|
||||||
type: 'spec_regeneration_progress',
|
|
||||||
content: block.text,
|
|
||||||
projectPath: projectPath,
|
|
||||||
});
|
|
||||||
} else if (block.type === 'tool_use') {
|
|
||||||
logger.info('Tool use:', block.name);
|
|
||||||
events.emit('spec-regeneration:event', {
|
|
||||||
type: 'spec_tool',
|
|
||||||
tool: block.name,
|
|
||||||
input: block.input,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
|
||||||
// Use result if it's a final accumulated message
|
|
||||||
if (msg.result.length > responseText.length) {
|
|
||||||
responseText = msg.result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse JSON from the response text using shared utility
|
|
||||||
if (responseText) {
|
|
||||||
structuredOutput = extractJson<SpecOutput>(responseText, { logger });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use Claude SDK for Claude models
|
|
||||||
logger.info('[SpecGeneration] Using Claude SDK');
|
|
||||||
|
|
||||||
const options = createSpecGenerationOptions({
|
|
||||||
cwd: projectPath,
|
|
||||||
abortController,
|
|
||||||
autoLoadClaudeMd,
|
|
||||||
model,
|
|
||||||
thinkingLevel, // Pass thinking level for extended thinking
|
|
||||||
outputFormat: {
|
|
||||||
type: 'json_schema',
|
|
||||||
schema: specOutputSchema,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
|
|
||||||
logger.info('Calling Claude Agent SDK query()...');
|
|
||||||
|
|
||||||
// Log auth status right before the SDK call
|
|
||||||
logAuthStatus('Right before SDK query()');
|
|
||||||
|
|
||||||
let stream;
|
|
||||||
try {
|
|
||||||
stream = query({ prompt, options });
|
|
||||||
logger.debug('query() returned stream successfully');
|
|
||||||
} catch (queryError) {
|
|
||||||
logger.error('❌ query() threw an exception:');
|
|
||||||
logger.error('Error:', queryError);
|
|
||||||
throw queryError;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Starting to iterate over stream...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const msg of stream) {
|
|
||||||
messageCount++;
|
|
||||||
logger.info(
|
|
||||||
`Stream message #${messageCount}: type=${msg.type}, subtype=${(msg as any).subtype}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (msg.type === 'assistant') {
|
|
||||||
const msgAny = msg as any;
|
|
||||||
if (msgAny.message?.content) {
|
|
||||||
for (const block of msgAny.message.content) {
|
|
||||||
if (block.type === 'text') {
|
|
||||||
responseText += block.text;
|
|
||||||
logger.info(
|
|
||||||
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
|
|
||||||
);
|
|
||||||
events.emit('spec-regeneration:event', {
|
|
||||||
type: 'spec_regeneration_progress',
|
|
||||||
content: block.text,
|
|
||||||
projectPath: projectPath,
|
|
||||||
});
|
|
||||||
} else if (block.type === 'tool_use') {
|
|
||||||
logger.info('Tool use:', block.name);
|
|
||||||
events.emit('spec-regeneration:event', {
|
|
||||||
type: 'spec_tool',
|
|
||||||
tool: block.name,
|
|
||||||
input: block.input,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
|
|
||||||
logger.info('Received success result');
|
|
||||||
// Check for structured output - this is the reliable way to get spec data
|
|
||||||
const resultMsg = msg as any;
|
|
||||||
if (resultMsg.structured_output) {
|
|
||||||
structuredOutput = resultMsg.structured_output as SpecOutput;
|
|
||||||
logger.info('✅ Received structured output');
|
|
||||||
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
|
|
||||||
} else {
|
|
||||||
logger.warn('⚠️ No structured output in result, will fall back to text parsing');
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'result') {
|
|
||||||
// Handle error result types
|
|
||||||
const subtype = (msg as any).subtype;
|
|
||||||
logger.info(`Result message: subtype=${subtype}`);
|
|
||||||
if (subtype === 'error_max_turns') {
|
|
||||||
logger.error('❌ Hit max turns limit!');
|
|
||||||
} else if (subtype === 'error_max_structured_output_retries') {
|
|
||||||
logger.error('❌ Failed to produce valid structured output after retries');
|
|
||||||
throw new Error('Could not produce valid spec output');
|
|
||||||
}
|
|
||||||
} else if ((msg as { type: string }).type === 'error') {
|
|
||||||
logger.error('❌ Received error message from stream:');
|
|
||||||
logger.error('Error message:', JSON.stringify(msg, null, 2));
|
|
||||||
} else if (msg.type === 'user') {
|
|
||||||
// Log user messages (tool results)
|
|
||||||
logger.info(`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (streamError) {
|
|
||||||
logger.error('❌ Error while iterating stream:');
|
|
||||||
logger.error('Stream error:', streamError);
|
|
||||||
throw streamError;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Stream iteration complete. Total messages: ${messageCount}`);
|
// Use streamingQuery with event callbacks
|
||||||
|
const result = await streamingQuery({
|
||||||
|
prompt: finalPrompt,
|
||||||
|
model,
|
||||||
|
cwd: projectPath,
|
||||||
|
maxTurns: 250,
|
||||||
|
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||||
|
abortController,
|
||||||
|
thinkingLevel,
|
||||||
|
readOnly: true, // Spec generation only reads code, we write the spec ourselves
|
||||||
|
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||||
|
outputFormat: useStructuredOutput
|
||||||
|
? {
|
||||||
|
type: 'json_schema',
|
||||||
|
schema: specOutputSchema,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onText: (text) => {
|
||||||
|
responseText += text;
|
||||||
|
logger.info(
|
||||||
|
`Text block received (${text.length} chars), total now: ${responseText.length} chars`
|
||||||
|
);
|
||||||
|
events.emit('spec-regeneration:event', {
|
||||||
|
type: 'spec_regeneration_progress',
|
||||||
|
content: text,
|
||||||
|
projectPath: projectPath,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onToolUse: (tool, input) => {
|
||||||
|
logger.info('Tool use:', tool);
|
||||||
|
events.emit('spec-regeneration:event', {
|
||||||
|
type: 'spec_tool',
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get structured output if available
|
||||||
|
if (result.structured_output) {
|
||||||
|
structuredOutput = result.structured_output as unknown as SpecOutput;
|
||||||
|
logger.info('✅ Received structured output');
|
||||||
|
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
|
||||||
|
} else if (!useStructuredOutput && responseText) {
|
||||||
|
// For non-Claude providers, parse JSON from response text
|
||||||
|
structuredOutput = extractJson<SpecOutput>(responseText, { logger });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Stream iteration complete.`);
|
||||||
logger.info(`Response text length: ${responseText.length} chars`);
|
logger.info(`Response text length: ${responseText.length} chars`);
|
||||||
|
|
||||||
// Determine XML content to save
|
// Determine XML content to save
|
||||||
|
|||||||
@@ -11,13 +11,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||||
import { PathNotAllowedError } from '@automaker/platform';
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
|
||||||
import * as secureFs from '../../../lib/secure-fs.js';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { SettingsService } from '../../../services/settings-service.js';
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
@@ -49,31 +47,6 @@ interface DescribeFileErrorResponse {
|
|||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract text content from Claude SDK response messages
|
|
||||||
*/
|
|
||||||
async function extractTextFromStream(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
stream: AsyncIterable<any>
|
|
||||||
): Promise<string> {
|
|
||||||
let responseText = '';
|
|
||||||
|
|
||||||
for await (const msg of stream) {
|
|
||||||
if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
|
|
||||||
for (const block of blocks) {
|
|
||||||
if (block.type === 'text' && block.text) {
|
|
||||||
responseText += block.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
|
||||||
responseText = msg.result || responseText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseText;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the describe-file request handler
|
* Create the describe-file request handler
|
||||||
*
|
*
|
||||||
@@ -159,16 +132,14 @@ export function createDescribeFileHandler(
|
|||||||
|
|
||||||
// Build prompt with file content passed as structured data
|
// Build prompt with file content passed as structured data
|
||||||
// The file content is included directly, not via tool invocation
|
// The file content is included directly, not via tool invocation
|
||||||
const instructionText = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project").
|
const prompt = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project").
|
||||||
|
|
||||||
Respond with ONLY the description text, no additional formatting, preamble, or explanation.
|
Respond with ONLY the description text, no additional formatting, preamble, or explanation.
|
||||||
|
|
||||||
File: ${fileName}${truncated ? ' (truncated)' : ''}`;
|
File: ${fileName}${truncated ? ' (truncated)' : ''}
|
||||||
|
|
||||||
const promptContent = [
|
--- FILE CONTENT ---
|
||||||
{ type: 'text' as const, text: instructionText },
|
${contentToAnalyze}`;
|
||||||
{ type: 'text' as const, text: `\n\n--- FILE CONTENT ---\n${contentToAnalyze}` },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Use the file's directory as the working directory
|
// Use the file's directory as the working directory
|
||||||
const cwd = path.dirname(resolvedPath);
|
const cwd = path.dirname(resolvedPath);
|
||||||
@@ -190,67 +161,19 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
|
|||||||
|
|
||||||
logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
|
logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
|
||||||
|
|
||||||
let description: string;
|
// Use simpleQuery - provider abstraction handles routing to correct provider
|
||||||
|
const result = await simpleQuery({
|
||||||
|
prompt,
|
||||||
|
model,
|
||||||
|
cwd,
|
||||||
|
maxTurns: 1,
|
||||||
|
allowedTools: [],
|
||||||
|
thinkingLevel,
|
||||||
|
readOnly: true, // File description only reads, doesn't write
|
||||||
|
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
// Route to appropriate provider based on model type
|
const description = result.text;
|
||||||
if (isCursorModel(model)) {
|
|
||||||
// Use Cursor provider for Cursor models
|
|
||||||
logger.info(`Using Cursor provider for model: ${model}`);
|
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
|
||||||
const bareModel = stripProviderPrefix(model);
|
|
||||||
|
|
||||||
// Build a simple text prompt for Cursor (no multi-part content blocks)
|
|
||||||
const cursorPrompt = `${instructionText}\n\n--- FILE CONTENT ---\n${contentToAnalyze}`;
|
|
||||||
|
|
||||||
let responseText = '';
|
|
||||||
for await (const msg of provider.executeQuery({
|
|
||||||
prompt: cursorPrompt,
|
|
||||||
model: bareModel,
|
|
||||||
cwd,
|
|
||||||
maxTurns: 1,
|
|
||||||
allowedTools: [],
|
|
||||||
readOnly: true, // File description only reads, doesn't write
|
|
||||||
})) {
|
|
||||||
if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
for (const block of msg.message.content) {
|
|
||||||
if (block.type === 'text' && block.text) {
|
|
||||||
responseText += block.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
description = responseText;
|
|
||||||
} else {
|
|
||||||
// Use Claude SDK for Claude models
|
|
||||||
logger.info(`Using Claude SDK for model: ${model}`);
|
|
||||||
|
|
||||||
// Use centralized SDK options with proper cwd validation
|
|
||||||
// No tools needed since we're passing file content directly
|
|
||||||
const sdkOptions = createCustomOptions({
|
|
||||||
cwd,
|
|
||||||
model,
|
|
||||||
maxTurns: 1,
|
|
||||||
allowedTools: [],
|
|
||||||
autoLoadClaudeMd,
|
|
||||||
thinkingLevel, // Pass thinking level for extended thinking
|
|
||||||
});
|
|
||||||
|
|
||||||
const promptGenerator = (async function* () {
|
|
||||||
yield {
|
|
||||||
type: 'user' as const,
|
|
||||||
session_id: '',
|
|
||||||
message: { role: 'user' as const, content: promptContent },
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
const stream = query({ prompt: promptGenerator, options: sdkOptions });
|
|
||||||
|
|
||||||
// Extract the description from the response
|
|
||||||
description = await extractTextFromStream(stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!description || description.trim().length === 0) {
|
if (!description || description.trim().length === 0) {
|
||||||
logger.warn('Received empty response from Claude');
|
logger.warn('Received empty response from Claude');
|
||||||
|
|||||||
@@ -12,12 +12,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
||||||
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
||||||
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
|
||||||
import * as secureFs from '../../../lib/secure-fs.js';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { SettingsService } from '../../../services/settings-service.js';
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
@@ -178,57 +176,10 @@ function mapDescribeImageError(rawMessage: string | undefined): {
|
|||||||
return baseResponse;
|
return baseResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract text content from Claude SDK response messages and log high-signal stream events.
|
|
||||||
*/
|
|
||||||
async function extractTextFromStream(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
stream: AsyncIterable<any>,
|
|
||||||
requestId: string
|
|
||||||
): Promise<string> {
|
|
||||||
let responseText = '';
|
|
||||||
let messageCount = 0;
|
|
||||||
|
|
||||||
logger.info(`[${requestId}] [Stream] Begin reading SDK stream...`);
|
|
||||||
|
|
||||||
for await (const msg of stream) {
|
|
||||||
messageCount++;
|
|
||||||
const msgType = msg?.type;
|
|
||||||
const msgSubtype = msg?.subtype;
|
|
||||||
|
|
||||||
// Keep this concise but informative. Full error object is logged in catch blocks.
|
|
||||||
logger.info(
|
|
||||||
`[${requestId}] [Stream] #${messageCount} type=${String(msgType)} subtype=${String(msgSubtype ?? '')}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (msgType === 'assistant' && msg.message?.content) {
|
|
||||||
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
|
|
||||||
logger.info(`[${requestId}] [Stream] assistant blocks=${blocks.length}`);
|
|
||||||
for (const block of blocks) {
|
|
||||||
if (block.type === 'text' && block.text) {
|
|
||||||
responseText += block.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msgType === 'result' && msgSubtype === 'success') {
|
|
||||||
if (typeof msg.result === 'string' && msg.result.length > 0) {
|
|
||||||
responseText = msg.result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`[${requestId}] [Stream] End of stream. messages=${messageCount} textLength=${responseText.length}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return responseText;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the describe-image request handler
|
* Create the describe-image request handler
|
||||||
*
|
*
|
||||||
* Uses Claude SDK query with multi-part content blocks to include the image (base64),
|
* Uses the provider abstraction with multi-part content blocks to include the image (base64),
|
||||||
* matching the agent runner behavior.
|
* matching the agent runner behavior.
|
||||||
*
|
*
|
||||||
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
|
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
|
||||||
@@ -309,27 +260,6 @@ export function createDescribeImageHandler(
|
|||||||
`[${requestId}] image meta filename=${imageData.filename} mime=${imageData.mimeType} base64Len=${base64Length} estBytes=${estimatedBytes}`
|
`[${requestId}] image meta filename=${imageData.filename} mime=${imageData.mimeType} base64Len=${base64Length} estBytes=${estimatedBytes}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build multi-part prompt with image block (no Read tool required)
|
|
||||||
const instructionText =
|
|
||||||
`Describe this image in 1-2 sentences suitable for use as context in an AI coding assistant. ` +
|
|
||||||
`Focus on what the image shows and its purpose (e.g., "UI mockup showing login form with email/password fields", ` +
|
|
||||||
`"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` +
|
|
||||||
`Respond with ONLY the description text, no additional formatting, preamble, or explanation.`;
|
|
||||||
|
|
||||||
const promptContent = [
|
|
||||||
{ type: 'text' as const, text: instructionText },
|
|
||||||
{
|
|
||||||
type: 'image' as const,
|
|
||||||
source: {
|
|
||||||
type: 'base64' as const,
|
|
||||||
media_type: imageData.mimeType,
|
|
||||||
data: imageData.base64,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
logger.info(`[${requestId}] Built multi-part prompt blocks=${promptContent.length}`);
|
|
||||||
|
|
||||||
const cwd = path.dirname(actualPath);
|
const cwd = path.dirname(actualPath);
|
||||||
logger.info(`[${requestId}] Using cwd=${cwd}`);
|
logger.info(`[${requestId}] Using cwd=${cwd}`);
|
||||||
|
|
||||||
@@ -348,85 +278,59 @@ export function createDescribeImageHandler(
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Using model: ${model}`);
|
logger.info(`[${requestId}] Using model: ${model}`);
|
||||||
|
|
||||||
let description: string;
|
// Build the instruction text
|
||||||
|
const instructionText =
|
||||||
|
`Describe this image in 1-2 sentences suitable for use as context in an AI coding assistant. ` +
|
||||||
|
`Focus on what the image shows and its purpose (e.g., "UI mockup showing login form with email/password fields", ` +
|
||||||
|
`"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` +
|
||||||
|
`Respond with ONLY the description text, no additional formatting, preamble, or explanation.`;
|
||||||
|
|
||||||
|
// Build prompt based on provider capability
|
||||||
|
// Some providers (like Cursor) may not support image content blocks
|
||||||
|
let prompt: string | Array<{ type: string; text?: string; source?: object }>;
|
||||||
|
|
||||||
// Route to appropriate provider based on model type
|
|
||||||
if (isCursorModel(model)) {
|
if (isCursorModel(model)) {
|
||||||
// Use Cursor provider for Cursor models
|
// Cursor may not support base64 image blocks directly
|
||||||
// Note: Cursor may have limited support for image content blocks
|
// Use text prompt with image path reference
|
||||||
logger.info(`[${requestId}] Using Cursor provider for model: ${model}`);
|
logger.info(`[${requestId}] Using text prompt for Cursor model`);
|
||||||
|
prompt = `${instructionText}\n\nImage file: ${actualPath}\nMIME type: ${imageData.mimeType}`;
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
|
||||||
const bareModel = stripProviderPrefix(model);
|
|
||||||
|
|
||||||
// Build prompt with image reference for Cursor
|
|
||||||
// Note: Cursor CLI may not support base64 image blocks directly,
|
|
||||||
// so we include the image path as context
|
|
||||||
const cursorPrompt = `${instructionText}\n\nImage file: ${actualPath}\nMIME type: ${imageData.mimeType}`;
|
|
||||||
|
|
||||||
let responseText = '';
|
|
||||||
const queryStart = Date.now();
|
|
||||||
for await (const msg of provider.executeQuery({
|
|
||||||
prompt: cursorPrompt,
|
|
||||||
model: bareModel,
|
|
||||||
cwd,
|
|
||||||
maxTurns: 1,
|
|
||||||
allowedTools: ['Read'], // Allow Read tool so Cursor can read the image if needed
|
|
||||||
readOnly: true, // Image description only reads, doesn't write
|
|
||||||
})) {
|
|
||||||
if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
for (const block of msg.message.content) {
|
|
||||||
if (block.type === 'text' && block.text) {
|
|
||||||
responseText += block.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.info(`[${requestId}] Cursor query completed in ${Date.now() - queryStart}ms`);
|
|
||||||
description = responseText;
|
|
||||||
} else {
|
} else {
|
||||||
// Use Claude SDK for Claude models (supports image content blocks)
|
// Claude and other vision-capable models support multi-part prompts with images
|
||||||
logger.info(`[${requestId}] Using Claude SDK for model: ${model}`);
|
logger.info(`[${requestId}] Using multi-part prompt with image block`);
|
||||||
|
prompt = [
|
||||||
// Use the same centralized option builder used across the server (validates cwd)
|
{ type: 'text', text: instructionText },
|
||||||
const sdkOptions = createCustomOptions({
|
{
|
||||||
cwd,
|
type: 'image',
|
||||||
model,
|
source: {
|
||||||
maxTurns: 1,
|
type: 'base64',
|
||||||
allowedTools: [],
|
media_type: imageData.mimeType,
|
||||||
autoLoadClaudeMd,
|
data: imageData.base64,
|
||||||
thinkingLevel, // Pass thinking level for extended thinking
|
},
|
||||||
});
|
},
|
||||||
|
];
|
||||||
logger.info(
|
|
||||||
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
|
|
||||||
sdkOptions.allowedTools
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const promptGenerator = (async function* () {
|
|
||||||
yield {
|
|
||||||
type: 'user' as const,
|
|
||||||
session_id: '',
|
|
||||||
message: { role: 'user' as const, content: promptContent },
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
logger.info(`[${requestId}] Calling query()...`);
|
|
||||||
const queryStart = Date.now();
|
|
||||||
const stream = query({ prompt: promptGenerator, options: sdkOptions });
|
|
||||||
logger.info(`[${requestId}] query() returned stream in ${Date.now() - queryStart}ms`);
|
|
||||||
|
|
||||||
// Extract the description from the response
|
|
||||||
const extractStart = Date.now();
|
|
||||||
description = await extractTextFromStream(stream, requestId);
|
|
||||||
logger.info(`[${requestId}] extractMs=${Date.now() - extractStart}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Calling simpleQuery...`);
|
||||||
|
const queryStart = Date.now();
|
||||||
|
|
||||||
|
// Use simpleQuery - provider abstraction handles routing
|
||||||
|
const result = await simpleQuery({
|
||||||
|
prompt,
|
||||||
|
model,
|
||||||
|
cwd,
|
||||||
|
maxTurns: 1,
|
||||||
|
allowedTools: isCursorModel(model) ? ['Read'] : [], // Allow Read for Cursor to read image if needed
|
||||||
|
thinkingLevel,
|
||||||
|
readOnly: true, // Image description only reads, doesn't write
|
||||||
|
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`);
|
||||||
|
|
||||||
|
const description = result.text;
|
||||||
|
|
||||||
if (!description || description.trim().length === 0) {
|
if (!description || description.trim().length === 0) {
|
||||||
logger.warn(`[${requestId}] Received empty response from Claude`);
|
logger.warn(`[${requestId}] Received empty response from AI`);
|
||||||
const response: DescribeImageErrorResponse = {
|
const response: DescribeImageErrorResponse = {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Failed to generate description - empty response',
|
error: 'Failed to generate description - empty response',
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* POST /enhance-prompt endpoint - Enhance user input text
|
* POST /enhance-prompt endpoint - Enhance user input text
|
||||||
*
|
*
|
||||||
* Uses Claude AI or Cursor to enhance text based on the specified enhancement mode.
|
* Uses the provider abstraction to enhance text based on the specified
|
||||||
* Supports modes: improve, technical, simplify, acceptance
|
* enhancement mode. Works with any configured provider (Claude, Cursor, etc.).
|
||||||
|
* Supports modes: improve, technical, simplify, acceptance, ux-reviewer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
import {
|
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
|
||||||
CLAUDE_MODEL_MAP,
|
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||||
isCursorModel,
|
|
||||||
isOpencodeModel,
|
|
||||||
stripProviderPrefix,
|
|
||||||
ThinkingLevel,
|
|
||||||
getThinkingTokenBudget,
|
|
||||||
} from '@automaker/types';
|
|
||||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
|
||||||
import type { SettingsService } from '../../../services/settings-service.js';
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
|
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
|
||||||
import {
|
import {
|
||||||
@@ -38,7 +31,7 @@ interface EnhanceRequestBody {
|
|||||||
enhancementMode: string;
|
enhancementMode: string;
|
||||||
/** Optional model override */
|
/** Optional model override */
|
||||||
model?: string;
|
model?: string;
|
||||||
/** Optional thinking level for Claude models (ignored for Cursor models) */
|
/** Optional thinking level for Claude models */
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,80 +51,6 @@ interface EnhanceErrorResponse {
|
|||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract text content from Claude SDK response messages
|
|
||||||
*
|
|
||||||
* @param stream - The async iterable from the query function
|
|
||||||
* @returns The extracted text content
|
|
||||||
*/
|
|
||||||
async function extractTextFromStream(
|
|
||||||
stream: AsyncIterable<{
|
|
||||||
type: string;
|
|
||||||
subtype?: string;
|
|
||||||
result?: string;
|
|
||||||
message?: {
|
|
||||||
content?: Array<{ type: string; text?: string }>;
|
|
||||||
};
|
|
||||||
}>
|
|
||||||
): Promise<string> {
|
|
||||||
let responseText = '';
|
|
||||||
|
|
||||||
for await (const msg of stream) {
|
|
||||||
if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
for (const block of msg.message.content) {
|
|
||||||
if (block.type === 'text' && block.text) {
|
|
||||||
responseText += block.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
|
||||||
responseText = msg.result || responseText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseText;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute enhancement using a provider (Cursor, OpenCode, etc.)
|
|
||||||
*
|
|
||||||
* @param prompt - The enhancement prompt
|
|
||||||
* @param model - The model to use
|
|
||||||
* @returns The enhanced text
|
|
||||||
*/
|
|
||||||
async function executeWithProvider(prompt: string, model: string): Promise<string> {
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
|
||||||
const bareModel = stripProviderPrefix(model);
|
|
||||||
|
|
||||||
let responseText = '';
|
|
||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
|
||||||
prompt,
|
|
||||||
model: bareModel,
|
|
||||||
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
|
|
||||||
readOnly: true, // Prompt enhancement only generates text, doesn't write files
|
|
||||||
})) {
|
|
||||||
if (msg.type === 'error') {
|
|
||||||
// Throw error with the message from the provider
|
|
||||||
const errorMessage = msg.error || 'Provider returned an error';
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
} else if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
for (const block of msg.message.content) {
|
|
||||||
if (block.type === 'text' && block.text) {
|
|
||||||
responseText += block.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
|
||||||
// Use result if it's a final accumulated message
|
|
||||||
if (msg.result.length > responseText.length) {
|
|
||||||
responseText = msg.result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseText;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the enhance request handler
|
* Create the enhance request handler
|
||||||
*
|
*
|
||||||
@@ -200,7 +119,6 @@ export function createEnhanceHandler(
|
|||||||
logger.debug(`Using ${validMode} system prompt (length: ${systemPrompt.length} chars)`);
|
logger.debug(`Using ${validMode} system prompt (length: ${systemPrompt.length} chars)`);
|
||||||
|
|
||||||
// Build the user prompt with few-shot examples
|
// Build the user prompt with few-shot examples
|
||||||
// This helps the model understand this is text transformation, not a coding task
|
|
||||||
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
|
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
|
||||||
|
|
||||||
// Resolve the model - use the passed model, default to sonnet for quality
|
// Resolve the model - use the passed model, default to sonnet for quality
|
||||||
@@ -208,47 +126,20 @@ export function createEnhanceHandler(
|
|||||||
|
|
||||||
logger.debug(`Using model: ${resolvedModel}`);
|
logger.debug(`Using model: ${resolvedModel}`);
|
||||||
|
|
||||||
let enhancedText: string;
|
// Use simpleQuery - provider abstraction handles routing to correct provider
|
||||||
|
// The system prompt is combined with user prompt since some providers
|
||||||
|
// don't have a separate system prompt concept
|
||||||
|
const result = await simpleQuery({
|
||||||
|
prompt: `${systemPrompt}\n\n${userPrompt}`,
|
||||||
|
model: resolvedModel,
|
||||||
|
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
|
||||||
|
maxTurns: 1,
|
||||||
|
allowedTools: [],
|
||||||
|
thinkingLevel,
|
||||||
|
readOnly: true, // Prompt enhancement only generates text, doesn't write files
|
||||||
|
});
|
||||||
|
|
||||||
// Route to appropriate provider based on model
|
const enhancedText = result.text;
|
||||||
if (isCursorModel(resolvedModel)) {
|
|
||||||
// Use Cursor provider for Cursor models
|
|
||||||
logger.info(`Using Cursor provider for model: ${resolvedModel}`);
|
|
||||||
|
|
||||||
// Cursor doesn't have a separate system prompt concept, so combine them
|
|
||||||
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
|
|
||||||
enhancedText = await executeWithProvider(combinedPrompt, resolvedModel);
|
|
||||||
} else if (isOpencodeModel(resolvedModel)) {
|
|
||||||
// Use OpenCode provider for OpenCode models (static and dynamic)
|
|
||||||
logger.info(`Using OpenCode provider for model: ${resolvedModel}`);
|
|
||||||
|
|
||||||
// OpenCode CLI handles the system prompt, so combine them
|
|
||||||
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
|
|
||||||
enhancedText = await executeWithProvider(combinedPrompt, resolvedModel);
|
|
||||||
} else {
|
|
||||||
// Use Claude SDK for Claude models
|
|
||||||
logger.info(`Using Claude provider for model: ${resolvedModel}`);
|
|
||||||
|
|
||||||
// Convert thinkingLevel to maxThinkingTokens for SDK
|
|
||||||
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
|
||||||
const queryOptions: Parameters<typeof query>[0]['options'] = {
|
|
||||||
model: resolvedModel,
|
|
||||||
systemPrompt,
|
|
||||||
maxTurns: 1,
|
|
||||||
allowedTools: [],
|
|
||||||
permissionMode: 'acceptEdits',
|
|
||||||
};
|
|
||||||
if (maxThinkingTokens) {
|
|
||||||
queryOptions.maxThinkingTokens = maxThinkingTokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stream = query({
|
|
||||||
prompt: userPrompt,
|
|
||||||
options: queryOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
enhancedText = await extractTextFromStream(stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!enhancedText || enhancedText.trim().length === 0) {
|
if (!enhancedText || enhancedText.trim().length === 0) {
|
||||||
logger.warn('Received empty response from AI');
|
logger.warn('Received empty response from AI');
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* POST /features/generate-title endpoint - Generate a concise title from description
|
* POST /features/generate-title endpoint - Generate a concise title from description
|
||||||
*
|
*
|
||||||
* Uses Claude Haiku to generate a short, descriptive title from feature description.
|
* Uses the provider abstraction to generate a short, descriptive title
|
||||||
|
* from a feature description. Works with any configured provider (Claude, Cursor, etc.).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
|
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
|
||||||
|
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||||
|
|
||||||
const logger = createLogger('GenerateTitle');
|
const logger = createLogger('GenerateTitle');
|
||||||
|
|
||||||
@@ -34,33 +35,6 @@ Rules:
|
|||||||
- No quotes, periods, or extra formatting
|
- No quotes, periods, or extra formatting
|
||||||
- Capture the essence of the feature in a scannable way`;
|
- Capture the essence of the feature in a scannable way`;
|
||||||
|
|
||||||
async function extractTextFromStream(
|
|
||||||
stream: AsyncIterable<{
|
|
||||||
type: string;
|
|
||||||
subtype?: string;
|
|
||||||
result?: string;
|
|
||||||
message?: {
|
|
||||||
content?: Array<{ type: string; text?: string }>;
|
|
||||||
};
|
|
||||||
}>
|
|
||||||
): Promise<string> {
|
|
||||||
let responseText = '';
|
|
||||||
|
|
||||||
for await (const msg of stream) {
|
|
||||||
if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
for (const block of msg.message.content) {
|
|
||||||
if (block.type === 'text' && block.text) {
|
|
||||||
responseText += block.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
|
||||||
responseText = msg.result || responseText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseText;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise<void> {
|
export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise<void> {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -89,21 +63,19 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
|
|||||||
|
|
||||||
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
|
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
|
||||||
|
|
||||||
const stream = query({
|
// Use simpleQuery - provider abstraction handles all the streaming/extraction
|
||||||
prompt: userPrompt,
|
const result = await simpleQuery({
|
||||||
options: {
|
prompt: `${SYSTEM_PROMPT}\n\n${userPrompt}`,
|
||||||
model: CLAUDE_MODEL_MAP.haiku,
|
model: CLAUDE_MODEL_MAP.haiku,
|
||||||
systemPrompt: SYSTEM_PROMPT,
|
cwd: process.cwd(),
|
||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
permissionMode: 'default',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const title = await extractTextFromStream(stream);
|
const title = result.text;
|
||||||
|
|
||||||
if (!title || title.trim().length === 0) {
|
if (!title || title.trim().length === 0) {
|
||||||
logger.warn('Received empty response from Claude');
|
logger.warn('Received empty response from AI');
|
||||||
const response: GenerateTitleErrorResponse = {
|
const response: GenerateTitleErrorResponse = {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Failed to generate title - empty response',
|
error: 'Failed to generate title - empty response',
|
||||||
|
|||||||
@@ -5,6 +5,43 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||||
|
|
||||||
|
const GIT_REMOTE_ORIGIN_COMMAND = 'git remote get-url origin';
|
||||||
|
const GH_REPO_VIEW_COMMAND = 'gh repo view --json name,owner';
|
||||||
|
const GITHUB_REPO_URL_PREFIX = 'https://github.com/';
|
||||||
|
const GITHUB_HTTPS_REMOTE_REGEX = /https:\/\/github\.com\/([^/]+)\/([^/.]+)/;
|
||||||
|
const GITHUB_SSH_REMOTE_REGEX = /git@github\.com:([^/]+)\/([^/.]+)/;
|
||||||
|
|
||||||
|
interface GhRepoViewResponse {
|
||||||
|
name?: string;
|
||||||
|
owner?: {
|
||||||
|
login?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRepoFromGh(projectPath: string): Promise<{
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync(GH_REPO_VIEW_COMMAND, {
|
||||||
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = JSON.parse(stdout) as GhRepoViewResponse;
|
||||||
|
const owner = typeof data.owner?.login === 'string' ? data.owner.login : null;
|
||||||
|
const repo = typeof data.name === 'string' ? data.name : null;
|
||||||
|
|
||||||
|
if (!owner || !repo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { owner, repo };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitHubRemoteStatus {
|
export interface GitHubRemoteStatus {
|
||||||
hasGitHubRemote: boolean;
|
hasGitHubRemote: boolean;
|
||||||
remoteUrl: string | null;
|
remoteUrl: string | null;
|
||||||
@@ -21,19 +58,38 @@ export async function checkGitHubRemote(projectPath: string): Promise<GitHubRemo
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the remote URL (origin by default)
|
let remoteUrl = '';
|
||||||
const { stdout } = await execAsync('git remote get-url origin', {
|
try {
|
||||||
cwd: projectPath,
|
// Get the remote URL (origin by default)
|
||||||
env: execEnv,
|
const { stdout } = await execAsync(GIT_REMOTE_ORIGIN_COMMAND, {
|
||||||
});
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
});
|
||||||
|
remoteUrl = stdout.trim();
|
||||||
|
status.remoteUrl = remoteUrl || null;
|
||||||
|
} catch {
|
||||||
|
// Ignore missing origin remote
|
||||||
|
}
|
||||||
|
|
||||||
const remoteUrl = stdout.trim();
|
const ghRepo = await resolveRepoFromGh(projectPath);
|
||||||
status.remoteUrl = remoteUrl;
|
if (ghRepo) {
|
||||||
|
status.hasGitHubRemote = true;
|
||||||
|
status.owner = ghRepo.owner;
|
||||||
|
status.repo = ghRepo.repo;
|
||||||
|
if (!status.remoteUrl) {
|
||||||
|
status.remoteUrl = `${GITHUB_REPO_URL_PREFIX}${ghRepo.owner}/${ghRepo.repo}`;
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if it's a GitHub URL
|
// Check if it's a GitHub URL
|
||||||
// Formats: https://github.com/owner/repo.git, git@github.com:owner/repo.git
|
// Formats: https://github.com/owner/repo.git, git@github.com:owner/repo.git
|
||||||
const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^/]+)\/([^/.]+)/);
|
if (!remoteUrl) {
|
||||||
const sshMatch = remoteUrl.match(/git@github\.com:([^/]+)\/([^/.]+)/);
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpsMatch = remoteUrl.match(GITHUB_HTTPS_REMOTE_REGEX);
|
||||||
|
const sshMatch = remoteUrl.match(GITHUB_SSH_REMOTE_REGEX);
|
||||||
|
|
||||||
const match = httpsMatch || sshMatch;
|
const match = httpsMatch || sshMatch;
|
||||||
if (match) {
|
if (match) {
|
||||||
|
|||||||
@@ -25,19 +25,24 @@ interface GraphQLComment {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GraphQLCommentConnection {
|
||||||
|
totalCount: number;
|
||||||
|
pageInfo: {
|
||||||
|
hasNextPage: boolean;
|
||||||
|
endCursor: string | null;
|
||||||
|
};
|
||||||
|
nodes: GraphQLComment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphQLIssueOrPullRequest {
|
||||||
|
__typename: 'Issue' | 'PullRequest';
|
||||||
|
comments: GraphQLCommentConnection;
|
||||||
|
}
|
||||||
|
|
||||||
interface GraphQLResponse {
|
interface GraphQLResponse {
|
||||||
data?: {
|
data?: {
|
||||||
repository?: {
|
repository?: {
|
||||||
issue?: {
|
issueOrPullRequest?: GraphQLIssueOrPullRequest | null;
|
||||||
comments: {
|
|
||||||
totalCount: number;
|
|
||||||
pageInfo: {
|
|
||||||
hasNextPage: boolean;
|
|
||||||
endCursor: string | null;
|
|
||||||
};
|
|
||||||
nodes: GraphQLComment[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
errors?: Array<{ message: string }>;
|
errors?: Array<{ message: string }>;
|
||||||
@@ -45,6 +50,7 @@ interface GraphQLResponse {
|
|||||||
|
|
||||||
/** Timeout for GitHub API requests in milliseconds */
|
/** Timeout for GitHub API requests in milliseconds */
|
||||||
const GITHUB_API_TIMEOUT_MS = 30000;
|
const GITHUB_API_TIMEOUT_MS = 30000;
|
||||||
|
const COMMENTS_PAGE_SIZE = 50;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate cursor format (GraphQL cursors are typically base64 strings)
|
* Validate cursor format (GraphQL cursors are typically base64 strings)
|
||||||
@@ -54,7 +60,7 @@ function isValidCursor(cursor: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch comments for a specific issue using GitHub GraphQL API
|
* Fetch comments for a specific issue or pull request using GitHub GraphQL API
|
||||||
*/
|
*/
|
||||||
async function fetchIssueComments(
|
async function fetchIssueComments(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
@@ -70,24 +76,52 @@ async function fetchIssueComments(
|
|||||||
|
|
||||||
// Use GraphQL variables instead of string interpolation for safety
|
// Use GraphQL variables instead of string interpolation for safety
|
||||||
const query = `
|
const query = `
|
||||||
query GetIssueComments($owner: String!, $repo: String!, $issueNumber: Int!, $cursor: String) {
|
query GetIssueComments(
|
||||||
|
$owner: String!
|
||||||
|
$repo: String!
|
||||||
|
$issueNumber: Int!
|
||||||
|
$cursor: String
|
||||||
|
$pageSize: Int!
|
||||||
|
) {
|
||||||
repository(owner: $owner, name: $repo) {
|
repository(owner: $owner, name: $repo) {
|
||||||
issue(number: $issueNumber) {
|
issueOrPullRequest(number: $issueNumber) {
|
||||||
comments(first: 50, after: $cursor) {
|
__typename
|
||||||
totalCount
|
... on Issue {
|
||||||
pageInfo {
|
comments(first: $pageSize, after: $cursor) {
|
||||||
hasNextPage
|
totalCount
|
||||||
endCursor
|
pageInfo {
|
||||||
}
|
hasNextPage
|
||||||
nodes {
|
endCursor
|
||||||
id
|
}
|
||||||
author {
|
nodes {
|
||||||
login
|
id
|
||||||
avatarUrl
|
author {
|
||||||
|
login
|
||||||
|
avatarUrl
|
||||||
|
}
|
||||||
|
body
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
... on PullRequest {
|
||||||
|
comments(first: $pageSize, after: $cursor) {
|
||||||
|
totalCount
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
author {
|
||||||
|
login
|
||||||
|
avatarUrl
|
||||||
|
}
|
||||||
|
body
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
}
|
}
|
||||||
body
|
|
||||||
createdAt
|
|
||||||
updatedAt
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,6 +133,7 @@ async function fetchIssueComments(
|
|||||||
repo,
|
repo,
|
||||||
issueNumber,
|
issueNumber,
|
||||||
cursor: cursor || null,
|
cursor: cursor || null,
|
||||||
|
pageSize: COMMENTS_PAGE_SIZE,
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestBody = JSON.stringify({ query, variables });
|
const requestBody = JSON.stringify({ query, variables });
|
||||||
@@ -140,10 +175,10 @@ async function fetchIssueComments(
|
|||||||
throw new Error(response.errors[0].message);
|
throw new Error(response.errors[0].message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const commentsData = response.data?.repository?.issue?.comments;
|
const commentsData = response.data?.repository?.issueOrPullRequest?.comments;
|
||||||
|
|
||||||
if (!commentsData) {
|
if (!commentsData) {
|
||||||
throw new Error('Issue not found or no comments data available');
|
throw new Error('Issue or pull request not found or no comments data available');
|
||||||
}
|
}
|
||||||
|
|
||||||
const comments: GitHubComment[] = commentsData.nodes.map((node) => ({
|
const comments: GitHubComment[] = commentsData.nodes.map((node) => ({
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ import { checkGitHubRemote } from './check-github-remote.js';
|
|||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
const logger = createLogger('ListIssues');
|
const logger = createLogger('ListIssues');
|
||||||
|
const OPEN_ISSUES_LIMIT = 100;
|
||||||
|
const CLOSED_ISSUES_LIMIT = 50;
|
||||||
|
const ISSUE_LIST_FIELDS = 'number,title,state,author,createdAt,labels,url,body,assignees';
|
||||||
|
const ISSUE_STATE_OPEN = 'open';
|
||||||
|
const ISSUE_STATE_CLOSED = 'closed';
|
||||||
|
const GH_ISSUE_LIST_COMMAND = 'gh issue list';
|
||||||
|
const GH_STATE_FLAG = '--state';
|
||||||
|
const GH_JSON_FLAG = '--json';
|
||||||
|
const GH_LIMIT_FLAG = '--limit';
|
||||||
|
const LINKED_PRS_BATCH_SIZE = 20;
|
||||||
|
const LINKED_PRS_TIMELINE_ITEMS = 10;
|
||||||
|
|
||||||
export interface GitHubLabel {
|
export interface GitHubLabel {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -69,34 +80,68 @@ async function fetchLinkedPRs(
|
|||||||
|
|
||||||
// Build GraphQL query for batch fetching linked PRs
|
// Build GraphQL query for batch fetching linked PRs
|
||||||
// We fetch up to 20 issues at a time to avoid query limits
|
// We fetch up to 20 issues at a time to avoid query limits
|
||||||
const batchSize = 20;
|
for (let i = 0; i < issueNumbers.length; i += LINKED_PRS_BATCH_SIZE) {
|
||||||
for (let i = 0; i < issueNumbers.length; i += batchSize) {
|
const batch = issueNumbers.slice(i, i + LINKED_PRS_BATCH_SIZE);
|
||||||
const batch = issueNumbers.slice(i, i + batchSize);
|
|
||||||
|
|
||||||
const issueQueries = batch
|
const issueQueries = batch
|
||||||
.map(
|
.map(
|
||||||
(num, idx) => `
|
(num, idx) => `
|
||||||
issue${idx}: issue(number: ${num}) {
|
issue${idx}: issueOrPullRequest(number: ${num}) {
|
||||||
number
|
... on Issue {
|
||||||
timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) {
|
number
|
||||||
nodes {
|
timelineItems(
|
||||||
... on CrossReferencedEvent {
|
first: ${LINKED_PRS_TIMELINE_ITEMS}
|
||||||
source {
|
itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]
|
||||||
... on PullRequest {
|
) {
|
||||||
number
|
nodes {
|
||||||
title
|
... on CrossReferencedEvent {
|
||||||
state
|
source {
|
||||||
url
|
... on PullRequest {
|
||||||
|
number
|
||||||
|
title
|
||||||
|
state
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
... on ConnectedEvent {
|
||||||
|
subject {
|
||||||
|
... on PullRequest {
|
||||||
|
number
|
||||||
|
title
|
||||||
|
state
|
||||||
|
url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
... on ConnectedEvent {
|
}
|
||||||
subject {
|
}
|
||||||
... on PullRequest {
|
... on PullRequest {
|
||||||
number
|
number
|
||||||
title
|
timelineItems(
|
||||||
state
|
first: ${LINKED_PRS_TIMELINE_ITEMS}
|
||||||
url
|
itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]
|
||||||
|
) {
|
||||||
|
nodes {
|
||||||
|
... on CrossReferencedEvent {
|
||||||
|
source {
|
||||||
|
... on PullRequest {
|
||||||
|
number
|
||||||
|
title
|
||||||
|
state
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
... on ConnectedEvent {
|
||||||
|
subject {
|
||||||
|
... on PullRequest {
|
||||||
|
number
|
||||||
|
title
|
||||||
|
state
|
||||||
|
url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,16 +258,35 @@ export function createListIssuesHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch open and closed issues in parallel (now including assignees)
|
// Fetch open and closed issues in parallel (now including assignees)
|
||||||
|
const repoQualifier =
|
||||||
|
remoteStatus.owner && remoteStatus.repo ? `${remoteStatus.owner}/${remoteStatus.repo}` : '';
|
||||||
|
const repoFlag = repoQualifier ? `-R ${repoQualifier}` : '';
|
||||||
const [openResult, closedResult] = await Promise.all([
|
const [openResult, closedResult] = await Promise.all([
|
||||||
execAsync(
|
execAsync(
|
||||||
'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body,assignees --limit 100',
|
[
|
||||||
|
GH_ISSUE_LIST_COMMAND,
|
||||||
|
repoFlag,
|
||||||
|
`${GH_STATE_FLAG} ${ISSUE_STATE_OPEN}`,
|
||||||
|
`${GH_JSON_FLAG} ${ISSUE_LIST_FIELDS}`,
|
||||||
|
`${GH_LIMIT_FLAG} ${OPEN_ISSUES_LIMIT}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
{
|
{
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
execAsync(
|
execAsync(
|
||||||
'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body,assignees --limit 50',
|
[
|
||||||
|
GH_ISSUE_LIST_COMMAND,
|
||||||
|
repoFlag,
|
||||||
|
`${GH_STATE_FLAG} ${ISSUE_STATE_CLOSED}`,
|
||||||
|
`${GH_JSON_FLAG} ${ISSUE_LIST_FIELDS}`,
|
||||||
|
`${GH_LIMIT_FLAG} ${CLOSED_ISSUES_LIMIT}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
{
|
{
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
|
|||||||
@@ -6,6 +6,17 @@ import type { Request, Response } from 'express';
|
|||||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||||
import { checkGitHubRemote } from './check-github-remote.js';
|
import { checkGitHubRemote } from './check-github-remote.js';
|
||||||
|
|
||||||
|
const OPEN_PRS_LIMIT = 100;
|
||||||
|
const MERGED_PRS_LIMIT = 50;
|
||||||
|
const PR_LIST_FIELDS =
|
||||||
|
'number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body';
|
||||||
|
const PR_STATE_OPEN = 'open';
|
||||||
|
const PR_STATE_MERGED = 'merged';
|
||||||
|
const GH_PR_LIST_COMMAND = 'gh pr list';
|
||||||
|
const GH_STATE_FLAG = '--state';
|
||||||
|
const GH_JSON_FLAG = '--json';
|
||||||
|
const GH_LIMIT_FLAG = '--limit';
|
||||||
|
|
||||||
export interface GitHubLabel {
|
export interface GitHubLabel {
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
@@ -57,16 +68,36 @@ export function createListPRsHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const repoQualifier =
|
||||||
|
remoteStatus.owner && remoteStatus.repo ? `${remoteStatus.owner}/${remoteStatus.repo}` : '';
|
||||||
|
const repoFlag = repoQualifier ? `-R ${repoQualifier}` : '';
|
||||||
|
|
||||||
const [openResult, mergedResult] = await Promise.all([
|
const [openResult, mergedResult] = await Promise.all([
|
||||||
execAsync(
|
execAsync(
|
||||||
'gh pr list --state open --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 100',
|
[
|
||||||
|
GH_PR_LIST_COMMAND,
|
||||||
|
repoFlag,
|
||||||
|
`${GH_STATE_FLAG} ${PR_STATE_OPEN}`,
|
||||||
|
`${GH_JSON_FLAG} ${PR_LIST_FIELDS}`,
|
||||||
|
`${GH_LIMIT_FLAG} ${OPEN_PRS_LIMIT}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
{
|
{
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
execAsync(
|
execAsync(
|
||||||
'gh pr list --state merged --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 50',
|
[
|
||||||
|
GH_PR_LIST_COMMAND,
|
||||||
|
repoFlag,
|
||||||
|
`${GH_STATE_FLAG} ${PR_STATE_MERGED}`,
|
||||||
|
`${GH_JSON_FLAG} ${PR_LIST_FIELDS}`,
|
||||||
|
`${GH_LIMIT_FLAG} ${MERGED_PRS_LIMIT}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
{
|
{
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
|
|||||||
@@ -1,29 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK or Cursor (async)
|
* POST /validate-issue endpoint - Validate a GitHub issue using provider abstraction (async)
|
||||||
*
|
*
|
||||||
* Scans the codebase to determine if an issue is valid, invalid, or needs clarification.
|
* Scans the codebase to determine if an issue is valid, invalid, or needs clarification.
|
||||||
* Runs asynchronously and emits events for progress and completion.
|
* Runs asynchronously and emits events for progress and completion.
|
||||||
* Supports both Claude models and Cursor models.
|
* Supports Claude, Codex, Cursor, and OpenCode models.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
||||||
import type { EventEmitter } from '../../../lib/events.js';
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
import type {
|
import type {
|
||||||
IssueValidationResult,
|
IssueValidationResult,
|
||||||
IssueValidationEvent,
|
IssueValidationEvent,
|
||||||
ModelAlias,
|
ModelId,
|
||||||
CursorModelId,
|
|
||||||
GitHubComment,
|
GitHubComment,
|
||||||
LinkedPRInfo,
|
LinkedPRInfo,
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
|
ReasoningEffort,
|
||||||
|
} from '@automaker/types';
|
||||||
|
import {
|
||||||
|
DEFAULT_PHASE_MODELS,
|
||||||
|
isClaudeModel,
|
||||||
|
isCodexModel,
|
||||||
|
isCursorModel,
|
||||||
|
isOpencodeModel,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { isCursorModel, DEFAULT_PHASE_MODELS, stripProviderPrefix } from '@automaker/types';
|
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
|
|
||||||
import { extractJson } from '../../../lib/json-extractor.js';
|
import { extractJson } from '../../../lib/json-extractor.js';
|
||||||
import { writeValidation } from '../../../lib/validation-storage.js';
|
import { writeValidation } from '../../../lib/validation-storage.js';
|
||||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
import { streamingQuery } from '../../../providers/simple-query-service.js';
|
||||||
import {
|
import {
|
||||||
issueValidationSchema,
|
issueValidationSchema,
|
||||||
ISSUE_VALIDATION_SYSTEM_PROMPT,
|
ISSUE_VALIDATION_SYSTEM_PROMPT,
|
||||||
@@ -41,9 +45,6 @@ import {
|
|||||||
import type { SettingsService } from '../../../services/settings-service.js';
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
|
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
|
||||||
|
|
||||||
/** Valid Claude model values for validation */
|
|
||||||
const VALID_CLAUDE_MODELS: readonly ModelAlias[] = ['opus', 'sonnet', 'haiku'] as const;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request body for issue validation
|
* Request body for issue validation
|
||||||
*/
|
*/
|
||||||
@@ -53,10 +54,12 @@ interface ValidateIssueRequestBody {
|
|||||||
issueTitle: string;
|
issueTitle: string;
|
||||||
issueBody: string;
|
issueBody: string;
|
||||||
issueLabels?: string[];
|
issueLabels?: string[];
|
||||||
/** Model to use for validation (opus, sonnet, haiku, or cursor model IDs) */
|
/** Model to use for validation (Claude alias or provider model ID) */
|
||||||
model?: ModelAlias | CursorModelId;
|
model?: ModelId;
|
||||||
/** Thinking level for Claude models (ignored for Cursor models) */
|
/** Thinking level for Claude models (ignored for non-Claude models) */
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
|
/** Reasoning effort for Codex models (ignored for non-Codex models) */
|
||||||
|
reasoningEffort?: ReasoningEffort;
|
||||||
/** Comments to include in validation analysis */
|
/** Comments to include in validation analysis */
|
||||||
comments?: GitHubComment[];
|
comments?: GitHubComment[];
|
||||||
/** Linked pull requests for this issue */
|
/** Linked pull requests for this issue */
|
||||||
@@ -68,7 +71,7 @@ interface ValidateIssueRequestBody {
|
|||||||
*
|
*
|
||||||
* Emits events for start, progress, complete, and error.
|
* Emits events for start, progress, complete, and error.
|
||||||
* Stores result on completion.
|
* Stores result on completion.
|
||||||
* Supports both Claude models (with structured output) and Cursor models (with JSON parsing).
|
* Supports Claude/Codex models (structured output) and Cursor/OpenCode models (JSON parsing).
|
||||||
*/
|
*/
|
||||||
async function runValidation(
|
async function runValidation(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
@@ -76,13 +79,14 @@ async function runValidation(
|
|||||||
issueTitle: string,
|
issueTitle: string,
|
||||||
issueBody: string,
|
issueBody: string,
|
||||||
issueLabels: string[] | undefined,
|
issueLabels: string[] | undefined,
|
||||||
model: ModelAlias | CursorModelId,
|
model: ModelId,
|
||||||
events: EventEmitter,
|
events: EventEmitter,
|
||||||
abortController: AbortController,
|
abortController: AbortController,
|
||||||
settingsService?: SettingsService,
|
settingsService?: SettingsService,
|
||||||
comments?: ValidationComment[],
|
comments?: ValidationComment[],
|
||||||
linkedPRs?: ValidationLinkedPR[],
|
linkedPRs?: ValidationLinkedPR[],
|
||||||
thinkingLevel?: ThinkingLevel
|
thinkingLevel?: ThinkingLevel,
|
||||||
|
reasoningEffort?: ReasoningEffort
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Emit start event
|
// Emit start event
|
||||||
const startEvent: IssueValidationEvent = {
|
const startEvent: IssueValidationEvent = {
|
||||||
@@ -102,7 +106,7 @@ async function runValidation(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Build the prompt (include comments and linked PRs if provided)
|
// Build the prompt (include comments and linked PRs if provided)
|
||||||
const prompt = buildValidationPrompt(
|
const basePrompt = buildValidationPrompt(
|
||||||
issueNumber,
|
issueNumber,
|
||||||
issueTitle,
|
issueTitle,
|
||||||
issueBody,
|
issueBody,
|
||||||
@@ -111,20 +115,15 @@ async function runValidation(
|
|||||||
linkedPRs
|
linkedPRs
|
||||||
);
|
);
|
||||||
|
|
||||||
let validationResult: IssueValidationResult | null = null;
|
|
||||||
let responseText = '';
|
let responseText = '';
|
||||||
|
|
||||||
// Route to appropriate provider based on model
|
// Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't)
|
||||||
if (isCursorModel(model)) {
|
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
|
||||||
// Use Cursor provider for Cursor models
|
|
||||||
logger.info(`Using Cursor provider for validation with model: ${model}`);
|
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
let finalPrompt = basePrompt;
|
||||||
const bareModel = stripProviderPrefix(model);
|
if (!useStructuredOutput) {
|
||||||
|
finalPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT}
|
||||||
// For Cursor, include the system prompt and schema in the user prompt
|
|
||||||
const cursorPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT}
|
|
||||||
|
|
||||||
CRITICAL INSTRUCTIONS:
|
CRITICAL INSTRUCTIONS:
|
||||||
1. DO NOT write any files. Return the JSON in your response only.
|
1. DO NOT write any files. Return the JSON in your response only.
|
||||||
@@ -135,121 +134,78 @@ ${JSON.stringify(issueValidationSchema, null, 2)}
|
|||||||
|
|
||||||
Your entire response should be valid JSON starting with { and ending with }. No text before or after.
|
Your entire response should be valid JSON starting with { and ending with }. No text before or after.
|
||||||
|
|
||||||
${prompt}`;
|
${basePrompt}`;
|
||||||
|
}
|
||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
// Load autoLoadClaudeMd setting
|
||||||
prompt: cursorPrompt,
|
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||||
model: bareModel,
|
projectPath,
|
||||||
cwd: projectPath,
|
settingsService,
|
||||||
readOnly: true, // Issue validation only reads code, doesn't write
|
'[ValidateIssue]'
|
||||||
})) {
|
);
|
||||||
if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
for (const block of msg.message.content) {
|
|
||||||
if (block.type === 'text' && block.text) {
|
|
||||||
responseText += block.text;
|
|
||||||
|
|
||||||
// Emit progress event
|
// Use request overrides if provided, otherwise fall back to settings
|
||||||
const progressEvent: IssueValidationEvent = {
|
let effectiveThinkingLevel: ThinkingLevel | undefined = thinkingLevel;
|
||||||
type: 'issue_validation_progress',
|
let effectiveReasoningEffort: ReasoningEffort | undefined = reasoningEffort;
|
||||||
issueNumber,
|
if (!effectiveThinkingLevel || !effectiveReasoningEffort) {
|
||||||
content: block.text,
|
const settings = await settingsService?.getGlobalSettings();
|
||||||
projectPath,
|
const phaseModelEntry =
|
||||||
};
|
settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel;
|
||||||
events.emit('issue-validation:event', progressEvent);
|
const resolved = resolvePhaseModel(phaseModelEntry);
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
|
||||||
// Use result if it's a final accumulated message
|
|
||||||
if (msg.result.length > responseText.length) {
|
|
||||||
responseText = msg.result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse JSON from the response text using shared utility
|
|
||||||
if (responseText) {
|
|
||||||
validationResult = extractJson<IssueValidationResult>(responseText, { logger });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use Claude SDK for Claude models
|
|
||||||
logger.info(`Using Claude provider for validation with model: ${model}`);
|
|
||||||
|
|
||||||
// Load autoLoadClaudeMd setting
|
|
||||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
|
||||||
projectPath,
|
|
||||||
settingsService,
|
|
||||||
'[ValidateIssue]'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use thinkingLevel from request if provided, otherwise fall back to settings
|
|
||||||
let effectiveThinkingLevel: ThinkingLevel | undefined = thinkingLevel;
|
|
||||||
if (!effectiveThinkingLevel) {
|
if (!effectiveThinkingLevel) {
|
||||||
const settings = await settingsService?.getGlobalSettings();
|
|
||||||
const phaseModelEntry =
|
|
||||||
settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel;
|
|
||||||
const resolved = resolvePhaseModel(phaseModelEntry);
|
|
||||||
effectiveThinkingLevel = resolved.thinkingLevel;
|
effectiveThinkingLevel = resolved.thinkingLevel;
|
||||||
}
|
}
|
||||||
|
if (!effectiveReasoningEffort && typeof phaseModelEntry !== 'string') {
|
||||||
// Create SDK options with structured output and abort controller
|
effectiveReasoningEffort = phaseModelEntry.reasoningEffort;
|
||||||
const options = createSuggestionsOptions({
|
|
||||||
cwd: projectPath,
|
|
||||||
model: model as ModelAlias,
|
|
||||||
systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT,
|
|
||||||
abortController,
|
|
||||||
autoLoadClaudeMd,
|
|
||||||
thinkingLevel: effectiveThinkingLevel,
|
|
||||||
outputFormat: {
|
|
||||||
type: 'json_schema',
|
|
||||||
schema: issueValidationSchema as Record<string, unknown>,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Execute the query
|
|
||||||
const stream = query({ prompt, options });
|
|
||||||
|
|
||||||
for await (const msg of stream) {
|
|
||||||
// Collect assistant text for debugging and emit progress
|
|
||||||
if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
for (const block of msg.message.content) {
|
|
||||||
if (block.type === 'text') {
|
|
||||||
responseText += block.text;
|
|
||||||
|
|
||||||
// Emit progress event
|
|
||||||
const progressEvent: IssueValidationEvent = {
|
|
||||||
type: 'issue_validation_progress',
|
|
||||||
issueNumber,
|
|
||||||
content: block.text,
|
|
||||||
projectPath,
|
|
||||||
};
|
|
||||||
events.emit('issue-validation:event', progressEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract structured output on success
|
|
||||||
if (msg.type === 'result' && msg.subtype === 'success') {
|
|
||||||
const resultMsg = msg as { structured_output?: IssueValidationResult };
|
|
||||||
if (resultMsg.structured_output) {
|
|
||||||
validationResult = resultMsg.structured_output;
|
|
||||||
logger.debug('Received structured output:', validationResult);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle errors
|
|
||||||
if (msg.type === 'result') {
|
|
||||||
const resultMsg = msg as { subtype?: string };
|
|
||||||
if (resultMsg.subtype === 'error_max_structured_output_retries') {
|
|
||||||
logger.error('Failed to produce valid structured output after retries');
|
|
||||||
throw new Error('Could not produce valid validation output');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`Using model: ${model}`);
|
||||||
|
|
||||||
|
// Use streamingQuery with event callbacks
|
||||||
|
const result = await streamingQuery({
|
||||||
|
prompt: finalPrompt,
|
||||||
|
model: model as string,
|
||||||
|
cwd: projectPath,
|
||||||
|
systemPrompt: useStructuredOutput ? ISSUE_VALIDATION_SYSTEM_PROMPT : undefined,
|
||||||
|
abortController,
|
||||||
|
thinkingLevel: effectiveThinkingLevel,
|
||||||
|
reasoningEffort: effectiveReasoningEffort,
|
||||||
|
readOnly: true, // Issue validation only reads code, doesn't write
|
||||||
|
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||||
|
outputFormat: useStructuredOutput
|
||||||
|
? {
|
||||||
|
type: 'json_schema',
|
||||||
|
schema: issueValidationSchema as Record<string, unknown>,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onText: (text) => {
|
||||||
|
responseText += text;
|
||||||
|
// Emit progress event
|
||||||
|
const progressEvent: IssueValidationEvent = {
|
||||||
|
type: 'issue_validation_progress',
|
||||||
|
issueNumber,
|
||||||
|
content: text,
|
||||||
|
projectPath,
|
||||||
|
};
|
||||||
|
events.emit('issue-validation:event', progressEvent);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Clear timeout
|
// Clear timeout
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
// Get validation result from structured output or parse from text
|
||||||
|
let validationResult: IssueValidationResult | null = null;
|
||||||
|
|
||||||
|
if (result.structured_output) {
|
||||||
|
validationResult = result.structured_output as unknown as IssueValidationResult;
|
||||||
|
logger.debug('Received structured output:', validationResult);
|
||||||
|
} else if (responseText) {
|
||||||
|
// Parse JSON from response text
|
||||||
|
validationResult = extractJson<IssueValidationResult>(responseText, { logger });
|
||||||
|
}
|
||||||
|
|
||||||
// Require validation result
|
// Require validation result
|
||||||
if (!validationResult) {
|
if (!validationResult) {
|
||||||
logger.error('No validation result received from AI provider');
|
logger.error('No validation result received from AI provider');
|
||||||
@@ -299,7 +255,7 @@ ${prompt}`;
|
|||||||
/**
|
/**
|
||||||
* Creates the handler for validating GitHub issues against the codebase.
|
* Creates the handler for validating GitHub issues against the codebase.
|
||||||
*
|
*
|
||||||
* Uses Claude SDK with:
|
* Uses the provider abstraction with:
|
||||||
* - Read-only tools (Read, Glob, Grep) for codebase analysis
|
* - Read-only tools (Read, Glob, Grep) for codebase analysis
|
||||||
* - JSON schema structured output for reliable parsing
|
* - JSON schema structured output for reliable parsing
|
||||||
* - System prompt guiding the validation process
|
* - System prompt guiding the validation process
|
||||||
@@ -319,6 +275,7 @@ export function createValidateIssueHandler(
|
|||||||
issueLabels,
|
issueLabels,
|
||||||
model = 'opus',
|
model = 'opus',
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
|
reasoningEffort,
|
||||||
comments: rawComments,
|
comments: rawComments,
|
||||||
linkedPRs: rawLinkedPRs,
|
linkedPRs: rawLinkedPRs,
|
||||||
} = req.body as ValidateIssueRequestBody;
|
} = req.body as ValidateIssueRequestBody;
|
||||||
@@ -366,14 +323,17 @@ export function createValidateIssueHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate model parameter at runtime - accept Claude models or Cursor models
|
// Validate model parameter at runtime - accept any supported provider model
|
||||||
const isValidClaudeModel = VALID_CLAUDE_MODELS.includes(model as ModelAlias);
|
const isValidModel =
|
||||||
const isValidCursorModel = isCursorModel(model);
|
isClaudeModel(model) ||
|
||||||
|
isCursorModel(model) ||
|
||||||
|
isCodexModel(model) ||
|
||||||
|
isOpencodeModel(model);
|
||||||
|
|
||||||
if (!isValidClaudeModel && !isValidCursorModel) {
|
if (!isValidModel) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Invalid model. Must be one of: ${VALID_CLAUDE_MODELS.join(', ')}, or a Cursor model ID`,
|
error: 'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias).',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -404,7 +364,8 @@ export function createValidateIssueHandler(
|
|||||||
settingsService,
|
settingsService,
|
||||||
validationComments,
|
validationComments,
|
||||||
validationLinkedPRs,
|
validationLinkedPRs,
|
||||||
thinkingLevel
|
thinkingLevel,
|
||||||
|
reasoningEffort
|
||||||
)
|
)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Error is already handled inside runValidation (event emitted)
|
// Error is already handled inside runValidation (event emitted)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Each provider shows: `{ configured: boolean, masked: string }`
|
* Each provider shows: `{ configured: boolean, masked: string }`
|
||||||
* Masked shows first 4 and last 4 characters for verification.
|
* Masked shows first 4 and last 4 characters for verification.
|
||||||
*
|
*
|
||||||
* Response: `{ "success": true, "credentials": { anthropic } }`
|
* Response: `{ "success": true, "credentials": { anthropic, google, openai } }`
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* PUT /api/settings/credentials - Update API credentials
|
* PUT /api/settings/credentials - Update API credentials
|
||||||
*
|
*
|
||||||
* Updates API keys for Anthropic. Partial updates supported.
|
* Updates API keys for supported providers. Partial updates supported.
|
||||||
* Returns masked credentials for verification without exposing full keys.
|
* Returns masked credentials for verification without exposing full keys.
|
||||||
*
|
*
|
||||||
* Request body: `Partial<Credentials>` (usually just apiKeys)
|
* Request body: `Partial<Credentials>` (usually just apiKeys)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function createApiKeysHandler() {
|
|||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY,
|
hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY,
|
||||||
|
hasGoogleKey: !!getApiKey('google'),
|
||||||
hasOpenaiKey: !!getApiKey('openai') || !!process.env.OPENAI_API_KEY,
|
hasOpenaiKey: !!getApiKey('openai') || !!process.env.OPENAI_API_KEY,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -21,22 +21,25 @@ export function createStoreApiKeyHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setApiKey(provider, apiKey);
|
const providerEnvMap: Record<string, string> = {
|
||||||
|
anthropic: 'ANTHROPIC_API_KEY',
|
||||||
// Also set as environment variable and persist to .env
|
anthropic_oauth_token: 'ANTHROPIC_API_KEY',
|
||||||
if (provider === 'anthropic' || provider === 'anthropic_oauth_token') {
|
openai: 'OPENAI_API_KEY',
|
||||||
// Both API key and OAuth token use ANTHROPIC_API_KEY
|
};
|
||||||
process.env.ANTHROPIC_API_KEY = apiKey;
|
const envKey = providerEnvMap[provider];
|
||||||
await persistApiKeyToEnv('ANTHROPIC_API_KEY', apiKey);
|
if (!envKey) {
|
||||||
logger.info('[Setup] Stored API key as ANTHROPIC_API_KEY');
|
|
||||||
} else {
|
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Unsupported provider: ${provider}. Only anthropic is supported.`,
|
error: `Unsupported provider: ${provider}. Only anthropic and openai are supported.`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setApiKey(provider, apiKey);
|
||||||
|
process.env[envKey] = apiKey;
|
||||||
|
await persistApiKeyToEnv(envKey, apiKey);
|
||||||
|
logger.info(`[Setup] Stored API key as ${envKey}`);
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Store API key failed');
|
logError(error, 'Store API key failed');
|
||||||
|
|||||||
@@ -5,19 +5,12 @@
|
|||||||
* (AI Suggestions in the UI). Supports both Claude and Cursor models.
|
* (AI Suggestions in the UI). Supports both Claude and Cursor models.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import {
|
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
|
||||||
DEFAULT_PHASE_MODELS,
|
|
||||||
isCursorModel,
|
|
||||||
stripProviderPrefix,
|
|
||||||
type ThinkingLevel,
|
|
||||||
} from '@automaker/types';
|
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
import { createSuggestionsOptions } from '../../lib/sdk-options.js';
|
|
||||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||||
import { getAppSpecPath } from '@automaker/platform';
|
import { getAppSpecPath } from '@automaker/platform';
|
||||||
import * as secureFs from '../../lib/secure-fs.js';
|
import * as secureFs from '../../lib/secure-fs.js';
|
||||||
@@ -204,19 +197,14 @@ The response will be automatically formatted as structured JSON.`;
|
|||||||
logger.info('[Suggestions] Using model:', model);
|
logger.info('[Suggestions] Using model:', model);
|
||||||
|
|
||||||
let responseText = '';
|
let responseText = '';
|
||||||
let structuredOutput: { suggestions: Array<Record<string, unknown>> } | null = null;
|
|
||||||
|
|
||||||
// Route to appropriate provider based on model type
|
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
|
||||||
if (isCursorModel(model)) {
|
const useStructuredOutput = !isCursorModel(model);
|
||||||
// Use Cursor provider for Cursor models
|
|
||||||
logger.info('[Suggestions] Using Cursor provider');
|
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
// Build the final prompt - for Cursor, include JSON schema instructions
|
||||||
// Strip provider prefix - providers expect bare model IDs
|
let finalPrompt = prompt;
|
||||||
const bareModel = stripProviderPrefix(model);
|
if (!useStructuredOutput) {
|
||||||
|
finalPrompt = `${prompt}
|
||||||
// For Cursor, include the JSON schema in the prompt with clear instructions
|
|
||||||
const cursorPrompt = `${prompt}
|
|
||||||
|
|
||||||
CRITICAL INSTRUCTIONS:
|
CRITICAL INSTRUCTIONS:
|
||||||
1. DO NOT write any files. Return the JSON in your response only.
|
1. DO NOT write any files. Return the JSON in your response only.
|
||||||
@@ -226,104 +214,60 @@ CRITICAL INSTRUCTIONS:
|
|||||||
${JSON.stringify(suggestionsSchema, null, 2)}
|
${JSON.stringify(suggestionsSchema, null, 2)}
|
||||||
|
|
||||||
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
|
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
|
||||||
|
|
||||||
for await (const msg of provider.executeQuery({
|
|
||||||
prompt: cursorPrompt,
|
|
||||||
model: bareModel,
|
|
||||||
cwd: projectPath,
|
|
||||||
maxTurns: 250,
|
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
|
||||||
abortController,
|
|
||||||
readOnly: true, // Suggestions only reads code, doesn't write
|
|
||||||
})) {
|
|
||||||
if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
for (const block of msg.message.content) {
|
|
||||||
if (block.type === 'text' && block.text) {
|
|
||||||
responseText += block.text;
|
|
||||||
events.emit('suggestions:event', {
|
|
||||||
type: 'suggestions_progress',
|
|
||||||
content: block.text,
|
|
||||||
});
|
|
||||||
} else if (block.type === 'tool_use') {
|
|
||||||
events.emit('suggestions:event', {
|
|
||||||
type: 'suggestions_tool',
|
|
||||||
tool: block.name,
|
|
||||||
input: block.input,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
|
||||||
// Use result if it's a final accumulated message (from Cursor provider)
|
|
||||||
logger.info('[Suggestions] Received result from Cursor, length:', msg.result.length);
|
|
||||||
logger.info('[Suggestions] Previous responseText length:', responseText.length);
|
|
||||||
if (msg.result.length > responseText.length) {
|
|
||||||
logger.info('[Suggestions] Using Cursor result (longer than accumulated text)');
|
|
||||||
responseText = msg.result;
|
|
||||||
} else {
|
|
||||||
logger.info('[Suggestions] Keeping accumulated text (longer than Cursor result)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use Claude SDK for Claude models
|
|
||||||
logger.info('[Suggestions] Using Claude SDK');
|
|
||||||
|
|
||||||
const options = createSuggestionsOptions({
|
|
||||||
cwd: projectPath,
|
|
||||||
abortController,
|
|
||||||
autoLoadClaudeMd,
|
|
||||||
model, // Pass the model from settings
|
|
||||||
thinkingLevel, // Pass thinking level for extended thinking
|
|
||||||
outputFormat: {
|
|
||||||
type: 'json_schema',
|
|
||||||
schema: suggestionsSchema,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const stream = query({ prompt, options });
|
|
||||||
|
|
||||||
for await (const msg of stream) {
|
|
||||||
if (msg.type === 'assistant' && msg.message.content) {
|
|
||||||
for (const block of msg.message.content) {
|
|
||||||
if (block.type === 'text') {
|
|
||||||
responseText += block.text;
|
|
||||||
events.emit('suggestions:event', {
|
|
||||||
type: 'suggestions_progress',
|
|
||||||
content: block.text,
|
|
||||||
});
|
|
||||||
} else if (block.type === 'tool_use') {
|
|
||||||
events.emit('suggestions:event', {
|
|
||||||
type: 'suggestions_tool',
|
|
||||||
tool: block.name,
|
|
||||||
input: block.input,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
|
||||||
// Check for structured output
|
|
||||||
const resultMsg = msg as any;
|
|
||||||
if (resultMsg.structured_output) {
|
|
||||||
structuredOutput = resultMsg.structured_output as {
|
|
||||||
suggestions: Array<Record<string, unknown>>;
|
|
||||||
};
|
|
||||||
logger.debug('Received structured output:', structuredOutput);
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'result') {
|
|
||||||
const resultMsg = msg as any;
|
|
||||||
if (resultMsg.subtype === 'error_max_structured_output_retries') {
|
|
||||||
logger.error('Failed to produce valid structured output after retries');
|
|
||||||
throw new Error('Could not produce valid suggestions output');
|
|
||||||
} else if (resultMsg.subtype === 'error_max_turns') {
|
|
||||||
logger.error('Hit max turns limit before completing suggestions generation');
|
|
||||||
logger.warn(`Response text length: ${responseText.length} chars`);
|
|
||||||
// Still try to parse what we have
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use streamingQuery with event callbacks
|
||||||
|
const result = await streamingQuery({
|
||||||
|
prompt: finalPrompt,
|
||||||
|
model,
|
||||||
|
cwd: projectPath,
|
||||||
|
maxTurns: 250,
|
||||||
|
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||||
|
abortController,
|
||||||
|
thinkingLevel,
|
||||||
|
readOnly: true, // Suggestions only reads code, doesn't write
|
||||||
|
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||||
|
outputFormat: useStructuredOutput
|
||||||
|
? {
|
||||||
|
type: 'json_schema',
|
||||||
|
schema: suggestionsSchema,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onText: (text) => {
|
||||||
|
responseText += text;
|
||||||
|
events.emit('suggestions:event', {
|
||||||
|
type: 'suggestions_progress',
|
||||||
|
content: text,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onToolUse: (tool, input) => {
|
||||||
|
events.emit('suggestions:event', {
|
||||||
|
type: 'suggestions_tool',
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Use structured output if available, otherwise fall back to parsing text
|
// Use structured output if available, otherwise fall back to parsing text
|
||||||
try {
|
try {
|
||||||
|
let structuredOutput: { suggestions: Array<Record<string, unknown>> } | null = null;
|
||||||
|
|
||||||
|
if (result.structured_output) {
|
||||||
|
structuredOutput = result.structured_output as {
|
||||||
|
suggestions: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
logger.debug('Received structured output:', structuredOutput);
|
||||||
|
} else if (responseText) {
|
||||||
|
// Fallback: try to parse from text using shared extraction utility
|
||||||
|
logger.warn('No structured output received, attempting to parse from text');
|
||||||
|
structuredOutput = extractJsonWithArray<{ suggestions: Array<Record<string, unknown>> }>(
|
||||||
|
responseText,
|
||||||
|
'suggestions',
|
||||||
|
{ logger }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (structuredOutput && structuredOutput.suggestions) {
|
if (structuredOutput && structuredOutput.suggestions) {
|
||||||
// Use structured output directly
|
// Use structured output directly
|
||||||
events.emit('suggestions:event', {
|
events.emit('suggestions:event', {
|
||||||
@@ -334,24 +278,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Fallback: try to parse from text using shared extraction utility
|
throw new Error('No valid JSON found in response');
|
||||||
logger.warn('No structured output received, attempting to parse from text');
|
|
||||||
const parsed = extractJsonWithArray<{ suggestions: Array<Record<string, unknown>> }>(
|
|
||||||
responseText,
|
|
||||||
'suggestions',
|
|
||||||
{ logger }
|
|
||||||
);
|
|
||||||
if (parsed && parsed.suggestions) {
|
|
||||||
events.emit('suggestions:event', {
|
|
||||||
type: 'suggestions_complete',
|
|
||||||
suggestions: parsed.suggestions.map((s: Record<string, unknown>, i: number) => ({
|
|
||||||
...s,
|
|
||||||
id: s.id || `suggestion-${Date.now()}-${i}`,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error('No valid JSON found in response');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log the parsing error for debugging
|
// Log the parsing error for debugging
|
||||||
|
|||||||
@@ -10,15 +10,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||||
|
import { simpleQuery } from '../providers/simple-query-service.js';
|
||||||
import type {
|
import type {
|
||||||
ExecuteOptions,
|
ExecuteOptions,
|
||||||
Feature,
|
Feature,
|
||||||
ModelProvider,
|
ModelProvider,
|
||||||
PipelineStep,
|
PipelineStep,
|
||||||
|
FeatureStatusWithPipeline,
|
||||||
|
PipelineConfig,
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
PlanningMode,
|
PlanningMode,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { DEFAULT_PHASE_MODELS, stripProviderPrefix } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, isClaudeModel, stripProviderPrefix } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
buildPromptWithImages,
|
buildPromptWithImages,
|
||||||
classifyError,
|
classifyError,
|
||||||
@@ -83,6 +86,26 @@ interface PlanSpec {
|
|||||||
tasks?: ParsedTask[];
|
tasks?: ParsedTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information about pipeline status when resuming a feature.
|
||||||
|
* Used to determine how to handle features stuck in pipeline execution.
|
||||||
|
*
|
||||||
|
* @property {boolean} isPipeline - Whether the feature is in a pipeline step
|
||||||
|
* @property {string | null} stepId - ID of the current pipeline step (e.g., 'step_123')
|
||||||
|
* @property {number} stepIndex - Index of the step in the sorted pipeline steps (-1 if not found)
|
||||||
|
* @property {number} totalSteps - Total number of steps in the pipeline
|
||||||
|
* @property {PipelineStep | null} step - The pipeline step configuration, or null if step not found
|
||||||
|
* @property {PipelineConfig | null} config - The full pipeline configuration, or null if no pipeline
|
||||||
|
*/
|
||||||
|
interface PipelineStatusInfo {
|
||||||
|
isPipeline: boolean;
|
||||||
|
stepId: string | null;
|
||||||
|
stepIndex: number;
|
||||||
|
totalSteps: number;
|
||||||
|
step: PipelineStep | null;
|
||||||
|
config: PipelineConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse tasks from generated spec content
|
* Parse tasks from generated spec content
|
||||||
* Looks for the ```tasks code block and extracts task lines
|
* Looks for the ```tasks code block and extracts task lines
|
||||||
@@ -917,6 +940,25 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
|||||||
throw new Error('already running');
|
throw new Error('already running');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load feature to check status
|
||||||
|
const feature = await this.loadFeature(projectPath, featureId);
|
||||||
|
if (!feature) {
|
||||||
|
throw new Error(`Feature ${featureId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if feature is stuck in a pipeline step
|
||||||
|
const pipelineInfo = await this.detectPipelineStatus(
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
(feature.status || '') as FeatureStatusWithPipeline
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pipelineInfo.isPipeline) {
|
||||||
|
// Feature stuck in pipeline - use pipeline resume
|
||||||
|
return this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal resume flow for non-pipeline features
|
||||||
// Check if context exists in .automaker directory
|
// Check if context exists in .automaker directory
|
||||||
const featureDir = getFeatureDir(projectPath, featureId);
|
const featureDir = getFeatureDir(projectPath, featureId);
|
||||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||||
@@ -936,11 +978,252 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No context, start fresh - executeFeature will handle adding to runningFeatures
|
// No context, start fresh - executeFeature will handle adding to runningFeatures
|
||||||
// Remove the temporary entry we added
|
|
||||||
this.runningFeatures.delete(featureId);
|
|
||||||
return this.executeFeature(projectPath, featureId, useWorktrees, false);
|
return this.executeFeature(projectPath, featureId, useWorktrees, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume a feature that crashed during pipeline execution.
|
||||||
|
* Handles multiple edge cases to ensure robust recovery:
|
||||||
|
* - No context file: Restart entire pipeline from beginning
|
||||||
|
* - Step deleted from config: Complete feature without remaining pipeline steps
|
||||||
|
* - Valid step exists: Resume from the crashed step and continue
|
||||||
|
*
|
||||||
|
* @param {string} projectPath - Absolute path to the project directory
|
||||||
|
* @param {Feature} feature - The feature object (already loaded to avoid redundant reads)
|
||||||
|
* @param {boolean} useWorktrees - Whether to use git worktrees for isolation
|
||||||
|
* @param {PipelineStatusInfo} pipelineInfo - Information about the pipeline status from detectPipelineStatus()
|
||||||
|
* @returns {Promise<void>} Resolves when resume operation completes or throws on error
|
||||||
|
* @throws {Error} If pipeline config is null but stepIndex is valid (should never happen)
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async resumePipelineFeature(
|
||||||
|
projectPath: string,
|
||||||
|
feature: Feature,
|
||||||
|
useWorktrees: boolean,
|
||||||
|
pipelineInfo: PipelineStatusInfo
|
||||||
|
): Promise<void> {
|
||||||
|
const featureId = feature.id;
|
||||||
|
console.log(
|
||||||
|
`[AutoMode] Resuming feature ${featureId} from pipeline step ${pipelineInfo.stepId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for context file
|
||||||
|
const featureDir = getFeatureDir(projectPath, featureId);
|
||||||
|
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||||
|
|
||||||
|
let hasContext = false;
|
||||||
|
try {
|
||||||
|
await secureFs.access(contextPath);
|
||||||
|
hasContext = true;
|
||||||
|
} catch {
|
||||||
|
// No context
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge Case 1: No context file - restart entire pipeline from beginning
|
||||||
|
if (!hasContext) {
|
||||||
|
console.warn(
|
||||||
|
`[AutoMode] No context found for pipeline feature ${featureId}, restarting from beginning`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset status to in_progress and start fresh
|
||||||
|
await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
|
||||||
|
|
||||||
|
return this.executeFeature(projectPath, featureId, useWorktrees, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge Case 2: Step no longer exists in pipeline config
|
||||||
|
if (pipelineInfo.stepIndex === -1) {
|
||||||
|
console.warn(
|
||||||
|
`[AutoMode] Step ${pipelineInfo.stepId} no longer exists in pipeline, completing feature without pipeline`
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||||
|
|
||||||
|
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
||||||
|
|
||||||
|
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
|
featureId,
|
||||||
|
passes: true,
|
||||||
|
message:
|
||||||
|
'Pipeline step no longer exists - feature completed without remaining pipeline steps',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal case: Valid pipeline step exists, has context
|
||||||
|
// Resume from the stuck step (re-execute the step that crashed)
|
||||||
|
if (!pipelineInfo.config) {
|
||||||
|
throw new Error('Pipeline config is null but stepIndex is valid - this should not happen');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.resumeFromPipelineStep(
|
||||||
|
projectPath,
|
||||||
|
feature,
|
||||||
|
useWorktrees,
|
||||||
|
pipelineInfo.stepIndex,
|
||||||
|
pipelineInfo.config
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume pipeline execution from a specific step index.
|
||||||
|
* Re-executes the step that crashed (to handle partial completion),
|
||||||
|
* then continues executing all remaining pipeline steps in order.
|
||||||
|
*
|
||||||
|
* This method handles the complete pipeline resume workflow:
|
||||||
|
* - Validates feature and step index
|
||||||
|
* - Locates or creates git worktree if needed
|
||||||
|
* - Executes remaining steps starting from the crashed step
|
||||||
|
* - Updates feature status to verified/waiting_approval when complete
|
||||||
|
* - Emits progress events throughout execution
|
||||||
|
*
|
||||||
|
* @param {string} projectPath - Absolute path to the project directory
|
||||||
|
* @param {Feature} feature - The feature object (already loaded to avoid redundant reads)
|
||||||
|
* @param {boolean} useWorktrees - Whether to use git worktrees for isolation
|
||||||
|
* @param {number} startFromStepIndex - Zero-based index of the step to resume from
|
||||||
|
* @param {PipelineConfig} pipelineConfig - Pipeline config passed from detectPipelineStatus to avoid re-reading
|
||||||
|
* @returns {Promise<void>} Resolves when pipeline execution completes successfully
|
||||||
|
* @throws {Error} If feature not found, step index invalid, or pipeline execution fails
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async resumeFromPipelineStep(
|
||||||
|
projectPath: string,
|
||||||
|
feature: Feature,
|
||||||
|
useWorktrees: boolean,
|
||||||
|
startFromStepIndex: number,
|
||||||
|
pipelineConfig: PipelineConfig
|
||||||
|
): Promise<void> {
|
||||||
|
const featureId = feature.id;
|
||||||
|
|
||||||
|
const sortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
// Validate step index
|
||||||
|
if (startFromStepIndex < 0 || startFromStepIndex >= sortedSteps.length) {
|
||||||
|
throw new Error(`Invalid step index: ${startFromStepIndex}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get steps to execute (from startFromStepIndex onwards)
|
||||||
|
const stepsToExecute = sortedSteps.slice(startFromStepIndex);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add to running features immediately
|
||||||
|
const abortController = new AbortController();
|
||||||
|
this.runningFeatures.set(featureId, {
|
||||||
|
featureId,
|
||||||
|
projectPath,
|
||||||
|
worktreePath: null, // Will be set below
|
||||||
|
branchName: feature.branchName ?? null,
|
||||||
|
abortController,
|
||||||
|
isAutoMode: false,
|
||||||
|
startTime: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate project path
|
||||||
|
validateWorkingDirectory(projectPath);
|
||||||
|
|
||||||
|
// Derive workDir from feature.branchName
|
||||||
|
let worktreePath: string | null = null;
|
||||||
|
const branchName = feature.branchName;
|
||||||
|
|
||||||
|
if (useWorktrees && branchName) {
|
||||||
|
worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName);
|
||||||
|
if (worktreePath) {
|
||||||
|
console.log(`[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`[AutoMode] Worktree for branch "${branchName}" not found, using project path`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
|
||||||
|
validateWorkingDirectory(workDir);
|
||||||
|
|
||||||
|
// Update running feature with worktree info
|
||||||
|
const runningFeature = this.runningFeatures.get(featureId);
|
||||||
|
if (runningFeature) {
|
||||||
|
runningFeature.worktreePath = worktreePath;
|
||||||
|
runningFeature.branchName = branchName ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit resume event
|
||||||
|
this.emitAutoModeEvent('auto_mode_feature_start', {
|
||||||
|
featureId,
|
||||||
|
projectPath,
|
||||||
|
feature: {
|
||||||
|
id: featureId,
|
||||||
|
title: feature.title || 'Resuming Pipeline',
|
||||||
|
description: feature.description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emitAutoModeEvent('auto_mode_progress', {
|
||||||
|
featureId,
|
||||||
|
content: `Resuming from pipeline step ${startFromStepIndex + 1}/${sortedSteps.length}`,
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load autoLoadClaudeMd setting
|
||||||
|
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||||
|
projectPath,
|
||||||
|
this.settingsService,
|
||||||
|
'[AutoMode]'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute remaining pipeline steps (starting from crashed step)
|
||||||
|
await this.executePipelineSteps(
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
feature,
|
||||||
|
stepsToExecute,
|
||||||
|
workDir,
|
||||||
|
abortController,
|
||||||
|
autoLoadClaudeMd
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine final status
|
||||||
|
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||||
|
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
||||||
|
|
||||||
|
console.log('[AutoMode] Pipeline resume completed successfully');
|
||||||
|
|
||||||
|
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
|
featureId,
|
||||||
|
passes: true,
|
||||||
|
message: 'Pipeline resumed and completed successfully',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorInfo = classifyError(error);
|
||||||
|
|
||||||
|
if (errorInfo.isAbort) {
|
||||||
|
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
|
featureId,
|
||||||
|
passes: false,
|
||||||
|
message: 'Pipeline resume stopped by user',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error(`[AutoMode] Pipeline resume failed for feature ${featureId}:`, error);
|
||||||
|
await this.updateFeatureStatus(projectPath, featureId, 'backlog');
|
||||||
|
this.emitAutoModeEvent('auto_mode_error', {
|
||||||
|
featureId,
|
||||||
|
error: errorInfo.message,
|
||||||
|
errorType: errorInfo.type,
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.runningFeatures.delete(featureId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Follow up on a feature with additional instructions
|
* Follow up on a feature with additional instructions
|
||||||
*/
|
*/
|
||||||
@@ -2885,6 +3168,111 @@ Review the previous work and continue the implementation. If the feature appears
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if a feature is stuck in a pipeline step and extract step information.
|
||||||
|
* Parses the feature status to determine if it's a pipeline status (e.g., 'pipeline_step_xyz'),
|
||||||
|
* loads the pipeline configuration, and validates that the step still exists.
|
||||||
|
*
|
||||||
|
* This method handles several scenarios:
|
||||||
|
* - Non-pipeline status: Returns default PipelineStatusInfo with isPipeline=false
|
||||||
|
* - Invalid pipeline status format: Returns isPipeline=true but null step info
|
||||||
|
* - Step deleted from config: Returns stepIndex=-1 to signal missing step
|
||||||
|
* - Valid pipeline step: Returns full step information and config
|
||||||
|
*
|
||||||
|
* @param {string} projectPath - Absolute path to the project directory
|
||||||
|
* @param {string} featureId - Unique identifier of the feature
|
||||||
|
* @param {FeatureStatusWithPipeline} currentStatus - Current feature status (may include pipeline step info)
|
||||||
|
* @returns {Promise<PipelineStatusInfo>} Information about the pipeline status and step
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async detectPipelineStatus(
|
||||||
|
projectPath: string,
|
||||||
|
featureId: string,
|
||||||
|
currentStatus: FeatureStatusWithPipeline
|
||||||
|
): Promise<PipelineStatusInfo> {
|
||||||
|
// Check if status is pipeline format using PipelineService
|
||||||
|
const isPipeline = pipelineService.isPipelineStatus(currentStatus);
|
||||||
|
|
||||||
|
if (!isPipeline) {
|
||||||
|
return {
|
||||||
|
isPipeline: false,
|
||||||
|
stepId: null,
|
||||||
|
stepIndex: -1,
|
||||||
|
totalSteps: 0,
|
||||||
|
step: null,
|
||||||
|
config: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract step ID using PipelineService
|
||||||
|
const stepId = pipelineService.getStepIdFromStatus(currentStatus);
|
||||||
|
|
||||||
|
if (!stepId) {
|
||||||
|
console.warn(
|
||||||
|
`[AutoMode] Feature ${featureId} has invalid pipeline status format: ${currentStatus}`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
isPipeline: true,
|
||||||
|
stepId: null,
|
||||||
|
stepIndex: -1,
|
||||||
|
totalSteps: 0,
|
||||||
|
step: null,
|
||||||
|
config: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load pipeline config
|
||||||
|
const config = await pipelineService.getPipelineConfig(projectPath);
|
||||||
|
|
||||||
|
if (!config || config.steps.length === 0) {
|
||||||
|
// Pipeline config doesn't exist or empty - feature stuck with invalid pipeline status
|
||||||
|
console.warn(
|
||||||
|
`[AutoMode] Feature ${featureId} has pipeline status but no pipeline config exists`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
isPipeline: true,
|
||||||
|
stepId,
|
||||||
|
stepIndex: -1,
|
||||||
|
totalSteps: 0,
|
||||||
|
step: null,
|
||||||
|
config: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the step directly from config (already loaded, avoid redundant file read)
|
||||||
|
const sortedSteps = [...config.steps].sort((a, b) => a.order - b.order);
|
||||||
|
const stepIndex = sortedSteps.findIndex((s) => s.id === stepId);
|
||||||
|
const step = stepIndex === -1 ? null : sortedSteps[stepIndex];
|
||||||
|
|
||||||
|
if (!step) {
|
||||||
|
// Step not found in current config - step was deleted/changed
|
||||||
|
console.warn(
|
||||||
|
`[AutoMode] Feature ${featureId} stuck in step ${stepId} which no longer exists in pipeline config`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
isPipeline: true,
|
||||||
|
stepId,
|
||||||
|
stepIndex: -1,
|
||||||
|
totalSteps: sortedSteps.length,
|
||||||
|
step: null,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[AutoMode] Detected pipeline status for feature ${featureId}: step ${stepIndex + 1}/${sortedSteps.length} (${step.name})`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPipeline: true,
|
||||||
|
stepId,
|
||||||
|
stepIndex,
|
||||||
|
totalSteps: sortedSteps.length,
|
||||||
|
step,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a focused prompt for executing a single task.
|
* Build a focused prompt for executing a single task.
|
||||||
* Each task gets minimal context to keep the agent focused.
|
* Each task gets minimal context to keep the agent focused.
|
||||||
@@ -3193,41 +3581,43 @@ IMPORTANT: Only include NON-OBVIOUS learnings with real reasoning. Skip trivial
|
|||||||
If nothing notable: {"learnings": []}`;
|
If nothing notable: {"learnings": []}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Import query dynamically to avoid circular dependencies
|
|
||||||
const { query } = await import('@anthropic-ai/claude-agent-sdk');
|
|
||||||
|
|
||||||
// Get model from phase settings
|
// Get model from phase settings
|
||||||
const settings = await this.settingsService?.getGlobalSettings();
|
const settings = await this.settingsService?.getGlobalSettings();
|
||||||
const phaseModelEntry =
|
const phaseModelEntry =
|
||||||
settings?.phaseModels?.memoryExtractionModel || DEFAULT_PHASE_MODELS.memoryExtractionModel;
|
settings?.phaseModels?.memoryExtractionModel || DEFAULT_PHASE_MODELS.memoryExtractionModel;
|
||||||
const { model } = resolvePhaseModel(phaseModelEntry);
|
const { model } = resolvePhaseModel(phaseModelEntry);
|
||||||
|
const hasClaudeKey = Boolean(process.env.ANTHROPIC_API_KEY);
|
||||||
|
let resolvedModel = model;
|
||||||
|
|
||||||
const stream = query({
|
if (isClaudeModel(model) && !hasClaudeKey) {
|
||||||
prompt: userPrompt,
|
const fallbackModel = feature.model
|
||||||
options: {
|
? resolveModelString(feature.model, DEFAULT_MODELS.claude)
|
||||||
model,
|
: null;
|
||||||
maxTurns: 1,
|
if (fallbackModel && !isClaudeModel(fallbackModel)) {
|
||||||
allowedTools: [],
|
console.log(
|
||||||
permissionMode: 'acceptEdits',
|
`[AutoMode] Claude not configured for memory extraction; using feature model "${fallbackModel}".`
|
||||||
systemPrompt:
|
);
|
||||||
'You are a JSON extraction assistant. You MUST respond with ONLY valid JSON, no explanations, no markdown, no other text. Extract learnings from the provided implementation context and return them as JSON.',
|
resolvedModel = fallbackModel;
|
||||||
},
|
} else {
|
||||||
});
|
console.log(
|
||||||
|
'[AutoMode] Claude not configured for memory extraction; skipping learning extraction.'
|
||||||
// Extract text from stream
|
);
|
||||||
let responseText = '';
|
return;
|
||||||
for await (const msg of stream) {
|
|
||||||
if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
for (const block of msg.message.content) {
|
|
||||||
if (block.type === 'text' && block.text) {
|
|
||||||
responseText += block.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
|
||||||
responseText = msg.result || responseText;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await simpleQuery({
|
||||||
|
prompt: userPrompt,
|
||||||
|
model: resolvedModel,
|
||||||
|
cwd: projectPath,
|
||||||
|
maxTurns: 1,
|
||||||
|
allowedTools: [],
|
||||||
|
systemPrompt:
|
||||||
|
'You are a JSON extraction assistant. You MUST respond with ONLY valid JSON, no explanations, no markdown, no other text. Extract learnings from the provided implementation context and return them as JSON.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = result.text;
|
||||||
|
|
||||||
console.log(`[AutoMode] Learning extraction response: ${responseText.length} chars`);
|
console.log(`[AutoMode] Learning extraction response: ${responseText.length} chars`);
|
||||||
console.log(`[AutoMode] Response preview: ${responseText.substring(0, 300)}`);
|
console.log(`[AutoMode] Response preview: ${responseText.substring(0, 300)}`);
|
||||||
|
|
||||||
|
|||||||
@@ -431,6 +431,8 @@ export class SettingsService {
|
|||||||
*/
|
*/
|
||||||
async getMaskedCredentials(): Promise<{
|
async getMaskedCredentials(): Promise<{
|
||||||
anthropic: { configured: boolean; masked: string };
|
anthropic: { configured: boolean; masked: string };
|
||||||
|
google: { configured: boolean; masked: string };
|
||||||
|
openai: { configured: boolean; masked: string };
|
||||||
}> {
|
}> {
|
||||||
const credentials = await this.getCredentials();
|
const credentials = await this.getCredentials();
|
||||||
|
|
||||||
@@ -444,6 +446,14 @@ export class SettingsService {
|
|||||||
configured: !!credentials.apiKeys.anthropic,
|
configured: !!credentials.apiKeys.anthropic,
|
||||||
masked: maskKey(credentials.apiKeys.anthropic),
|
masked: maskKey(credentials.apiKeys.anthropic),
|
||||||
},
|
},
|
||||||
|
google: {
|
||||||
|
configured: !!credentials.apiKeys.google,
|
||||||
|
masked: maskKey(credentials.apiKeys.google),
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
configured: !!credentials.apiKeys.openai,
|
||||||
|
masked: maskKey(credentials.apiKeys.openai),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ describe('codex-provider.ts', () => {
|
|||||||
expect(results[1].result).toBe('Hello from SDK');
|
expect(results[1].result).toBe('Hello from SDK');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the CLI when tools are requested even if an API key is present', async () => {
|
it('uses the SDK when API key is present, even for tool requests (to avoid OAuth issues)', async () => {
|
||||||
process.env[OPENAI_API_KEY_ENV] = 'sk-test';
|
process.env[OPENAI_API_KEY_ENV] = 'sk-test';
|
||||||
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
||||||
|
|
||||||
@@ -270,8 +270,8 @@ describe('codex-provider.ts', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(codexRunMock).not.toHaveBeenCalled();
|
expect(codexRunMock).toHaveBeenCalled();
|
||||||
expect(spawnJSONLProcess).toHaveBeenCalled();
|
expect(spawnJSONLProcess).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to CLI when no tools are requested and no API key is available', async () => {
|
it('falls back to CLI when no tools are requested and no API key is available', async () => {
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ const eslintConfig = defineConfig([
|
|||||||
AbortSignal: 'readonly',
|
AbortSignal: 'readonly',
|
||||||
Audio: 'readonly',
|
Audio: 'readonly',
|
||||||
ScrollBehavior: 'readonly',
|
ScrollBehavior: 'readonly',
|
||||||
|
URL: 'readonly',
|
||||||
|
URLSearchParams: 'readonly',
|
||||||
// Timers
|
// Timers
|
||||||
setTimeout: 'readonly',
|
setTimeout: 'readonly',
|
||||||
setInterval: 'readonly',
|
setInterval: 'readonly',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
|
import type { ModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
|
||||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||||
|
|
||||||
export interface UseModelOverrideOptions {
|
export interface UseModelOverrideOptions {
|
||||||
@@ -14,7 +14,7 @@ export interface UseModelOverrideResult {
|
|||||||
/** The effective model entry (override or global default) */
|
/** The effective model entry (override or global default) */
|
||||||
effectiveModelEntry: PhaseModelEntry;
|
effectiveModelEntry: PhaseModelEntry;
|
||||||
/** The effective model string (for backward compatibility with APIs that only accept strings) */
|
/** The effective model string (for backward compatibility with APIs that only accept strings) */
|
||||||
effectiveModel: ModelAlias | CursorModelId;
|
effectiveModel: ModelId;
|
||||||
/** Whether the model is currently overridden */
|
/** Whether the model is currently overridden */
|
||||||
isOverridden: boolean;
|
isOverridden: boolean;
|
||||||
/** Set a model override */
|
/** Set a model override */
|
||||||
@@ -32,7 +32,7 @@ export interface UseModelOverrideResult {
|
|||||||
*/
|
*/
|
||||||
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
|
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
|
||||||
if (typeof entry === 'string') {
|
if (typeof entry === 'string') {
|
||||||
return { model: entry as ModelAlias | CursorModelId };
|
return { model: entry as ModelId };
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
@@ -40,9 +40,9 @@ function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
|
|||||||
/**
|
/**
|
||||||
* Extract model string from PhaseModelEntry or string
|
* Extract model string from PhaseModelEntry or string
|
||||||
*/
|
*/
|
||||||
function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId {
|
function extractModel(entry: PhaseModelEntry | string): ModelId {
|
||||||
if (typeof entry === 'string') {
|
if (typeof entry === 'string') {
|
||||||
return entry as ModelAlias | CursorModelId;
|
return entry as ModelId;
|
||||||
}
|
}
|
||||||
return entry.model;
|
return entry.model;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,6 +422,31 @@ export function BoardView() {
|
|||||||
const selectedWorktreeBranch =
|
const selectedWorktreeBranch =
|
||||||
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
|
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
|
||||||
|
|
||||||
|
// Helper function to add and select a worktree
|
||||||
|
const addAndSelectWorktree = useCallback(
|
||||||
|
(worktreeResult: { path: string; branch: string }) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
const currentWorktrees = getWorktrees(currentProject.path);
|
||||||
|
const existingWorktree = currentWorktrees.find((w) => w.branch === worktreeResult.branch);
|
||||||
|
|
||||||
|
// Only add if it doesn't already exist (to avoid duplicates)
|
||||||
|
if (!existingWorktree) {
|
||||||
|
const newWorktreeInfo = {
|
||||||
|
path: worktreeResult.path,
|
||||||
|
branch: worktreeResult.branch,
|
||||||
|
isMain: false,
|
||||||
|
isCurrent: false,
|
||||||
|
hasWorktree: true,
|
||||||
|
};
|
||||||
|
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
|
||||||
|
}
|
||||||
|
// Select the worktree (whether it existed or was just added)
|
||||||
|
setCurrentWorktree(currentProject.path, worktreeResult.path, worktreeResult.branch);
|
||||||
|
},
|
||||||
|
[currentProject, getWorktrees, setWorktrees, setCurrentWorktree]
|
||||||
|
);
|
||||||
|
|
||||||
// Extract all action handlers into a hook
|
// Extract all action handlers into a hook
|
||||||
const {
|
const {
|
||||||
handleAddFeature,
|
handleAddFeature,
|
||||||
@@ -467,43 +492,90 @@ export function BoardView() {
|
|||||||
outputFeature,
|
outputFeature,
|
||||||
projectPath: currentProject?.path || null,
|
projectPath: currentProject?.path || null,
|
||||||
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
|
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
|
||||||
onWorktreeAutoSelect: (newWorktree) => {
|
onWorktreeAutoSelect: addAndSelectWorktree,
|
||||||
if (!currentProject) return;
|
|
||||||
// Check if worktree already exists in the store (by branch name)
|
|
||||||
const currentWorktrees = getWorktrees(currentProject.path);
|
|
||||||
const existingWorktree = currentWorktrees.find((w) => w.branch === newWorktree.branch);
|
|
||||||
|
|
||||||
// Only add if it doesn't already exist (to avoid duplicates)
|
|
||||||
if (!existingWorktree) {
|
|
||||||
const newWorktreeInfo = {
|
|
||||||
path: newWorktree.path,
|
|
||||||
branch: newWorktree.branch,
|
|
||||||
isMain: false,
|
|
||||||
isCurrent: false,
|
|
||||||
hasWorktree: true,
|
|
||||||
};
|
|
||||||
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
|
|
||||||
}
|
|
||||||
// Select the worktree (whether it existed or was just added)
|
|
||||||
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
|
|
||||||
},
|
|
||||||
currentWorktreeBranch,
|
currentWorktreeBranch,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handler for bulk updating multiple features
|
// Handler for bulk updating multiple features
|
||||||
const handleBulkUpdate = useCallback(
|
const handleBulkUpdate = useCallback(
|
||||||
async (updates: Partial<Feature>) => {
|
async (updates: Partial<Feature>, workMode: 'current' | 'auto' | 'custom') => {
|
||||||
if (!currentProject || selectedFeatureIds.size === 0) return;
|
if (!currentProject || selectedFeatureIds.size === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Determine final branch name based on work mode:
|
||||||
|
// - 'current': Empty string to clear branch assignment (work on main/current branch)
|
||||||
|
// - 'auto': Auto-generate branch name based on current branch
|
||||||
|
// - 'custom': Use the provided branch name
|
||||||
|
let finalBranchName: string | undefined;
|
||||||
|
|
||||||
|
if (workMode === 'current') {
|
||||||
|
// Empty string clears the branch assignment, moving features to main/current branch
|
||||||
|
finalBranchName = '';
|
||||||
|
} else if (workMode === 'auto') {
|
||||||
|
// Auto-generate a branch name based on current branch and timestamp
|
||||||
|
const baseBranch =
|
||||||
|
currentWorktreeBranch || getPrimaryWorktreeBranch(currentProject.path) || 'main';
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
||||||
|
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
|
||||||
|
} else {
|
||||||
|
// Custom mode - use provided branch name
|
||||||
|
finalBranchName = updates.branchName || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create worktree for 'auto' or 'custom' modes when we have a branch name
|
||||||
|
if ((workMode === 'auto' || workMode === 'custom') && finalBranchName) {
|
||||||
|
try {
|
||||||
|
const electronApi = getElectronAPI();
|
||||||
|
if (electronApi?.worktree?.create) {
|
||||||
|
const result = await electronApi.worktree.create(
|
||||||
|
currentProject.path,
|
||||||
|
finalBranchName
|
||||||
|
);
|
||||||
|
if (result.success && result.worktree) {
|
||||||
|
logger.info(
|
||||||
|
`Worktree for branch "${finalBranchName}" ${
|
||||||
|
result.worktree?.isNew ? 'created' : 'already exists'
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
// Auto-select the worktree when creating/using it for bulk update
|
||||||
|
addAndSelectWorktree(result.worktree);
|
||||||
|
// Refresh worktree list in UI
|
||||||
|
setWorktreeRefreshKey((k) => k + 1);
|
||||||
|
} else if (!result.success) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to create worktree for branch "${finalBranchName}":`,
|
||||||
|
result.error
|
||||||
|
);
|
||||||
|
toast.error('Failed to create worktree', {
|
||||||
|
description: result.error || 'An error occurred',
|
||||||
|
});
|
||||||
|
return; // Don't proceed with update if worktree creation failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating worktree:', error);
|
||||||
|
toast.error('Failed to create worktree', {
|
||||||
|
description: error instanceof Error ? error.message : 'An error occurred',
|
||||||
|
});
|
||||||
|
return; // Don't proceed with update if worktree creation failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the final branch name in updates
|
||||||
|
const finalUpdates = {
|
||||||
|
...updates,
|
||||||
|
branchName: finalBranchName,
|
||||||
|
};
|
||||||
|
|
||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
const featureIds = Array.from(selectedFeatureIds);
|
const featureIds = Array.from(selectedFeatureIds);
|
||||||
const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates);
|
const result = await api.features.bulkUpdate(currentProject.path, featureIds, finalUpdates);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Update local state
|
// Update local state
|
||||||
featureIds.forEach((featureId) => {
|
featureIds.forEach((featureId) => {
|
||||||
updateFeature(featureId, updates);
|
updateFeature(featureId, finalUpdates);
|
||||||
});
|
});
|
||||||
toast.success(`Updated ${result.updatedCount} features`);
|
toast.success(`Updated ${result.updatedCount} features`);
|
||||||
exitSelectionMode();
|
exitSelectionMode();
|
||||||
@@ -517,7 +589,16 @@ export function BoardView() {
|
|||||||
toast.error('Failed to update features');
|
toast.error('Failed to update features');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]
|
[
|
||||||
|
currentProject,
|
||||||
|
selectedFeatureIds,
|
||||||
|
updateFeature,
|
||||||
|
exitSelectionMode,
|
||||||
|
currentWorktreeBranch,
|
||||||
|
getPrimaryWorktreeBranch,
|
||||||
|
addAndSelectWorktree,
|
||||||
|
setWorktreeRefreshKey,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handler for bulk deleting multiple features
|
// Handler for bulk deleting multiple features
|
||||||
@@ -1325,6 +1406,9 @@ export function BoardView() {
|
|||||||
onClose={() => setShowMassEditDialog(false)}
|
onClose={() => setShowMassEditDialog(false)}
|
||||||
selectedFeatures={selectedFeatures}
|
selectedFeatures={selectedFeatures}
|
||||||
onApply={handleBulkUpdate}
|
onApply={handleBulkUpdate}
|
||||||
|
branchSuggestions={branchSuggestions}
|
||||||
|
branchCardCounts={branchCardCounts}
|
||||||
|
currentBranch={currentWorktreeBranch || undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Board Background Modal */}
|
{/* Board Background Modal */}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export function BoardHeader({
|
|||||||
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
|
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
|
||||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||||
<Label htmlFor="worktrees-toggle" className="text-sm font-medium cursor-pointer">
|
<Label htmlFor="worktrees-toggle" className="text-sm font-medium cursor-pointer">
|
||||||
Worktrees
|
Worktree Bar
|
||||||
</Label>
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id="worktrees-toggle"
|
id="worktrees-toggle"
|
||||||
|
|||||||
@@ -117,73 +117,90 @@ export function CardActions({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isCurrentAutoTask && feature.status === 'in_progress' && (
|
{!isCurrentAutoTask &&
|
||||||
<>
|
(feature.status === 'in_progress' ||
|
||||||
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
|
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'))) && (
|
||||||
{feature.planSpec?.status === 'generated' && onApprovePlan && (
|
<>
|
||||||
<Button
|
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
|
||||||
variant="default"
|
{feature.planSpec?.status === 'generated' && onApprovePlan && (
|
||||||
size="sm"
|
<Button
|
||||||
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
variant="default"
|
||||||
onClick={(e) => {
|
size="sm"
|
||||||
e.stopPropagation();
|
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
||||||
onApprovePlan();
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onApprovePlan();
|
||||||
data-testid={`approve-plan-${feature.id}`}
|
}}
|
||||||
>
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
<FileText className="w-3 h-3 mr-1" />
|
data-testid={`approve-plan-${feature.id}`}
|
||||||
Approve Plan
|
>
|
||||||
</Button>
|
<FileText className="w-3 h-3 mr-1" />
|
||||||
)}
|
Approve Plan
|
||||||
{feature.skipTests && onManualVerify ? (
|
</Button>
|
||||||
<Button
|
)}
|
||||||
variant="default"
|
{feature.skipTests && onManualVerify ? (
|
||||||
size="sm"
|
<Button
|
||||||
className="flex-1 h-7 text-[11px]"
|
variant="default"
|
||||||
onClick={(e) => {
|
size="sm"
|
||||||
e.stopPropagation();
|
className="flex-1 h-7 text-[11px]"
|
||||||
onManualVerify();
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onManualVerify();
|
||||||
data-testid={`manual-verify-${feature.id}`}
|
}}
|
||||||
>
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
data-testid={`manual-verify-${feature.id}`}
|
||||||
Verify
|
>
|
||||||
</Button>
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
) : onResume ? (
|
Verify
|
||||||
<Button
|
</Button>
|
||||||
variant="default"
|
) : onResume ? (
|
||||||
size="sm"
|
<Button
|
||||||
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
variant="default"
|
||||||
onClick={(e) => {
|
size="sm"
|
||||||
e.stopPropagation();
|
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
||||||
onResume();
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onResume();
|
||||||
data-testid={`resume-feature-${feature.id}`}
|
}}
|
||||||
>
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
<RotateCcw className="w-3 h-3 mr-1" />
|
data-testid={`resume-feature-${feature.id}`}
|
||||||
Resume
|
>
|
||||||
</Button>
|
<RotateCcw className="w-3 h-3 mr-1" />
|
||||||
) : null}
|
Resume
|
||||||
{onViewOutput && !feature.skipTests && (
|
</Button>
|
||||||
<Button
|
) : onVerify ? (
|
||||||
variant="secondary"
|
<Button
|
||||||
size="sm"
|
variant="default"
|
||||||
className="h-7 text-[11px] px-2"
|
size="sm"
|
||||||
onClick={(e) => {
|
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
onViewOutput();
|
e.stopPropagation();
|
||||||
}}
|
onVerify();
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
}}
|
||||||
data-testid={`view-output-inprogress-${feature.id}`}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
data-testid={`verify-feature-${feature.id}`}
|
||||||
<FileText className="w-3 h-3" />
|
>
|
||||||
</Button>
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
)}
|
Verify
|
||||||
</>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
|
{onViewOutput && !feature.skipTests && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[11px] px-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewOutput();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`view-output-inprogress-${feature.id}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{!isCurrentAutoTask && feature.status === 'verified' && (
|
{!isCurrentAutoTask && feature.status === 'verified' && (
|
||||||
<>
|
<>
|
||||||
{/* Logs button */}
|
{/* Logs button */}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import {
|
|||||||
FeatureTextFilePath as DescriptionTextFilePath,
|
FeatureTextFilePath as DescriptionTextFilePath,
|
||||||
ImagePreviewMap,
|
ImagePreviewMap,
|
||||||
} from '@/components/ui/description-image-dropzone';
|
} from '@/components/ui/description-image-dropzone';
|
||||||
import { Play, Cpu, FolderKanban } from 'lucide-react';
|
import { Play, Cpu, FolderKanban, Settings2 } from 'lucide-react';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { modelSupportsThinking } from '@/lib/utils';
|
import { modelSupportsThinking } from '@/lib/utils';
|
||||||
@@ -33,7 +34,7 @@ import {
|
|||||||
PlanningMode,
|
PlanningMode,
|
||||||
Feature,
|
Feature,
|
||||||
} from '@/store/app-store';
|
} from '@/store/app-store';
|
||||||
import type { ReasoningEffort, PhaseModelEntry } from '@automaker/types';
|
import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types';
|
||||||
import { supportsReasoningEffort, isClaudeModel } from '@automaker/types';
|
import { supportsReasoningEffort, isClaudeModel } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
TestingTabContent,
|
TestingTabContent,
|
||||||
@@ -122,7 +123,7 @@ interface AddFeatureDialogProps {
|
|||||||
selectedNonMainWorktreeBranch?: string;
|
selectedNonMainWorktreeBranch?: string;
|
||||||
/**
|
/**
|
||||||
* When true, forces the dialog to default to 'current' work mode (work on current branch).
|
* When true, forces the dialog to default to 'current' work mode (work on current branch).
|
||||||
* This is used when the "Use selected worktree branch" setting is disabled.
|
* This is used when the "Default to worktree mode" setting is disabled.
|
||||||
*/
|
*/
|
||||||
forceCurrentBranchMode?: boolean;
|
forceCurrentBranchMode?: boolean;
|
||||||
}
|
}
|
||||||
@@ -152,6 +153,7 @@ export function AddFeatureDialog({
|
|||||||
forceCurrentBranchMode,
|
forceCurrentBranchMode,
|
||||||
}: AddFeatureDialogProps) {
|
}: AddFeatureDialogProps) {
|
||||||
const isSpawnMode = !!parentFeature;
|
const isSpawnMode = !!parentFeature;
|
||||||
|
const navigate = useNavigate();
|
||||||
const [workMode, setWorkMode] = useState<WorkMode>('current');
|
const [workMode, setWorkMode] = useState<WorkMode>('current');
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
@@ -187,7 +189,8 @@ export function AddFeatureDialog({
|
|||||||
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
|
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Get defaults from store
|
// Get defaults from store
|
||||||
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
|
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees, defaultFeatureModel } =
|
||||||
|
useAppStore();
|
||||||
|
|
||||||
// Track previous open state to detect when dialog opens
|
// Track previous open state to detect when dialog opens
|
||||||
const wasOpenRef = useRef(false);
|
const wasOpenRef = useRef(false);
|
||||||
@@ -207,7 +210,7 @@ export function AddFeatureDialog({
|
|||||||
);
|
);
|
||||||
setPlanningMode(defaultPlanningMode);
|
setPlanningMode(defaultPlanningMode);
|
||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
setModelEntry({ model: 'opus' });
|
setModelEntry(defaultFeatureModel);
|
||||||
|
|
||||||
// Initialize description history (empty for new feature)
|
// Initialize description history (empty for new feature)
|
||||||
setDescriptionHistory([]);
|
setDescriptionHistory([]);
|
||||||
@@ -228,6 +231,7 @@ export function AddFeatureDialog({
|
|||||||
defaultBranch,
|
defaultBranch,
|
||||||
defaultPlanningMode,
|
defaultPlanningMode,
|
||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
|
defaultFeatureModel,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
selectedNonMainWorktreeBranch,
|
selectedNonMainWorktreeBranch,
|
||||||
forceCurrentBranchMode,
|
forceCurrentBranchMode,
|
||||||
@@ -318,7 +322,7 @@ export function AddFeatureDialog({
|
|||||||
// When a non-main worktree is selected, use its branch name for custom mode
|
// When a non-main worktree is selected, use its branch name for custom mode
|
||||||
setBranchName(selectedNonMainWorktreeBranch || '');
|
setBranchName(selectedNonMainWorktreeBranch || '');
|
||||||
setPriority(2);
|
setPriority(2);
|
||||||
setModelEntry({ model: 'opus' });
|
setModelEntry(defaultFeatureModel);
|
||||||
setWorkMode(
|
setWorkMode(
|
||||||
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
|
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
|
||||||
);
|
);
|
||||||
@@ -473,9 +477,31 @@ export function AddFeatureDialog({
|
|||||||
|
|
||||||
{/* AI & Execution Section */}
|
{/* AI & Execution Section */}
|
||||||
<div className={cardClass}>
|
<div className={cardClass}>
|
||||||
<div className={sectionHeaderClass}>
|
<div className="flex items-center justify-between">
|
||||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
<div className={sectionHeaderClass}>
|
||||||
<span>AI & Execution</span>
|
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span>AI & Execution</span>
|
||||||
|
</div>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
navigate({ to: '/settings', search: { view: 'defaults' } });
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Settings2 className="w-3.5 h-3.5" />
|
||||||
|
<span>Edit Defaults</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Change default model and planning settings for new features</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ export function BacklogPlanDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="py-4">{renderContent()}</div>
|
<div className="py-4 overflow-y-auto">{renderContent()}</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{mode === 'input' && (
|
{mode === 'input' && (
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function CreatePRDialog({
|
|||||||
description: `PR already exists for ${result.result.branch}`,
|
description: `PR already exists for ${result.result.branch}`,
|
||||||
action: {
|
action: {
|
||||||
label: 'View PR',
|
label: 'View PR',
|
||||||
onClick: () => window.open(result.result!.prUrl!, '_blank'),
|
onClick: () => window.open(result.result!.prUrl!, '_blank', 'noopener,noreferrer'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -125,7 +125,7 @@ export function CreatePRDialog({
|
|||||||
description: `PR created from ${result.result.branch}`,
|
description: `PR created from ${result.result.branch}`,
|
||||||
action: {
|
action: {
|
||||||
label: 'View PR',
|
label: 'View PR',
|
||||||
onClick: () => window.open(result.result!.prUrl!, '_blank'),
|
onClick: () => window.open(result.result!.prUrl!, '_blank', 'noopener,noreferrer'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -251,7 +251,10 @@ export function CreatePRDialog({
|
|||||||
<p className="text-sm text-muted-foreground mt-1">Your PR is ready for review</p>
|
<p className="text-sm text-muted-foreground mt-1">Your PR is ready for review</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 justify-center">
|
<div className="flex gap-2 justify-center">
|
||||||
<Button onClick={() => window.open(prUrl, '_blank')} className="gap-2">
|
<Button
|
||||||
|
onClick={() => window.open(prUrl, '_blank', 'noopener,noreferrer')}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
View Pull Request
|
View Pull Request
|
||||||
</Button>
|
</Button>
|
||||||
@@ -277,7 +280,7 @@ export function CreatePRDialog({
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (browserUrl) {
|
if (browserUrl) {
|
||||||
window.open(browserUrl, '_blank');
|
window.open(browserUrl, '_blank', 'noopener,noreferrer');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="gap-2 w-full"
|
className="gap-2 w-full"
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import {
|
|||||||
FeatureTextFilePath as DescriptionTextFilePath,
|
FeatureTextFilePath as DescriptionTextFilePath,
|
||||||
ImagePreviewMap,
|
ImagePreviewMap,
|
||||||
} from '@/components/ui/description-image-dropzone';
|
} from '@/components/ui/description-image-dropzone';
|
||||||
import { GitBranch, Cpu, FolderKanban } from 'lucide-react';
|
import { GitBranch, Cpu, FolderKanban, Settings2 } from 'lucide-react';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn, modelSupportsThinking } from '@/lib/utils';
|
import { cn, modelSupportsThinking } from '@/lib/utils';
|
||||||
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
|
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
|
||||||
@@ -86,6 +87,7 @@ export function EditFeatureDialog({
|
|||||||
isMaximized,
|
isMaximized,
|
||||||
allFeatures,
|
allFeatures,
|
||||||
}: EditFeatureDialogProps) {
|
}: EditFeatureDialogProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
||||||
// Derive initial workMode from feature's branchName
|
// Derive initial workMode from feature's branchName
|
||||||
const [workMode, setWorkMode] = useState<WorkMode>(() => {
|
const [workMode, setWorkMode] = useState<WorkMode>(() => {
|
||||||
@@ -363,9 +365,31 @@ export function EditFeatureDialog({
|
|||||||
|
|
||||||
{/* AI & Execution Section */}
|
{/* AI & Execution Section */}
|
||||||
<div className={cardClass}>
|
<div className={cardClass}>
|
||||||
<div className={sectionHeaderClass}>
|
<div className="flex items-center justify-between">
|
||||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
<div className={sectionHeaderClass}>
|
||||||
<span>AI & Execution</span>
|
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span>AI & Execution</span>
|
||||||
|
</div>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
navigate({ to: '/settings', search: { view: 'defaults' } });
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Settings2 className="w-3.5 h-3.5" />
|
||||||
|
<span>Edit Defaults</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Change default model and planning settings for new features</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
import { modelSupportsThinking } from '@/lib/utils';
|
import { modelSupportsThinking } from '@/lib/utils';
|
||||||
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
|
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
|
||||||
import { TestingTabContent, PrioritySelect, PlanningModeSelect } from '../shared';
|
import { TestingTabContent, PrioritySelect, PlanningModeSelect, WorkModeSelector } from '../shared';
|
||||||
|
import type { WorkMode } from '../shared';
|
||||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||||
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
|
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -23,7 +24,10 @@ interface MassEditDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
selectedFeatures: Feature[];
|
selectedFeatures: Feature[];
|
||||||
onApply: (updates: Partial<Feature>) => Promise<void>;
|
onApply: (updates: Partial<Feature>, workMode: WorkMode) => Promise<void>;
|
||||||
|
branchSuggestions: string[];
|
||||||
|
branchCardCounts?: Record<string, number>;
|
||||||
|
currentBranch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApplyState {
|
interface ApplyState {
|
||||||
@@ -33,6 +37,7 @@ interface ApplyState {
|
|||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
priority: boolean;
|
priority: boolean;
|
||||||
skipTests: boolean;
|
skipTests: boolean;
|
||||||
|
branchName: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMixedValues(features: Feature[]): Record<string, boolean> {
|
function getMixedValues(features: Feature[]): Record<string, boolean> {
|
||||||
@@ -47,6 +52,7 @@ function getMixedValues(features: Feature[]): Record<string, boolean> {
|
|||||||
),
|
),
|
||||||
priority: !features.every((f) => f.priority === first.priority),
|
priority: !features.every((f) => f.priority === first.priority),
|
||||||
skipTests: !features.every((f) => f.skipTests === first.skipTests),
|
skipTests: !features.every((f) => f.skipTests === first.skipTests),
|
||||||
|
branchName: !features.every((f) => f.branchName === first.branchName),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +103,15 @@ function FieldWrapper({ label, isMixed, willApply, onApplyChange, children }: Fi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: MassEditDialogProps) {
|
export function MassEditDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
selectedFeatures,
|
||||||
|
onApply,
|
||||||
|
branchSuggestions,
|
||||||
|
branchCardCounts,
|
||||||
|
currentBranch,
|
||||||
|
}: MassEditDialogProps) {
|
||||||
const [isApplying, setIsApplying] = useState(false);
|
const [isApplying, setIsApplying] = useState(false);
|
||||||
|
|
||||||
// Track which fields to apply
|
// Track which fields to apply
|
||||||
@@ -108,6 +122,7 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
|
|||||||
requirePlanApproval: false,
|
requirePlanApproval: false,
|
||||||
priority: false,
|
priority: false,
|
||||||
skipTests: false,
|
skipTests: false,
|
||||||
|
branchName: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Field values
|
// Field values
|
||||||
@@ -118,6 +133,18 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
|
|||||||
const [priority, setPriority] = useState(2);
|
const [priority, setPriority] = useState(2);
|
||||||
const [skipTests, setSkipTests] = useState(false);
|
const [skipTests, setSkipTests] = useState(false);
|
||||||
|
|
||||||
|
// Work mode and branch name state
|
||||||
|
const [workMode, setWorkMode] = useState<WorkMode>(() => {
|
||||||
|
// Derive initial work mode from first selected feature's branchName
|
||||||
|
if (selectedFeatures.length > 0 && selectedFeatures[0].branchName) {
|
||||||
|
return 'custom';
|
||||||
|
}
|
||||||
|
return 'current';
|
||||||
|
});
|
||||||
|
const [branchName, setBranchName] = useState(() => {
|
||||||
|
return getInitialValue(selectedFeatures, 'branchName', '') as string;
|
||||||
|
});
|
||||||
|
|
||||||
// Calculate mixed values
|
// Calculate mixed values
|
||||||
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
|
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
|
||||||
|
|
||||||
@@ -131,6 +158,7 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
|
|||||||
requirePlanApproval: false,
|
requirePlanApproval: false,
|
||||||
priority: false,
|
priority: false,
|
||||||
skipTests: false,
|
skipTests: false,
|
||||||
|
branchName: false,
|
||||||
});
|
});
|
||||||
setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias);
|
setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias);
|
||||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
||||||
@@ -138,6 +166,10 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
|
|||||||
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
|
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
|
||||||
setPriority(getInitialValue(selectedFeatures, 'priority', 2));
|
setPriority(getInitialValue(selectedFeatures, 'priority', 2));
|
||||||
setSkipTests(getInitialValue(selectedFeatures, 'skipTests', false));
|
setSkipTests(getInitialValue(selectedFeatures, 'skipTests', false));
|
||||||
|
// Reset work mode and branch name
|
||||||
|
const initialBranchName = getInitialValue(selectedFeatures, 'branchName', '') as string;
|
||||||
|
setBranchName(initialBranchName);
|
||||||
|
setWorkMode(initialBranchName ? 'custom' : 'current');
|
||||||
}
|
}
|
||||||
}, [open, selectedFeatures]);
|
}, [open, selectedFeatures]);
|
||||||
|
|
||||||
@@ -150,6 +182,12 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
|
|||||||
if (applyState.requirePlanApproval) updates.requirePlanApproval = requirePlanApproval;
|
if (applyState.requirePlanApproval) updates.requirePlanApproval = requirePlanApproval;
|
||||||
if (applyState.priority) updates.priority = priority;
|
if (applyState.priority) updates.priority = priority;
|
||||||
if (applyState.skipTests) updates.skipTests = skipTests;
|
if (applyState.skipTests) updates.skipTests = skipTests;
|
||||||
|
if (applyState.branchName) {
|
||||||
|
// For 'current' mode, use empty string (work on current branch)
|
||||||
|
// For 'auto' mode, use empty string (will be auto-generated)
|
||||||
|
// For 'custom' mode, use the specified branch name
|
||||||
|
updates.branchName = workMode === 'custom' ? branchName : '';
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
if (Object.keys(updates).length === 0) {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -158,7 +196,7 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
|
|||||||
|
|
||||||
setIsApplying(true);
|
setIsApplying(true);
|
||||||
try {
|
try {
|
||||||
await onApply(updates);
|
await onApply(updates, workMode);
|
||||||
onClose();
|
onClose();
|
||||||
} finally {
|
} finally {
|
||||||
setIsApplying(false);
|
setIsApplying(false);
|
||||||
@@ -293,6 +331,25 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
|
|||||||
testIdPrefix="mass-edit"
|
testIdPrefix="mass-edit"
|
||||||
/>
|
/>
|
||||||
</FieldWrapper>
|
</FieldWrapper>
|
||||||
|
|
||||||
|
{/* Branch / Work Mode */}
|
||||||
|
<FieldWrapper
|
||||||
|
label="Branch / Work Mode"
|
||||||
|
isMixed={mixedValues.branchName}
|
||||||
|
willApply={applyState.branchName}
|
||||||
|
onApplyChange={(apply) => setApplyState((prev) => ({ ...prev, branchName: apply }))}
|
||||||
|
>
|
||||||
|
<WorkModeSelector
|
||||||
|
workMode={workMode}
|
||||||
|
onWorkModeChange={setWorkMode}
|
||||||
|
branchName={branchName}
|
||||||
|
onBranchNameChange={setBranchName}
|
||||||
|
branchSuggestions={branchSuggestions}
|
||||||
|
branchCardCounts={branchCardCounts}
|
||||||
|
currentBranch={currentBranch}
|
||||||
|
testIdPrefix="mass-edit-work-mode"
|
||||||
|
/>
|
||||||
|
</FieldWrapper>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function PlanSettingsDialog({
|
|||||||
className="text-sm font-medium cursor-pointer flex items-center gap-2"
|
className="text-sm font-medium cursor-pointer flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||||
Use selected worktree branch
|
Default to worktree mode
|
||||||
</Label>
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id="plan-worktree-branch-toggle"
|
id="plan-worktree-branch-toggle"
|
||||||
@@ -55,8 +55,8 @@ export function PlanSettingsDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
When enabled, features created via the Plan dialog will be assigned to the currently
|
Planned features will automatically use isolated worktrees, keeping changes separate
|
||||||
selected worktree branch. When disabled, features will be added to the main branch.
|
from your main branch until you're ready to merge.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function WorktreeSettingsDialog({
|
|||||||
className="text-sm font-medium cursor-pointer flex items-center gap-2"
|
className="text-sm font-medium cursor-pointer flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||||
Use selected worktree branch
|
Default to worktree mode
|
||||||
</Label>
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id="worktree-branch-toggle"
|
id="worktree-branch-toggle"
|
||||||
@@ -55,8 +55,8 @@ export function WorktreeSettingsDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
When enabled, the Add Feature dialog will default to custom branch mode with the
|
New features will automatically use isolated worktrees, keeping changes separate
|
||||||
currently selected worktree branch pre-filled.
|
from your main branch until you're ready to merge.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -102,7 +102,10 @@ export function useBoardEffects({
|
|||||||
const checkAllContexts = async () => {
|
const checkAllContexts = async () => {
|
||||||
const featuresWithPotentialContext = features.filter(
|
const featuresWithPotentialContext = features.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
f.status === 'in_progress' || f.status === 'waiting_approval' || f.status === 'verified'
|
f.status === 'in_progress' ||
|
||||||
|
f.status === 'waiting_approval' ||
|
||||||
|
f.status === 'verified' ||
|
||||||
|
(typeof f.status === 'string' && f.status.startsWith('pipeline_'))
|
||||||
);
|
);
|
||||||
const contextChecks = await Promise.all(
|
const contextChecks = await Promise.all(
|
||||||
featuresWithPotentialContext.map(async (f) => ({
|
featuresWithPotentialContext.map(async (f) => ({
|
||||||
|
|||||||
@@ -143,8 +143,12 @@ export function WorktreeActionsDropdown({
|
|||||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
Dev Server Running (:{devServerInfo?.port})
|
Dev Server Running (:{devServerInfo?.port})
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuItem onClick={() => onOpenDevServerUrl(worktree)} className="text-xs">
|
<DropdownMenuItem
|
||||||
<Globe className="w-3.5 h-3.5 mr-2" />
|
onClick={() => onOpenDevServerUrl(worktree)}
|
||||||
|
className="text-xs"
|
||||||
|
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
||||||
Open in Browser
|
Open in Browser
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -320,7 +324,7 @@ export function WorktreeActionsDropdown({
|
|||||||
<>
|
<>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.open(worktree.pr!.url, '_blank');
|
window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer');
|
||||||
}}
|
}}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -298,20 +298,29 @@ export function WorktreeTab({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isDevServerRunning && (
|
{isDevServerRunning && (
|
||||||
<Button
|
<TooltipProvider>
|
||||||
variant={isSelected ? 'default' : 'outline'}
|
<Tooltip>
|
||||||
size="sm"
|
<TooltipTrigger asChild>
|
||||||
className={cn(
|
<Button
|
||||||
'h-7 w-7 p-0 rounded-none border-r-0',
|
variant={isSelected ? 'default' : 'outline'}
|
||||||
isSelected && 'bg-primary text-primary-foreground',
|
size="sm"
|
||||||
!isSelected && 'bg-secondary/50 hover:bg-secondary',
|
className={cn(
|
||||||
'text-green-500'
|
'h-7 w-7 p-0 rounded-none border-r-0',
|
||||||
)}
|
isSelected && 'bg-primary text-primary-foreground',
|
||||||
onClick={() => onOpenDevServerUrl(worktree)}
|
!isSelected && 'bg-secondary/50 hover:bg-secondary',
|
||||||
title={`Open dev server (port ${devServerInfo?.port})`}
|
'text-green-500'
|
||||||
>
|
)}
|
||||||
<Globe className="w-3 h-3" />
|
onClick={() => onOpenDevServerUrl(worktree)}
|
||||||
</Button>
|
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
|
||||||
|
>
|
||||||
|
<Globe className="w-3 h-3" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Open dev server (:{devServerInfo?.port})</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<WorktreeActionsDropdown
|
<WorktreeActionsDropdown
|
||||||
|
|||||||
@@ -118,8 +118,37 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
const handleOpenDevServerUrl = useCallback(
|
const handleOpenDevServerUrl = useCallback(
|
||||||
(worktree: WorktreeInfo) => {
|
(worktree: WorktreeInfo) => {
|
||||||
const serverInfo = runningDevServers.get(getWorktreeKey(worktree));
|
const serverInfo = runningDevServers.get(getWorktreeKey(worktree));
|
||||||
if (serverInfo) {
|
if (!serverInfo) {
|
||||||
window.open(serverInfo.url, '_blank');
|
logger.warn('No dev server info found for worktree:', getWorktreeKey(worktree));
|
||||||
|
toast.error('Dev server not found', {
|
||||||
|
description: 'The dev server may have stopped. Try starting it again.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Rewrite URL hostname to match the current browser's hostname.
|
||||||
|
// This ensures dev server URLs work when accessing Automaker from
|
||||||
|
// remote machines (e.g., 192.168.x.x or hostname.local instead of localhost).
|
||||||
|
const devServerUrl = new URL(serverInfo.url);
|
||||||
|
|
||||||
|
// Security: Only allow http/https protocols to prevent potential attacks
|
||||||
|
// via data:, javascript:, file:, or other dangerous URL schemes
|
||||||
|
if (devServerUrl.protocol !== 'http:' && devServerUrl.protocol !== 'https:') {
|
||||||
|
logger.error('Invalid dev server URL protocol:', devServerUrl.protocol);
|
||||||
|
toast.error('Invalid dev server URL', {
|
||||||
|
description: 'The server returned an unsupported URL protocol.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
devServerUrl.hostname = window.location.hostname;
|
||||||
|
window.open(devServerUrl.toString(), '_blank', 'noopener,noreferrer');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to parse dev server URL:', error);
|
||||||
|
toast.error('Failed to open dev server', {
|
||||||
|
description: 'The server URL could not be processed. Please try again.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[runningDevServers, getWorktreeKey]
|
[runningDevServers, getWorktreeKey]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
IssueValidationEvent,
|
IssueValidationEvent,
|
||||||
StoredValidation,
|
StoredValidation,
|
||||||
} from '@/lib/electron';
|
} from '@/lib/electron';
|
||||||
import type { LinkedPRInfo, PhaseModelEntry, ModelAlias, CursorModelId } from '@automaker/types';
|
import type { LinkedPRInfo, PhaseModelEntry, ModelId } from '@automaker/types';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { isValidationStale } from '../utils';
|
import { isValidationStale } from '../utils';
|
||||||
@@ -19,12 +19,10 @@ const logger = createLogger('IssueValidation');
|
|||||||
/**
|
/**
|
||||||
* Extract model string from PhaseModelEntry or string (handles both formats)
|
* Extract model string from PhaseModelEntry or string (handles both formats)
|
||||||
*/
|
*/
|
||||||
function extractModel(
|
function extractModel(entry: PhaseModelEntry | string | undefined): ModelId | undefined {
|
||||||
entry: PhaseModelEntry | string | undefined
|
|
||||||
): ModelAlias | CursorModelId | undefined {
|
|
||||||
if (!entry) return undefined;
|
if (!entry) return undefined;
|
||||||
if (typeof entry === 'string') {
|
if (typeof entry === 'string') {
|
||||||
return entry as ModelAlias | CursorModelId;
|
return entry as ModelId;
|
||||||
}
|
}
|
||||||
return entry.model;
|
return entry.model;
|
||||||
}
|
}
|
||||||
@@ -228,8 +226,8 @@ export function useIssueValidation({
|
|||||||
issue: GitHubIssue,
|
issue: GitHubIssue,
|
||||||
options: {
|
options: {
|
||||||
forceRevalidate?: boolean;
|
forceRevalidate?: boolean;
|
||||||
model?: string | PhaseModelEntry; // Accept either string (backward compat) or PhaseModelEntry
|
model?: ModelId | PhaseModelEntry; // Accept either model ID (backward compat) or PhaseModelEntry
|
||||||
modelEntry?: PhaseModelEntry; // New preferred way to pass model with thinking level
|
modelEntry?: PhaseModelEntry; // New preferred way to pass model with thinking/reasoning
|
||||||
comments?: GitHubComment[];
|
comments?: GitHubComment[];
|
||||||
linkedPRs?: LinkedPRInfo[];
|
linkedPRs?: LinkedPRInfo[];
|
||||||
} = {}
|
} = {}
|
||||||
@@ -267,15 +265,16 @@ export function useIssueValidation({
|
|||||||
? modelEntry
|
? modelEntry
|
||||||
: model
|
: model
|
||||||
? typeof model === 'string'
|
? typeof model === 'string'
|
||||||
? { model: model as ModelAlias | CursorModelId }
|
? { model: model as ModelId }
|
||||||
: model
|
: model
|
||||||
: phaseModels.validationModel;
|
: phaseModels.validationModel;
|
||||||
const normalizedEntry =
|
const normalizedEntry =
|
||||||
typeof effectiveModelEntry === 'string'
|
typeof effectiveModelEntry === 'string'
|
||||||
? { model: effectiveModelEntry as ModelAlias | CursorModelId }
|
? { model: effectiveModelEntry as ModelId }
|
||||||
: effectiveModelEntry;
|
: effectiveModelEntry;
|
||||||
const modelToUse = normalizedEntry.model;
|
const modelToUse = normalizedEntry.model;
|
||||||
const thinkingLevelToUse = normalizedEntry.thinkingLevel;
|
const thinkingLevelToUse = normalizedEntry.thinkingLevel;
|
||||||
|
const reasoningEffortToUse = normalizedEntry.reasoningEffort;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
@@ -292,7 +291,8 @@ export function useIssueValidation({
|
|||||||
currentProject.path,
|
currentProject.path,
|
||||||
validationInput,
|
validationInput,
|
||||||
modelToUse,
|
modelToUse,
|
||||||
thinkingLevelToUse
|
thinkingLevelToUse,
|
||||||
|
reasoningEffortToUse
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
|
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
|
||||||
import type { ModelAlias, CursorModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types';
|
import type { ModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types';
|
||||||
|
|
||||||
export interface IssueRowProps {
|
export interface IssueRowProps {
|
||||||
issue: GitHubIssue;
|
issue: GitHubIssue;
|
||||||
@@ -37,7 +37,7 @@ export interface IssueDetailPanelProps {
|
|||||||
/** Model override state */
|
/** Model override state */
|
||||||
modelOverride: {
|
modelOverride: {
|
||||||
effectiveModelEntry: PhaseModelEntry;
|
effectiveModelEntry: PhaseModelEntry;
|
||||||
effectiveModel: ModelAlias | CursorModelId;
|
effectiveModel: ModelId;
|
||||||
isOverridden: boolean;
|
isOverridden: boolean;
|
||||||
setOverride: (entry: PhaseModelEntry | null) => void;
|
setOverride: (entry: PhaseModelEntry | null) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useSearch } from '@tanstack/react-router';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
|
||||||
|
|
||||||
import { useSettingsView, type SettingsViewId } from './settings-view/hooks';
|
import { useSettingsView, type SettingsViewId } from './settings-view/hooks';
|
||||||
import { NAV_ITEMS } from './settings-view/config/navigation';
|
import { NAV_ITEMS } from './settings-view/config/navigation';
|
||||||
@@ -51,6 +51,8 @@ export function SettingsView() {
|
|||||||
setDefaultPlanningMode,
|
setDefaultPlanningMode,
|
||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
setDefaultRequirePlanApproval,
|
setDefaultRequirePlanApproval,
|
||||||
|
defaultFeatureModel,
|
||||||
|
setDefaultFeatureModel,
|
||||||
autoLoadClaudeMd,
|
autoLoadClaudeMd,
|
||||||
setAutoLoadClaudeMd,
|
setAutoLoadClaudeMd,
|
||||||
promptCustomization,
|
promptCustomization,
|
||||||
@@ -88,8 +90,11 @@ export function SettingsView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get initial view from URL search params
|
||||||
|
const { view: initialView } = useSearch({ from: '/settings' });
|
||||||
|
|
||||||
// Use settings view navigation hook
|
// Use settings view navigation hook
|
||||||
const { activeView, navigateTo } = useSettingsView();
|
const { activeView, navigateTo } = useSettingsView({ initialView });
|
||||||
|
|
||||||
// Handle navigation - if navigating to 'providers', default to 'claude-provider'
|
// Handle navigation - if navigating to 'providers', default to 'claude-provider'
|
||||||
const handleNavigate = (viewId: SettingsViewId) => {
|
const handleNavigate = (viewId: SettingsViewId) => {
|
||||||
@@ -154,11 +159,13 @@ export function SettingsView() {
|
|||||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||||
defaultPlanningMode={defaultPlanningMode}
|
defaultPlanningMode={defaultPlanningMode}
|
||||||
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||||
|
defaultFeatureModel={defaultFeatureModel}
|
||||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||||
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
|
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
|
||||||
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
||||||
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||||
|
onDefaultFeatureModelChange={setDefaultFeatureModel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'worktrees':
|
case 'worktrees':
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
|
|||||||
{
|
{
|
||||||
label: 'Model & Prompts',
|
label: 'Model & Prompts',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
|
|
||||||
{ id: 'defaults', label: 'Feature Defaults', icon: FlaskConical },
|
{ id: 'defaults', label: 'Feature Defaults', icon: FlaskConical },
|
||||||
|
{ id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
|
||||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||||
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
|
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
|
||||||
{ id: 'api-keys', label: 'API Keys', icon: Key },
|
{ id: 'api-keys', label: 'API Keys', icon: Key },
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ScrollText,
|
ScrollText,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
FastForward,
|
FastForward,
|
||||||
|
Cpu,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +20,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import type { PhaseModelEntry } from '@automaker/types';
|
||||||
|
import { PhaseModelSelector } from '../model-defaults/phase-model-selector';
|
||||||
|
|
||||||
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||||
|
|
||||||
@@ -28,11 +31,13 @@ interface FeatureDefaultsSectionProps {
|
|||||||
skipVerificationInAutoMode: boolean;
|
skipVerificationInAutoMode: boolean;
|
||||||
defaultPlanningMode: PlanningMode;
|
defaultPlanningMode: PlanningMode;
|
||||||
defaultRequirePlanApproval: boolean;
|
defaultRequirePlanApproval: boolean;
|
||||||
|
defaultFeatureModel: PhaseModelEntry;
|
||||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||||
onEnableDependencyBlockingChange: (value: boolean) => void;
|
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||||
onSkipVerificationInAutoModeChange: (value: boolean) => void;
|
onSkipVerificationInAutoModeChange: (value: boolean) => void;
|
||||||
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
||||||
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
||||||
|
onDefaultFeatureModelChange: (value: PhaseModelEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeatureDefaultsSection({
|
export function FeatureDefaultsSection({
|
||||||
@@ -41,11 +46,13 @@ export function FeatureDefaultsSection({
|
|||||||
skipVerificationInAutoMode,
|
skipVerificationInAutoMode,
|
||||||
defaultPlanningMode,
|
defaultPlanningMode,
|
||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
|
defaultFeatureModel,
|
||||||
onDefaultSkipTestsChange,
|
onDefaultSkipTestsChange,
|
||||||
onEnableDependencyBlockingChange,
|
onEnableDependencyBlockingChange,
|
||||||
onSkipVerificationInAutoModeChange,
|
onSkipVerificationInAutoModeChange,
|
||||||
onDefaultPlanningModeChange,
|
onDefaultPlanningModeChange,
|
||||||
onDefaultRequirePlanApprovalChange,
|
onDefaultRequirePlanApprovalChange,
|
||||||
|
onDefaultFeatureModelChange,
|
||||||
}: FeatureDefaultsSectionProps) {
|
}: FeatureDefaultsSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -68,6 +75,30 @@ export function FeatureDefaultsSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-5">
|
<div className="p-6 space-y-5">
|
||||||
|
{/* Default Feature Model Setting */}
|
||||||
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
|
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-brand-500/10">
|
||||||
|
<Cpu className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-foreground font-medium">Default Model</Label>
|
||||||
|
<PhaseModelSelector
|
||||||
|
value={defaultFeatureModel}
|
||||||
|
onChange={onDefaultFeatureModelChange}
|
||||||
|
compact
|
||||||
|
align="end"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
The default AI model and thinking level used when creating new feature cards.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
{/* Planning Mode Default */}
|
{/* Planning Mode Default */}
|
||||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
<div
|
<div
|
||||||
@@ -165,12 +196,11 @@ export function FeatureDefaultsSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-border/30" />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
{defaultPlanningMode === 'skip' && <div className="border-t border-border/30" />}
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
{/* Automated Testing Setting */}
|
{/* Automated Testing Setting */}
|
||||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function AddEditServerDialog({
|
|||||||
Configure an MCP server to extend agent capabilities with custom tools.
|
Configure an MCP server to extend agent capabilities with custom tools.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4 overflow-y-auto">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="server-name">Name</Label>
|
<Label htmlFor="server-name">Name</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function CreateSpecDialog({
|
|||||||
<DialogDescription className="text-muted-foreground">{description}</DialogDescription>
|
<DialogDescription className="text-muted-foreground">{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4 overflow-y-auto">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Project Overview</label>
|
<label className="text-sm font-medium">Project Overview</label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function RegenerateSpecDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4 overflow-y-auto">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Describe your project</label>
|
<label className="text-sm font-medium">Describe your project</label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -564,6 +564,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
useWorktrees: settings.useWorktrees ?? true,
|
useWorktrees: settings.useWorktrees ?? true,
|
||||||
defaultPlanningMode: settings.defaultPlanningMode ?? 'skip',
|
defaultPlanningMode: settings.defaultPlanningMode ?? 'skip',
|
||||||
defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false,
|
defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false,
|
||||||
|
defaultFeatureModel: settings.defaultFeatureModel ?? { model: 'opus' },
|
||||||
muteDoneSound: settings.muteDoneSound ?? false,
|
muteDoneSound: settings.muteDoneSound ?? false,
|
||||||
enhancementModel: settings.enhancementModel ?? 'sonnet',
|
enhancementModel: settings.enhancementModel ?? 'sonnet',
|
||||||
validationModel: settings.validationModel ?? 'opus',
|
validationModel: settings.validationModel ?? 'opus',
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'useWorktrees',
|
'useWorktrees',
|
||||||
'defaultPlanningMode',
|
'defaultPlanningMode',
|
||||||
'defaultRequirePlanApproval',
|
'defaultRequirePlanApproval',
|
||||||
|
'defaultFeatureModel',
|
||||||
'muteDoneSound',
|
'muteDoneSound',
|
||||||
'enhancementModel',
|
'enhancementModel',
|
||||||
'validationModel',
|
'validationModel',
|
||||||
@@ -466,6 +467,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
useWorktrees: serverSettings.useWorktrees,
|
useWorktrees: serverSettings.useWorktrees,
|
||||||
defaultPlanningMode: serverSettings.defaultPlanningMode,
|
defaultPlanningMode: serverSettings.defaultPlanningMode,
|
||||||
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,
|
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,
|
||||||
|
defaultFeatureModel: serverSettings.defaultFeatureModel ?? { model: 'opus' },
|
||||||
muteDoneSound: serverSettings.muteDoneSound,
|
muteDoneSound: serverSettings.muteDoneSound,
|
||||||
enhancementModel: serverSettings.enhancementModel,
|
enhancementModel: serverSettings.enhancementModel,
|
||||||
validationModel: serverSettings.validationModel,
|
validationModel: serverSettings.validationModel,
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import type {
|
|||||||
IssueValidationResponse,
|
IssueValidationResponse,
|
||||||
IssueValidationEvent,
|
IssueValidationEvent,
|
||||||
StoredValidation,
|
StoredValidation,
|
||||||
AgentModel,
|
ModelId,
|
||||||
|
ThinkingLevel,
|
||||||
|
ReasoningEffort,
|
||||||
GitHubComment,
|
GitHubComment,
|
||||||
IssueCommentsResult,
|
IssueCommentsResult,
|
||||||
Idea,
|
Idea,
|
||||||
@@ -314,7 +316,9 @@ export interface GitHubAPI {
|
|||||||
validateIssue: (
|
validateIssue: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
issue: IssueValidationInput,
|
issue: IssueValidationInput,
|
||||||
model?: AgentModel
|
model?: ModelId,
|
||||||
|
thinkingLevel?: ThinkingLevel,
|
||||||
|
reasoningEffort?: ReasoningEffort
|
||||||
) => Promise<{ success: boolean; message?: string; issueNumber?: number; error?: string }>;
|
) => Promise<{ success: boolean; message?: string; issueNumber?: number; error?: string }>;
|
||||||
/** Check validation status for an issue or all issues */
|
/** Check validation status for an issue or all issues */
|
||||||
getValidationStatus: (
|
getValidationStatus: (
|
||||||
@@ -1294,6 +1298,7 @@ interface SetupAPI {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
hasAnthropicKey: boolean;
|
hasAnthropicKey: boolean;
|
||||||
hasGoogleKey: boolean;
|
hasGoogleKey: boolean;
|
||||||
|
hasOpenaiKey: boolean;
|
||||||
}>;
|
}>;
|
||||||
deleteApiKey: (
|
deleteApiKey: (
|
||||||
provider: string
|
provider: string
|
||||||
@@ -1377,6 +1382,7 @@ function createMockSetupAPI(): SetupAPI {
|
|||||||
success: true,
|
success: true,
|
||||||
hasAnthropicKey: false,
|
hasAnthropicKey: false,
|
||||||
hasGoogleKey: false,
|
hasGoogleKey: false,
|
||||||
|
hasOpenaiKey: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -3008,8 +3014,20 @@ function createMockGitHubAPI(): GitHubAPI {
|
|||||||
mergedPRs: [],
|
mergedPRs: [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
validateIssue: async (projectPath: string, issue: IssueValidationInput, model?: AgentModel) => {
|
validateIssue: async (
|
||||||
console.log('[Mock] Starting async validation:', { projectPath, issue, model });
|
projectPath: string,
|
||||||
|
issue: IssueValidationInput,
|
||||||
|
model?: ModelId,
|
||||||
|
thinkingLevel?: ThinkingLevel,
|
||||||
|
reasoningEffort?: ReasoningEffort
|
||||||
|
) => {
|
||||||
|
console.log('[Mock] Starting async validation:', {
|
||||||
|
projectPath,
|
||||||
|
issue,
|
||||||
|
model,
|
||||||
|
thinkingLevel,
|
||||||
|
reasoningEffort,
|
||||||
|
});
|
||||||
|
|
||||||
// Simulate async validation in background
|
// Simulate async validation in background
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import type {
|
|||||||
import type { Message, SessionListItem } from '@/types/electron';
|
import type { Message, SessionListItem } from '@/types/electron';
|
||||||
import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
|
import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
|
||||||
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
|
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
|
||||||
|
import type { ModelId, ThinkingLevel, ReasoningEffort } from '@automaker/types';
|
||||||
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
|
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
|
||||||
|
|
||||||
const logger = createLogger('HttpClient');
|
const logger = createLogger('HttpClient');
|
||||||
@@ -1173,6 +1174,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
hasAnthropicKey: boolean;
|
hasAnthropicKey: boolean;
|
||||||
hasGoogleKey: boolean;
|
hasGoogleKey: boolean;
|
||||||
|
hasOpenaiKey: boolean;
|
||||||
}> => this.get('/api/setup/api-keys'),
|
}> => this.get('/api/setup/api-keys'),
|
||||||
|
|
||||||
getPlatform: (): Promise<{
|
getPlatform: (): Promise<{
|
||||||
@@ -1838,9 +1840,17 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
validateIssue: (
|
validateIssue: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
issue: IssueValidationInput,
|
issue: IssueValidationInput,
|
||||||
model?: string,
|
model?: ModelId,
|
||||||
thinkingLevel?: string
|
thinkingLevel?: ThinkingLevel,
|
||||||
) => this.post('/api/github/validate-issue', { projectPath, ...issue, model, thinkingLevel }),
|
reasoningEffort?: ReasoningEffort
|
||||||
|
) =>
|
||||||
|
this.post('/api/github/validate-issue', {
|
||||||
|
projectPath,
|
||||||
|
...issue,
|
||||||
|
model,
|
||||||
|
thinkingLevel,
|
||||||
|
reasoningEffort,
|
||||||
|
}),
|
||||||
getValidationStatus: (projectPath: string, issueNumber?: number) =>
|
getValidationStatus: (projectPath: string, issueNumber?: number) =>
|
||||||
this.post('/api/github/validation-status', { projectPath, issueNumber }),
|
this.post('/api/github/validation-status', { projectPath, issueNumber }),
|
||||||
stopValidation: (projectPath: string, issueNumber: number) =>
|
stopValidation: (projectPath: string, issueNumber: number) =>
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { SettingsView } from '@/components/views/settings-view';
|
import { SettingsView } from '@/components/views/settings-view';
|
||||||
|
import type { SettingsViewId } from '@/components/views/settings-view/hooks';
|
||||||
|
|
||||||
|
interface SettingsSearchParams {
|
||||||
|
view?: SettingsViewId;
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/settings')({
|
export const Route = createFileRoute('/settings')({
|
||||||
component: SettingsView,
|
component: SettingsView,
|
||||||
|
validateSearch: (search: Record<string, unknown>): SettingsSearchParams => {
|
||||||
|
return {
|
||||||
|
view: search.view as SettingsViewId | undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -657,6 +657,7 @@ export interface AppState {
|
|||||||
|
|
||||||
defaultPlanningMode: PlanningMode;
|
defaultPlanningMode: PlanningMode;
|
||||||
defaultRequirePlanApproval: boolean;
|
defaultRequirePlanApproval: boolean;
|
||||||
|
defaultFeatureModel: PhaseModelEntry;
|
||||||
|
|
||||||
// Plan Approval State
|
// Plan Approval State
|
||||||
// When a plan requires user approval, this holds the pending approval details
|
// When a plan requires user approval, this holds the pending approval details
|
||||||
@@ -689,6 +690,7 @@ export interface AppState {
|
|||||||
codexModelsLoading: boolean;
|
codexModelsLoading: boolean;
|
||||||
codexModelsError: string | null;
|
codexModelsError: string | null;
|
||||||
codexModelsLastFetched: number | null;
|
codexModelsLastFetched: number | null;
|
||||||
|
codexModelsLastFailedAt: number | null;
|
||||||
|
|
||||||
// Pipeline Configuration (per-project, keyed by project path)
|
// Pipeline Configuration (per-project, keyed by project path)
|
||||||
pipelineConfigByProject: Record<string, PipelineConfig>;
|
pipelineConfigByProject: Record<string, PipelineConfig>;
|
||||||
@@ -1106,6 +1108,7 @@ export interface AppActions {
|
|||||||
|
|
||||||
setDefaultPlanningMode: (mode: PlanningMode) => void;
|
setDefaultPlanningMode: (mode: PlanningMode) => void;
|
||||||
setDefaultRequirePlanApproval: (require: boolean) => void;
|
setDefaultRequirePlanApproval: (require: boolean) => void;
|
||||||
|
setDefaultFeatureModel: (entry: PhaseModelEntry) => void;
|
||||||
|
|
||||||
// Plan Approval actions
|
// Plan Approval actions
|
||||||
setPendingPlanApproval: (
|
setPendingPlanApproval: (
|
||||||
@@ -1279,6 +1282,7 @@ const initialState: AppState = {
|
|||||||
specCreatingForProject: null,
|
specCreatingForProject: null,
|
||||||
defaultPlanningMode: 'skip' as PlanningMode,
|
defaultPlanningMode: 'skip' as PlanningMode,
|
||||||
defaultRequirePlanApproval: false,
|
defaultRequirePlanApproval: false,
|
||||||
|
defaultFeatureModel: { model: 'opus' } as PhaseModelEntry,
|
||||||
pendingPlanApproval: null,
|
pendingPlanApproval: null,
|
||||||
claudeRefreshInterval: 60,
|
claudeRefreshInterval: 60,
|
||||||
claudeUsage: null,
|
claudeUsage: null,
|
||||||
@@ -1289,6 +1293,7 @@ const initialState: AppState = {
|
|||||||
codexModelsLoading: false,
|
codexModelsLoading: false,
|
||||||
codexModelsError: null,
|
codexModelsError: null,
|
||||||
codexModelsLastFetched: null,
|
codexModelsLastFetched: null,
|
||||||
|
codexModelsLastFailedAt: null,
|
||||||
pipelineConfigByProject: {},
|
pipelineConfigByProject: {},
|
||||||
worktreePanelVisibleByProject: {},
|
worktreePanelVisibleByProject: {},
|
||||||
showInitScriptIndicatorByProject: {},
|
showInitScriptIndicatorByProject: {},
|
||||||
@@ -3145,6 +3150,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
|
|
||||||
setDefaultPlanningMode: (mode) => set({ defaultPlanningMode: mode }),
|
setDefaultPlanningMode: (mode) => set({ defaultPlanningMode: mode }),
|
||||||
setDefaultRequirePlanApproval: (require) => set({ defaultRequirePlanApproval: require }),
|
setDefaultRequirePlanApproval: (require) => set({ defaultRequirePlanApproval: require }),
|
||||||
|
setDefaultFeatureModel: (entry) => set({ defaultFeatureModel: entry }),
|
||||||
|
|
||||||
// Plan Approval actions
|
// Plan Approval actions
|
||||||
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
|
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
|
||||||
@@ -3167,13 +3173,29 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
|
|
||||||
// Codex Models actions
|
// Codex Models actions
|
||||||
fetchCodexModels: async (forceRefresh = false) => {
|
fetchCodexModels: async (forceRefresh = false) => {
|
||||||
const { codexModelsLastFetched, codexModelsLoading } = get();
|
const FAILURE_COOLDOWN_MS = 30 * 1000; // 30 seconds
|
||||||
|
const SUCCESS_CACHE_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
const { codexModelsLastFetched, codexModelsLoading, codexModelsLastFailedAt } = get();
|
||||||
|
|
||||||
// Skip if already loading
|
// Skip if already loading
|
||||||
if (codexModelsLoading) return;
|
if (codexModelsLoading) return;
|
||||||
|
|
||||||
// Skip if recently fetched (< 5 minutes ago) and not forcing refresh
|
// Skip if recently failed and not forcing refresh
|
||||||
if (!forceRefresh && codexModelsLastFetched && Date.now() - codexModelsLastFetched < 300000) {
|
if (
|
||||||
|
!forceRefresh &&
|
||||||
|
codexModelsLastFailedAt &&
|
||||||
|
Date.now() - codexModelsLastFailedAt < FAILURE_COOLDOWN_MS
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if recently fetched successfully and not forcing refresh
|
||||||
|
if (
|
||||||
|
!forceRefresh &&
|
||||||
|
codexModelsLastFetched &&
|
||||||
|
Date.now() - codexModelsLastFetched < SUCCESS_CACHE_MS
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3196,12 +3218,14 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
codexModelsLastFetched: Date.now(),
|
codexModelsLastFetched: Date.now(),
|
||||||
codexModelsLoading: false,
|
codexModelsLoading: false,
|
||||||
codexModelsError: null,
|
codexModelsError: null,
|
||||||
|
codexModelsLastFailedAt: null, // Clear failure on success
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
set({
|
set({
|
||||||
codexModelsError: errorMessage,
|
codexModelsError: errorMessage,
|
||||||
codexModelsLoading: false,
|
codexModelsLoading: false,
|
||||||
|
codexModelsLastFailedAt: Date.now(), // Record failure time for cooldown
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -123,8 +123,17 @@ test.describe('Feature Manual Review Flow', () => {
|
|||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Verify we're on the correct project
|
// Expand sidebar if collapsed to see project name
|
||||||
await expect(page.getByText(projectName).first()).toBeVisible({ timeout: 10000 });
|
const expandSidebarButton = page.locator('button:has-text("Expand sidebar")');
|
||||||
|
if (await expandSidebarButton.isVisible()) {
|
||||||
|
await expandSidebarButton.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we're on the correct project (project name appears in sidebar button)
|
||||||
|
await expect(page.getByRole('button', { name: new RegExp(projectName) })).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
// Create the feature via HTTP API (writes to disk)
|
// Create the feature via HTTP API (writes to disk)
|
||||||
const feature = {
|
const feature = {
|
||||||
|
|||||||
@@ -33,27 +33,29 @@ test.describe('Project Creation', () => {
|
|||||||
const projectName = `test-project-${Date.now()}`;
|
const projectName = `test-project-${Date.now()}`;
|
||||||
|
|
||||||
await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR });
|
await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR });
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
// Intercept settings API to ensure it doesn't return a currentProjectId
|
// Intercept settings API BEFORE authenticateForTests (which navigates to the page)
|
||||||
// This prevents settings hydration from restoring a project
|
// This prevents settings hydration from restoring a project and disables auto-open
|
||||||
await page.route('**/api/settings/global', async (route) => {
|
await page.route('**/api/settings/global', async (route) => {
|
||||||
const response = await route.fetch();
|
const response = await route.fetch();
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
// Remove currentProjectId to prevent restoring a project
|
// Remove currentProjectId and clear projects to prevent auto-open
|
||||||
if (json.settings) {
|
if (json.settings) {
|
||||||
json.settings.currentProjectId = null;
|
json.settings.currentProjectId = null;
|
||||||
|
json.settings.projects = [];
|
||||||
}
|
}
|
||||||
await route.fulfill({ response, json });
|
await route.fulfill({ response, json });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Navigate to root
|
await authenticateForTests(page);
|
||||||
await page.goto('/');
|
|
||||||
|
// Navigate directly to dashboard to avoid auto-open logic
|
||||||
|
await page.goto('/dashboard');
|
||||||
await page.waitForLoadState('load');
|
await page.waitForLoadState('load');
|
||||||
await handleLoginScreenIfPresent(page);
|
await handleLoginScreenIfPresent(page);
|
||||||
|
|
||||||
// Wait for welcome view to be visible
|
// Wait for dashboard view
|
||||||
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 15000 });
|
await expect(page.locator('[data-testid="dashboard-view"]')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
await page.locator('[data-testid="create-new-project"]').click();
|
await page.locator('[data-testid="create-new-project"]').click();
|
||||||
await page.locator('[data-testid="quick-setup-option"]').click();
|
await page.locator('[data-testid="quick-setup-option"]').click();
|
||||||
@@ -67,10 +69,18 @@ test.describe('Project Creation', () => {
|
|||||||
|
|
||||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
|
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Expand sidebar if collapsed to see project name
|
||||||
|
const expandSidebarButton = page.locator('button:has-text("Expand sidebar")');
|
||||||
|
if (await expandSidebarButton.isVisible()) {
|
||||||
|
await expandSidebarButton.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for project to be set as current and visible on the page
|
// Wait for project to be set as current and visible on the page
|
||||||
// The project name appears in multiple places: project-selector, board header paragraph, etc.
|
// The project name appears in the sidebar project selector button
|
||||||
// Check any element containing the project name
|
await expect(page.getByRole('button', { name: new RegExp(projectName) })).toBeVisible({
|
||||||
await expect(page.getByText(projectName).first()).toBeVisible({ timeout: 15000 });
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
// Project was created successfully if we're on board view with project name visible
|
// Project was created successfully if we're on board view with project name visible
|
||||||
// Note: The actual project directory is created in the server's default workspace,
|
// Note: The actual project directory is created in the server's default workspace,
|
||||||
|
|||||||
@@ -113,12 +113,13 @@ test.describe('Open Project', () => {
|
|||||||
|
|
||||||
// Now navigate to the app
|
// Now navigate to the app
|
||||||
await authenticateForTests(page);
|
await authenticateForTests(page);
|
||||||
await page.goto('/');
|
// Navigate directly to dashboard to avoid auto-open which would bypass the project selection
|
||||||
|
await page.goto('/dashboard');
|
||||||
await page.waitForLoadState('load');
|
await page.waitForLoadState('load');
|
||||||
await handleLoginScreenIfPresent(page);
|
await handleLoginScreenIfPresent(page);
|
||||||
|
|
||||||
// Wait for welcome view to be visible
|
// Wait for dashboard view
|
||||||
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 15000 });
|
await expect(page.locator('[data-testid="dashboard-view"]')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
// Verify we see the "Recent Projects" section
|
// Verify we see the "Recent Projects" section
|
||||||
await expect(page.getByText('Recent Projects')).toBeVisible({ timeout: 5000 });
|
await expect(page.getByText('Recent Projects')).toBeVisible({ timeout: 5000 });
|
||||||
@@ -135,7 +136,7 @@ test.describe('Open Project', () => {
|
|||||||
if (!isOurProjectVisible) {
|
if (!isOurProjectVisible) {
|
||||||
// Our project isn't visible - use the first available recent project card instead
|
// Our project isn't visible - use the first available recent project card instead
|
||||||
// This tests the "open recent project" flow even if our specific project didn't get injected
|
// This tests the "open recent project" flow even if our specific project didn't get injected
|
||||||
const firstProjectCard = page.locator('[data-testid^="recent-project-"]').first();
|
const firstProjectCard = page.locator('[data-testid^="project-card-"]').first();
|
||||||
await expect(firstProjectCard).toBeVisible({ timeout: 5000 });
|
await expect(firstProjectCard).toBeVisible({ timeout: 5000 });
|
||||||
// Get the project name from the card to verify later
|
// Get the project name from the card to verify later
|
||||||
targetProjectName = (await firstProjectCard.locator('p').first().textContent()) || '';
|
targetProjectName = (await firstProjectCard.locator('p').first().textContent()) || '';
|
||||||
@@ -147,10 +148,19 @@ test.describe('Open Project', () => {
|
|||||||
// Wait for the board view to appear (project was opened)
|
// Wait for the board view to appear (project was opened)
|
||||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
|
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Expand sidebar if collapsed to see project name
|
||||||
|
const expandSidebarButton = page.locator('button:has-text("Expand sidebar")');
|
||||||
|
if (await expandSidebarButton.isVisible()) {
|
||||||
|
await expandSidebarButton.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for a project to be set as current and visible on the page
|
// Wait for a project to be set as current and visible on the page
|
||||||
// The project name appears in multiple places: project-selector, board header paragraph, etc.
|
// The project name appears in the sidebar project selector button
|
||||||
if (targetProjectName) {
|
if (targetProjectName) {
|
||||||
await expect(page.getByText(targetProjectName).first()).toBeVisible({ timeout: 15000 });
|
await expect(page.getByRole('button', { name: new RegExp(targetProjectName) })).toBeVisible({
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only verify filesystem if we opened our specific test project
|
// Only verify filesystem if we opened our specific test project
|
||||||
|
|||||||
@@ -93,7 +93,9 @@ test.describe('Settings startup sync race', () => {
|
|||||||
|
|
||||||
// App should eventually render a main view after settings hydration.
|
// App should eventually render a main view after settings hydration.
|
||||||
await page
|
await page
|
||||||
.locator('[data-testid="welcome-view"], [data-testid="board-view"]')
|
.locator(
|
||||||
|
'[data-testid="welcome-view"], [data-testid="dashboard-view"], [data-testid="board-view"]'
|
||||||
|
)
|
||||||
.first()
|
.first()
|
||||||
.waitFor({ state: 'visible', timeout: 30000 });
|
.waitFor({ state: 'visible', timeout: 30000 });
|
||||||
|
|
||||||
@@ -112,7 +114,9 @@ test.describe('Settings startup sync race', () => {
|
|||||||
await page.waitForLoadState('load');
|
await page.waitForLoadState('load');
|
||||||
await handleLoginScreenIfPresent(page);
|
await handleLoginScreenIfPresent(page);
|
||||||
await page
|
await page
|
||||||
.locator('[data-testid="welcome-view"], [data-testid="board-view"]')
|
.locator(
|
||||||
|
'[data-testid="welcome-view"], [data-testid="dashboard-view"], [data-testid="board-view"]'
|
||||||
|
)
|
||||||
.first()
|
.first()
|
||||||
.waitFor({ state: 'visible', timeout: 30000 });
|
.waitFor({ state: 'visible', timeout: 30000 });
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export const TEST_IDS = {
|
|||||||
agentView: 'agent-view',
|
agentView: 'agent-view',
|
||||||
settingsView: 'settings-view',
|
settingsView: 'settings-view',
|
||||||
welcomeView: 'welcome-view',
|
welcomeView: 'welcome-view',
|
||||||
|
dashboardView: 'dashboard-view',
|
||||||
setupView: 'setup-view',
|
setupView: 'setup-view',
|
||||||
|
|
||||||
// Board View Components
|
// Board View Components
|
||||||
|
|||||||
@@ -75,28 +75,44 @@ export async function handleLoginScreenIfPresent(page: Page): Promise<boolean> {
|
|||||||
.locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]')
|
.locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]')
|
||||||
.first();
|
.first();
|
||||||
const appContent = page.locator(
|
const appContent = page.locator(
|
||||||
'[data-testid="welcome-view"], [data-testid="board-view"], [data-testid="context-view"], [data-testid="agent-view"]'
|
'[data-testid="welcome-view"], [data-testid="dashboard-view"], [data-testid="board-view"], [data-testid="context-view"], [data-testid="agent-view"]'
|
||||||
);
|
);
|
||||||
|
const loggedOutPage = page.getByRole('heading', { name: /logged out/i });
|
||||||
|
const goToLoginButton = page.locator('button:has-text("Go to login")');
|
||||||
|
|
||||||
const maxWaitMs = 15000;
|
const maxWaitMs = 15000;
|
||||||
|
|
||||||
// Race between login screen, a delayed redirect to /login, and actual content
|
// Race between login screen, logged-out page, a delayed redirect to /login, and actual content
|
||||||
const loginVisible = await Promise.race([
|
const result = await Promise.race([
|
||||||
page
|
page
|
||||||
.waitForURL((url) => url.pathname.includes('/login'), { timeout: maxWaitMs })
|
.waitForURL((url) => url.pathname.includes('/login'), { timeout: maxWaitMs })
|
||||||
.then(() => true)
|
.then(() => 'login-redirect' as const)
|
||||||
.catch(() => false),
|
.catch(() => null),
|
||||||
loginInput
|
loginInput
|
||||||
.waitFor({ state: 'visible', timeout: maxWaitMs })
|
.waitFor({ state: 'visible', timeout: maxWaitMs })
|
||||||
.then(() => true)
|
.then(() => 'login-input' as const)
|
||||||
.catch(() => false),
|
.catch(() => null),
|
||||||
|
loggedOutPage
|
||||||
|
.waitFor({ state: 'visible', timeout: maxWaitMs })
|
||||||
|
.then(() => 'logged-out' as const)
|
||||||
|
.catch(() => null),
|
||||||
appContent
|
appContent
|
||||||
.first()
|
.first()
|
||||||
.waitFor({ state: 'visible', timeout: maxWaitMs })
|
.waitFor({ state: 'visible', timeout: maxWaitMs })
|
||||||
.then(() => false)
|
.then(() => 'app-content' as const)
|
||||||
.catch(() => false),
|
.catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Handle logged-out page - click "Go to login" button and then login
|
||||||
|
if (result === 'logged-out') {
|
||||||
|
await goToLoginButton.click();
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
// Now handle the login screen
|
||||||
|
return handleLoginScreenIfPresent(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginVisible = result === 'login-redirect' || result === 'login-input';
|
||||||
|
|
||||||
if (loginVisible) {
|
if (loginVisible) {
|
||||||
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
||||||
await loginInput.fill(apiKey);
|
await loginInput.fill(apiKey);
|
||||||
|
|||||||
@@ -152,7 +152,8 @@ export async function navigateToSetup(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to the welcome view (clear project selection)
|
* Navigate to the welcome/dashboard view (clear project selection)
|
||||||
|
* Note: The app redirects from / to /dashboard when no project is selected
|
||||||
*/
|
*/
|
||||||
export async function navigateToWelcome(page: Page): Promise<void> {
|
export async function navigateToWelcome(page: Page): Promise<void> {
|
||||||
// Authenticate before navigating
|
// Authenticate before navigating
|
||||||
@@ -167,7 +168,11 @@ export async function navigateToWelcome(page: Page): Promise<void> {
|
|||||||
// Handle login redirect if needed
|
// Handle login redirect if needed
|
||||||
await handleLoginScreenIfPresent(page);
|
await handleLoginScreenIfPresent(page);
|
||||||
|
|
||||||
await waitForElement(page, 'welcome-view', { timeout: 10000 });
|
// Wait for either welcome-view or dashboard-view (app redirects to /dashboard when no project)
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="welcome-view"], [data-testid="dashboard-view"]')
|
||||||
|
.first()
|
||||||
|
.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
215
check-sync.sh
Executable file
215
check-sync.sh
Executable file
@@ -0,0 +1,215 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEFAULT_RC_PATTERN="v*rc"
|
||||||
|
DEFAULT_PREVIEW_COUNT=5
|
||||||
|
|
||||||
|
PREVIEW_COUNT="${PREVIEW_COUNT:-$DEFAULT_PREVIEW_COUNT}"
|
||||||
|
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
|
||||||
|
ORIGIN_REF="origin/${CURRENT_BRANCH}"
|
||||||
|
TARGET_RC_SOURCE="auto"
|
||||||
|
|
||||||
|
print_header() {
|
||||||
|
echo "=== Sync Status Check ==="
|
||||||
|
echo
|
||||||
|
printf "Target RC: %s (%s)\n" "$TARGET_RC" "$TARGET_RC_SOURCE"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_git_repo() {
|
||||||
|
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
echo "Not inside a git repository."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_remote() {
|
||||||
|
local remote="$1"
|
||||||
|
if ! git remote get-url "$remote" >/dev/null 2>&1; then
|
||||||
|
echo "Remote '$remote' is not configured."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_remote() {
|
||||||
|
local remote="$1"
|
||||||
|
git fetch --quiet "$remote"
|
||||||
|
}
|
||||||
|
|
||||||
|
warn_if_dirty() {
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo "Warning: working tree has uncommitted changes."
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_target_rc() {
|
||||||
|
if [[ -n "${TARGET_RC:-}" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local rc_candidates
|
||||||
|
rc_candidates="$(git for-each-ref --format='%(refname:short)' "refs/remotes/upstream/${DEFAULT_RC_PATTERN}" || true)"
|
||||||
|
if [[ -n "$rc_candidates" ]]; then
|
||||||
|
TARGET_RC="$(printf "%s\n" "$rc_candidates" | sed 's|^upstream/||' | sort -V | tail -n 1)"
|
||||||
|
TARGET_RC_SOURCE="auto:latest"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local upstream_head
|
||||||
|
upstream_head="$(git symbolic-ref --quiet --short refs/remotes/upstream/HEAD 2>/dev/null || true)"
|
||||||
|
if [[ -n "$upstream_head" ]]; then
|
||||||
|
TARGET_RC="${upstream_head#upstream/}"
|
||||||
|
TARGET_RC_SOURCE="auto:upstream-head"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Unable to resolve target RC automatically. Use --rc <branch>."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ref_exists() {
|
||||||
|
local ref="$1"
|
||||||
|
git show-ref --verify --quiet "refs/remotes/${ref}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_status_line() {
|
||||||
|
local label="$1"
|
||||||
|
local behind="$2"
|
||||||
|
local ahead="$3"
|
||||||
|
|
||||||
|
if [[ "$behind" -eq 0 && "$ahead" -eq 0 ]]; then
|
||||||
|
printf "✅ %s: in sync (behind %s, ahead %s)\n" "$label" "$behind" "$ahead"
|
||||||
|
elif [[ "$behind" -eq 0 ]]; then
|
||||||
|
printf "⬆️ %s: ahead %s (behind %s)\n" "$label" "$ahead" "$behind"
|
||||||
|
elif [[ "$ahead" -eq 0 ]]; then
|
||||||
|
printf "⬇️ %s: behind %s (ahead %s)\n" "$label" "$behind" "$ahead"
|
||||||
|
else
|
||||||
|
printf "⚠️ %s: %s behind, %s ahead (diverged)\n" "$label" "$behind" "$ahead"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_preview() {
|
||||||
|
local title="$1"
|
||||||
|
local range="$2"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "$title"
|
||||||
|
git log --oneline -n "$PREVIEW_COUNT" "$range"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_branch_context() {
|
||||||
|
echo "Branch: $CURRENT_BRANCH"
|
||||||
|
echo "Upstream RC: $UPSTREAM_REF"
|
||||||
|
echo "Upstream push: enabled for sync workflow"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
print_upstream_summary() {
|
||||||
|
local behind="$1"
|
||||||
|
local ahead="$2"
|
||||||
|
|
||||||
|
if [[ "$behind" -eq 0 && "$ahead" -eq 0 ]]; then
|
||||||
|
echo "Branch vs upstream RC: in sync (behind $behind, ahead $ahead)"
|
||||||
|
else
|
||||||
|
echo "Branch vs upstream RC: behind $behind, ahead $ahead"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_workflow_hint() {
|
||||||
|
local behind="$1"
|
||||||
|
local ahead="$2"
|
||||||
|
|
||||||
|
if [[ "$behind" -eq 0 && "$ahead" -eq 0 ]]; then
|
||||||
|
echo "Workflow: sync"
|
||||||
|
elif [[ "$behind" -gt 0 && "$ahead" -eq 0 ]]; then
|
||||||
|
echo "Workflow: sync (merge upstream RC)"
|
||||||
|
elif [[ "$ahead" -gt 0 && "$behind" -eq 0 ]]; then
|
||||||
|
echo "Workflow: pr (local work not in upstream)"
|
||||||
|
else
|
||||||
|
echo "Workflow: diverged (resolve manually)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_usage() {
|
||||||
|
echo "Usage: ./check-sync.sh [--rc <branch>] [--preview <count>]"
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--rc)
|
||||||
|
shift
|
||||||
|
if [[ -z "${1-}" ]]; then
|
||||||
|
echo "Missing value for --rc"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
TARGET_RC="$1"
|
||||||
|
TARGET_RC_SOURCE="flag"
|
||||||
|
;;
|
||||||
|
--preview)
|
||||||
|
shift
|
||||||
|
if [[ -z "${1-}" ]]; then
|
||||||
|
echo "Missing value for --preview"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! [[ "$1" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "Invalid preview count: $1"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PREVIEW_COUNT="$1"
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
print_usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1"
|
||||||
|
print_usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_git_repo
|
||||||
|
ensure_remote origin
|
||||||
|
ensure_remote upstream
|
||||||
|
parse_args "$@"
|
||||||
|
|
||||||
|
fetch_remote origin
|
||||||
|
fetch_remote upstream
|
||||||
|
resolve_target_rc
|
||||||
|
|
||||||
|
UPSTREAM_REF="upstream/${TARGET_RC}"
|
||||||
|
|
||||||
|
print_header
|
||||||
|
warn_if_dirty
|
||||||
|
print_branch_context
|
||||||
|
|
||||||
|
if ! ref_exists "$ORIGIN_REF"; then
|
||||||
|
echo "Origin branch '$ORIGIN_REF' does not exist."
|
||||||
|
else
|
||||||
|
read -r origin_behind origin_ahead < <(git rev-list --left-right --count "$ORIGIN_REF...HEAD")
|
||||||
|
print_status_line "Origin" "$origin_behind" "$origin_ahead"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! ref_exists "$UPSTREAM_REF"; then
|
||||||
|
echo "Upstream ref '$UPSTREAM_REF' does not exist."
|
||||||
|
else
|
||||||
|
read -r upstream_behind upstream_ahead < <(git rev-list --left-right --count "$UPSTREAM_REF...HEAD")
|
||||||
|
print_status_line "Upstream" "$upstream_behind" "$upstream_ahead"
|
||||||
|
echo
|
||||||
|
print_upstream_summary "$upstream_behind" "$upstream_ahead"
|
||||||
|
print_workflow_hint "$upstream_behind" "$upstream_ahead"
|
||||||
|
|
||||||
|
if [[ "$upstream_behind" -gt 0 ]]; then
|
||||||
|
print_preview "Recent upstream commits:" "HEAD..$UPSTREAM_REF"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$upstream_ahead" -gt 0 ]]; then
|
||||||
|
print_preview "Commits on this branch not in upstream:" "$UPSTREAM_REF..HEAD"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
@@ -17,6 +17,11 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
|
args:
|
||||||
|
# Match container user to host user for mounted volume permissions
|
||||||
|
# Override with: UID=$(id -u) GID=$(id -g) docker-compose build
|
||||||
|
UID: ${UID:-1001}
|
||||||
|
GID: ${GID:-1001}
|
||||||
container_name: automaker-dev-server-only
|
container_name: automaker-dev-server-only
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
|
args:
|
||||||
|
# Match container user to host user for mounted volume permissions
|
||||||
|
# Override with: UID=$(id -u) GID=$(id -g) docker-compose build
|
||||||
|
UID: ${UID:-1001}
|
||||||
|
GID: ${GID:-1001}
|
||||||
container_name: automaker-dev-server
|
container_name: automaker-dev-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -94,6 +99,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
|
args:
|
||||||
|
UID: ${UID:-1001}
|
||||||
|
GID: ${GID:-1001}
|
||||||
container_name: automaker-dev-ui
|
container_name: automaker-dev-ui
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ services:
|
|||||||
# This shares your 'cursor-agent login' OAuth session with the container
|
# This shares your 'cursor-agent login' OAuth session with the container
|
||||||
# - ~/.cursor:/home/automaker/.cursor
|
# - ~/.cursor:/home/automaker/.cursor
|
||||||
|
|
||||||
|
# OpenCode CLI - mount your ~/.local/share/opencode directory
|
||||||
|
# This shares your 'opencode auth login' session with the container
|
||||||
|
# - ~/.local/share/opencode:/home/automaker/.local/share/opencode
|
||||||
|
# - ~/.config/opencode:/home/automaker/.config/opencode
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
# Set root directory for all projects and file operations
|
# Set root directory for all projects and file operations
|
||||||
# Users can only create/open projects within this directory
|
# Users can only create/open projects within this directory
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: server
|
target: server
|
||||||
|
args:
|
||||||
|
# Match container user to host user for mounted volume permissions
|
||||||
|
# Override with: UID=$(id -u) GID=$(id -g) docker-compose build
|
||||||
|
UID: ${UID:-1001}
|
||||||
|
GID: ${GID:-1001}
|
||||||
container_name: automaker-server
|
container_name: automaker-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -78,6 +83,16 @@ services:
|
|||||||
# This allows 'cursor-agent login' authentication to persist between restarts
|
# This allows 'cursor-agent login' authentication to persist between restarts
|
||||||
- automaker-cursor-config:/home/automaker/.cursor
|
- automaker-cursor-config:/home/automaker/.cursor
|
||||||
|
|
||||||
|
# Persist OpenCode CLI configuration and authentication across container restarts
|
||||||
|
# This allows 'opencode auth login' authentication to persist between restarts
|
||||||
|
- automaker-opencode-data:/home/automaker/.local/share/opencode
|
||||||
|
|
||||||
|
# Persist OpenCode user configuration across container restarts
|
||||||
|
- automaker-opencode-config:/home/automaker/.config/opencode
|
||||||
|
|
||||||
|
# Persist OpenCode cache directory (contains version file and other cache data)
|
||||||
|
- automaker-opencode-cache:/home/automaker/.cache/opencode
|
||||||
|
|
||||||
# NO host directory mounts - container cannot access your laptop files
|
# NO host directory mounts - container cannot access your laptop files
|
||||||
# If you need to work on a project, create it INSIDE the container
|
# If you need to work on a project, create it INSIDE the container
|
||||||
# or use a separate docker-compose override file
|
# or use a separate docker-compose override file
|
||||||
@@ -101,3 +116,18 @@ volumes:
|
|||||||
name: automaker-cursor-config
|
name: automaker-cursor-config
|
||||||
# Named volume for Cursor CLI configuration and authentication
|
# Named volume for Cursor CLI configuration and authentication
|
||||||
# Persists cursor-agent login authentication across container restarts
|
# Persists cursor-agent login authentication across container restarts
|
||||||
|
|
||||||
|
automaker-opencode-data:
|
||||||
|
name: automaker-opencode-data
|
||||||
|
# Named volume for OpenCode CLI data and authentication (~/.local/share/opencode)
|
||||||
|
# Persists opencode auth login authentication across container restarts
|
||||||
|
|
||||||
|
automaker-opencode-config:
|
||||||
|
name: automaker-opencode-config
|
||||||
|
# Named volume for OpenCode user configuration (~/.config/opencode)
|
||||||
|
# Persists user configuration across container restarts
|
||||||
|
|
||||||
|
automaker-opencode-cache:
|
||||||
|
name: automaker-opencode-cache
|
||||||
|
# Named volume for OpenCode cache directory (~/.cache/opencode)
|
||||||
|
# Contains version file and other cached data
|
||||||
|
|||||||
@@ -25,6 +25,28 @@ fi
|
|||||||
chown -R automaker:automaker /home/automaker/.cursor
|
chown -R automaker:automaker /home/automaker/.cursor
|
||||||
chmod -R 700 /home/automaker/.cursor
|
chmod -R 700 /home/automaker/.cursor
|
||||||
|
|
||||||
|
# Ensure OpenCode CLI config directory exists with correct permissions
|
||||||
|
# OpenCode stores config and auth in ~/.local/share/opencode/
|
||||||
|
if [ ! -d "/home/automaker/.local/share/opencode" ]; then
|
||||||
|
mkdir -p /home/automaker/.local/share/opencode
|
||||||
|
fi
|
||||||
|
chown -R automaker:automaker /home/automaker/.local/share/opencode
|
||||||
|
chmod -R 700 /home/automaker/.local/share/opencode
|
||||||
|
|
||||||
|
# OpenCode also uses ~/.config/opencode for configuration
|
||||||
|
if [ ! -d "/home/automaker/.config/opencode" ]; then
|
||||||
|
mkdir -p /home/automaker/.config/opencode
|
||||||
|
fi
|
||||||
|
chown -R automaker:automaker /home/automaker/.config/opencode
|
||||||
|
chmod -R 700 /home/automaker/.config/opencode
|
||||||
|
|
||||||
|
# OpenCode also uses ~/.cache/opencode for cache data (version file, etc.)
|
||||||
|
if [ ! -d "/home/automaker/.cache/opencode" ]; then
|
||||||
|
mkdir -p /home/automaker/.cache/opencode
|
||||||
|
fi
|
||||||
|
chown -R automaker:automaker /home/automaker/.cache/opencode
|
||||||
|
chmod -R 700 /home/automaker/.cache/opencode
|
||||||
|
|
||||||
# If CURSOR_AUTH_TOKEN is set, write it to the cursor auth file
|
# If CURSOR_AUTH_TOKEN is set, write it to the cursor auth file
|
||||||
# On Linux, cursor-agent uses ~/.config/cursor/auth.json for file-based credential storage
|
# On Linux, cursor-agent uses ~/.config/cursor/auth.json for file-based credential storage
|
||||||
# The env var CURSOR_AUTH_TOKEN is also checked directly by cursor-agent
|
# The env var CURSOR_AUTH_TOKEN is also checked directly by cursor-agent
|
||||||
|
|||||||
@@ -57,6 +57,20 @@ docker-compose -f docker-compose.yml -f docker-compose.project.yml up -d
|
|||||||
|
|
||||||
**Tip**: Use `:ro` (read-only) when possible for extra safety.
|
**Tip**: Use `:ro` (read-only) when possible for extra safety.
|
||||||
|
|
||||||
|
### Fixing File Permission Issues
|
||||||
|
|
||||||
|
When mounting host directories, files created by the container may be owned by UID 1001 (the default container user), causing permission mismatches with your host user. To fix this, rebuild the image with your host UID/GID:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rebuild with your user's UID/GID
|
||||||
|
UID=$(id -u) GID=$(id -g) docker-compose build
|
||||||
|
|
||||||
|
# Then start normally
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the container user with the same UID/GID as your host user, so files in mounted volumes have correct ownership.
|
||||||
|
|
||||||
## CLI Authentication (macOS)
|
## CLI Authentication (macOS)
|
||||||
|
|
||||||
On macOS, OAuth tokens are stored in Keychain (Claude) and SQLite (Cursor). Use these scripts to extract and pass them to the container:
|
On macOS, OAuth tokens are stored in Keychain (Claude) and SQLite (Cursor). Use these scripts to extract and pass them to the container:
|
||||||
@@ -80,6 +94,16 @@ echo "CURSOR_AUTH_TOKEN=$(./scripts/get-cursor-token.sh)" >> .env
|
|||||||
- **macOS**: Tokens are stored in Keychain (service: `cursor-access-token`)
|
- **macOS**: Tokens are stored in Keychain (service: `cursor-access-token`)
|
||||||
- **Linux**: Tokens are stored in `~/.config/cursor/auth.json` (not `~/.cursor`)
|
- **Linux**: Tokens are stored in `~/.config/cursor/auth.json` (not `~/.cursor`)
|
||||||
|
|
||||||
|
### OpenCode CLI
|
||||||
|
|
||||||
|
OpenCode stores its configuration and auth at `~/.local/share/opencode/`. To share your host authentication with the container:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# In docker-compose.override.yml
|
||||||
|
volumes:
|
||||||
|
- ~/.local/share/opencode:/home/automaker/.local/share/opencode
|
||||||
|
```
|
||||||
|
|
||||||
### Apply to container
|
### Apply to container
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -107,13 +131,16 @@ echo "CURSOR_AUTH_TOKEN=$(jq -r '.accessToken' ~/.config/cursor/auth.json)" >> .
|
|||||||
volumes:
|
volumes:
|
||||||
- ~/.claude:/home/automaker/.claude
|
- ~/.claude:/home/automaker/.claude
|
||||||
- ~/.config/cursor:/home/automaker/.config/cursor
|
- ~/.config/cursor:/home/automaker/.config/cursor
|
||||||
|
- ~/.local/share/opencode:/home/automaker/.local/share/opencode
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
| Problem | Solution |
|
| Problem | Solution |
|
||||||
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Container won't start | Check `.env` has `ANTHROPIC_API_KEY` set. Run `docker-compose logs` for errors. |
|
| Container won't start | Check `.env` has `ANTHROPIC_API_KEY` set. Run `docker-compose logs` for errors. |
|
||||||
| Can't access web UI | Verify container is running with `docker ps \| grep automaker` |
|
| Can't access web UI | Verify container is running with `docker ps \| grep automaker` |
|
||||||
| Need a fresh start | Run `docker-compose down && docker volume rm automaker-data && docker-compose up -d --build` |
|
| Need a fresh start | Run `docker-compose down && docker volume rm automaker-data && docker-compose up -d --build` |
|
||||||
| Cursor auth fails | Re-extract token with `./scripts/get-cursor-token.sh` - tokens expire periodically. Make sure you've run `cursor-agent login` on your host first. |
|
| Cursor auth fails | Re-extract token with `./scripts/get-cursor-token.sh` - tokens expire periodically. Make sure you've run `cursor-agent login` on your host first. |
|
||||||
|
| OpenCode not detected | Mount `~/.local/share/opencode` to `/home/automaker/.local/share/opencode`. Make sure you've run `opencode auth login` on your host first. |
|
||||||
|
| File permission errors | Rebuild with `UID=$(id -u) GID=$(id -g) docker-compose build` to match container user to your host user. See [Fixing File Permission Issues](#fixing-file-permission-issues). |
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGener
|
|||||||
* Spawns a subprocess and collects all output
|
* Spawns a subprocess and collects all output
|
||||||
*/
|
*/
|
||||||
export async function spawnProcess(options: SubprocessOptions): Promise<SubprocessResult> {
|
export async function spawnProcess(options: SubprocessOptions): Promise<SubprocessResult> {
|
||||||
const { command, args, cwd, env, abortController } = options;
|
const { command, args, cwd, env, abortController, stdinData } = options;
|
||||||
|
|
||||||
const processEnv = {
|
const processEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -204,10 +204,15 @@ export async function spawnProcess(options: SubprocessOptions): Promise<Subproce
|
|||||||
const childProcess = spawn(command, args, {
|
const childProcess = spawn(command, args, {
|
||||||
cwd,
|
cwd,
|
||||||
env: processEnv,
|
env: processEnv,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: [stdinData ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
||||||
shell: needsShell,
|
shell: needsShell,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (stdinData && childProcess.stdin) {
|
||||||
|
childProcess.stdin.write(stdinData);
|
||||||
|
childProcess.stdin.end();
|
||||||
|
}
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export {
|
|||||||
type ModelAlias,
|
type ModelAlias,
|
||||||
type CodexModelId,
|
type CodexModelId,
|
||||||
type AgentModel,
|
type AgentModel,
|
||||||
|
type ModelId,
|
||||||
} from './model.js';
|
} from './model.js';
|
||||||
|
|
||||||
// Event types
|
// Event types
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Types for validating GitHub issues against the codebase using Claude SDK.
|
* Types for validating GitHub issues against the codebase using Claude SDK.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ModelAlias } from './model.js';
|
import type { ModelId } from './model.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verdict from issue validation
|
* Verdict from issue validation
|
||||||
@@ -137,8 +137,8 @@ export type IssueValidationEvent =
|
|||||||
issueTitle: string;
|
issueTitle: string;
|
||||||
result: IssueValidationResult;
|
result: IssueValidationResult;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
/** Model used for validation (opus, sonnet, haiku) */
|
/** Model used for validation */
|
||||||
model: ModelAlias;
|
model: ModelId;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'issue_validation_error';
|
type: 'issue_validation_error';
|
||||||
@@ -162,8 +162,8 @@ export interface StoredValidation {
|
|||||||
issueTitle: string;
|
issueTitle: string;
|
||||||
/** ISO timestamp when validation was performed */
|
/** ISO timestamp when validation was performed */
|
||||||
validatedAt: string;
|
validatedAt: string;
|
||||||
/** Model used for validation (opus, sonnet, haiku) */
|
/** Model used for validation */
|
||||||
model: ModelAlias;
|
model: ModelId;
|
||||||
/** The validation result */
|
/** The validation result */
|
||||||
result: IssueValidationResult;
|
result: IssueValidationResult;
|
||||||
/** ISO timestamp when user viewed this validation (undefined = not yet viewed) */
|
/** ISO timestamp when user viewed this validation (undefined = not yet viewed) */
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Model alias mapping for Claude models
|
* Model alias mapping for Claude models
|
||||||
*/
|
*/
|
||||||
|
import type { CursorModelId } from './cursor-models.js';
|
||||||
|
import type { OpencodeModelId } from './opencode-models.js';
|
||||||
|
|
||||||
export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
||||||
haiku: 'claude-haiku-4-5-20251001',
|
haiku: 'claude-haiku-4-5-20251001',
|
||||||
sonnet: 'claude-sonnet-4-5-20250929',
|
sonnet: 'claude-sonnet-4-5-20250929',
|
||||||
@@ -74,3 +77,26 @@ export type CodexModelId = (typeof CODEX_MODEL_MAP)[keyof typeof CODEX_MODEL_MAP
|
|||||||
* Represents available models across providers
|
* Represents available models across providers
|
||||||
*/
|
*/
|
||||||
export type AgentModel = ModelAlias | CodexModelId;
|
export type AgentModel = ModelAlias | CodexModelId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic provider model IDs discovered at runtime (provider/model format)
|
||||||
|
*/
|
||||||
|
export type DynamicModelId = `${string}/${string}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider-prefixed model IDs used for routing
|
||||||
|
*/
|
||||||
|
export type PrefixedCursorModelId = `cursor-${string}`;
|
||||||
|
export type PrefixedOpencodeModelId = `opencode-${string}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModelId - Unified model identifier across providers
|
||||||
|
*/
|
||||||
|
export type ModelId =
|
||||||
|
| ModelAlias
|
||||||
|
| CodexModelId
|
||||||
|
| CursorModelId
|
||||||
|
| OpencodeModelId
|
||||||
|
| DynamicModelId
|
||||||
|
| PrefixedCursorModelId
|
||||||
|
| PrefixedOpencodeModelId;
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export interface ContentBlock {
|
|||||||
*/
|
*/
|
||||||
export interface ProviderMessage {
|
export interface ProviderMessage {
|
||||||
type: 'assistant' | 'user' | 'error' | 'result';
|
type: 'assistant' | 'user' | 'error' | 'result';
|
||||||
subtype?: 'success' | 'error';
|
subtype?: 'success' | 'error' | 'error_max_turns' | 'error_max_structured_output_retries';
|
||||||
session_id?: string;
|
session_id?: string;
|
||||||
message?: {
|
message?: {
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
@@ -183,6 +183,8 @@ export interface ProviderMessage {
|
|||||||
result?: string;
|
result?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
parent_tool_use_id?: string | null;
|
parent_tool_use_id?: string | null;
|
||||||
|
/** Structured output from SDK when using outputFormat */
|
||||||
|
structured_output?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* (for file I/O via SettingsService) and the UI (for state management and sync).
|
* (for file I/O via SettingsService) and the UI (for state management and sync).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ModelAlias, AgentModel, CodexModelId } from './model.js';
|
import type { ModelAlias, ModelId } from './model.js';
|
||||||
import type { CursorModelId } from './cursor-models.js';
|
import type { CursorModelId } from './cursor-models.js';
|
||||||
import { CURSOR_MODEL_MAP, getAllCursorModelIds } from './cursor-models.js';
|
import { CURSOR_MODEL_MAP, getAllCursorModelIds } from './cursor-models.js';
|
||||||
import type { OpencodeModelId } from './opencode-models.js';
|
import type { OpencodeModelId } from './opencode-models.js';
|
||||||
@@ -114,8 +114,8 @@ const DEFAULT_CODEX_ADDITIONAL_DIRS: string[] = [];
|
|||||||
* - Cursor models: Handle thinking internally
|
* - Cursor models: Handle thinking internally
|
||||||
*/
|
*/
|
||||||
export interface PhaseModelEntry {
|
export interface PhaseModelEntry {
|
||||||
/** The model to use (Claude alias, Cursor model ID, or Codex model ID) */
|
/** The model to use (supports Claude, Cursor, Codex, OpenCode, and dynamic provider IDs) */
|
||||||
model: ModelAlias | CursorModelId | CodexModelId;
|
model: ModelId;
|
||||||
/** Extended thinking level (only applies to Claude models, defaults to 'none') */
|
/** Extended thinking level (only applies to Claude models, defaults to 'none') */
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
/** Reasoning effort level (only applies to Codex models, defaults to 'none') */
|
/** Reasoning effort level (only applies to Codex models, defaults to 'none') */
|
||||||
@@ -379,6 +379,8 @@ export interface GlobalSettings {
|
|||||||
defaultPlanningMode: PlanningMode;
|
defaultPlanningMode: PlanningMode;
|
||||||
/** Default: require manual approval before generating */
|
/** Default: require manual approval before generating */
|
||||||
defaultRequirePlanApproval: boolean;
|
defaultRequirePlanApproval: boolean;
|
||||||
|
/** Default model and thinking level for new feature cards */
|
||||||
|
defaultFeatureModel: PhaseModelEntry;
|
||||||
|
|
||||||
// Audio Preferences
|
// Audio Preferences
|
||||||
/** Mute completion notification sound */
|
/** Mute completion notification sound */
|
||||||
@@ -706,6 +708,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
|||||||
useWorktrees: true,
|
useWorktrees: true,
|
||||||
defaultPlanningMode: 'skip',
|
defaultPlanningMode: 'skip',
|
||||||
defaultRequirePlanApproval: false,
|
defaultRequirePlanApproval: false,
|
||||||
|
defaultFeatureModel: { model: 'opus' },
|
||||||
muteDoneSound: false,
|
muteDoneSound: false,
|
||||||
phaseModels: DEFAULT_PHASE_MODELS,
|
phaseModels: DEFAULT_PHASE_MODELS,
|
||||||
enhancementModel: 'sonnet',
|
enhancementModel: 'sonnet',
|
||||||
|
|||||||
91
package-lock.json
generated
91
package-lock.json
generated
@@ -680,6 +680,7 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -1272,6 +1273,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
|
||||||
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
|
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.5.0",
|
"@codemirror/state": "^6.5.0",
|
||||||
"crelt": "^1.0.6",
|
"crelt": "^1.0.6",
|
||||||
@@ -1314,6 +1316,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -2134,7 +2137,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-dirname": "^0.1.0",
|
"cross-dirname": "^0.1.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
@@ -2156,7 +2158,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
"jsonfile": "^6.0.1",
|
"jsonfile": "^6.0.1",
|
||||||
@@ -2173,7 +2174,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"universalify": "^2.0.0"
|
"universalify": "^2.0.0"
|
||||||
},
|
},
|
||||||
@@ -2188,7 +2188,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
@@ -2956,7 +2955,6 @@
|
|||||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3081,7 +3079,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -3098,7 +3095,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -3115,7 +3111,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -3224,7 +3219,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -3247,7 +3241,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -3270,7 +3263,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -3356,7 +3348,6 @@
|
|||||||
],
|
],
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/runtime": "^1.7.0"
|
"@emnapi/runtime": "^1.7.0"
|
||||||
},
|
},
|
||||||
@@ -3379,7 +3370,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -3399,7 +3389,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -3799,8 +3788,7 @@
|
|||||||
"version": "16.0.10",
|
"version": "16.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
|
||||||
"integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==",
|
"integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "16.0.10",
|
"version": "16.0.10",
|
||||||
@@ -3814,7 +3802,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
@@ -3831,7 +3818,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
@@ -3848,7 +3834,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
@@ -3865,7 +3850,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
@@ -3882,7 +3866,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
@@ -3899,7 +3882,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
@@ -3916,7 +3898,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
@@ -3933,7 +3914,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
@@ -4033,6 +4013,7 @@
|
|||||||
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
|
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.57.0"
|
"playwright": "1.57.0"
|
||||||
},
|
},
|
||||||
@@ -5504,7 +5485,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
@@ -5838,6 +5818,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",
|
||||||
"integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==",
|
"integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/history": "1.141.0",
|
"@tanstack/history": "1.141.0",
|
||||||
"@tanstack/react-store": "^0.8.0",
|
"@tanstack/react-store": "^0.8.0",
|
||||||
@@ -6264,6 +6245,7 @@
|
|||||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^5.0.0",
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
@@ -6406,6 +6388,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -6416,6 +6399,7 @@
|
|||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -6521,6 +6505,7 @@
|
|||||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.50.0",
|
"@typescript-eslint/scope-manager": "8.50.0",
|
||||||
"@typescript-eslint/types": "8.50.0",
|
"@typescript-eslint/types": "8.50.0",
|
||||||
@@ -7014,7 +6999,8 @@
|
|||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@xyflow/react": {
|
"node_modules/@xyflow/react": {
|
||||||
"version": "12.10.0",
|
"version": "12.10.0",
|
||||||
@@ -7112,6 +7098,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -7172,6 +7159,7 @@
|
|||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
@@ -7770,6 +7758,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -8301,8 +8290,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/cliui": {
|
"node_modules/cliui": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
@@ -8607,8 +8595,7 @@
|
|||||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/cross-env": {
|
"node_modules/cross-env": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
@@ -8705,6 +8692,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@@ -9006,6 +8994,7 @@
|
|||||||
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
|
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "26.0.12",
|
"app-builder-lib": "26.0.12",
|
||||||
"builder-util": "26.0.11",
|
"builder-util": "26.0.11",
|
||||||
@@ -9332,7 +9321,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron/asar": "^3.2.1",
|
"@electron/asar": "^3.2.1",
|
||||||
"debug": "^4.1.1",
|
"debug": "^4.1.1",
|
||||||
@@ -9353,7 +9341,6 @@
|
|||||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.1.2",
|
"graceful-fs": "^4.1.2",
|
||||||
"jsonfile": "^4.0.0",
|
"jsonfile": "^4.0.0",
|
||||||
@@ -9604,6 +9591,7 @@
|
|||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -9918,6 +9906,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -11585,7 +11574,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11651,7 +11639,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -14093,7 +14080,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.6",
|
"nanoid": "^3.3.6",
|
||||||
"picocolors": "^1.0.0",
|
"picocolors": "^1.0.0",
|
||||||
@@ -14110,7 +14096,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^9.4.0"
|
"commander": "^9.4.0"
|
||||||
},
|
},
|
||||||
@@ -14128,7 +14113,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.20.0 || >=14"
|
"node": "^12.20.0 || >=14"
|
||||||
}
|
}
|
||||||
@@ -14317,6 +14301,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -14326,6 +14311,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -14684,7 +14670,6 @@
|
|||||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"glob": "^7.1.3"
|
"glob": "^7.1.3"
|
||||||
},
|
},
|
||||||
@@ -14873,6 +14858,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz",
|
||||||
"integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==",
|
"integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
@@ -14921,7 +14907,6 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.0.0",
|
"@img/colour": "^1.0.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
@@ -14972,7 +14957,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -14995,7 +14979,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -15018,7 +15001,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -15035,7 +15017,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -15052,7 +15033,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -15069,7 +15049,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -15086,7 +15065,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -15103,7 +15081,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -15120,7 +15097,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -15137,7 +15113,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -15160,7 +15135,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -15183,7 +15157,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -15206,7 +15179,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -15229,7 +15201,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -15252,7 +15223,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -15721,7 +15691,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||||
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
|
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"client-only": "0.0.1"
|
"client-only": "0.0.1"
|
||||||
},
|
},
|
||||||
@@ -15891,7 +15860,6 @@
|
|||||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mkdirp": "^0.5.1",
|
"mkdirp": "^0.5.1",
|
||||||
"rimraf": "~2.6.2"
|
"rimraf": "~2.6.2"
|
||||||
@@ -15955,7 +15923,6 @@
|
|||||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimist": "^1.2.6"
|
"minimist": "^1.2.6"
|
||||||
},
|
},
|
||||||
@@ -16053,6 +16020,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -16257,6 +16225,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -16628,6 +16597,7 @@
|
|||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -16717,7 +16687,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
|
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
|
||||||
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
|
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/fdir": {
|
"node_modules/vite/node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
@@ -16743,6 +16714,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -16785,6 +16757,7 @@
|
|||||||
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
|
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.0.16",
|
"@vitest/expect": "4.0.16",
|
||||||
"@vitest/mocker": "4.0.16",
|
"@vitest/mocker": "4.0.16",
|
||||||
@@ -17042,6 +17015,7 @@
|
|||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
@@ -17110,6 +17084,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
|
||||||
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user