@mokuyoaxis/dsh-iris 0.2.7 → 0.3.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/README.en.md +103 -312
  3. package/README.md +100 -310
  4. package/bin/dsh-iris.js +333 -65
  5. package/docs/AGENT_CORE_QUICKSTART.md +64 -0
  6. package/docs/AGENT_QUICKSTART.md +2 -0
  7. package/docs/ARCHITECTURE.md +13 -3
  8. package/docs/AUDIO_FLOWS.md +71 -0
  9. package/docs/CONTACT_SHEET.md +62 -0
  10. package/docs/CORE_CONNECTIONS.md +99 -0
  11. package/docs/CORE_QUICKSTART.md +108 -0
  12. package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
  13. package/docs/CORE_SERVICE.md +97 -0
  14. package/docs/CORE_SKILLS.md +35 -3
  15. package/docs/DSH_COMPATIBILITY.md +10 -0
  16. package/docs/DSH_RC2_ADAPTATION.md +1 -1
  17. package/docs/DSH_RUNTIME.md +6 -2
  18. package/docs/HEADLESS_CLI.md +2 -2
  19. package/docs/IMAGE_FLOW.md +4 -0
  20. package/docs/IMAGE_GENERATION.md +127 -0
  21. package/docs/LOCAL_API.md +4 -2
  22. package/docs/LOCAL_API_CONTRACT.md +39 -14
  23. package/docs/LOCAL_CREATION.md +7 -1
  24. package/docs/LOCAL_OPERATIONS.md +46 -0
  25. package/docs/LOCAL_SELECTION.md +57 -0
  26. package/docs/LOCAL_WEB.md +201 -0
  27. package/docs/MCP.md +191 -0
  28. package/docs/MUSIC.md +2 -0
  29. package/docs/PANEL_EXTENSIONS.md +64 -0
  30. package/docs/PLUGINS.md +189 -0
  31. package/docs/PLUGIN_STARTUP.md +100 -0
  32. package/docs/PROVIDER_ADAPTER_CONTRACT.md +1 -1
  33. package/docs/PROVIDER_EXTENSIONS.md +78 -0
  34. package/docs/PROVIDER_HEALTH.md +3 -1
  35. package/docs/PROVIDER_SETTINGS.md +85 -0
  36. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
  37. package/docs/ROADMAP.md +17 -7
  38. package/docs/VISION_CLI.md +1 -1
  39. package/docs/WEB_PROTOTYPE.md +21 -6
  40. package/docs/WEB_RECOVERY.md +46 -0
  41. package/docs/WORKBENCH_LOGIN.md +63 -0
  42. package/docs/assets/screenshots/core-connections-preview.webp +0 -0
  43. package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
  44. package/docs/assets/screenshots/core-overview-preview.webp +0 -0
  45. package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
  46. package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
  47. package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
  48. package/docs/assets/screenshots/core-studio-preview.webp +0 -0
  49. package/docs/releases/0.3.0-alpha.1.md +51 -0
  50. package/docs/screenshots.md +41 -1
  51. package/lib/actions.js +53 -2
  52. package/lib/adapters.js +20 -11
  53. package/lib/application-runtime.js +34 -7
  54. package/lib/audio-flow-input.js +37 -0
  55. package/lib/client.js +346 -53
  56. package/lib/comfyui-adapter.js +94 -0
  57. package/lib/comfyui-client.js +51 -0
  58. package/lib/comfyui-inspection.js +73 -0
  59. package/lib/comfyui-workflow.js +94 -0
  60. package/lib/config.js +36 -1
  61. package/lib/configured-image-runtime.js +50 -0
  62. package/lib/connection-skill.js +89 -0
  63. package/lib/connection-template.js +23 -0
  64. package/lib/core-artifact-store.js +16 -2
  65. package/lib/core-bridge-selection.js +31 -0
  66. package/lib/core-bridge-tools.js +83 -0
  67. package/lib/core-capability-catalog.js +182 -0
  68. package/lib/core-connection-diagnostics.js +49 -0
  69. package/lib/core-connection-store.js +277 -0
  70. package/lib/core-connections.js +225 -0
  71. package/lib/core-maintenance.js +9 -3
  72. package/lib/core-runtime.js +2 -1
  73. package/lib/core-service-client.js +170 -0
  74. package/lib/core-tasks.js +29 -12
  75. package/lib/core-writer-lease.js +16 -0
  76. package/lib/creation-request-store.js +38 -64
  77. package/lib/dsh-host-adapter.js +4 -1
  78. package/lib/dsh-service-bridge.js +66 -0
  79. package/lib/execution-ownership.js +43 -0
  80. package/lib/flow-store.js +105 -10
  81. package/lib/generation-input.js +10 -1
  82. package/lib/headless-vision.js +7 -7
  83. package/lib/image-edit-input.js +1 -1
  84. package/lib/image-flow.js +73 -29
  85. package/lib/index.js +37 -17
  86. package/lib/local-api-projection.js +10 -5
  87. package/lib/local-api-token-directory.js +31 -0
  88. package/lib/local-api.js +769 -67
  89. package/lib/local-creation.js +89 -24
  90. package/lib/local-operations.js +36 -0
  91. package/lib/local-request-root.js +86 -0
  92. package/lib/local-web-assets.js +37 -0
  93. package/lib/mcp-bridge.js +58 -0
  94. package/lib/mcp-check.js +26 -0
  95. package/lib/mcp-config.js +42 -0
  96. package/lib/mcp-delivery.js +46 -0
  97. package/lib/mcp-http.js +101 -0
  98. package/lib/mcp-server.js +47 -0
  99. package/lib/model-directory-service.js +16 -6
  100. package/lib/model-directory.js +3 -3
  101. package/lib/models.js +2 -2
  102. package/lib/music-transport.js +2 -1
  103. package/lib/operation-runner.js +236 -0
  104. package/lib/operation-store.js +216 -0
  105. package/lib/panel-extension.js +36 -0
  106. package/lib/plugin-contract.js +194 -0
  107. package/lib/plugin-execution.js +51 -0
  108. package/lib/plugin-launch.js +86 -0
  109. package/lib/plugin-operation-store.js +101 -0
  110. package/lib/plugin-operations.js +28 -0
  111. package/lib/plugin-registry.js +109 -0
  112. package/lib/plugin-selection.js +15 -0
  113. package/lib/plugin-source-image.js +5 -0
  114. package/lib/plugin-web-assets.js +54 -0
  115. package/lib/plugins.d.ts +288 -0
  116. package/lib/plugins.js +58 -0
  117. package/lib/provider-adapter.js +5 -1
  118. package/lib/provider-adapters.js +56 -18
  119. package/lib/provider-catalog.js +40 -29
  120. package/lib/provider-config-service.js +105 -23
  121. package/lib/provider-diagnostics.js +56 -0
  122. package/lib/provider-extensions.js +29 -0
  123. package/lib/provider-health.js +8 -1
  124. package/lib/provider-monitor.js +79 -0
  125. package/lib/provider-protocol.js +70 -20
  126. package/lib/provider-registry.js +118 -0
  127. package/lib/provider-response.js +28 -0
  128. package/lib/provider-settings.js +134 -0
  129. package/lib/provider-task-runner.js +22 -9
  130. package/lib/skill-installation.js +23 -11
  131. package/lib/vision-model-routing.js +3 -0
  132. package/lib/web/app.js +642 -0
  133. package/lib/web/appearance.js +111 -0
  134. package/lib/web/comfyui.js +146 -0
  135. package/lib/web/connections.js +377 -0
  136. package/lib/web/creation.js +355 -0
  137. package/lib/web/flow-view.js +33 -0
  138. package/lib/web/gallery.js +132 -0
  139. package/lib/web/index.html +291 -0
  140. package/lib/web/media-client.js +204 -0
  141. package/lib/web/plugin-panel.js +221 -0
  142. package/lib/web/plugin-surface.css +17 -0
  143. package/lib/web/providers.js +492 -0
  144. package/lib/web/recovery.js +105 -0
  145. package/lib/web/startup.js +18 -0
  146. package/lib/web/styles.css +518 -0
  147. package/lib/web/transport.js +96 -0
  148. package/lib/web/workbench-login.js +165 -0
  149. package/lib/workbench-auth-cli.js +47 -0
  150. package/lib/workbench-auth.js +200 -0
  151. package/package.json +20 -5
  152. package/plugins/contact-sheet/index.d.mts +4 -0
  153. package/plugins/contact-sheet/index.mjs +33 -0
  154. package/plugins/contact-sheet/web/board.css +14 -0
  155. package/plugins/contact-sheet/web/board.mjs +93 -0
  156. package/skills/iris-core/SKILL.md +8 -4
  157. package/skills/iris-core/references/cli.md +2 -2
  158. package/skills/iris-core/references/comfyui.md +69 -0
  159. package/skills/iris-core/references/tasks.md +2 -0
  160. package/user_guide.md +5 -1
