@inflexa-ai/harness 0.35.5 → 0.35.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 (41) hide show
  1. package/dist/agents/conversation-agent.js +10 -2
  2. package/dist/index.d.ts +6 -0
  3. package/dist/index.js +17 -0
  4. package/dist/lib/metrics.d.ts +55 -4
  5. package/dist/lib/metrics.js +101 -4
  6. package/dist/prompts/briefing.js +6 -3
  7. package/dist/prompts/literature-reviewer.d.ts +1 -1
  8. package/dist/prompts/literature-reviewer.js +16 -8
  9. package/dist/prompts/planner.d.ts +4 -0
  10. package/dist/prompts/planner.js +22 -16
  11. package/dist/sandbox/create-sandbox.js +23 -1
  12. package/dist/sandbox/exec-outcome.d.ts +45 -0
  13. package/dist/sandbox/exec-outcome.js +65 -0
  14. package/dist/sandbox/k8s-client.d.ts +10 -0
  15. package/dist/sandbox/k8s-client.js +40 -5
  16. package/dist/sandbox/liveness.d.ts +3 -1
  17. package/dist/sandbox/types.d.ts +24 -0
  18. package/dist/sandbox/types.js +24 -0
  19. package/dist/tools/ad-hoc-router.d.ts +23 -0
  20. package/dist/tools/ad-hoc-router.js +93 -5
  21. package/dist/tools/bio/alphafold-prediction.d.ts +19 -0
  22. package/dist/tools/bio/alphafold-prediction.js +40 -0
  23. package/dist/tools/bio/index.d.ts +2 -0
  24. package/dist/tools/bio/index.js +3 -0
  25. package/dist/tools/bio/search-protein.d.ts +20 -0
  26. package/dist/tools/bio/search-protein.js +61 -0
  27. package/dist/tools/execute-analysis.d.ts +2 -1
  28. package/dist/tools/execute-analysis.js +66 -5
  29. package/dist/tools/lib/alphafold-client.d.ts +55 -0
  30. package/dist/tools/lib/alphafold-client.js +72 -0
  31. package/dist/tools/lib/api-utils.d.ts +25 -0
  32. package/dist/tools/lib/api-utils.js +45 -3
  33. package/dist/tools/lib/uniprot-client.d.ts +131 -0
  34. package/dist/tools/lib/uniprot-client.js +190 -0
  35. package/dist/tools/research/literature-reviewer.js +2 -0
  36. package/dist/tools/sandbox/list-available-packages.d.ts +25 -0
  37. package/dist/tools/sandbox/list-available-packages.js +51 -29
  38. package/dist/workflows/sandbox-step.js +10 -1
  39. package/dist/workflows/target-assessment/progress-stream.d.ts +75 -0
  40. package/dist/workflows/target-assessment/progress-stream.js +110 -0
  41. package/package.json +1 -1
@@ -105,3 +105,193 @@ export async function getReactomePathwaysByUniProt(accession) {
105
105
  const rec = await getUniProtRecord(accession);
106
106
  return rec?.reactomePathwayIds ?? [];
107
107
  }
