create-claude-workspace 1.1.151 → 2.0.0
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/README.md +33 -1
- package/dist/index.js +29 -56
- package/dist/scheduler/agents/health-checker.mjs +98 -0
- package/dist/scheduler/agents/health-checker.spec.js +143 -0
- package/dist/scheduler/agents/orchestrator.mjs +149 -0
- package/dist/scheduler/agents/orchestrator.spec.js +87 -0
- package/dist/scheduler/agents/prompt-builder.mjs +204 -0
- package/dist/scheduler/agents/prompt-builder.spec.js +240 -0
- package/dist/scheduler/agents/worker-pool.mjs +137 -0
- package/dist/scheduler/agents/worker-pool.spec.js +45 -0
- package/dist/scheduler/git/ci-watcher.mjs +93 -0
- package/dist/scheduler/git/ci-watcher.spec.js +35 -0
- package/dist/scheduler/git/manager.mjs +228 -0
- package/dist/scheduler/git/manager.spec.js +198 -0
- package/dist/scheduler/git/release.mjs +117 -0
- package/dist/scheduler/git/release.spec.js +175 -0
- package/dist/scheduler/index.mjs +309 -0
- package/dist/scheduler/index.spec.js +72 -0
- package/dist/scheduler/integration.spec.js +289 -0
- package/dist/scheduler/loop.mjs +435 -0
- package/dist/scheduler/loop.spec.js +139 -0
- package/dist/scheduler/state/session.mjs +14 -0
- package/dist/scheduler/state/session.spec.js +36 -0
- package/dist/scheduler/state/state.mjs +102 -0
- package/dist/scheduler/state/state.spec.js +175 -0
- package/dist/scheduler/tasks/inbox.mjs +98 -0
- package/dist/scheduler/tasks/inbox.spec.js +168 -0
- package/dist/scheduler/tasks/parser.mjs +228 -0
- package/dist/scheduler/tasks/parser.spec.js +303 -0
- package/dist/scheduler/tasks/queue.mjs +152 -0
- package/dist/scheduler/tasks/queue.spec.js +223 -0
- package/dist/scheduler/types.mjs +20 -0
- package/dist/{scripts/lib → scheduler/ui}/tui.mjs +84 -41
- package/dist/{scripts/lib → scheduler/ui}/tui.spec.js +56 -0
- package/dist/scheduler/util/memory.mjs +126 -0
- package/dist/scheduler/util/memory.spec.js +165 -0
- package/dist/template/.claude/{profiles/angular.md → agents/angular-engineer.md} +9 -4
- package/dist/template/.claude/{profiles/react.md → agents/react-engineer.md} +9 -4
- package/dist/template/.claude/{profiles/svelte.md → agents/svelte-engineer.md} +9 -4
- package/dist/template/.claude/{profiles/vue.md → agents/vue-engineer.md} +9 -4
- package/package.json +3 -4
- package/dist/scripts/autonomous.mjs +0 -492
- package/dist/scripts/autonomous.spec.js +0 -46
- package/dist/scripts/docker-run.mjs +0 -462
- package/dist/scripts/integration.spec.js +0 -108
- package/dist/scripts/lib/formatter.mjs +0 -309
- package/dist/scripts/lib/formatter.spec.js +0 -262
- package/dist/scripts/lib/state.mjs +0 -44
- package/dist/scripts/lib/state.spec.js +0 -59
- package/dist/template/.claude/docker/.dockerignore +0 -8
- package/dist/template/.claude/docker/Dockerfile +0 -54
- package/dist/template/.claude/docker/docker-compose.yml +0 -22
- package/dist/template/.claude/docker/docker-entrypoint.sh +0 -101
- /package/dist/{scripts/lib/types.mjs → scheduler/shared-types.mjs} +0 -0
- /package/dist/{scripts/lib → scheduler/util}/idle-poll.mjs +0 -0
- /package/dist/{scripts/lib → scheduler/util}/idle-poll.spec.js +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// ─── State persistence: scheduler-state.json + scheduler-log.ndjson ───
|
|
2
|
+
// Atomic writes via tmp+rename. Append-only event log with rotation.
|
|
3
|
+
import { readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
const STATE_DIR = '.claude/scheduler';
|
|
6
|
+
const STATE_FILE = 'state.json';
|
|
7
|
+
const LOG_FILE = 'log.ndjson';
|
|
8
|
+
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10MB
|
|
9
|
+
// ─── State ───
|
|
10
|
+
export function emptyWorker(id) {
|
|
11
|
+
return {
|
|
12
|
+
id,
|
|
13
|
+
status: 'idle',
|
|
14
|
+
taskId: null,
|
|
15
|
+
sessionId: null,
|
|
16
|
+
pid: null,
|
|
17
|
+
startedAt: null,
|
|
18
|
+
agentType: null,
|
|
19
|
+
step: null,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function emptyState(concurrency) {
|
|
23
|
+
return {
|
|
24
|
+
version: 1,
|
|
25
|
+
startedAt: Date.now(),
|
|
26
|
+
iteration: 0,
|
|
27
|
+
currentPhase: 0,
|
|
28
|
+
workers: Array.from({ length: concurrency }, (_, i) => emptyWorker(i)),
|
|
29
|
+
pipelines: {},
|
|
30
|
+
completedTasks: [],
|
|
31
|
+
skippedTasks: [],
|
|
32
|
+
sessionMap: {},
|
|
33
|
+
kitVersion: null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function readState(projectDir) {
|
|
37
|
+
const path = resolve(projectDir, STATE_DIR, STATE_FILE);
|
|
38
|
+
try {
|
|
39
|
+
const raw = readFileSync(path, 'utf-8');
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
if (parsed.version !== 1)
|
|
42
|
+
return null;
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function writeState(projectDir, state) {
|
|
50
|
+
const dir = resolve(projectDir, STATE_DIR);
|
|
51
|
+
const path = resolve(dir, STATE_FILE);
|
|
52
|
+
const tmp = path + '.tmp';
|
|
53
|
+
try {
|
|
54
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
|
55
|
+
renameSync(tmp, path);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
try {
|
|
59
|
+
unlinkSync(tmp);
|
|
60
|
+
}
|
|
61
|
+
catch { /* ignore */ }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// ─── Event log ───
|
|
65
|
+
export function appendEvent(projectDir, event) {
|
|
66
|
+
const path = resolve(projectDir, STATE_DIR, LOG_FILE);
|
|
67
|
+
const line = JSON.stringify(event) + '\n';
|
|
68
|
+
try {
|
|
69
|
+
appendFileSync(path, line, 'utf-8');
|
|
70
|
+
}
|
|
71
|
+
catch { /* ignore — log is best-effort */ }
|
|
72
|
+
}
|
|
73
|
+
export function createEvent(type, fields) {
|
|
74
|
+
return { timestamp: Date.now(), type, ...fields };
|
|
75
|
+
}
|
|
76
|
+
export function rotateLog(projectDir) {
|
|
77
|
+
const path = resolve(projectDir, STATE_DIR, LOG_FILE);
|
|
78
|
+
try {
|
|
79
|
+
const stats = statSync(path);
|
|
80
|
+
if (stats.size < MAX_LOG_SIZE)
|
|
81
|
+
return false;
|
|
82
|
+
const archive = resolve(projectDir, STATE_DIR, `log.${Date.now()}.ndjson`);
|
|
83
|
+
renameSync(path, archive);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// ─── Log reader (for recovery/debugging) ───
|
|
91
|
+
export function readRecentEvents(projectDir, maxLines = 50) {
|
|
92
|
+
const path = resolve(projectDir, STATE_DIR, LOG_FILE);
|
|
93
|
+
try {
|
|
94
|
+
const content = readFileSync(path, 'utf-8');
|
|
95
|
+
const lines = content.trim().split('\n').filter(l => l.length > 0);
|
|
96
|
+
const recent = lines.slice(-maxLines);
|
|
97
|
+
return recent.map(line => JSON.parse(line));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
|
3
|
+
import { resolve, join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { emptyState, emptyWorker, readState, writeState, appendEvent, createEvent, rotateLog, readRecentEvents, } from './state.mjs';
|
|
6
|
+
let testDir;
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
testDir = mkdtempSync(join(tmpdir(), 'scheduler-state-test-'));
|
|
9
|
+
mkdirSync(resolve(testDir, '.claude/scheduler'), { recursive: true });
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
// ─── emptyState ───
|
|
15
|
+
describe('emptyState', () => {
|
|
16
|
+
it('creates state with correct concurrency', () => {
|
|
17
|
+
const state = emptyState(3);
|
|
18
|
+
expect(state.workers).toHaveLength(3);
|
|
19
|
+
expect(state.workers[0].id).toBe(0);
|
|
20
|
+
expect(state.workers[1].id).toBe(1);
|
|
21
|
+
expect(state.workers[2].id).toBe(2);
|
|
22
|
+
});
|
|
23
|
+
it('initializes all workers as idle', () => {
|
|
24
|
+
const state = emptyState(2);
|
|
25
|
+
for (const w of state.workers) {
|
|
26
|
+
expect(w.status).toBe('idle');
|
|
27
|
+
expect(w.taskId).toBeNull();
|
|
28
|
+
expect(w.pid).toBeNull();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
it('starts at iteration 0, phase 0', () => {
|
|
32
|
+
const state = emptyState(1);
|
|
33
|
+
expect(state.iteration).toBe(0);
|
|
34
|
+
expect(state.currentPhase).toBe(0);
|
|
35
|
+
expect(state.version).toBe(1);
|
|
36
|
+
});
|
|
37
|
+
it('has empty task lists', () => {
|
|
38
|
+
const state = emptyState(1);
|
|
39
|
+
expect(state.completedTasks).toEqual([]);
|
|
40
|
+
expect(state.skippedTasks).toEqual([]);
|
|
41
|
+
expect(state.pipelines).toEqual({});
|
|
42
|
+
expect(state.sessionMap).toEqual({});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
// ─── emptyWorker ───
|
|
46
|
+
describe('emptyWorker', () => {
|
|
47
|
+
it('creates an idle worker with correct id', () => {
|
|
48
|
+
const w = emptyWorker(5);
|
|
49
|
+
expect(w.id).toBe(5);
|
|
50
|
+
expect(w.status).toBe('idle');
|
|
51
|
+
expect(w.step).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
// ─── writeState / readState ───
|
|
55
|
+
describe('writeState / readState', () => {
|
|
56
|
+
it('round-trips state through write and read', () => {
|
|
57
|
+
const state = emptyState(2);
|
|
58
|
+
state.iteration = 5;
|
|
59
|
+
state.currentPhase = 1;
|
|
60
|
+
state.completedTasks = ['p0-1', 'p0-2'];
|
|
61
|
+
writeState(testDir, state);
|
|
62
|
+
const loaded = readState(testDir);
|
|
63
|
+
expect(loaded).not.toBeNull();
|
|
64
|
+
expect(loaded.iteration).toBe(5);
|
|
65
|
+
expect(loaded.currentPhase).toBe(1);
|
|
66
|
+
expect(loaded.completedTasks).toEqual(['p0-1', 'p0-2']);
|
|
67
|
+
expect(loaded.workers).toHaveLength(2);
|
|
68
|
+
});
|
|
69
|
+
it('returns null when no state file exists', () => {
|
|
70
|
+
expect(readState(testDir)).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
it('returns null for corrupt state file', () => {
|
|
73
|
+
writeFileSync(resolve(testDir, '.claude/scheduler/state.json'), 'not json');
|
|
74
|
+
expect(readState(testDir)).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
it('returns null for wrong version', () => {
|
|
77
|
+
writeFileSync(resolve(testDir, '.claude/scheduler/state.json'), '{"version": 99}');
|
|
78
|
+
expect(readState(testDir)).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
it('atomic write survives — no .tmp file left on success', () => {
|
|
81
|
+
const state = emptyState(1);
|
|
82
|
+
writeState(testDir, state);
|
|
83
|
+
expect(existsSync(resolve(testDir, '.claude/scheduler/state.json.tmp'))).toBe(false);
|
|
84
|
+
expect(existsSync(resolve(testDir, '.claude/scheduler/state.json'))).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
it('preserves pipeline and session data', () => {
|
|
87
|
+
const state = emptyState(1);
|
|
88
|
+
state.pipelines['p1-1'] = {
|
|
89
|
+
taskId: 'p1-1',
|
|
90
|
+
workerId: 0,
|
|
91
|
+
worktreePath: '/tmp/worktree',
|
|
92
|
+
step: 'implement',
|
|
93
|
+
architectPlan: 'Build users endpoint',
|
|
94
|
+
apiContract: null,
|
|
95
|
+
reviewFindings: null,
|
|
96
|
+
testingSection: null,
|
|
97
|
+
reviewCycles: 1,
|
|
98
|
+
ciFixes: 0,
|
|
99
|
+
buildFixes: 0,
|
|
100
|
+
assignedAgent: 'backend-ts-architect',
|
|
101
|
+
};
|
|
102
|
+
state.sessionMap['p1-1'] = 'session-abc-123';
|
|
103
|
+
writeState(testDir, state);
|
|
104
|
+
const loaded = readState(testDir);
|
|
105
|
+
expect(loaded.pipelines['p1-1'].step).toBe('implement');
|
|
106
|
+
expect(loaded.pipelines['p1-1'].architectPlan).toBe('Build users endpoint');
|
|
107
|
+
expect(loaded.sessionMap['p1-1']).toBe('session-abc-123');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
// ─── appendEvent / readRecentEvents ───
|
|
111
|
+
describe('appendEvent / readRecentEvents', () => {
|
|
112
|
+
it('appends events to log file', () => {
|
|
113
|
+
const event1 = createEvent('task_started', { taskId: 'p1-1', agentType: 'backend' });
|
|
114
|
+
const event2 = createEvent('agent_completed', { taskId: 'p1-1' });
|
|
115
|
+
appendEvent(testDir, event1);
|
|
116
|
+
appendEvent(testDir, event2);
|
|
117
|
+
const events = readRecentEvents(testDir);
|
|
118
|
+
expect(events).toHaveLength(2);
|
|
119
|
+
expect(events[0].type).toBe('task_started');
|
|
120
|
+
expect(events[0].taskId).toBe('p1-1');
|
|
121
|
+
expect(events[1].type).toBe('agent_completed');
|
|
122
|
+
});
|
|
123
|
+
it('returns empty array when no log file', () => {
|
|
124
|
+
expect(readRecentEvents(testDir)).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
it('limits to maxLines most recent events', () => {
|
|
127
|
+
for (let i = 0; i < 10; i++) {
|
|
128
|
+
appendEvent(testDir, createEvent('step_changed', { detail: `step-${i}` }));
|
|
129
|
+
}
|
|
130
|
+
const recent = readRecentEvents(testDir, 3);
|
|
131
|
+
expect(recent).toHaveLength(3);
|
|
132
|
+
expect(recent[0].detail).toBe('step-7');
|
|
133
|
+
expect(recent[2].detail).toBe('step-9');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
// ─── createEvent ───
|
|
137
|
+
describe('createEvent', () => {
|
|
138
|
+
it('creates event with timestamp', () => {
|
|
139
|
+
const before = Date.now();
|
|
140
|
+
const event = createEvent('health_check');
|
|
141
|
+
const after = Date.now();
|
|
142
|
+
expect(event.timestamp).toBeGreaterThanOrEqual(before);
|
|
143
|
+
expect(event.timestamp).toBeLessThanOrEqual(after);
|
|
144
|
+
expect(event.type).toBe('health_check');
|
|
145
|
+
});
|
|
146
|
+
it('merges optional fields', () => {
|
|
147
|
+
const event = createEvent('agent_spawned', { taskId: 'p1-1', agentType: 'ui-engineer', workerId: 2 });
|
|
148
|
+
expect(event.taskId).toBe('p1-1');
|
|
149
|
+
expect(event.agentType).toBe('ui-engineer');
|
|
150
|
+
expect(event.workerId).toBe(2);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
// ─── rotateLog ───
|
|
154
|
+
describe('rotateLog', () => {
|
|
155
|
+
it('does not rotate small log files', () => {
|
|
156
|
+
appendEvent(testDir, createEvent('health_check'));
|
|
157
|
+
expect(rotateLog(testDir)).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
it('returns false when log does not exist', () => {
|
|
160
|
+
expect(rotateLog(testDir)).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
it('rotates large log files', () => {
|
|
163
|
+
const logPath = resolve(testDir, '.claude/scheduler/log.ndjson');
|
|
164
|
+
// Write ~11MB of data
|
|
165
|
+
const bigLine = JSON.stringify({ timestamp: 0, type: 'health_check', detail: 'x'.repeat(1000) }) + '\n';
|
|
166
|
+
const count = Math.ceil((10 * 1024 * 1024) / bigLine.length) + 10;
|
|
167
|
+
const chunks = [];
|
|
168
|
+
for (let i = 0; i < count; i++)
|
|
169
|
+
chunks.push(bigLine);
|
|
170
|
+
writeFileSync(logPath, chunks.join(''));
|
|
171
|
+
expect(rotateLog(testDir)).toBe(true);
|
|
172
|
+
// Original should no longer exist (renamed)
|
|
173
|
+
expect(existsSync(logPath)).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// ─── File-based inbox for runtime user input ───
|
|
2
|
+
// User writes to .claude/scheduler/inbox.json, scheduler reads each iteration.
|
|
3
|
+
// Processed messages are archived to inbox-archive.ndjson.
|
|
4
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync } from 'node:fs';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
const INBOX_DIR = '.claude/scheduler';
|
|
7
|
+
const INBOX_FILE = 'inbox.json';
|
|
8
|
+
const ARCHIVE_FILE = 'inbox-archive.ndjson';
|
|
9
|
+
// ─── Read inbox ───
|
|
10
|
+
export function readInbox(projectDir) {
|
|
11
|
+
const path = resolve(projectDir, INBOX_DIR, INBOX_FILE);
|
|
12
|
+
if (!existsSync(path))
|
|
13
|
+
return [];
|
|
14
|
+
try {
|
|
15
|
+
const raw = readFileSync(path, 'utf-8').trim();
|
|
16
|
+
if (!raw)
|
|
17
|
+
return [];
|
|
18
|
+
const parsed = JSON.parse(raw);
|
|
19
|
+
// Support both single message and array
|
|
20
|
+
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
|
21
|
+
return messages.filter(isValidMessage);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// ─── Process and archive ───
|
|
28
|
+
export function processInbox(projectDir) {
|
|
29
|
+
const messages = readInbox(projectDir);
|
|
30
|
+
if (messages.length === 0)
|
|
31
|
+
return [];
|
|
32
|
+
// Archive processed messages
|
|
33
|
+
const archivePath = resolve(projectDir, INBOX_DIR, ARCHIVE_FILE);
|
|
34
|
+
for (const msg of messages) {
|
|
35
|
+
const entry = { ...msg, processedAt: Date.now() };
|
|
36
|
+
appendFileSync(archivePath, JSON.stringify(entry) + '\n', 'utf-8');
|
|
37
|
+
}
|
|
38
|
+
// Clear inbox
|
|
39
|
+
clearInbox(projectDir);
|
|
40
|
+
return messages;
|
|
41
|
+
}
|
|
42
|
+
export function clearInbox(projectDir) {
|
|
43
|
+
const path = resolve(projectDir, INBOX_DIR, INBOX_FILE);
|
|
44
|
+
try {
|
|
45
|
+
unlinkSync(path);
|
|
46
|
+
}
|
|
47
|
+
catch { /* file may not exist */ }
|
|
48
|
+
}
|
|
49
|
+
// ─── Write to inbox (for programmatic use / CLI helper) ───
|
|
50
|
+
export function writeToInbox(projectDir, messages) {
|
|
51
|
+
const path = resolve(projectDir, INBOX_DIR, INBOX_FILE);
|
|
52
|
+
const existing = readInbox(projectDir);
|
|
53
|
+
const all = [...existing, ...messages.map(m => ({ ...m, timestamp: m.timestamp ?? Date.now() }))];
|
|
54
|
+
writeFileSync(path, JSON.stringify(all, null, 2) + '\n', 'utf-8');
|
|
55
|
+
}
|
|
56
|
+
// ─── Convert inbox message to Task ───
|
|
57
|
+
export function addTaskMessageToTask(msg, phase, nextId) {
|
|
58
|
+
return {
|
|
59
|
+
id: nextId,
|
|
60
|
+
title: msg.title,
|
|
61
|
+
phase: msg.phase ?? phase,
|
|
62
|
+
type: msg.taskType ?? 'fullstack',
|
|
63
|
+
complexity: msg.complexity ?? 'M',
|
|
64
|
+
status: 'todo',
|
|
65
|
+
dependsOn: msg.dependsOn ?? [],
|
|
66
|
+
issueMarker: null,
|
|
67
|
+
kitUpgrade: false,
|
|
68
|
+
lineNumber: 0,
|
|
69
|
+
changelog: 'added',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// ─── Validation ───
|
|
73
|
+
function isValidMessage(obj) {
|
|
74
|
+
if (typeof obj !== 'object' || obj === null)
|
|
75
|
+
return false;
|
|
76
|
+
const msg = obj;
|
|
77
|
+
if (typeof msg.type !== 'string')
|
|
78
|
+
return false;
|
|
79
|
+
const validTypes = ['add-task', 'prioritize', 'pause', 'resume', 'stop', 'message'];
|
|
80
|
+
if (!validTypes.includes(msg.type))
|
|
81
|
+
return false;
|
|
82
|
+
// Type-specific validation
|
|
83
|
+
if (msg.type === 'add-task' && typeof msg.title !== 'string')
|
|
84
|
+
return false;
|
|
85
|
+
if (msg.type === 'prioritize' && typeof msg.taskId !== 'string')
|
|
86
|
+
return false;
|
|
87
|
+
if (msg.type === 'message' && typeof msg.text !== 'string')
|
|
88
|
+
return false;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
// ─── Template generators (for documentation / CLI help) ───
|
|
92
|
+
export function exampleInbox() {
|
|
93
|
+
const examples = [
|
|
94
|
+
{ type: 'add-task', title: 'Add dark mode toggle', taskType: 'frontend', complexity: 'S' },
|
|
95
|
+
{ type: 'message', text: 'Focus on performance optimization for the API endpoints' },
|
|
96
|
+
];
|
|
97
|
+
return JSON.stringify(examples, null, 2);
|
|
98
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
|
3
|
+
import { resolve, join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { readInbox, processInbox, clearInbox, writeToInbox, addTaskMessageToTask, exampleInbox, } from './inbox.mjs';
|
|
6
|
+
let testDir;
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
testDir = mkdtempSync(join(tmpdir(), 'inbox-test-'));
|
|
9
|
+
mkdirSync(resolve(testDir, '.claude/scheduler'), { recursive: true });
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
// ─── readInbox ───
|
|
15
|
+
describe('readInbox', () => {
|
|
16
|
+
it('returns empty array when no inbox file', () => {
|
|
17
|
+
expect(readInbox(testDir)).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
it('returns empty array for empty file', () => {
|
|
20
|
+
writeFileSync(resolve(testDir, '.claude/scheduler/inbox.json'), '');
|
|
21
|
+
expect(readInbox(testDir)).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
it('reads a single message', () => {
|
|
24
|
+
const msg = { type: 'add-task', title: 'New feature' };
|
|
25
|
+
writeFileSync(resolve(testDir, '.claude/scheduler/inbox.json'), JSON.stringify(msg));
|
|
26
|
+
const messages = readInbox(testDir);
|
|
27
|
+
expect(messages).toHaveLength(1);
|
|
28
|
+
expect(messages[0].type).toBe('add-task');
|
|
29
|
+
expect(messages[0].title).toBe('New feature');
|
|
30
|
+
});
|
|
31
|
+
it('reads an array of messages', () => {
|
|
32
|
+
const msgs = [
|
|
33
|
+
{ type: 'add-task', title: 'Task A' },
|
|
34
|
+
{ type: 'message', text: 'Do this first' },
|
|
35
|
+
{ type: 'pause' },
|
|
36
|
+
];
|
|
37
|
+
writeFileSync(resolve(testDir, '.claude/scheduler/inbox.json'), JSON.stringify(msgs));
|
|
38
|
+
const messages = readInbox(testDir);
|
|
39
|
+
expect(messages).toHaveLength(3);
|
|
40
|
+
});
|
|
41
|
+
it('filters out invalid messages', () => {
|
|
42
|
+
const msgs = [
|
|
43
|
+
{ type: 'add-task', title: 'Valid' },
|
|
44
|
+
{ type: 'unknown-type' },
|
|
45
|
+
{ type: 'add-task' }, // missing title
|
|
46
|
+
{ noType: true },
|
|
47
|
+
{ type: 'message', text: 'Valid too' },
|
|
48
|
+
];
|
|
49
|
+
writeFileSync(resolve(testDir, '.claude/scheduler/inbox.json'), JSON.stringify(msgs));
|
|
50
|
+
const messages = readInbox(testDir);
|
|
51
|
+
expect(messages).toHaveLength(2);
|
|
52
|
+
});
|
|
53
|
+
it('handles corrupt JSON gracefully', () => {
|
|
54
|
+
writeFileSync(resolve(testDir, '.claude/scheduler/inbox.json'), '{broken');
|
|
55
|
+
expect(readInbox(testDir)).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
// ─── processInbox ───
|
|
59
|
+
describe('processInbox', () => {
|
|
60
|
+
it('returns messages and clears inbox', () => {
|
|
61
|
+
const msgs = [{ type: 'add-task', title: 'Task A' }];
|
|
62
|
+
writeFileSync(resolve(testDir, '.claude/scheduler/inbox.json'), JSON.stringify(msgs));
|
|
63
|
+
const processed = processInbox(testDir);
|
|
64
|
+
expect(processed).toHaveLength(1);
|
|
65
|
+
expect(existsSync(resolve(testDir, '.claude/scheduler/inbox.json'))).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
it('archives processed messages to ndjson', () => {
|
|
68
|
+
const msgs = [
|
|
69
|
+
{ type: 'add-task', title: 'Task A' },
|
|
70
|
+
{ type: 'message', text: 'Hello' },
|
|
71
|
+
];
|
|
72
|
+
writeFileSync(resolve(testDir, '.claude/scheduler/inbox.json'), JSON.stringify(msgs));
|
|
73
|
+
processInbox(testDir);
|
|
74
|
+
const archivePath = resolve(testDir, '.claude/scheduler/inbox-archive.ndjson');
|
|
75
|
+
expect(existsSync(archivePath)).toBe(true);
|
|
76
|
+
const lines = readFileSync(archivePath, 'utf-8').trim().split('\n');
|
|
77
|
+
expect(lines).toHaveLength(2);
|
|
78
|
+
const archived = JSON.parse(lines[0]);
|
|
79
|
+
expect(archived.type).toBe('add-task');
|
|
80
|
+
expect(archived.processedAt).toBeDefined();
|
|
81
|
+
});
|
|
82
|
+
it('returns empty for empty inbox', () => {
|
|
83
|
+
expect(processInbox(testDir)).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
it('accumulates archive across multiple process calls', () => {
|
|
86
|
+
writeFileSync(resolve(testDir, '.claude/scheduler/inbox.json'), JSON.stringify([{ type: 'pause' }]));
|
|
87
|
+
processInbox(testDir);
|
|
88
|
+
writeFileSync(resolve(testDir, '.claude/scheduler/inbox.json'), JSON.stringify([{ type: 'resume' }]));
|
|
89
|
+
processInbox(testDir);
|
|
90
|
+
const archivePath = resolve(testDir, '.claude/scheduler/inbox-archive.ndjson');
|
|
91
|
+
const lines = readFileSync(archivePath, 'utf-8').trim().split('\n');
|
|
92
|
+
expect(lines).toHaveLength(2);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
// ─── clearInbox ───
|
|
96
|
+
describe('clearInbox', () => {
|
|
97
|
+
it('removes inbox file', () => {
|
|
98
|
+
writeFileSync(resolve(testDir, '.claude/scheduler/inbox.json'), '[]');
|
|
99
|
+
clearInbox(testDir);
|
|
100
|
+
expect(existsSync(resolve(testDir, '.claude/scheduler/inbox.json'))).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
it('does not throw when file does not exist', () => {
|
|
103
|
+
expect(() => clearInbox(testDir)).not.toThrow();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
// ─── writeToInbox ───
|
|
107
|
+
describe('writeToInbox', () => {
|
|
108
|
+
it('writes messages to inbox', () => {
|
|
109
|
+
writeToInbox(testDir, [
|
|
110
|
+
{ type: 'add-task', title: 'New task' },
|
|
111
|
+
]);
|
|
112
|
+
const messages = readInbox(testDir);
|
|
113
|
+
expect(messages).toHaveLength(1);
|
|
114
|
+
expect(messages[0].title).toBe('New task');
|
|
115
|
+
});
|
|
116
|
+
it('appends to existing inbox messages', () => {
|
|
117
|
+
writeToInbox(testDir, [{ type: 'pause' }]);
|
|
118
|
+
writeToInbox(testDir, [{ type: 'resume' }]);
|
|
119
|
+
const messages = readInbox(testDir);
|
|
120
|
+
expect(messages).toHaveLength(2);
|
|
121
|
+
expect(messages[0].type).toBe('pause');
|
|
122
|
+
expect(messages[1].type).toBe('resume');
|
|
123
|
+
});
|
|
124
|
+
it('adds timestamp if not provided', () => {
|
|
125
|
+
writeToInbox(testDir, [{ type: 'stop' }]);
|
|
126
|
+
const raw = JSON.parse(readFileSync(resolve(testDir, '.claude/scheduler/inbox.json'), 'utf-8'));
|
|
127
|
+
expect(raw[0].timestamp).toBeDefined();
|
|
128
|
+
expect(typeof raw[0].timestamp).toBe('number');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
// ─── addTaskMessageToTask ───
|
|
132
|
+
describe('addTaskMessageToTask', () => {
|
|
133
|
+
it('converts add-task message to Task with defaults', () => {
|
|
134
|
+
const msg = { type: 'add-task', title: 'Dark mode' };
|
|
135
|
+
const task = addTaskMessageToTask(msg, 1, 'p1-5');
|
|
136
|
+
expect(task.id).toBe('p1-5');
|
|
137
|
+
expect(task.title).toBe('Dark mode');
|
|
138
|
+
expect(task.phase).toBe(1);
|
|
139
|
+
expect(task.type).toBe('fullstack');
|
|
140
|
+
expect(task.complexity).toBe('M');
|
|
141
|
+
expect(task.status).toBe('todo');
|
|
142
|
+
expect(task.changelog).toBe('added');
|
|
143
|
+
});
|
|
144
|
+
it('respects explicit fields', () => {
|
|
145
|
+
const msg = {
|
|
146
|
+
type: 'add-task',
|
|
147
|
+
title: 'Fix API bug',
|
|
148
|
+
taskType: 'backend',
|
|
149
|
+
complexity: 'S',
|
|
150
|
+
phase: 2,
|
|
151
|
+
dependsOn: ['p1-1'],
|
|
152
|
+
};
|
|
153
|
+
const task = addTaskMessageToTask(msg, 1, 'p2-3');
|
|
154
|
+
expect(task.type).toBe('backend');
|
|
155
|
+
expect(task.complexity).toBe('S');
|
|
156
|
+
expect(task.phase).toBe(2);
|
|
157
|
+
expect(task.dependsOn).toEqual(['p1-1']);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
// ─── exampleInbox ───
|
|
161
|
+
describe('exampleInbox', () => {
|
|
162
|
+
it('generates valid JSON', () => {
|
|
163
|
+
const example = exampleInbox();
|
|
164
|
+
const parsed = JSON.parse(example);
|
|
165
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
166
|
+
expect(parsed.length).toBeGreaterThan(0);
|
|
167
|
+
});
|
|
168
|
+
});
|