@yeaft/webchat-agent 1.0.165 → 1.0.167

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.
@@ -29,7 +29,7 @@ import { loadMcpServers, updateMcpConfig } from '../mcp.js';
29
29
  import { getLlmConfig, updateLlmConfig, getYeaftSettings, updateYeaftSettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../yeaft/config-api.js';
30
30
  import { discoverLlmModels } from '../llm-model-discovery.js';
31
31
  import { fetchModelsDev } from '../yeaft/llm/models-dev.js';
32
- import { handleYeaftSessionSend, handleYeaftAskUserAnswer, 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';
32
+ import { handleYeaftSessionSend, handleYeaftAskUserAnswer, handleYeaftSubAgentPrompt, handleYeaftTaskCancel, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, handleYeaftLoadHistory, handleYeaftSearchHistory, handleYeaftLoadHistoryWindow, 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
34
  import { handleWorkCenterRequest } from '../yeaft/work-center/bridge.js';
35
35
 
@@ -479,6 +479,14 @@ export async function handleMessage(msg) {
479
479
  await handleYeaftLoadHistory(msg);
480
480
  break;
481
481
 
482
+ case 'yeaft_search_history':
483
+ await handleYeaftSearchHistory(msg);
484
+ break;
485
+
486
+ case 'yeaft_load_history_window':
487
+ await handleYeaftLoadHistoryWindow(msg);
488
+ break;
489
+
482
490
  case 'yeaft_load_more_history':
483
491
  case 'unify_load_more_history':
484
492
  await handleYeaftLoadMoreHistory(msg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.165",
3
+ "version": "1.0.167",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -1395,6 +1395,105 @@ export class ConversationStore {
1395
1395
  return Number.isFinite(seq) ? seq : null;
1396
1396
  }
1397
1397
 
1398
+ /**
1399
+ * Search user-visible messages inside one Session. The scan is newest-first
1400
+ * and stops as soon as one page plus a `hasMore` sentinel is found, so a
1401
+ * common recent hit does not materialize the full transcript.
1402
+ *
1403
+ * @param {string} sessionId
1404
+ * @param {string} query
1405
+ * @param {{ limit?: number, beforeSeq?: number|null }} [opts]
1406
+ * @returns {{ results: object[], hasMore: boolean, nextBeforeSeq: number|null }}
1407
+ */
1408
+ searchVisibleBySession(sessionId, query, opts = {}) {
1409
+ const needle = typeof query === 'string' ? query.trim().toLocaleLowerCase() : '';
1410
+ if (!sessionId || needle.length < 2) return { results: [], hasMore: false, nextBeforeSeq: null };
1411
+
1412
+ const limit = Math.min(50, Math.max(1, Number.isFinite(opts.limit) ? Math.floor(opts.limit) : 20));
1413
+ const beforeSeq = Number.isFinite(opts.beforeSeq) ? opts.beforeSeq : Infinity;
1414
+ const results = [];
1415
+ const seen = new Set();
1416
+ let hasMore = false;
1417
+
1418
+ for (const message of this.#iterateSessionRows(sessionId, { beforeSeq, desc: true })) {
1419
+ if (!message || message.sessionId !== sessionId || isHiddenConversationRow(message)) continue;
1420
+ if (message.role !== 'user' && message.role !== 'assistant') continue;
1421
+ if (!message.id || seen.has(message.id)) continue;
1422
+ seen.add(message.id);
1423
+
1424
+ const text = this.#visibleSearchText(message.content);
1425
+ const matchIndex = text.toLocaleLowerCase().indexOf(needle);
1426
+ if (matchIndex < 0) continue;
1427
+ if (results.length >= limit) {
1428
+ hasMore = true;
1429
+ break;
1430
+ }
1431
+
1432
+ const seq = parseSeqFromId(message.id);
1433
+ if (!Number.isFinite(seq)) continue;
1434
+ results.push({
1435
+ messageId: message.id,
1436
+ turnId: message.turnId || message.threadId || message.id,
1437
+ seq,
1438
+ role: message.role,
1439
+ speakerVpId: message.speakerVpId || null,
1440
+ timestamp: message.ts || message.time || null,
1441
+ snippet: this.#searchSnippet(text, matchIndex, needle.length),
1442
+ });
1443
+ }
1444
+
1445
+ return {
1446
+ results,
1447
+ hasMore,
1448
+ nextBeforeSeq: hasMore && results.length > 0 ? results[results.length - 1].seq : null,
1449
+ };
1450
+ }
1451
+
1452
+ /**
1453
+ * Load a bounded visible window around a search hit. This deliberately does
1454
+ * not mutate normal older-history cursors: the web client merges the window
1455
+ * into its cache solely to mount and focus the requested virtual-list item.
1456
+ *
1457
+ * @param {string} sessionId
1458
+ * @param {number} anchorSeq
1459
+ * @param {{ beforeTurns?: number, afterTurns?: number }} [opts]
1460
+ * @returns {{ messages: object[], oldestSeq: number|null, hasMoreBefore: boolean }}
1461
+ */
1462
+ loadVisibleWindowBySession(sessionId, anchorSeq, opts = {}) {
1463
+ if (!sessionId || !Number.isFinite(anchorSeq)) {
1464
+ return { messages: [], oldestSeq: null, hasMoreBefore: false };
1465
+ }
1466
+
1467
+ const beforeTurns = Math.min(10, Math.max(1, Number.isFinite(opts.beforeTurns) ? Math.floor(opts.beforeTurns) : 3));
1468
+ const afterTurns = Math.min(10, Math.max(1, Number.isFinite(opts.afterTurns) ? Math.floor(opts.afterTurns) : 3));
1469
+ const before = this.loadVisibleBySession(sessionId, anchorSeq + 1, beforeTurns + 1);
1470
+ const messages = before.messages.slice();
1471
+ const seen = new Set(messages.map(message => message?.id).filter(Boolean));
1472
+ let followingUserTurns = 0;
1473
+
1474
+ for (const message of this.#iterateSessionRows(sessionId, { afterSeq: anchorSeq, desc: false })) {
1475
+ if (!message || message.sessionId !== sessionId || isHiddenConversationRow(message)) continue;
1476
+ if (message.role !== 'user' && message.role !== 'assistant') continue;
1477
+ if (message.role === 'user') {
1478
+ followingUserTurns += 1;
1479
+ if (followingUserTurns > afterTurns) break;
1480
+ }
1481
+ if (message.id && seen.has(message.id)) continue;
1482
+ const projected = this.#projectVisibleMessage(message);
1483
+ if (!projected) continue;
1484
+ if (projected.id) seen.add(projected.id);
1485
+ messages.push(projected);
1486
+ }
1487
+
1488
+ messages.sort(compareMessagesBySeq);
1489
+ const oldestSeq = messages.length > 0 ? parseSeqFromId(messages[0].id) : null;
1490
+ return {
1491
+ messages,
1492
+ oldestSeq: Number.isFinite(oldestSeq) ? oldestSeq : null,
1493
+ hasMoreBefore: before.hasMore,
1494
+ };
1495
+ }
1496
+
1398
1497
  /**
1399
1498
  * Count hot messages.
1400
1499
  *
@@ -2115,6 +2214,33 @@ export class ConversationStore {
2115
2214
  };
2116
2215
  }
2117
2216
 
2217
+ #visibleSearchText(content) {
2218
+ if (typeof content === 'string') return content.replace(/\s+/g, ' ').trim();
2219
+ if (!Array.isArray(content)) return '';
2220
+ return content
2221
+ .filter(part => part && typeof part === 'object' && part.type === 'text')
2222
+ .map(part => typeof part.text === 'string' ? part.text : '')
2223
+ .join(' ')
2224
+ .replace(/\s+/g, ' ')
2225
+ .trim();
2226
+ }
2227
+
2228
+ #searchSnippet(text, matchIndex, needleLength) {
2229
+ const radius = 90;
2230
+ const start = Math.max(0, matchIndex - radius);
2231
+ const end = Math.min(text.length, matchIndex + needleLength + radius);
2232
+ return `${start > 0 ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}`;
2233
+ }
2234
+
2235
+ #projectVisibleMessage(message) {
2236
+ if (!message || (message.role !== 'user' && message.role !== 'assistant')) return null;
2237
+ if (message.role !== 'assistant' || !Array.isArray(message.toolCalls) || message.toolCalls.length === 0) {
2238
+ return message;
2239
+ }
2240
+ const { toolCalls, ...rest } = message;
2241
+ return { ...rest, toolSummaryCount: toolCalls.length };
2242
+ }
2243
+
2118
2244
  #readSegmentRows(conversationDir, opts = {}) {
2119
2245
  return this.#segmentStoreForConversationDir(conversationDir).readAll(opts);
2120
2246
  }
@@ -58,7 +58,8 @@ Use this when work must continue beyond the current turn, needs role handoffs, r
58
58
 
59
59
  // Dynamic import avoids tools/index -> create-work-item -> bridge -> runner
60
60
  // -> tools/index becoming a static initialization cycle.
61
- const { createWorkItemFromProducer } = await import('../work-center/bridge.js');
61
+ const { createWorkItemFromProducer, snapshotCurrentSessionContext } = await import('../work-center/bridge.js');
62
+ const sessionContext = await snapshotCurrentSessionContext(sessionId);
62
63
  const detail = await createWorkItemFromProducer({
63
64
  title,
64
65
  goal,
@@ -74,6 +75,7 @@ Use this when work must continue beyond the current turn, needs role handoffs, r
74
75
  createdBy: ctx.currentVpId || 'assistant',
75
76
  },
76
77
  linkedSessionIds: [sessionId],
78
+ sessionContext,
77
79
  start: input.start !== false,
78
80
  });
79
81
  return JSON.stringify({
@@ -50,6 +50,7 @@ import {
50
50
  restoreSessionToRegistry,
51
51
  readWorkDirRegistry,
52
52
  migrateRegisteredWorkDirSessions,
53
+ resolveSessionYeaftDir,
53
54
  } from './sessions/session-crud.js';
54
55
  import { openSession, loadSessionMeta } from './sessions/session-store.js';
55
56
  import { loadSessionConfig, resolveSessionConfig, SessionConfigError } from './sessions/session-config.js';
@@ -5872,6 +5873,82 @@ export async function handleYeaftLoadHistory(msg) {
5872
5873
  *
5873
5874
  * @param {object} msg — { sessionId, beforeSeq, turns }
5874
5875
  */
5876
+ export async function handleYeaftSearchHistory(msg) {
5877
+ const sessionId = typeof msg?.sessionId === 'string' ? msg.sessionId.trim() : '';
5878
+ const query = typeof msg?.query === 'string' ? msg.query.trim().slice(0, 500) : '';
5879
+ const requestId = typeof msg?.requestId === 'string' ? msg.requestId : null;
5880
+ const beforeSeq = Number.isFinite(msg?.beforeSeq) ? msg.beforeSeq : null;
5881
+ const limit = Math.min(50, Math.max(1, Number.isFinite(msg?.limit) ? Math.floor(msg.limit) : 20));
5882
+ const response = {
5883
+ type: 'yeaft_history_search_result',
5884
+ requestId,
5885
+ sessionId: sessionId || null,
5886
+ query,
5887
+ results: [],
5888
+ hasMore: false,
5889
+ nextBeforeSeq: null,
5890
+ _requestClientId: msg?._requestClientId || null,
5891
+ };
5892
+
5893
+ if (!sessionId || query.length < 2) {
5894
+ sendToServer(response);
5895
+ return;
5896
+ }
5897
+
5898
+ try {
5899
+ const defaultYeaftDir = ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR;
5900
+ const storeDir = resolveSessionYeaftDir(defaultYeaftDir, sessionId);
5901
+ const store = new ConversationStore(storeDir);
5902
+ const result = store.searchVisibleBySession(sessionId, query, { limit, beforeSeq });
5903
+ sendToServer({ ...response, ...result });
5904
+ } catch (err) {
5905
+ console.error('[Yeaft] Session history search failed:', err?.message || err);
5906
+ sendToServer({ ...response, error: 'search_failed' });
5907
+ }
5908
+ }
5909
+
5910
+ export async function handleYeaftLoadHistoryWindow(msg) {
5911
+ const sessionId = typeof msg?.sessionId === 'string' ? msg.sessionId.trim() : '';
5912
+ const requestId = typeof msg?.requestId === 'string' ? msg.requestId : null;
5913
+ const anchorSeq = Number(msg?.anchorSeq);
5914
+ const anchorMessageId = typeof msg?.anchorMessageId === 'string' ? msg.anchorMessageId : null;
5915
+ const response = {
5916
+ type: 'yeaft_history_window',
5917
+ requestId,
5918
+ conversationId: ensureYeaftConversationId(),
5919
+ sessionId: sessionId || null,
5920
+ anchorMessageId,
5921
+ anchorSeq: Number.isFinite(anchorSeq) ? anchorSeq : null,
5922
+ messages: [],
5923
+ oldestSeq: null,
5924
+ hasMoreBefore: false,
5925
+ _requestClientId: msg?._requestClientId || null,
5926
+ };
5927
+
5928
+ if (!sessionId || !Number.isFinite(anchorSeq)) {
5929
+ sendToServer({ ...response, error: 'invalid_anchor' });
5930
+ return;
5931
+ }
5932
+
5933
+ try {
5934
+ const defaultYeaftDir = ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR;
5935
+ const storeDir = resolveSessionYeaftDir(defaultYeaftDir, sessionId);
5936
+ const store = new ConversationStore(storeDir);
5937
+ const window = store.loadVisibleWindowBySession(sessionId, anchorSeq, {
5938
+ beforeTurns: msg?.beforeTurns,
5939
+ afterTurns: msg?.afterTurns,
5940
+ });
5941
+ sendToServer({
5942
+ ...response,
5943
+ ...window,
5944
+ messages: projectVisibleHistoryChunkMessages(window.messages),
5945
+ });
5946
+ } catch (err) {
5947
+ console.error('[Yeaft] Session history anchor load failed:', err?.message || err);
5948
+ sendToServer({ ...response, error: 'window_load_failed' });
5949
+ }
5950
+ }
5951
+
5875
5952
  export async function handleYeaftLoadMoreHistory(msg) {
5876
5953
  const sessionId = (msg && typeof msg.sessionId === 'string' && msg.sessionId) || null;
5877
5954
  const perfTraceId = typeof msg?.perfTraceId === 'string' && msg.perfTraceId.trim() ? msg.perfTraceId.trim() : null;
@@ -136,13 +136,21 @@ export function resolveWorkItemModel(config, vp, rawPolicy) {
136
136
  throw policyError(`Configured Work Center model is unavailable: ${model}`);
137
137
  }
138
138
  const effortOptions = Array.isArray(available?.effortOptions) ? available.effortOptions : [];
139
- if (policy.effort && !effortOptions.includes(policy.effort)) {
140
- throw policyError(`Configured Work Center effort is unsupported by ${model}: ${policy.effort}`);
141
- }
139
+ const effortOrder = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
140
+ const requestedIndex = effortOrder.indexOf(policy.effort);
141
+ const effort = !policy.effort || effortOptions.length === 0
142
+ ? null
143
+ : effortOptions.includes(policy.effort)
144
+ ? policy.effort
145
+ : effortOptions
146
+ .map(value => ({ value, distance: Math.abs(effortOrder.indexOf(value) - requestedIndex) }))
147
+ .filter(item => effortOrder.includes(item.value))
148
+ .sort((left, right) => left.distance - right.distance
149
+ || effortOrder.indexOf(right.value) - effortOrder.indexOf(left.value))[0]?.value || null;
142
150
  const parsed = parseModelRef(model);
143
151
  return {
144
152
  model,
145
- effort: policy.effort || null,
153
+ effort,
146
154
  provider: available?.provider || parsed.providerName || null,
147
155
  source,
148
156
  policy,
@@ -9,6 +9,7 @@ import { projectWorkCenterEvent, projectWorkItemDetail } from './projection.js';
9
9
  import { previewWorkCenterPlan } from './planner.js';
10
10
  import { readWorkCenterSettings, writeWorkCenterSettings } from './settings.js';
11
11
  import { defaultWorkCenterStageInstructions } from './workflow.js';
12
+ import { snapshotSessionContext } from './session-context.js';
12
13
  import { join } from 'node:path';
13
14
 
14
15
  let service = null;
@@ -22,8 +23,7 @@ const BROWSER_DETAIL_OPS = new Set(['get', 'create', 'update', 'start', 'cancel'
22
23
  // client-supplied value and only emits files resolved from owned upload ids.
23
24
  const BROWSER_FILE_FIELDS = Object.freeze({
24
25
  create: [
25
- 'title', 'goal', 'acceptanceCriteria', 'workItemType', 'workDir', 'reuseMemory', 'origin',
26
- 'linkedSessionIds', 'files', 'start',
26
+ 'title', 'goal', 'acceptanceCriteria', 'workItemType', 'workDir', 'reuseMemory', 'files', 'start',
27
27
  ],
28
28
  guide: ['id', 'guidance', 'actionId', 'revision', 'files'],
29
29
  });
@@ -100,6 +100,7 @@ async function createDefaultService() {
100
100
  },
101
101
  policyProvider: async () => readWorkCenterSettings(yeaftDir),
102
102
  attachmentRoot: join(yeaftDir, 'work-center', 'attachments'),
103
+ actionWorktreeRoot: join(yeaftDir, 'work-center', 'worktrees'),
103
104
  registry: defaultRegistry,
104
105
  store: null,
105
106
  });
@@ -107,6 +108,9 @@ async function createDefaultService() {
107
108
  yeaftDir,
108
109
  runner,
109
110
  runtimeInfoProvider: getSettingsRuntime,
111
+ watcherOptions: {
112
+ concurrencyProvider: () => readWorkCenterSettings(yeaftDir).maxConcurrentActions,
113
+ },
110
114
  onEvent(event) {
111
115
  send({ type: 'work_center_event', event: projectWorkCenterEvent(event) });
112
116
  },
@@ -140,6 +144,11 @@ export async function bootWorkCenter() {
140
144
  return ensureWorkCenter();
141
145
  }
142
146
 
147
+ export async function snapshotCurrentSessionContext(sessionId) {
148
+ const runtime = await getRuntime();
149
+ return snapshotSessionContext(runtime?.conversationStore, sessionId);
150
+ }
151
+
143
152
  export async function createWorkItemFromProducer(payload) {
144
153
  const workCenter = await ensureWorkCenter();
145
154
  return workCenter.handle('create', payload, { trustedProducer: true });
@@ -6,6 +6,7 @@ import {
6
6
  initialActionFor,
7
7
  RUN_OUTCOMES,
8
8
  } from './workflow.js';
9
+ import { renderSessionContextSnapshot } from './session-context.js';
9
10
  import { normalizeEvidence } from './evidence.js';
10
11
 
11
12
  function normalizeCriteria(value) {
@@ -132,12 +133,18 @@ export class WorkflowController {
132
133
  attachments: Array.isArray(input.attachments) ? input.attachments : [],
133
134
  };
134
135
  let firstAction = input.start !== false ? initialActionFor(draft) : null;
136
+ if (firstAction) {
137
+ firstAction = {
138
+ ...firstAction,
139
+ instruction: actionInstruction(firstAction, draft, [], renderSessionContextSnapshot(draft.sessionContext)),
140
+ };
141
+ }
135
142
  if (firstAction && draft.reuseMemory !== false) {
136
143
  const context = this.store.getReusableContext(draft.workDir, draft.id);
137
144
  firstAction = {
138
145
  ...firstAction,
139
146
  context,
140
- instruction: actionInstruction(firstAction, draft, context),
147
+ instruction: actionInstruction(firstAction, draft, context, renderSessionContextSnapshot(draft.sessionContext)),
141
148
  };
142
149
  }
143
150
  return this.store.createWorkItem(draft, firstAction);
@@ -151,7 +158,7 @@ export class WorkflowController {
151
158
  return {
152
159
  ...action,
153
160
  context,
154
- instruction: actionInstruction(action, workItem, context),
161
+ instruction: actionInstruction(action, workItem, context, renderSessionContextSnapshot(workItem.sessionContext)),
155
162
  };
156
163
  });
157
164
  if (!detail) throw new Error(`WorkItem not found: ${id}`);
@@ -200,7 +207,7 @@ export class WorkflowController {
200
207
  return {
201
208
  ...step,
202
209
  context,
203
- instruction: actionInstruction(step, workItem, context),
210
+ instruction: actionInstruction(step, workItem, context, renderSessionContextSnapshot(workItem.sessionContext)),
204
211
  maxAttempts: previous.maxAttempts || 2,
205
212
  };
206
213
  }, input.attachments);
@@ -221,6 +228,9 @@ export class WorkflowController {
221
228
  assignmentPolicy: previous.assignmentPolicy,
222
229
  modelPolicy: previous.modelPolicy,
223
230
  requiredRole: previous.requiredRole,
231
+ dependsOnStageIds: previous.dependsOnStageIds,
232
+ workspaceMode: previous.workspaceMode,
233
+ changesRequestedStageId: previous.changesRequestedStageId,
224
234
  brief: previous.brief,
225
235
  }
226
236
  : initialActionFor(workItem);
@@ -240,7 +250,7 @@ export class WorkflowController {
240
250
  return {
241
251
  ...step,
242
252
  context,
243
- instruction: actionInstruction(step, workItem, context),
253
+ instruction: actionInstruction(step, workItem, context, renderSessionContextSnapshot(workItem.sessionContext)),
244
254
  maxAttempts: previous?.maxAttempts || 2,
245
255
  };
246
256
  });
@@ -287,6 +297,7 @@ export class WorkflowController {
287
297
  workItemStatus: 'waiting',
288
298
  keepCurrentAction: true,
289
299
  eventType: 'action.waiting',
300
+ graphAdvance: workItem.workflowSnapshot?.executionMode === 'graph',
290
301
  eventData: { reason: result.waitingReason },
291
302
  };
292
303
  }
@@ -298,6 +309,7 @@ export class WorkflowController {
298
309
  workItemStatus: retryable ? 'ready' : 'needs_attention',
299
310
  keepCurrentAction: true,
300
311
  eventType: retryable ? 'action.retry_scheduled' : 'action.retry_exhausted',
312
+ graphAdvance: workItem.workflowSnapshot?.executionMode === 'graph',
301
313
  eventData: {
302
314
  attempt: action.attempt,
303
315
  maxAttempts: action.maxAttempts,
@@ -312,6 +324,7 @@ export class WorkflowController {
312
324
  workItemStatus: 'needs_attention',
313
325
  keepCurrentAction: true,
314
326
  eventType: 'action.failed',
327
+ graphAdvance: workItem.workflowSnapshot?.executionMode === 'graph',
315
328
  eventData: { error: result.error },
316
329
  };
317
330
  }
@@ -332,6 +345,35 @@ export class WorkflowController {
332
345
  const plannedWorkItem = generatedWorkflow
333
346
  ? { ...effectiveWorkItem, workflowSnapshot: generatedWorkflow }
334
347
  : effectiveWorkItem;
348
+ const context = [...(action.context || []), contextEntry(action, result, activeRun)];
349
+ if (plannedWorkItem.workflowSnapshot?.executionMode === 'graph') {
350
+ if (action.type === 'review' && result.reviewDecision === 'changes_requested') {
351
+ const targetStage = plannedWorkItem.workflowSnapshot.stages
352
+ .find(stage => stage.id === action.changesRequestedStageId);
353
+ if (!targetStage) throw new Error('Work Center review return target is missing');
354
+ return {
355
+ actionStatus: 'completed', workItemStatus: 'ready', graphAdvance: true,
356
+ graphResetStageId: action.changesRequestedStageId,
357
+ graphResetAction: actionForStage(targetStage, plannedWorkItem, context),
358
+ eventType: 'review.changes_requested',
359
+ eventData: { targetStageId: action.changesRequestedStageId },
360
+ };
361
+ }
362
+ const nextActions = generatedWorkflow
363
+ ? generatedWorkflow.stages.slice(1).map(stage => actionForStage(stage, plannedWorkItem, context))
364
+ : [];
365
+ return {
366
+ actionStatus: 'completed',
367
+ workItemStatus: nextActions.length > 0 ? 'ready' : 'running',
368
+ contractPatch,
369
+ workflowSnapshot: generatedWorkflow,
370
+ nextActions,
371
+ graphAdvance: true,
372
+ eventType: 'action.completed',
373
+ eventData: { nextActionCount: nextActions.length, reviewDecision: result.reviewDecision },
374
+ };
375
+ }
376
+
335
377
  const nextStep = getNextStep(plannedWorkItem, action.stageId || action.type, result);
336
378
  if (!nextStep) {
337
379
  return {
@@ -344,7 +386,6 @@ export class WorkflowController {
344
386
  };
345
387
  }
346
388
 
347
- const context = [...(action.context || []), contextEntry(action, result, activeRun)];
348
389
  return {
349
390
  actionStatus: 'completed',
350
391
  workItemStatus: 'ready',
@@ -100,6 +100,8 @@ function projectAction(action, runs) {
100
100
  type: action.type,
101
101
  stageId: action.stageId || action.type,
102
102
  assignmentPolicy: projectAssignmentPolicy(action.assignmentPolicy),
103
+ dependsOnStageIds: Array.isArray(action.dependsOnStageIds) ? action.dependsOnStageIds : [],
104
+ workspaceMode: action.workspaceMode || 'shared',
103
105
  requiredRole: action.requiredRole || '',
104
106
  brief: normalizeActionBrief(action.brief, action.type),
105
107
  status: action.status,
@@ -152,6 +154,7 @@ export function projectWorkItemDetail(detail) {
152
154
  workflowTemplate: detail.workflowTemplate,
153
155
  workItemType: detail.workflowSnapshot?.workItemType || null,
154
156
  planningMode: detail.workflowSnapshot?.planningMode || 'static',
157
+ executionMode: detail.workflowSnapshot?.executionMode || 'linear',
155
158
  status: detail.status,
156
159
  currentActionId: detail.currentActionId || null,
157
160
  executionStats: sumExecutionStats(Array.isArray(detail.runs) ? detail.runs : []),
@@ -206,6 +209,7 @@ export function projectWorkItemSummary(detail) {
206
209
  goal: detail.goal,
207
210
  workItemType: detail.workflowSnapshot?.workItemType || null,
208
211
  planningMode: detail.workflowSnapshot?.planningMode || 'static',
212
+ executionMode: detail.workflowSnapshot?.executionMode || 'linear',
209
213
  status: detail.status,
210
214
  currentActionId: detail.currentActionId || null,
211
215
  executionStats: sumExecutionStats(Array.isArray(detail.runs) ? detail.runs : []),