@yeaft/webchat-agent 1.0.123 → 1.0.124
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/connection/buffer.js +5 -1
- package/connection/message-router.js +5 -0
- package/connection/upgrade.js +7 -1
- package/index.js +16 -5
- package/package.json +1 -1
- package/yeaft/engine.js +2 -2
- package/yeaft/sub-agent/runner.js +1 -0
- package/yeaft/tools/apply-patch.js +15 -5
- package/yeaft/tools/create-work-item.js +79 -0
- package/yeaft/tools/index.js +2 -0
- package/yeaft/web-bridge.js +1 -1
- package/yeaft/work-center/bridge.js +142 -0
- package/yeaft/work-center/controller.js +213 -0
- package/yeaft/work-center/evidence.js +48 -0
- package/yeaft/work-center/index.js +7 -0
- package/yeaft/work-center/projection.js +54 -0
- package/yeaft/work-center/runner.js +292 -0
- package/yeaft/work-center/service.js +119 -0
- package/yeaft/work-center/store.js +783 -0
- package/yeaft/work-center/watcher.js +123 -0
- package/yeaft/work-center/workflow.js +99 -0
package/connection/buffer.js
CHANGED
|
@@ -9,7 +9,11 @@ export const BUFFERABLE_TYPES = new Set([
|
|
|
9
9
|
'turn_completed', 'conversation_closed',
|
|
10
10
|
'session_id_update', 'compact_status', 'slash_commands_update',
|
|
11
11
|
'background_task_started', 'background_task_output',
|
|
12
|
-
'subagent_started', 'subagent_message', 'subagent_completed'
|
|
12
|
+
'subagent_started', 'subagent_message', 'subagent_completed',
|
|
13
|
+
// Work Center broadcasts are projections over Agent-local SQLite. Buffering
|
|
14
|
+
// prevents a terminal transition from disappearing during a short reconnect;
|
|
15
|
+
// clients still refresh with `list` after reconnect for authoritative state.
|
|
16
|
+
'work_center_event'
|
|
13
17
|
]);
|
|
14
18
|
|
|
15
19
|
function bufferMessage(msg, reason) {
|
|
@@ -31,6 +31,7 @@ import { discoverLlmModels } from '../llm-model-discovery.js';
|
|
|
31
31
|
import { fetchModelsDev } from '../yeaft/llm/models-dev.js';
|
|
32
32
|
import { handleYeaftSessionSend, handleYeaftSubAgentPrompt, handleYeaftTaskCancel, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, handleYeaftLoadHistory, handleYeaftLoadMoreHistory, handleYeaftAbortThread, handleYeaftAbortAll, handleYeaftAbortTurn, handleYeaftVpSubscribe, handleYeaftVpCreate, handleYeaftVpUpdate, handleYeaftVpDelete, handleYeaftVpRead, handleYeaftListSessions, handleYeaftCreateSession, handleYeaftRenameSession, handleYeaftUpdateSession, handleYeaftUpdateSessionConfig, handleYeaftArchiveSession, handleYeaftDeleteSession, handleYeaftSessionAddMember, handleYeaftSessionRemoveMember, handleYeaftSessionSetDefaultVp, handleYeaftScanWorkdirSessions, handleYeaftRestoreSession, handleYeaftDreamTrigger, handleYeaftFetchToolStats, handleYeaftFetchDebugHistory, handleYeaftMcpList, handleYeaftMcpAdd, handleYeaftMcpRemove, handleYeaftMcpReload, broadcastLanguageChange, broadcastYeaftSessionSnapshotEager, preloadYeaftSkillSlashCommands } from '../yeaft/web-bridge.js';
|
|
33
33
|
import { startYeaftStatusRefresh, forceRefreshYeaftStatus } from '../yeaft/status-cache.js';
|
|
34
|
+
import { handleWorkCenterRequest } from '../yeaft/work-center/bridge.js';
|
|
34
35
|
|
|
35
36
|
export async function applyLlmConfigUpdate(msg, dependencies = {}) {
|
|
36
37
|
const updateConfig = dependencies.updateLlmConfig || updateLlmConfig;
|
|
@@ -638,6 +639,10 @@ export async function handleMessage(msg) {
|
|
|
638
639
|
await handleYeaftFetchDebugHistory(msg);
|
|
639
640
|
break;
|
|
640
641
|
|
|
642
|
+
case 'work_center_request':
|
|
643
|
+
await handleWorkCenterRequest(msg);
|
|
644
|
+
break;
|
|
645
|
+
|
|
641
646
|
// Expert roles definition (for ExpertPanel detail view)
|
|
642
647
|
case 'get_expert_roles': {
|
|
643
648
|
const { getExpertRolesDefinition } = await import('../expert-roles.js');
|
package/connection/upgrade.js
CHANGED
|
@@ -73,7 +73,7 @@ export function buildWindowsWorkerCommand(nodePath) {
|
|
|
73
73
|
|
|
74
74
|
// Shared cleanup logic for restart/upgrade
|
|
75
75
|
function cleanupAndExit(exitCode) {
|
|
76
|
-
setTimeout(() => {
|
|
76
|
+
setTimeout(async () => {
|
|
77
77
|
for (const [, term] of ctx.terminals) {
|
|
78
78
|
if (term.pty) { try { term.pty.kill(); } catch {} }
|
|
79
79
|
if (term.timer) clearTimeout(term.timer);
|
|
@@ -84,6 +84,12 @@ function cleanupAndExit(exitCode) {
|
|
|
84
84
|
if (state.inputStream) state.inputStream.done();
|
|
85
85
|
}
|
|
86
86
|
ctx.conversations.clear();
|
|
87
|
+
try {
|
|
88
|
+
const { shutdownWorkCenter } = await import('../yeaft/work-center/bridge.js');
|
|
89
|
+
await shutdownWorkCenter();
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.warn(`[Agent] Work Center shutdown failed: ${err?.message || err}`);
|
|
92
|
+
}
|
|
87
93
|
stopAgentHeartbeat();
|
|
88
94
|
if (ctx.ws) {
|
|
89
95
|
ctx.ws.removeAllListeners('close');
|
package/index.js
CHANGED
|
@@ -290,7 +290,7 @@ async function ensureYeaftSkills() {
|
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
// 优雅退出
|
|
293
|
-
function cleanup() {
|
|
293
|
+
async function cleanup() {
|
|
294
294
|
// 清理所有终端
|
|
295
295
|
for (const [, term] of ctx.terminals) {
|
|
296
296
|
if (term.pty) {
|
|
@@ -309,18 +309,22 @@ function cleanup() {
|
|
|
309
309
|
}
|
|
310
310
|
}
|
|
311
311
|
ctx.conversations.clear();
|
|
312
|
+
try {
|
|
313
|
+
const { shutdownWorkCenter } = await import('./yeaft/work-center/bridge.js');
|
|
314
|
+
await shutdownWorkCenter();
|
|
315
|
+
} catch {}
|
|
312
316
|
if (ctx.ws) ctx.ws.close();
|
|
313
317
|
}
|
|
314
318
|
|
|
315
|
-
process.on('SIGINT', () => {
|
|
319
|
+
process.on('SIGINT', async () => {
|
|
316
320
|
console.log('Shutting down...');
|
|
317
|
-
cleanup();
|
|
321
|
+
await cleanup();
|
|
318
322
|
process.exit(0);
|
|
319
323
|
});
|
|
320
324
|
|
|
321
|
-
process.on('SIGTERM', () => {
|
|
325
|
+
process.on('SIGTERM', async () => {
|
|
322
326
|
console.log('Shutting down...');
|
|
323
|
-
cleanup();
|
|
327
|
+
await cleanup();
|
|
324
328
|
process.exit(0);
|
|
325
329
|
});
|
|
326
330
|
|
|
@@ -341,5 +345,12 @@ process.on('SIGTERM', () => {
|
|
|
341
345
|
} catch (err) {
|
|
342
346
|
console.warn(`[Agent] models.dev prime failed (will use config/defaults): ${err?.message || err}`);
|
|
343
347
|
}
|
|
348
|
+
try {
|
|
349
|
+
const { bootWorkCenter } = await import('./yeaft/work-center/bridge.js');
|
|
350
|
+
await bootWorkCenter();
|
|
351
|
+
console.log('[Agent] Work Center watcher started');
|
|
352
|
+
} catch (err) {
|
|
353
|
+
console.warn(`[Agent] Work Center failed to start: ${err?.message || err}`);
|
|
354
|
+
}
|
|
344
355
|
connect();
|
|
345
356
|
})();
|
package/package.json
CHANGED
package/yeaft/engine.js
CHANGED
|
@@ -2278,7 +2278,7 @@ export class Engine {
|
|
|
2278
2278
|
// OpenAI/Anthropic toolCallId pairing intact.
|
|
2279
2279
|
let wireMessages = stripMetaForWire([...conversationMessages]);
|
|
2280
2280
|
|
|
2281
|
-
if (this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
|
|
2281
|
+
if (scenario !== 'work-item' && this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
|
|
2282
2282
|
try {
|
|
2283
2283
|
const swept = await archiveToolResults({
|
|
2284
2284
|
root: `${this.#yeaftDir}/memory`,
|
|
@@ -2315,7 +2315,7 @@ export class Engine {
|
|
|
2315
2315
|
// The estimator (`estimateMessagesTokens`) is approxTokens
|
|
2316
2316
|
// (char/4 with CJK weighting) — good enough for a guard rail; a
|
|
2317
2317
|
// real tokenizer would be exact but adds a heavy dep.
|
|
2318
|
-
if (this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
|
|
2318
|
+
if (scenario !== 'work-item' && this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
|
|
2319
2319
|
const PREFLIGHT_RATIO = 0.85;
|
|
2320
2320
|
const threshold = Math.floor(currentContextWindow * PREFLIGHT_RATIO);
|
|
2321
2321
|
const estimate = estimateMessagesTokens(systemPrompt, wireMessages);
|
|
@@ -15,7 +15,19 @@ import { resolve, dirname } from 'path';
|
|
|
15
15
|
* @param {string} patch
|
|
16
16
|
* @returns {Array<{ file: string, hunks: Array }>}
|
|
17
17
|
*/
|
|
18
|
-
function
|
|
18
|
+
function parsePatchTarget(line) {
|
|
19
|
+
const raw = line.slice(4).replace(/\r$/, '');
|
|
20
|
+
const tabIndex = raw.indexOf('\t');
|
|
21
|
+
let filePath = (tabIndex === -1 ? raw : raw.slice(0, tabIndex)).trim();
|
|
22
|
+
if (!filePath) throw new Error('Patch target path is empty');
|
|
23
|
+
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(filePath)) {
|
|
24
|
+
throw new Error('Patch target path contains control characters');
|
|
25
|
+
}
|
|
26
|
+
if (filePath.startsWith('b/')) filePath = filePath.slice(2);
|
|
27
|
+
return filePath === '/dev/null' ? null : filePath;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parsePatch(patch) {
|
|
19
31
|
const files = [];
|
|
20
32
|
const lines = patch.split('\n');
|
|
21
33
|
let currentFile = null;
|
|
@@ -30,10 +42,8 @@ function parsePatch(patch) {
|
|
|
30
42
|
continue;
|
|
31
43
|
}
|
|
32
44
|
if (line.startsWith('+++ ')) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (filePath.startsWith('b/')) filePath = filePath.slice(2);
|
|
36
|
-
if (filePath === '/dev/null') continue;
|
|
45
|
+
const filePath = parsePatchTarget(line);
|
|
46
|
+
if (!filePath) continue;
|
|
37
47
|
|
|
38
48
|
currentFile = { file: filePath, hunks: [] };
|
|
39
49
|
files.push(currentFile);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { defineTool } from './types.js';
|
|
2
|
+
|
|
3
|
+
function cleanCriteria(value) {
|
|
4
|
+
return Array.isArray(value)
|
|
5
|
+
? value.map(item => String(item).trim()).filter(Boolean)
|
|
6
|
+
: [];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default defineTool({
|
|
10
|
+
name: 'CreateWorkItem',
|
|
11
|
+
description: {
|
|
12
|
+
en: `Create a persistent Agent-level Work Center item from the current Session.
|
|
13
|
+
|
|
14
|
+
Use this when work must continue beyond the current turn, needs role handoffs, review, waiting, retry, or durable tracking. This creates the work contract; it does not execute it inline. The current Session is always stamped as the origin and cannot be overridden by model input.`,
|
|
15
|
+
zh: `从当前 Session 创建一个持久化的 Agent 级工作项。
|
|
16
|
+
|
|
17
|
+
当工作需要跨 turn 继续、需要角色接力、评审、等待、重试或长期跟踪时使用。该工具只创建工作契约,不在当前 turn 内执行。来源 Session 由运行时强制写入,模型输入不能覆盖。`,
|
|
18
|
+
},
|
|
19
|
+
parameters: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
title: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: { en: 'Short work item title', zh: '简短的工作项标题' },
|
|
25
|
+
},
|
|
26
|
+
goal: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: { en: 'Stable outcome the work item must achieve', zh: '工作项必须达到的稳定目标' },
|
|
29
|
+
},
|
|
30
|
+
acceptanceCriteria: {
|
|
31
|
+
type: 'array',
|
|
32
|
+
items: { type: 'string' },
|
|
33
|
+
description: { en: 'Verifiable completion criteria', zh: '可验证的完成条件' },
|
|
34
|
+
},
|
|
35
|
+
workDir: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: { en: 'Optional project directory for execution', zh: '执行时使用的可选项目目录' },
|
|
38
|
+
},
|
|
39
|
+
start: {
|
|
40
|
+
type: 'boolean',
|
|
41
|
+
description: { en: 'Start triage immediately (default true)', zh: '是否立即开始 triage(默认 true)' },
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
required: ['title', 'goal'],
|
|
45
|
+
},
|
|
46
|
+
isConcurrencySafe: () => false,
|
|
47
|
+
isReadOnly: () => false,
|
|
48
|
+
async execute(input, ctx = {}) {
|
|
49
|
+
const sessionId = typeof ctx.sessionId === 'string' ? ctx.sessionId.trim() : '';
|
|
50
|
+
if (!sessionId) throw new Error('CreateWorkItem requires an active Session');
|
|
51
|
+
const title = typeof input?.title === 'string' ? input.title.trim() : '';
|
|
52
|
+
const goal = typeof input?.goal === 'string' ? input.goal.trim() : '';
|
|
53
|
+
if (!title || !goal) throw new Error('title and goal are required');
|
|
54
|
+
|
|
55
|
+
// Dynamic import avoids tools/index -> create-work-item -> bridge -> runner
|
|
56
|
+
// -> tools/index becoming a static initialization cycle.
|
|
57
|
+
const { createWorkItemFromProducer } = await import('../work-center/bridge.js');
|
|
58
|
+
const detail = await createWorkItemFromProducer({
|
|
59
|
+
title,
|
|
60
|
+
goal,
|
|
61
|
+
acceptanceCriteria: cleanCriteria(input.acceptanceCriteria),
|
|
62
|
+
workDir: typeof input.workDir === 'string' ? input.workDir.trim() : (ctx.cwd || ''),
|
|
63
|
+
workflowTemplate: 'software-change',
|
|
64
|
+
origin: {
|
|
65
|
+
sessionId,
|
|
66
|
+
messageId: ctx.inboundEnvelope?.msgId || null,
|
|
67
|
+
createdBy: ctx.currentVpId || 'assistant',
|
|
68
|
+
},
|
|
69
|
+
linkedSessionIds: [sessionId],
|
|
70
|
+
start: input.start !== false,
|
|
71
|
+
});
|
|
72
|
+
return JSON.stringify({
|
|
73
|
+
workItemId: detail.id,
|
|
74
|
+
status: detail.status,
|
|
75
|
+
title: detail.title,
|
|
76
|
+
message: `Created Work Center item ${detail.id}`,
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
});
|
package/yeaft/tools/index.js
CHANGED
|
@@ -55,6 +55,7 @@ import routeForward from './route-forward.js';
|
|
|
55
55
|
// --- P1 Progress tracking ---
|
|
56
56
|
import todoWrite from './todo-write.js';
|
|
57
57
|
import startPlan from './start-plan.js';
|
|
58
|
+
import createWorkItem from './create-work-item.js';
|
|
58
59
|
|
|
59
60
|
// H2.f.4: user-facing thread tools (spawnThread/switchThread/listThreads/...)
|
|
60
61
|
// were deleted. PR #797 reintroduces runtime-owned VP thread routing below the
|
|
@@ -119,6 +120,7 @@ export const allTools = [
|
|
|
119
120
|
// P1 Progress tracking
|
|
120
121
|
todoWrite,
|
|
121
122
|
startPlan,
|
|
123
|
+
createWorkItem,
|
|
122
124
|
|
|
123
125
|
// P2 Auxiliary
|
|
124
126
|
jsRepl,
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -3943,7 +3943,7 @@ export function buildVpQueryOpts({ vpId, sessionCoordinator, sessionId, envelope
|
|
|
3943
3943
|
* @param {{ workDir?: string, sessionId?: string|null, sessionMeta?: object, perfTraceId?: string|null, messageType?: string }} [opts]
|
|
3944
3944
|
* @returns {Promise<import('./session.js').Session>}
|
|
3945
3945
|
*/
|
|
3946
|
-
async function ensureSessionLoaded(opts = {}) {
|
|
3946
|
+
export async function ensureSessionLoaded(opts = {}) {
|
|
3947
3947
|
if (session) return session;
|
|
3948
3948
|
if (sessionLoadPromise) return sessionLoadPromise;
|
|
3949
3949
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import ctx from '../../context.js';
|
|
2
|
+
import { sendToServer } from '../../connection/buffer.js';
|
|
3
|
+
import { ensureSessionLoaded } from '../web-bridge.js';
|
|
4
|
+
import { defaultRegistry } from '../vp/registry.js';
|
|
5
|
+
import { scanVpLibrary } from '../vp/vp-store.js';
|
|
6
|
+
import { WorkCenterService } from './service.js';
|
|
7
|
+
import { WorkItemRunner } from './runner.js';
|
|
8
|
+
import { projectWorkCenterEvent } from './projection.js';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
let service = null;
|
|
12
|
+
let initPromise = null;
|
|
13
|
+
let shuttingDown = false;
|
|
14
|
+
let shutdownPromise = null;
|
|
15
|
+
let serviceFactory = null;
|
|
16
|
+
|
|
17
|
+
function send(msg) {
|
|
18
|
+
sendToServer(msg);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function getRuntime() {
|
|
22
|
+
return ensureSessionLoaded();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function createDefaultService() {
|
|
26
|
+
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
27
|
+
if (!yeaftDir) throw new Error('Work Center requires a configured Yeaft directory');
|
|
28
|
+
const runner = new WorkItemRunner({
|
|
29
|
+
runtimeProvider: async () => {
|
|
30
|
+
const runtime = await getRuntime();
|
|
31
|
+
if (defaultRegistry.vpCount() === 0) {
|
|
32
|
+
for (const vp of scanVpLibrary({ dir: join(runtime.yeaftDir, 'virtual-persons') })) defaultRegistry.setVp(vp);
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
...runtime,
|
|
36
|
+
defaultWorkDir: ctx.CONFIG?.workDir || process.cwd(),
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
registry: defaultRegistry,
|
|
40
|
+
store: null,
|
|
41
|
+
});
|
|
42
|
+
const created = new WorkCenterService({
|
|
43
|
+
yeaftDir,
|
|
44
|
+
runner,
|
|
45
|
+
onEvent(event) {
|
|
46
|
+
send({ type: 'work_center_event', event: projectWorkCenterEvent(event) });
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
runner.store = created.store;
|
|
50
|
+
return created;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function ensureWorkCenter() {
|
|
54
|
+
if (service) return service;
|
|
55
|
+
if (shuttingDown) throw new Error('Work Center is shutting down');
|
|
56
|
+
if (initPromise) return initPromise;
|
|
57
|
+
initPromise = (async () => {
|
|
58
|
+
const created = serviceFactory ? await serviceFactory() : await createDefaultService();
|
|
59
|
+
if (shuttingDown) {
|
|
60
|
+
await created.shutdown();
|
|
61
|
+
throw new Error('Work Center shut down during initialization');
|
|
62
|
+
}
|
|
63
|
+
created.start();
|
|
64
|
+
service = created;
|
|
65
|
+
return created;
|
|
66
|
+
})();
|
|
67
|
+
try {
|
|
68
|
+
return await initPromise;
|
|
69
|
+
} finally {
|
|
70
|
+
initPromise = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function bootWorkCenter() {
|
|
75
|
+
return ensureWorkCenter();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function createWorkItemFromProducer(payload) {
|
|
79
|
+
const workCenter = await ensureWorkCenter();
|
|
80
|
+
return workCenter.handle('create', payload);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function handleWorkCenterRequest(msg) {
|
|
84
|
+
const requestId = typeof msg.requestId === 'string' ? msg.requestId : null;
|
|
85
|
+
const op = typeof msg.op === 'string' ? msg.op : '';
|
|
86
|
+
try {
|
|
87
|
+
const workCenter = await ensureWorkCenter();
|
|
88
|
+
const data = await workCenter.handle(op, msg.payload || {});
|
|
89
|
+
send({
|
|
90
|
+
type: 'work_center_response',
|
|
91
|
+
requestId,
|
|
92
|
+
op,
|
|
93
|
+
ok: true,
|
|
94
|
+
data,
|
|
95
|
+
_requestUserId: msg._requestUserId || null,
|
|
96
|
+
});
|
|
97
|
+
} catch (err) {
|
|
98
|
+
send({
|
|
99
|
+
type: 'work_center_response',
|
|
100
|
+
requestId,
|
|
101
|
+
op,
|
|
102
|
+
ok: false,
|
|
103
|
+
error: err?.message || String(err),
|
|
104
|
+
_requestUserId: msg._requestUserId || null,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function shutdownWorkCenter() {
|
|
110
|
+
if (shutdownPromise) return shutdownPromise;
|
|
111
|
+
shuttingDown = true;
|
|
112
|
+
const pendingInit = initPromise;
|
|
113
|
+
shutdownPromise = (async () => {
|
|
114
|
+
let current = service;
|
|
115
|
+
if (!current && pendingInit) {
|
|
116
|
+
try { current = await pendingInit; } catch {}
|
|
117
|
+
}
|
|
118
|
+
service = null;
|
|
119
|
+
if (current) await current.shutdown();
|
|
120
|
+
})();
|
|
121
|
+
try {
|
|
122
|
+
await shutdownPromise;
|
|
123
|
+
} finally {
|
|
124
|
+
initPromise = null;
|
|
125
|
+
shutdownPromise = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function __testSetWorkCenterService(next) {
|
|
130
|
+
service = next || null;
|
|
131
|
+
initPromise = null;
|
|
132
|
+
shuttingDown = false;
|
|
133
|
+
shutdownPromise = null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function __testSetWorkCenterFactory(factory) {
|
|
137
|
+
serviceFactory = factory || null;
|
|
138
|
+
service = null;
|
|
139
|
+
initPromise = null;
|
|
140
|
+
shuttingDown = false;
|
|
141
|
+
shutdownPromise = null;
|
|
142
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { actionInstruction, getNextStep, initialActionFor, RUN_OUTCOMES } from './workflow.js';
|
|
2
|
+
import { normalizeEvidence } from './evidence.js';
|
|
3
|
+
|
|
4
|
+
function normalizeCriteria(value) {
|
|
5
|
+
if (!Array.isArray(value)) return null;
|
|
6
|
+
return value.map(item => String(item).trim()).filter(Boolean);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function normalizeContractPatch(value) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
11
|
+
const patch = {};
|
|
12
|
+
if (typeof value.goal === 'string' && value.goal.trim()) patch.goal = value.goal.trim();
|
|
13
|
+
if (Object.prototype.hasOwnProperty.call(value, 'acceptanceCriteria')) {
|
|
14
|
+
const criteria = normalizeCriteria(value.acceptanceCriteria);
|
|
15
|
+
if (!criteria) throw new Error('contractPatch.acceptanceCriteria must be an array');
|
|
16
|
+
patch.acceptanceCriteria = criteria;
|
|
17
|
+
}
|
|
18
|
+
return Object.keys(patch).length > 0 ? patch : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeTerminalResult(result, action) {
|
|
22
|
+
if (!result || !RUN_OUTCOMES.includes(result.outcome)) {
|
|
23
|
+
throw new Error(`Invalid Work Center outcome: ${result?.outcome || '(missing)'}`);
|
|
24
|
+
}
|
|
25
|
+
const normalized = {
|
|
26
|
+
outcome: result.outcome,
|
|
27
|
+
summary: String(result.summary || ''),
|
|
28
|
+
evidence: normalizeEvidence(result.evidence),
|
|
29
|
+
waitingReason: result.waitingReason ? String(result.waitingReason) : null,
|
|
30
|
+
error: result.error ? String(result.error) : null,
|
|
31
|
+
reviewDecision: ['approved', 'changes_requested'].includes(result.reviewDecision)
|
|
32
|
+
? result.reviewDecision
|
|
33
|
+
: null,
|
|
34
|
+
contractPatch: normalizeContractPatch(result.contractPatch),
|
|
35
|
+
};
|
|
36
|
+
if (normalized.outcome === 'waiting' && !normalized.waitingReason) {
|
|
37
|
+
throw new Error('waiting outcome requires waitingReason');
|
|
38
|
+
}
|
|
39
|
+
if (action.type !== 'triage' && normalized.contractPatch) {
|
|
40
|
+
normalized.outcome = 'failed';
|
|
41
|
+
normalized.error = 'Only triage may submit a WorkItem contractPatch';
|
|
42
|
+
normalized.contractPatch = null;
|
|
43
|
+
}
|
|
44
|
+
if (action.type === 'review' && normalized.outcome === 'completed' && !normalized.reviewDecision) {
|
|
45
|
+
normalized.outcome = 'failed';
|
|
46
|
+
normalized.error = 'Completed review requires approved or changes_requested';
|
|
47
|
+
}
|
|
48
|
+
return normalized;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function contextEntry(action, result) {
|
|
52
|
+
return {
|
|
53
|
+
type: action.type,
|
|
54
|
+
role: action.requiredRole,
|
|
55
|
+
summary: result.summary || '',
|
|
56
|
+
evidence: result.evidence || [],
|
|
57
|
+
reviewDecision: result.reviewDecision || null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class WorkflowController {
|
|
62
|
+
constructor(store) {
|
|
63
|
+
this.store = store;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
create(input) {
|
|
67
|
+
const draft = {
|
|
68
|
+
...input,
|
|
69
|
+
workflowTemplate: input.workflowTemplate || 'software-change',
|
|
70
|
+
acceptanceCriteria: Array.isArray(input.acceptanceCriteria) ? input.acceptanceCriteria : [],
|
|
71
|
+
};
|
|
72
|
+
const firstAction = input.start !== false ? initialActionFor(draft) : null;
|
|
73
|
+
return this.store.createWorkItem(draft, firstAction);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
start(id) {
|
|
77
|
+
const detail = this.store.startWorkItemAtomic(id, initialActionFor);
|
|
78
|
+
if (!detail) throw new Error(`WorkItem not found: ${id}`);
|
|
79
|
+
return detail;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
update(id, patch) {
|
|
83
|
+
const updated = this.store.updateWorkItemAtomic(id, patch, initialActionFor);
|
|
84
|
+
if (!updated) throw new Error(`WorkItem not found: ${id}`);
|
|
85
|
+
return this.store.getWorkItemDetail(id);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
cancel(id) {
|
|
89
|
+
const workItem = this.store.cancelWorkItemAtomic(id);
|
|
90
|
+
if (!workItem) throw new Error(`WorkItem not found: ${id}`);
|
|
91
|
+
return this.store.getWorkItemDetail(id);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
retry(id, input = {}) {
|
|
95
|
+
const answer = typeof input.answer === 'string' ? input.answer.trim().slice(0, 8_000) : '';
|
|
96
|
+
const detail = this.store.retryWorkItemAtomic(id, (workItem, previous, previousRun) => {
|
|
97
|
+
if (workItem.status === 'waiting' && !answer) {
|
|
98
|
+
throw new Error('answer is required to resume a waiting WorkItem');
|
|
99
|
+
}
|
|
100
|
+
const step = previous
|
|
101
|
+
? { type: previous.type, requiredRole: previous.requiredRole }
|
|
102
|
+
: initialActionFor(workItem);
|
|
103
|
+
const context = Array.isArray(previous?.context) ? [...previous.context] : [];
|
|
104
|
+
if (previousRun) {
|
|
105
|
+
context.push({
|
|
106
|
+
type: previous.type,
|
|
107
|
+
role: previous.requiredRole,
|
|
108
|
+
summary: previousRun.summary || '',
|
|
109
|
+
evidence: normalizeEvidence(previousRun.evidence),
|
|
110
|
+
waitingReason: previousRun.waitingReason || null,
|
|
111
|
+
answer: answer || null,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
...step,
|
|
116
|
+
context,
|
|
117
|
+
instruction: actionInstruction(step, workItem, context),
|
|
118
|
+
maxAttempts: previous?.maxAttempts || 2,
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
if (!detail) throw new Error(`WorkItem not found: ${id}`);
|
|
122
|
+
return detail;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
submit(runId, ownerBootId, leaseEpoch, rawResult) {
|
|
126
|
+
const activeRun = this.store.getRun(runId);
|
|
127
|
+
const activeAction = activeRun ? this.store.getAction(activeRun.actionId) : null;
|
|
128
|
+
if (!activeRun || !activeAction) throw new Error('Run is stale, cancelled, or already finished');
|
|
129
|
+
const result = normalizeTerminalResult(rawResult, activeAction);
|
|
130
|
+
const detail = this.store.finalizeRun(
|
|
131
|
+
runId,
|
|
132
|
+
ownerBootId,
|
|
133
|
+
leaseEpoch,
|
|
134
|
+
result,
|
|
135
|
+
({ action, workItem }) => {
|
|
136
|
+
if (result.outcome === 'waiting') {
|
|
137
|
+
return {
|
|
138
|
+
actionStatus: 'completed',
|
|
139
|
+
workItemStatus: 'waiting',
|
|
140
|
+
keepCurrentAction: true,
|
|
141
|
+
eventType: 'action.waiting',
|
|
142
|
+
eventData: { reason: result.waitingReason },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (result.outcome === 'retryable') {
|
|
147
|
+
const retryable = action.attempt < action.maxAttempts;
|
|
148
|
+
return {
|
|
149
|
+
actionStatus: retryable ? 'ready' : 'failed',
|
|
150
|
+
workItemStatus: retryable ? 'ready' : 'needs_attention',
|
|
151
|
+
keepCurrentAction: true,
|
|
152
|
+
eventType: retryable ? 'action.retry_scheduled' : 'action.retry_exhausted',
|
|
153
|
+
eventData: {
|
|
154
|
+
attempt: action.attempt,
|
|
155
|
+
maxAttempts: action.maxAttempts,
|
|
156
|
+
error: result.error,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (result.outcome === 'failed') {
|
|
162
|
+
return {
|
|
163
|
+
actionStatus: 'failed',
|
|
164
|
+
workItemStatus: 'needs_attention',
|
|
165
|
+
keepCurrentAction: true,
|
|
166
|
+
eventType: 'action.failed',
|
|
167
|
+
eventData: { error: result.error },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const contractPatch = action.type === 'triage' ? result.contractPatch : null;
|
|
172
|
+
const effectiveWorkItem = contractPatch
|
|
173
|
+
? {
|
|
174
|
+
...workItem,
|
|
175
|
+
goal: contractPatch.goal ?? workItem.goal,
|
|
176
|
+
acceptanceCriteria: contractPatch.acceptanceCriteria ?? workItem.acceptanceCriteria,
|
|
177
|
+
revision: workItem.revision + 1,
|
|
178
|
+
}
|
|
179
|
+
: workItem;
|
|
180
|
+
const nextStep = getNextStep(workItem.workflowTemplate, action.type, result);
|
|
181
|
+
if (!nextStep) {
|
|
182
|
+
return {
|
|
183
|
+
actionStatus: 'completed',
|
|
184
|
+
workItemStatus: 'done',
|
|
185
|
+
contractPatch,
|
|
186
|
+
eventType: 'work_item.completed',
|
|
187
|
+
eventData: { summary: result.summary },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const context = [...(action.context || []), contextEntry(action, result)];
|
|
192
|
+
return {
|
|
193
|
+
actionStatus: 'completed',
|
|
194
|
+
workItemStatus: 'ready',
|
|
195
|
+
contractPatch,
|
|
196
|
+
nextAction: {
|
|
197
|
+
...nextStep,
|
|
198
|
+
context,
|
|
199
|
+
instruction: actionInstruction(nextStep, effectiveWorkItem, context),
|
|
200
|
+
maxAttempts: 2,
|
|
201
|
+
},
|
|
202
|
+
eventType: 'action.completed',
|
|
203
|
+
eventData: {
|
|
204
|
+
nextActionType: nextStep.type,
|
|
205
|
+
reviewDecision: result.reviewDecision,
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
if (!detail) throw new Error('Run is stale, cancelled, expired, or already finished');
|
|
211
|
+
return detail;
|
|
212
|
+
}
|
|
213
|
+
}
|