@alexeiled/pi-fusion 0.6.2 → 0.8.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.
- package/README.md +10 -7
- package/agents/fusion-composer.md +3 -0
- package/agents/fusion-judge.md +3 -0
- package/agents/fusion-panelist.md +3 -0
- package/docs/user-guide.md +32 -11
- package/package.json +1 -1
- package/skills/fusion-review/SKILL.md +2 -1
- package/src/caller-contract.ts +80 -0
- package/src/config.ts +58 -2
- package/src/fusion-args.ts +29 -2
- package/src/fusion-rpc.ts +88 -13
- package/src/index.ts +33 -5
- package/src/lifecycle-reconcile.ts +445 -0
- package/src/orchestrator.ts +562 -129
- package/src/panel-completion.ts +105 -7
- package/src/panel-quorum.ts +22 -0
- package/src/report.ts +105 -3
- package/src/result-extract.ts +84 -7
- package/src/run-builder.ts +141 -15
- package/src/run-store.ts +377 -11
- package/src/status.ts +1 -1
- package/src/types.ts +89 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import type { FailedPanelSummary, PanelOutput } from "./types.js";
|
|
2
|
+
import { mergeRunObservations } from "./run-observations.js";
|
|
3
|
+
import {
|
|
4
|
+
extractPanelResults,
|
|
5
|
+
type ExtractPanelResultsResult,
|
|
6
|
+
type ExtractPanelResultsSuccess,
|
|
7
|
+
} from "./result-extract.js";
|
|
8
|
+
import type { FusionProfile } from "./types.js";
|
|
9
|
+
import { isRecord } from "./utils.js";
|
|
10
|
+
|
|
11
|
+
export function reconcileIndexedLifecycleResult(
|
|
12
|
+
eventPayload: unknown,
|
|
13
|
+
statusPayload: unknown,
|
|
14
|
+
index: number,
|
|
15
|
+
label: "judge" | "panel",
|
|
16
|
+
): string | undefined {
|
|
17
|
+
const eventResults = findLifecycleArray(eventPayload, "results");
|
|
18
|
+
const eventResult = eventResults?.[index];
|
|
19
|
+
if (!isRecord(eventResult)) return undefined;
|
|
20
|
+
|
|
21
|
+
const statusSteps = findLifecycleArray(statusPayload, "steps");
|
|
22
|
+
const rawStatusResults = findLifecycleArray(statusPayload, "results");
|
|
23
|
+
const statusResults = authoritativeStatusLifecycleArray(
|
|
24
|
+
statusPayload,
|
|
25
|
+
statusSteps,
|
|
26
|
+
rawStatusResults,
|
|
27
|
+
);
|
|
28
|
+
const statusResult = statusResults?.[index];
|
|
29
|
+
if (statusResults && !isRecord(statusResult)) {
|
|
30
|
+
return `Subagents event includes ${label} result ${index + 1}, but status does not.`;
|
|
31
|
+
}
|
|
32
|
+
if (!isRecord(statusResult)) return undefined;
|
|
33
|
+
if (isFailedLifecycleResult(eventResult) === isFailedLifecycleResult(statusResult)) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return `Subagents event and status disagree about ${label} result ${index + 1}.`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ReconcilePanelResultsOptions {
|
|
40
|
+
/** Extra terminal result allowed after the configured panel (the judge). */
|
|
41
|
+
allowedTrailingResults?: number;
|
|
42
|
+
/** Indices intentionally absent from status after early agreement. */
|
|
43
|
+
stoppedPanelIndices?: readonly number[];
|
|
44
|
+
/** A workflow deadline terminalized running child slots as timeout failures. */
|
|
45
|
+
terminalizeRunning?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Reconciles compact completion events with the richer lifecycle snapshot.
|
|
50
|
+
* Status is authoritative when it describes every configured panel member.
|
|
51
|
+
* Incomplete or contradictory lifecycle data fails closed.
|
|
52
|
+
*/
|
|
53
|
+
export function reconcilePanelResults(
|
|
54
|
+
eventResults: ExtractPanelResultsSuccess,
|
|
55
|
+
statusPayload: unknown,
|
|
56
|
+
profile: FusionProfile,
|
|
57
|
+
resultPayload: unknown,
|
|
58
|
+
options: ReconcilePanelResultsOptions = {},
|
|
59
|
+
): ExtractPanelResultsResult {
|
|
60
|
+
const expectedCount = profile.panel.length;
|
|
61
|
+
const allowedTrailing = options.allowedTrailingResults ?? 0;
|
|
62
|
+
const rawEvent = findLifecycleArray(resultPayload, "results");
|
|
63
|
+
if (rawEvent && rawEvent.length > expectedCount + allowedTrailing) {
|
|
64
|
+
return error(
|
|
65
|
+
`Terminal subagents data contained ${rawEvent.length} results for ${expectedCount + allowedTrailing} expected workflow steps.`,
|
|
66
|
+
"$.results",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const rawStatusSteps = findLifecycleArray(statusPayload, "steps");
|
|
71
|
+
const rawStatusResults = findLifecycleArray(statusPayload, "results");
|
|
72
|
+
// Preserve an explicit terminal `results: []`: it is authoritative even
|
|
73
|
+
// when it contains no child results. A running empty poll, however, is not
|
|
74
|
+
// a terminal lifecycle assertion and can race a completion event.
|
|
75
|
+
const rawStatus = authoritativeStatusLifecycleArray(
|
|
76
|
+
statusPayload,
|
|
77
|
+
rawStatusSteps,
|
|
78
|
+
rawStatusResults,
|
|
79
|
+
);
|
|
80
|
+
if (!rawStatus) {
|
|
81
|
+
if (eventResults.outputs.length + eventResults.failures.length !== expectedCount) {
|
|
82
|
+
return error(
|
|
83
|
+
`Terminal subagents data described ${eventResults.outputs.length + eventResults.failures.length} of ${expectedCount} configured panel members.`,
|
|
84
|
+
"$.results",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return eventResults;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (rawStatus.length > expectedCount + allowedTrailing) {
|
|
91
|
+
return error(
|
|
92
|
+
`Terminal subagents status contained ${rawStatus.length} steps for ${expectedCount + allowedTrailing} expected workflow steps.`,
|
|
93
|
+
"$.steps",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const statusResults = extractPanelResults(statusPayload, {
|
|
98
|
+
panel: profile.panel,
|
|
99
|
+
...(options.terminalizeRunning
|
|
100
|
+
? { terminalizeRunning: true }
|
|
101
|
+
: { completedOnly: true }),
|
|
102
|
+
limit: expectedCount,
|
|
103
|
+
...(options.stoppedPanelIndices
|
|
104
|
+
? { stoppedPanelIndices: options.stoppedPanelIndices }
|
|
105
|
+
: {}),
|
|
106
|
+
});
|
|
107
|
+
if (!statusResults.ok) {
|
|
108
|
+
return error(
|
|
109
|
+
`${statusResults.error.message} (${statusResults.error.path})`,
|
|
110
|
+
statusResults.error.path,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const statusCount =
|
|
115
|
+
statusResults.outputs.length + statusResults.failures.length;
|
|
116
|
+
if (
|
|
117
|
+
!options.terminalizeRunning &&
|
|
118
|
+
eventResults.outputs.length + eventResults.failures.length === expectedCount
|
|
119
|
+
) {
|
|
120
|
+
const eventSucceeded = new Set(eventResults.outputs.map((item) => item.index));
|
|
121
|
+
const statusSucceeded = new Set(statusResults.outputs.map((item) => item.index));
|
|
122
|
+
for (let index = 0; index < expectedCount; index++) {
|
|
123
|
+
const eventKnown =
|
|
124
|
+
eventSucceeded.has(index) || eventResults.failures.some((item) => item.index === index);
|
|
125
|
+
const statusKnown =
|
|
126
|
+
statusSucceeded.has(index) || statusResults.failures.some((item) => item.index === index);
|
|
127
|
+
if (eventKnown && statusKnown && eventSucceeded.has(index) !== statusSucceeded.has(index)) {
|
|
128
|
+
return error(
|
|
129
|
+
`Subagents event and status disagree about panel result ${index + 1}.`,
|
|
130
|
+
`$.steps[${index}]`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const eventCount =
|
|
136
|
+
eventResults.outputs.length + eventResults.failures.length;
|
|
137
|
+
const statusIndices = new Set([
|
|
138
|
+
...statusResults.outputs.map(({ index }) => index),
|
|
139
|
+
...statusResults.failures.map(({ index }) => index),
|
|
140
|
+
]);
|
|
141
|
+
const stopped = new Set(options.stoppedPanelIndices ?? []);
|
|
142
|
+
const missingStatusIndices = Array.from(
|
|
143
|
+
{ length: expectedCount },
|
|
144
|
+
(_, index) => index,
|
|
145
|
+
).filter((index) => !statusIndices.has(index));
|
|
146
|
+
|
|
147
|
+
if (statusCount === expectedCount) {
|
|
148
|
+
return options.terminalizeRunning
|
|
149
|
+
? mergeTerminalDeadlineResults(
|
|
150
|
+
eventResults,
|
|
151
|
+
statusResults,
|
|
152
|
+
statusPayload,
|
|
153
|
+
resultPayload,
|
|
154
|
+
)
|
|
155
|
+
: preserveAgreementReasons(statusResults, eventResults);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const missingWereStopped =
|
|
159
|
+
eventCount === expectedCount &&
|
|
160
|
+
missingStatusIndices.length > 0 &&
|
|
161
|
+
missingStatusIndices.every((index) => stopped.has(index));
|
|
162
|
+
if (!missingWereStopped) {
|
|
163
|
+
return error(
|
|
164
|
+
`Terminal subagents status described ${statusCount} of ${expectedCount} configured panel members.`,
|
|
165
|
+
"$.steps",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (eventCount !== expectedCount) {
|
|
170
|
+
return error(
|
|
171
|
+
`Terminal subagents data described ${eventCount} of ${expectedCount} configured panel members.`,
|
|
172
|
+
"$.results",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return mergeObservations(eventResults, statusResults);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Status is normally authoritative. At a workflow deadline it can still show
|
|
181
|
+
* a child as running even though the completion artifact contains that child's
|
|
182
|
+
* verified final output. Keep that verified output, normalize only genuinely
|
|
183
|
+
* unfinished slots, and retain status observations/failure details.
|
|
184
|
+
*/
|
|
185
|
+
function mergeTerminalDeadlineResults(
|
|
186
|
+
event: ExtractPanelResultsSuccess,
|
|
187
|
+
status: ExtractPanelResultsSuccess,
|
|
188
|
+
statusPayload: unknown,
|
|
189
|
+
eventPayload: unknown,
|
|
190
|
+
): ExtractPanelResultsSuccess {
|
|
191
|
+
const eventOutputs = new Map(event.outputs.map((item) => [item.index, item]));
|
|
192
|
+
const eventFailures = new Map(event.failures.map((item) => [item.index, item]));
|
|
193
|
+
const statusOutputs = new Map(status.outputs.map((item) => [item.index, item]));
|
|
194
|
+
const statusFailures = new Map(status.failures.map((item) => [item.index, item]));
|
|
195
|
+
const replaceableSlots = deadlineEventReplacementSlots(
|
|
196
|
+
statusPayload,
|
|
197
|
+
eventPayload,
|
|
198
|
+
);
|
|
199
|
+
const outputs: PanelOutput[] = [];
|
|
200
|
+
const failures: FailedPanelSummary[] = [];
|
|
201
|
+
const maxIndex = Math.max(
|
|
202
|
+
...[...eventOutputs.keys(), ...eventFailures.keys(), ...statusOutputs.keys(), ...statusFailures.keys()],
|
|
203
|
+
-1,
|
|
204
|
+
);
|
|
205
|
+
for (let index = 0; index <= maxIndex; index++) {
|
|
206
|
+
const eventOutput = eventOutputs.get(index);
|
|
207
|
+
const eventFailure = eventFailures.get(index);
|
|
208
|
+
const statusOutput = statusOutputs.get(index);
|
|
209
|
+
const statusFailure = statusFailures.get(index);
|
|
210
|
+
|
|
211
|
+
// A completed status slot is authoritative even when a stale compact
|
|
212
|
+
// event reports a failure. Event data can replace only a status slot that
|
|
213
|
+
// deadline handling normalized from a nonterminal state, and only when
|
|
214
|
+
// both records identify the same public panel-N slot.
|
|
215
|
+
if (!replaceableSlots.has(index)) {
|
|
216
|
+
if (statusOutput) outputs.push(statusOutput);
|
|
217
|
+
else if (statusFailure) failures.push(statusFailure);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (eventOutput) {
|
|
222
|
+
outputs.push(withObservation(eventOutput, statusFailure?.observation));
|
|
223
|
+
} else if (eventFailure) {
|
|
224
|
+
failures.push(withObservation(eventFailure, statusFailure?.observation));
|
|
225
|
+
} else if (statusFailure) {
|
|
226
|
+
failures.push(statusFailure);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { ok: true, outputs, failures, ...(event.runId ? { runId: event.runId } : {}) };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Deadline reconciliation never trusts compact-event array order: failed
|
|
234
|
+
* children can be omitted or arrive late. A compact event may replace only a
|
|
235
|
+
* status record terminalized from a nonterminal state when both explicitly
|
|
236
|
+
* name the same public workflow slot.
|
|
237
|
+
*/
|
|
238
|
+
function deadlineEventReplacementSlots(
|
|
239
|
+
statusPayload: unknown,
|
|
240
|
+
eventPayload: unknown,
|
|
241
|
+
): ReadonlySet<number> {
|
|
242
|
+
const statusResults =
|
|
243
|
+
findLifecycleArray(statusPayload, "steps") ??
|
|
244
|
+
findLifecycleArray(statusPayload, "results");
|
|
245
|
+
const eventResults = findLifecycleArray(eventPayload, "results");
|
|
246
|
+
if (!statusResults || !eventResults) return new Set<number>();
|
|
247
|
+
|
|
248
|
+
const nonterminalStatusSlots = new Set<number>();
|
|
249
|
+
for (const result of statusResults) {
|
|
250
|
+
const slot = stablePanelSlot(result);
|
|
251
|
+
if (slot !== undefined && isNonterminalLifecycleResult(result)) {
|
|
252
|
+
nonterminalStatusSlots.add(slot);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const matchingEventSlots = new Set<number>();
|
|
257
|
+
for (const result of eventResults) {
|
|
258
|
+
const slot = stablePanelSlot(result);
|
|
259
|
+
if (slot !== undefined && nonterminalStatusSlots.has(slot)) {
|
|
260
|
+
matchingEventSlots.add(slot);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return matchingEventSlots;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function stablePanelSlot(result: unknown): number | undefined {
|
|
267
|
+
if (!isRecord(result)) return undefined;
|
|
268
|
+
// Result extraction already accepts these lifecycle fields as zero-based
|
|
269
|
+
// public slots. Keep deadline matching exactly aligned; a numeric 1 must
|
|
270
|
+
// mean panel slot 1, never a guessed one-based panel-1.
|
|
271
|
+
for (const candidate of [result.index, result.taskIndex, result.stepIndex]) {
|
|
272
|
+
if (typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 0) {
|
|
273
|
+
return candidate;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const key = firstString(
|
|
277
|
+
result.key,
|
|
278
|
+
result.taskKey,
|
|
279
|
+
result.stepKey,
|
|
280
|
+
result.agent,
|
|
281
|
+
);
|
|
282
|
+
const match = key?.match(/^panel-([1-9]\d*)$/);
|
|
283
|
+
return match ? Number(match[1]) - 1 : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isNonterminalLifecycleResult(result: unknown): boolean {
|
|
287
|
+
if (!isRecord(result)) return false;
|
|
288
|
+
const status = firstString(result.status, result.state);
|
|
289
|
+
return (
|
|
290
|
+
status === "running" ||
|
|
291
|
+
status === "active" ||
|
|
292
|
+
status === "pending" ||
|
|
293
|
+
status === "queued"
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function preserveAgreementReasons(
|
|
298
|
+
status: ExtractPanelResultsSuccess,
|
|
299
|
+
event: ExtractPanelResultsSuccess,
|
|
300
|
+
): ExtractPanelResultsSuccess {
|
|
301
|
+
const eventFailures = new Map(
|
|
302
|
+
event.failures.map((failure) => [failure.index, failure]),
|
|
303
|
+
);
|
|
304
|
+
return {
|
|
305
|
+
...status,
|
|
306
|
+
failures: status.failures.map((failure) => {
|
|
307
|
+
const eventFailure = eventFailures.get(failure.index);
|
|
308
|
+
return eventFailure?.reason === "stopped-after-agreement"
|
|
309
|
+
? { ...failure, reason: eventFailure.reason }
|
|
310
|
+
: failure;
|
|
311
|
+
}),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function mergeObservations(
|
|
316
|
+
event: ExtractPanelResultsSuccess,
|
|
317
|
+
status: ExtractPanelResultsSuccess,
|
|
318
|
+
): ExtractPanelResultsSuccess {
|
|
319
|
+
const observations = new Map<number, PanelOutput["observation"]>();
|
|
320
|
+
for (const output of status.outputs) {
|
|
321
|
+
observations.set(output.index, output.observation);
|
|
322
|
+
}
|
|
323
|
+
for (const failure of status.failures) {
|
|
324
|
+
observations.set(failure.index, failure.observation);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
...event,
|
|
329
|
+
outputs: event.outputs.map((output) =>
|
|
330
|
+
withObservation(output, observations.get(output.index)),
|
|
331
|
+
),
|
|
332
|
+
failures: event.failures.map((failure) =>
|
|
333
|
+
withObservation(failure, observations.get(failure.index)),
|
|
334
|
+
),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function withObservation<T extends PanelOutput | FailedPanelSummary>(
|
|
339
|
+
item: T,
|
|
340
|
+
statusObservation: PanelOutput["observation"] | undefined,
|
|
341
|
+
): T {
|
|
342
|
+
const observation = mergeRunObservations(statusObservation, item.observation);
|
|
343
|
+
return hasObservationData(observation) ? { ...item, observation } : item;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function hasObservationData(
|
|
347
|
+
observation: PanelOutput["observation"] | undefined,
|
|
348
|
+
): boolean {
|
|
349
|
+
return Boolean(
|
|
350
|
+
observation &&
|
|
351
|
+
(observation.model ||
|
|
352
|
+
observation.durationMs !== undefined ||
|
|
353
|
+
observation.usage ||
|
|
354
|
+
observation.attempts ||
|
|
355
|
+
observation.providerFailures),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function findLifecycleArray(
|
|
360
|
+
payload: unknown,
|
|
361
|
+
key: "results" | "steps",
|
|
362
|
+
): readonly unknown[] | undefined {
|
|
363
|
+
if (!isRecord(payload)) return undefined;
|
|
364
|
+
const direct = unknownArray(payload[key]);
|
|
365
|
+
if (direct) return direct;
|
|
366
|
+
if (isRecord(payload.details)) {
|
|
367
|
+
const nested = unknownArray(payload.details[key]);
|
|
368
|
+
if (nested) return nested;
|
|
369
|
+
}
|
|
370
|
+
if (isRecord(payload.data)) return findLifecycleArray(payload.data, key);
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function authoritativeStatusLifecycleArray(
|
|
375
|
+
payload: unknown,
|
|
376
|
+
steps: readonly unknown[] | undefined,
|
|
377
|
+
results: readonly unknown[] | undefined,
|
|
378
|
+
): readonly unknown[] | undefined {
|
|
379
|
+
const lifecycle = steps ?? results;
|
|
380
|
+
if (!lifecycle || lifecycle.length > 0 || isTerminalLifecyclePayload(payload)) {
|
|
381
|
+
return lifecycle;
|
|
382
|
+
}
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function isTerminalLifecyclePayload(payload: unknown): boolean {
|
|
387
|
+
if (!isRecord(payload)) return false;
|
|
388
|
+
const state = firstString(payload.state, payload.status);
|
|
389
|
+
const textState = firstString(payload.text)?.match(
|
|
390
|
+
/(?:^|\n)(?:State|Status):\s*([^\n\r]+)/i,
|
|
391
|
+
)?.[1]?.trim();
|
|
392
|
+
if (
|
|
393
|
+
state === "complete" ||
|
|
394
|
+
textState === "complete" ||
|
|
395
|
+
textState === "completed" ||
|
|
396
|
+
textState === "done" ||
|
|
397
|
+
textState === "failed" ||
|
|
398
|
+
textState === "paused" ||
|
|
399
|
+
textState === "detached" ||
|
|
400
|
+
state === "completed" ||
|
|
401
|
+
state === "done" ||
|
|
402
|
+
state === "failed" ||
|
|
403
|
+
state === "paused" ||
|
|
404
|
+
state === "detached"
|
|
405
|
+
) {
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
if (isRecord(payload.details) && isTerminalLifecyclePayload(payload.details)) {
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
return isRecord(payload.data) && isTerminalLifecyclePayload(payload.data);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function isFailedLifecycleResult(result: Record<string, unknown>): boolean {
|
|
415
|
+
if (result.success === false) return true;
|
|
416
|
+
if (result.timedOut === true || result.interrupted === true) return true;
|
|
417
|
+
if (typeof result.error === "string" && result.error.trim()) return true;
|
|
418
|
+
const status = firstString(result.status, result.state);
|
|
419
|
+
return status === "failed" || status === "paused" || status === "detached";
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function firstString(...values: readonly unknown[]): string | undefined {
|
|
423
|
+
for (const value of values) {
|
|
424
|
+
if (typeof value === "string") return value;
|
|
425
|
+
}
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function unknownArray(value: unknown): readonly unknown[] | undefined {
|
|
430
|
+
return Array.isArray(value) ? value : undefined;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function error(
|
|
434
|
+
message: string,
|
|
435
|
+
path: string,
|
|
436
|
+
): ExtractPanelResultsResult {
|
|
437
|
+
return {
|
|
438
|
+
ok: false,
|
|
439
|
+
error: {
|
|
440
|
+
code: "unknown-result-shape",
|
|
441
|
+
message,
|
|
442
|
+
path,
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|