@meiyukichan/dsh-sandore 0.1.0-rc.6

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.
Files changed (295) hide show
  1. package/LICENSE +201 -0
  2. package/cordis.patch.yml +39 -0
  3. package/dsh.plugin.json +16 -0
  4. package/lib/client/CommandEditor.d.ts +19 -0
  5. package/lib/client/DagEditor.d.ts +22 -0
  6. package/lib/client/DefinitionDetail.d.ts +13 -0
  7. package/lib/client/DefinitionList.d.ts +19 -0
  8. package/lib/client/DiscussionInput.d.ts +63 -0
  9. package/lib/client/DragHandle.d.ts +16 -0
  10. package/lib/client/EditorPane.d.ts +13 -0
  11. package/lib/client/FlowEditor.d.ts +31 -0
  12. package/lib/client/InstanceList.d.ts +24 -0
  13. package/lib/client/NewInstanceDialog.d.ts +19 -0
  14. package/lib/client/PreviewPanel.d.ts +13 -0
  15. package/lib/client/RunView.d.ts +79 -0
  16. package/lib/client/StepChatPanel.d.ts +34 -0
  17. package/lib/client/TreeNav.d.ts +27 -0
  18. package/lib/client/UnifiedTimeline.d.ts +11 -0
  19. package/lib/client/WorkflowButton.d.ts +11 -0
  20. package/lib/client/WorkflowList.d.ts +18 -0
  21. package/lib/client/WorkflowOverlay.d.ts +6 -0
  22. package/lib/client/chat/AssistantMarkdown.d.ts +22 -0
  23. package/lib/client/chat/CompactionItem.d.ts +24 -0
  24. package/lib/client/chat/ContextBody.d.ts +136 -0
  25. package/lib/client/chat/ContextInjectionRow.d.ts +25 -0
  26. package/lib/client/chat/GenericCommandCard.d.ts +10 -0
  27. package/lib/client/chat/MessageIconActions.d.ts +41 -0
  28. package/lib/client/chat/MessageItem.d.ts +65 -0
  29. package/lib/client/chat/ReasoningRow.d.ts +13 -0
  30. package/lib/client/chat/TurnTailNodeView.d.ts +29 -0
  31. package/lib/client/chat/UserMessageRow.d.ts +18 -0
  32. package/lib/client/chat/attachment/ImageLightbox.d.ts +26 -0
  33. package/lib/client/chat/attachment/MessageImage.d.ts +56 -0
  34. package/lib/client/chat/attachment/image-attachment.d.ts +41 -0
  35. package/lib/client/chat/attachment/image-labels.d.ts +21 -0
  36. package/lib/client/chat/message-chrome.d.ts +51 -0
  37. package/lib/client/chat/reference/ReferenceIcon.d.ts +20 -0
  38. package/lib/client/chat/turn-assistant.d.ts +12 -0
  39. package/lib/client/chat/turn-metrics.d.ts +42 -0
  40. package/lib/client/chat/use-calendar-day.d.ts +5 -0
  41. package/lib/client/chat/use-throttled-visual-update.d.ts +7 -0
  42. package/lib/client/chat-flow.d.ts +110 -0
  43. package/lib/client/composer-selects.d.ts +17 -0
  44. package/lib/client/dag-editor-model.d.ts +141 -0
  45. package/lib/client/describe-error.d.ts +9 -0
  46. package/lib/client/index.d.ts +6 -0
  47. package/lib/client/locales.d.ts +777 -0
  48. package/lib/client/model-faces.d.ts +156 -0
  49. package/lib/client/open-discussion.d.ts +24 -0
  50. package/lib/client/panel-model.d.ts +34 -0
  51. package/lib/client/remote.d.ts +34 -0
  52. package/lib/client/run-view-model.d.ts +304 -0
  53. package/lib/client/sandore-save.d.ts +41 -0
  54. package/lib/client/sandore-sse.d.ts +15 -0
  55. package/lib/client/step-chat-retry.d.ts +44 -0
  56. package/lib/client/tool/GenericToolCard.d.ts +28 -0
  57. package/lib/client/tool/ToolRow.d.ts +95 -0
  58. package/lib/client/tool/diff-card-model.d.ts +63 -0
  59. package/lib/client/tool/read-card-model.d.ts +67 -0
  60. package/lib/client/tool/search-card-model.d.ts +94 -0
  61. package/lib/client/tool/terminal-card-model.d.ts +86 -0
  62. package/lib/client/tool/tool-call-model.d.ts +74 -0
  63. package/lib/client/tool/web-card-model.d.ts +44 -0
  64. package/lib/client/use-focus-refresh.d.ts +1 -0
  65. package/lib/client/workflow-face.d.ts +68 -0
  66. package/lib/client/workflow-store.d.ts +80 -0
  67. package/lib/client/workspace-path.d.ts +15 -0
  68. package/lib/client.js +38389 -0
  69. package/lib/index.js +8854 -0
  70. package/lib/microflow/branch-evaluator.d.ts +290 -0
  71. package/lib/microflow/concurrency-limiter.d.ts +39 -0
  72. package/lib/microflow/errors.d.ts +39 -0
  73. package/lib/microflow/file-utils.d.ts +210 -0
  74. package/lib/microflow/finalize-manager.d.ts +414 -0
  75. package/lib/microflow/fork-session.d.ts +151 -0
  76. package/lib/microflow/index.d.ts +17 -0
  77. package/lib/microflow/internal.d.ts +56 -0
  78. package/lib/microflow/loop-manager.d.ts +127 -0
  79. package/lib/microflow/rules-resolver.d.ts +28 -0
  80. package/lib/microflow/stage-executor.d.ts +362 -0
  81. package/lib/microflow/step-navigator.d.ts +108 -0
  82. package/lib/microflow/step-runner.d.ts +190 -0
  83. package/lib/microflow/subagents.d.ts +380 -0
  84. package/lib/tool/index.d.ts +65 -0
  85. package/lib/typert.host.d.ts +16 -0
  86. package/lib/typert.host.js +25 -0
  87. package/lib/typert.remote-client.d.ts +114 -0
  88. package/lib/typert.remote-client.js +399 -0
  89. package/lib/workflow/ask-tool.d.ts +78 -0
  90. package/lib/workflow/dag-navigator.d.ts +136 -0
  91. package/lib/workflow/domain.d.ts +304 -0
  92. package/lib/workflow/engine-surface.d.ts +70 -0
  93. package/lib/workflow/errors.d.ts +13 -0
  94. package/lib/workflow/host-agent.d.ts +53 -0
  95. package/lib/workflow/index.d.ts +23 -0
  96. package/lib/workflow/interaction-manager.d.ts +170 -0
  97. package/lib/workflow/lifecycle-manager.d.ts +111 -0
  98. package/lib/workflow/persistence.d.ts +35 -0
  99. package/lib/workflow/plugin.d.ts +27 -0
  100. package/lib/workflow/reactive-state.d.ts +27 -0
  101. package/lib/workflow/recovery-manager.d.ts +158 -0
  102. package/lib/workflow/remote.d.ts +139 -0
  103. package/lib/workflow/schema.d.ts +1917 -0
  104. package/lib/workflow/seam.d.ts +254 -0
  105. package/lib/workflow/shell.d.ts +127 -0
  106. package/lib/workflow/sse.d.ts +16 -0
  107. package/lib/workflow/typert/typert.host.d.ts +16 -0
  108. package/lib/workflow/typert/typert.remote-client.d.ts +114 -0
  109. package/lib/workflow/types.d.ts +187 -0
  110. package/lib/workflow/wire.d.ts +256 -0
  111. package/lib/workflow/workflow-engine.d.ts +373 -0
  112. package/lib/workflow/yaml-error.d.ts +22 -0
  113. package/lib/workflow/yaml-parser.d.ts +23 -0
  114. package/package.json +140 -0
  115. package/packages/client/CommandEditor.tsx +73 -0
  116. package/packages/client/DagEditor.module.css +711 -0
  117. package/packages/client/DagEditor.tsx +1203 -0
  118. package/packages/client/DefinitionDetail.module.css +110 -0
  119. package/packages/client/DefinitionDetail.tsx +126 -0
  120. package/packages/client/DefinitionList.tsx +173 -0
  121. package/packages/client/DiscussionInput.tsx +763 -0
  122. package/packages/client/DragHandle.tsx +92 -0
  123. package/packages/client/EditorPane.tsx +70 -0
  124. package/packages/client/FlowEditor.tsx +227 -0
  125. package/packages/client/InstanceList.tsx +250 -0
  126. package/packages/client/NewInstanceDialog.module.css +244 -0
  127. package/packages/client/NewInstanceDialog.tsx +478 -0
  128. package/packages/client/PreviewPanel.tsx +55 -0
  129. package/packages/client/RunView.module.css +246 -0
  130. package/packages/client/RunView.tsx +314 -0
  131. package/packages/client/StepChatPanel.tsx +486 -0
  132. package/packages/client/TreeNav.tsx +294 -0
  133. package/packages/client/UnifiedTimeline.tsx +484 -0
  134. package/packages/client/WorkflowButton.module.css +72 -0
  135. package/packages/client/WorkflowButton.tsx +50 -0
  136. package/packages/client/WorkflowList.tsx +158 -0
  137. package/packages/client/WorkflowOverlay.module.css +390 -0
  138. package/packages/client/WorkflowOverlay.tsx +136 -0
  139. package/packages/client/WorkflowPanel.module.css +247 -0
  140. package/packages/client/chat/AssistantMarkdown.module.css +38 -0
  141. package/packages/client/chat/AssistantMarkdown.tsx +120 -0
  142. package/packages/client/chat/ChatView.module.css +198 -0
  143. package/packages/client/chat/CompactionItem.tsx +85 -0
  144. package/packages/client/chat/ContextBody.module.css +162 -0
  145. package/packages/client/chat/ContextBody.tsx +598 -0
  146. package/packages/client/chat/ContextInjectionRow.module.css +63 -0
  147. package/packages/client/chat/ContextInjectionRow.tsx +78 -0
  148. package/packages/client/chat/GenericCommandCard.module.css +87 -0
  149. package/packages/client/chat/GenericCommandCard.tsx +76 -0
  150. package/packages/client/chat/MessageIconActions.module.css +84 -0
  151. package/packages/client/chat/MessageIconActions.tsx +147 -0
  152. package/packages/client/chat/MessageItem.module.css +293 -0
  153. package/packages/client/chat/MessageItem.tsx +384 -0
  154. package/packages/client/chat/ReasoningRow.module.css +82 -0
  155. package/packages/client/chat/ReasoningRow.tsx +70 -0
  156. package/packages/client/chat/TurnTailNodeView.module.css +10 -0
  157. package/packages/client/chat/TurnTailNodeView.tsx +63 -0
  158. package/packages/client/chat/UserMessageRow.module.css +75 -0
  159. package/packages/client/chat/UserMessageRow.tsx +92 -0
  160. package/packages/client/chat/accessibility.module.css +9 -0
  161. package/packages/client/chat/attachment/ImageLightbox.module.css +45 -0
  162. package/packages/client/chat/attachment/ImageLightbox.tsx +70 -0
  163. package/packages/client/chat/attachment/MessageImage.module.css +70 -0
  164. package/packages/client/chat/attachment/MessageImage.tsx +136 -0
  165. package/packages/client/chat/attachment/image-attachment.ts +98 -0
  166. package/packages/client/chat/attachment/image-labels.ts +34 -0
  167. package/packages/client/chat/message-chrome.ts +96 -0
  168. package/packages/client/chat/reference/ReferenceIcon.tsx +40 -0
  169. package/packages/client/chat/turn-assistant.ts +15 -0
  170. package/packages/client/chat/turn-metrics.ts +102 -0
  171. package/packages/client/chat/use-calendar-day.ts +27 -0
  172. package/packages/client/chat/use-throttled-visual-update.ts +44 -0
  173. package/packages/client/chat-flow.spec.ts +357 -0
  174. package/packages/client/chat-flow.ts +278 -0
  175. package/packages/client/composer-selects.module.css +376 -0
  176. package/packages/client/composer-selects.tsx +531 -0
  177. package/packages/client/dag-editor-model.spec.ts +173 -0
  178. package/packages/client/dag-editor-model.ts +359 -0
  179. package/packages/client/describe-error.spec.ts +18 -0
  180. package/packages/client/describe-error.ts +12 -0
  181. package/packages/client/index.ts +144 -0
  182. package/packages/client/input.module.css +854 -0
  183. package/packages/client/is-conditional-check.spec.ts +34 -0
  184. package/packages/client/locales.ts +789 -0
  185. package/packages/client/model-faces.ts +225 -0
  186. package/packages/client/open-discussion.spec.ts +39 -0
  187. package/packages/client/open-discussion.ts +32 -0
  188. package/packages/client/panel-model.spec.ts +152 -0
  189. package/packages/client/panel-model.ts +72 -0
  190. package/packages/client/preview.module.css +95 -0
  191. package/packages/client/react-dom.d.ts +11 -0
  192. package/packages/client/reactflow-theme.css +91 -0
  193. package/packages/client/remote.spec.ts +41 -0
  194. package/packages/client/remote.ts +46 -0
  195. package/packages/client/run-view-model.spec.ts +641 -0
  196. package/packages/client/run-view-model.ts +636 -0
  197. package/packages/client/sandore-save.spec.ts +146 -0
  198. package/packages/client/sandore-save.ts +97 -0
  199. package/packages/client/sandore-sse.ts +59 -0
  200. package/packages/client/step-chat-retry.spec.ts +70 -0
  201. package/packages/client/step-chat-retry.ts +65 -0
  202. package/packages/client/timeline.module.css +503 -0
  203. package/packages/client/tool/GenericToolCard.tsx +99 -0
  204. package/packages/client/tool/ToolRow.module.css +308 -0
  205. package/packages/client/tool/ToolRow.tsx +328 -0
  206. package/packages/client/tool/diff-card-model.ts +103 -0
  207. package/packages/client/tool/read-card-model.ts +86 -0
  208. package/packages/client/tool/search-card-model.ts +166 -0
  209. package/packages/client/tool/terminal-card-model.ts +225 -0
  210. package/packages/client/tool/tool-call-model.ts +267 -0
  211. package/packages/client/tool/web-card-model.ts +80 -0
  212. package/packages/client/tree-nav.module.css +282 -0
  213. package/packages/client/use-focus-refresh.ts +23 -0
  214. package/packages/client/workflow-face.ts +72 -0
  215. package/packages/client/workflow-store.spec.ts +49 -0
  216. package/packages/client/workflow-store.ts +161 -0
  217. package/packages/client/workspace-path.ts +27 -0
  218. package/packages/css-modules.d.ts +6 -0
  219. package/packages/microflow/branch-evaluator.spec.ts +745 -0
  220. package/packages/microflow/branch-evaluator.ts +1037 -0
  221. package/packages/microflow/concurrency-limiter.ts +70 -0
  222. package/packages/microflow/errors.ts +64 -0
  223. package/packages/microflow/file-utils.spec.ts +604 -0
  224. package/packages/microflow/file-utils.ts +521 -0
  225. package/packages/microflow/finalize-manager.spec.ts +1977 -0
  226. package/packages/microflow/finalize-manager.ts +1464 -0
  227. package/packages/microflow/fork-session.spec.ts +184 -0
  228. package/packages/microflow/fork-session.ts +316 -0
  229. package/packages/microflow/index.ts +17 -0
  230. package/packages/microflow/internal.ts +166 -0
  231. package/packages/microflow/loop-manager.spec.ts +294 -0
  232. package/packages/microflow/loop-manager.ts +255 -0
  233. package/packages/microflow/rules-resolver.spec.ts +147 -0
  234. package/packages/microflow/rules-resolver.ts +58 -0
  235. package/packages/microflow/stage-executor.spec.ts +1577 -0
  236. package/packages/microflow/stage-executor.ts +1478 -0
  237. package/packages/microflow/step-navigator.spec.ts +301 -0
  238. package/packages/microflow/step-navigator.ts +300 -0
  239. package/packages/microflow/step-runner.spec.ts +1132 -0
  240. package/packages/microflow/step-runner.ts +752 -0
  241. package/packages/microflow/subagents.spec.ts +215 -0
  242. package/packages/microflow/subagents.ts +422 -0
  243. package/packages/tests/fixtures/test-workflow.yaml +128 -0
  244. package/packages/tests/mocks/agents.ts +206 -0
  245. package/packages/tests/mocks/llm.ts +46 -0
  246. package/packages/tests/mocks/session-title.ts +31 -0
  247. package/packages/tests/mocks/sessions.ts +131 -0
  248. package/packages/tests/mocks/shell.ts +60 -0
  249. package/packages/tests/mocks/storage-domain.ts +101 -0
  250. package/packages/tests/mocks/subagents.ts +197 -0
  251. package/packages/tests/mocks/subprocess.ts +59 -0
  252. package/packages/tests/mocks/user-questions.ts +33 -0
  253. package/packages/tests/mocks/workspace.ts +52 -0
  254. package/packages/tool/index.spec.ts +287 -0
  255. package/packages/tool/index.ts +348 -0
  256. package/packages/workflow/ask-tool.ts +173 -0
  257. package/packages/workflow/dag-navigator.spec.ts +236 -0
  258. package/packages/workflow/dag-navigator.ts +425 -0
  259. package/packages/workflow/domain.ts +26 -0
  260. package/packages/workflow/engine-surface.ts +71 -0
  261. package/packages/workflow/errors.ts +13 -0
  262. package/packages/workflow/host-agent.spec.ts +92 -0
  263. package/packages/workflow/host-agent.ts +62 -0
  264. package/packages/workflow/index.ts +25 -0
  265. package/packages/workflow/interaction-manager.spec.ts +390 -0
  266. package/packages/workflow/interaction-manager.ts +397 -0
  267. package/packages/workflow/lifecycle-manager.spec.ts +683 -0
  268. package/packages/workflow/lifecycle-manager.ts +288 -0
  269. package/packages/workflow/persistence.spec.ts +109 -0
  270. package/packages/workflow/persistence.ts +64 -0
  271. package/packages/workflow/plugin.ts +39 -0
  272. package/packages/workflow/reactive-state.spec.ts +69 -0
  273. package/packages/workflow/reactive-state.ts +52 -0
  274. package/packages/workflow/recovery-manager.spec.ts +606 -0
  275. package/packages/workflow/recovery-manager.ts +471 -0
  276. package/packages/workflow/remote.spec.ts +1045 -0
  277. package/packages/workflow/remote.ts +479 -0
  278. package/packages/workflow/schema.spec.ts +303 -0
  279. package/packages/workflow/schema.ts +562 -0
  280. package/packages/workflow/seam.ts +293 -0
  281. package/packages/workflow/shell.ts +142 -0
  282. package/packages/workflow/sse.ts +110 -0
  283. package/packages/workflow/typert/typert.host.d.ts +16 -0
  284. package/packages/workflow/typert/typert.host.js +25 -0
  285. package/packages/workflow/typert/typert.remote-client.d.ts +114 -0
  286. package/packages/workflow/typert/typert.remote-client.js +399 -0
  287. package/packages/workflow/types.ts +258 -0
  288. package/packages/workflow/wire.ts +267 -0
  289. package/packages/workflow/workflow-engine-e2e.spec.ts +262 -0
  290. package/packages/workflow/workflow-engine-real-e2e.spec.ts +922 -0
  291. package/packages/workflow/workflow-engine.spec.ts +4389 -0
  292. package/packages/workflow/workflow-engine.ts +2133 -0
  293. package/packages/workflow/yaml-error.ts +29 -0
  294. package/packages/workflow/yaml-parser.spec.ts +757 -0
  295. package/packages/workflow/yaml-parser.ts +287 -0