108
+ /**
109
+ * The field list of the search. It is separate from `FIELDS` on purpose:
110
+ * `FIELDS` feeds `getUniProtRecord` and the target-assessment dossier, thus a
111
+ * widening of it would change what that workflow reads.
112
+ */
113
+ const SEARCH_FIELDS = ["accession", "id", "protein_name", "gene_names", "length", "cc_function", "cc_subcellular_location"].join(",");
114
+ /** A FUNCTION comment runs to several hundred words; this is what one answer carries. */
115
+ const MAX_FUNCTION_CHARS = 1200;
116
+ /**
117
+ * `entryType` reads `UniProtKB reviewed (Swiss-Prot)` or
118
+ * `UniProtKB unreviewed (TrEMBL)`. The unreviewed value HOLDS the reviewed one
119
+ * as a substring, thus the negative test must run first. A test for `reviewed`
120
+ * alone answers true for every entry of either kind.
121
+ */
122
+ const REVIEWED_ENTRY_TYPE = "reviewed";
123
+ const UNREVIEWED_ENTRY_TYPE = "unreviewed";
124
+ /** Is the entry a curated Swiss-Prot record, as opposed to a machine-annotated TrEMBL one? */
125
+ function isReviewedEntry(entryType) {
126
+ const value = (entryType ?? "").toLowerCase();
127
+ if (value.includes(UNREVIEWED_ENTRY_TYPE))
128
+ return false;
129
+ return value.includes(REVIEWED_ENTRY_TYPE);
130
+ }
131
+ const SearchCommentSchema = z.object({
132
+ commentType: z.string().optional(),
133
+ texts: z.array(z.object({ value: z.string().optional() })).optional(),
134
+ subcellularLocations: z.array(z.object({ location: z.object({ value: z.string().optional() }).optional() })).optional(),
135
+ });
136
+ const ProteinNameSchema = z.object({ fullName: z.object({ value: z.string().optional() }).optional() });
137
+ /**
138
+ * One result row of `uniprotkb/search`, with the same all-optional discipline as
139
+ * `UniProtRecordSchema`: UniProt omits the key of an absent value, thus every
140
+ * field carries `.optional()` and none carries `.nullable()`.
141
+ *
142
+ * A TrEMBL row can carry `submissionNames` in place of `recommendedName`
143
+ * (`X5D778` is one), thus the name reader falls back rather than reporting the
144
+ * protein as unnamed.
145
+ */
146
+ export const UniProtSearchResultSchema = z.object({
147
+ primaryAccession: z.string().optional(),
148
+ uniProtkbId: z.string().optional(),
149
+ entryType: z.string().optional(),
150
+ proteinDescription: z
151
+ .object({
152
+ recommendedName: ProteinNameSchema.optional(),
153
+ submissionNames: z.array(ProteinNameSchema).optional(),
154
+ })
155
+ .optional(),
156
+ genes: z.array(z.object({ geneName: z.object({ value: z.string().optional() }).optional() })).optional(),
157
+ sequence: z.object({ length: z.number().optional() }).optional(),
158
+ comments: z.array(SearchCommentSchema).optional(),
159
+ });
160
+ /** The search envelope. It is exported so that the golden-fixture table drives it. */
161
+ export const UniProtSearchResponseSchema = z.object({
162
+ results: z.array(UniProtSearchResultSchema).optional(),
163
+ });
164
+ /** Read the protein name, preferring the recommended name over a submitted one. */
165
+ function extractProteinName(raw) {
166
+ const description = raw.proteinDescription;
167
+ const recommended = description?.recommendedName?.fullName?.value?.trim();
168
+ if (recommended)
169
+ return recommended;
170
+ for (const submitted of description?.submissionNames ?? []) {
171
+ const value = submitted.fullName?.value?.trim();
172
+ if (value)
173
+ return value;
174
+ }
175
+ return null;
176
+ }
177
+ /** Read the first FUNCTION comment, capped at {@link MAX_FUNCTION_CHARS}. */
178
+ function extractFunctionText(raw) {
179
+ for (const comment of raw.comments ?? []) {
180
+ if (comment.commentType !== "FUNCTION")
181
+ continue;
182
+ for (const text of comment.texts ?? []) {
183
+ const value = text.value?.trim();
184
+ if (value)
185
+ return value.length > MAX_FUNCTION_CHARS ? `${value.slice(0, MAX_FUNCTION_CHARS)}…` : value;
186
+ }
187
+ }
188
+ return null;
189
+ }
190
+ /**
191
+ * Read the distinct subcellular locations. UniProt splits them over more than
192
+ * one SUBCELLULAR LOCATION comment, and it repeats a location across them, thus
193
+ * the reader collects across every comment and keeps the first occurrence only.
194
+ */
195
+ function extractSubcellularLocations(raw) {
196
+ const locations = [];
197
+ const seen = new Set();
198
+ for (const comment of raw.comments ?? []) {
199
+ if (comment.commentType !== "SUBCELLULAR LOCATION")
200
+ continue;
201
+ for (const entry of comment.subcellularLocations ?? []) {
202
+ const value = entry.location?.value?.trim();
203
+ if (!value || seen.has(value))
204
+ continue;
205
+ seen.add(value);
206
+ locations.push(value);
207
+ }
208
+ }
209
+ return locations;
210
+ }
211
+ function toProtein(raw) {
212
+ return {
213
+ accession: raw.primaryAccession ?? "",
214
+ uniProtkbId: raw.uniProtkbId ?? null,
215
+ proteinName: extractProteinName(raw),
216
+ geneNames: (raw.genes ?? []).map((gene) => gene.geneName?.value).filter((name) => Boolean(name)),
217
+ sequenceLength: raw.sequence?.length ?? null,
218
+ function: extractFunctionText(raw),
219
+ subcellularLocations: extractSubcellularLocations(raw),
220
+ reviewed: isReviewedEntry(raw.entryType),
221
+ };
222
+ }
223
+ /**
224
+ * The accession forms that UniProt itself documents: the six-character form and
225
+ * the ten-character form, with an optional isoform suffix.
226
+ *
227
+ * The shape does NOT tell an accession from a gene symbol, because the two
228
+ * spaces overlap: `P2RY12`, `B3GAT1`, and their siblings are real gene symbols
229
+ * that match this form. Thus the match decides only whether the `accession:`
230
+ * clause is SAFE to send, never which space the query names.
231
+ */
232
+ const ACCESSION_RE = /^(?:[OPQ][0-9][A-Z0-9]{3}[0-9]|[A-NR-Z][0-9](?:[A-Z][A-Z0-9]{2}[0-9]){1,2})(?:-\d+)?$/i;
233
+ /**
234
+ * Can the query ride in an `accession:` clause?
235
+ *
236
+ * UniProt validates the value of that clause, and it answers HTTP 400 for a
237
+ * value that is not accession-shaped. Thus a plain symbol such as `BRCA1` must
238
+ * never reach one, because the 400 fails the whole request.
239
+ */
240
+ export function isUniProtAccession(query) {
241
+ return ACCESSION_RE.test(query.trim());
242
+ }
243
+ /**
244
+ * Build the UniProtKB query.
245
+ *
246
+ * The organism filter and the reviewed filter narrow an ambiguous SYMBOL
247
+ * search. An accession is a unique key, thus the filters must not reach it: a
248
+ * lookup of `P02769` under the human default would otherwise answer nothing,
249
+ * although the accession names bovine serum albumin.
250
+ *
251
+ * An accession-shaped input is ambiguous, because the two identifier spaces
252
+ * overlap. Such an input searches both, and the unfiltered accession clause is
253
+ * OR-ed with the filtered symbol clause. An input that is not
254
+ * accession-shaped searches the symbol space alone.
255
+ *
256
+ * The accession side carries `active:true`, and that is the one filter it
257
+ * takes. A deleted accession still answers an `accession:` query, as an
258
+ * `Inactive` row with no name and no function, and `UniProtProtein` has no
259
+ * field for the deletion reason. `B3GAT1` is such a case: the gene symbol also
260
+ * names a deleted TrEMBL accession, thus without the filter the answer holds
261
+ * the human protein and a hollow second row.
262
+ */
263
+ function buildSearchQuery(query, organismId, reviewedOnly) {
264
+ const symbolClauses = [`gene_exact:${query}`];
265
+ if (organismId !== undefined)
266
+ symbolClauses.push(`organism_id:${organismId}`);
267
+ if (reviewedOnly)
268
+ symbolClauses.push("reviewed:true");
269
+ const symbolQuery = symbolClauses.join(" AND ");
270
+ if (!isUniProtAccession(query))
271
+ return symbolQuery;
272
+ return `((accession:${query} AND active:true) OR (${symbolQuery}))`;
273
+ }
274
+ /**
275
+ * Search UniProtKB for a protein by gene symbol or by accession.
276
+ *
277
+ * An unknown query is HTTP 200 with `{"results":[]}`, never a 404, thus the
278
+ * empty result is an ordinary answer and never an error. The request asks for
279
+ * one row beyond `limit`, because UniProt reports its match count in the
280
+ * `x-total-results` header and `apiFetch` exposes no header. The extra row is
281
+ * what separates a trimmed answer from a complete one.
282
+ */
283
+ export async function searchProteins(query, opts = {}) {
284
+ const { organismId, reviewedOnly = true, limit = 10 } = opts;
285
+ const trimmed = query.trim();
286
+ const params = new URLSearchParams({
287
+ query: buildSearchQuery(trimmed, organismId, reviewedOnly),
288
+ fields: SEARCH_FIELDS,
289
+ format: "json",
290
+ size: String(limit + 1),
291
+ });
292
+ const res = await apiFetchValidated(`${UNIPROT_BASE}/uniprotkb/search?${params.toString()}`, UniProtSearchResponseSchema, { headers: UNIPROT_HEADERS });
293
+ if (res.isErr())
294
+ throw new Error(describeApiError(res.error));
295
+ const rows = res.value.results ?? [];
296
+ return { proteins: rows.slice(0, limit).map(toProtein), hasMore: rows.length > limit };
297
+ }
@@ -26,6 +26,7 @@ import { createChemDbTools, createNcbiTools } from "../bio/keys.js";
26
26
  import { genePreclinicalProfileTool } from "../bio/gene-preclinical-profile.js";
