16 Commits

Author SHA1 Message Date
gsxdsm
06ef4f883f Merge pull request #781 from gsxdsm/fix/improve-restart-recovery
feat: Add feature state reconciliation on server startup
2026-02-17 11:15:03 -08:00
gsxdsm
7e84591ef1 Merge pull request #774 from gsxdsm/feat/duplicate-festure
Feat: Add ability to duplicate a feature and duplicate as a child
2026-02-17 10:43:04 -08:00
gsxdsm
efcdd849b9 fix: Add 'ready' status to FeatureStatusWithPipeline type union 2026-02-17 10:37:45 -08:00
gsxdsm
dee770c2ab refactor: Consolidate global settings fetching to avoid duplicate calls 2026-02-17 10:32:20 -08:00
gsxdsm
f7b3f75163 feat: Add path validation and security improvements to worktree routes 2026-02-17 10:17:23 -08:00
gsxdsm
b5ad77b0f9 feat: Add feature state reconciliation on server startup 2026-02-17 09:56:54 -08:00
DhanushSantosh
98b925b821 Merge remote-tracking branch 'upstream/v0.15.0rc' into patchcraft 2026-02-17 19:57:42 +05:30
gsxdsm
a09a2c76ae fix: Address code review feedback and fix lint errors 2026-02-17 00:13:38 -08:00
gsxdsm
b9653d6338 fix: Strip runtime and state fields when duplicating features 2026-02-16 23:41:08 -08:00
gsxdsm
44ef2084cf Merge remote-tracking branch 'upstream/v0.15.0rc' into feat/duplicate-festure
# Conflicts:
#	apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx
#	apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx
#	apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts
2026-02-16 23:28:32 -08:00
gsxdsm
fa799d3cb5 feat: Implement optimistic updates for feature persistence
Add optimistic UI updates with rollback capability for feature creation and deletion operations. Await persistFeatureDelete promise and add Playwright testing dependency.
2026-02-16 23:08:09 -08:00
gsxdsm
78ec389477 Merge remote-tracking branch 'upstream/main' into feat/duplicate-festure 2026-02-16 22:56:35 -08:00
eclipxe
e9802ac00c Feat: Add ability to duplicate a feature and duplicate as a child 2026-02-15 21:28:07 -08:00
Web Dev Cody
dfe6920df9 Merge pull request #772 from AutoMaker-Org/gsxdsm-patch-1
Update README to remove maintenance notice
2026-02-15 20:47:34 -05:00
gsxdsm
525b2f82b6 Update README to remove maintenance notice
Removed maintenance warning from README.
2026-02-15 17:18:33 -08:00
DhanushSantosh
094f0809d7 chore: final dev commit
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 11:29:12 +05:30
37 changed files with 844 additions and 612 deletions

2
.gitignore vendored
View File

@@ -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

View File

@@ -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.

View File

@@ -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>

View 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;

View File

@@ -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);

View File

@@ -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;
}

View 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',
});
}
};
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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'),

View File

@@ -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,

View File

@@ -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');

View File

@@ -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)
// ===========================================================================

View File

@@ -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);
}
}

View File

@@ -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 };

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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',

View File

@@ -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)}

View File

@@ -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);

View File

@@ -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}
/>

View File

@@ -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]

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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]

View File

@@ -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}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -38,6 +38,8 @@ const FEATURE_LIST_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
'plan_rejected',
'pipeline_step_started',
'pipeline_step_complete',
'feature_status_changed',
'features_reconciled',
];
/**

View File

@@ -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)',

View File

@@ -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 {

View File

@@ -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 =

View File

@@ -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

View File

@@ -21,6 +21,7 @@ export type PipelineStatus = `pipeline_${string}`;
export type FeatureStatusWithPipeline =
| 'backlog'
| 'ready'
| 'in_progress'
| 'interrupted'
| 'waiting_approval'

View File

@@ -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)