@mokuyoaxis/dsh-iris 0.2.6 → 0.2.7

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 (58) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.en.md +28 -19
  3. package/README.md +27 -18
  4. package/bin/dsh-iris.js +140 -26
  5. package/docs/AGENT_QUICKSTART.md +5 -5
  6. package/docs/ARCHITECTURE.md +9 -3
  7. package/docs/CLI_MANAGEMENT.md +1 -1
  8. package/docs/CORE_RUNTIME_CONTRACT.md +36 -5
  9. package/docs/CORE_SKILLS.md +62 -0
  10. package/docs/DSH_COMPATIBILITY.md +4 -4
  11. package/docs/DSH_CORE_MIGRATION.md +4 -2
  12. package/docs/DSH_PROVIDER_CONNECTIONS.md +1 -1
  13. package/docs/DSH_RUNTIME.md +36 -0
  14. package/docs/FUNCTION_MODEL_POOLS.md +1 -1
  15. package/docs/HEADLESS_CLI.md +11 -3
  16. package/docs/HOST_DOCTOR.md +13 -1
  17. package/docs/IMAGE_FLOW.md +71 -0
  18. package/docs/LOCAL_API.md +60 -0
  19. package/docs/LOCAL_API_CONTRACT.md +115 -0
  20. package/docs/LOCAL_CREATION.md +50 -0
  21. package/docs/PROVIDER_HEALTH.md +1 -1
  22. package/docs/ROADMAP.md +14 -2
  23. package/docs/SECURITY.md +9 -1
  24. package/docs/SYNC_DELIVERY_RECOVERY.md +2 -0
  25. package/docs/VISION_CLI.md +1 -1
  26. package/docs/WEB_PROTOTYPE.md +47 -0
  27. package/docs/releases/0.2.7.md +62 -0
  28. package/lib/actions.js +79 -68
  29. package/lib/api.js +38 -38
  30. package/lib/application-runtime.js +186 -0
  31. package/lib/client.js +89 -3
  32. package/lib/core-artifact-store.js +5 -0
  33. package/lib/core-maintenance.js +25 -2
  34. package/lib/core-runtime.js +19 -0
  35. package/lib/core-tasks.js +62 -8
  36. package/lib/creation-request-store.js +159 -0
  37. package/lib/doctor.js +12 -1
  38. package/lib/dsh-core-adapter.js +666 -572
  39. package/lib/dsh-host-adapter.js +4 -1
  40. package/lib/flow-store.js +195 -0
  41. package/lib/image-flow.js +188 -0
  42. package/lib/index.js +59 -53
  43. package/lib/local-api-projection.js +27 -0
  44. package/lib/local-api.js +381 -0
  45. package/lib/local-creation.js +85 -0
  46. package/lib/private-storage.js +12 -0
  47. package/lib/provider-task-runner.js +22 -2
  48. package/lib/render-address.js +35 -0
  49. package/lib/render.js +52 -13
  50. package/lib/skill-installation.js +199 -0
  51. package/lib/workbench-artifacts.js +1 -2
  52. package/lib/workbench-management.js +8 -9
  53. package/package.json +3 -2
  54. package/skills/iris-core/SKILL.md +40 -0
  55. package/skills/iris-core/agents/openai.yaml +4 -0
  56. package/skills/iris-core/references/cli.md +95 -0
  57. package/skills/iris-core/references/tasks.md +58 -0
  58. package/user_guide.md +14 -8
package/lib/index.js CHANGED
@@ -19,8 +19,8 @@ import { waitForModelWork } from './model-call-runtime.js';
19
19
  import { hasHostPort, requireHostPort } from './host-contract.js';
20
20
  import { createDshHostAdapter } from './dsh-host-adapter.js';
21
21
  import { withDshProviderConfig } from './dsh-provider-config.js';
22
- import { configureProviderHostForDsh } from './dsh-core-adapter.js';
23
- import { cropForDsh, diffForDsh, framesForDsh, summaryFramesForDsh, visionImageForDsh, htmlForDsh, inspectProviderTaskForDsh, projectCoreTaskForDsh, readCoreArtifactMediaForDsh, resumeProviderTaskWatchesForDsh, stopProviderTaskWatchesForDsh } from './dsh-core-adapter.js';
22
+ import { createDshRuntime, dshCoreDataRoot } from './dsh-core-adapter.js';
23
+ import { ModelPortError } from './model-port-contract.js';
24
24
  import { imageDimensions } from './pixels.js';