@@ -0,0 +1,47 @@
1
+ import { bridgeToolsForScopes, callCoreBridgeTool } from './core-bridge-tools.js';
2
+ import { projectedMcpClient } from './core-connections.js';
3
+ import { coreConnectionDiagnostic } from './core-connection-diagnostics.js';
4
+
5
+ /** Client labels are optional observations. Their failure must not delay a Core
6
+ * command or discard its receipt; command authorization remains in Core. */
7
+ export async function reportMcpClientInfo(client, clientInfo, transport) {
8
+ try { await client.reportClientInfo(clientInfo, transport); }
9
+ catch (error) {
10
+ console.error('[iris] MCP client declaration not recorded: ' + (/^IRIS_[A-Z_]+$/.test(error?.code) ? error.code : 'IRIS_MCP_METADATA_FAILED'));
11
+ }
12
+ }
13
+
14
+ /** One official-SDK tool factory for both standard transports and protocol eras. */
15
+ export async function createMcpServerFactory({ client, version, transport = 'stdio' }) {
16
+ const [{ McpServer }, { z }] = await Promise.all([import('@modelcontextprotocol/server'), import('zod')]);
17
+ const snapshot = client.snapshot();
18
+ const tools = bridgeToolsForScopes(snapshot.scopes, snapshot.capabilities, snapshot.tools, snapshot.pluginOperations);
19
+ const factory = () => {
20
+ const server = new McpServer({ name: 'iris-core', version }, { capabilities: { tools: {} },
21
+ instructions: 'Iris is a proxy to an explicitly started Core. Inspect the granted catalog first. Execute only a user-selected operation; preserve its request UUID and exact dependency. An Operation receipt is not completion. After interruption or lost responses, inspect/lookup the original Operation; never automatically replay a write or start another Core. Display actual saved image content only after reading it.' });
22
+ if (transport === 'stdio') server.server.oninitialized = () => {
23
+ void reportMcpClientInfo(client, projectedMcpClient(server.server.getClientVersion()), transport);
24
+ };
25
+ for (const tool of tools) server.registerTool(tool.name, {
26
+ description: tool.description, inputSchema: z.fromJSONSchema(tool.parameters),
27
+ annotations: { readOnlyHint: tool.scope === 'read', destructiveHint: tool.scope === 'stop',
28
+ idempotentHint: true, openWorldHint: tool.scope !== 'read' }
29
+ }, async (args, context) => {
30
+ try {
31
+ void reportMcpClientInfo(client, projectedMcpClient(context.mcpReq.envelope?.['io.modelcontextprotocol/clientInfo'] || server.server.getClientVersion()), transport);
32
+ const result = await callCoreBridgeTool(client, tool.name, args, { signal: context.mcpReq.signal });
33
+ if (result.image) return { content: [{ type: 'image', data: result.image.bytes.toString('base64'), mimeType: result.image.mediaType },
34
+ { type: 'text', text: JSON.stringify({ artifactId: result.image.artifactId, bytes: result.image.bytes.length }) }] };
35
+ if (result.audio) return { content: [{ type: 'audio', data: result.audio.bytes.toString('base64'), mimeType: result.audio.mediaType },
36
+ { type: 'text', text: JSON.stringify({ artifactId: result.audio.artifactId, bytes: result.audio.bytes.length }) }] };
37
+ return { content: [{ type: 'text', text: JSON.stringify(result.value) }], structuredContent: result.value };
38
+ } catch (error) {
39
+ const diagnostic = coreConnectionDiagnostic(error, { stage: 'tool', references: args });
40
+ return { isError: true, content: [{ type: 'text', text: diagnostic.code + ' [' + diagnostic.stage + ']: ' + diagnostic.message + ' ' + diagnostic.action
41
+ + (diagnostic.requestId ? ' Request ID: ' + diagnostic.requestId : '') }], structuredContent: { contractVersion: 0, error: diagnostic } };
42
+ }
43
+ });
44
+ return server;
45
+ };
46
+ return { factory, tools: tools.map(tool => tool.name) };
47
+ }
@@ -1,14 +1,17 @@
1
1
  import { listModels } from './adapters.js';
2
2
  import { capabilitiesOfDiscoveredModel } from './models.js';
3
- import { providerApiKey, providerMediaBaseUrl, selectMediaProtocol } from './provider-protocol.js';
3
+ import { providerApiKey, providerMediaBaseUrl, selectMediaProtocol, providerProtocolDescriptor } from './provider-protocol.js';
4
+ import { createConfiguredProviderAdapter } from './provider-adapters.js';
5
+ import { invokeProviderOperation } from './provider-adapter.js';
4
6
  import { redactProviderMessage } from './provider-contract.js';