27
27
  import { lookupAnnotationTool } from "../bio/lookup-annotation.js";
28
28
  import { searchGeneTool } from "../bio/search-gene.js";
29
+ import { searchProteinTool } from "../bio/search-protein.js";
29
30
  import { searchInteractionsTool } from "../bio/search-interactions.js";
30
31
  import { createResolveCitationTool } from "./resolve-citation.js";
31
32
  /** Sub-agent identity — appended to `callPath`, set as `agentId`. */
@@ -38,6 +39,7 @@ export function createLiteratureReviewerTool(deps) {
38
39
  const chemDb = createChemDbTools(deps.bioKeys, { ...(deps.logger ? { logger: deps.logger } : {}) });
39
40
  const reviewerTools = [
40
41
  searchGeneTool,
42
+ searchProteinTool,
41
43
  lookupAnnotationTool,
42
44
  searchInteractionsTool,
43
45
  ncbi.pubmed,
@@ -116,6 +116,31 @@ export type ListAvailablePackagesDeps = Pick<EnvironmentStorePaths, "farmLockFil
116
116
  */
117
117
  readonly linkToolPresent?: boolean;
118
118
  };
119
+ /**
120
+ * What one inventory read produced: the sections of the bound source, or the
121
+ * fact that the source cannot answer, with the reason where the source gives
122
+ * one. There is no empty-on-error shape — an unreadable inventory must stay
123
+ * distinguishable from an inventory that holds nothing, and a caller that
124
+ * cannot tell them apart reports a present package as absent.
125
+ */
126
+ export type InventoryRead = {
127
+ readonly kind: "sections";
128
+ readonly sections: readonly Section[];
129
+ } | {
130
+ readonly kind: "unavailable";
131
+ readonly reason?: string;
132
+ };
133
+ /**
134
+ * Read the inventory of the bound source: the pool-scope reader when the
135
+ * embedder binds one (a conversation or planning surface), the first readable
136
+ * farm `inflexa.lock` otherwise (a sandbox agent). The image record merges
137
+ * into either report.
138
+ *
139
+ * Every caller that resolves a name against the environment reads through
140
+ * here, thus the census a model sees and the check a launch runs never
141
+ * disagree about what the source holds.
142
+ */
143
+ export declare function readInventorySections(deps: ListAvailablePackagesDeps): Promise<InventoryRead>;
119
144
  /**
120
145
  * Create the package inventory over the bound source: the pool-scope reader
121
146
  * when the embedder binds one (a conversation or planning surface), the
@@ -390,6 +390,50 @@ export function queryPackages(rawSections, { names, query, language, limit }) {
390
390
  const { content, returned } = renderListing(filtered, limit ?? DEFAULT_LIMIT, bothTrackNames(sections));
391
391
  return { available: true, total, returned, hasMore: returned < total, content };
392
392
  }
393
+ /**
394
+ * Read the inventory of the bound source: the pool-scope reader when the
395
+ * embedder binds one (a conversation or planning surface), the first readable
396
+ * farm `inflexa.lock` otherwise (a sandbox agent). The image record merges
397
+ * into either report.
398
+ *
399
+ * Every caller that resolves a name against the environment reads through
400
+ * here, thus the census a model sees and the check a launch runs never
401
+ * disagree about what the source holds.
402
+ */
403
+ export async function readInventorySections(deps) {
404
+ // The image record merges in when the host can read a valid one. An absent
405
+ // record and an invalid record both merge nothing, and neither is an
406
+ // error: the record is an enrichment, and the farm or pool inventory stays
407
+ // whole without it. A store packed before the record existed carries none.
408
+ // There is no logger here, thus an invalid record cannot be reported — the
409
+ // schema of the harness refuses it, and the report degrades to the tracks
410
+ // that it could read.
411
+ const recordSections = readImagePackagesFile(deps.imagePackagesFile ?? DEFAULT_IMAGE_PACKAGES_FILE)
412
+ .map(imageSections)
413
+ .unwrapOr([]);
414
+ if (deps.readPoolInventory) {
415
+ const pool = await deps.readPoolInventory().catch((cause) => ({
416
+ kind: "unavailable",
417
+ reason: cause instanceof Error ? cause.message : String(cause),
418
+ }));
419
+ if (pool.kind === "unavailable")
420
+ return { kind: "unavailable", reason: pool.reason };
421
+ return { kind: "sections", sections: [...pool.sections, ...recordSections] };
422
+ }
423
+ // Both farm container paths are tried when the host injects none, because
424
+ // the path keys on the declared toolchain and this read carries none.
425
+ let lock = null;
426
+ for (const candidate of deps.farmLockFile ? [deps.farmLockFile] : DEFAULT_FARM_LOCK_FILES) {
427
+ lock = readFarmLockFile(candidate).unwrapOr(null);
428
+ if (lock !== null)
429
+ break;
430
+ }
431
+ // A lock that no candidate path holds is an absence with no reason to
432
+ // report: the read never got far enough to learn one.
433
+ if (lock === null)
434
+ return { kind: "unavailable" };
435
+ return { kind: "sections", sections: [...lockSections(lock), ...recordSections] };
436
+ }
393
437
  /**
394
438
  * Create the package inventory over the bound source: the pool-scope reader
395
439
  * when the embedder binds one (a conversation or planning surface), the
@@ -397,8 +441,6 @@ export function queryPackages(rawSections, { names, query, language, limit }) {
397
441
  * record merges into either report.
398
442
  */
399
443
  export function createListAvailablePackagesTool(deps = {}) {
400
- const lockCandidates = deps.farmLockFile ? [deps.farmLockFile] : DEFAULT_FARM_LOCK_FILES;
401
- const imagePackagesFile = deps.imagePackagesFile ?? DEFAULT_IMAGE_PACKAGES_FILE;
402
444
  const readPoolInventory = deps.readPoolInventory;
403
445
  // The scope decides the framing sentence: a sandbox agent reads what it can
404
446
  // import NOW, and a conversation surface reads what the store HOLDS — a
@@ -461,38 +503,18 @@ export function createListAvailablePackagesTool(deps = {}) {
461
503
  return "full package list";
462
504
  },
463
505
  execute: async (input) => {
464
- // The image record merges in when the host can read a valid one.
465
- // An absent record and an invalid record both merge nothing, and
466
- // neither is an error: the record is an enrichment, and the farm
467
- // or pool inventory stays whole without it. A store packed before
468
- // the record existed carries none. The tool holds no logger, thus
469
- // an invalid record cannot be reported here — the schema of the
470
- // harness refuses it, and the report degrades to the tracks it
471
- // could read.
472
- const recordSections = readImagePackagesFile(imagePackagesFile).map(imageSections).unwrapOr([]);
506
+ const read = await readInventorySections(deps);
473
507
  // An unreadable inventory is an expected environment state — model it as an
474
508
  // `available: false` data variant telling the caller the set is UNKNOWN,
475
509
  // WITH the reason: without it, a structural fault (a damaged dependency
476
510
  // graph) reads as a transient flake, and the caller retries for ever.
477
- if (readPoolInventory) {
478
- const pool = await readPoolInventory().catch((cause) => ({
479
- kind: "unavailable",
480
- reason: cause instanceof Error ? cause.message : String(cause),
481
- }));
482
- if (pool.kind === "unavailable")
483
- return ok({ available: false, content: `${POOL_UNAVAILABLE_NOTE} The reason: ${pool.reason}.` });
484
- return ok(queryPackages([...pool.sections, ...recordSections], input));
485
- }
486
- let lock = null;
487
- for (const candidate of lockCandidates) {
488
- lock = readFarmLockFile(candidate).unwrapOr(null);
489
- if (lock !== null)
490
- break;
491
- }
492
- if (lock === null) {
493
- return ok({ available: false, content: UNAVAILABLE_NOTE });
511
+ // The note follows the vantage of the bound source, because a
512
+ // conversation surface has no runtime to probe.
513
+ if (read.kind === "unavailable") {
514
+ const note = readPoolInventory ? POOL_UNAVAILABLE_NOTE : UNAVAILABLE_NOTE;
515
+ return ok({ available: false, content: read.reason === undefined ? note : `${note} The reason: ${read.reason}.` });
494
516
  }
495
- return ok(queryPackages([...lockSections(lock), ...recordSections], input));
517
+ return ok(queryPackages(read.sections, input));
496
518
  },
497
519
  });
498
520
  }
