@k2b/cloud 0.26.0 → 0.27.0

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 (76) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/define-app.ts +8 -1
  3. package/src/_internal/process-identity.ts +7 -1
  4. package/src/_internal/registry-validation.ts +3 -0
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/ai/admin.ts +1 -0
  7. package/src/ai/browser-code-contracts.ts +14 -2
  8. package/src/ai/browser.ts +8 -1
  9. package/src/ai/capabilities.ts +82 -22
  10. package/src/ai/chat/builtin-tools.tsx +31 -23
  11. package/src/ai/chat/file-tools.tsx +4 -1
  12. package/src/ai/chat/live-turn.browser-harness.tsx +11 -4
  13. package/src/ai/chat/messages.ts +8 -2
  14. package/src/ai/chat/presentation.tsx +29 -9
  15. package/src/ai/chat/turn-view.tsx +47 -12
  16. package/src/ai/client/controller.ts +60 -31
  17. package/src/ai/client/file-source.ts +20 -3
  18. package/src/ai/client/projection.ts +7 -2
  19. package/src/ai/code-mode-skill.ts +27 -27
  20. package/src/ai/code-runtime-tools.ts +10 -1
  21. package/src/ai/code-source-contracts.ts +54 -4
  22. package/src/ai/code-source-tools.ts +10 -3
  23. package/src/ai/credentials.ts +17 -3
  24. package/src/ai/data-analysis-skill.ts +2 -2
  25. package/src/ai/default-tools.ts +2 -2
  26. package/src/ai/executor.ts +148 -84
  27. package/src/ai/file-context.ts +14 -2
  28. package/src/ai/file-tools.ts +17 -3
  29. package/src/ai/files-store.ts +134 -11
  30. package/src/ai/grids-skill.ts +2 -2
  31. package/src/ai/index.ts +7 -0
  32. package/src/ai/memories.ts +14 -0
  33. package/src/ai/migrate.ts +125 -0
  34. package/src/ai/model-request-settings.ts +98 -0
  35. package/src/ai/protocol.ts +6 -0
  36. package/src/ai/provider.ts +7 -1
  37. package/src/ai/quota-provider.ts +2 -2
  38. package/src/ai/request-headers.ts +117 -0
  39. package/src/ai/routes.ts +34 -6
  40. package/src/ai/runtime.ts +1 -1
  41. package/src/ai/settings.ts +19 -2
  42. package/src/ai/skill-seeds.ts +31 -3
  43. package/src/ai/skills.ts +26 -0
  44. package/src/ai/solid.ts +1 -1
  45. package/src/ai/store.ts +109 -57
  46. package/src/ai/stream.ts +180 -37
  47. package/src/ai/structured.ts +20 -5
  48. package/src/ai/system-prompt.ts +25 -0
  49. package/src/ai/tool-call-names.ts +45 -0
  50. package/src/ai/turn-policy.ts +247 -0
  51. package/src/ai/types.ts +24 -3
  52. package/src/api/admin-ai-quotas.ts +36 -1
  53. package/src/api/admin-core-settings.ts +16 -23
  54. package/src/api/admin-outgoing-mail.ts +62 -0
  55. package/src/api/index.ts +2 -0
  56. package/src/cli/admin/ai-quotas.ts +70 -1
  57. package/src/cli/admin/index.ts +6 -0
  58. package/src/cli/admin/outgoing-mail.ts +118 -0
  59. package/src/contracts/app.ts +2 -0
  60. package/src/contracts/index.ts +1 -0
  61. package/src/contracts/outgoing-mail.ts +77 -0
  62. package/src/contracts/registry.ts +2 -0
  63. package/src/services/index.ts +3 -0
  64. package/src/services/notifications/email.ts +16 -26
  65. package/src/services/outgoing-mail/index.ts +19 -0
  66. package/src/services/outgoing-mail/store.ts +286 -0
  67. package/src/services/outgoing-mail/test-send.ts +40 -0
  68. package/src/services/outgoing-mail/transport.ts +13 -0
  69. package/src/services/settings/core-settings.ts +1 -38
  70. package/src/services/settings/store.ts +5 -1
  71. package/src/shared/ai-model-request-settings.ts +21 -0
  72. package/src/shared/ai-platform-prompt.ts +1 -1
  73. package/src/shared/ai-request-options.ts +185 -0
  74. package/src/ssr/admin-navigation.ts +1 -1
  75. package/src/ssr/platform-messages.ts +2 -0
  76. package/src/ssr/workspace-navigation.ts +7 -1