25
25
  import { runLocateRequest, runSummaryRequest, describeGeneratedImage } from './composite-vision-routing.js';
26
26
  import { normalizeSummarySource, summaryFrameRecords, summaryTimelineLabel } from './summary-input.js';
@@ -66,12 +66,11 @@ function outputDir() {
66
66
 
67
67
  /* ---------------- 媒体路由:/iris/media/:taskId/:token/:name ---------------- */
68
68
 
69
- let mediaRouteMounted = false;
70
- function mountIrisRoutes(routeCtx) {
69
+ function mountIrisRoutes(routeCtx, core, routes) {
71
70
  // Cordis 服务只在 DSH 宿主边界探测;Command 与通用工具辅助函数不接收原始 ctx
72
71
  const host = dshHost(routeCtx);
73
72
  const reg = hasHostPort(host, 'routes') ? host.ports.routes : undefined;
74
- if (!reg || typeof reg.register !== 'function' || mediaRouteMounted) return;
73
+ if (!reg || typeof reg.register !== 'function' || routes.mounted) return;
75
74
  try {
76
75
  const disposeMedia = reg.register({
77
76
  kind: 'prefix',
@@ -81,21 +80,21 @@ function mountIrisRoutes(routeCtx) {
81
80
  const disposeApi = reg.register({
82
81
  kind: 'prefix',
83
82
  path: '/iris/api',
84
- handler: guarded((req, res) => serveApi(req, res, dshHost(routeCtx)))
83
+ handler: guarded((req, res) => serveApi(req, res, dshHost(routeCtx), core))
85
84
  });
86
85
  const disposeRender = reg.register({
87
86
  kind: 'prefix',
88
87
  path: '/iris/render',
89
88
  handler: guarded(serveRender)
90
89
  });
91
- mediaRouteMounted = true;
90
+ routes.mounted = true;
92
91
  recordHostRoutes(EXPECTED_IRIS_ROUTES);
93
92
  routeCtx.effect(() => () => {
94
93
  if (typeof disposeMedia === 'function') disposeMedia();
95
94
  if (typeof disposeApi === 'function') disposeApi();
96
95
  if (typeof disposeRender === 'function') disposeRender();
97
96
  closeAllSse(); // SSE 长连接随插件停用一起关闭,不留悬挂连接
98
- mediaRouteMounted = false;
97
+ routes.mounted = false;
99
98
  }, 'iris: media+api+actions+render routes');
100
99
  console.log('[iris] 媒体+工作台+操作路由已挂载:/iris/media · /iris/api · /iris/api/actions · /iris/render');
101
100
  } catch (err) {
@@ -181,11 +180,11 @@ function coreTaskTerminalProblem(task, label = '任务') {
181
180
  return label + '需要人工处理';
182
181
  }
183
182
 
184
- async function awaitCoreTerminal(taskId, { timeoutMs, signal }) {
183
+ async function awaitCoreTerminal(core, taskId, { timeoutMs, signal }) {
185
184
  const deadline = Date.now() + timeoutMs;
186
185
  for (;;) {
187
186
  if (signal?.aborted) throw new Error('已取消;Core 任务仍保留远端受理事实,可稍后查询');
188
- const task = await inspectProviderTaskForDsh(taskId, { signal });
187
+ const task = await core.inspectProviderTaskForDsh(taskId, { signal });
189
188
  if (coreTaskWaitFinished(task)) return task;
190
189
  if (Date.now() > deadline) return null;
191
190
  await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -221,7 +220,14 @@ function safeRegister(host, definition) {
221
220
 
222
221
  export async function apply(ctx) {
223
222
  beginHostRuntime({ pluginId: IRIS_PLUGIN_ID, version: PACKAGE_VERSION });
224
- const releaseProviderHost = configureProviderHostForDsh(() => dshHost(ctx));
223
+ const core = createDshRuntime({ dataRoot: dshCoreDataRoot(), getHost: () => dshHost(ctx) });
224
+ ctx.provide('irisRuntime', core);
225
+ const routes = { mounted: false };
226
+ const { cropForDsh, diffForDsh, framesForDsh, summaryFramesForDsh, visionImageForDsh, htmlForDsh, inspectProviderTaskForDsh, projectCoreTaskForDsh, readCoreArtifactMediaForDsh, resumeProviderTaskWatchesForDsh } = core;
227
+ ctx.effect(() => () => {
228
+ tasks.stopWatchAll();
229
+ return core.dispose();
230
+ }, 'iris: DSH runtime');
225
231
  guard('Iris 私有存储权限收紧', () => hardenPrivateTree(store.irisHome()));
226
232
 
227
233
  guard('上传临时文件清理', () => purgeStaleUploads());
@@ -245,27 +251,18 @@ export async function apply(ctx) {
245
251
  if (resumed.length) console.log(`[iris] 已恢复接管 ${resumed.length} 个后台任务`);
246
252
  });
247
253
 
248
- try {
249
- const resumed = await resumeProviderTaskWatchesForDsh();
250
- if (resumed.length) console.log(`[iris] 已恢复接管 ${resumed.length} 个 Core 图片任务`);
251
- } catch (error) {
252
- console.error('[iris] 恢复 Core 图片后台任务失败(已隔离,不影响宿主):', error?.message || error);
253
- }
254
-
255
- // Fiber 清理:插件停用/更新时停掉 legacy 与 Core 的全部 Host 盯守句柄
256
- guard('盯守句柄注册', () => {
257
- ctx.effect(() => () => {
258
- tasks.stopWatchAll();
259
- stopProviderTaskWatchesForDsh();
260
- releaseProviderHost();
261
- });
254
+ // Owned background setup lets unload cancel a slow connection resolver during takeover.
255
+ void resumeProviderTaskWatchesForDsh().then(resumed => {
256
+ if (resumed.length) console.log('[iris] 已恢复接管 ' + resumed.length + ' 个 Core 媒体任务');
257
+ }, error => {
258
+ if (core.snapshot().state === 'open') console.error('[iris] 恢复 Core 后台任务失败:', error?.code || 'unknown');
262
259
  });
263
260
 
264
261
  // 媒体路由(视频/音频在对话流里的可播通路;图片仍走原生附件)
265
- ctx.inject(['webServer'], mountIrisRoutes);
266
- ctx.inject(['httpServer'], mountIrisRoutes);
262
+ ctx.inject(['webServer'], routeCtx => mountIrisRoutes(routeCtx, core, routes));
263
+ ctx.inject(['httpServer'], routeCtx => mountIrisRoutes(routeCtx, core, routes));
267
264
  // 若 webServer 已在运行,inject 回调也会触发;这里兜底立即挂载一次
268
- guard('媒体路由兜底挂载', () => mountIrisRoutes(ctx));
265
+ guard('媒体路由兜底挂载', () => mountIrisRoutes(ctx, core, routes));
269
266
 
270
267
  // DSH 的 Skill registry 是可选宿主能力。存在时把 npm 包内三项 Skill 注册到
271
268
  // 全局层;项目目录中的同名 Skill 仍由 DSH 原生优先级覆盖。
@@ -283,13 +280,22 @@ export async function apply(ctx) {
283
280
  'iris_media_summarize', 'iris_locate', 'iris_long_ocr']);
284
281
  const visionTools = new Set(['iris_look_at_image', 'iris_relook_attachment', 'iris_media_summarize', 'iris_locate', 'iris_long_ocr']);
285
282
  function registerIrisTool(definition) {
286
- safeRegister(host, { ...definition, execute(args, exec) {
287
- const execute = async () => {
288
- if (visionTools.has(definition.name) && !args?.model) await store.refreshAutomaticDirectories({ capability: 'vision', signal: exec.signal });
289
- return definition.execute(args, exec);
290
- };
291
- return providerTools.has(definition.name)
292
- ? withDshProviderConfig(dshHost(ctx), execute, { signal: exec.signal }) : execute();
283
+ safeRegister(host, { ...definition, execute(args, exec = {}) {
284
+ const owned = structuredClone(args || {});
285
+ return core.runHostOperation(signal => {
286
+ const execute = async () => {
287
+ if (visionTools.has(definition.name) && !owned.model) await store.refreshAutomaticDirectories({ capability: 'vision', signal });
288
+ signal.throwIfAborted();
289
+ return definition.execute(owned, { ...exec, signal });
290
+ };
291
+ return providerTools.has(definition.name)
292
+ ? withDshProviderConfig(dshHost(ctx), execute, { signal }) : execute();
293
+ }, { signal: exec.signal }).catch(error => {
294
+ if (visionTools.has(definition.name) && exec.signal?.aborted && error === exec.signal.reason) {
295
+ throw new ModelPortError('IRIS_MODEL_ABORTED');
296
+ }
297
+ throw error;
298
+ });
293
299
  } });
294
300
  }
295
301
  if (!hasHostPort(host, 'tools')) {
@@ -299,7 +305,7 @@ export async function apply(ctx) {
299
305
 
300
306
  async function coreImageToolResult(submitted, prompt, signal) {
301
307
  let final = await inspectProviderTaskForDsh(submitted.taskId, { signal });
302
- if (!coreTaskWaitFinished(final)) final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.image, signal });
308
+ if (!coreTaskWaitFinished(final)) final = await awaitCoreTerminal(core, submitted.taskId, { timeoutMs: AWAIT_MS.image, signal });
303
309
  if (!final) return { blocks: [{ type: 'text', text: `[iris] Core 图片任务仍在后台观察(task: ${submitted.taskId})。稍后可用 iris_task_status 查询进度与 Artifact。` }] };
304
310
  const terminalProblem = coreTaskTerminalProblem(final, '图像生成');
305
311
  if (terminalProblem) throw new Error(terminalProblem);
@@ -345,9 +351,9 @@ export async function apply(ctx) {
345
351
  const attachmentId = String(args.attachment_id || '').trim();
346
352
  if (Boolean(imagePath) === Boolean(attachmentId)) throw new Error('图片导入须选择image_path或attachment_id之一');
347
353
  const input = imagePath ? { image_path: imagePath } : {
348
- image_bytes: (await resolveVisionImageInput(dshHost(ctx), exec, { attachment_id: attachmentId }, exec.signal)).bytes
354
+ image_bytes: (await resolveVisionImageInput(core, dshHost(ctx), exec, { attachment_id: attachmentId }, exec.signal)).bytes
349
355
  };
350
- return runAction(dshHost(ctx), 'image_import', input, { signal: exec.signal });
356
+ return runAction(dshHost(ctx), 'image_import', input, { signal: exec.signal, core });
351
357
  }
352
358
  });
353
359
 
@@ -374,7 +380,7 @@ export async function apply(ctx) {
374
380
  isConcurrencySafe: () => false,
375
381
  async execute(args, exec) {
376
382
  const prompt = String(args.prompt || '').trim();
377
- const submitted = await runAction(dshHost(ctx), 'image', args, { signal: exec.signal });
383
+ const submitted = await runAction(dshHost(ctx), 'image', args, { signal: exec.signal, core });
378
384
  if (submitted.storage === 'core') {
379
385
  return coreImageToolResult(submitted, prompt, exec.signal);
380
386
  }
@@ -437,7 +443,7 @@ export async function apply(ctx) {
437
443
  output: { schema: { type: 'object' }, render: (_args, value) => value.blocks },
438
444
  isConcurrencySafe: () => false,
439
445
  async execute(args, exec) {
440
- const submitted = await runAction(dshHost(ctx), 'image_edit', args, { signal: exec.signal });
446
+ const submitted = await runAction(dshHost(ctx), 'image_edit', args, { signal: exec.signal, core });
441
447
  return coreImageToolResult(submitted, String(args.prompt || '').trim(), exec.signal);
442
448
  }
443
449
  });
@@ -468,11 +474,11 @@ export async function apply(ctx) {
468
474
  output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
469
475
  isConcurrencySafe: () => false,
470
476
  async execute(args, exec) {
471
- const submitted = await runAction(dshHost(ctx), 'video', { ...args, session_id: exec.agent?.session?.id }, { signal: exec.signal });
477
+ const submitted = await runAction(dshHost(ctx), 'video', { ...args, session_id: exec.agent?.session?.id }, { signal: exec.signal, core });
472
478
  if (submitted.storage === 'core') {
473
479
  let final = await inspectProviderTaskForDsh(submitted.taskId, { signal: exec.signal });
474
480
  if (!coreTaskWaitFinished(final)) {
475
- final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.video, signal: exec.signal });
481
+ final = await awaitCoreTerminal(core, submitted.taskId, { timeoutMs: AWAIT_MS.video, signal: exec.signal });
476
482
  }
477
483
  if (!final) {
478
484
  return `[iris] Core 视频任务已提交并转入后台观察(task: ${submitted.taskId},remoteTask: ${submitted.remoteTaskId})。\n` +
@@ -518,7 +524,7 @@ export async function apply(ctx) {
518
524
  output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
519
525
  isConcurrencySafe: () => true,
520
526
  async execute(args, exec) {
521
- const submitted = await runAction(dshHost(ctx), 'music', args, { signal: exec.signal });
527
+ const submitted = await runAction(dshHost(ctx), 'music', args, { signal: exec.signal, core });
522
528
  return submitted.text + '\nartifact: ' + submitted.artifactIds.join(', ') + '\n' +
523
529
  submitted.artifactIds.map(id => '[♪ 音乐播放](/iris/api/core/artifact/' + encodeURIComponent(id) + '/media)').join('\n');
524
530
  }
@@ -540,7 +546,7 @@ export async function apply(ctx) {
540
546
  output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
541
547
  isConcurrencySafe: () => true,
542
548
  async execute(args, exec) {
543
- const submitted = await runAction(dshHost(ctx), 'tts', args, { signal: exec.signal });
549
+ const submitted = await runAction(dshHost(ctx), 'tts', args, { signal: exec.signal, core });
544
550
  if (submitted.storage === 'core') {
545
551
  const final = await inspectProviderTaskForDsh(submitted.taskId, { signal: exec.signal });
546
552
  const terminalProblem = coreTaskTerminalProblem(final, '语音合成');
@@ -577,11 +583,11 @@ export async function apply(ctx) {
577
583
  output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
578
584
  isConcurrencySafe: () => false,
579
585
  async execute(args, exec) {
580
- const submitted = await runAction(dshHost(ctx), 'transcribe', args, { signal: exec.signal });
586
+ const submitted = await runAction(dshHost(ctx), 'transcribe', args, { signal: exec.signal, core });
581
587
  if (submitted.storage === 'core') {
582
588
  let final = await inspectProviderTaskForDsh(submitted.taskId, { signal: exec.signal });
583
589
  if (!coreTaskWaitFinished(final)) {
584
- final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.transcribe, signal: exec.signal });
590
+ final = await awaitCoreTerminal(core, submitted.taskId, { timeoutMs: AWAIT_MS.transcribe, signal: exec.signal });
585
591
  }
586
592
  if (!final) {
587
593
  return `[iris] Core 转写任务已提交并转入后台观察(task: ${submitted.taskId})。完成后用 iris_task_status 查询进度与 Artifact。`;
@@ -714,8 +720,8 @@ export async function apply(ctx) {
714
720
  try {
715
721
  const audio = await extractAudioTrack({ inputPath: p, signal });
716
722
  try {
717
- const submitted = await runAction(dshHost(ctx), 'transcribe', { audio_path: audio.filePath }, { signal });
718
- const final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: 120000, signal });
723
+ const submitted = await runAction(dshHost(ctx), 'transcribe', { audio_path: audio.filePath }, { signal, core });
724
+ const final = await awaitCoreTerminal(core, submitted.taskId, { timeoutMs: 120000, signal });
719
725
  if (final?.outcome === 'succeeded' && final.deliveryState === 'ready') {
720
726
  const texts = [];
721
727
  for (const artifactId of final.artifactIds) {
@@ -852,7 +858,7 @@ export async function apply(ctx) {
852
858
  output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
853
859
  isConcurrencySafe: () => true,
854
860
  async execute(args) {
855
- const result = await runAction(dshHost(ctx), 'status', args || {});
861
+ const result = await runAction(dshHost(ctx), 'status', args || {}, { core });
856
862
  return result.text;
857
863
  }
858
864
  });
@@ -985,7 +991,7 @@ export async function apply(ctx) {
985
991
  async execute(args, exec) {
986
992
  const r = await runLocateRequest(dshHost(ctx), {
987
993
  providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, onModelAvailability: store.recordAvailability, model: args.model, target: args.target,
988
- prepareImage: signal => resolveVisionImageInput(dshHost(ctx), exec, args, signal), signal: exec.signal
994
+ prepareImage: signal => resolveVisionImageInput(core, dshHost(ctx), exec, args, signal), signal: exec.signal
989
995
  });
990
996
  const { width, height } = r;
991
997
  const sourceNote = args.artifact_id ? `\nCore Artifact: ${args.artifact_id}` : '';
@@ -1074,7 +1080,7 @@ export async function apply(ctx) {
1074
1080
  const host = dshHost(ctx);
1075
1081
  const result = await runOcrRequest(host, {
1076
1082
  providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, onModelAvailability: store.recordAvailability,
1077
- prepareImage: operationSignal => resolveVisionImageInput(host, exec, args, operationSignal),
1083
+ prepareImage: operationSignal => resolveVisionImageInput(core, host, exec, args, operationSignal),
1078
1084
  chunkHeight: args.chunk_height,
1079
1085
  overlap: args.overlap,
1080
1086
  signal: exec.signal
@@ -1133,10 +1139,10 @@ async function resolveImageInput(host, exec, { image_path, attachment_id }) {
1133
1139
  }
1134
1140
 
1135
1141
  /** 视觉解引用在整体预算内;路径限额读,Host 会话/附件等待可取消。 */
1136
- async function resolveVisionImageInput(host, exec, input, signal) {
1142
+ async function resolveVisionImageInput(core, host, exec, input, signal) {
1137
1143
  const source = normalizeVisionImageSource(input, { allowAttachment: true });
1138
1144
  const { image_path, artifact_id, attachment_id } = input;
1139
- if (source === 'artifact_id') return visionImageForDsh({ artifactId: artifact_id, signal });
1145
+ if (source === 'artifact_id') return core.visionImageForDsh({ artifactId: artifact_id, signal });
1140
1146
  if (image_path) {
1141
1147
  const file = String(image_path).trim();
1142
1148
  const mediaType = MEDIA_TYPES[path.extname(file).toLowerCase()];
@@ -0,0 +1,27 @@
1
+ // HTTP DTOs whitelist existing Core facts; never expose records or configuration wholesale.
2
+ const STATUSES = new Set(['not_started', 'running', 'needs_attention', 'delivery_failed',
3
+ 'needs_comparison', 'awaiting_selection', 'selected']);
4
+
5
+ export function projectLocalApiFlow(facts) {
6
+ if (facts.contractVersion !== 0 || facts.run.recipe !== 'image-edit/v0' || !STATUSES.has(facts.status)) {
7
+ throw new TypeError('不支持的流程契约或状态');
8
+ }
9
+ const { run, task } = facts;
10
+ const ref = value => value ? { artifactId: value.artifactId } : null;
11
+ return {
12
+ contractVersion: 0, id: run.id, recipe: run.recipe, revision: run.revision,
13
+ createdAt: run.createdAt, updatedAt: run.updatedAt, status: facts.status,
14
+ input: { prompt: run.input.prompt, modelRef: run.input.model_ref,
15
+ ...(run.input.size !== undefined ? { size: run.input.size } : {}) },
16
+ source: ref(run.source), output: ref(run.output), comparison: ref(run.comparison), selection: ref(run.selection),
17
+ task: task ? { id: task.id, acceptance: task.acceptance, outcome: task.outcome,
18
+ deliveryState: task.deliveryState, attemptCount: task.attempts.length } : null,
19
+ metrics: facts.metrics ? Object.fromEntries(['ratio', 'diffPixels', 'totalPixels', 'width', 'height']
20
+ .map(key => [key, facts.metrics[key]])) : null
21
+ };
22
+ }
23
+
24
+ export function projectLocalApiRunSummary(run) {
25
+ return { id: run.id, recipe: run.recipe, createdAt: run.createdAt,
26
+ modelRef: run.modelRef, source: { artifactId: run.source.artifactId } };
27
+ }