@@ -34,6 +34,7 @@ import { unwrapOrThrow } from "../lib/result.js";
34
34
  import { isBudgetExceeded } from "../loop/budget-exceeded.js";
35
35
  import { runAgent } from "../loop/run-agent.js";
36
36
  import { durableStep } from "../loop/run-step.js";
37
+ import { lastExecOutcome } from "../sandbox/exec-outcome.js";
37
38
  import { activityForTool, applyTreeDelta, isChatDataPart, sandboxTreeDelta, stepPartId } from "../sandbox/sandbox-step-translate.js";
38
39
  import { createDetailResolver } from "../tools/detail-resolver.js";
39
40
  import { forSubAgent } from "../auth/types.js";
@@ -274,8 +275,15 @@ export async function runSandboxStepBody(input, deps) {
274
275
  // The scrub below is deliberate and load-bearing, which makes this record
275
276
  // the ONLY account of why the step died — the thrown error carries just the
276
277
  // generic phrase, so nothing downstream can reconstruct the cause.
278
+ // `durationMs`, the iteration cap, and the last exec of the sandbox are
279
+ // what separate a step that died in the model from one that died in the
280
+ // machine. `lastExec` holds no stream text (see `exec-outcome.ts`).
281
+ const lastExec = lastExecOutcome(sandbox.sandboxId);
277
282
  logger.error("step failure", {
278
283
  errorClass,
284
+ durationMs,
285
+ hitMaxSteps,
286
+ ...(lastExec ? { lastExec } : {}),
279
287
  ...logger.errorFields(err),
280
288
  });
281
289
  const safe = userFacingStepFailure(errorClass);
@@ -341,7 +349,8 @@ export async function runSandboxStepBody(input, deps) {
341
349
  const neverAbort = new AbortController().signal;
342
350
  let transcript;
343
351
  let finishReason;
344
- let hitMaxSteps;
352
+ // `false` until the loop reports otherwise: a loop that throws never reached its cap.
353
+ let hitMaxSteps = false;
345
354
  let stepUsage;
346
355
  try {
347
356
  const agentResult = await runAgent(agent, initial, session, {
@@ -0,0 +1,75 @@
1
+ /**
2
+ * TargetAssessmentProgressStream — the read side of a target assessment's
3
+ * durable progress stream.
4
+ *
5
+ * `emitProgress` (the sibling `progress.ts`) writes one part per phase
6
+ * transition and nothing reads it back, so an embedder that wants to show a
7
+ * user how far an assessment has got had to reach for the durability engine
8
+ * itself. This seam is that read, and it quarantines the engine the way
9
+ * `RunLauncher` does for starting workflows: the delivered value is the
10
+ * harness's own `TargetAssessmentProgressEvent` contract type, and no engine
11
+ * type appears in the exported signature.
12
+ *
13
+ * It is far simpler than `execution/run-event-stream.ts`, and deliberately so.
14
+ * A run fans out over a parent workflow and every sandbox-step child, each
15
+ * writing its own stream, so that seam polls a ledger to discover children and
16
+ * folds reconciling parts before delivery. A target assessment writes progress
17
+ * from the parent body alone, under `workflowID === assessmentId`. One stream,
18
+ * no discovery, no pool.
19
+ *
20
+ * It is a push subscription rather than a returned iterator because handing the
21
+ * engine's async generator back to a caller would put the engine's model on the
22
+ * very surface this seam exists to remove, and would leave nowhere to contain a
23
+ * failure. A handler gives both, and matches the `EmitFn` idiom the writer side
24
+ * already speaks.
25
+ */
26
+ import { type TargetAssessmentProgressEvent } from "@inflexa-ai/harness/contracts/target-dossier.js";
27
+ import type { Logger } from "../../lib/logger.js";
28
+ /**
29
+ * Construction-time dependencies. The logger is where every contained failure
30
+ * is reported, and defaults to silence so an embedder that wired none sees
31
+ * nothing rather than having console forced on it.
32
+ */
33
+ export interface TargetAssessmentProgressStreamDeps {
34
+ readonly logger?: Logger;
35
+ }
36
+ /**
37
+ * Receives one progress event. May be async — the subscription awaits it, so a
38
+ * handler is never invoked concurrently with itself. A throw is logged and
39
+ * swallowed.
40
+ */
41
+ export type TargetAssessmentProgressHandler = (event: TargetAssessmentProgressEvent) => void | Promise<void>;
42
+ /** Call-time parameters of one subscription. */
43
+ export interface TargetAssessmentProgressSubscribeOptions {
44
+ /** The assessment to observe. Also the workflow's id — TA sets the two equal. */
45
+ readonly assessmentId: string;
46
+ /** Where each delivered event goes. */
47
+ readonly onEvent: TargetAssessmentProgressHandler;
48
+ /** Aborting stops delivery and settles the returned promise. */
49
+ readonly signal: AbortSignal;
50
+ }
51
+ /**
52
+ * The progress read seam. One method, because an assessment-scoped subscription
53
+ * is the whole capability — a general-purpose stream reader would leak the
54
+ * engine's model back into the surface this exists to quarantine.
55
+ */
56
+ export interface TargetAssessmentProgressStream {
57
+ /**
58
+ * Deliver every progress event the assessment produces to `onEvent`,
59
+ * resolving when the assessment's stream drains or when the signal aborts.
60
+ *
61
+ * The stream drains when the workflow stops being active. A workflow that
62
+ * self-cancels on a 402 is no longer active, so the stream drains at the
63
+ * `suspended` phase and this settles there — it does NOT wait for a later
64
+ * `DBOS.resumeWorkflow`. Re-opening across a resume would mean polling the
65
+ * row's status, which is the caller's business, not this seam's.
66
+ *
67
+ * Events arrive in the order the workflow wrote them, each exactly once.
68
+ * There is no fold: the body emits each phase once and the engine caches a
69
+ * step's stream-write offset, so a recovered workflow does not re-write one.
70
+ * A fold would collapse nothing and cost a buffer.
71
+ */
72
+ subscribe(options: TargetAssessmentProgressSubscribeOptions): Promise<void>;
73
+ }
74
+ /** Build the DBOS-backed realization of {@link TargetAssessmentProgressStream}. */
75
+ export declare function createTargetAssessmentProgressStream(deps?: TargetAssessmentProgressStreamDeps): TargetAssessmentProgressStream;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * TargetAssessmentProgressStream — the read side of a target assessment's
3
+ * durable progress stream.
4
+ *
5
+ * `emitProgress` (the sibling `progress.ts`) writes one part per phase
6
+ * transition and nothing reads it back, so an embedder that wants to show a
7
+ * user how far an assessment has got had to reach for the durability engine
8
+ * itself. This seam is that read, and it quarantines the engine the way
9
+ * `RunLauncher` does for starting workflows: the delivered value is the
10
+ * harness's own `TargetAssessmentProgressEvent` contract type, and no engine
11
+ * type appears in the exported signature.
12
+ *
13
+ * It is far simpler than `execution/run-event-stream.ts`, and deliberately so.
14
+ * A run fans out over a parent workflow and every sandbox-step child, each
15
+ * writing its own stream, so that seam polls a ledger to discover children and
16
+ * folds reconciling parts before delivery. A target assessment writes progress
17
+ * from the parent body alone, under `workflowID === assessmentId`. One stream,
18
+ * no discovery, no pool.
19
+ *
20
+ * It is a push subscription rather than a returned iterator because handing the
21
+ * engine's async generator back to a caller would put the engine's model on the
22
+ * very surface this seam exists to remove, and would leave nowhere to contain a
23
+ * failure. A handler gives both, and matches the `EmitFn` idiom the writer side
24
+ * already speaks.
25
+ */
26
+ import { DBOS } from "@dbos-inc/dbos-sdk";
27
+ import { TargetAssessmentProgressEventSchema } from "@inflexa-ai/harness/contracts/target-dossier.js";
28
+ import { createNoopLogger } from "../../lib/console-logger.js";
29
+ import { TA_PROGRESS_STREAM_KEY } from "./progress.js";
30
+ /**
31
+ * Shape `emitProgress` writes. The `data-target-assessment-progress` key is not
32
+ * in the chat-part registry — it is the writer's private envelope — so the
33
+ * payload, not the envelope, is what a caller gets.
34
+ */
35
+ function readEvent(value) {
36
+ if (typeof value !== "object" || value === null)
37
+ return null;
38
+ const parsed = TargetAssessmentProgressEventSchema.safeParse(value.payload);
39
+ return parsed.success ? parsed.data : null;
40
+ }
41
+ /** Build the DBOS-backed realization of {@link TargetAssessmentProgressStream}. */
42
+ export function createTargetAssessmentProgressStream(deps = {}) {
43
+ const baseLogger = (deps.logger ?? createNoopLogger()).named("ta-progress-stream");
44
+ return {
45
+ async subscribe({ assessmentId, onEvent, signal }) {
46
+ const logger = baseLogger.with({ assessmentId });
47
+ let resolveAbort;
48
+ const aborted = new Promise((resolve) => {
49
+ resolveAbort = resolve;
50
+ });
51
+ const generator = DBOS.readStream(assessmentId, TA_PROGRESS_STREAM_KEY);
52
+ // The engine's reader exposes no cancellation: while it waits on a
53
+ // quiet-but-active workflow it is suspended inside an await, where a
54
+ // queued `return()` is only honoured once it next reaches a yield.
55
+ // Teardown is therefore best-effort by construction, and the read's
56
+ // own promise is raced against the signal rather than waiting on it.
57
+ const onAbort = () => {
58
+ resolveAbort();
59
+ void generator.return(undefined).catch(() => {
60
+ /* the read is already being abandoned */
61
+ });
62
+ };
63
+ if (signal.aborted)
64
+ onAbort();
65
+ else
66
+ signal.addEventListener("abort", onAbort, { once: true });
67
+ const read = (async () => {
68
+ try {
69
+ for (;;) {
70
+ const next = await generator.next();
71
+ // Checked after the read, not before: `next()` is what
72
+ // notices the workflow going inactive, and an aborted
73
+ // subscription must deliver nothing further.
74
+ if (next.done === true || signal.aborted)
75
+ return;
76
+ const event = readEvent(next.value);
77
+ if (event === null) {
78
+ logger.debug("dropped a stream value outside the progress contract");
79
+ continue;
80
+ }
81
+ try {
82
+ await onEvent(event);
83
+ }
84
+ catch (err) {
85
+ logger.warn("progress handler threw", { phase: event.phase, ...logger.errorFields(err) });
86
+ }
87
+ }
88
+ }
89
+ catch (err) {
90
+ // Observation is a diagnostic channel: `emitProgress` already
91
+ // treats a failed write as best-effort so a lost frame cannot
92
+ // fail the workflow. A read that propagated here would be
93
+ // strictly less robust than the writer it observes.
94
+ logger.error("progress stream read failed", logger.errorFields(err));
95
+ }
96
+ finally {
97
+ void generator.return(undefined).catch(() => {
98
+ /* already finished, or abandoned under abort */
99
+ });
100
+ }
101
+ })();
102
+ try {
103
+ await Promise.race([read, aborted]);
104
+ }
105
+ finally {
106
+ signal.removeEventListener("abort", onAbort);
107
+ }
108
+ },
109
+ };
110
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.35.5",
3
+ "version": "0.35.7",
4
4
  "description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",