@@ -15,7 +15,7 @@ import {
15
15
  untrack,
16
16
  } from "solid-js";
17
17
  import { markdown } from "../../shared";
18
- import type { AiTurnBlock } from "../protocol";
18
+ import { AI_TURN_LEASE_MS, type AiTurnBlock } from "../protocol";
19
19
  import { ApprovalBlockView, CompactionBlockView, CompactToolRow, SurveyToolView, TextEditorToolView } from "./blocks";
20
20
  import { CapabilityTablePreview } from "./capability-table";
21
21
  import { PresentToolBlock } from "./file-tools";
@@ -47,8 +47,12 @@ export type AiTurnSegment = {
47
47
  duration: () => AiTurnDuration | null;
48
48
  /** Shown above the work of a delivered scheduled result. */
49
49
  scheduledTask?: { taskId: string; occurrenceId: string } | null;
50
- /** A model call of the live turn waits for its retry. Set on the turn's last segment, so the wait ends the turn. */
51
- retrying?: boolean;
50
+ /**
51
+ * The live turn cannot reach the model or the server right now: a model call waits for its retry, or the stream
52
+ * reconnects. The working segment's work line says so while its clock stands; a turn without a work line yet says
53
+ * it in a row at the end of its last segment.
54
+ */
55
+ reconnect?: "line" | "row";
52
56
  };
53
57
 
54
58
  const isLive = (phase: AiTurnPhase) => phase === "running" || phase === "waiting";
@@ -63,6 +67,10 @@ export const formatWorkDuration = (ms: number, t: Messages, long = false): strin
63
67
  return long ? t.durationHoursLong({ hours, minutes: minutes % 60 }) : t.durationHours({ hours, minutes: minutes % 60 });
64
68
  };
65
69
 
70
+ /** Identity of the current step: it changes when another block becomes current or the current call changes state. */
71
+ const stepKey = (block: AiTurnBlock | null): string =>
72
+ !block ? "start" : `${block.id}:${block.kind === "tool" || block.kind === "compaction" ? block.status : block.kind}`;
73
+
66
74
  /** A running clock: "2:41", "1:02:41". */
