tycono 0.1.89-beta.0 → 0.1.89-beta.1
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/package.json +1 -1
- package/src/api/src/create-server.ts +2 -1
- package/src/api/src/engine/agent-loop.ts +5 -0
- package/src/api/src/engine/context-assembler.ts +3 -2
- package/src/api/src/engine/runners/claude-cli.ts +3 -0
- package/src/api/src/engine/runners/direct-api.ts +1 -0
- package/src/api/src/engine/runners/types.ts +4 -1
- package/src/api/src/routes/execute.ts +59 -21
- package/src/api/src/routes/operations.ts +133 -1
- package/src/api/src/routes/sessions.ts +4 -7
- package/src/api/src/services/activity-stream.ts +9 -21
- package/src/api/src/services/activity-tracker.ts +11 -1
- package/src/api/src/services/job-manager.ts +44 -26
- package/src/api/src/services/scaffold.ts +2 -2
- package/src/api/src/services/session-store.ts +4 -7
- package/src/api/src/services/wave-multiplexer.ts +5 -2
- package/src/api/src/services/wave-tracker.ts +4 -5
- package/src/web/dist/assets/index--pQ-Ce3E.js +110 -0
- package/src/web/dist/assets/{preview-app-DogEuj8S.js → preview-app-B2p8z-M8.js} +1 -1
- package/src/web/dist/index.html +1 -1
- package/src/web/dist/assets/index-oa99cszw.js +0 -110
package/package.json
CHANGED
|
@@ -21,6 +21,7 @@ import { engineRouter } from './routes/engine.js';
|
|
|
21
21
|
import { sessionsRouter } from './routes/sessions.js';
|
|
22
22
|
import { setupRouter } from './routes/setup.js';
|
|
23
23
|
import { getAllActivities, completeActivity } from './services/activity-tracker.js';
|
|
24
|
+
import { type RoleStatus, isRoleActive } from '../../shared/types';
|
|
24
25
|
import { knowledgeRouter } from './routes/knowledge.js';
|
|
25
26
|
import { preferencesRouter } from './routes/preferences.js';
|
|
26
27
|
import { saveRouter } from './routes/save.js';
|
|
@@ -50,7 +51,7 @@ const corsOrigin = isProd ? true : /^http:\/\/localhost:\d+$/;
|
|
|
50
51
|
function cleanupStaleActivities(): void {
|
|
51
52
|
const activities = getAllActivities();
|
|
52
53
|
for (const activity of activities) {
|
|
53
|
-
if (activity.status
|
|
54
|
+
if (isRoleActive(activity.status as RoleStatus)) {
|
|
54
55
|
completeActivity(activity.roleId);
|
|
55
56
|
console.log(`[STARTUP] Cleaned stale activity: ${activity.roleId} (was working on "${activity.currentTask}")`);
|
|
56
57
|
}
|
|
@@ -35,6 +35,8 @@ export interface AgentConfig {
|
|
|
35
35
|
onDispatch?: (roleId: string, task: string) => void;
|
|
36
36
|
onConsult?: (roleId: string, question: string) => void;
|
|
37
37
|
onTurnComplete?: (turn: number) => void;
|
|
38
|
+
/** Trace: emitted when system prompt is assembled */
|
|
39
|
+
onPromptAssembled?: (systemPrompt: string, userTask: string) => void;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export interface AgentResult {
|
|
@@ -124,6 +126,9 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
|
|
|
124
126
|
// 1. Assemble context
|
|
125
127
|
const context = assembleContext(companyRoot, roleId, task, sourceRole, orgTree, { teamStatus: config.teamStatus, targetRoles: config.targetRoles });
|
|
126
128
|
|
|
129
|
+
// Trace: capture assembled prompt for debugging
|
|
130
|
+
config.onPromptAssembled?.(context.systemPrompt, task);
|
|
131
|
+
|
|
127
132
|
// 2. Determine tools
|
|
128
133
|
const subordinates = getSubordinates(orgTree, roleId);
|
|
129
134
|
const hasBash = !readOnly && !!config.codeRoot;
|
|
@@ -42,7 +42,8 @@ export interface AssembledContext {
|
|
|
42
42
|
* 8. CEO Decisions (전사 공지 — Approved 결정만)
|
|
43
43
|
* 9. Task
|
|
44
44
|
*/
|
|
45
|
-
export type TeamStatus
|
|
45
|
+
export type { TeamStatus } from '../../../shared/types';
|
|
46
|
+
import { type RoleStatus, type TeamStatus, isRoleActive } from '../../../shared/types';
|
|
46
47
|
|
|
47
48
|
export function assembleContext(
|
|
48
49
|
companyRoot: string,
|
|
@@ -461,7 +462,7 @@ function buildDispatchSection(orgTree: OrgTree, roleId: string, subordinates: st
|
|
|
461
462
|
|
|
462
463
|
// Header: name, id, persona summary
|
|
463
464
|
const st = teamStatus?.[id];
|
|
464
|
-
const status = st?.status
|
|
465
|
+
const status = st?.status && isRoleActive(st.status)
|
|
465
466
|
? `🔴 Working${st.task ? ` — "${st.task.slice(0, 60)}"` : ''}`
|
|
466
467
|
: '🟢 Idle';
|
|
467
468
|
lines.push(`### ${sub.name} (\`${id}\`) — ${status}`);
|
|
@@ -267,6 +267,9 @@ export class ClaudeCliRunner implements ExecutionRunner {
|
|
|
267
267
|
// 1. Context Assembly
|
|
268
268
|
const context = assembleContext(companyRoot, roleId, task, sourceRole, orgTree, { teamStatus, targetRoles });
|
|
269
269
|
|
|
270
|
+
// Trace: capture assembled prompt for debugging
|
|
271
|
+
callbacks.onPromptAssembled?.(context.systemPrompt, task);
|
|
272
|
+
|
|
270
273
|
// 2. System prompt를 임시 파일로 저장 (CLI arg 길이 제한 대비)
|
|
271
274
|
const tmpDir = path.join(os.tmpdir(), 'tycono-engine');
|
|
272
275
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
@@ -47,6 +47,7 @@ export class DirectApiRunner implements ExecutionRunner {
|
|
|
47
47
|
onDispatch: (roleId, task) => callbacks.onDispatch?.(roleId, task),
|
|
48
48
|
onConsult: (roleId, question) => callbacks.onConsult?.(roleId, question),
|
|
49
49
|
onTurnComplete: (turn) => callbacks.onTurnComplete?.(turn),
|
|
50
|
+
onPromptAssembled: (systemPrompt, userTask) => callbacks.onPromptAssembled?.(systemPrompt, userTask),
|
|
50
51
|
}).then((agentResult): RunnerResult => ({
|
|
51
52
|
output: agentResult.output,
|
|
52
53
|
turns: agentResult.turns,
|
|
@@ -23,7 +23,8 @@ export interface ImageAttachment {
|
|
|
23
23
|
|
|
24
24
|
/* ─── Config ──────────────────────────────────── */
|
|
25
25
|
|
|
26
|
-
export type TeamStatus
|
|
26
|
+
export type { TeamStatus } from '../../../../shared/types';
|
|
27
|
+
import type { TeamStatus } from '../../../../shared/types';
|
|
27
28
|
|
|
28
29
|
export interface RunnerConfig {
|
|
29
30
|
companyRoot: string;
|
|
@@ -55,6 +56,8 @@ export interface RunnerCallbacks {
|
|
|
55
56
|
onConsult?: (roleId: string, question: string) => void;
|
|
56
57
|
onTurnComplete?: (turn: number) => void;
|
|
57
58
|
onError?: (error: string) => void;
|
|
59
|
+
/** Trace: emitted when system prompt is assembled, for full prompt capture */
|
|
60
|
+
onPromptAssembled?: (systemPrompt: string, userTask: string) => void;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
/* ─── Result ──────────────────────────────────── */
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type ImageAttachment,
|
|
15
15
|
} from '../services/session-store.js';
|
|
16
16
|
import { jobManager, type Job } from '../services/job-manager.js';
|
|
17
|
+
import { type JobStatus, type RoleStatus, type WaveRoleStatus, type TeamStatus, isJobActive, isRoleActive, jobStatusToRoleStatus, eventTypeToJobStatus } from '../../../shared/types';
|
|
17
18
|
import { ActivityStream, type ActivityEvent, type ActivitySubscriber } from '../services/activity-stream.js';
|
|
18
19
|
import { earnCoinsInternal } from './coins.js';
|
|
19
20
|
import { appendFollowUpToWave } from '../services/wave-tracker.js';
|
|
@@ -32,7 +33,7 @@ function getRunner() {
|
|
|
32
33
|
|
|
33
34
|
/* ─── Active execution tracking (legacy, kept for /api/exec/status compat) ──── */
|
|
34
35
|
|
|
35
|
-
const roleStatus = new Map<string,
|
|
36
|
+
const roleStatus = new Map<string, RoleStatus>();
|
|
36
37
|
|
|
37
38
|
/* ─── Raw HTTP handler (Express 5 SSE 호환 문제 우회) ─── */
|
|
38
39
|
|
|
@@ -116,6 +117,21 @@ function handleJobsRequest(url: string, method: string, req: IncomingMessage, re
|
|
|
116
117
|
return;
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
// POST /api/jobs/:id/abort — abort a running job directly by jobId
|
|
121
|
+
const abortMatch = path.match(/^\/api\/jobs\/([^/]+)\/abort$/);
|
|
122
|
+
if (method === 'POST' && abortMatch) {
|
|
123
|
+
const jobId = abortMatch[1];
|
|
124
|
+
const success = jobManager.abortJob(jobId);
|
|
125
|
+
if (!success) {
|
|
126
|
+
res.writeHead(404);
|
|
127
|
+
res.end(JSON.stringify({ error: 'Job not found or not running' }));
|
|
128
|
+
} else {
|
|
129
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
130
|
+
res.end(JSON.stringify({ ok: true, jobId }));
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
119
135
|
// All other /api/jobs/* endpoints → 410
|
|
120
136
|
res.writeHead(410);
|
|
121
137
|
res.end(JSON.stringify({ error: 'Use /api/sessions/* for client-facing operations. /api/jobs/:id and /api/jobs/:id/history are internal only.' }));
|
|
@@ -311,9 +327,9 @@ function handleSaveWave(body: Record<string, unknown>, res: ServerResponse): voi
|
|
|
311
327
|
roleId: string;
|
|
312
328
|
roleName: string;
|
|
313
329
|
jobId: string;
|
|
314
|
-
status:
|
|
330
|
+
status: WaveRoleStatus;
|
|
315
331
|
events: ReturnType<typeof ActivityStream.readAll>;
|
|
316
|
-
childJobs: Array<{ roleId: string; roleName: string; jobId: string; status:
|
|
332
|
+
childJobs: Array<{ roleId: string; roleName: string; jobId: string; status: WaveRoleStatus; events: ReturnType<typeof ActivityStream.readAll> }>;
|
|
317
333
|
}
|
|
318
334
|
const rolesData: WaveRoleData[] = [];
|
|
319
335
|
|
|
@@ -323,7 +339,7 @@ function handleSaveWave(body: Record<string, unknown>, res: ServerResponse): voi
|
|
|
323
339
|
const roleId = startEvent?.roleId ?? 'unknown';
|
|
324
340
|
const roleName = (startEvent?.data?.roleName as string) ?? roleId;
|
|
325
341
|
const doneEvent = events.find(e => e.type === 'job:done' || e.type === 'job:awaiting_input' || e.type === 'job:error');
|
|
326
|
-
const status
|
|
342
|
+
const status: WaveRoleStatus = doneEvent ? eventTypeToJobStatus(doneEvent.type) as WaveRoleStatus : 'unknown';
|
|
327
343
|
|
|
328
344
|
// Collect child jobs (dispatched sub-roles)
|
|
329
345
|
const childJobs: WaveRoleData['childJobs'] = [];
|
|
@@ -333,7 +349,7 @@ function handleSaveWave(body: Record<string, unknown>, res: ServerResponse): voi
|
|
|
333
349
|
const targetRoleId = (e.data.targetRoleId as string) ?? 'unknown';
|
|
334
350
|
const childEvents = ActivityStream.readAll(childJobId);
|
|
335
351
|
const childDone = childEvents.find(ce => ce.type === 'job:done' || ce.type === 'job:error' || ce.type === 'job:awaiting_input');
|
|
336
|
-
const childStatus = childDone
|
|
352
|
+
const childStatus: WaveRoleStatus = childDone ? eventTypeToJobStatus(childDone.type) as WaveRoleStatus : 'unknown';
|
|
337
353
|
childJobs.push({
|
|
338
354
|
roleId: targetRoleId,
|
|
339
355
|
roleName: (childEvents.find(ce => ce.type === 'job:start')?.data?.roleName as string) ?? targetRoleId,
|
|
@@ -547,6 +563,9 @@ function handleAssign(body: Record<string, unknown>, req: IncomingMessage, res:
|
|
|
547
563
|
case 'stderr':
|
|
548
564
|
sendSSE(res, 'stderr', { message: event.data.message });
|
|
549
565
|
break;
|
|
566
|
+
case 'job:awaiting_input':
|
|
567
|
+
sendSSE(res, 'awaiting_input', { question: event.data.question, targetRole: event.data.targetRole, reason: event.data.reason });
|
|
568
|
+
break;
|
|
550
569
|
case 'job:done':
|
|
551
570
|
cleanupLifecycle();
|
|
552
571
|
sendSSE(res, 'done', event.data);
|
|
@@ -568,6 +587,7 @@ function handleAssign(body: Record<string, unknown>, req: IncomingMessage, res:
|
|
|
568
587
|
req.on('close', () => {
|
|
569
588
|
cleanupLifecycle();
|
|
570
589
|
job.stream.unsubscribe(subscriber);
|
|
590
|
+
roleStatus.set(roleId, 'idle');
|
|
571
591
|
});
|
|
572
592
|
}
|
|
573
593
|
|
|
@@ -645,6 +665,9 @@ function handleWave(body: Record<string, unknown>, req: IncomingMessage, res: Se
|
|
|
645
665
|
case 'stderr':
|
|
646
666
|
sendSSE(res, 'stderr', { roleId: rolePrefix, message: event.data.message });
|
|
647
667
|
break;
|
|
668
|
+
case 'job:awaiting_input':
|
|
669
|
+
sendSSE(res, 'role:awaiting_input', { roleId: rolePrefix, question: event.data.question, targetRole: event.data.targetRole, reason: event.data.reason });
|
|
670
|
+
break;
|
|
648
671
|
case 'job:done':
|
|
649
672
|
sendSSE(res, 'role:done', { roleId: rolePrefix, ...event.data });
|
|
650
673
|
doneCount++;
|
|
@@ -687,27 +710,31 @@ function handleStatus(res: ServerResponse): void {
|
|
|
687
710
|
statuses[activity.roleId] = activity.status;
|
|
688
711
|
}
|
|
689
712
|
|
|
690
|
-
// 2. JobManager
|
|
691
|
-
const
|
|
692
|
-
const
|
|
713
|
+
// 2. JobManager active jobs (isJobActive: running | awaiting_input)
|
|
714
|
+
const activeJobs = jobManager.listJobs({ active: true });
|
|
715
|
+
const activeRoles = new Set(activeJobs.map(j => j.roleId));
|
|
693
716
|
|
|
694
717
|
// 2b. In-memory roleStatus (includes chat streaming sessions, not just jobs)
|
|
695
718
|
const memoryWorking = new Set<string>();
|
|
696
719
|
for (const [rid, st] of roleStatus.entries()) {
|
|
697
|
-
if (st
|
|
720
|
+
if (isRoleActive(st as RoleStatus)) memoryWorking.add(rid);
|
|
698
721
|
}
|
|
699
722
|
|
|
700
|
-
// 3.
|
|
723
|
+
// 3. Stale cleanup: active in file but NOT in JobManager AND NOT in memory → done
|
|
701
724
|
for (const roleId of Object.keys(statuses)) {
|
|
702
|
-
|
|
725
|
+
const s = statuses[roleId];
|
|
726
|
+
if (isRoleActive(s as RoleStatus) && !activeRoles.has(roleId) && !memoryWorking.has(roleId)) {
|
|
703
727
|
statuses[roleId] = 'done';
|
|
704
728
|
completeActivity(roleId);
|
|
705
729
|
}
|
|
706
730
|
}
|
|
707
731
|
|
|
708
|
-
// 4.
|
|
709
|
-
for (const job of
|
|
710
|
-
|
|
732
|
+
// 4. Active jobs override — use jobStatusToRoleStatus() for canonical mapping
|
|
733
|
+
for (const job of activeJobs) {
|
|
734
|
+
const mappedStatus = jobStatusToRoleStatus(job.status as JobStatus);
|
|
735
|
+
// running job → 'working' always wins over awaiting_input
|
|
736
|
+
if (statuses[job.roleId] === 'working' && mappedStatus === 'awaiting_input') continue;
|
|
737
|
+
statuses[job.roleId] = mappedStatus;
|
|
711
738
|
}
|
|
712
739
|
|
|
713
740
|
// 5. In-memory working (chat streaming) also overrides
|
|
@@ -715,7 +742,7 @@ function handleStatus(res: ServerResponse): void {
|
|
|
715
742
|
statuses[rid] = 'working';
|
|
716
743
|
}
|
|
717
744
|
|
|
718
|
-
const activeExecs =
|
|
745
|
+
const activeExecs = activeJobs.map((j) => ({
|
|
719
746
|
id: j.id,
|
|
720
747
|
roleId: j.roleId,
|
|
721
748
|
task: j.task,
|
|
@@ -885,6 +912,14 @@ function handleSessionMessage(
|
|
|
885
912
|
input: event.data.input,
|
|
886
913
|
});
|
|
887
914
|
break;
|
|
915
|
+
case 'job:awaiting_input':
|
|
916
|
+
sendSSE(res, 'dispatch:progress', {
|
|
917
|
+
roleId: event.roleId,
|
|
918
|
+
type: 'awaiting_input',
|
|
919
|
+
question: event.data.question,
|
|
920
|
+
targetRole: event.data.targetRole,
|
|
921
|
+
});
|
|
922
|
+
break;
|
|
888
923
|
case 'job:done':
|
|
889
924
|
sendSSE(res, 'dispatch:progress', {
|
|
890
925
|
roleId: event.roleId,
|
|
@@ -906,15 +941,18 @@ function handleSessionMessage(
|
|
|
906
941
|
childSubscriptions.push({ job: childJob, subscriber });
|
|
907
942
|
});
|
|
908
943
|
|
|
909
|
-
// Build team status from
|
|
910
|
-
const teamStatus:
|
|
911
|
-
for (const j of jobManager.listJobs({
|
|
912
|
-
|
|
944
|
+
// Build team status from active jobs using schema helpers
|
|
945
|
+
const teamStatus: TeamStatus = {};
|
|
946
|
+
for (const j of jobManager.listJobs({ active: true })) {
|
|
947
|
+
const mapped = jobStatusToRoleStatus(j.status as JobStatus);
|
|
948
|
+
// 'working' takes priority over 'awaiting_input' for same role
|
|
949
|
+
if (teamStatus[j.roleId]?.status === 'working' && mapped === 'awaiting_input') continue;
|
|
950
|
+
teamStatus[j.roleId] = { status: mapped, task: j.task };
|
|
913
951
|
}
|
|
914
952
|
// Also include roleStatus for roles working via session (not tracked as jobs)
|
|
915
953
|
for (const [rid, status] of roleStatus) {
|
|
916
|
-
if (status
|
|
917
|
-
teamStatus[rid] = { status:
|
|
954
|
+
if (isRoleActive(status as RoleStatus) && rid !== roleId && !teamStatus[rid]) {
|
|
955
|
+
teamStatus[rid] = { status: status as RoleStatus };
|
|
918
956
|
}
|
|
919
957
|
}
|
|
920
958
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Router, Request, Response, NextFunction } from 'express';
|
|
2
2
|
import { readFile, listFiles, fileExists, COMPANY_ROOT } from '../services/file-reader.js';
|
|
3
3
|
import { extractBoldKeyValues } from '../services/markdown-parser.js';
|
|
4
|
+
import { ActivityStream } from '../services/activity-stream.js';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import fs from 'node:fs';
|
|
7
|
+
import { type JobStatus, isJobActive } from '../../../shared/types';
|
|
6
8
|
|
|
7
9
|
export const operationsRouter = Router();
|
|
8
10
|
|
|
@@ -50,7 +52,7 @@ operationsRouter.get('/waves', (_req: Request, res: Response, next: NextFunction
|
|
|
50
52
|
try {
|
|
51
53
|
const data = JSON.parse(readFile(`operations/waves/${f}`));
|
|
52
54
|
const roles = data.roles ?? [];
|
|
53
|
-
const hasRunning = roles.some((r: { status?: string }) => r.status
|
|
55
|
+
const hasRunning = roles.some((r: { status?: string }) => r.status && isJobActive(r.status as JobStatus));
|
|
54
56
|
return {
|
|
55
57
|
id,
|
|
56
58
|
timestamp: id,
|
|
@@ -174,3 +176,133 @@ operationsRouter.delete('/decisions/:id', (req: Request, res: Response, next: Ne
|
|
|
174
176
|
next(err);
|
|
175
177
|
}
|
|
176
178
|
});
|
|
179
|
+
|
|
180
|
+
// --- Traces (AI-readable agent conversation debugging) ---
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* GET /api/ops/traces/:jobId — Dump full trace for a job
|
|
184
|
+
* Returns all events including full prompt/response for the job
|
|
185
|
+
* and all child jobs in the trace chain.
|
|
186
|
+
*
|
|
187
|
+
* Query params:
|
|
188
|
+
* ?chain=true — include all jobs in the same trace (default: true)
|
|
189
|
+
* ?type=trace — filter to trace:prompt and trace:response events only
|
|
190
|
+
*/
|
|
191
|
+
operationsRouter.get('/traces/:jobId', (req: Request, res: Response, next: NextFunction) => {
|
|
192
|
+
try {
|
|
193
|
+
const jobId = String(req.params.jobId);
|
|
194
|
+
const includeChain = String(req.query.chain ?? 'true') !== 'false';
|
|
195
|
+
const typeFilter = String(req.query.type ?? '') || undefined;
|
|
196
|
+
|
|
197
|
+
// Read the target job's events
|
|
198
|
+
const events = ActivityStream.readAll(jobId);
|
|
199
|
+
if (events.length === 0) {
|
|
200
|
+
res.status(404).json({ error: `No activity stream found for job: ${jobId}` });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Extract traceId from the first event
|
|
205
|
+
const traceId = events[0]?.traceId ?? events.find(e => e.data?.traceId)?.data?.traceId as string ?? jobId;
|
|
206
|
+
|
|
207
|
+
if (!includeChain) {
|
|
208
|
+
const filtered = typeFilter
|
|
209
|
+
? events.filter(e => e.type.startsWith(typeFilter))
|
|
210
|
+
: events;
|
|
211
|
+
res.json({ traceId, jobs: [{ jobId, events: filtered }] });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Find all jobs in the same trace
|
|
216
|
+
const allJobIds = ActivityStream.listAll();
|
|
217
|
+
const traceJobs: Array<{ jobId: string; roleId: string; events: typeof events }> = [];
|
|
218
|
+
|
|
219
|
+
for (const jid of allJobIds) {
|
|
220
|
+
const jobEvents = ActivityStream.readAll(jid);
|
|
221
|
+
const startEvent = jobEvents.find(e => e.type === 'job:start');
|
|
222
|
+
const jobTraceId = jobEvents[0]?.traceId ?? startEvent?.data?.traceId;
|
|
223
|
+
|
|
224
|
+
if (jobTraceId === traceId || jid === jobId) {
|
|
225
|
+
const filtered = typeFilter
|
|
226
|
+
? jobEvents.filter(e => e.type.startsWith(typeFilter))
|
|
227
|
+
: jobEvents;
|
|
228
|
+
traceJobs.push({
|
|
229
|
+
jobId: jid,
|
|
230
|
+
roleId: startEvent?.roleId ?? 'unknown',
|
|
231
|
+
events: filtered,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Sort by timestamp of first event
|
|
237
|
+
traceJobs.sort((a, b) => {
|
|
238
|
+
const aTs = a.events[0]?.ts ?? '';
|
|
239
|
+
const bTs = b.events[0]?.ts ?? '';
|
|
240
|
+
return aTs.localeCompare(bTs);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
res.json({ traceId, jobCount: traceJobs.length, jobs: traceJobs });
|
|
244
|
+
} catch (err) {
|
|
245
|
+
next(err);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* GET /api/ops/traces — List recent traces (grouped by traceId)
|
|
251
|
+
* Query params:
|
|
252
|
+
* ?limit=20 — max traces to return
|
|
253
|
+
* ?roleId=cto — filter by role
|
|
254
|
+
*/
|
|
255
|
+
operationsRouter.get('/traces', (req: Request, res: Response, next: NextFunction) => {
|
|
256
|
+
try {
|
|
257
|
+
const limit = parseInt(String(req.query.limit ?? '20')) || 20;
|
|
258
|
+
const roleFilter = req.query.roleId ? String(req.query.roleId) : undefined;
|
|
259
|
+
|
|
260
|
+
const allJobIds = ActivityStream.listAll();
|
|
261
|
+
const traces = new Map<string, {
|
|
262
|
+
traceId: string;
|
|
263
|
+
startedAt: string;
|
|
264
|
+
rootRole: string;
|
|
265
|
+
rootTask: string;
|
|
266
|
+
jobCount: number;
|
|
267
|
+
status: string;
|
|
268
|
+
}>();
|
|
269
|
+
|
|
270
|
+
for (const jid of allJobIds) {
|
|
271
|
+
const events = ActivityStream.readAll(jid);
|
|
272
|
+
const startEvent = events.find(e => e.type === 'job:start');
|
|
273
|
+
if (!startEvent) continue;
|
|
274
|
+
|
|
275
|
+
const traceId = events[0]?.traceId ?? startEvent?.data?.traceId as string ?? jid;
|
|
276
|
+
if (roleFilter && startEvent.roleId !== roleFilter) continue;
|
|
277
|
+
|
|
278
|
+
if (!traces.has(traceId)) {
|
|
279
|
+
const doneEvent = events.find(e => e.type === 'job:done');
|
|
280
|
+
const errorEvent = events.find(e => e.type === 'job:error');
|
|
281
|
+
const awaitingEvent = events.find(e => e.type === 'job:awaiting_input');
|
|
282
|
+
const status = awaitingEvent ? 'awaiting_input'
|
|
283
|
+
: doneEvent ? 'done'
|
|
284
|
+
: errorEvent ? 'error'
|
|
285
|
+
: 'running';
|
|
286
|
+
|
|
287
|
+
traces.set(traceId, {
|
|
288
|
+
traceId,
|
|
289
|
+
startedAt: startEvent.ts,
|
|
290
|
+
rootRole: startEvent.roleId,
|
|
291
|
+
rootTask: (startEvent.data.task as string ?? '').slice(0, 200),
|
|
292
|
+
jobCount: 1,
|
|
293
|
+
status,
|
|
294
|
+
});
|
|
295
|
+
} else {
|
|
296
|
+
traces.get(traceId)!.jobCount++;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const sorted = [...traces.values()]
|
|
301
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))
|
|
302
|
+
.slice(0, limit);
|
|
303
|
+
|
|
304
|
+
res.json(sorted);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
next(err);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type Message,
|
|
12
12
|
} from '../services/session-store.js';
|
|
13
13
|
import { jobManager } from '../services/job-manager.js';
|
|
14
|
+
import { isJobActive } from '../../../shared/types';
|
|
14
15
|
import { ActivityStream, type ActivityEvent } from '../services/activity-stream.js';
|
|
15
16
|
import { updateFollowUpForReply } from '../services/wave-tracker.js';
|
|
16
17
|
|
|
@@ -118,7 +119,7 @@ sessionsRouter.get('/:id/stream', (req, res) => {
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
// If the job is finished or doesn't exist, end
|
|
121
|
-
if (!job || (job.status
|
|
122
|
+
if (!job || !isJobActive(job.status)) {
|
|
122
123
|
sendEvent('stream:end', { reason: job ? job.status : 'no-job' });
|
|
123
124
|
res.end();
|
|
124
125
|
return;
|
|
@@ -159,12 +160,8 @@ sessionsRouter.get('/:id/stream', (req, res) => {
|
|
|
159
160
|
|
|
160
161
|
/** POST /api/sessions/:id/abort — abort linked job */
|
|
161
162
|
sessionsRouter.post('/:id/abort', (req, res) => {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
res.status(404).json({ error: 'Session not found' });
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
|
|
163
|
+
// Try session-based lookup first, then fallback to direct job search
|
|
164
|
+
// (session file may not exist after server restart, but job can still be in-memory)
|
|
168
165
|
const job = jobManager.getJobBySessionId(req.params.id);
|
|
169
166
|
if (!job) {
|
|
170
167
|
res.status(404).json({ error: 'No active job for this session' });
|
|
@@ -2,26 +2,10 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { COMPANY_ROOT } from './file-reader.js';
|
|
4
4
|
|
|
5
|
-
/* ─── Types
|
|
6
|
-
|
|
7
|
-
export type ActivityEventType
|
|
8
|
-
|
|
9
|
-
| 'job:awaiting_input' | 'job:reply'
|
|
10
|
-
| 'text' | 'thinking'
|
|
11
|
-
| 'tool:start' | 'tool:result'
|
|
12
|
-
| 'dispatch:start' | 'dispatch:done'
|
|
13
|
-
| 'turn:complete' | 'turn:warning' | 'turn:limit'
|
|
14
|
-
| 'import:scan' | 'import:process' | 'import:created'
|
|
15
|
-
| 'stderr';
|
|
16
|
-
|
|
17
|
-
export interface ActivityEvent {
|
|
18
|
-
seq: number;
|
|
19
|
-
ts: string;
|
|
20
|
-
type: ActivityEventType;
|
|
21
|
-
roleId: string;
|
|
22
|
-
parentJobId?: string;
|
|
23
|
-
data: Record<string, unknown>;
|
|
24
|
-
}
|
|
5
|
+
/* ─── Types (re-export from shared contract) ── */
|
|
6
|
+
|
|
7
|
+
export { type ActivityEventType, type ActivityEvent } from '../../../shared/types';
|
|
8
|
+
import type { ActivityEventType, ActivityEvent } from '../../../shared/types';
|
|
25
9
|
|
|
26
10
|
/* ─── Constants ──────────────────────────── */
|
|
27
11
|
|
|
@@ -50,16 +34,19 @@ export class ActivityStream {
|
|
|
50
34
|
readonly jobId: string;
|
|
51
35
|
readonly roleId: string;
|
|
52
36
|
readonly parentJobId?: string;
|
|
37
|
+
/** Trace ID for full chain tracking — top-level jobId propagated to all children */
|
|
38
|
+
readonly traceId?: string;
|
|
53
39
|
|
|
54
40
|
private seq = 0;
|
|
55
41
|
private subscribers = new Set<ActivitySubscriber>();
|
|
56
42
|
private filePath: string;
|
|
57
43
|
private closed = false;
|
|
58
44
|
|
|
59
|
-
constructor(jobId: string, roleId: string, parentJobId?: string) {
|
|
45
|
+
constructor(jobId: string, roleId: string, parentJobId?: string, traceId?: string) {
|
|
60
46
|
this.jobId = jobId;
|
|
61
47
|
this.roleId = roleId;
|
|
62
48
|
this.parentJobId = parentJobId;
|
|
49
|
+
this.traceId = traceId;
|
|
63
50
|
|
|
64
51
|
ensureDir();
|
|
65
52
|
this.filePath = streamPath(jobId);
|
|
@@ -75,6 +62,7 @@ export class ActivityStream {
|
|
|
75
62
|
type,
|
|
76
63
|
roleId,
|
|
77
64
|
parentJobId: this.parentJobId,
|
|
65
|
+
...(this.traceId && { traceId: this.traceId }),
|
|
78
66
|
data,
|
|
79
67
|
};
|
|
80
68
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { COMPANY_ROOT } from './file-reader.js';
|
|
4
|
+
import type { RoleStatus } from '../../../shared/types';
|
|
4
5
|
|
|
5
6
|
function activityDir(): string {
|
|
6
7
|
return path.join(COMPANY_ROOT, 'operations', 'activity');
|
|
@@ -8,7 +9,7 @@ function activityDir(): string {
|
|
|
8
9
|
|
|
9
10
|
export interface RoleActivity {
|
|
10
11
|
roleId: string;
|
|
11
|
-
status:
|
|
12
|
+
status: RoleStatus;
|
|
12
13
|
currentTask: string;
|
|
13
14
|
startedAt: string;
|
|
14
15
|
updatedAt: string;
|
|
@@ -49,6 +50,15 @@ export function updateActivity(roleId: string, output: string): void {
|
|
|
49
50
|
invalidateCache();
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
export function markAwaitingInput(roleId: string): void {
|
|
54
|
+
const activity = getActivity(roleId);
|
|
55
|
+
if (!activity) return;
|
|
56
|
+
activity.status = 'awaiting_input';
|
|
57
|
+
activity.updatedAt = new Date().toISOString();
|
|
58
|
+
fs.writeFileSync(activityPath(roleId), JSON.stringify(activity, null, 2));
|
|
59
|
+
invalidateCache();
|
|
60
|
+
}
|
|
61
|
+
|
|
52
62
|
export function completeActivity(roleId: string): void {
|
|
53
63
|
const activity = getActivity(roleId);
|
|
54
64
|
if (!activity) return;
|