5
7
  import { MODEL_DIRECTORY_TTL_MS, MODEL_DIRECTORY_RETRY_MS, currentModelDirectory, modelDirectoryBinding } from './model-directory.js';
6
8
 
7
9
  const pending = new Map();
10
+ const registryPending = new WeakMap();
8
11
 
9
12
  /** 只有完整发现成功才替换快照;相同连接合并网络读取,各调用方保存自己的配置。 */
10
- export async function refreshModelDirectory(provider, { force = false, signal, save, discover = listModels, now = Date.now() } = {}) {
11
- if (!['dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images', 'gemini-web-music'].includes(selectMediaProtocol(provider).mediaProtocol)) {
13
+ export async function refreshModelDirectory(provider, { force = false, signal, save, discover = listModels, now = Date.now(), registry } = {}) {
14
+ if (!providerProtocolDescriptor(selectMediaProtocol(provider).mediaProtocol, registry)) {
12
15
  return { refreshed: false, error: '当前协议未提供模型目录发现' };
13
16
  }
14
17
  const previous = currentModelDirectory(provider);
@@ -20,14 +23,21 @@ export async function refreshModelDirectory(provider, { force = false, signal, s
20
23
  }
21
24
  }
22
25
  signal?.throwIfAborted();
26
+ let calls = pending;
27
+ if (registry) {
28
+ if (!registryPending.has(registry)) registryPending.set(registry, new Map());
29
+ calls = registryPending.get(registry);
30
+ discover = async ({ signal, timeoutMs }) => (await invokeProviderOperation(
31
+ createConfiguredProviderAdapter(provider, { registry }), 'discover', { signal, timeoutMs })).models;
32
+ }
23
33
  const binding = modelDirectoryBinding(provider);
24
- let job = pending.get(binding);
34
+ let job = calls.get(binding);
25
35
  if (!job) {
26
36
  const budget = AbortSignal.timeout(15000);
27
37
  job = discover({ key: providerApiKey(provider), baseUrl: providerMediaBaseUrl(provider),
28
38
  signal: signal ? AbortSignal.any([signal, budget]) : budget, timeoutMs: 15000 });
29
- pending.set(binding, job);
30
- job.finally(() => { if (pending.get(binding) === job) pending.delete(binding); }).catch(() => {});
39
+ calls.set(binding, job);
40
+ job.finally(() => { if (calls.get(binding) === job) calls.delete(binding); }).catch(() => {});
31
41
  }
32
42
  let discovered;
33
43
  try {
@@ -1,6 +1,6 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { isKnownCapability } from './capability.js';
3
- import { providerApiKey, providerMediaBaseUrl, selectMediaProtocol, providerForImageModel, providerForMusicModel } from './provider-protocol.js';
3
+ import { providerApiKey, providerMediaBaseUrl, selectMediaProtocol, providerForModelBinding } from './provider-protocol.js';
4
4
 
5
5
  export const MODEL_DIRECTORY_TTL_MS = 10 * 60 * 1000;
6
6
  export const MODEL_DIRECTORY_RETRY_MS = 30 * 1000;
@@ -35,9 +35,9 @@ export function currentModelDirectory(provider, { allowUnresolvedConnections = f
35
35
  }
36
36
 
37
37
  export function modelAvailabilityBinding(provider, modelId, capability) {
38
- if (capability === 'image-gen') return modelDirectoryBinding(providerForImageModel(provider, modelId));
38
+ if (capability === 'image-gen') return modelDirectoryBinding(providerForModelBinding(provider, modelId, capability));
39
39
  if (capability === 'music-gen' && provider.models?.some(model => model?.id === modelId && model.musicProtocol)) {
40
- return modelDirectoryBinding(providerForMusicModel(provider, modelId));
40
+ return modelDirectoryBinding(providerForModelBinding(provider, modelId, capability));
41
41
  }
42
42
  return modelDirectoryBinding(provider);
43
43
  }
package/lib/models.js CHANGED
@@ -207,14 +207,14 @@ export function savedAssignmentRefs(pool, capability, values) {
207
207
  }
208
208
 
209
209
  /** Automatic pools reject known protocol gaps before constructing any adapter. */
210
- export function functionModelPool(providers, capability, assignment, { includeHostVision = false, allowUnresolvedConnections = false } = {}) {
210
+ export function functionModelPool(providers, capability, assignment, { includeHostVision = false, allowUnresolvedConnections = false, registry } = {}) {
211
211
  const policy = functionPolicy(assignment, capability);
212
212
  return modelPool(providers, { includeHostVision }).filter(model => {
213
213
  if (!model.capabilities.includes(capability)) return false;
214
214
  if (policy.mode !== 'auto' || model.ref === HOST_VISION_REF) return true;
215
215
  const provider = providers.find(provider => provider.id === model.providerId);
216
216
  return supportsModelCapability(provider, model.id, capability, {
217
- checkEndpoint: !allowUnresolvedConnections || !provider?.dshProvider
217
+ checkEndpoint: !allowUnresolvedConnections || !provider?.dshProvider, registry
218
218
  });
219
219
  });
220
220
  }
@@ -1,4 +1,5 @@
1
1
  import { ProviderContractError } from './provider-contract.js';
2
+ import { readProviderText, PROVIDER_RESPONSE_LIMITS } from './provider-response.js';
2
3
 
3
4
  function responseError(message) {
4
5
  return new ProviderContractError(message, { stage: 'response', category: 'protocol', acceptance: 'unknown' });
@@ -32,7 +33,7 @@ export async function generateMusic({ key, baseUrl, model, prompt, timeoutMs = 3
32
33
  signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
33
34
  body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content: prompt }] })
34
35
  });
35
- const text = await response.text();
36
+ const text = await readProviderText(response, { maxBytes: response.ok ? PROVIDER_RESPONSE_LIMITS.mediaJson : PROVIDER_RESPONSE_LIMITS.json });
36
37
  let body;
37
38
  try { body = JSON.parse(text); }
38
39
  catch (error) { if (response.ok) throw responseError('Music 响应不是有效 JSON'); }
@@ -0,0 +1,236 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createApplicationRuntime } from './application-runtime.js';
4
+ import { normalizeCoreOptions } from './core-contract.js';
5
+ import { createCoreRuntime } from './core-runtime.js';
6
+ import { createImageFlowService } from './image-flow.js';
7
+ import { inspectRunnerArea } from './execution-ownership.js';
8
+ import { requestDirectory } from './local-request-root.js';
9
+ import { inspectCoreWriterLease } from './core-runtime.js';
10
+ import { recoverCoreWriterLease } from './core-lease-recovery.js';
11
+ import { acceptOperation, inspectExecutionRoot, operationId, readOperation, readAllOperations,
12
+ transitionOperation, projectOperation, operationFail, MAX_ACTIVE_OPERATIONS } from './operation-store.js';
13
+ import { reservePluginOperation } from './plugin-operations.js';
14
+ import { invokeReservedPlugin } from './plugin-execution.js';
15
+ import { acceptPluginOperation, pluginInputDigest, pluginSourceArtifacts, reservePluginArtifact } from './plugin-operation-store.js';
16
+
17
+ const activeState = record => ['queued', 'running'].includes(record.state);
18
+ const busy = () => operationFail('IRIS_CORE_DATA_ROOT_BUSY', '当前写者正在执行,请用原 Key 稍后明确重试');
19
+
20
+ export function executionRunnerDirectory(root) {
21
+ return requestDirectory(root, inspectExecutionRoot(root), 'runner');
22
+ }
23
+ export function inspectExecutionLease(root) {
24
+ return inspectCoreWriterLease(executionRunnerDirectory(root));
25
+ }
26
+ export function recoverExecutionLease(root, options) {
27
+ return recoverCoreWriterLease(executionRunnerDirectory(root), options);
28
+ }
29
+
30
+ /** One execution owner, one finite continue per accepted Operation. Construction performs no I/O. */
31
+ export function createOperationRunner({ dataRoot, application: suppliedApplication, resolveImageCandidates, resolveAudioCandidates, resolveTaskAdapter, onExecutionError,
32
+ pluginRegistry, allowedOperations = [] } = {}) {
33
+ const root = normalizeCoreOptions({ dataRoot }).dataRoot;
34
+ const application = suppliedApplication || createApplicationRuntime({ dataRoot: root, resolveImageCandidates, resolveAudioCandidates, resolveTaskAdapter });
35
+ const ports = { resolveImageCandidates, resolveAudioCandidates, resolveTaskAdapter };
36
+ if (onExecutionError !== undefined && typeof onExecutionError !== 'function') throw new TypeError('执行错误报告器须为函数');
37
+ const queue = [];
38
+ let state = 'new', physicalRoot, directory, ownership, lease, ownerInstanceId, active, draining, disposal, failure;
39
+
40
+ function snapshot() {
41
+ return Object.freeze({ state, queued: queue.length, active: active?.id || null,
42
+ executionEnabled: state === 'open', failed: Boolean(failure) });
43
+ }
44
+ function requireOwner() {
45
+ if (state !== 'open' && state !== 'closing') operationFail('IRIS_EXECUTION_UNAVAILABLE', '执行服务未就绪或已停止');
46
+ if (inspectRunnerArea(directory)?.fingerprint !== ownership.fingerprint) {
47
+ operationFail('IRIS_EXECUTION_OWNERSHIP_LOST', '执行所有权已改变,停止执行并保留现场');
48
+ }
49
+ }
50
+ function writable() {
51
+ requireOwner();
52
+ if (state !== 'open') operationFail('IRIS_EXECUTION_UNAVAILABLE', '执行服务正在关闭');
53
+ }
54
+ function failRunner(error) {
55
+ failure ||= error; state = 'failed';
56
+ active?.controller.abort();
57
+ }
58
+ async function finishIfActive(id, terminal) {
59
+ return application.run('writer', runtime => runtime.run('execute', ({ dataRoot }) => {
60
+ requireOwner();
61
+ const record = readOperation(dataRoot, id);
62
+ if (!record || !activeState(record)) return record;
63
+ if (record.ownerInstanceId !== ownerInstanceId) operationFail('IRIS_EXECUTION_OWNERSHIP_LOST', 'Operation 不属于本次执行实例');
64
+ return transitionOperation(dataRoot, { id, expectedRevision: record.revision, state: terminal });
65
+ }));
66
+ }
67
+ async function perform(job) {
68
+ try {
69
+ await application.run('writer', runtime => runtime.run('execute', async ({ dataRoot }) => {
70
+ requireOwner();
71
+ let record = readOperation(dataRoot, job.id);
72
+ if (!record || !activeState(record)) return;
73
+ if (record.ownerInstanceId !== ownerInstanceId) operationFail('IRIS_EXECUTION_OWNERSHIP_LOST', 'Operation 执行归属不一致');
74
+ if (job.stopReason) {
75
+ transitionOperation(dataRoot, { id: job.id, expectedRevision: record.revision, state: job.stopReason }); return;
76
+ }
77
+ if (record.action === 'plugin.execute') {
78
+ let reservation, result, executionError;
79
+ try {
80
+ job.authorize?.();
81
+ reservation = reservePluginOperation(pluginRegistry, record.operationId, record.input, allowedOperations, record.dependency);
82
+ record = transitionOperation(dataRoot, { id: job.id, expectedRevision: record.revision, state: 'running' });
83
+ result = await invokeReservedPlugin(runtime, reservation, { imports: { operationId: record.id, sources: record.sourceArtifactIds,
84
+ authorize: job.authorize, reserve: () => reservePluginArtifact(dataRoot, inspectExecutionRoot(dataRoot), job.id) } });
85
+ } catch (error) { executionError = error; }
86
+ finally { reservation?.release(); }
87
+ const current = readOperation(dataRoot, job.id);
88
+ const dependencyError = /IRIS_PLUGIN_(?:CLOSED|CONTRIBUTION_MISSING|DEPENDENCY_MISMATCH)/.test(executionError?.code || '');
89
+ const denied = /(?:DENIED|HANDSHAKE_REQUIRED|AUTH_REQUIRED|REVOKED)$/.test(executionError?.code || '');
90
+ transitionOperation(dataRoot, { id: job.id, expectedRevision: current.revision, state: job.stopReason || (executionError ? 'failed' : 'succeeded'),
91
+ ...(executionError && !job.stopReason ? { failureCode: dependencyError ? 'dependency_unavailable' : denied ? 'permission_denied' : 'execution_failed' } : {}),
92
+ ...(!executionError && !job.stopReason ? { value: result.value } : {}) });
93
+ if (executionError && !job.stopReason) onExecutionError?.(executionError, job.id);
94
+ return;
95
+ }
96
+ try { record = transitionOperation(dataRoot, { id: job.id, expectedRevision: record.revision, state: 'running' }); }
97
+ catch (error) {
98
+ if (error.code !== 'IRIS_OPERATION_RUN_CHANGED') throw error;
99
+ transitionOperation(dataRoot, { id: job.id, expectedRevision: record.revision, state: 'failed', failureCode: 'run_changed' }); return;
100
+ }
101
+ let executionError;
102
+ try { await createImageFlowService(runtime, ports).execute('flow.continue', { run_id: record.runId }); }
103
+ catch (error) { executionError = error; }
104
+ // The operation-scoped writer is still held, including after its signal aborts.
105
+ // Persist only local execution facts; Provider Task outcome remains authoritative.
106
+ transitionOperation(dataRoot, { id: job.id, expectedRevision: record.revision,
107
+ state: job.stopReason || (executionError ? 'failed' : 'succeeded'),
108
+ ...(executionError && !job.stopReason ? { failureCode: 'execution_failed' } : {}) });
109
+ if (executionError && !job.stopReason) onExecutionError?.(executionError, job.id);
110
+ }), { signal: job.controller.signal });
111
+ } catch (error) {
112
+ if (job.stopReason && error.name === 'AbortError' && !application.snapshot().cleanupFailed) {
113
+ await finishIfActive(job.id, job.stopReason);
114
+ } else throw error;
115
+ }
116
+ }
117
+ function pump() {
118
+ if (draining || state !== 'open') return;
119
+ draining = (async () => {
120
+ while (state === 'open' && queue.length) {
121
+ active = queue.shift();
122
+ active.done = perform(active);
123
+ try { await active.done; }
124
+ catch (error) {
125
+ if (error.code !== 'IRIS_CORE_DATA_ROOT_BUSY') throw error;
126
+ queue.unshift(active); active = null; break; // No timer or lease stealing; explicit retry wakes this queue.
127
+ }
128
+ active = null;
129
+ }
130
+ })();
131
+ void draining.then(() => { draining = null; }, error => { draining = null; failRunner(error); });
132
+ }
133
+ async function start() {
134
+ if (state !== 'new') operationFail('IRIS_EXECUTION_LIFECYCLE', '执行服务只能显式启动一次');
135
+ state = 'starting';
136
+ try {
137
+ physicalRoot = fs.realpathSync(root); directory = executionRunnerDirectory(physicalRoot);
138
+ lease = createCoreRuntime({ dataRoot: directory, mode: 'writer' }); lease.start();
139
+ ownership = inspectRunnerArea(directory); ownerInstanceId = ownership.owner.instanceId;
140
+ state = 'open';
141
+ await application.run('writer', runtime => runtime.run('execute', ({ dataRoot }) => {
142
+ requireOwner();
143
+ for (const record of readAllOperations(dataRoot).filter(activeState)) {
144
+ transitionOperation(dataRoot, { id: record.id, expectedRevision: record.revision, state: 'interrupted' });
145
+ }
146
+ }));
147
+ return snapshot();
148
+ } catch (error) {
149
+ state = 'failed'; failure = error;
150
+ if (lease) await lease.dispose();
151
+ throw error;
152
+ }
153
+ }
154
+ async function submit(input, { authorize } = {}) {
155
+ writable();
156
+ if (input?.operationId !== undefined) {
157
+ if (!input || Object.keys(input).sort().join(',') !== 'dependency,input,key,operationId') operationFail('IRIS_OPERATION_INPUT_INVALID', '业务执行需要原 Key、准确贡献依赖及输入');
158
+ authorize?.();
159
+ const marker = inspectExecutionRoot(physicalRoot), id = operationId(marker, input.key), previous = readOperation(physicalRoot, id, marker);
160
+ const fingerprint = pluginInputDigest(input.operationId, input.input, input.dependency, previous?.sourceArtifactIds);
161
+ if (previous) {
162
+ if (previous.action !== 'plugin.execute' || previous.inputDigest !== fingerprint) operationFail('IRIS_OPERATION_KEY_CONFLICT', '该 Key 已绑定另一业务或输入');
163
+ if (previous.state === 'queued' && previous.ownerInstanceId === ownerInstanceId) queueMicrotask(pump);
164
+ return { contractVersion: 0, operation: projectOperation(previous), idempotentReplay: true };
165
+ }
166
+ if (application.snapshot().activeWrites || application.snapshot().queuedWrites) busy();
167
+ const reservation = reservePluginOperation(pluginRegistry, input.operationId, input.input, allowedOperations, input.dependency);
168
+ let record;
169
+ try {
170
+ record = await application.run('writer', runtime => runtime.run('execute', ({ dataRoot }) => {
171
+ writable(); authorize?.();
172
+ if (readAllOperations(dataRoot).filter(activeState).length >= MAX_ACTIVE_OPERATIONS) operationFail('IRIS_OPERATION_QUEUE_FULL', '未结束的 Operation 已达上限');
173
+ return acceptPluginOperation(dataRoot, marker, id, { ...input, input: reservation.input,
174
+ sourceArtifactIds: pluginSourceArtifacts(reservation.input, reservation.artifactInputs), ownerInstanceId });
175
+ }));
176
+ } finally { reservation.release(); }
177
+ queue.push({ id, controller: new AbortController(), stopReason: null, authorize }); queueMicrotask(pump);
178
+ return { contractVersion: 0, operation: projectOperation(record), idempotentReplay: false };
179
+ }
180
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).length !== 3
181
+ || !['key', 'runId', 'expectedRunRevision'].every(key => Object.hasOwn(input, key))) {
182
+ operationFail('IRIS_OPERATION_INPUT_INVALID', '需要明确 Key、Run 和预期 revision');
183
+ }
184
+ const marker = inspectExecutionRoot(physicalRoot), id = operationId(marker, input.key);
185
+ const previous = readOperation(physicalRoot, id, marker);
186
+ if (previous) {
187
+ if (previous.runId !== input.runId || previous.expectedRunRevision !== input.expectedRunRevision) {
188
+ operationFail('IRIS_OPERATION_KEY_CONFLICT', '该 Key 已绑定其他操作输入');
189
+ }
190
+ if (previous.state === 'queued' && previous.ownerInstanceId === ownerInstanceId) queueMicrotask(pump);
191
+ return { contractVersion: 0, operation: projectOperation(previous), idempotentReplay: true };
192
+ }
193
+ if (application.snapshot().activeWrites || application.snapshot().queuedWrites) busy();
194
+ const accepted = await application.run('writer', runtime => runtime.run('execute', ({ dataRoot }) => {
195
+ writable(); return acceptOperation(dataRoot, { ...input, ownerInstanceId });
196
+ }));
197
+ if (!accepted.idempotentReplay) {
198
+ queue.push({ id: accepted.operation.id, controller: new AbortController(), stopReason: null });
199
+ queueMicrotask(pump);
200
+ }
201
+ return { contractVersion: 0, operation: projectOperation(accepted.operation), idempotentReplay: accepted.idempotentReplay };
202
+ }
203
+ async function stop(id) {
204
+ writable();
205
+ const record = readOperation(physicalRoot, id);
206
+ if (!record) operationFail('IRIS_OPERATION_NOT_FOUND', 'Operation 不存在');
207
+ if (activeState(record)) {
208
+ if (record.ownerInstanceId !== ownerInstanceId) operationFail('IRIS_EXECUTION_OWNERSHIP_LOST', '不能停止另一执行实例的操作');
209
+ const job = active?.id === id ? active : queue.find(job => job.id === id);
210
+ if (job) { job.stopReason = 'canceled'; job.controller.abort(); }
211
+ if (active?.id === id) await job.done;
212
+ else await finishIfActive(id, 'canceled');
213
+ }
214
+ const saved = readOperation(physicalRoot, id);
215
+ if (activeState(saved)) operationFail('IRIS_EXECUTION_STOP_UNSAVED', '停止尚未持久保存,请查询原 Operation');
216
+ return { contractVersion: 0, operation: projectOperation(saved),
217
+ localStopped: saved.state === 'canceled', remoteCancellation: 'not_requested' };
218
+ }
219
+ async function idle() { if (draining) await draining; if (failure) throw failure; return snapshot(); }
220
+ function dispose() {
221
+ if (disposal) return disposal;
222
+ if (state === 'new') { state = 'closed'; return disposal = suppliedApplication ? Promise.resolve(snapshot()) : application.dispose().then(snapshot); }
223
+ if (state === 'closed') return Promise.resolve(snapshot());
224
+ if (state !== 'failed') state = 'closing';
225
+ if (active) { active.stopReason ||= 'interrupted'; active.controller.abort(); }
226
+ disposal = (async () => {
227
+ if (draining) await draining;
228
+ if (failure) throw failure;
229
+ for (const job of queue.splice(0)) await finishIfActive(job.id, 'interrupted');
230
+ if (!suppliedApplication) await application.dispose();
231
+ await lease?.dispose(); state = 'closed'; return snapshot();
232
+ })();
233
+ return disposal;
234
+ }
235
+ return Object.freeze({ start, submit, stop, idle, dispose, snapshot });
236
+ }
@@ -0,0 +1,216 @@
1
+ // Durable, non-executing local intentions. Call mutations under the supplied Core writer.
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { atomicCreatePrivate, atomicWritePrivate } from './private-storage.js';
6
+ import { inspectLocalRequestRoot, initializeLocalRequestRoot, requestDirectory } from './local-request-root.js';
7
+ import { listCreationRequests } from './creation-request-store.js';
8
+ import { readFlowRun, flowStep } from './flow-store.js';
9
+ import { findFlowTask } from './core-tasks.js';
10
+ import { findCoreArtifactMetadata } from './core-artifacts.js';
11
+ import { validatePluginOperation, validatePluginBinding, transitionPluginOperation, projectPluginOperation } from './plugin-operation-store.js';
12
+
13
+ const ID = /^op_[a-f0-9]{64}$/;
14
+ const RUN = /^run_[a-f0-9]{24}$/;
15
+ const TASK = /^task_[a-f0-9]{24}$/;
16
+ const ARTIFACT = /^artifact_[a-f0-9]{24}$/;
17
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
18
+ const DIGEST = /^sha256:[a-f0-9]{64}$/;
19
+ const STATES = new Set(['queued', 'running', 'succeeded', 'failed', 'canceled', 'interrupted']);
20
+ const ACTIVE = new Set(['queued', 'running']);
21
+ const FAILURE_CODES = new Set(['run_changed', 'execution_failed']);
22
+ export const MAX_ACTIVE_OPERATIONS = 8;
23
+ export function operationFail(code, message) { throw Object.assign(new Error(message), { code }); }
24
+ const invalid = () => operationFail('IRIS_OPERATION_STORE_INVALID', 'Operation 记录未知、损坏或与请求容器不一致');
25
+ const hash = value => crypto.createHash('sha256').update(value).digest('hex');
26
+ const iso = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
27
+ const positive = value => Number.isSafeInteger(value) && value >= 1;
28
+ function keys(value, allowed, required = allowed) {
29
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value)
30
+ && Object.keys(value).every(key => allowed.includes(key)) && required.every(key => Object.hasOwn(value, key)));
31
+ }
32
+ function input(value, allowed, required = allowed) {
33
+ if (!keys(value, allowed, required)) operationFail('IRIS_OPERATION_INPUT_INVALID', 'Operation 输入包含缺失或不支持的字段');
34
+ }
35
+ export function inspectExecutionRoot(root) {
36
+ const marker = inspectLocalRequestRoot(root, { allowAbsent: true });
37
+ if (![1, 2, 3].includes(marker?.schemaVersion)) operationFail('IRIS_OPERATION_ROOT_REQUIRED', 'Operation 需要新的专用执行根,请显式执行 execution init;不转换原根');
38
+ return marker;
39
+ }
40
+ export function initializeExecutionRoot(root, { imageGeneration = false, audio = false } = {}) {
41
+ const marker = initializeLocalRequestRoot(root, audio ? 3 : imageGeneration ? 2 : 1);
42
+ listCreationRequests(root); readAllOperations(root);
43
+ return marker;
44
+ }
45
+ export function operationId(marker, key) {
46
+ if (!UUID.test(key)) operationFail('IRIS_OPERATION_KEY_INVALID', 'Operation Key 须为小写 UUIDv4');
47
+ return 'op_' + hash(JSON.stringify(['operation/v0', marker.rootId, key]));
48
+ }
49
+ function inputDigest(runId, revision) {
50
+ return 'sha256:' + hash(JSON.stringify(['operation-call/v0', 'flow.continue', runId, revision]));
51
+ }
52
+ const bindingFor = run => ({ taskId: flowStep(run).taskId, sourceArtifactId: run.source?.artifactId ?? null,
53
+ comparisonArtifactId: run.compare?.artifactId ?? null, inputDigest: run.inputDigest });
54
+ const artifactsFor = run => [...new Set([run.source?.artifactId, run.output?.artifactId, run.comparison?.artifactId, run.selection?.artifactId].filter(Boolean))];
55
+ function validate(record, id, marker) {
56
+ if (record?.schemaVersion === 1) return validatePluginOperation(record, id, marker);
57
+ if (!keys(record, ['schemaVersion', 'id', 'rootId', 'revision', 'action', 'runId', 'expectedRunRevision', 'inputDigest',
58
+ 'ownerInstanceId', 'binding', 'createdAt', 'updatedAt', 'state', 'cancelRequested', 'startedAt', 'finishedAt', 'result', 'failureCode'],
59
+ ['schemaVersion', 'id', 'rootId', 'revision', 'action', 'runId', 'expectedRunRevision', 'inputDigest',
60
+ 'ownerInstanceId', 'binding', 'createdAt', 'updatedAt', 'state', 'cancelRequested'])
61
+ || record.schemaVersion !== 0 || !ID.test(id) || record.id !== id || record.rootId !== marker.rootId
62
+ || !positive(record.revision) || record.action !== 'flow.continue' || !RUN.test(record.runId)
63
+ || !positive(record.expectedRunRevision) || record.inputDigest !== inputDigest(record.runId, record.expectedRunRevision)
64
+ || !UUID.test(record.ownerInstanceId) || !STATES.has(record.state) || typeof record.cancelRequested !== 'boolean'
65
+ || !iso(record.createdAt) || !iso(record.updatedAt) || record.updatedAt < record.createdAt
66
+ || !keys(record.binding, ['taskId', 'sourceArtifactId', 'comparisonArtifactId', 'inputDigest'])
67
+ || !TASK.test(record.binding.taskId) || !(marker.schemaVersion === 3 && (record.binding.sourceArtifactId === null || ARTIFACT.test(record.binding.sourceArtifactId))
68
+ && (record.binding.comparisonArtifactId === null || ARTIFACT.test(record.binding.comparisonArtifactId))
69
+ || record.binding.sourceArtifactId === null && record.binding.comparisonArtifactId === null && marker.schemaVersion === 2
70
+ || ARTIFACT.test(record.binding.sourceArtifactId) && ARTIFACT.test(record.binding.comparisonArtifactId))
71
+ || !DIGEST.test(record.binding.inputDigest)) invalid();
72
+ const hasStarted = Object.hasOwn(record, 'startedAt');
73
+ if (record.revision !== (record.state === 'queued' ? 1 : record.state === 'running' || !hasStarted ? 2 : 3)) invalid();
74
+ if (hasStarted && (!iso(record.startedAt) || record.startedAt < record.createdAt || record.startedAt > record.updatedAt)) invalid();
75
+ if (record.state === 'queued' && (record.revision !== 1 || record.updatedAt !== record.createdAt || hasStarted || record.cancelRequested)
76
+ || record.state === 'running' && (!hasStarted || record.cancelRequested)
77
+ || record.state === 'succeeded' && (!hasStarted || record.cancelRequested)
78
+ || record.cancelRequested !== (record.state === 'canceled')
79
+ || record.state === 'failed' && (!FAILURE_CODES.has(record.failureCode) || !hasStarted && record.failureCode !== 'run_changed')
80
+ || record.state !== 'failed' && Object.hasOwn(record, 'failureCode')) invalid();
81
+ if (ACTIVE.has(record.state)) {
82
+ if (Object.hasOwn(record, 'finishedAt') || Object.hasOwn(record, 'result')) invalid();
83
+ } else {
84
+ if (!iso(record.finishedAt) || record.finishedAt !== record.updatedAt || record.finishedAt < record.createdAt
85
+ || !keys(record.result, ['runRevision', 'taskId', 'artifactIds']) || !positive(record.result.runRevision)
86
+ || record.result.runRevision < record.expectedRunRevision
87
+ || record.result.taskId !== null && record.result.taskId !== record.binding.taskId
88
+ || !Array.isArray(record.result.artifactIds) || record.binding.sourceArtifactId !== null && !record.result.artifactIds.length
89
+ || new Set(record.result.artifactIds).size !== record.result.artifactIds.length
90
+ || !record.result.artifactIds.every(value => ARTIFACT.test(value))
91
+ || record.binding.sourceArtifactId !== null && !record.result.artifactIds.includes(record.binding.sourceArtifactId)
92
+ || record.state === 'succeeded' && !record.result.taskId) invalid();
93
+ }
94
+ return record;
95
+ }
96
+ function validateBinding(root, record) {
97
+ if (record.action === 'plugin.execute') return validatePluginBinding(root, record);
98
+ const run = readFlowRun(root, record.runId), task = findFlowTask(root, run);
99
+ if (Object.entries(bindingFor(run)).some(([key, value]) => record.binding[key] !== value) || run.revision < record.expectedRunRevision
100
+ || record.result && (record.result.runRevision > run.revision || record.result.taskId && !task
101
+ || record.result.artifactIds.some(id => !artifactsFor(run).includes(id)))) {
102
+ operationFail('IRIS_OPERATION_BINDING_CONFLICT', 'Operation 与原 Run/Task/作品绑定冲突,停止操作并保留原记录');
103
+ }
104
+ return { run, task };
105
+ }
106
+ function fileFor(root, marker, id) {
107
+ if (!ID.test(id)) operationFail('IRIS_OPERATION_INPUT_INVALID', 'Operation ID 格式无效');
108
+ return path.join(requestDirectory(root, marker, 'operations'), id + '.json');
109
+ }
110
+ export function readOperation(root, id, marker = inspectExecutionRoot(root)) {
111
+ const file = fileFor(root, marker, id);
112
+ let record;
113
+ try {
114
+ const stat = fs.lstatSync(file);
115
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 256 * 1024) invalid();
116
+ try { record = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { invalid(); }
117
+ } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
118
+ validate(record, id, marker); validateBinding(root, record);
119
+ return structuredClone(record);
120
+ }
121
+ export function readAllOperations(root) {
122
+ const marker = inspectLocalRequestRoot(root, { allowAbsent: true });
123
+ if (!marker || marker.schemaVersion === 0) return [];
124
+ const records = [];
125
+ for (const name of fs.readdirSync(requestDirectory(root, marker, 'operations'))) {
126
+ if (/^op_[a-f0-9]{64}\.json\.tmp-[1-9][0-9]*-[a-f0-9]{12}$/.test(name)) continue;
127
+ if (!/^op_[a-f0-9]{64}\.json$/.test(name)) invalid();
128
+ const record = readOperation(root, name.slice(0, -5), marker);
129
+ if (!record) invalid();
130
+ records.push(record);
131
+ }
132
+ const active = records.filter(record => ACTIVE.has(record.state));
133
+ if (active.length > MAX_ACTIVE_OPERATIONS || new Set(active.filter(record => record.action === 'flow.continue').map(record => record.runId)).size !== active.filter(record => record.action === 'flow.continue').length) invalid();
134
+ return records;
135
+ }
136
+
137
+ export function acceptOperation(root, value) {
138
+ input(value, ['key', 'runId', 'expectedRunRevision', 'ownerInstanceId']);
139
+ if (!RUN.test(value.runId) || !positive(value.expectedRunRevision) || !UUID.test(value.ownerInstanceId)) {
140
+ operationFail('IRIS_OPERATION_INPUT_INVALID', '需要明确 Run、正整数 revision 与 owner 实例 UUID');
141
+ }
142
+ const marker = inspectExecutionRoot(root), id = operationId(marker, value.key);
143
+ const fingerprint = inputDigest(value.runId, value.expectedRunRevision), previous = readOperation(root, id, marker);
144
+ if (previous) {
145
+ if (previous.inputDigest !== fingerprint) operationFail('IRIS_OPERATION_KEY_CONFLICT', '该 Key 已绑定其他操作输入');
146
+ return { operation: previous, idempotentReplay: true };
147
+ }
148
+ const run = readFlowRun(root, value.runId); findFlowTask(root, run);
149
+ if (run.revision !== value.expectedRunRevision) operationFail('IRIS_OPERATION_RUN_CHANGED', 'Run revision 已改变,请读取原流程后明确操作');
150
+ const active = readAllOperations(root).filter(record => ACTIVE.has(record.state));
151
+ const conflicting = active.find(record => record.runId === run.id);
152
+ if (conflicting) throw Object.assign(new Error('此 Run 已有未结束的 Operation'), { code: 'IRIS_OPERATION_RUN_BUSY', operationId: conflicting.id });
153
+ if (active.length >= MAX_ACTIVE_OPERATIONS) operationFail('IRIS_OPERATION_QUEUE_FULL', '未结束的 Operation 已达上限,本次未接收');
154
+ const now = new Date().toISOString();
155
+ const record = { schemaVersion: 0, id, rootId: marker.rootId, revision: 1, action: 'flow.continue', runId: run.id,
156
+ expectedRunRevision: value.expectedRunRevision, inputDigest: fingerprint, ownerInstanceId: value.ownerInstanceId,
157
+ binding: bindingFor(run), createdAt: now, updatedAt: now, state: 'queued', cancelRequested: false };
158
+ validate(record, id, marker);
159
+ atomicCreatePrivate(fileFor(root, marker, id), JSON.stringify(record, null, 2) + '\n');
160
+ return { operation: structuredClone(record), idempotentReplay: false };
161
+ }
162
+
163
+ /** Store-only CAS. It neither runs flow.continue nor delivers an abort signal or remote cancel. */
164
+ export function transitionOperation(root, value) {
165
+ input(value, ['id', 'expectedRevision', 'state', 'failureCode', 'value'], ['id', 'expectedRevision', 'state']);
166
+ if (!positive(value.expectedRevision) || !STATES.has(value.state)) operationFail('IRIS_OPERATION_INPUT_INVALID', 'Operation 状态或 revision 无效');
167
+ const marker = inspectExecutionRoot(root), current = readOperation(root, value.id, marker);
168
+ if (!current) operationFail('IRIS_OPERATION_NOT_FOUND', 'Operation 不存在');
169
+ if (current.action === 'plugin.execute') return transitionPluginOperation(root, marker, value);
170
+ if (Object.hasOwn(value, 'value')) operationFail('IRIS_OPERATION_INPUT_INVALID', '媒体流程不接受插件业务结果');
171
+ if (current.revision !== value.expectedRevision) operationFail('IRIS_OPERATION_REVISION_CONFLICT', 'Operation 已改变,请先读取原记录');
172
+ const allowed = current.state === 'queued' ? ['running', 'canceled', 'interrupted', 'failed']
173
+ : current.state === 'running' ? ['succeeded', 'failed', 'canceled', 'interrupted'] : [];
174
+ if (!allowed.includes(value.state)) operationFail('IRIS_OPERATION_TRANSITION_INVALID', '不允许此 Operation 状态转换;结束记录不重新排队');
175
+ if (value.state === 'failed' ? !FAILURE_CODES.has(value.failureCode) || current.state === 'queued' && value.failureCode !== 'run_changed'
176
+ : Object.hasOwn(value, 'failureCode')) operationFail('IRIS_OPERATION_INPUT_INVALID', '失败记录只接受有限错误类别,不保存异常原文');
177
+ const { run, task } = validateBinding(root, current);
178
+ if (value.state === 'running' && run.revision !== current.expectedRunRevision) operationFail('IRIS_OPERATION_RUN_CHANGED', 'Run revision 已改变,未开始本次调用');
179
+ if (value.state === 'succeeded' && !task) operationFail('IRIS_OPERATION_RESULT_INVALID', '没有原 Task 事实,不能记录调用成功');
180
+ const now = new Date(Math.max(Date.now(), Date.parse(current.updatedAt))).toISOString();
181
+ const next = { ...current, revision: current.revision + 1, state: value.state, updatedAt: now,
182
+ cancelRequested: value.state === 'canceled',
183
+ ...(value.state === 'running' ? { startedAt: now } : { finishedAt: now,
184
+ result: { runRevision: run.revision, taskId: task?.id || null, artifactIds: artifactsFor(run) } }),
185
+ ...(value.state === 'failed' ? { failureCode: value.failureCode } : {}) };
186
+ validate(next, next.id, marker);
187
+ atomicWritePrivate(fileFor(root, marker, next.id), JSON.stringify(next, null, 2) + '\n');
188
+ return structuredClone(next);
189
+ }
190
+
191
+ export function listOperations(root, value = {}) {
192
+ input(value, ['runId', 'limit', 'cursor'], []);
193
+ if (value.runId !== undefined && !RUN.test(value.runId) || value.limit !== undefined && (!positive(value.limit) || value.limit > 30)
194
+ || value.cursor !== undefined && !ID.test(value.cursor)) operationFail('IRIS_OPERATION_INPUT_INVALID', 'Operation 列表参数无效');
195
+ inspectExecutionRoot(root);
196
+ const ordered = readAllOperations(root).filter(record => value.runId === undefined || record.runId === value.runId)
197
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
198
+ const offset = value.cursor === undefined ? 0 : ordered.findIndex(record => record.id === value.cursor) + 1;
199
+ if (value.cursor !== undefined && offset === 0) operationFail('IRIS_OPERATION_INPUT_INVALID', 'Operation 游标不属于当前列表');
200
+ const page = ordered.slice(offset, offset + (value.limit ?? 20));
201
+ return { operations: page, total: ordered.length, nextCursor: offset + page.length < ordered.length ? page.at(-1).id : null };
202
+ }
203
+ export function operationReferences(root) {
204
+ return readAllOperations(root).map(record => record.action === 'plugin.execute' ? { operationId: record.id, runId: null, taskIds: [],
205
+ artifactIds: [...new Set([...record.sourceArtifactIds, ...record.reservedArtifactIds.filter(id => findCoreArtifactMetadata(root, id))])] } : ({ operationId: record.id, runId: record.runId,
206
+ taskIds: [record.binding.taskId], artifactIds: [...new Set([record.binding.sourceArtifactId,
207
+ record.binding.comparisonArtifactId, ...(record.result?.artifactIds || [])].filter(Boolean))] }));
208
+ }
209
+ export function projectOperation(record) {
210
+ if (record.action === 'plugin.execute') return projectPluginOperation(record);
211
+ return { contractVersion: 0, id: record.id, action: record.action, runId: record.runId,
212
+ revision: record.revision, expectedRunRevision: record.expectedRunRevision, state: record.state,
213
+ createdAt: record.createdAt, updatedAt: record.updatedAt, cancelRequested: record.cancelRequested,
214
+ ...(record.startedAt ? { startedAt: record.startedAt } : {}), ...(record.finishedAt ? { finishedAt: record.finishedAt } : {}),
215
+ ...(record.result ? { result: structuredClone(record.result) } : {}), ...(record.failureCode ? { failureCode: record.failureCode } : {}) };
216
+ }