67
75
  export const formatWorkClock = (ms: number): string => {
68
76
  const total = Math.max(0, Math.floor(ms / 1000));
@@ -80,14 +88,25 @@ const toolTarget = (block: ToolBlock): string => {
80
88
  return typeof value === "string" ? basename(value.trim()) : "";
81
89
  };
82
90
 
83
- /** The current step of a live turn, as a short progressive phrase. */
84
- export const liveStepLabel = (block: AiTurnBlock | null, t: Messages): string => {
91
+ /** A step that runs longer than this shows its duration, so a long step does not look like a stalled one. */
92
+ export const AI_LONG_STEP_MS = AI_TURN_LEASE_MS;
93
+
94
+ /**
95
+ * The current step of a live turn, as a short progressive phrase. A step that runs long shows its duration in place of
96
+ * its target: "Running code · 3 min".
97
+ */
98
+ export const liveStepLabel = (block: AiTurnBlock | null, t: Messages, stepMs = 0): string => {
99
+ if (stepMs < AI_LONG_STEP_MS) return stepPhrase(block, t, true);
100
+ return `${stepPhrase(block, t, false)} · ${formatWorkDuration(stepMs, t)}`;
101
+ };
102
+
103
+ const stepPhrase = (block: AiTurnBlock | null, t: Messages, withTargets: boolean): string => {
85
104
  if (!block || block.kind === "thinking" || block.kind === "steer_applied" || block.kind === "steer_message") return t.stepThinking;
86
105
  if (block.kind === "text") return t.stepWriting;
87
106
  if (block.kind === "compaction") return block.status === "running" ? t.stepCompacting : t.stepThinking;
88
107
  // A finished call means the model is deciding what to do next.
89
108
  if (block.status !== "running" && block.status !== "awaiting_client") return t.stepThinking;
90
- const target = toolTarget(block);
109
+ const target = withTargets ? toolTarget(block) : "";
91
110
  const withTarget = (label: string) => (target ? `${label} · ${target}` : label);
92
111
  const name = block.name;
93
112
  if (block.presentation?.kind === "capability") return withTarget(t.stepUsingApp({ app: block.presentation.appName }));
@@ -117,15 +136,31 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
117
136
  const t = () => aiChatMessages(locale());
118
137
  const segment = () => props.segment();
119
138
  const layout = () => segment().layout;
120
- const waiting = () => segment().phase === "waiting" && layout().waitingFor !== null;
121
- const working = () => isLive(segment().phase) && !waiting();
139
+ const reconnecting = () => isLive(segment().phase) && segment().reconnect === "line";
140
+ const waiting = () => !reconnecting() && segment().phase === "waiting" && layout().waitingFor !== null;
141
+ const working = () => isLive(segment().phase) && !waiting() && !reconnecting();
142
+ // The current step is timed on the work clock, so it stands while the turn reconnects.
143
+ const currentStep = createMemo(() => (isLive(segment().phase) ? stepKey(layout().current) : null));
144
+ const step = createMemo(on(currentStep, (key) => (key === null ? null : (untrack(segment().duration)?.workedMs ?? 0))));
145
+ const stepMs = () => {
146
+ const startedAt = step();
147
+ const workedMs = segment().duration()?.workedMs;
148
+ return startedAt === null || workedMs === undefined ? 0 : workedMs - startedAt;
149
+ };
150
+ // Screen readers hear once that the turn reconnects; the line itself is not read while it changes.
151
+ createEffect(
152
+ on(reconnecting, (now, before) => {
153
+ if (now && !before) announce(t().reconnecting);
154
+ }),
155
+ );
122
156
  const worked = () => {
123
157
  const duration = segment().duration();
124
158
  return !segment().earlier && duration && duration.workedMs > 0 ? duration.workedMs : null;
125
159
  };
126
160
  const steps = () => t().steps({ count: layout().steps });
127
161
  const label = () => {
128
- if (working()) return liveStepLabel(layout().current, t());
162
+ if (reconnecting()) return t().reconnecting;
163
+ if (working()) return liveStepLabel(layout().current, t(), stepMs());
129
164
  if (waiting()) return layout().waitingFor === "approval" ? t().waitingForApproval : t().waitingForAnswer;
130
165
  const duration = worked();
131
166
  const short = duration === null ? "" : formatWorkDuration(duration, t());
@@ -145,7 +180,7 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
145
180
  };
146
181
  const trailing = () => {
147
182
  const duration = segment().duration();
148
- if (working() && duration) {
183
+ if ((working() || reconnecting()) && duration) {
149
184
  return (
150
185
  <span class="ai-turn-work__meta">
151
186
  {formatWorkClock(duration.workedMs)}
@@ -599,8 +634,8 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
599
634
  <For each={actionIds()}>
600
635
  {(id) => <AiTurnActionView action={() => actions().get(id)} turnId={props.segment().turnId} phase={props.segment().phase} />}
601
636
  </For>
602
- <Show when={props.segment().retrying}>
603
- {/* Calm by design: no busy sweep while the model call waits for its retry. */}
637
+ <Show when={props.segment().reconnect === "row"}>
638
+ {/* Calm by design: no busy sweep while the turn reconnects. */}
604
639
  <Chat.Activity label={aiChatMessages(locale()).reconnecting} icon="ti ti-refresh" />
605
640
  </Show>
606
641
  </div>
@@ -2,7 +2,7 @@ import { type Accessor, createMemo, createSignal, onCleanup } from "solid-js";
2
2
  import { createStore, reconcile } from "solid-js/store";
3
3
  import { type AiAttachmentRef, aiAttachmentMarker } from "../attachments";
4
4
  import { aiChatMessages } from "../chat/messages";
5
- import { type AiStreamEvent, type AiTurnBlock, type AiTurnSnapshot, steerMessageBlockId } from "../protocol";
5
+ import { AI_TURN_LEASE_MS, type AiStreamEvent, type AiTurnBlock, type AiTurnSnapshot, steerMessageBlockId } from "../protocol";
6
6
  import { type AiResourceMarker, aiResourceMarker } from "../resource-markers";
7
7
  import type {
8
8
  AiClientToolId,
@@ -201,6 +201,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
201
201
  const abortRequests = new Map<string, Promise<boolean>>();
202
202
  let stream: AiStreamHandle | null = null;
203
203
  let streamSession: AiStreamSession | null = null;
204
+ let silenceTimer: ReturnType<typeof setTimeout> | undefined;
204
205
  /** The chat error left by a stream that ended for good, until a stream opens again. */
205
206
  let streamError: string | null = null;
206
207
  let streamGeneration = 0;
@@ -294,8 +295,32 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
294
295
  void markConversationViewed(session.conversationId);
295
296
  };
296
297
 
298
+ const clearSilenceTimer = () => {
299
+ if (silenceTimer) clearTimeout(silenceTimer);
300
+ silenceTimer = undefined;
301
+ };
302
+
303
+ /**
304
+ * A running turn whose open stream delivers nothing for one worker lease either works silently or lost an update,
305
+ * such as its end. The stream then starts again from the turn's saved state. The wait starts with the stream's
306
+ * first event, so a slow first state is never cut off. A turn that waits for an action in this browser is not
307
+ * silent, and a stream that reconnects brings a fresh state anyway.
308
+ */
309
+ const watchSilence = (session: AiStreamSession) => {
310
+ clearSilenceTimer();
311
+ silenceTimer = setTimeout(() => {
312
+ silenceTimer = undefined;
313
+ if (!isCurrentStreamSession(streamSession, session)) return;
314
+ const status = streamStatus();
315
+ if (state.activeTurn?.status !== "running" || status === "connecting" || status === "reconnecting") return watchSilence(session);
316
+ closeStream();
317
+ openStream(session.conversationId);
318
+ }, AI_TURN_LEASE_MS);
319
+ };
320
+
297
321
  const applyEvent = (session: AiStreamSession, event: AiStreamEvent) => {
298
322
  if (!isCurrentStreamSession(streamSession, session)) return;
323
+ watchSilence(session);
299
324
  const conversationId = session.conversationId;
300
325
  if (event.type === "turn_finished" && pendingSends()[conversationId]) {
301
326
  completedDuringSend.set(conversationId, event.turnId);
@@ -327,6 +352,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
327
352
  };
328
353
 
329
354
  const closeStream = () => {
355
+ clearSilenceTimer();
330
356
  stream?.close();
331
357
  stream = null;
332
358
  streamSession = null;
@@ -400,15 +426,15 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
400
426
  console.warn(`No frontend handler registered for AI tool "${name}" — leaving the action request pending.`);
401
427
  return false;
402
428
  }
429
+ let result: unknown;
403
430
  try {
404
- const result = await handler({ name, callId, args, turnId, conversationId });
405
- return submitTurnActionForConversation(conversationId, turnId, callId, { type: "tool_result", result });
431
+ result = await handler({ name, callId, args, turnId, conversationId });
406
432
  } catch (toolError) {
407
- return submitTurnActionForConversation(conversationId, turnId, callId, {
408
- type: "tool_result",
409
- result: { error: toolError instanceof Error ? toolError.message : "Frontend tool failed" },
410
- });
433
+ result = { error: toolError instanceof Error ? toolError.message : "Frontend tool failed" };
411
434
  }
435
+ const submitted = await submitTurnActionForConversation(conversationId, turnId, callId, { type: "tool_result", result });
436
+ if (submitted) showActionResolved(conversationId, turnId, { type: "tool_result", callId, result });
437
+ return submitted;
412
438
  };
413
439
 
414
440
  // ---- conversation loading --------------------------------------------
@@ -1258,6 +1284,28 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1258
1284
  }
1259
1285
  };
1260
1286
 
1287
+ /**
1288
+ * Shows an action the server accepted as resolved, so the turn runs on before the stream confirms it. A turn that
1289
+ * runs is watched for silence again, which heals a confirmation that never arrives.
1290
+ */
1291
+ const showActionResolved = (
1292
+ conversationId: string,
1293
+ turnId: string,
1294
+ action: { type: "approval_response"; callId: string; approved: boolean } | { type: "tool_result"; callId: string; result: unknown },
1295
+ ) => {
1296
+ if (!isActiveConversation(conversationId) || state.activeTurn?.turnId !== turnId) return;
1297
+ const waitingStatus = action.type === "approval_response" ? "awaiting_approval" : "awaiting_client";
1298
+ setState("activeTurn", (current) => {
1299
+ if (!current || current.turnId !== turnId) return current;
1300
+ const pending = current.blocks.some(
1301
+ (block) => block.kind === "tool" && block.callId === action.callId && block.status === waitingStatus,
1302
+ );
1303
+ if (!pending) return current;
1304
+ return reconcileActiveTurnActions(current, [{ callId: action.callId, resolvedEvent: action }]);
1305
+ });
1306
+ cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
1307
+ };
1308
+
1261
1309
  const submitTurnAction = (
1262
1310
  turnId: string,
1263
1311
  callId: string,
@@ -1277,18 +1325,9 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1277
1325
  approved: input.approved,
1278
1326
  remember: input.remember,
1279
1327
  });
1280
- if (!submitted || !isActiveConversation(conversationId) || state.activeTurn?.turnId !== request.turnId) return submitted;
1281
- const action = { type: "approval_response" as const, callId: request.callId, approved: input.approved };
1282
- setState("activeTurn", (current) => {
1283
- if (!current || current.turnId !== request.turnId) return current;
1284
- const pending = current.blocks.some(
1285
- (block) => block.kind === "tool" && block.callId === request.callId && block.status === "awaiting_approval",
1286
- );
1287
- if (!pending) return current;
1288
- return reconcileActiveTurnActions(current, [{ callId: request.callId, resolvedEvent: action }]);
1289
- });
1290
- cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
1291
- return true;
1328
+ if (submitted)
1329
+ showActionResolved(conversationId, request.turnId, { type: "approval_response", callId: request.callId, approved: input.approved });
1330
+ return submitted;
1292
1331
  };
1293
1332
 
1294
1333
  const submitFrontendToolResult = async (request: { turnId: string; callId: string }, result: unknown) => {
@@ -1299,18 +1338,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1299
1338
  type: "tool_result",
1300
1339
  result,
1301
1340
  });
1302
- if (!submitted || !isActiveConversation(conversationId) || state.activeTurn?.turnId !== request.turnId) return submitted;
1303
- const action = { type: "tool_result" as const, callId: request.callId, result };
1304
- setState("activeTurn", (current) => {
1305
- if (!current || current.turnId !== request.turnId) return current;
1306
- const pending = current.blocks.some(
1307
- (block) => block.kind === "tool" && block.callId === request.callId && block.status === "awaiting_client",
1308
- );
1309
- if (!pending) return current;
1310
- return reconcileActiveTurnActions(current, [{ callId: request.callId, resolvedEvent: action }]);
1311
- });
1312
- cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
1313
- return true;
1341
+ if (submitted) showActionResolved(conversationId, request.turnId, { type: "tool_result", callId: request.callId, result });
1342
+ return submitted;
1314
1343
  };
1315
1344
 
1316
1345
  if (options.initialConversationId) {
@@ -30,7 +30,14 @@ const bytesToBase64 = (bytes: Uint8Array): string => {
30
30
  return btoa(binary);
31
31
  };
32
32
 
33
- export const conversationFileSource = (baseUrl: string, conversationId: string): FileSource & { listFiles(): Promise<AiFileStat[]> } => {
33
+ export type ConversationFileSource = FileSource & {
34
+ /** Files below `prefix` (default `/`); with `page`, at most `page.limit` files by path after `page.after`. */
35
+ listFiles(prefix?: string, page?: { limit: number; after?: string }): Promise<AiFileStat[]>;
36
+ /** Delete a folder with everything below it. */
37
+ removeFolder(path: string): Promise<void>;
38
+ };
39
+
40
+ export const conversationFileSource = (baseUrl: string, conversationId: string): ConversationFileSource => {
34
41
  const filesUrl = (suffix = "", params?: Record<string, string>) => {
35
42
  const query = params ? `?${new URLSearchParams(params).toString()}` : "";
36
43
  return `${baseUrl}/conversations/${conversationId}/files${suffix}${query}`;
@@ -45,8 +52,14 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
45
52
  return (await response.json()) as T;
46
53
  };
47
54
 
48
- const listFiles = async (): Promise<AiFileStat[]> =>
49
- (await request<{ files: AiFileStat[] }>(filesUrl(), { method: "GET" }, "Failed to load files")).files;
55
+ const listFiles = async (prefix?: string, page?: { limit: number; after?: string }): Promise<AiFileStat[]> => {
56
+ const params: Record<string, string> = {
57
+ ...(prefix ? { prefix } : {}),
58
+ ...(page ? { limit: String(page.limit), ...(page.after ? { after: page.after } : {}) } : {}),
59
+ };
60
+ const url = Object.keys(params).length ? filesUrl("", params) : filesUrl();
61
+ return (await request<{ files: AiFileStat[] }>(url, { method: "GET" }, "Failed to load files")).files;
62
+ };
50
63
 
51
64
  return {
52
65
  previewPreferencesKey: "assistant.csv-preview",
@@ -80,6 +93,10 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
80
93
  await request(filesUrl("", { path }), { method: "DELETE" }, "Failed to delete file");
81
94
  },
82
95
 
96
+ async removeFolder(path) {
97
+ await request(filesUrl("", { path, recursive: "true" }), { method: "DELETE" }, "Failed to delete folder");
98
+ },
99
+
83
100
  async rename(from, to) {
84
101
  await request(filesUrl("/rename"), { method: "POST", body: JSON.stringify({ from, to }) }, "Failed to rename file");
85
102
  },
@@ -9,7 +9,10 @@ export type AiActiveTurn = {
9
9
  status: "running" | "waiting_for_action";
10
10
  blocks: AiTurnBlock[];
11
11
  modelProfileId: string | null;
12
- /** A model call failed transiently and waits for its retry. Live only: the next turn event or a snapshot ends it. */
12
+ /**
13
+ * A model call failed transiently and waits for its retry. Live only: the next turn event ends it, and so does a
14
+ * snapshot that has moved past the retry.
15
+ */
13
16
  providerRetry?: boolean;
14
17
  /** Server start of the turn, known from a state snapshot. */
15
18
  createdAt?: string;
@@ -98,7 +101,9 @@ export const mergeActiveTurn = (previous: AiActiveTurn | null, incoming: AiActiv
98
101
  const pendingSteers = previous.blocks.filter((block) => block.kind === "steer_message" && block.status !== "consumed");
99
102
  const known = new Set(incoming.blocks.map((block) => block.id));
100
103
  const blocks = [...incoming.blocks, ...pendingSteers.filter((block) => !known.has(block.id))];
101
- return { ...turnTiming(previous), ...incoming, blocks, status: deriveStatus(blocks) };
104
+ // A snapshot never carries a provider retry. One at the retry's own position has seen nothing after it, so the wait goes on.
105
+ const retry = previous.providerRetry && incoming.seq === previous.seq ? { providerRetry: true } : {};
106
+ return { ...turnTiming(previous), ...incoming, ...retry, blocks, status: deriveStatus(blocks) };
102
107
  };
103
108
 
104
109
  export const reconcileActiveTurnActions = (