mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-25 00:33:08 +00:00
Compare commits
16 Commits
57446b4fba
...
06ef4f883f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06ef4f883f | ||
|
|
7e84591ef1 | ||
|
|
efcdd849b9 | ||
|
|
dee770c2ab | ||
|
|
f7b3f75163 | ||
|
|
b5ad77b0f9 | ||
|
|
98b925b821 | ||
|
|
a09a2c76ae | ||
|
|
b9653d6338 | ||
|
|
44ef2084cf | ||
|
|
fa799d3cb5 | ||
|
|
78ec389477 | ||
|
|
e9802ac00c | ||
|
|
dfe6920df9 | ||
|
|
525b2f82b6 | ||
|
|
094f0809d7 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -90,6 +90,8 @@ pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
# Fork-specific workflow files (should never be committed)
|
||||
DEVELOPMENT_WORKFLOW.md
|
||||
check-sync.sh
|
||||
# API key files
|
||||
data/.api-key
|
||||
data/credentials.json
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
# 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.
|
||||
@@ -14,10 +14,6 @@
|
||||
|
||||
**Stop typing code. Start directing AI agents.**
|
||||
|
||||
> **[!WARNING]**
|
||||
>
|
||||
> **This project is no longer actively maintained.** The codebase is provided as-is. No bug fixes, security updates, or new features are being developed.
|
||||
|
||||
<details open>
|
||||
<summary><h2>Table of Contents</h2></summary>
|
||||
|
||||
|
||||
74
apps/server/eslint.config.mjs
Normal file
74
apps/server/eslint.config.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import js from '@eslint/js';
|
||||
import ts from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
globals: {
|
||||
// Node.js globals
|
||||
console: 'readonly',
|
||||
process: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
URL: 'readonly',
|
||||
URLSearchParams: 'readonly',
|
||||
AbortController: 'readonly',
|
||||
AbortSignal: 'readonly',
|
||||
fetch: 'readonly',
|
||||
Response: 'readonly',
|
||||
Request: 'readonly',
|
||||
Headers: 'readonly',
|
||||
FormData: 'readonly',
|
||||
RequestInit: 'readonly',
|
||||
// Timers
|
||||
setTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
setImmediate: 'readonly',
|
||||
clearImmediate: 'readonly',
|
||||
queueMicrotask: 'readonly',
|
||||
// Node.js types
|
||||
NodeJS: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': ts,
|
||||
},
|
||||
rules: {
|
||||
...ts.configs.recommended.rules,
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
// Server code frequently works with terminal output containing ANSI escape codes
|
||||
'no-control-regex': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': [
|
||||
'error',
|
||||
{
|
||||
'ts-nocheck': 'allow-with-description',
|
||||
minimumDescriptionLength: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
globalIgnores(['dist/**', 'node_modules/**']),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -368,24 +368,61 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
|
||||
logger.warn('Failed to check for legacy settings migration:', err);
|
||||
}
|
||||
|
||||
// Apply logging settings from saved settings
|
||||
// Fetch global settings once and reuse for logging config and feature reconciliation
|
||||
let globalSettings: Awaited<ReturnType<typeof settingsService.getGlobalSettings>> | null = null;
|
||||
try {
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
if (settings.serverLogLevel && LOG_LEVEL_MAP[settings.serverLogLevel] !== undefined) {
|
||||
setLogLevel(LOG_LEVEL_MAP[settings.serverLogLevel]);
|
||||
logger.info(`Server log level set to: ${settings.serverLogLevel}`);
|
||||
}
|
||||
// Apply request logging setting (default true if not set)
|
||||
const enableRequestLog = settings.enableRequestLogging ?? true;
|
||||
setRequestLoggingEnabled(enableRequestLog);
|
||||
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
|
||||
globalSettings = await settingsService.getGlobalSettings();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to load logging settings, using defaults');
|
||||
logger.warn('Failed to load global settings, using defaults');
|
||||
}
|
||||
|
||||
// Apply logging settings from saved settings
|
||||
if (globalSettings) {
|
||||
try {
|
||||
if (
|
||||
globalSettings.serverLogLevel &&
|
||||
LOG_LEVEL_MAP[globalSettings.serverLogLevel] !== undefined
|
||||
) {
|
||||
setLogLevel(LOG_LEVEL_MAP[globalSettings.serverLogLevel]);
|
||||
logger.info(`Server log level set to: ${globalSettings.serverLogLevel}`);
|
||||
}
|
||||
// Apply request logging setting (default true if not set)
|
||||
const enableRequestLog = globalSettings.enableRequestLogging ?? true;
|
||||
setRequestLoggingEnabled(enableRequestLog);
|
||||
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to apply logging settings, using defaults');
|
||||
}
|
||||
}
|
||||
|
||||
await agentService.initialize();
|
||||
logger.info('Agent service initialized');
|
||||
|
||||
// Reconcile feature states on startup
|
||||
// After any type of restart (clean, forced, crash), features may be stuck in
|
||||
// transient states (in_progress, interrupted, pipeline_*) that don't match reality.
|
||||
// Reconcile them back to resting states before the UI is served.
|
||||
if (globalSettings) {
|
||||
try {
|
||||
if (globalSettings.projects && globalSettings.projects.length > 0) {
|
||||
let totalReconciled = 0;
|
||||
for (const project of globalSettings.projects) {
|
||||
const count = await autoModeService.reconcileFeatureStates(project.path);
|
||||
totalReconciled += count;
|
||||
}
|
||||
if (totalReconciled > 0) {
|
||||
logger.info(
|
||||
`[STARTUP] Reconciled ${totalReconciled} feature(s) across ${globalSettings.projects.length} project(s)`
|
||||
);
|
||||
} else {
|
||||
logger.info('[STARTUP] Feature state reconciliation complete - no stale states found');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[STARTUP] Failed to reconcile feature states:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap Codex model cache in background (don't block server startup)
|
||||
void codexModelCacheService.getModels().catch((err) => {
|
||||
logger.error('Failed to bootstrap Codex model cache:', err);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js';
|
||||
import { createCommitFeatureHandler } from './routes/commit-feature.js';
|
||||
import { createApprovePlanHandler } from './routes/approve-plan.js';
|
||||
import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
|
||||
import { createReconcileHandler } from './routes/reconcile.js';
|
||||
|
||||
/**
|
||||
* Create auto-mode routes.
|
||||
@@ -81,6 +82,11 @@ export function createAutoModeRoutes(autoModeService: AutoModeServiceCompat): Ro
|
||||
validatePathParams('projectPath'),
|
||||
createResumeInterruptedHandler(autoModeService)
|
||||
);
|
||||
router.post(
|
||||
'/reconcile',
|
||||
validatePathParams('projectPath'),
|
||||
createReconcileHandler(autoModeService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
53
apps/server/src/routes/auto-mode/routes/reconcile.ts
Normal file
53
apps/server/src/routes/auto-mode/routes/reconcile.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Reconcile Feature States Handler
|
||||
*
|
||||
* On-demand endpoint to reconcile all feature states for a project.
|
||||
* Resets features stuck in transient states (in_progress, interrupted, pipeline_*)
|
||||
* back to resting states (ready/backlog) and emits events to update the UI.
|
||||
*
|
||||
* This is useful when:
|
||||
* - The UI reconnects after a server restart
|
||||
* - A client detects stale feature states
|
||||
* - An admin wants to force-reset stuck features
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
|
||||
const logger = createLogger('ReconcileFeatures');
|
||||
|
||||
interface ReconcileRequest {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
export function createReconcileHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
const { projectPath } = req.body as ReconcileRequest;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ error: 'Project path is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Reconciling feature states for ${projectPath}`);
|
||||
|
||||
try {
|
||||
const reconciledCount = await autoModeService.reconcileFeatureStates(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
reconciledCount,
|
||||
message:
|
||||
reconciledCount > 0
|
||||
? `Reconciled ${reconciledCount} feature(s)`
|
||||
: 'No features needed reconciliation',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error reconciling feature states:', error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -24,19 +24,6 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate title if title is provided
|
||||
if (feature.title && feature.title.trim()) {
|
||||
const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title);
|
||||
if (duplicate) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: `A feature with title "${feature.title}" already exists`,
|
||||
duplicateFeatureId: duplicate.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const created = await featureLoader.create(projectPath, feature);
|
||||
|
||||
// Emit feature_created event for hooks
|
||||
|
||||
@@ -40,23 +40,6 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate title if title is being updated
|
||||
if (updates.title && updates.title.trim()) {
|
||||
const duplicate = await featureLoader.findDuplicateTitle(
|
||||
projectPath,
|
||||
updates.title,
|
||||
featureId // Exclude the current feature from duplicate check
|
||||
);
|
||||
if (duplicate) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: `A feature with title "${updates.title}" already exists`,
|
||||
duplicateFeatureId: duplicate.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current feature to detect status changes
|
||||
const currentFeature = await featureLoader.get(projectPath, featureId);
|
||||
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
|
||||
|
||||
@@ -101,7 +101,12 @@ export function createWorktreeRoutes(
|
||||
requireValidWorktree,
|
||||
createPullHandler()
|
||||
);
|
||||
router.post('/checkout-branch', requireValidWorktree, createCheckoutBranchHandler());
|
||||
router.post(
|
||||
'/checkout-branch',
|
||||
validatePathParams('worktreePath'),
|
||||
requireValidWorktree,
|
||||
createCheckoutBranchHandler()
|
||||
);
|
||||
router.post(
|
||||
'/list-branches',
|
||||
validatePathParams('worktreePath'),
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
* POST /checkout-branch endpoint - Create and checkout a new branch
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
* the requireValidWorktree middleware in index.ts.
|
||||
* Path validation (ALLOWED_ROOT_DIRECTORY) is handled by validatePathParams
|
||||
* middleware in index.ts.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import path from 'path';
|
||||
import { stat } from 'fs/promises';
|
||||
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
|
||||
|
||||
export function createCheckoutBranchHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -36,27 +36,47 @@ export function createCheckoutBranchHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate branch name (basic validation)
|
||||
const invalidChars = /[\s~^:?*\[\\]/;
|
||||
if (invalidChars.test(branchName)) {
|
||||
// Validate branch name using shared allowlist: /^[a-zA-Z0-9._\-/]+$/
|
||||
if (!isValidBranchName(branchName)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Branch name contains invalid characters',
|
||||
error:
|
||||
'Invalid branch name. Must contain only letters, numbers, dots, dashes, underscores, or slashes.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current branch for reference
|
||||
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
// Resolve and validate worktreePath to prevent traversal attacks.
|
||||
// The validatePathParams middleware checks against ALLOWED_ROOT_DIRECTORY,
|
||||
// but we also resolve the path and verify it exists as a directory.
|
||||
const resolvedPath = path.resolve(worktreePath);
|
||||
try {
|
||||
const stats = await stat(resolvedPath);
|
||||
if (!stats.isDirectory()) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath is not a directory',
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath does not exist or is not accessible',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current branch for reference (using argument array to avoid shell injection)
|
||||
const currentBranchOutput = await execGitCommand(
|
||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
resolvedPath
|
||||
);
|
||||
const currentBranch = currentBranchOutput.trim();
|
||||
|
||||
// Check if branch already exists
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${branchName}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
await execGitCommand(['rev-parse', '--verify', branchName], resolvedPath);
|
||||
// Branch exists
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -67,10 +87,8 @@ export function createCheckoutBranchHandler() {
|
||||
// Branch doesn't exist, good to create
|
||||
}
|
||||
|
||||
// Create and checkout the new branch
|
||||
await execAsync(`git checkout -b ${branchName}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
// Create and checkout the new branch (using argument array to avoid shell injection)
|
||||
await execGitCommand(['checkout', '-b', branchName], resolvedPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -125,19 +125,14 @@ export function createOpenInEditorHandler() {
|
||||
`Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await openInFileManager(worktreePath);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in ${result.editorName}`,
|
||||
editorName: result.editorName,
|
||||
},
|
||||
});
|
||||
} catch (fallbackError) {
|
||||
// Both editor and file manager failed
|
||||
throw fallbackError;
|
||||
}
|
||||
const result = await openInFileManager(worktreePath);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in ${result.editorName}`,
|
||||
editorName: result.editorName,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Open in editor failed');
|
||||
|
||||
@@ -88,6 +88,10 @@ export class AutoModeServiceCompat {
|
||||
return this.globalService.markAllRunningFeaturesInterrupted(reason);
|
||||
}
|
||||
|
||||
async reconcileFeatureStates(projectPath: string): Promise<number> {
|
||||
return this.globalService.reconcileFeatureStates(projectPath);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// PER-PROJECT OPERATIONS (delegated to facades)
|
||||
// ===========================================================================
|
||||
|
||||
@@ -205,4 +205,21 @@ export class GlobalAutoModeService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile all feature states for a project on server startup.
|
||||
*
|
||||
* Resets features stuck in transient states (in_progress, interrupted, pipeline_*)
|
||||
* back to a resting state and emits events so the UI reflects corrected states.
|
||||
*
|
||||
* This should be called during server initialization to handle:
|
||||
* - Clean shutdown: features already marked as interrupted
|
||||
* - Forced kill / crash: features left in in_progress or pipeline_* states
|
||||
*
|
||||
* @param projectPath - The project path to reconcile
|
||||
* @returns The number of features that were reconciled
|
||||
*/
|
||||
async reconcileFeatureStates(projectPath: string): Promise<number> {
|
||||
return this.featureStateManager.reconcileAllFeatureStates(projectPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,7 +662,7 @@ export class ClaudeUsageService {
|
||||
|
||||
resetTime = this.parseResetTime(resetText, type);
|
||||
// Strip timezone like "(Asia/Dubai)" from the display text
|
||||
resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim();
|
||||
resetText = resetText.replace(/\s*\([A-Za-z_/]+\)\s*$/, '').trim();
|
||||
}
|
||||
|
||||
return { percentage: percentage ?? 0, resetTime, resetText };
|
||||
|
||||
@@ -124,7 +124,7 @@ class DevServerService {
|
||||
/(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format
|
||||
/(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format
|
||||
/(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL
|
||||
/(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/i, // Any HTTP(S) URL
|
||||
/(https?:\/\/[^\s<>"{}|\\^`[\]]+)/i, // Any HTTP(S) URL
|
||||
];
|
||||
|
||||
for (const pattern of urlPatterns) {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { getFeatureDir, getFeaturesDir } from '@automaker/platform';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import type { AutoModeEventType } from './typed-event-bus.js';
|
||||
import { getNotificationService } from './notification-service.js';
|
||||
import { FeatureLoader } from './feature-loader.js';
|
||||
|
||||
@@ -268,20 +269,39 @@ export class FeatureStateManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset features that were stuck in transient states due to server crash.
|
||||
* Called when auto mode is enabled to clean up from previous session.
|
||||
* Shared helper that scans features in a project directory and resets any stuck
|
||||
* in transient states (in_progress, interrupted, pipeline_*) back to resting states.
|
||||
*
|
||||
* Resets:
|
||||
* - in_progress features back to ready (if has plan) or backlog (if no plan)
|
||||
* Also resets:
|
||||
* - generating planSpec status back to pending
|
||||
* - in_progress tasks back to pending
|
||||
*
|
||||
* @param projectPath - The project path to reset features for
|
||||
* @param projectPath - The project path to scan
|
||||
* @param callerLabel - Label for log messages (e.g., 'resetStuckFeatures', 'reconcileAllFeatureStates')
|
||||
* @returns Object with reconciledFeatures (id + status info), reconciledCount, and scanned count
|
||||
*/
|
||||
async resetStuckFeatures(projectPath: string): Promise<void> {
|
||||
private async scanAndResetFeatures(
|
||||
projectPath: string,
|
||||
callerLabel: string
|
||||
): Promise<{
|
||||
reconciledFeatures: Array<{
|
||||
id: string;
|
||||
previousStatus: string | undefined;
|
||||
newStatus: string | undefined;
|
||||
}>;
|
||||
reconciledFeatureIds: string[];
|
||||
reconciledCount: number;
|
||||
scanned: number;
|
||||
}> {
|
||||
const featuresDir = getFeaturesDir(projectPath);
|
||||
let featuresScanned = 0;
|
||||
let featuresReset = 0;
|
||||
let scanned = 0;
|
||||
let reconciledCount = 0;
|
||||
const reconciledFeatureIds: string[] = [];
|
||||
const reconciledFeatures: Array<{
|
||||
id: string;
|
||||
previousStatus: string | undefined;
|
||||
newStatus: string | undefined;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
|
||||
@@ -289,7 +309,7 @@ export class FeatureStateManager {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
featuresScanned++;
|
||||
scanned++;
|
||||
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
|
||||
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
||||
maxBackups: DEFAULT_BACKUP_COUNT,
|
||||
@@ -300,14 +320,21 @@ export class FeatureStateManager {
|
||||
if (!feature) continue;
|
||||
|
||||
let needsUpdate = false;
|
||||
const originalStatus = feature.status;
|
||||
|
||||
// Reset in_progress features back to ready/backlog
|
||||
if (feature.status === 'in_progress') {
|
||||
// Reset features in active execution states back to a resting state
|
||||
// After a server restart, no processes are actually running
|
||||
const isActiveState =
|
||||
originalStatus === 'in_progress' ||
|
||||
originalStatus === 'interrupted' ||
|
||||
(originalStatus != null && originalStatus.startsWith('pipeline_'));
|
||||
|
||||
if (isActiveState) {
|
||||
const hasApprovedPlan = feature.planSpec?.status === 'approved';
|
||||
feature.status = hasApprovedPlan ? 'ready' : 'backlog';
|
||||
needsUpdate = true;
|
||||
logger.info(
|
||||
`[resetStuckFeatures] Reset feature ${feature.id} from in_progress to ${feature.status}`
|
||||
`[${callerLabel}] Reset feature ${feature.id} from ${originalStatus} to ${feature.status}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -316,7 +343,7 @@ export class FeatureStateManager {
|
||||
feature.planSpec.status = 'pending';
|
||||
needsUpdate = true;
|
||||
logger.info(
|
||||
`[resetStuckFeatures] Reset feature ${feature.id} planSpec status from generating to pending`
|
||||
`[${callerLabel}] Reset feature ${feature.id} planSpec status from generating to pending`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -327,13 +354,13 @@ export class FeatureStateManager {
|
||||
task.status = 'pending';
|
||||
needsUpdate = true;
|
||||
logger.info(
|
||||
`[resetStuckFeatures] Reset task ${task.id} for feature ${feature.id} from in_progress to pending`
|
||||
`[${callerLabel}] Reset task ${task.id} for feature ${feature.id} from in_progress to pending`
|
||||
);
|
||||
// Clear currentTaskId if it points to this reverted task
|
||||
if (feature.planSpec?.currentTaskId === task.id) {
|
||||
feature.planSpec.currentTaskId = undefined;
|
||||
logger.info(
|
||||
`[resetStuckFeatures] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})`
|
||||
`[${callerLabel}] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -343,19 +370,94 @@ export class FeatureStateManager {
|
||||
if (needsUpdate) {
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||
featuresReset++;
|
||||
reconciledCount++;
|
||||
reconciledFeatureIds.push(feature.id);
|
||||
reconciledFeatures.push({
|
||||
id: feature.id,
|
||||
previousStatus: originalStatus,
|
||||
newStatus: feature.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[resetStuckFeatures] Scanned ${featuresScanned} features, reset ${featuresReset} features for ${projectPath}`
|
||||
);
|
||||
} catch (error) {
|
||||
// If features directory doesn't exist, that's fine
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error(`[resetStuckFeatures] Error resetting features for ${projectPath}:`, error);
|
||||
logger.error(`[${callerLabel}] Error resetting features for ${projectPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return { reconciledFeatures, reconciledFeatureIds, reconciledCount, scanned };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset features that were stuck in transient states due to server crash.
|
||||
* Called when auto mode is enabled to clean up from previous session.
|
||||
*
|
||||
* Resets:
|
||||
* - in_progress features back to ready (if has plan) or backlog (if no plan)
|
||||
* - interrupted features back to ready (if has plan) or backlog (if no plan)
|
||||
* - pipeline_* features back to ready (if has plan) or backlog (if no plan)
|
||||
* - generating planSpec status back to pending
|
||||
* - in_progress tasks back to pending
|
||||
*
|
||||
* @param projectPath - The project path to reset features for
|
||||
*/
|
||||
async resetStuckFeatures(projectPath: string): Promise<void> {
|
||||
const { reconciledCount, scanned } = await this.scanAndResetFeatures(
|
||||
projectPath,
|
||||
'resetStuckFeatures'
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`[resetStuckFeatures] Scanned ${scanned} features, reset ${reconciledCount} features for ${projectPath}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile all feature states on server startup.
|
||||
*
|
||||
* This method resets all features stuck in transient states (in_progress,
|
||||
* interrupted, pipeline_*) and emits events so connected UI clients
|
||||
* immediately reflect the corrected states.
|
||||
*
|
||||
* Should be called once during server initialization, before the UI is served,
|
||||
* to ensure feature state consistency after any type of restart (clean, forced, crash).
|
||||
*
|
||||
* @param projectPath - The project path to reconcile features for
|
||||
* @returns The number of features that were reconciled
|
||||
*/
|
||||
async reconcileAllFeatureStates(projectPath: string): Promise<number> {
|
||||
logger.info(`[reconcileAllFeatureStates] Starting reconciliation for ${projectPath}`);
|
||||
|
||||
const { reconciledFeatures, reconciledFeatureIds, reconciledCount, scanned } =
|
||||
await this.scanAndResetFeatures(projectPath, 'reconcileAllFeatureStates');
|
||||
|
||||
// Emit per-feature status change events so UI invalidates its cache
|
||||
for (const { id, previousStatus, newStatus } of reconciledFeatures) {
|
||||
this.emitAutoModeEvent('feature_status_changed', {
|
||||
featureId: id,
|
||||
projectPath,
|
||||
status: newStatus,
|
||||
previousStatus,
|
||||
reason: 'server_restart_reconciliation',
|
||||
});
|
||||
}
|
||||
|
||||
// Emit a bulk reconciliation event for the UI
|
||||
if (reconciledCount > 0) {
|
||||
this.emitAutoModeEvent('features_reconciled', {
|
||||
projectPath,
|
||||
reconciledCount,
|
||||
reconciledFeatureIds,
|
||||
message: `Reconciled ${reconciledCount} feature(s) after server restart`,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[reconcileAllFeatureStates] Scanned ${scanned} features, reconciled ${reconciledCount} for ${projectPath}`
|
||||
);
|
||||
|
||||
return reconciledCount;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -532,7 +634,7 @@ export class FeatureStateManager {
|
||||
* @param eventType - The event type (e.g., 'auto_mode_summary')
|
||||
* @param data - The event payload
|
||||
*/
|
||||
private emitAutoModeEvent(eventType: string, data: Record<string, unknown>): void {
|
||||
private emitAutoModeEvent(eventType: AutoModeEventType, data: Record<string, unknown>): void {
|
||||
// Wrap the event in auto-mode:event format expected by the client
|
||||
this.events.emit('auto-mode:event', {
|
||||
type: eventType,
|
||||
|
||||
@@ -888,7 +888,7 @@ ${contextSection}${existingWorkSection}`;
|
||||
|
||||
for (const line of lines) {
|
||||
// Check for numbered items or markdown headers
|
||||
const titleMatch = line.match(/^(?:\d+[\.\)]\s*\*{0,2}|#{1,3}\s+)(.+)/);
|
||||
const titleMatch = line.match(/^(?:\d+[.)]\s*\*{0,2}|#{1,3}\s+)(.+)/);
|
||||
|
||||
if (titleMatch) {
|
||||
// Save previous suggestion
|
||||
|
||||
@@ -40,9 +40,13 @@ export type AutoModeEventType =
|
||||
| 'plan_rejected'
|
||||
| 'plan_revision_requested'
|
||||
| 'plan_revision_warning'
|
||||
| 'plan_spec_updated'
|
||||
| 'pipeline_step_started'
|
||||
| 'pipeline_step_complete'
|
||||
| string; // Allow other strings for extensibility
|
||||
| 'pipeline_test_failed'
|
||||
| 'pipeline_merge_conflict'
|
||||
| 'feature_status_changed'
|
||||
| 'features_reconciled';
|
||||
|
||||
/**
|
||||
* TypedEventBus wraps an EventEmitter to provide type-safe event emission
|
||||
|
||||
@@ -119,7 +119,15 @@ const eslintConfig = defineConfig([
|
||||
},
|
||||
rules: {
|
||||
...ts.configs.recommended.rules,
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/ban-ts-comment': [
|
||||
'error',
|
||||
|
||||
@@ -590,6 +590,7 @@ export function BoardView() {
|
||||
handleForceStopFeature,
|
||||
handleStartNextFeatures,
|
||||
handleArchiveAllVerified,
|
||||
handleDuplicateFeature,
|
||||
} = useBoardActions({
|
||||
currentProject,
|
||||
features: hookFeatures,
|
||||
@@ -1503,6 +1504,8 @@ export function BoardView() {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
},
|
||||
onDuplicate: (feature) => handleDuplicateFeature(feature, false),
|
||||
onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true),
|
||||
}}
|
||||
runningAutoTasks={runningAutoTasksAllWorktrees}
|
||||
pipelineConfig={pipelineConfig}
|
||||
@@ -1542,6 +1545,8 @@ export function BoardView() {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
onDuplicate={(feature) => handleDuplicateFeature(feature, false)}
|
||||
onDuplicateAsChild={(feature) => handleDuplicateFeature(feature, true)}
|
||||
featuresWithContext={featuresWithContext}
|
||||
runningAutoTasks={runningAutoTasksAllWorktrees}
|
||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-nocheck - header component props with optional handlers and status variants
|
||||
import { memo, useState } from 'react';
|
||||
import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core';
|
||||
import { Feature } from '@/store/app-store';
|
||||
@@ -9,6 +8,9 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
GitFork,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { CountUpTimer } from '@/components/ui/count-up-timer';
|
||||
@@ -27,6 +30,65 @@ import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
|
||||
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
||||
|
||||
function DuplicateMenuItems({
|
||||
onDuplicate,
|
||||
onDuplicateAsChild,
|
||||
}: {
|
||||
onDuplicate?: () => void;
|
||||
onDuplicateAsChild?: () => void;
|
||||
}) {
|
||||
if (!onDuplicate) return null;
|
||||
|
||||
// When there's no sub-child action, render a simple menu item (no DropdownMenuSub wrapper)
|
||||
if (!onDuplicateAsChild) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Copy className="w-3 h-3 mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
// When sub-child action is available, render a proper DropdownMenuSub with
|
||||
// DropdownMenuSubTrigger and DropdownMenuSubContent per Radix conventions
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="text-xs">
|
||||
<Copy className="w-3 h-3 mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Copy className="w-3 h-3 mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicateAsChild();
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<GitFork className="w-3 h-3 mr-2" />
|
||||
Duplicate as Child
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
feature: Feature;
|
||||
isDraggable: boolean;
|
||||
@@ -36,6 +98,8 @@ interface CardHeaderProps {
|
||||
onDelete: () => void;
|
||||
onViewOutput?: () => void;
|
||||
onSpawnTask?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onDuplicateAsChild?: () => void;
|
||||
dragHandleListeners?: DraggableSyntheticListeners;
|
||||
dragHandleAttributes?: DraggableAttributes;
|
||||
}
|
||||
@@ -49,6 +113,8 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
onDelete,
|
||||
onViewOutput,
|
||||
onSpawnTask,
|
||||
onDuplicate,
|
||||
onDuplicateAsChild,
|
||||
dragHandleListeners,
|
||||
dragHandleAttributes,
|
||||
}: CardHeaderProps) {
|
||||
@@ -71,7 +137,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
|
||||
<Spinner size="xs" />
|
||||
{feature.startedAt && (
|
||||
{typeof feature.startedAt === 'string' && (
|
||||
<CountUpTimer
|
||||
startedAt={feature.startedAt}
|
||||
className="text-[var(--status-in-progress)] text-[10px]"
|
||||
@@ -114,6 +180,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
<GitFork className="w-3 h-3 mr-2" />
|
||||
Spawn Sub-Task
|
||||
</DropdownMenuItem>
|
||||
<DuplicateMenuItems
|
||||
onDuplicate={onDuplicate}
|
||||
onDuplicateAsChild={onDuplicateAsChild}
|
||||
/>
|
||||
{/* Model info in dropdown */}
|
||||
{(() => {
|
||||
const ProviderIcon = getProviderIconForModel(feature.model);
|
||||
@@ -162,6 +232,29 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
{/* Only render overflow menu when there are actionable items */}
|
||||
{onDuplicate && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`menu-backlog-${feature.id}`}
|
||||
>
|
||||
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DuplicateMenuItems
|
||||
onDuplicate={onDuplicate}
|
||||
onDuplicateAsChild={onDuplicateAsChild}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -187,22 +280,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSpawnTask?.();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`spawn-${
|
||||
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
|
||||
}-${feature.id}`}
|
||||
title="Spawn Sub-Task"
|
||||
>
|
||||
<GitFork className="w-4 h-4" />
|
||||
</Button>
|
||||
{onViewOutput && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -234,6 +311,41 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`menu-${
|
||||
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
|
||||
}-${feature.id}`}
|
||||
>
|
||||
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSpawnTask?.();
|
||||
}}
|
||||
data-testid={`spawn-${
|
||||
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
|
||||
}-${feature.id}`}
|
||||
className="text-xs"
|
||||
>
|
||||
<GitFork className="w-3 h-3 mr-2" />
|
||||
Spawn Sub-Task
|
||||
</DropdownMenuItem>
|
||||
<DuplicateMenuItems
|
||||
onDuplicate={onDuplicate}
|
||||
onDuplicateAsChild={onDuplicateAsChild}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -302,6 +414,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
<GitFork className="w-3 h-3 mr-2" />
|
||||
Spawn Sub-Task
|
||||
</DropdownMenuItem>
|
||||
<DuplicateMenuItems
|
||||
onDuplicate={onDuplicate}
|
||||
onDuplicateAsChild={onDuplicateAsChild}
|
||||
/>
|
||||
{/* Model info in dropdown */}
|
||||
{(() => {
|
||||
const ProviderIcon = getProviderIconForModel(feature.model);
|
||||
|
||||
@@ -52,6 +52,8 @@ interface KanbanCardProps {
|
||||
onViewPlan?: () => void;
|
||||
onApprovePlan?: () => void;
|
||||
onSpawnTask?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onDuplicateAsChild?: () => void;
|
||||
hasContext?: boolean;
|
||||
isCurrentAutoTask?: boolean;
|
||||
shortcutKey?: string;
|
||||
@@ -86,6 +88,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
onViewPlan,
|
||||
onApprovePlan,
|
||||
onSpawnTask,
|
||||
onDuplicate,
|
||||
onDuplicateAsChild,
|
||||
hasContext,
|
||||
isCurrentAutoTask,
|
||||
shortcutKey,
|
||||
@@ -254,6 +258,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
onDelete={onDelete}
|
||||
onViewOutput={onViewOutput}
|
||||
onSpawnTask={onSpawnTask}
|
||||
onDuplicate={onDuplicate}
|
||||
onDuplicateAsChild={onDuplicateAsChild}
|
||||
dragHandleListeners={isDraggable ? listeners : undefined}
|
||||
dragHandleAttributes={isDraggable ? attributes : undefined}
|
||||
/>
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface ListViewActionHandlers {
|
||||
onViewPlan?: (feature: Feature) => void;
|
||||
onApprovePlan?: (feature: Feature) => void;
|
||||
onSpawnTask?: (feature: Feature) => void;
|
||||
onDuplicate?: (feature: Feature) => void;
|
||||
onDuplicateAsChild?: (feature: Feature) => void;
|
||||
}
|
||||
|
||||
export interface ListViewProps {
|
||||
@@ -313,6 +315,18 @@ export const ListView = memo(function ListView({
|
||||
if (f) actionHandlers.onSpawnTask?.(f);
|
||||
}
|
||||
: undefined,
|
||||
duplicate: actionHandlers.onDuplicate
|
||||
? (id) => {
|
||||
const f = allFeatures.find((f) => f.id === id);
|
||||
if (f) actionHandlers.onDuplicate?.(f);
|
||||
}
|
||||
: undefined,
|
||||
duplicateAsChild: actionHandlers.onDuplicateAsChild
|
||||
? (id) => {
|
||||
const f = allFeatures.find((f) => f.id === id);
|
||||
if (f) actionHandlers.onDuplicateAsChild?.(f);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
[actionHandlers, allFeatures]
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
GitBranch,
|
||||
GitFork,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -22,6 +23,9 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
@@ -43,6 +47,8 @@ export interface RowActionHandlers {
|
||||
onViewPlan?: () => void;
|
||||
onApprovePlan?: () => void;
|
||||
onSpawnTask?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onDuplicateAsChild?: () => void;
|
||||
}
|
||||
|
||||
export interface RowActionsProps {
|
||||
@@ -405,6 +411,31 @@ export const RowActions = memo(function RowActions({
|
||||
onClick={withClose(handlers.onSpawnTask)}
|
||||
/>
|
||||
)}
|
||||
{handlers.onDuplicate && (
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenuItem
|
||||
onClick={withClose(handlers.onDuplicate)}
|
||||
className="flex-1 pr-0 rounded-r-none"
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
{handlers.onDuplicateAsChild && (
|
||||
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||
)}
|
||||
</div>
|
||||
{handlers.onDuplicateAsChild && (
|
||||
<DropdownMenuSubContent>
|
||||
<MenuItem
|
||||
icon={GitFork}
|
||||
label="Duplicate as Child"
|
||||
onClick={withClose(handlers.onDuplicateAsChild)}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
)}
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<MenuItem
|
||||
icon={Trash2}
|
||||
@@ -457,6 +488,31 @@ export const RowActions = memo(function RowActions({
|
||||
onClick={withClose(handlers.onSpawnTask)}
|
||||
/>
|
||||
)}
|
||||
{handlers.onDuplicate && (
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenuItem
|
||||
onClick={withClose(handlers.onDuplicate)}
|
||||
className="flex-1 pr-0 rounded-r-none"
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
{handlers.onDuplicateAsChild && (
|
||||
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||
)}
|
||||
</div>
|
||||
{handlers.onDuplicateAsChild && (
|
||||
<DropdownMenuSubContent>
|
||||
<MenuItem
|
||||
icon={GitFork}
|
||||
label="Duplicate as Child"
|
||||
onClick={withClose(handlers.onDuplicateAsChild)}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
)}
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={Trash2}
|
||||
label="Delete"
|
||||
@@ -503,6 +559,31 @@ export const RowActions = memo(function RowActions({
|
||||
onClick={withClose(handlers.onSpawnTask)}
|
||||
/>
|
||||
)}
|
||||
{handlers.onDuplicate && (
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenuItem
|
||||
onClick={withClose(handlers.onDuplicate)}
|
||||
className="flex-1 pr-0 rounded-r-none"
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
{handlers.onDuplicateAsChild && (
|
||||
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||
)}
|
||||
</div>
|
||||
{handlers.onDuplicateAsChild && (
|
||||
<DropdownMenuSubContent>
|
||||
<MenuItem
|
||||
icon={GitFork}
|
||||
label="Duplicate as Child"
|
||||
onClick={withClose(handlers.onDuplicateAsChild)}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
)}
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={Trash2}
|
||||
label="Delete"
|
||||
@@ -554,6 +635,31 @@ export const RowActions = memo(function RowActions({
|
||||
onClick={withClose(handlers.onSpawnTask)}
|
||||
/>
|
||||
)}
|
||||
{handlers.onDuplicate && (
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenuItem
|
||||
onClick={withClose(handlers.onDuplicate)}
|
||||
className="flex-1 pr-0 rounded-r-none"
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
{handlers.onDuplicateAsChild && (
|
||||
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||
)}
|
||||
</div>
|
||||
{handlers.onDuplicateAsChild && (
|
||||
<DropdownMenuSubContent>
|
||||
<MenuItem
|
||||
icon={GitFork}
|
||||
label="Duplicate as Child"
|
||||
onClick={withClose(handlers.onDuplicateAsChild)}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
)}
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={Trash2}
|
||||
label="Delete"
|
||||
@@ -581,6 +687,31 @@ export const RowActions = memo(function RowActions({
|
||||
onClick={withClose(handlers.onSpawnTask)}
|
||||
/>
|
||||
)}
|
||||
{handlers.onDuplicate && (
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenuItem
|
||||
onClick={withClose(handlers.onDuplicate)}
|
||||
className="flex-1 pr-0 rounded-r-none"
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
{handlers.onDuplicateAsChild && (
|
||||
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||
)}
|
||||
</div>
|
||||
{handlers.onDuplicateAsChild && (
|
||||
<DropdownMenuSubContent>
|
||||
<MenuItem
|
||||
icon={GitFork}
|
||||
label="Duplicate as Child"
|
||||
onClick={withClose(handlers.onDuplicateAsChild)}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
)}
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<MenuItem
|
||||
icon={Trash2}
|
||||
@@ -615,6 +746,8 @@ export function createRowActionHandlers(
|
||||
viewPlan?: (id: string) => void;
|
||||
approvePlan?: (id: string) => void;
|
||||
spawnTask?: (id: string) => void;
|
||||
duplicate?: (id: string) => void;
|
||||
duplicateAsChild?: (id: string) => void;
|
||||
}
|
||||
): RowActionHandlers {
|
||||
return {
|
||||
@@ -631,5 +764,9 @@ export function createRowActionHandlers(
|
||||
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
|
||||
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined,
|
||||
onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined,
|
||||
onDuplicate: actions.duplicate ? () => actions.duplicate!(featureId) : undefined,
|
||||
onDuplicateAsChild: actions.duplicateAsChild
|
||||
? () => actions.duplicateAsChild!(featureId)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -517,7 +517,7 @@ export function useBoardActions({
|
||||
}
|
||||
|
||||
removeFeature(featureId);
|
||||
persistFeatureDelete(featureId);
|
||||
await persistFeatureDelete(featureId);
|
||||
},
|
||||
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]
|
||||
);
|
||||
@@ -1090,6 +1090,38 @@ export function useBoardActions({
|
||||
});
|
||||
}, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]);
|
||||
|
||||
const handleDuplicateFeature = useCallback(
|
||||
async (feature: Feature, asChild: boolean = false) => {
|
||||
// Copy all feature data, stripping id, status (handled by create), and runtime/state fields
|
||||
const {
|
||||
id: _id,
|
||||
status: _status,
|
||||
startedAt: _startedAt,
|
||||
error: _error,
|
||||
summary: _summary,
|
||||
spec: _spec,
|
||||
passes: _passes,
|
||||
planSpec: _planSpec,
|
||||
descriptionHistory: _descriptionHistory,
|
||||
titleGenerating: _titleGenerating,
|
||||
...featureData
|
||||
} = feature;
|
||||
const duplicatedFeatureData = {
|
||||
...featureData,
|
||||
// If duplicating as child, set source as dependency; otherwise keep existing
|
||||
...(asChild && { dependencies: [feature.id] }),
|
||||
};
|
||||
|
||||
// Reuse the existing handleAddFeature logic
|
||||
await handleAddFeature(duplicatedFeatureData);
|
||||
|
||||
toast.success(asChild ? 'Duplicated as child' : 'Feature duplicated', {
|
||||
description: `Created copy of: ${truncateDescription(feature.description || feature.title || '')}`,
|
||||
});
|
||||
},
|
||||
[handleAddFeature]
|
||||
);
|
||||
|
||||
return {
|
||||
handleAddFeature,
|
||||
handleUpdateFeature,
|
||||
@@ -1110,5 +1142,6 @@ export function useBoardActions({
|
||||
handleForceStopFeature,
|
||||
handleStartNextFeatures,
|
||||
handleArchiveAllVerified,
|
||||
handleDuplicateFeature,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,15 +85,48 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
throw new Error('Features API not available');
|
||||
}
|
||||
|
||||
const result = await api.features.create(currentProject.path, feature as ApiFeature);
|
||||
if (result.success && result.feature) {
|
||||
updateFeature(result.feature.id, result.feature as Partial<Feature>);
|
||||
// Invalidate React Query cache to sync UI
|
||||
// Capture previous cache snapshot for synchronous rollback on error
|
||||
const previousFeatures = queryClient.getQueryData<Feature[]>(
|
||||
queryKeys.features.all(currentProject.path)
|
||||
);
|
||||
|
||||
// Optimistically add to React Query cache for immediate board refresh
|
||||
queryClient.setQueryData<Feature[]>(
|
||||
queryKeys.features.all(currentProject.path),
|
||||
(existing) => (existing ? [...existing, feature] : [feature])
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await api.features.create(currentProject.path, feature as ApiFeature);
|
||||
if (result.success && result.feature) {
|
||||
updateFeature(result.feature.id, result.feature as Partial<Feature>);
|
||||
// Update cache with server-confirmed feature before invalidating
|
||||
queryClient.setQueryData<Feature[]>(
|
||||
queryKeys.features.all(currentProject.path),
|
||||
(features) => {
|
||||
if (!features) return features;
|
||||
return features.map((f) =>
|
||||
f.id === result.feature!.id ? { ...f, ...(result.feature as Feature) } : f
|
||||
);
|
||||
}
|
||||
);
|
||||
} else if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to create feature on server');
|
||||
}
|
||||
// Always invalidate to sync with server state
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
} else if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to create feature on server');
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist feature creation:', error);
|
||||
// Rollback optimistic update synchronously on error
|
||||
if (previousFeatures) {
|
||||
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[currentProject, updateFeature, queryClient]
|
||||
@@ -104,20 +137,42 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
async (featureId: string) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
// Optimistically remove from React Query cache for immediate board refresh
|
||||
const previousFeatures = queryClient.getQueryData<Feature[]>(
|
||||
queryKeys.features.all(currentProject.path)
|
||||
);
|
||||
queryClient.setQueryData<Feature[]>(
|
||||
queryKeys.features.all(currentProject.path),
|
||||
(existing) => (existing ? existing.filter((f) => f.id !== featureId) : existing)
|
||||
);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
logger.error('Features API not available');
|
||||
return;
|
||||
// Rollback optimistic deletion since we can't persist
|
||||
if (previousFeatures) {
|
||||
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
throw new Error('Features API not available');
|
||||
}
|
||||
|
||||
await api.features.delete(currentProject.path, featureId);
|
||||
// Invalidate React Query cache to sync UI
|
||||
// Invalidate to sync with server state
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist feature deletion:', error);
|
||||
// Rollback optimistic update on error
|
||||
if (previousFeatures) {
|
||||
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
}
|
||||
},
|
||||
[currentProject, queryClient]
|
||||
|
||||
@@ -46,6 +46,8 @@ interface KanbanBoardProps {
|
||||
onViewPlan: (feature: Feature) => void;
|
||||
onApprovePlan: (feature: Feature) => void;
|
||||
onSpawnTask?: (feature: Feature) => void;
|
||||
onDuplicate?: (feature: Feature) => void;
|
||||
onDuplicateAsChild?: (feature: Feature) => void;
|
||||
featuresWithContext: Set<string>;
|
||||
runningAutoTasks: string[];
|
||||
onArchiveAllVerified: () => void;
|
||||
@@ -282,6 +284,8 @@ export function KanbanBoard({
|
||||
onViewPlan,
|
||||
onApprovePlan,
|
||||
onSpawnTask,
|
||||
onDuplicate,
|
||||
onDuplicateAsChild,
|
||||
featuresWithContext,
|
||||
runningAutoTasks,
|
||||
onArchiveAllVerified,
|
||||
@@ -569,6 +573,8 @@ export function KanbanBoard({
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
onApprovePlan={() => onApprovePlan(feature)}
|
||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||
onDuplicate={() => onDuplicate?.(feature)}
|
||||
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
shortcutKey={shortcutKey}
|
||||
@@ -611,6 +617,8 @@ export function KanbanBoard({
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
onApprovePlan={() => onApprovePlan(feature)}
|
||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||
onDuplicate={() => onDuplicate?.(feature)}
|
||||
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
shortcutKey={shortcutKey}
|
||||
|
||||
@@ -32,7 +32,6 @@ function featureToInternal(feature: Feature): FeatureWithId {
|
||||
}
|
||||
|
||||
function internalToFeature(internal: FeatureWithId): Feature {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { _id, _locationIds, ...feature } = internal;
|
||||
return feature;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ function phaseToInternal(phase: RoadmapPhase): PhaseWithId {
|
||||
}
|
||||
|
||||
function internalToPhase(internal: PhaseWithId): RoadmapPhase {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { _id, ...phase } = internal;
|
||||
return phase;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ const FEATURE_LIST_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
|
||||
'plan_rejected',
|
||||
'pipeline_step_started',
|
||||
'pipeline_step_complete',
|
||||
'feature_status_changed',
|
||||
'features_reconciled',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1062,7 +1062,6 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
|
||||
// Mock API for development/fallback when no backend is available
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _getMockElectronAPI = (): ElectronAPI => {
|
||||
return {
|
||||
ping: async () => 'pong (mock)',
|
||||
|
||||
@@ -155,7 +155,6 @@ export const useTestRunnersStore = create<TestRunnersState & TestRunnersActions>
|
||||
const finishedAt = new Date().toISOString();
|
||||
|
||||
// Remove from active sessions since it's no longer running
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [session.worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree;
|
||||
|
||||
return {
|
||||
@@ -202,7 +201,6 @@ export const useTestRunnersStore = create<TestRunnersState & TestRunnersActions>
|
||||
const session = state.sessions[sessionId];
|
||||
if (!session) return state;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [sessionId]: _, ...remainingSessions } = state.sessions;
|
||||
|
||||
// Remove from active if this was the active session
|
||||
@@ -231,7 +229,6 @@ export const useTestRunnersStore = create<TestRunnersState & TestRunnersActions>
|
||||
});
|
||||
|
||||
// Remove from active
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree;
|
||||
|
||||
return {
|
||||
|
||||
17
apps/ui/src/types/electron.d.ts
vendored
17
apps/ui/src/types/electron.d.ts
vendored
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
|
||||
import type { ParsedTask } from '@automaker/types';
|
||||
import type { ParsedTask, FeatureStatusWithPipeline } from '@automaker/types';
|
||||
|
||||
export interface ImageAttachment {
|
||||
id?: string; // Optional - may not be present in messages loaded from server
|
||||
@@ -359,6 +359,21 @@ export type AutoModeEvent =
|
||||
title?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: 'feature_status_changed';
|
||||
featureId: string;
|
||||
projectPath?: string;
|
||||
status: FeatureStatusWithPipeline;
|
||||
previousStatus: FeatureStatusWithPipeline;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
type: 'features_reconciled';
|
||||
projectPath?: string;
|
||||
reconciledCount: number;
|
||||
reconciledFeatureIds: string[];
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type SpecRegenerationEvent =
|
||||
|
||||
215
check-sync.sh
215
check-sync.sh
@@ -1,215 +0,0 @@
|
||||
#!/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
|
||||
@@ -21,6 +21,7 @@ export type PipelineStatus = `pipeline_${string}`;
|
||||
|
||||
export type FeatureStatusWithPipeline =
|
||||
| 'backlog'
|
||||
| 'ready'
|
||||
| 'in_progress'
|
||||
| 'interrupted'
|
||||
| 'waiting_approval'
|
||||
|
||||
@@ -36,8 +36,24 @@ elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
fi
|
||||
|
||||
# Port configuration
|
||||
DEFAULT_WEB_PORT=3007
|
||||
DEFAULT_SERVER_PORT=3008
|
||||
# Defaults can be overridden via AUTOMAKER_WEB_PORT and AUTOMAKER_SERVER_PORT env vars
|
||||
|
||||
# Validate env-provided ports early (before colors are available)
|
||||
if [ -n "$AUTOMAKER_WEB_PORT" ]; then
|
||||
if ! [[ "$AUTOMAKER_WEB_PORT" =~ ^[0-9]+$ ]] || [ "$AUTOMAKER_WEB_PORT" -lt 1 ] || [ "$AUTOMAKER_WEB_PORT" -gt 65535 ]; then
|
||||
echo "Error: AUTOMAKER_WEB_PORT must be a number between 1-65535, got '$AUTOMAKER_WEB_PORT'"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ -n "$AUTOMAKER_SERVER_PORT" ]; then
|
||||
if ! [[ "$AUTOMAKER_SERVER_PORT" =~ ^[0-9]+$ ]] || [ "$AUTOMAKER_SERVER_PORT" -lt 1 ] || [ "$AUTOMAKER_SERVER_PORT" -gt 65535 ]; then
|
||||
echo "Error: AUTOMAKER_SERVER_PORT must be a number between 1-65535, got '$AUTOMAKER_SERVER_PORT'"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
DEFAULT_WEB_PORT=${AUTOMAKER_WEB_PORT:-3007}
|
||||
DEFAULT_SERVER_PORT=${AUTOMAKER_SERVER_PORT:-3008}
|
||||
PORT_SEARCH_MAX_ATTEMPTS=100
|
||||
WEB_PORT=$DEFAULT_WEB_PORT
|
||||
SERVER_PORT=$DEFAULT_SERVER_PORT
|
||||
@@ -136,6 +152,9 @@ EXAMPLES:
|
||||
start-automaker.sh docker # Launch Docker dev container
|
||||
start-automaker.sh --version # Show version
|
||||
|
||||
AUTOMAKER_WEB_PORT=4000 AUTOMAKER_SERVER_PORT=4001 start-automaker.sh web
|
||||
# Launch web mode on custom ports
|
||||
|
||||
KEYBOARD SHORTCUTS (in menu):
|
||||
Up/Down arrows Navigate between options
|
||||
Enter Select highlighted option
|
||||
@@ -146,6 +165,10 @@ HISTORY:
|
||||
Your last selected mode is remembered in: ~/.automaker_launcher_history
|
||||
Use --no-history to disable this feature
|
||||
|
||||
ENVIRONMENT VARIABLES:
|
||||
AUTOMAKER_WEB_PORT Override default web/UI port (default: 3007)
|
||||
AUTOMAKER_SERVER_PORT Override default API server port (default: 3008)
|
||||
|
||||
PLATFORMS:
|
||||
Linux, macOS, Windows (Git Bash, WSL, MSYS2, Cygwin)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user