agentic-orchestrator 0.1.20 → 0.1.22
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 +5 -8
- package/agentic/orchestrator/prompts/planner.system.md +7 -1
- package/apps/control-plane/src/cli/help-command-handler.ts +6 -2
- package/apps/control-plane/src/cli/init-command-handler.ts +1 -1
- package/apps/control-plane/src/cli/stop-command-handler.ts +166 -2
- package/apps/control-plane/src/interfaces/cli/bootstrap.ts +11 -7
- package/apps/control-plane/test/bootstrap-edge-cases.spec.ts +35 -0
- package/apps/control-plane/test/bootstrap.spec.ts +34 -2
- package/apps/control-plane/test/cli-helpers.spec.ts +5 -4
- package/apps/control-plane/test/cli.unit.spec.ts +7 -2
- package/apps/control-plane/test/dashboard-client.spec.ts +51 -0
- package/apps/control-plane/test/stop-command.spec.ts +395 -0
- package/config/agentic/orchestrator/agents.yaml +1 -1
- package/config/agentic/orchestrator/gates.yaml +2 -2
- package/config/agentic/orchestrator/prompts/builder.system.md +47 -0
- package/config/agentic/orchestrator/prompts/planner.system.md +52 -1
- package/config/agentic/orchestrator/prompts/qa.system.md +46 -0
- package/dist/apps/control-plane/cli/help-command-handler.js +6 -2
- package/dist/apps/control-plane/cli/help-command-handler.js.map +1 -1
- package/dist/apps/control-plane/cli/init-command-handler.js +1 -1
- package/dist/apps/control-plane/cli/stop-command-handler.d.ts +5 -1
- package/dist/apps/control-plane/cli/stop-command-handler.js +145 -2
- package/dist/apps/control-plane/cli/stop-command-handler.js.map +1 -1
- package/dist/apps/control-plane/interfaces/cli/bootstrap.js +9 -6
- package/dist/apps/control-plane/interfaces/cli/bootstrap.js.map +1 -1
- package/package.json +1 -1
- package/packages/web-dashboard/src/app/api/events/route.ts +37 -22
- package/packages/web-dashboard/src/app/page.tsx +45 -6
- package/packages/web-dashboard/src/lib/aop-client.ts +94 -4
- package/spec-files/outstanding/agentic_orchestrator_worker_runtime_watchdog_resilience_spec.md +1 -1
- package/spec-files/progress.md +89 -1
package/README.md
CHANGED
|
@@ -329,18 +329,15 @@ node apps/control-plane/scripts/validate-docker-mcp-contract.mjs --smoke
|
|
|
329
329
|
|
|
330
330
|
### `stop`
|
|
331
331
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
```json
|
|
335
|
-
{ "ok": true, "data": { "status": "stop_not_implemented_yet" } }
|
|
336
|
-
```
|
|
337
|
-
|
|
338
|
-
It does not currently terminate active sessions or revoke leases.
|
|
332
|
+
Stops the dashboard server by killing the process listening on a port.
|
|
339
333
|
|
|
340
334
|
Example:
|
|
341
335
|
|
|
342
336
|
```bash
|
|
343
337
|
aop stop
|
|
338
|
+
|
|
339
|
+
# Stop a custom dashboard port
|
|
340
|
+
aop stop --port 8080
|
|
344
341
|
```
|
|
345
342
|
|
|
346
343
|
### `delete`
|
|
@@ -511,7 +508,7 @@ Supported options:
|
|
|
511
508
|
| `--all` | Aggregate across all projects (`status`); delete all features (`delete`) |
|
|
512
509
|
| `--auto` | Non-interactive init with all defaults (`init`) |
|
|
513
510
|
| `--force` | Overwrite existing config (`init`); force retry regardless of state (`retry`) |
|
|
514
|
-
| `--port <number>` | Dashboard listen port (
|
|
511
|
+
| `--port <number>` | Dashboard listen port (`aop dashboard`) or stop target port (`aop stop`) |
|
|
515
512
|
| `--dev` | Start dashboard in Next.js dev mode |
|
|
516
513
|
| `--foreground` | Keep dashboard server in the foreground |
|
|
517
514
|
| `--message <text>` | Message to deliver to agent session (`send`) |
|
|
@@ -21,7 +21,13 @@ Every `PLAN_SUBMISSION` must include ALL of the following fields in `plan_json`:
|
|
|
21
21
|
| `acceptance_criteria` | string[] (min 1) | Conditions that must all be met before merge |
|
|
22
22
|
| `gate_profile` | string | Gate profile name (e.g. `"fast"` or `"full"`) |
|
|
23
23
|
|
|
24
|
-
Optional fields
|
|
24
|
+
Optional fields (with types):
|
|
25
|
+
|
|
26
|
+
- `gate_targets`: `string[]` — explicit gate mode names to run
|
|
27
|
+
- `risk`: **`string[]`** — list of risk statements (e.g. `["Schema migration may require downtime"]`). **Must be an array, never a plain string.**
|
|
28
|
+
- `revision_of`: `integer` — plan_version this revises
|
|
29
|
+
- `revision_reason`: `string` — why the plan was revised
|
|
30
|
+
- `verification_overrides`: `object`
|
|
25
31
|
|
|
26
32
|
### Minimal example
|
|
27
33
|
|
|
@@ -59,8 +59,12 @@ const COMMAND_HELP: Record<CliCommand, CommandHelp> = {
|
|
|
59
59
|
],
|
|
60
60
|
},
|
|
61
61
|
[CliCommand.Stop]: {
|
|
62
|
-
usage: 'aop stop',
|
|
63
|
-
description: '
|
|
62
|
+
usage: 'aop stop [flags]',
|
|
63
|
+
description: 'Stop the dashboard server by terminating listeners on a target port.',
|
|
64
|
+
flags: [
|
|
65
|
+
{ flag: '--port <number>', description: 'Port to stop (default: 3000)' },
|
|
66
|
+
{ flag: '--project <name>', description: 'Use the project default dashboard port' },
|
|
67
|
+
],
|
|
64
68
|
},
|
|
65
69
|
[CliCommand.Delete]: {
|
|
66
70
|
usage: 'aop delete [flags]',
|
|
@@ -424,7 +424,7 @@ runtime:
|
|
|
424
424
|
default_provider: ${wizard.defaultProvider}
|
|
425
425
|
default_model: ${wizard.defaultModel}
|
|
426
426
|
worker_provider_mode: live
|
|
427
|
-
worker_response_timeout_ms:
|
|
427
|
+
worker_response_timeout_ms: 600000
|
|
428
428
|
max_consecutive_no_progress_iterations: 2
|
|
429
429
|
${providerConfigEnvLine} role_provider_overrides: {}
|
|
430
430
|
`;
|
|
@@ -1,5 +1,169 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const DEFAULT_DASHBOARD_PORT = 3000;
|
|
6
|
+
|
|
7
|
+
interface StopCommandOptions {
|
|
8
|
+
port?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function normalizePort(port: number | undefined): number {
|
|
12
|
+
if (typeof port === 'number' && Number.isInteger(port) && port >= 1 && port <= 65535) {
|
|
13
|
+
return port;
|
|
14
|
+
}
|
|
15
|
+
return DEFAULT_DASHBOARD_PORT;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parsePids(raw: string): number[] {
|
|
19
|
+
const values = raw
|
|
20
|
+
.split(/\r?\n/)
|
|
21
|
+
.map((line) => Number.parseInt(line.trim(), 10))
|
|
22
|
+
.filter((value) => Number.isInteger(value) && value > 0);
|
|
23
|
+
return [...new Set(values)];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function extractStdout(result: unknown): string {
|
|
27
|
+
if (typeof result === 'string') {
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
if (!result || typeof result !== 'object') {
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
const record = result as Record<string, unknown>;
|
|
34
|
+
const stdout = record.stdout;
|
|
35
|
+
if (typeof stdout === 'string') {
|
|
36
|
+
return stdout;
|
|
37
|
+
}
|
|
38
|
+
if (stdout instanceof Uint8Array) {
|
|
39
|
+
return Buffer.from(stdout).toString('utf8');
|
|
40
|
+
}
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readErrorCode(error: unknown): string | number | undefined {
|
|
45
|
+
if (!error || typeof error !== 'object') {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const record = error as Record<string, unknown>;
|
|
49
|
+
const { code } = record;
|
|
50
|
+
return typeof code === 'string' || typeof code === 'number' ? code : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isNotFoundError(error: unknown): boolean {
|
|
54
|
+
return readErrorCode(error) === 'ENOENT';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isNoMatchesError(error: unknown): boolean {
|
|
58
|
+
return readErrorCode(error) === 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function findPidsOnWindows(port: number): Promise<number[]> {
|
|
62
|
+
const stdout = extractStdout(await execFileAsync('netstat', ['-ano', '-p', 'tcp']));
|
|
63
|
+
const pids: number[] = [];
|
|
64
|
+
const lines = stdout.split(/\r?\n/);
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
if (!line.includes('LISTENING')) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const parts = line.trim().split(/\s+/);
|
|
70
|
+
if (parts.length < 5) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const localAddress = parts[1];
|
|
74
|
+
const pid = Number.parseInt(parts[4], 10);
|
|
75
|
+
const localPortRaw = localAddress.includes(':') ? localAddress.split(':').pop() : null;
|
|
76
|
+
const localPort = localPortRaw ? Number.parseInt(localPortRaw, 10) : Number.NaN;
|
|
77
|
+
if (localPort === port && Number.isInteger(pid) && pid > 0) {
|
|
78
|
+
pids.push(pid);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return [...new Set(pids)];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function findPidsOnUnix(port: number): Promise<number[]> {
|
|
85
|
+
try {
|
|
86
|
+
const stdout = extractStdout(
|
|
87
|
+
await execFileAsync('lsof', ['-nP', '-iTCP:' + String(port), '-sTCP:LISTEN', '-t']),
|
|
88
|
+
);
|
|
89
|
+
return parsePids(stdout);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (isNoMatchesError(error)) {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
if (isNotFoundError(error)) {
|
|
95
|
+
// Minimal fallback when lsof is unavailable.
|
|
96
|
+
const stdout = extractStdout(
|
|
97
|
+
await execFileAsync('ss', ['-ltnp', 'sport = :' + String(port)]),
|
|
98
|
+
);
|
|
99
|
+
const pidMatches = [...stdout.matchAll(/pid=(\d+)/g)];
|
|
100
|
+
const pids = pidMatches
|
|
101
|
+
.map((match) => Number.parseInt(match[1], 10))
|
|
102
|
+
.filter((value) => Number.isInteger(value) && value > 0);
|
|
103
|
+
return [...new Set(pids)];
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function findListeningPids(port: number): Promise<number[]> {
|
|
110
|
+
if (process.platform === 'win32') {
|
|
111
|
+
return await findPidsOnWindows(port);
|
|
112
|
+
}
|
|
113
|
+
return await findPidsOnUnix(port);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function killPid(pid: number): Promise<void> {
|
|
117
|
+
if (process.platform === 'win32') {
|
|
118
|
+
await execFileAsync('taskkill', ['/PID', String(pid), '/F']);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
await execFileAsync('kill', ['-TERM', String(pid)]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isAlreadyExitedError(error: unknown): boolean {
|
|
125
|
+
const code = readErrorCode(error);
|
|
126
|
+
return code === 'ESRCH' || code === 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
1
129
|
export class StopCommandHandler {
|
|
2
|
-
execute(): Promise<unknown> {
|
|
3
|
-
|
|
130
|
+
async execute(options: StopCommandOptions = {}): Promise<unknown> {
|
|
131
|
+
const port = normalizePort(options.port);
|
|
132
|
+
const pids = await findListeningPids(port);
|
|
133
|
+
if (pids.length === 0) {
|
|
134
|
+
return {
|
|
135
|
+
ok: true,
|
|
136
|
+
data: {
|
|
137
|
+
command: 'stop',
|
|
138
|
+
target: 'dashboard',
|
|
139
|
+
port,
|
|
140
|
+
stopped: false,
|
|
141
|
+
message: `No process is listening on port ${port}`,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const pid of pids) {
|
|
147
|
+
try {
|
|
148
|
+
await killPid(pid);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (isAlreadyExitedError(error)) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
data: {
|
|
160
|
+
command: 'stop',
|
|
161
|
+
target: 'dashboard',
|
|
162
|
+
port,
|
|
163
|
+
stopped: true,
|
|
164
|
+
pids,
|
|
165
|
+
message: `Stopped listener(s) on port ${port}`,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
4
168
|
}
|
|
5
169
|
}
|
|
@@ -253,6 +253,17 @@ export async function runCli(
|
|
|
253
253
|
return 0;
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
if (options.command === CliCommand.Stop) {
|
|
257
|
+
const handler = new StopCommandHandler();
|
|
258
|
+
const defaultDashboardPort =
|
|
259
|
+
selectedProject?.dashboard_port ?? multiConfig?.defaults?.dashboard_port;
|
|
260
|
+
const payload = await handler.execute({
|
|
261
|
+
port: options.port ?? defaultDashboardPort,
|
|
262
|
+
});
|
|
263
|
+
printPayload(payload);
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
256
267
|
const transport = parser.resolveTransport(options.transport);
|
|
257
268
|
const runId = `run:${crypto.randomUUID()}`;
|
|
258
269
|
|
|
@@ -380,13 +391,6 @@ export async function runCli(
|
|
|
380
391
|
return 0;
|
|
381
392
|
}
|
|
382
393
|
|
|
383
|
-
if (options.command === CliCommand.Stop) {
|
|
384
|
-
const handler = new StopCommandHandler();
|
|
385
|
-
const payload = await handler.execute();
|
|
386
|
-
printPayload(payload);
|
|
387
|
-
return 0;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
394
|
if (options.command === CliCommand.Delete) {
|
|
391
395
|
const handler = new DeleteCommandHandler(toolClient, runId);
|
|
392
396
|
const payload = await handler.execute(options);
|
|
@@ -13,6 +13,17 @@ const ensureLoadedMock = vi.hoisted(() => vi.fn(async () => undefined));
|
|
|
13
13
|
const createToolingRuntimeMock = vi.hoisted(() => vi.fn(async () => ({ runtime: 'ok' })));
|
|
14
14
|
const resolveToolClientMock = vi.hoisted(() => vi.fn());
|
|
15
15
|
const toolCallMock = vi.hoisted(() => vi.fn(async () => ({ ok: true, data: {} })));
|
|
16
|
+
const stopExecuteMock = vi.hoisted(() =>
|
|
17
|
+
vi.fn(async (options?: { port?: number }) => ({
|
|
18
|
+
ok: true,
|
|
19
|
+
data: {
|
|
20
|
+
command: 'stop',
|
|
21
|
+
target: 'dashboard',
|
|
22
|
+
port: options?.port ?? 3000,
|
|
23
|
+
stopped: false,
|
|
24
|
+
},
|
|
25
|
+
})),
|
|
26
|
+
);
|
|
16
27
|
|
|
17
28
|
/** Allows per-test control of getPolicySnapshot return value */
|
|
18
29
|
const policySnapshotOverride = vi.hoisted(() => ({
|
|
@@ -98,6 +109,14 @@ vi.mock('../src/cli/dashboard-command-handler.js', () => ({
|
|
|
98
109
|
},
|
|
99
110
|
}));
|
|
100
111
|
|
|
112
|
+
vi.mock('../src/cli/stop-command-handler.js', () => ({
|
|
113
|
+
StopCommandHandler: class StopCommandHandler {
|
|
114
|
+
async execute(options?: { port?: number }) {
|
|
115
|
+
return await stopExecuteMock(options);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
}));
|
|
119
|
+
|
|
101
120
|
vi.mock('../src/cli/run-command-handler.js', () => ({
|
|
102
121
|
RunCommandHandler: class RunCommandHandler {
|
|
103
122
|
async execute() {
|
|
@@ -156,6 +175,7 @@ describe('bootstrap edge-case branch coverage', () => {
|
|
|
156
175
|
toolCallMock.mockResolvedValue({ ok: true, data: { features: [] } });
|
|
157
176
|
ensureLoadedMock.mockClear();
|
|
158
177
|
createToolingRuntimeMock.mockClear();
|
|
178
|
+
stopExecuteMock.mockClear();
|
|
159
179
|
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
160
180
|
policySnapshotOverride.value = {};
|
|
161
181
|
kernelThrowOverride.value = null;
|
|
@@ -288,6 +308,21 @@ describe('bootstrap edge-case branch coverage', () => {
|
|
|
288
308
|
expect(code).toBe(0);
|
|
289
309
|
});
|
|
290
310
|
|
|
311
|
+
it('GIVEN_stop_command_without_port_option_WHEN_executed_THEN_uses_project_default_port', async () => {
|
|
312
|
+
const proj1 = path.join(tmpDir, 'proj1');
|
|
313
|
+
await fs.mkdir(proj1, { recursive: true });
|
|
314
|
+
await writeMultiProjectYaml(
|
|
315
|
+
tmpDir,
|
|
316
|
+
`version: "1.0"\nprojects:\n - name: proj1\n path: ${proj1}\n dashboard_port: 4111\n`,
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const { runCli } = await import('../src/interfaces/cli/bootstrap.js');
|
|
320
|
+
const code = await runCli(['stop', '--project', 'proj1'], { cwd: tmpDir, env: process.env });
|
|
321
|
+
|
|
322
|
+
expect(code).toBe(0);
|
|
323
|
+
expect(stopExecuteMock).toHaveBeenCalledWith({ port: 4111 });
|
|
324
|
+
});
|
|
325
|
+
|
|
291
326
|
it('GIVEN_status_all_with_one_project_throwing_error_without_code_WHEN_executed_THEN_uses_fallback_code', async () => {
|
|
292
327
|
// Covers line 205: typed.code ?? ERROR_CODES.INTERNAL_ERROR when typed.code is undefined
|
|
293
328
|
const proj1 = path.join(tmpDir, 'proj1');
|
|
@@ -11,6 +11,17 @@ const resolveToolClientMock = vi.hoisted(() => vi.fn());
|
|
|
11
11
|
const toolCallMock = vi.hoisted(() => vi.fn(async () => ({ ok: true, data: {} })));
|
|
12
12
|
const readlineQuestionMock = vi.hoisted(() => vi.fn(async () => ''));
|
|
13
13
|
const readlineCloseMock = vi.hoisted(() => vi.fn());
|
|
14
|
+
const stopExecuteMock = vi.hoisted(() =>
|
|
15
|
+
vi.fn(async (options?: { port?: number }) => ({
|
|
16
|
+
ok: true,
|
|
17
|
+
data: {
|
|
18
|
+
command: 'stop',
|
|
19
|
+
target: 'dashboard',
|
|
20
|
+
port: options?.port ?? 3000,
|
|
21
|
+
stopped: false,
|
|
22
|
+
},
|
|
23
|
+
})),
|
|
24
|
+
);
|
|
14
25
|
|
|
15
26
|
vi.mock('node:readline/promises', () => ({
|
|
16
27
|
default: {
|
|
@@ -88,6 +99,14 @@ vi.mock('../src/cli/dashboard-command-handler.js', () => ({
|
|
|
88
99
|
},
|
|
89
100
|
}));
|
|
90
101
|
|
|
102
|
+
vi.mock('../src/cli/stop-command-handler.js', () => ({
|
|
103
|
+
StopCommandHandler: class StopCommandHandler {
|
|
104
|
+
async execute(options?: { port?: number }) {
|
|
105
|
+
return await stopExecuteMock(options);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
}));
|
|
109
|
+
|
|
91
110
|
vi.mock('../src/cli/run-command-handler.js', () => ({
|
|
92
111
|
RunCommandHandler: class RunCommandHandler {
|
|
93
112
|
async execute() {
|
|
@@ -151,6 +170,7 @@ describe('bootstrap runCli', () => {
|
|
|
151
170
|
toolCallMock.mockClear();
|
|
152
171
|
ensureLoadedMock.mockClear();
|
|
153
172
|
createToolingRuntimeMock.mockClear();
|
|
173
|
+
stopExecuteMock.mockClear();
|
|
154
174
|
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
155
175
|
});
|
|
156
176
|
|
|
@@ -159,13 +179,25 @@ describe('bootstrap runCli', () => {
|
|
|
159
179
|
vi.clearAllMocks();
|
|
160
180
|
});
|
|
161
181
|
|
|
162
|
-
it('
|
|
182
|
+
it('GIVEN_stop_command_WHEN_executed_THEN_returns_stop_dashboard_payload', async () => {
|
|
163
183
|
const { runCli } = await import('../src/interfaces/cli/bootstrap.js');
|
|
164
184
|
const code = await runCli(['stop'], { cwd: '/tmp', env: process.env });
|
|
165
185
|
|
|
166
186
|
const writes = asJsonWrites(stdoutSpy);
|
|
167
187
|
expect(code).toBe(0);
|
|
168
|
-
expect(
|
|
188
|
+
expect(stopExecuteMock).toHaveBeenCalledWith({ port: undefined });
|
|
189
|
+
expect(writes[0]).toMatchObject({
|
|
190
|
+
ok: true,
|
|
191
|
+
data: { command: 'stop', target: 'dashboard', port: 3000, stopped: false },
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('GIVEN_stop_command_with_port_WHEN_executed_THEN_passes_port_to_handler', async () => {
|
|
196
|
+
const { runCli } = await import('../src/interfaces/cli/bootstrap.js');
|
|
197
|
+
const code = await runCli(['stop', '--port', '8080'], { cwd: '/tmp', env: process.env });
|
|
198
|
+
|
|
199
|
+
expect(code).toBe(0);
|
|
200
|
+
expect(stopExecuteMock).toHaveBeenCalledWith({ port: 8080 });
|
|
169
201
|
});
|
|
170
202
|
|
|
171
203
|
it('GIVEN_delete_command_with_feature_id_and_yes_WHEN_executed_THEN_calls_toolClient', async () => {
|
|
@@ -486,14 +486,15 @@ describe('HelpCommandHandler', () => {
|
|
|
486
486
|
expect(result.data.help).toContain('Commands:');
|
|
487
487
|
});
|
|
488
488
|
|
|
489
|
-
it('
|
|
489
|
+
it('GIVEN_stop_help_WHEN_execute_called_THEN_renders_stop_flags_section', async () => {
|
|
490
490
|
const { HelpCommandHandler } = await import('../src/cli/help-command-handler.js');
|
|
491
491
|
const handler = new HelpCommandHandler();
|
|
492
492
|
const result = handler.execute('stop');
|
|
493
493
|
expect(result.ok).toBe(true);
|
|
494
|
-
expect(result.data.help).toContain('Usage: aop stop');
|
|
495
|
-
|
|
496
|
-
expect(result.data.help).
|
|
494
|
+
expect(result.data.help).toContain('Usage: aop stop [flags]');
|
|
495
|
+
expect(result.data.help).toContain('Flags:');
|
|
496
|
+
expect(result.data.help).toContain('--port <number>');
|
|
497
|
+
expect(result.data.help).toContain('--project <name>');
|
|
497
498
|
});
|
|
498
499
|
|
|
499
500
|
it('GIVEN_delete_help_WHEN_execute_called_THEN_documents_yes_for_destructive_delete', async () => {
|
|
@@ -348,14 +348,19 @@ describe('aop CLI unit', () => {
|
|
|
348
348
|
});
|
|
349
349
|
});
|
|
350
350
|
|
|
351
|
-
it('
|
|
351
|
+
it('GIVEN_stop_command_WHEN_main_runs_THEN_returns_stop_dashboard_payload', async () => {
|
|
352
352
|
const code = await main(['stop'], { cwd, env: {} as NodeJS.ProcessEnv });
|
|
353
353
|
const writes = asJsonWrites(stdoutSpy);
|
|
354
354
|
|
|
355
355
|
expect(code).toBe(0);
|
|
356
356
|
expect(writes[0]).toMatchObject({
|
|
357
357
|
ok: true,
|
|
358
|
-
data: {
|
|
358
|
+
data: expect.objectContaining({
|
|
359
|
+
command: 'stop',
|
|
360
|
+
target: 'dashboard',
|
|
361
|
+
port: 3000,
|
|
362
|
+
stopped: expect.any(Boolean),
|
|
363
|
+
}),
|
|
359
364
|
});
|
|
360
365
|
});
|
|
361
366
|
|
|
@@ -118,6 +118,57 @@ describe('dashboard aop client mapping', () => {
|
|
|
118
118
|
expect(payload.features[0].gates).toEqual({ fast: 'pass', full: 'fail', merge: 'na' });
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
it('GIVEN_building_feature_without_activity_state_but_running_role_WHEN_readDashboardStatus_THEN_infers_active_activity', async () => {
|
|
122
|
+
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aop-dash-client-'));
|
|
123
|
+
tempRoots.push(repoRoot);
|
|
124
|
+
await fs.mkdir(path.join(repoRoot, '.aop', 'features'), { recursive: true });
|
|
125
|
+
await fs.writeFile(
|
|
126
|
+
path.join(repoRoot, '.aop', 'features', 'index.json'),
|
|
127
|
+
JSON.stringify({ active: ['feature_build'], blocked: [], merged: [], blocked_queue: [] }),
|
|
128
|
+
'utf8',
|
|
129
|
+
);
|
|
130
|
+
await writeState(
|
|
131
|
+
repoRoot,
|
|
132
|
+
'feature_build',
|
|
133
|
+
[
|
|
134
|
+
'feature_id: feature_build',
|
|
135
|
+
'version: 1',
|
|
136
|
+
'status: building',
|
|
137
|
+
'role_status:',
|
|
138
|
+
' planner: done',
|
|
139
|
+
' builder: running',
|
|
140
|
+
' qa: ready',
|
|
141
|
+
].join('\n'),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const payload = await readDashboardStatus(repoRoot);
|
|
145
|
+
expect(payload.features[0].activity_state).toBe('active');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('GIVEN_building_feature_without_activity_state_and_stale_timestamp_WHEN_readDashboardStatus_THEN_infers_idle_activity', async () => {
|
|
149
|
+
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aop-dash-client-'));
|
|
150
|
+
tempRoots.push(repoRoot);
|
|
151
|
+
await fs.mkdir(path.join(repoRoot, '.aop', 'features'), { recursive: true });
|
|
152
|
+
await fs.writeFile(
|
|
153
|
+
path.join(repoRoot, '.aop', 'features', 'index.json'),
|
|
154
|
+
JSON.stringify({ active: ['feature_idle'], blocked: [], merged: [], blocked_queue: [] }),
|
|
155
|
+
'utf8',
|
|
156
|
+
);
|
|
157
|
+
await writeState(
|
|
158
|
+
repoRoot,
|
|
159
|
+
'feature_idle',
|
|
160
|
+
[
|
|
161
|
+
'feature_id: feature_idle',
|
|
162
|
+
'version: 1',
|
|
163
|
+
'status: building',
|
|
164
|
+
'last_updated: 2026-01-01T00:00:00Z',
|
|
165
|
+
].join('\n'),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const payload = await readDashboardStatus(repoRoot);
|
|
169
|
+
expect(payload.features[0].activity_state).toBe('idle');
|
|
170
|
+
});
|
|
171
|
+
|
|
121
172
|
it('GIVEN_blocked_queue_feature_without_state_WHEN_readDashboardStatus_THEN_phase_is_blocked', async () => {
|
|
122
173
|
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aop-dash-client-'));
|
|
123
174
|
tempRoots.push(repoRoot);
|