commandmate 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -1
- package/.next/app-build-manifest.json +11 -11
- package/.next/app-path-routes-manifest.json +1 -1
- package/.next/build-manifest.json +2 -2
- package/.next/cache/.tsbuildinfo +1 -1
- package/.next/cache/config.json +3 -3
- package/.next/cache/webpack/client-production/0.pack +0 -0
- package/.next/cache/webpack/client-production/1.pack +0 -0
- package/.next/cache/webpack/client-production/2.pack +0 -0
- package/.next/cache/webpack/client-production/index.pack +0 -0
- package/.next/cache/webpack/client-production/index.pack.old +0 -0
- package/.next/cache/webpack/edge-server-production/0.pack +0 -0
- package/.next/cache/webpack/edge-server-production/index.pack +0 -0
- package/.next/cache/webpack/server-production/0.pack +0 -0
- package/.next/cache/webpack/server-production/index.pack +0 -0
- package/.next/next-server.js.nft.json +1 -1
- package/.next/prerender-manifest.json +1 -1
- package/.next/required-server-files.json +1 -1
- package/.next/routes-manifest.json +1 -1
- package/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/api/app/update-check/route.js +1 -1
- package/.next/server/app/api/external-apps/[id]/health/route.js +1 -1
- package/.next/server/app/api/external-apps/[id]/route.js +1 -1
- package/.next/server/app/api/external-apps/route.js +1 -1
- package/.next/server/app/api/hooks/claude-done/route.js +1 -1
- package/.next/server/app/api/repositories/clone/[jobId]/route.js +1 -1
- package/.next/server/app/api/repositories/clone/route.js +1 -1
- package/.next/server/app/api/repositories/excluded/route.js +7 -7
- package/.next/server/app/api/repositories/restore/route.js +3 -3
- package/.next/server/app/api/repositories/route.js +13 -11
- package/.next/server/app/api/repositories/scan/route.js +1 -1
- package/.next/server/app/api/repositories/sync/route.js +3 -3
- package/.next/server/app/api/worktrees/[id]/auto-yes/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/cli-tool/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/current-output/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/execution-logs/[logId]/route.js +1 -0
- package/.next/server/app/api/worktrees/[id]/execution-logs/[logId]/route.js.nft.json +1 -0
- package/.next/server/app/api/worktrees/[id]/execution-logs/route.js +9 -0
- package/.next/server/app/api/worktrees/[id]/execution-logs/route.js.nft.json +1 -0
- package/.next/server/app/api/worktrees/[id]/files/[...path]/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/interrupt/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/kill-session/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/logs/[filename]/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/logs/route.js +2 -2
- package/.next/server/app/api/worktrees/[id]/memos/[memoId]/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/memos/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/messages/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/prompt-response/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/respond/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/schedules/[scheduleId]/route.js +1 -0
- package/.next/server/app/api/worktrees/[id]/schedules/[scheduleId]/route.js.nft.json +1 -0
- package/.next/server/app/api/worktrees/[id]/schedules/route.js +4 -0
- package/.next/server/app/api/worktrees/[id]/schedules/route.js.nft.json +1 -0
- package/.next/server/app/api/worktrees/[id]/search/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/send/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/slash-commands/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/start-polling/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/tree/[...path]/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/tree/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/upload/[...path]/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/viewed/route.js +1 -1
- package/.next/server/app/api/worktrees/route.js +1 -1
- package/.next/server/app/login/page.js.nft.json +1 -1
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/proxy/[...path]/route.js +1 -1
- package/.next/server/app/worktrees/[id]/files/[...path]/page.js.nft.json +1 -1
- package/.next/server/app/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/worktrees/[id]/page.js +8 -3
- package/.next/server/app/worktrees/[id]/page.js.nft.json +1 -1
- package/.next/server/app/worktrees/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/worktrees/[id]/terminal/page.js.nft.json +1 -1
- package/.next/server/app/worktrees/[id]/terminal/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +13 -9
- package/.next/server/chunks/2314.js +1 -0
- package/.next/server/chunks/3860.js +1 -1
- package/.next/server/chunks/6228.js +1 -0
- package/.next/server/chunks/7425.js +85 -30
- package/.next/server/chunks/7536.js +1 -1
- package/.next/server/chunks/7566.js +2 -2
- package/.next/server/functions-config-manifest.json +1 -1
- package/.next/server/middleware-manifest.json +5 -5
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/app/worktrees/[id]/page-0c889ab3f30d5af7.js +1 -0
- package/.next/static/css/bd6065b03ddb3efd.css +3 -0
- package/.next/trace +5 -5
- package/.next/types/app/api/worktrees/[id]/execution-logs/[logId]/route.ts +343 -0
- package/.next/types/app/api/worktrees/[id]/execution-logs/route.ts +343 -0
- package/.next/types/app/api/worktrees/[id]/schedules/[scheduleId]/route.ts +343 -0
- package/.next/types/app/api/worktrees/[id]/schedules/route.ts +343 -0
- package/dist/cli/utils/docs-reader.d.ts.map +1 -1
- package/dist/cli/utils/docs-reader.js +1 -0
- package/dist/server/server.js +5 -0
- package/dist/server/src/config/cmate-constants.js +79 -0
- package/dist/server/src/config/schedule-config.js +54 -0
- package/dist/server/src/lib/claude-executor.js +147 -0
- package/dist/server/src/lib/claude-session.js +31 -6
- package/dist/server/src/lib/cli-patterns.js +1 -1
- package/dist/server/src/lib/cmate-parser.js +240 -0
- package/dist/server/src/lib/db-instance.js +3 -0
- package/dist/server/src/lib/db-migrations.js +96 -2
- package/dist/server/src/lib/env-sanitizer.js +57 -0
- package/dist/server/src/lib/response-poller.js +3 -2
- package/dist/server/src/lib/schedule-manager.js +397 -0
- package/dist/server/src/types/cmate.js +6 -0
- package/package.json +2 -1
- package/.next/static/chunks/app/worktrees/[id]/page-9418e49bdc1de02c.js +0 -1
- package/.next/static/css/b9ea6a4fad17dc32.css +0 -3
- /package/.next/static/{clTo9tuAoPMLcGRuVENfO → j8HFvzDZj7tHjAnhpXUno}/_buildManifest.js +0 -0
- /package/.next/static/{clTo9tuAoPMLcGRuVENfO → j8HFvzDZj7tHjAnhpXUno}/_ssgManifest.js +0 -0
|
@@ -19,7 +19,7 @@ const db_1 = require("./db");
|
|
|
19
19
|
* Current schema version
|
|
20
20
|
* Increment this when adding new migrations
|
|
21
21
|
*/
|
|
22
|
-
exports.CURRENT_SCHEMA_VERSION =
|
|
22
|
+
exports.CURRENT_SCHEMA_VERSION = 17;
|
|
23
23
|
/**
|
|
24
24
|
* Migration registry
|
|
25
25
|
* All migrations should be added to this array in order
|
|
@@ -717,6 +717,100 @@ const migrations = [
|
|
|
717
717
|
`);
|
|
718
718
|
console.log('✓ Removed issue_no column from external_apps table');
|
|
719
719
|
}
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
version: 17,
|
|
723
|
+
name: 'add-scheduled-executions-and-execution-logs',
|
|
724
|
+
up: (db) => {
|
|
725
|
+
// Issue #294: Schedule execution feature
|
|
726
|
+
// [S3-002] Clean up orphan records BEFORE creating new tables with FK constraints
|
|
727
|
+
// These records may exist if worktrees/repositories were deleted while FK was disabled
|
|
728
|
+
db.exec(`
|
|
729
|
+
DELETE FROM chat_messages WHERE worktree_id NOT IN (SELECT id FROM worktrees);
|
|
730
|
+
`);
|
|
731
|
+
db.exec(`
|
|
732
|
+
DELETE FROM session_states WHERE worktree_id NOT IN (SELECT id FROM worktrees);
|
|
733
|
+
`);
|
|
734
|
+
db.exec(`
|
|
735
|
+
DELETE FROM worktree_memos WHERE worktree_id NOT IN (SELECT id FROM worktrees);
|
|
736
|
+
`);
|
|
737
|
+
db.exec(`
|
|
738
|
+
UPDATE clone_jobs SET repository_id = NULL
|
|
739
|
+
WHERE repository_id IS NOT NULL AND repository_id NOT IN (SELECT id FROM repositories);
|
|
740
|
+
`);
|
|
741
|
+
// Create scheduled_executions table
|
|
742
|
+
db.exec(`
|
|
743
|
+
CREATE TABLE scheduled_executions (
|
|
744
|
+
id TEXT PRIMARY KEY,
|
|
745
|
+
worktree_id TEXT NOT NULL,
|
|
746
|
+
cli_tool_id TEXT DEFAULT 'claude',
|
|
747
|
+
name TEXT NOT NULL,
|
|
748
|
+
message TEXT NOT NULL,
|
|
749
|
+
cron_expression TEXT,
|
|
750
|
+
enabled INTEGER DEFAULT 1,
|
|
751
|
+
last_executed_at INTEGER,
|
|
752
|
+
next_execute_at INTEGER,
|
|
753
|
+
created_at INTEGER NOT NULL,
|
|
754
|
+
updated_at INTEGER NOT NULL,
|
|
755
|
+
UNIQUE(worktree_id, name),
|
|
756
|
+
FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE CASCADE
|
|
757
|
+
);
|
|
758
|
+
`);
|
|
759
|
+
// Create index on worktree_id for scheduled_executions
|
|
760
|
+
db.exec(`
|
|
761
|
+
CREATE INDEX idx_scheduled_executions_worktree
|
|
762
|
+
ON scheduled_executions(worktree_id);
|
|
763
|
+
`);
|
|
764
|
+
// Create index on enabled for filtering active schedules
|
|
765
|
+
db.exec(`
|
|
766
|
+
CREATE INDEX idx_scheduled_executions_enabled
|
|
767
|
+
ON scheduled_executions(enabled);
|
|
768
|
+
`);
|
|
769
|
+
// Create execution_logs table
|
|
770
|
+
db.exec(`
|
|
771
|
+
CREATE TABLE execution_logs (
|
|
772
|
+
id TEXT PRIMARY KEY,
|
|
773
|
+
schedule_id TEXT NOT NULL,
|
|
774
|
+
worktree_id TEXT NOT NULL,
|
|
775
|
+
message TEXT NOT NULL,
|
|
776
|
+
result TEXT,
|
|
777
|
+
exit_code INTEGER,
|
|
778
|
+
status TEXT DEFAULT 'running' CHECK(status IN ('running', 'completed', 'failed', 'timeout', 'cancelled')),
|
|
779
|
+
started_at INTEGER NOT NULL,
|
|
780
|
+
completed_at INTEGER,
|
|
781
|
+
created_at INTEGER NOT NULL,
|
|
782
|
+
FOREIGN KEY (schedule_id) REFERENCES scheduled_executions(id) ON DELETE CASCADE,
|
|
783
|
+
FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE CASCADE
|
|
784
|
+
);
|
|
785
|
+
`);
|
|
786
|
+
// Create indexes for execution_logs
|
|
787
|
+
db.exec(`
|
|
788
|
+
CREATE INDEX idx_execution_logs_schedule
|
|
789
|
+
ON execution_logs(schedule_id);
|
|
790
|
+
`);
|
|
791
|
+
db.exec(`
|
|
792
|
+
CREATE INDEX idx_execution_logs_worktree
|
|
793
|
+
ON execution_logs(worktree_id);
|
|
794
|
+
`);
|
|
795
|
+
db.exec(`
|
|
796
|
+
CREATE INDEX idx_execution_logs_status
|
|
797
|
+
ON execution_logs(status);
|
|
798
|
+
`);
|
|
799
|
+
console.log('✓ Cleaned up orphan records');
|
|
800
|
+
console.log('✓ Created scheduled_executions table');
|
|
801
|
+
console.log('✓ Created execution_logs table');
|
|
802
|
+
console.log('✓ Created indexes for schedule tables');
|
|
803
|
+
},
|
|
804
|
+
down: (db) => {
|
|
805
|
+
db.exec('DROP INDEX IF EXISTS idx_execution_logs_status');
|
|
806
|
+
db.exec('DROP INDEX IF EXISTS idx_execution_logs_worktree');
|
|
807
|
+
db.exec('DROP INDEX IF EXISTS idx_execution_logs_schedule');
|
|
808
|
+
db.exec('DROP TABLE IF EXISTS execution_logs');
|
|
809
|
+
db.exec('DROP INDEX IF EXISTS idx_scheduled_executions_enabled');
|
|
810
|
+
db.exec('DROP INDEX IF EXISTS idx_scheduled_executions_worktree');
|
|
811
|
+
db.exec('DROP TABLE IF EXISTS scheduled_executions');
|
|
812
|
+
console.log('✓ Dropped scheduled_executions and execution_logs tables');
|
|
813
|
+
}
|
|
720
814
|
}
|
|
721
815
|
];
|
|
722
816
|
/**
|
|
@@ -904,7 +998,7 @@ function validateSchema(db) {
|
|
|
904
998
|
ORDER BY name
|
|
905
999
|
`).all();
|
|
906
1000
|
const tableNames = tables.map(t => t.name);
|
|
907
|
-
const requiredTables = ['worktrees', 'chat_messages', 'session_states', 'schema_version', 'worktree_memos', 'external_apps', 'repositories', 'clone_jobs'];
|
|
1001
|
+
const requiredTables = ['worktrees', 'chat_messages', 'session_states', 'schema_version', 'worktree_memos', 'external_apps', 'repositories', 'clone_jobs', 'scheduled_executions', 'execution_logs'];
|
|
908
1002
|
const missingTables = requiredTables.filter(t => !tableNames.includes(t));
|
|
909
1003
|
if (missingTables.length > 0) {
|
|
910
1004
|
console.error('Missing required tables:', missingTables.join(', '));
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Environment Variable Sanitizer
|
|
4
|
+
* Issue #294: Sanitizes environment variables for child processes
|
|
5
|
+
*
|
|
6
|
+
* Removes sensitive environment variables (auth tokens, certificates, database paths)
|
|
7
|
+
* before spawning child processes like `claude -p`.
|
|
8
|
+
*
|
|
9
|
+
* [S1-001/S4-001] Centralized sensitive key management
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.SENSITIVE_ENV_KEYS = void 0;
|
|
13
|
+
exports.sanitizeEnvForChildProcess = sanitizeEnvForChildProcess;
|
|
14
|
+
/**
|
|
15
|
+
* List of environment variable keys that must be removed before
|
|
16
|
+
* passing environment to child processes.
|
|
17
|
+
*
|
|
18
|
+
* These include authentication tokens, TLS certificates, IP restriction
|
|
19
|
+
* settings, and database paths that should not be inherited by spawned
|
|
20
|
+
* CLI tool processes.
|
|
21
|
+
*/
|
|
22
|
+
exports.SENSITIVE_ENV_KEYS = [
|
|
23
|
+
'CLAUDECODE',
|
|
24
|
+
'CM_AUTH_TOKEN_HASH',
|
|
25
|
+
'CM_AUTH_EXPIRE',
|
|
26
|
+
'CM_HTTPS_KEY',
|
|
27
|
+
'CM_HTTPS_CERT',
|
|
28
|
+
'CM_ALLOWED_IPS',
|
|
29
|
+
'CM_TRUST_PROXY',
|
|
30
|
+
'CM_DB_PATH',
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* Create a sanitized copy of process.env suitable for child processes.
|
|
34
|
+
*
|
|
35
|
+
* Removes all keys listed in SENSITIVE_ENV_KEYS from the environment.
|
|
36
|
+
* Non-sensitive variables (PATH, HOME, NODE_ENV, etc.) are preserved.
|
|
37
|
+
*
|
|
38
|
+
* @returns A shallow copy of process.env with sensitive keys removed
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* import { execFile } from 'child_process';
|
|
43
|
+
* import { sanitizeEnvForChildProcess } from './env-sanitizer';
|
|
44
|
+
*
|
|
45
|
+
* execFile('claude', ['-p', message], {
|
|
46
|
+
* env: sanitizeEnvForChildProcess(),
|
|
47
|
+
* cwd: worktreePath,
|
|
48
|
+
* });
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
function sanitizeEnvForChildProcess() {
|
|
52
|
+
const env = { ...process.env };
|
|
53
|
+
for (const key of exports.SENSITIVE_ENV_KEYS) {
|
|
54
|
+
delete env[key];
|
|
55
|
+
}
|
|
56
|
+
return env;
|
|
57
|
+
}
|
|
@@ -35,9 +35,10 @@ const cli_patterns_1 = require("./cli-patterns");
|
|
|
35
35
|
*/
|
|
36
36
|
const POLLING_INTERVAL = 2000;
|
|
37
37
|
/**
|
|
38
|
-
* Maximum polling duration in milliseconds (default:
|
|
38
|
+
* Maximum polling duration in milliseconds (default: 30 minutes)
|
|
39
|
+
* Previously 5 minutes, which caused silent polling stops for long-running tasks.
|
|
39
40
|
*/
|
|
40
|
-
const MAX_POLLING_DURATION =
|
|
41
|
+
const MAX_POLLING_DURATION = 30 * 60 * 1000;
|
|
41
42
|
/**
|
|
42
43
|
* Number of tail lines to check for active thinking indicators in response extraction.
|
|
43
44
|
*
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Schedule Manager
|
|
4
|
+
* Issue #294: Manages scheduled execution of claude -p commands
|
|
5
|
+
*
|
|
6
|
+
* Uses a single timer to periodically scan all worktrees for CMATE.md changes
|
|
7
|
+
* and execute scheduled tasks via croner cron expressions.
|
|
8
|
+
*
|
|
9
|
+
* Patterns:
|
|
10
|
+
* - globalThis for hot reload persistence (same as auto-yes-manager.ts)
|
|
11
|
+
* - Single timer for all worktrees (60 second polling interval)
|
|
12
|
+
* - SIGKILL fire-and-forget for stopAllSchedules (< 1ms, within 3s graceful shutdown)
|
|
13
|
+
*
|
|
14
|
+
* [S3-001] stopAllSchedules() uses synchronous process.kill for immediate cleanup
|
|
15
|
+
* [S3-010] initScheduleManager() is called after initializeWorktrees()
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.MAX_CONCURRENT_SCHEDULES = exports.POLL_INTERVAL_MS = void 0;
|
|
19
|
+
exports.initScheduleManager = initScheduleManager;
|
|
20
|
+
exports.stopAllSchedules = stopAllSchedules;
|
|
21
|
+
exports.getActiveScheduleCount = getActiveScheduleCount;
|
|
22
|
+
exports.isScheduleManagerInitialized = isScheduleManagerInitialized;
|
|
23
|
+
const crypto_1 = require("crypto");
|
|
24
|
+
const croner_1 = require("croner");
|
|
25
|
+
const cmate_parser_1 = require("./cmate-parser");
|
|
26
|
+
const claude_executor_1 = require("./claude-executor");
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// Constants
|
|
29
|
+
// =============================================================================
|
|
30
|
+
/** Polling interval for CMATE.md changes (60 seconds) */
|
|
31
|
+
exports.POLL_INTERVAL_MS = 60 * 1000;
|
|
32
|
+
/** Maximum number of concurrent schedules across all worktrees */
|
|
33
|
+
exports.MAX_CONCURRENT_SCHEDULES = 100;
|
|
34
|
+
/**
|
|
35
|
+
* Get or initialize the global manager state.
|
|
36
|
+
*/
|
|
37
|
+
function getManagerState() {
|
|
38
|
+
if (!globalThis.__scheduleManagerStates) {
|
|
39
|
+
globalThis.__scheduleManagerStates = {
|
|
40
|
+
timerId: null,
|
|
41
|
+
schedules: new Map(),
|
|
42
|
+
initialized: false,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return globalThis.__scheduleManagerStates;
|
|
46
|
+
}
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// Lazy DB Accessor
|
|
49
|
+
// =============================================================================
|
|
50
|
+
/**
|
|
51
|
+
* Lazy-load the DB instance to avoid circular import issues.
|
|
52
|
+
* The db-instance module is loaded at runtime via require() because
|
|
53
|
+
* schedule-manager.ts is imported early in the server lifecycle.
|
|
54
|
+
*
|
|
55
|
+
* @returns The SQLite database instance
|
|
56
|
+
*/
|
|
57
|
+
function getLazyDbInstance() {
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
59
|
+
const { getDbInstance } = require('./db-instance');
|
|
60
|
+
return getDbInstance();
|
|
61
|
+
}
|
|
62
|
+
// =============================================================================
|
|
63
|
+
// DB Operations
|
|
64
|
+
// =============================================================================
|
|
65
|
+
/**
|
|
66
|
+
* Get all worktrees from the database.
|
|
67
|
+
*
|
|
68
|
+
* @returns Array of worktree rows with id and path
|
|
69
|
+
*/
|
|
70
|
+
function getAllWorktrees() {
|
|
71
|
+
try {
|
|
72
|
+
const db = getLazyDbInstance();
|
|
73
|
+
return db.prepare('SELECT id, path FROM worktrees').all();
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
console.error('[schedule-manager] Failed to get worktrees:', error);
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Upsert a schedule entry into the database.
|
|
82
|
+
* If a schedule with the same worktree_id and name exists, it is updated.
|
|
83
|
+
* Otherwise, a new schedule is created.
|
|
84
|
+
*
|
|
85
|
+
* @param worktreeId - The worktree ID to associate the schedule with
|
|
86
|
+
* @param entry - The schedule entry from CMATE.md
|
|
87
|
+
* @returns The schedule ID (existing or newly created)
|
|
88
|
+
*/
|
|
89
|
+
function upsertSchedule(worktreeId, entry) {
|
|
90
|
+
const db = getLazyDbInstance();
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
// Check if schedule already exists
|
|
93
|
+
const existing = db.prepare('SELECT id FROM scheduled_executions WHERE worktree_id = ? AND name = ?').get(worktreeId, entry.name);
|
|
94
|
+
if (existing) {
|
|
95
|
+
db.prepare(`
|
|
96
|
+
UPDATE scheduled_executions
|
|
97
|
+
SET message = ?, cron_expression = ?, cli_tool_id = ?, enabled = ?, updated_at = ?
|
|
98
|
+
WHERE id = ?
|
|
99
|
+
`).run(entry.message, entry.cronExpression, entry.cliToolId, entry.enabled ? 1 : 0, now, existing.id);
|
|
100
|
+
return existing.id;
|
|
101
|
+
}
|
|
102
|
+
const id = (0, crypto_1.randomUUID)();
|
|
103
|
+
db.prepare(`
|
|
104
|
+
INSERT INTO scheduled_executions (id, worktree_id, name, message, cron_expression, cli_tool_id, enabled, created_at, updated_at)
|
|
105
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
106
|
+
`).run(id, worktreeId, entry.name, entry.message, entry.cronExpression, entry.cliToolId, entry.enabled ? 1 : 0, now, now);
|
|
107
|
+
return id;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Create an execution log entry in 'running' status.
|
|
111
|
+
*
|
|
112
|
+
* @param scheduleId - The parent schedule ID
|
|
113
|
+
* @param worktreeId - The worktree ID
|
|
114
|
+
* @param message - The execution message/prompt
|
|
115
|
+
* @returns The new execution log ID
|
|
116
|
+
*/
|
|
117
|
+
function createExecutionLog(scheduleId, worktreeId, message) {
|
|
118
|
+
const db = getLazyDbInstance();
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
const id = (0, crypto_1.randomUUID)();
|
|
121
|
+
db.prepare(`
|
|
122
|
+
INSERT INTO execution_logs (id, schedule_id, worktree_id, message, status, started_at, created_at)
|
|
123
|
+
VALUES (?, ?, ?, ?, 'running', ?, ?)
|
|
124
|
+
`).run(id, scheduleId, worktreeId, message, now, now);
|
|
125
|
+
return id;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Update an execution log entry with results.
|
|
129
|
+
*
|
|
130
|
+
* @param logId - The execution log ID to update
|
|
131
|
+
* @param status - The final execution status
|
|
132
|
+
* @param result - The execution output or error message
|
|
133
|
+
* @param exitCode - The process exit code, or null if unknown
|
|
134
|
+
*/
|
|
135
|
+
function updateExecutionLog(logId, status, result, exitCode) {
|
|
136
|
+
const db = getLazyDbInstance();
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
db.prepare(`
|
|
139
|
+
UPDATE execution_logs SET status = ?, result = ?, exit_code = ?, completed_at = ? WHERE id = ?
|
|
140
|
+
`).run(status, result, exitCode, now, logId);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Update the last_executed_at timestamp for a schedule.
|
|
144
|
+
*
|
|
145
|
+
* @param scheduleId - The schedule ID to update
|
|
146
|
+
*/
|
|
147
|
+
function updateScheduleLastExecuted(scheduleId) {
|
|
148
|
+
const db = getLazyDbInstance();
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
db.prepare('UPDATE scheduled_executions SET last_executed_at = ?, updated_at = ? WHERE id = ?')
|
|
151
|
+
.run(now, now, scheduleId);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Recovery: mark all 'running' execution logs as 'failed' on startup.
|
|
155
|
+
* This handles the case where the server was killed while executions
|
|
156
|
+
* were still in progress.
|
|
157
|
+
*/
|
|
158
|
+
function recoverRunningLogs() {
|
|
159
|
+
try {
|
|
160
|
+
const db = getLazyDbInstance();
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
const result = db.prepare("UPDATE execution_logs SET status = 'failed', completed_at = ? WHERE status = 'running'").run(now);
|
|
163
|
+
if (result.changes > 0) {
|
|
164
|
+
console.warn(`[schedule-manager] Recovered ${result.changes} stale running execution(s) to failed status`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
console.error('[schedule-manager] Failed to recover running logs:', error);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Disable DB schedules that are no longer present in CMATE.md.
|
|
173
|
+
* Sets enabled = 0 for schedules belonging to the given worktrees
|
|
174
|
+
* that are not in the activeScheduleIds set.
|
|
175
|
+
* Skips records already disabled to avoid unnecessary DB writes.
|
|
176
|
+
*
|
|
177
|
+
* @param activeScheduleIds - Set of schedule IDs currently active from CMATE.md
|
|
178
|
+
* @param worktreeIds - Array of worktree IDs that were scanned
|
|
179
|
+
*/
|
|
180
|
+
function disableStaleSchedules(activeScheduleIds, worktreeIds) {
|
|
181
|
+
if (worktreeIds.length === 0)
|
|
182
|
+
return;
|
|
183
|
+
try {
|
|
184
|
+
const db = getLazyDbInstance();
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
const placeholders = worktreeIds.map(() => '?').join(',');
|
|
187
|
+
// Get enabled schedules for the scanned worktrees
|
|
188
|
+
const rows = db.prepare(`SELECT id FROM scheduled_executions WHERE worktree_id IN (${placeholders}) AND enabled = 1`).all(...worktreeIds);
|
|
189
|
+
let disabledCount = 0;
|
|
190
|
+
const updateStmt = db.prepare('UPDATE scheduled_executions SET enabled = 0, updated_at = ? WHERE id = ?');
|
|
191
|
+
for (const row of rows) {
|
|
192
|
+
if (!activeScheduleIds.has(row.id)) {
|
|
193
|
+
updateStmt.run(now, row.id);
|
|
194
|
+
disabledCount++;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (disabledCount > 0) {
|
|
198
|
+
console.log(`[schedule-manager] Disabled ${disabledCount} stale DB schedule(s)`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
console.error('[schedule-manager] Failed to disable stale schedules:', error);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// =============================================================================
|
|
206
|
+
// Schedule Execution
|
|
207
|
+
// =============================================================================
|
|
208
|
+
/**
|
|
209
|
+
* Execute a scheduled task.
|
|
210
|
+
* Guards against concurrent execution of the same schedule.
|
|
211
|
+
*
|
|
212
|
+
* @param state - The schedule state to execute
|
|
213
|
+
*/
|
|
214
|
+
async function executeSchedule(state) {
|
|
215
|
+
if (state.isExecuting) {
|
|
216
|
+
console.warn(`[schedule-manager] Skipping concurrent execution for schedule ${state.entry.name}`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
state.isExecuting = true;
|
|
220
|
+
const logId = createExecutionLog(state.scheduleId, state.worktreeId, state.entry.message);
|
|
221
|
+
try {
|
|
222
|
+
const db = getLazyDbInstance();
|
|
223
|
+
const worktree = db.prepare('SELECT path FROM worktrees WHERE id = ?').get(state.worktreeId);
|
|
224
|
+
if (!worktree) {
|
|
225
|
+
updateExecutionLog(logId, 'failed', 'Worktree not found', null);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const result = await (0, claude_executor_1.executeClaudeCommand)(state.entry.message, worktree.path, state.entry.cliToolId, state.entry.permission);
|
|
229
|
+
updateExecutionLog(logId, result.status, result.output, result.exitCode);
|
|
230
|
+
updateScheduleLastExecuted(state.scheduleId);
|
|
231
|
+
console.log(`[schedule-manager] Executed ${state.entry.name}: ${result.status}`);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
235
|
+
updateExecutionLog(logId, 'failed', errorMessage, null);
|
|
236
|
+
console.error(`[schedule-manager] Execution error for ${state.entry.name}:`, errorMessage);
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
state.isExecuting = false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// =============================================================================
|
|
243
|
+
// CMATE.md Sync
|
|
244
|
+
// =============================================================================
|
|
245
|
+
/**
|
|
246
|
+
* Sync schedules from CMATE.md files for all worktrees.
|
|
247
|
+
* Reads CMATE.md from each worktree, upserts schedules to DB,
|
|
248
|
+
* creates/updates cron jobs, and removes stale schedules.
|
|
249
|
+
*/
|
|
250
|
+
function syncSchedules() {
|
|
251
|
+
const manager = getManagerState();
|
|
252
|
+
const worktrees = getAllWorktrees();
|
|
253
|
+
// Track which scheduleIds are still valid
|
|
254
|
+
const activeScheduleIds = new Set();
|
|
255
|
+
for (const worktree of worktrees) {
|
|
256
|
+
try {
|
|
257
|
+
const config = (0, cmate_parser_1.readCmateFile)(worktree.path);
|
|
258
|
+
if (!config)
|
|
259
|
+
continue;
|
|
260
|
+
const scheduleRows = config.get('Schedules');
|
|
261
|
+
if (!scheduleRows)
|
|
262
|
+
continue;
|
|
263
|
+
const entries = (0, cmate_parser_1.parseSchedulesSection)(scheduleRows);
|
|
264
|
+
for (const entry of entries) {
|
|
265
|
+
if (manager.schedules.size >= exports.MAX_CONCURRENT_SCHEDULES) {
|
|
266
|
+
console.warn(`[schedule-manager] MAX_CONCURRENT_SCHEDULES (${exports.MAX_CONCURRENT_SCHEDULES}) reached`);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const scheduleId = upsertSchedule(worktree.id, entry);
|
|
270
|
+
activeScheduleIds.add(scheduleId);
|
|
271
|
+
// Check if this schedule already has a running cron job
|
|
272
|
+
const existingState = manager.schedules.get(scheduleId);
|
|
273
|
+
if (existingState) {
|
|
274
|
+
// Update entry if changed
|
|
275
|
+
existingState.entry = entry;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (!entry.enabled || !entry.cronExpression)
|
|
279
|
+
continue;
|
|
280
|
+
// Create new cron job
|
|
281
|
+
try {
|
|
282
|
+
const cronJob = new croner_1.Cron(entry.cronExpression, {
|
|
283
|
+
paused: false,
|
|
284
|
+
protect: true, // Prevent overlapping
|
|
285
|
+
});
|
|
286
|
+
const state = {
|
|
287
|
+
scheduleId,
|
|
288
|
+
worktreeId: worktree.id,
|
|
289
|
+
cronJob,
|
|
290
|
+
isExecuting: false,
|
|
291
|
+
entry,
|
|
292
|
+
};
|
|
293
|
+
// Schedule execution
|
|
294
|
+
cronJob.schedule(() => {
|
|
295
|
+
void executeSchedule(state);
|
|
296
|
+
});
|
|
297
|
+
manager.schedules.set(scheduleId, state);
|
|
298
|
+
console.log(`[schedule-manager] Scheduled ${entry.name} (${entry.cronExpression})`);
|
|
299
|
+
}
|
|
300
|
+
catch (cronError) {
|
|
301
|
+
console.warn(`[schedule-manager] Invalid cron for ${entry.name}:`, cronError);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
console.error(`[schedule-manager] Error syncing schedules for worktree ${worktree.id}:`, error);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Clean up schedules that no longer exist in CMATE.md
|
|
310
|
+
for (const [scheduleId, state] of manager.schedules) {
|
|
311
|
+
if (!activeScheduleIds.has(scheduleId)) {
|
|
312
|
+
state.cronJob.stop();
|
|
313
|
+
manager.schedules.delete(scheduleId);
|
|
314
|
+
console.log(`[schedule-manager] Removed stale schedule ${state.entry.name}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// Disable DB records for schedules no longer in CMATE.md
|
|
318
|
+
const worktreeIds = worktrees.map(w => w.id);
|
|
319
|
+
disableStaleSchedules(activeScheduleIds, worktreeIds);
|
|
320
|
+
}
|
|
321
|
+
// =============================================================================
|
|
322
|
+
// Manager Lifecycle
|
|
323
|
+
// =============================================================================
|
|
324
|
+
/**
|
|
325
|
+
* Initialize the schedule manager.
|
|
326
|
+
* Must be called after initializeWorktrees() completes.
|
|
327
|
+
*
|
|
328
|
+
* [S3-010] Called after await initializeWorktrees() in server.ts
|
|
329
|
+
*/
|
|
330
|
+
function initScheduleManager() {
|
|
331
|
+
const manager = getManagerState();
|
|
332
|
+
if (manager.initialized) {
|
|
333
|
+
console.log('[schedule-manager] Already initialized, skipping');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
console.log('[schedule-manager] Initializing...');
|
|
337
|
+
// Recovery: mark stale running logs as failed
|
|
338
|
+
recoverRunningLogs();
|
|
339
|
+
// Initial sync
|
|
340
|
+
syncSchedules();
|
|
341
|
+
// Start periodic sync timer
|
|
342
|
+
manager.timerId = setInterval(() => {
|
|
343
|
+
syncSchedules();
|
|
344
|
+
}, exports.POLL_INTERVAL_MS);
|
|
345
|
+
manager.initialized = true;
|
|
346
|
+
console.log(`[schedule-manager] Initialized with ${manager.schedules.size} schedule(s)`);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Stop all schedules and clean up resources.
|
|
350
|
+
* Uses synchronous SIGKILL fire-and-forget for immediate cleanup.
|
|
351
|
+
*
|
|
352
|
+
* [S3-001] Designed to complete within gracefulShutdown's 3-second timeout
|
|
353
|
+
*/
|
|
354
|
+
function stopAllSchedules() {
|
|
355
|
+
const manager = getManagerState();
|
|
356
|
+
// Stop the polling timer
|
|
357
|
+
if (manager.timerId !== null) {
|
|
358
|
+
clearInterval(manager.timerId);
|
|
359
|
+
manager.timerId = null;
|
|
360
|
+
}
|
|
361
|
+
// Stop all cron jobs
|
|
362
|
+
for (const [, state] of manager.schedules) {
|
|
363
|
+
try {
|
|
364
|
+
state.cronJob.stop();
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// Ignore errors during cleanup
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
manager.schedules.clear();
|
|
371
|
+
// Kill all active child processes (fire-and-forget SIGKILL)
|
|
372
|
+
const activeProcesses = (0, claude_executor_1.getActiveProcesses)();
|
|
373
|
+
for (const [pid] of activeProcesses) {
|
|
374
|
+
try {
|
|
375
|
+
process.kill(pid, 'SIGKILL');
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
// Process may have already exited - ignore
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
activeProcesses.clear();
|
|
382
|
+
manager.initialized = false;
|
|
383
|
+
console.log('[schedule-manager] All schedules stopped');
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Get the current number of active schedules.
|
|
387
|
+
* Useful for monitoring and testing.
|
|
388
|
+
*/
|
|
389
|
+
function getActiveScheduleCount() {
|
|
390
|
+
return getManagerState().schedules.size;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Check if the schedule manager is initialized.
|
|
394
|
+
*/
|
|
395
|
+
function isScheduleManagerInitialized() {
|
|
396
|
+
return getManagerState().initialized;
|
|
397
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "commandmate",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Git worktree management with Claude CLI and tmux sessions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"autoprefixer": "^10.4.22",
|
|
52
52
|
"better-sqlite3": "^12.4.1",
|
|
53
53
|
"commander": "^14.0.2",
|
|
54
|
+
"croner": "^10.0.1",
|
|
54
55
|
"date-fns": "^4.1.0",
|
|
55
56
|
"dotenv": "^17.2.3",
|
|
56
57
|
"gray-matter": "^4.0.3",
|