@@ -0,0 +1,1045 @@
1
+ /**
2
+ * remote.ts(SandoreRemote)的单元测试:定义/实例 CRUD、产物查询、
3
+ * Provider/Model 转发与 ping。测试用内存 mock storageDomain + mock llm +
4
+ * mock sandoreEngine。
5
+ * @module dsh-sandore/workflow/src/remote.spec
6
+ */
7
+ import { Context } from '@deepseek-ai/cordis'
8
+ import type { LlmRuntime } from '@deepseek-ai/dsh-llm'
9
+ import type { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
10
+ import { describe, expect, it, vi } from 'vitest'
11
+ import { ZodError } from 'zod'
12
+
13
+ import { MemoryStorageDomain } from '../tests/mocks/storage-domain.ts'
14
+ import { SandoreError } from './errors.ts'
15
+ import type { SandoreEngine } from './index.ts'
16
+ import { SandorePersistence } from './persistence.ts'
17
+ import { SandoreRemote } from './remote.ts'
18
+ import type { WorkflowDefinitionRecord, WorkflowTaskState } from './schema.ts'
19
+ import type { AskUserQuestionAnswer, DagRun, DagStartRequest, RevertBoundaryOption } from './types.ts'
20
+ import type { ImportDefinitionInput, RecoverOrphanedInstancesResult, StartInstanceInput } from './wire.ts'
21
+ import { parseWorkflowYaml } from './yaml-parser.ts'
22
+
23
+ const validV2Yaml = `id: wfl-source
24
+ name: demo
25
+ version: "1.0.0"
26
+ description: shell to prompt
27
+ stages:
28
+ - id: stg-1
29
+ name: 分析
30
+ steps:
31
+ - id: step-1
32
+ steps:
33
+ - id: step-1
34
+ name: lint
35
+ command:
36
+ type: shell
37
+ value:
38
+ command: "echo hi"
39
+ `
40
+
41
+ /** 构造一个字段完整的 `WorkflowTaskState` 快照(`taskId` 必填)。 */
42
+ function makeState(overrides: Partial<WorkflowTaskState> & { taskId: string }): WorkflowTaskState {
43
+ const { taskId, ...rest } = overrides
44
+ const now = new Date().toISOString()
45
+ return {
46
+ taskId,
47
+ workflowId: 'wfl-1',
48
+ cwd: '/tmp',
49
+ status: 'running',
50
+ originalInput: 'do the thing',
51
+ currentStageId: '',
52
+ executedSteps: [],
53
+ stageInfos: [],
54
+ pendingInteractions: [],
55
+ pendingQueue: [],
56
+ visitCounts: {},
57
+ passThroughVars: {},
58
+ activeLoopIterationStack: [],
59
+ activeLoopNodeId: null,
60
+ activeLoopStack: [],
61
+ pausedChildIds: [],
62
+ stageContracts: {},
63
+ createdAt: now,
64
+ updatedAt: now,
65
+ ...rest,
66
+ }
67
+ }
68
+
69
+ /**
70
+ * 捕获 `fn` 抛出的 {@link SandoreError} 并返回其 `code`;未抛错或非 SandoreError 返回 `undefined`。
71
+ * @param fn - 待执行的可疑同步函数。
72
+ */
73
+ function catchCode(fn: () => unknown): string | undefined {
74
+ try {
75
+ fn()
76
+ return undefined
77
+ } catch (err) {
78
+ return err instanceof SandoreError ? err.code : undefined
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 捕获 `fn` 异步拒绝的 {@link SandoreError} 并返回其 `code`;未拒绝或非 SandoreError 返回 `undefined`。
84
+ * @param fn - 待执行的可疑异步函数。
85
+ */
86
+ async function catchCodeAsync(fn: () => Promise<unknown>): Promise<string | undefined> {
87
+ try {
88
+ await fn()
89
+ return undefined
90
+ } catch (err) {
91
+ return err instanceof SandoreError ? err.code : undefined
92
+ }
93
+ }
94
+
95
+ /** mock 引擎的生命周期方法桩(`vi.fn` 记录调用,便于断言转调链路)。 */
96
+ interface MockLifecycleEngine {
97
+ getInstanceState: ReturnType<typeof vi.fn>
98
+ replyQuestion: ReturnType<typeof vi.fn>
99
+ rejectQuestion: ReturnType<typeof vi.fn>
100
+ replyPermission: ReturnType<typeof vi.fn>
101
+ pauseStep: ReturnType<typeof vi.fn>
102
+ resumeStep: ReturnType<typeof vi.fn>
103
+ pauseAllSteps: ReturnType<typeof vi.fn>
104
+ resumeAllSteps: ReturnType<typeof vi.fn>
105
+ retryStep: ReturnType<typeof vi.fn>
106
+ listRevertBoundaries: ReturnType<typeof vi.fn>
107
+ sendStepMessage: ReturnType<typeof vi.fn>
108
+ setPermissionMode: ReturnType<typeof vi.fn>
109
+ recoverInstance: ReturnType<typeof vi.fn>
110
+ recoverOrphanedInstances: ReturnType<typeof vi.fn>
111
+ }
112
+
113
+ /** 组装测试 harness:注入内存 storageDomain、mock llm 与 mock 引擎。 */
114
+ async function harness(): Promise<{
115
+ ctx: Context
116
+ persistence: SandorePersistence
117
+ remote: SandoreRemote
118
+ /** stub 引擎收到的全部 startInstance 请求(透传断言)。 */
119
+ startRequests: DagStartRequest[]
120
+ engine: {
121
+ startInstance: (request: DagStartRequest) => DagRun
122
+ abortInstance: (instanceId: string) => Promise<void>
123
+ } & MockLifecycleEngine
124
+ }> {
125
+ const ctx = new Context()
126
+ const storageDomain = new MemoryStorageDomain()
127
+ ctx.provide('storageDomain', storageDomain as unknown as DomainFacility)
128
+ await ctx.plugin(SandorePersistence)
129
+ const persistence = ctx.sandorePersistence
130
+
131
+ const llm = {
132
+ listProviders: () => [{ id: 'provider-a', name: 'Provider A' }],
133
+ listModels: async (provider: string) =>
134
+ provider === 'provider-a'
135
+ ? [{ provider: 'provider-a', id: 'model-a', name: 'Model A' }]
136
+ : [],
137
+ }
138
+ ctx.provide('llm', llm as unknown as LlmRuntime)
139
+
140
+ const startRequests: DagStartRequest[] = []
141
+ const startInstance = (request: DagStartRequest): DagRun => {
142
+ startRequests.push(request)
143
+ const instanceId = `inst-${globalThis.crypto.randomUUID().slice(0, 8)}`
144
+ const now = new Date().toISOString()
145
+ const state: WorkflowTaskState = {
146
+ taskId: instanceId,
147
+ workflowId: request.workflowId,
148
+ // cwd 必填(wire schema 已校验),mock 原样透传。
149
+ cwd: request.cwd,
150
+ status: 'running',
151
+ originalInput: request.originalInput,
152
+ currentStageId: '',
153
+ executedSteps: [],
154
+ stageInfos: [],
155
+ pendingInteractions: [],
156
+ pendingQueue: [],
157
+ visitCounts: {},
158
+ passThroughVars: {},
159
+ activeLoopIterationStack: [],
160
+ activeLoopNodeId: null,
161
+ activeLoopStack: [],
162
+ pausedChildIds: [],
163
+ stageContracts: {},
164
+ createdAt: now,
165
+ updatedAt: now,
166
+ }
167
+ void persistence.saveWorkflowState(state)
168
+ return { instanceId, status: 'running' }
169
+ }
170
+ const engine = {
171
+ startInstance,
172
+ abortInstance: async (instanceId: string): Promise<void> => {
173
+ const current = persistence.state.get(instanceId)
174
+ if (current === undefined) return
175
+ await persistence.saveWorkflowState({ ...current, status: 'aborted', updatedAt: new Date().toISOString() })
176
+ },
177
+ getInstanceState: vi.fn((_instanceId: string): WorkflowTaskState | undefined => undefined),
178
+ replyQuestion: vi.fn(async (_instanceId: string, _requestId: string, _answers: AskUserQuestionAnswer) => {}),
179
+ rejectQuestion: vi.fn(async (_instanceId: string, _requestId: string) => {}),
180
+ replyPermission: vi.fn(async (_instanceId: string, _requestId: string, _reply: 'once' | 'always' | 'reject') => {}),
181
+ pauseStep: vi.fn(async (_instanceId: string, _stepId: string) => {}),
182
+ resumeStep: vi.fn(async (_instanceId: string, _stepId: string, _resumePrompt?: string) => {}),
183
+ pauseAllSteps: vi.fn(async (_instanceId: string) => {}),
184
+ resumeAllSteps: vi.fn(async (_instanceId: string, _resumePrompt?: string) => {}),
185
+ retryStep: vi.fn(async (_instanceId: string, _stepId: string, _mode: 'continue' | 'revert' | 'fork', _message: string, _revertBoundary?: number) => {}),
186
+ listRevertBoundaries: vi.fn((_instanceId: string, _stepId: string): RevertBoundaryOption[] => []),
187
+ sendStepMessage: vi.fn(async (_instanceId: string, _stepId: string, _message: string) => {}),
188
+ setPermissionMode: vi.fn(async (_instanceId: string, _mode: 'read-only' | 'workspace-write' | 'danger-full-access') => {}),
189
+ recoverInstance: vi.fn(async (instanceId: string): Promise<DagRun> => ({ instanceId, status: 'running' })),
190
+ recoverOrphanedInstances: vi.fn(async (): Promise<RecoverOrphanedInstancesResult> => ({ recovered: [], errors: [] })),
191
+ }
192
+ ctx.provide('sandoreEngine', engine as unknown as SandoreEngine)
193
+
194
+ const remote = new SandoreRemote(ctx)
195
+ return { ctx, persistence, remote, startRequests, engine }
196
+ }
197
+
198
+ describe('SandoreRemote definition CRUD', () => {
199
+ it('creates a wfl- prefixed definition and lists/yaml round-trips it', async () => {
200
+ const { remote } = await harness()
201
+
202
+ const created = await remote.createDefinition({ yaml: validV2Yaml })
203
+ expect(created.id).toMatch(/^wfl-/)
204
+ expect(created.name).toBe('demo')
205
+ expect(created.version).toBe('1.0.0')
206
+
207
+ // 存储的 YAML 文本经规范序列化,其顶层 id 与记录键一致(而非调用方传入的原文 id)。
208
+ expect(parseWorkflowYaml(created.yaml).id).toBe(created.id)
209
+
210
+ const listed = remote.listDefinitions()
211
+ expect(listed).toHaveLength(1)
212
+ expect(listed[0]?.id).toBe(created.id)
213
+ expect(remote.getYaml(created.id)).toBe(created.yaml)
214
+ })
215
+
216
+ it('stores canonical YAML whose id matches the record key, not the sentinel id', async () => {
217
+ const { remote } = await harness()
218
+ const blankYaml = `id: wfl-new
219
+ name: 未命名工作流
220
+ version: "1.0.0"
221
+ skills: []
222
+ mcps: []
223
+ agents: []
224
+ settings:
225
+ defaultTimeout: 120
226
+ maxConcurrentAgents: 4
227
+ stages:
228
+ - id: stg-1
229
+ name: 阶段 1
230
+ steps: []
231
+ `
232
+
233
+ const created = await remote.createDefinition({ yaml: blankYaml })
234
+
235
+ expect(created.id).toMatch(/^wfl-/)
236
+ expect(created.id).not.toBe('wfl-new')
237
+ // 首次保存后再打开,YAML 里的 id 是真实 wfl-{uuid} 而非 wfl-new。
238
+ const stored = parseWorkflowYaml(remote.getYaml(created.id))
239
+ expect(stored.id).toBe(created.id)
240
+ expect(stored.id).not.toBe('wfl-new')
241
+ })
242
+
243
+ it('rejects invalid YAML and leaves the definitions table empty', async () => {
244
+ const { remote } = await harness()
245
+
246
+ await expect(remote.createDefinition({ yaml: 'stages: [ broken' })).rejects.toMatchObject({
247
+ code: 'YAML_PARSE',
248
+ })
249
+ expect(remote.listDefinitions()).toHaveLength(0)
250
+ })
251
+
252
+ it('rejects invalid YAML on updateYaml and keeps the old yaml', async () => {
253
+ const { remote } = await harness()
254
+ const created = await remote.createDefinition({ yaml: validV2Yaml })
255
+
256
+ await expect(remote.updateYaml(created.id, 'stages: [ broken')).rejects.toMatchObject({
257
+ code: 'YAML_PARSE',
258
+ })
259
+ expect(remote.getYaml(created.id)).toBe(created.yaml)
260
+ })
261
+
262
+ it('syncs metadata from the new yaml on updateYaml', async () => {
263
+ const { remote } = await harness()
264
+ const created = await remote.createDefinition({ yaml: validV2Yaml })
265
+ const renamed = validV2Yaml.replace('name: demo', 'name: 已改名工作流')
266
+
267
+ const updated = await remote.updateYaml(created.id, renamed)
268
+
269
+ expect(updated.name).toBe('已改名工作流')
270
+ expect(updated.yaml).toBe(renamed)
271
+ expect(remote.listDefinitions().find(entry => entry.id === created.id)?.name).toBe('已改名工作流')
272
+ })
273
+
274
+ it('deletes a definition idempotently', async () => {
275
+ const { remote } = await harness()
276
+ const created = await remote.createDefinition({ yaml: validV2Yaml })
277
+
278
+ await expect(remote.deleteDefinition(created.id)).resolves.toBe(true)
279
+ expect(remote.listDefinitions()).toHaveLength(0)
280
+ await expect(remote.deleteDefinition(created.id)).resolves.toBe(false)
281
+ })
282
+
283
+ it('updates definition metadata and returns the updated record', async () => {
284
+ const { remote } = await harness()
285
+ const created = await remote.createDefinition({ yaml: validV2Yaml })
286
+
287
+ const updated = await remote.updateDefinition(created.id, {
288
+ name: 'renamed',
289
+ version: '2.0.0',
290
+ description: 'new description',
291
+ })
292
+
293
+ expect(updated.id).toBe(created.id)
294
+ expect(updated.name).toBe('renamed')
295
+ expect(updated.version).toBe('2.0.0')
296
+ expect(updated.description).toBe('new description')
297
+ expect(updated.yaml).toBe(created.yaml)
298
+ })
299
+
300
+ it('getYaml throws NODE_NOT_FOUND for a nonexistent definition', async () => {
301
+ const { remote } = await harness()
302
+
303
+ expect(catchCode(() => remote.getYaml('wfl-nonexistent'))).toBe('NODE_NOT_FOUND')
304
+ })
305
+ })
306
+
307
+ describe('SandoreRemote importDefinitionFromYaml RPC (缺陷 30)', () => {
308
+ // V1 三级 YAML(stages[].scenarios[].tasks[])——parseWorkflowYaml 内建 convertLegacyToV2。
309
+ const v1Yaml = `id: code-review-workflow
310
+ name: 代码审查工作流
311
+ version: "1.0.0"
312
+ settings:
313
+ defaultTimeout: 60
314
+ stages:
315
+ - id: stg-analyze
316
+ name: 代码分析
317
+ scenarios:
318
+ - id: sce-static-analysis
319
+ name: 静态分析
320
+ tasks:
321
+ - id: tsk-lint
322
+ name: 代码规范检查
323
+ prompt:
324
+ type: Bash
325
+ value:
326
+ command: "npm run lint 2>&1"
327
+ outputTo:
328
+ - id: opt-lint-report
329
+ path: "reports/lint.txt"
330
+ `
331
+
332
+ it('creates a new definition when no match exists (matchBy name default)', async () => {
333
+ const { remote } = await harness()
334
+
335
+ const result = await remote.importDefinitionFromYaml({ yaml: validV2Yaml })
336
+
337
+ expect(result.status).toBe('created')
338
+ expect(result.matchedCount).toBe(0)
339
+ expect(result.id).toMatch(/^wfl-/)
340
+ expect(result.name).toBe('demo')
341
+ // 复用 createDefinition:存储的 YAML 顶层 id 与记录键一致(哨兵 id 被改写)。
342
+ expect(parseWorkflowYaml(remote.getYaml(result.id)).id).toBe(result.id)
343
+ })
344
+
345
+ it('skips when an existing name match has the same version', async () => {
346
+ const { remote } = await harness()
347
+ const created = await remote.createDefinition({ yaml: validV2Yaml })
348
+
349
+ // 再次导入同一 YAML(同 name 同 version)→ skipped,不新增记录。
350
+ const result = await remote.importDefinitionFromYaml({ yaml: validV2Yaml })
351
+
352
+ expect(result.status).toBe('skipped')
353
+ expect(result.matchedCount).toBe(1)
354
+ expect(result.id).toBe(created.id)
355
+ expect(remote.listDefinitions()).toHaveLength(1)
356
+ })
357
+
358
+ it('updates when an existing name match has a different version', async () => {
359
+ const { remote } = await harness()
360
+ const created = await remote.createDefinition({ yaml: validV2Yaml })
361
+ const v2Yaml = validV2Yaml.replace('version: "1.0.0"', 'version: "2.0.0"')
362
+
363
+ const result = await remote.importDefinitionFromYaml({ yaml: v2Yaml })
364
+
365
+ expect(result.status).toBe('updated')
366
+ expect(result.matchedCount).toBe(1)
367
+ expect(result.id).toBe(created.id)
368
+ // 复用 updateYaml:元数据从新 YAML 同步,YAML 文本为传入原文。
369
+ const stored = remote.listDefinitions().find(entry => entry.id === created.id)
370
+ expect(stored?.version).toBe('2.0.0')
371
+ expect(stored?.name).toBe('demo')
372
+ expect(remote.getYaml(created.id)).toBe(v2Yaml)
373
+ })
374
+
375
+ it('resolves name multi-match by latest updatedAt and reports matchedCount (SA8)', async () => {
376
+ const { remote, persistence } = await harness()
377
+ // 直接写入两条同名同版本记录,控制 updatedAt 以验证确定性策略(取最新)。
378
+ const seed = (updatedAt: string): WorkflowDefinitionRecord => ({
379
+ yaml: validV2Yaml,
380
+ name: 'demo',
381
+ version: '1.0.0',
382
+ createdAt: '2024-01-01T00:00:00.000Z',
383
+ updatedAt,
384
+ })
385
+ await persistence.definitions.put('wfl-older', seed('2024-01-01T00:00:00.000Z'))
386
+ await persistence.definitions.put('wfl-newer', seed('2024-01-02T00:00:00.000Z'))
387
+
388
+ const v2Yaml = validV2Yaml.replace('version: "1.0.0"', 'version: "2.0.0"')
389
+ const result = await remote.importDefinitionFromYaml({ yaml: v2Yaml })
390
+
391
+ // 多匹配:取最新 updatedAt(wfl-newer)更新,matchedCount 注明歧义命中数。
392
+ expect(result.status).toBe('updated')
393
+ expect(result.matchedCount).toBe(2)
394
+ expect(result.id).toBe('wfl-newer')
395
+ expect(remote.getYaml('wfl-newer')).toBe(v2Yaml)
396
+ // 未命中的旧记录保持不变。
397
+ expect(parseWorkflowYaml(remote.getYaml('wfl-older')).version).toBe('1.0.0')
398
+ })
399
+
400
+ it('imports V1 YAML via built-in conversion and reports created with converted name/version', async () => {
401
+ const { remote } = await harness()
402
+
403
+ const result = await remote.importDefinitionFromYaml({ yaml: v1Yaml })
404
+
405
+ expect(result.status).toBe('created')
406
+ expect(result.matchedCount).toBe(0)
407
+ expect(result.name).toBe('代码审查工作流')
408
+ // V1 已自动转换为 V2:存储的 YAML 无 scenarios,name/version 与转换后一致。
409
+ const stored = remote.listDefinitions().find(entry => entry.id === result.id)
410
+ expect(stored?.version).toBe('1.0.0')
411
+ expect(stored?.name).toBe('代码审查工作流')
412
+ const reparsed = parseWorkflowYaml(remote.getYaml(result.id))
413
+ expect(reparsed.stages[0]).not.toHaveProperty('scenarios')
414
+ expect(reparsed.name).toBe('代码审查工作流')
415
+ })
416
+
417
+ it('skips a V1 re-import after conversion when name/version already match', async () => {
418
+ const { remote } = await harness()
419
+ const created = await remote.importDefinitionFromYaml({ yaml: v1Yaml })
420
+
421
+ // 同一 V1 文件重放:转换后 name/version 与已存记录相同 → skipped(幂等)。
422
+ const result = await remote.importDefinitionFromYaml({ yaml: v1Yaml })
423
+
424
+ expect(result.status).toBe('skipped')
425
+ expect(result.matchedCount).toBe(1)
426
+ expect(result.id).toBe(created.id)
427
+ expect(remote.listDefinitions()).toHaveLength(1)
428
+ })
429
+
430
+ it('matches by YAML top-level id when matchBy id is requested', async () => {
431
+ const { remote, persistence } = await harness()
432
+ // 以 YAML 顶层 id 为键直接写入一条记录(绕过 createDefinition 的 uuid 改写),
433
+ // 验证 matchBy:'id' 按 definitions.get(yamlId) 命中并做版本比对。
434
+ const seeded: WorkflowDefinitionRecord = {
435
+ yaml: validV2Yaml,
436
+ name: 'demo',
437
+ version: '1.0.0',
438
+ createdAt: '2024-01-01T00:00:00.000Z',
439
+ updatedAt: '2024-01-01T00:00:00.000Z',
440
+ }
441
+ await persistence.definitions.put('wfl-source', seeded)
442
+
443
+ const sameVersion = await remote.importDefinitionFromYaml({ yaml: validV2Yaml, matchBy: 'id' })
444
+ expect(sameVersion.status).toBe('skipped')
445
+ expect(sameVersion.id).toBe('wfl-source')
446
+ expect(sameVersion.matchedCount).toBe(1)
447
+
448
+ const v2Yaml = validV2Yaml.replace('version: "1.0.0"', 'version: "2.0.0"')
449
+ const diffVersion = await remote.importDefinitionFromYaml({ yaml: v2Yaml, matchBy: 'id' })
450
+ expect(diffVersion.status).toBe('updated')
451
+ expect(diffVersion.id).toBe('wfl-source')
452
+ expect(diffVersion.matchedCount).toBe(1)
453
+ expect(remote.getYaml('wfl-source')).toBe(v2Yaml)
454
+ })
455
+
456
+ it('rejects a missing yaml field (zod, 缺陷 30)', async () => {
457
+ const { remote } = await harness()
458
+
459
+ await expect(
460
+ remote.importDefinitionFromYaml({} as ImportDefinitionInput),
461
+ ).rejects.toBeInstanceOf(ZodError)
462
+ })
463
+
464
+ it('rejects an invalid matchBy enum value (zod, 缺陷 30)', async () => {
465
+ const { remote } = await harness()
466
+
467
+ await expect(
468
+ remote.importDefinitionFromYaml({ yaml: validV2Yaml, matchBy: 'bad' as 'id' }),
469
+ ).rejects.toBeInstanceOf(ZodError)
470
+ })
471
+
472
+ it('rejects invalid YAML and leaves the definitions table empty (缺陷 30)', async () => {
473
+ const { remote } = await harness()
474
+
475
+ await expect(
476
+ remote.importDefinitionFromYaml({ yaml: 'stages: [ broken' }),
477
+ ).rejects.toMatchObject({ code: 'YAML_PARSE' })
478
+ expect(remote.listDefinitions()).toHaveLength(0)
479
+ })
480
+ })
481
+
482
+ describe('SandoreRemote instance lifecycle', () => {
483
+ it('startInstance returns a run and writes a readable state snapshot', async () => {
484
+ const { remote, persistence } = await harness()
485
+ const definition = await remote.createDefinition({ yaml: validV2Yaml })
486
+
487
+ const run = await remote.startInstance({ workflowId: definition.id, originalInput: '测试需求', cwd: '/tmp' })
488
+ expect(run.instanceId).toBeTruthy()
489
+ expect(run.status).toBe('running')
490
+
491
+ const state = remote.getInstanceState(run.instanceId)
492
+ expect(state.originalInput).toBe('测试需求')
493
+ expect(state.workflowId).toBe(definition.id)
494
+ expect(persistence.state.get(run.instanceId)?.originalInput).toBe('测试需求')
495
+ })
496
+
497
+ it('rejects startInstance for a nonexistent workflowId', async () => {
498
+ const { remote } = await harness()
499
+
500
+ await expect(remote.startInstance({ workflowId: 'wfl-nonexistent', originalInput: 'x', cwd: '/tmp' })).rejects.toMatchObject({
501
+ code: 'INVALID_OPERATION',
502
+ })
503
+ })
504
+
505
+ it('rejects a missing cwd on startInstance (wire schema, cwd required)', async () => {
506
+ const { remote } = await harness()
507
+ const definition = await remote.createDefinition({ yaml: validV2Yaml })
508
+
509
+ // cwd 已改为必填:缺 cwd 的请求在 wire schema 层被拒(ZodError),不会到达引擎。
510
+ await expect(
511
+ remote.startInstance({ workflowId: definition.id, originalInput: 'x' } as StartInstanceInput),
512
+ ).rejects.toBeInstanceOf(ZodError)
513
+ })
514
+
515
+ it('forwards the optional hostSessionId to the engine startInstance', async () => {
516
+ const { remote, startRequests } = await harness()
517
+ const definition = await remote.createDefinition({ yaml: validV2Yaml })
518
+
519
+ // 未传 hostSessionId:引擎请求不含该字段。
520
+ await remote.startInstance({ workflowId: definition.id, originalInput: 'a', cwd: '/tmp' })
521
+ expect(startRequests[0]).not.toHaveProperty('hostSessionId')
522
+
523
+ // 传入 hostSessionId:原样透传(复用宿主主代理会话,不新建)。
524
+ await remote.startInstance({ workflowId: definition.id, originalInput: 'b', cwd: '/tmp', hostSessionId: 'host-1' })
525
+ expect(startRequests[1]?.hostSessionId).toBe('host-1')
526
+ })
527
+
528
+ it('derives instance summaries for every state row (no job filter)', async () => {
529
+ const { remote } = await harness()
530
+ const definition = await remote.createDefinition({ yaml: validV2Yaml })
531
+ await remote.startInstance({ workflowId: definition.id, originalInput: 'a', cwd: '/tmp' })
532
+ await remote.startInstance({ workflowId: definition.id, originalInput: 'b', cwd: '/tmp' })
533
+
534
+ const instances = remote.listInstances()
535
+ expect(instances).toHaveLength(2)
536
+ expect(instances.map(instance => instance.workflowId)).toEqual([definition.id, definition.id])
537
+ expect(instances.map(instance => instance.originalInput).sort()).toEqual(['a', 'b'])
538
+ })
539
+
540
+ it('removes the snapshot on deleteInstance', async () => {
541
+ const { remote } = await harness()
542
+ const definition = await remote.createDefinition({ yaml: validV2Yaml })
543
+ const run = await remote.startInstance({ workflowId: definition.id, originalInput: 'x', cwd: '/tmp' })
544
+
545
+ await remote.deleteInstance(run.instanceId)
546
+ expect(catchCode(() => remote.getInstanceState(run.instanceId))).toBe('NODE_NOT_FOUND')
547
+ expect(remote.listInstances()).toHaveLength(0)
548
+ })
549
+
550
+ it('rejects abortInstance for a completed instance', async () => {
551
+ const { remote, persistence } = await harness()
552
+ await persistence.saveWorkflowState(makeState({ taskId: 'inst-done', status: 'completed' }))
553
+
554
+ await expect(remote.abortInstance('inst-done')).rejects.toMatchObject({ code: 'INVALID_OPERATION' })
555
+ })
556
+
557
+ it('getInstanceState returns memory state when engine has active instance (收尾中间态优先)', async () => {
558
+ const { remote, engine } = await harness()
559
+ // 模拟引擎内存有 active instance(含 finalizing 中间态)
560
+ const liveState = makeState({
561
+ taskId: 'inst-live',
562
+ status: 'running',
563
+ executedSteps: [{
564
+ type: 'default',
565
+ id: 'step-1',
566
+ stepId: 'step-1',
567
+ stageId: 'stg-1',
568
+ name: 'lint',
569
+ status: 'completed',
570
+ visitCount: 1,
571
+ finalizeMeta: {
572
+ currentPhase: 'finalizing',
573
+ forked: false,
574
+ gatesPassed: true,
575
+ },
576
+ }],
577
+ })
578
+ engine.getInstanceState.mockReturnValueOnce(liveState)
579
+
580
+ const state = remote.getInstanceState('inst-live')
581
+ expect(state.taskId).toBe('inst-live')
582
+ expect(state.executedSteps[0]?.finalizeMeta?.currentPhase).toBe('finalizing')
583
+ })
584
+
585
+ it('getInstanceState falls back to persistence when engine returns undefined (终态清表)', async () => {
586
+ const { remote, persistence, engine } = await harness()
587
+ // 引擎内存无此实例(终态清表后)
588
+ engine.getInstanceState.mockReturnValueOnce(undefined)
589
+ // 持久化有快照
590
+ await persistence.saveWorkflowState(makeState({ taskId: 'inst-done', status: 'completed' }))
591
+
592
+ const state = remote.getInstanceState('inst-done')
593
+ expect(state.taskId).toBe('inst-done')
594
+ expect(state.status).toBe('completed')
595
+ })
596
+
597
+ it('getInstanceState throws NODE_NOT_FOUND for a nonexistent instance', async () => {
598
+ const { remote, engine } = await harness()
599
+ // 引擎内存无此实例
600
+ engine.getInstanceState.mockReturnValueOnce(undefined)
601
+
602
+ expect(catchCode(() => remote.getInstanceState('inst-nonexistent'))).toBe('NODE_NOT_FOUND')
603
+ })
604
+
605
+ it('getArtifacts throws NODE_NOT_FOUND for a nonexistent instance', async () => {
606
+ const { remote } = await harness()
607
+
608
+ expect(catchCode(() => remote.getArtifacts('inst-nonexistent'))).toBe('NODE_NOT_FOUND')
609
+ })
610
+
611
+ it('abortInstance throws NODE_NOT_FOUND for a nonexistent instance', async () => {
612
+ const { remote } = await harness()
613
+
614
+ expect(await catchCodeAsync(() => remote.abortInstance('inst-nonexistent'))).toBe('NODE_NOT_FOUND')
615
+ })
616
+ })
617
+
618
+ describe('SandoreRemote lifecycle RPCs', () => {
619
+ it('replyQuestion forwards instanceId/requestId/answers to the engine', async () => {
620
+ const { remote, engine } = await harness()
621
+ const answers: AskUserQuestionAnswer = { answers: [{ id: 'q1', selected: ['A'] }] }
622
+
623
+ await remote.replyQuestion({ instanceId: 'inst-1', requestId: 'req-1', answers })
624
+
625
+ expect(engine.replyQuestion).toHaveBeenCalledWith('inst-1', 'req-1', answers)
626
+ })
627
+
628
+ it('rejectQuestion forwards instanceId/requestId to the engine (缺陷 26)', async () => {
629
+ const { remote, engine } = await harness()
630
+
631
+ await remote.rejectQuestion({ instanceId: 'inst-1', requestId: 'req-1' })
632
+
633
+ expect(engine.rejectQuestion).toHaveBeenCalledWith('inst-1', 'req-1')
634
+ })
635
+
636
+ it('rejects a missing requestId on rejectQuestion (zod, 缺陷 26)', async () => {
637
+ const { remote } = await harness()
638
+
639
+ await expect(
640
+ remote.rejectQuestion({ instanceId: 'inst-1' } as { instanceId: string; requestId: string }),
641
+ ).rejects.toBeInstanceOf(ZodError)
642
+ })
643
+
644
+ it('rejects a missing instanceId on rejectQuestion (zod, 缺陷 26)', async () => {
645
+ const { remote } = await harness()
646
+
647
+ await expect(
648
+ remote.rejectQuestion({ requestId: 'req-1' } as { instanceId: string; requestId: string }),
649
+ ).rejects.toBeInstanceOf(ZodError)
650
+ })
651
+
652
+ it('replyPermission forwards instanceId/requestId/reply to the engine', async () => {
653
+ const { remote, engine } = await harness()
654
+
655
+ await remote.replyPermission({ instanceId: 'inst-1', requestId: 'req-1', reply: 'always' })
656
+
657
+ expect(engine.replyPermission).toHaveBeenCalledWith('inst-1', 'req-1', 'always')
658
+ })
659
+
660
+ it('rejects an invalid reply enum value (zod)', async () => {
661
+ const { remote } = await harness()
662
+
663
+ await expect(
664
+ remote.replyPermission({ instanceId: 'inst-1', requestId: 'req-1', reply: 'bad' as 'once' }),
665
+ ).rejects.toBeInstanceOf(ZodError)
666
+ })
667
+
668
+ it('pauseStep forwards instanceId/stepId to the engine', async () => {
669
+ const { remote, engine } = await harness()
670
+
671
+ await remote.pauseStep({ instanceId: 'inst-1', stepId: 'step-1' })
672
+
673
+ expect(engine.pauseStep).toHaveBeenCalledWith('inst-1', 'step-1')
674
+ })
675
+
676
+ it('resumeStep forwards instanceId/stepId/resumePrompt to the engine', async () => {
677
+ const { remote, engine } = await harness()
678
+
679
+ await remote.resumeStep({ instanceId: 'inst-1', stepId: 'step-1', resumePrompt: 'continue please' })
680
+
681
+ expect(engine.resumeStep).toHaveBeenCalledWith('inst-1', 'step-1', 'continue please')
682
+ })
683
+
684
+ it('resumeStep forwards without resumePrompt when absent', async () => {
685
+ const { remote, engine } = await harness()
686
+
687
+ await remote.resumeStep({ instanceId: 'inst-1', stepId: 'step-1' })
688
+
689
+ expect(engine.resumeStep).toHaveBeenCalledWith('inst-1', 'step-1', undefined)
690
+ })
691
+
692
+ it('pauseAll forwards instanceId to engine.pauseAllSteps', async () => {
693
+ const { remote, engine } = await harness()
694
+
695
+ await remote.pauseAll({ instanceId: 'inst-1' })
696
+
697
+ expect(engine.pauseAllSteps).toHaveBeenCalledWith('inst-1')
698
+ })
699
+
700
+ it('resumeAll forwards instanceId/resumePrompt to engine.resumeAllSteps', async () => {
701
+ const { remote, engine } = await harness()
702
+
703
+ await remote.resumeAll({ instanceId: 'inst-1', resumePrompt: 'go' })
704
+
705
+ expect(engine.resumeAllSteps).toHaveBeenCalledWith('inst-1', 'go')
706
+ })
707
+
708
+ it('retryStep forwards instanceId/stepId/mode/message/revertBoundary to the engine', async () => {
709
+ const { remote, engine } = await harness()
710
+
711
+ await remote.retryStep({ instanceId: 'inst-1', stepId: 'step-1', mode: 'revert', message: 'retry now', revertBoundary: 5 })
712
+
713
+ expect(engine.retryStep).toHaveBeenCalledWith('inst-1', 'step-1', 'revert', 'retry now', 5)
714
+ })
715
+
716
+ it('retryStep forwards revertBoundary as undefined when omitted', async () => {
717
+ const { remote, engine } = await harness()
718
+
719
+ await remote.retryStep({ instanceId: 'inst-1', stepId: 'step-1', mode: 'continue', message: 'go' })
720
+
721
+ expect(engine.retryStep).toHaveBeenCalledWith('inst-1', 'step-1', 'continue', 'go', undefined)
722
+ })
723
+
724
+ it('rejects a non-numeric revertBoundary (zod)', async () => {
725
+ const { remote } = await harness()
726
+
727
+ await expect(
728
+ remote.retryStep({ instanceId: 'inst-1', stepId: 'step-1', mode: 'revert', message: 'x', revertBoundary: 'not-a-number' as unknown as number }),
729
+ ).rejects.toBeInstanceOf(ZodError)
730
+ })
731
+
732
+ it('rejects an invalid retry mode enum value (zod)', async () => {
733
+ const { remote } = await harness()
734
+
735
+ await expect(
736
+ remote.retryStep({ instanceId: 'inst-1', stepId: 'step-1', mode: 'bad' as 'fork', message: 'x' }),
737
+ ).rejects.toBeInstanceOf(ZodError)
738
+ })
739
+
740
+ it('listRevertBoundaries forwards instanceId/stepId to the engine and returns the boundary list', async () => {
741
+ const { remote, engine } = await harness()
742
+ const options: RevertBoundaryOption[] = [
743
+ { seq: 2, turn: 0, preview: 'first answer' },
744
+ { seq: 5, turn: 1, preview: 'second answer' },
745
+ ]
746
+ engine.listRevertBoundaries.mockReturnValueOnce(options)
747
+
748
+ const result = remote.listRevertBoundaries({ instanceId: 'inst-1', stepId: 'step-agent' })
749
+
750
+ expect(engine.listRevertBoundaries).toHaveBeenCalledWith('inst-1', 'step-agent')
751
+ expect(result).toEqual(options)
752
+ })
753
+
754
+ it('passes through SandoreError from the engine without swallowing it', async () => {
755
+ const { remote, engine } = await harness()
756
+ engine.pauseStep.mockRejectedValueOnce(new SandoreError('step busy', 'INVALID_OPERATION'))
757
+
758
+ await expect(remote.pauseStep({ instanceId: 'inst-1', stepId: 'step-1' })).rejects.toMatchObject({
759
+ code: 'INVALID_OPERATION',
760
+ })
761
+ })
762
+ })
763
+
764
+ describe('SandoreRemote updateInstancePermissionMode RPC', () => {
765
+ it('forwards instanceId/mode to the engine after passing the terminal guard (缺陷 29)', async () => {
766
+ const { remote, persistence, engine } = await harness()
767
+ await persistence.saveWorkflowState(makeState({ taskId: 'inst-1', status: 'running' }))
768
+
769
+ await remote.updateInstancePermissionMode({ instanceId: 'inst-1', mode: 'workspace-write' })
770
+
771
+ expect(engine.setPermissionMode).toHaveBeenCalledWith('inst-1', 'workspace-write')
772
+ })
773
+
774
+ it('rejects an invalid mode enum value (zod, 缺陷 29)', async () => {
775
+ const { remote } = await harness()
776
+
777
+ await expect(
778
+ remote.updateInstancePermissionMode({ instanceId: 'inst-1', mode: 'bad' as 'read-only' }),
779
+ ).rejects.toBeInstanceOf(ZodError)
780
+ })
781
+
782
+ it('rejects a missing instanceId (zod, 缺陷 29)', async () => {
783
+ const { remote } = await harness()
784
+
785
+ await expect(
786
+ remote.updateInstancePermissionMode({ mode: 'read-only' } as { instanceId: string; mode: 'read-only' }),
787
+ ).rejects.toBeInstanceOf(ZodError)
788
+ })
789
+
790
+ it('rejects a terminal instance with INVALID_OPERATION (terminal guard, 缺陷 29)', async () => {
791
+ const { remote, persistence } = await harness()
792
+ await persistence.saveWorkflowState(makeState({ taskId: 'inst-done', status: 'completed' }))
793
+
794
+ await expect(remote.updateInstancePermissionMode({ instanceId: 'inst-done', mode: 'read-only' })).rejects.toMatchObject({
795
+ code: 'INVALID_OPERATION',
796
+ })
797
+ })
798
+
799
+ it('throws NODE_NOT_FOUND when the instance snapshot is missing (缺陷 29)', async () => {
800
+ const { remote } = await harness()
801
+
802
+ expect(
803
+ await catchCodeAsync(() => remote.updateInstancePermissionMode({ instanceId: 'inst-nonexistent', mode: 'read-only' })),
804
+ ).toBe('NODE_NOT_FOUND')
805
+ })
806
+
807
+ it('passes through SandoreError from the engine without swallowing it (缺陷 29)', async () => {
808
+ const { remote, persistence, engine } = await harness()
809
+ await persistence.saveWorkflowState(makeState({ taskId: 'inst-1', status: 'running' }))
810
+ engine.setPermissionMode.mockRejectedValueOnce(new SandoreError('definition gone', 'NODE_NOT_FOUND'))
811
+
812
+ await expect(remote.updateInstancePermissionMode({ instanceId: 'inst-1', mode: 'read-only' })).rejects.toMatchObject({
813
+ code: 'NODE_NOT_FOUND',
814
+ })
815
+ })
816
+ })
817
+
818
+ describe('SandoreRemote sendStepMessage RPC', () => {
819
+ it('forwards instanceId/stepId/message to the engine after passing the terminal guard (缺陷 31)', async () => {
820
+ const { remote, persistence, engine } = await harness()
821
+ await persistence.saveWorkflowState(makeState({ taskId: 'inst-1', status: 'running' }))
822
+
823
+ await remote.sendStepMessage({ instanceId: 'inst-1', stepId: 'step-agent', message: '请确认参数' })
824
+
825
+ expect(engine.sendStepMessage).toHaveBeenCalledWith('inst-1', 'step-agent', '请确认参数')
826
+ })
827
+
828
+ it('rejects an empty message (zod min(1), 缺陷 31)', async () => {
829
+ const { remote } = await harness()
830
+
831
+ await expect(
832
+ remote.sendStepMessage({ instanceId: 'inst-1', stepId: 'step-agent', message: '' }),
833
+ ).rejects.toBeInstanceOf(ZodError)
834
+ })
835
+
836
+ it('rejects a missing stepId (zod, 缺陷 31)', async () => {
837
+ const { remote } = await harness()
838
+
839
+ await expect(
840
+ remote.sendStepMessage({ instanceId: 'inst-1', message: 'hi' } as { instanceId: string; stepId: string; message: string }),
841
+ ).rejects.toBeInstanceOf(ZodError)
842
+ })
843
+
844
+ it('rejects a missing instanceId (zod, 缺陷 31)', async () => {
845
+ const { remote } = await harness()
846
+
847
+ await expect(
848
+ remote.sendStepMessage({ stepId: 'step-agent', message: 'hi' } as { instanceId: string; stepId: string; message: string }),
849
+ ).rejects.toBeInstanceOf(ZodError)
850
+ })
851
+
852
+ it('rejects a terminal instance with INVALID_OPERATION (terminal guard, 缺陷 31)', async () => {
853
+ const { remote, persistence } = await harness()
854
+ await persistence.saveWorkflowState(makeState({ taskId: 'inst-done', status: 'completed' }))
855
+
856
+ await expect(remote.sendStepMessage({ instanceId: 'inst-done', stepId: 'step-agent', message: 'hi' })).rejects.toMatchObject({
857
+ code: 'INVALID_OPERATION',
858
+ })
859
+ })
860
+
861
+ it('throws NODE_NOT_FOUND when the instance snapshot is missing (缺陷 31)', async () => {
862
+ const { remote } = await harness()
863
+
864
+ expect(
865
+ await catchCodeAsync(() => remote.sendStepMessage({ instanceId: 'inst-nonexistent', stepId: 'step-agent', message: 'hi' })),
866
+ ).toBe('NODE_NOT_FOUND')
867
+ })
868
+
869
+ it('passes through SandoreError from the engine without swallowing it (缺陷 31)', async () => {
870
+ const { remote, persistence, engine } = await harness()
871
+ await persistence.saveWorkflowState(makeState({ taskId: 'inst-1', status: 'running' }))
872
+ engine.sendStepMessage.mockRejectedValueOnce(new SandoreError('step is synthetic', 'INVALID_OPERATION'))
873
+
874
+ await expect(remote.sendStepMessage({ instanceId: 'inst-1', stepId: 'step-shell', message: 'hi' })).rejects.toMatchObject({
875
+ code: 'INVALID_OPERATION',
876
+ })
877
+ })
878
+ })
879
+
880
+ describe('SandoreRemote recoverInstance RPC', () => {
881
+ it('forwards instanceId to the engine and returns the recovered DagRun', async () => {
882
+ const { remote, persistence, engine } = await harness()
883
+ // 内存未命中但快照非终态:recoverInstance 应转发引擎并返回非终态 DagRun
884
+ // (恢复后实例可被 pause/resume/retry/reply/abort 操作,UI 可用)。
885
+ await persistence.saveWorkflowState(makeState({ taskId: 'inst-orphan', status: 'running' }))
886
+
887
+ const run = await remote.recoverInstance({ instanceId: 'inst-orphan' })
888
+
889
+ expect(engine.recoverInstance).toHaveBeenCalledWith('inst-orphan')
890
+ expect(run).toEqual({ instanceId: 'inst-orphan', status: 'running' })
891
+ })
892
+
893
+ it('rejects a missing instanceId (zod validation)', async () => {
894
+ const { remote } = await harness()
895
+
896
+ await expect(
897
+ remote.recoverInstance({ instanceId: 123 as unknown as string }),
898
+ ).rejects.toBeInstanceOf(ZodError)
899
+ })
900
+
901
+ it('rejects a terminal instance with INVALID_OPERATION (terminal guard)', async () => {
902
+ const { remote, persistence } = await harness()
903
+ await persistence.saveWorkflowState(makeState({ taskId: 'inst-done', status: 'completed' }))
904
+
905
+ await expect(remote.recoverInstance({ instanceId: 'inst-done' })).rejects.toMatchObject({
906
+ code: 'INVALID_OPERATION',
907
+ })
908
+ })
909
+
910
+ it('throws NODE_NOT_FOUND when the instance snapshot is missing', async () => {
911
+ const { remote } = await harness()
912
+
913
+ expect(await catchCodeAsync(() => remote.recoverInstance({ instanceId: 'inst-nonexistent' }))).toBe('NODE_NOT_FOUND')
914
+ })
915
+
916
+ it('passes through SandoreError from the engine without swallowing it', async () => {
917
+ const { remote, persistence, engine } = await harness()
918
+ await persistence.saveWorkflowState(makeState({ taskId: 'inst-1', status: 'running' }))
919
+ engine.recoverInstance.mockRejectedValueOnce(new SandoreError('definition gone', 'NODE_NOT_FOUND'))
920
+
921
+ await expect(remote.recoverInstance({ instanceId: 'inst-1' })).rejects.toMatchObject({
922
+ code: 'NODE_NOT_FOUND',
923
+ })
924
+ })
925
+ })
926
+
927
+ describe('SandoreRemote recoverOrphanedInstances RPC', () => {
928
+ it('forwards to the engine with no input and returns the { recovered, errors } summary', async () => {
929
+ const { remote, persistence, engine } = await harness()
930
+ await persistence.saveWorkflowState(makeState({ taskId: 'inst-a', status: 'running' }))
931
+ await persistence.saveWorkflowState(makeState({ taskId: 'inst-b', status: 'paused' }))
932
+ engine.recoverOrphanedInstances.mockResolvedValueOnce({
933
+ recovered: ['inst-a', 'inst-b'],
934
+ errors: [],
935
+ })
936
+
937
+ const result = await remote.recoverOrphanedInstances()
938
+
939
+ expect(engine.recoverOrphanedInstances).toHaveBeenCalledWith()
940
+ expect(result).toEqual({ recovered: ['inst-a', 'inst-b'], errors: [] })
941
+ })
942
+
943
+ it('returns the errors array when some instances fail to recover (single-failure does not block)', async () => {
944
+ const { remote, persistence, engine } = await harness()
945
+ await persistence.saveWorkflowState(makeState({ taskId: 'inst-ok', status: 'running' }))
946
+ await persistence.saveWorkflowState(makeState({ taskId: 'inst-bad', status: 'running' }))
947
+ engine.recoverOrphanedInstances.mockResolvedValueOnce({
948
+ recovered: ['inst-ok'],
949
+ errors: [{ instanceId: 'inst-bad', error: 'definition gone' }],
950
+ })
951
+
952
+ const result = await remote.recoverOrphanedInstances()
953
+
954
+ expect(result.recovered).toEqual(['inst-ok'])
955
+ expect(result.errors).toEqual([{ instanceId: 'inst-bad', error: 'definition gone' }])
956
+ })
957
+ })
958
+
959
+ describe('SandoreRemote artifacts and llm forwarding', () => {
960
+ it('returns artifact entries and filters by stageId', async () => {
961
+ const { remote, persistence } = await harness()
962
+ const now = new Date().toISOString()
963
+ await persistence.saveWorkflowState(makeState({
964
+ taskId: 'inst-art',
965
+ status: 'completed',
966
+ currentStageId: 'stg-2',
967
+ executedSteps: [
968
+ {
969
+ type: 'default',
970
+ id: 'step-shell',
971
+ stepId: 'step-shell',
972
+ stageId: 'stg-1',
973
+ name: 'lint',
974
+ status: 'completed',
975
+ visitCount: 1,
976
+ },
977
+ {
978
+ type: 'default',
979
+ id: 'step-prompt',
980
+ stepId: 'step-prompt',
981
+ stageId: 'stg-2',
982
+ name: 'summarize',
983
+ status: 'completed',
984
+ visitCount: 1,
985
+ },
986
+ ],
987
+ stageContracts: {
988
+ 'stg-1': {
989
+ stageId: 'stg-1',
990
+ stageName: '分析',
991
+ status: 'completed',
992
+ artifacts: [{ stepId: 'step-shell', outputToId: 'opt-1', files: ['/abs/report.txt'] }],
993
+ passThroughVars: {},
994
+ contractArtifacts: {},
995
+ completedAt: now,
996
+ },
997
+ 'stg-2': {
998
+ stageId: 'stg-2',
999
+ stageName: '推理',
1000
+ status: 'completed',
1001
+ artifacts: [{ stepId: 'step-prompt', outputToId: 'opt-2', files: [], returnPrompt: 'The summary.' }],
1002
+ passThroughVars: {},
1003
+ contractArtifacts: {},
1004
+ completedAt: now,
1005
+ },
1006
+ },
1007
+ }))
1008
+
1009
+ const artifacts = remote.getArtifacts('inst-art')
1010
+ expect(artifacts).toHaveLength(2)
1011
+ expect(artifacts).toContainEqual({
1012
+ stepId: 'step-shell',
1013
+ stageId: 'stg-1',
1014
+ outputToId: 'opt-1',
1015
+ files: ['/abs/report.txt'],
1016
+ })
1017
+ expect(artifacts).toContainEqual({
1018
+ stepId: 'step-prompt',
1019
+ stageId: 'stg-2',
1020
+ outputToId: 'opt-2',
1021
+ files: [],
1022
+ returnPrompt: 'The summary.',
1023
+ })
1024
+
1025
+ expect(remote.getArtifacts('inst-art', 'stg-1')).toHaveLength(1)
1026
+ })
1027
+
1028
+ it('forwards listProviders and listModels', async () => {
1029
+ const { remote } = await harness()
1030
+
1031
+ expect(remote.listProviders()).toEqual([{ id: 'provider-a', name: 'Provider A' }])
1032
+ await expect(remote.listModels('provider-a')).resolves.toEqual([
1033
+ { provider: 'provider-a', id: 'model-a', name: 'Model A' },
1034
+ ])
1035
+ })
1036
+
1037
+ it('keeps ping returning pong/service/time', async () => {
1038
+ const { remote } = await harness()
1039
+
1040
+ const pong = remote.ping()
1041
+ expect(pong.pong).toBe(true)
1042
+ expect(pong.service).toBe('sandore')
1043
+ expect(pong.time).toMatch(/^\d{4}-\d{2}-\d{2}T/)
1044
+ })
1045
+ })