@inflexa-ai/harness 0.19.2 → 0.21.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 (139) hide show
  1. package/dist/agents/conversation-agent.d.ts +26 -14
  2. package/dist/agents/conversation-agent.js +12 -23
  3. package/dist/agents/report-session-agent.d.ts +31 -5
  4. package/dist/agents/report-session-agent.js +31 -6
  5. package/dist/app/chat-turn.js +3 -1
  6. package/dist/app/message-assembly.d.ts +15 -1
  7. package/dist/app/message-assembly.js +22 -8
  8. package/dist/app/report-session-runtime.d.ts +21 -4
  9. package/dist/app/report-session-runtime.js +53 -6
  10. package/dist/app/spawn-report-session.d.ts +174 -17
  11. package/dist/app/spawn-report-session.js +233 -21
  12. package/dist/contracts/chat-events.d.ts +7 -2
  13. package/dist/contracts/message.d.ts +10 -0
  14. package/dist/contracts/report-blocks.d.ts +743 -19
  15. package/dist/contracts/report-blocks.js +186 -11
  16. package/dist/contracts/report-reference.d.ts +88 -0
  17. package/dist/contracts/report-reference.js +48 -0
  18. package/dist/index.d.ts +9 -0
  19. package/dist/index.js +20 -0
  20. package/dist/lib/chrome.d.ts +26 -6
  21. package/dist/lib/chrome.js +99 -32
  22. package/dist/lib/eyes.d.ts +57 -0
  23. package/dist/lib/eyes.js +45 -0
  24. package/dist/lib/page-capture.d.ts +14 -0
  25. package/dist/lib/page-capture.js +56 -2
  26. package/dist/literature/sources/pubmed.js +2 -1
  27. package/dist/loop/run-agent.js +58 -23
  28. package/dist/loop/tool-detail.d.ts +19 -2
  29. package/dist/loop/tool-detail.js +35 -2
  30. package/dist/memory/content-to-cortex.d.ts +3 -2
  31. package/dist/memory/content-to-cortex.js +21 -12
  32. package/dist/memory/conversation-display-replay.js +14 -7
  33. package/dist/memory/thread-history.d.ts +60 -3
  34. package/dist/memory/thread-history.js +55 -16
  35. package/dist/memory/thread-store.d.ts +11 -1
  36. package/dist/memory/thread-store.js +12 -2
  37. package/dist/prompts/conversation.d.ts +1 -1
  38. package/dist/prompts/conversation.js +23 -6
  39. package/dist/prompts/report-session.d.ts +1 -1
  40. package/dist/prompts/report-session.js +183 -9
  41. package/dist/report-model/authoring-grammar.d.ts +463 -0
  42. package/dist/report-model/authoring-grammar.js +77 -0
  43. package/dist/report-model/block-walk.d.ts +57 -14
  44. package/dist/report-model/block-walk.js +116 -11
  45. package/dist/report-model/draft-finish.d.ts +22 -4
  46. package/dist/report-model/draft-finish.js +39 -7
  47. package/dist/report-model/draft-operations.d.ts +3 -0
  48. package/dist/report-model/draft-operations.js +19 -5
  49. package/dist/report-model/draft-read.d.ts +8 -5
  50. package/dist/report-model/draft-read.js +21 -5
  51. package/dist/report-model/fixture-resolver.js +15 -4
  52. package/dist/report-model/pin-snapshot.d.ts +24 -6
  53. package/dist/report-model/pin-snapshot.js +204 -6
  54. package/dist/report-model/production-resolver.js +15 -4
  55. package/dist/report-model/reference-resolver.d.ts +50 -1
  56. package/dist/report-model/reference-resolver.js +94 -1
  57. package/dist/report-model/reference-stamp.d.ts +45 -0
  58. package/dist/report-model/reference-stamp.js +173 -0
  59. package/dist/report-model/structural-validation.d.ts +4 -1
  60. package/dist/report-model/structural-validation.js +48 -3
  61. package/dist/report-model/validate.d.ts +1 -1
  62. package/dist/report-model/validate.js +1 -1
  63. package/dist/report-render/asset-lookup.d.ts +19 -0
  64. package/dist/report-render/asset-lookup.js +37 -0
  65. package/dist/report-render/assets.d.ts +45 -1
  66. package/dist/report-render/assets.js +65 -6
  67. package/dist/report-render/chart-presets.d.ts +102 -0
  68. package/dist/report-render/chart-presets.js +205 -0
  69. package/dist/report-render/chart.d.ts +112 -10
  70. package/dist/report-render/chart.js +1164 -41
  71. package/dist/report-render/design.d.ts +99 -0
  72. package/dist/report-render/design.js +289 -107
  73. package/dist/report-render/number-format.d.ts +117 -0
  74. package/dist/report-render/number-format.js +479 -0
  75. package/dist/report-render/page.d.ts +121 -5
  76. package/dist/report-render/page.js +689 -7
  77. package/dist/report-render/references.d.ts +70 -16
  78. package/dist/report-render/references.js +54 -22
  79. package/dist/report-render/render.d.ts +23 -5
  80. package/dist/report-render/render.js +81 -22
  81. package/dist/report-render/table-data.d.ts +92 -0
  82. package/dist/report-render/table-data.js +129 -0
  83. package/dist/report-render/types.d.ts +15 -0
  84. package/dist/report-render/value-bridge.js +6 -2
  85. package/dist/report-render/views/chart-view.d.ts +12 -5
  86. package/dist/report-render/views/chart-view.js +14 -8
  87. package/dist/report-render/views/page-view.d.ts +36 -15
  88. package/dist/report-render/views/page-view.js +45 -23
  89. package/dist/report-render/views/prose.d.ts +12 -2
  90. package/dist/report-render/views/prose.js +29 -7
  91. package/dist/report-render/views/references-view.d.ts +22 -8
  92. package/dist/report-render/views/references-view.js +101 -13
  93. package/dist/report-render/views/values.d.ts +128 -13
  94. package/dist/report-render/views/values.js +199 -18
  95. package/dist/runtime/assemble.d.ts +39 -5
  96. package/dist/runtime/assemble.js +47 -6
  97. package/dist/sandbox/client.d.ts +10 -1
  98. package/dist/sandbox/create-sandbox.d.ts +9 -4
  99. package/dist/sandbox/create-sandbox.js +22 -16
  100. package/dist/sandbox/docker-client.js +10 -7
  101. package/dist/sandbox/k8s-client.js +11 -18
  102. package/dist/sandbox/mount-plan.d.ts +45 -7
  103. package/dist/sandbox/mount-plan.js +43 -5
  104. package/dist/sandbox/types.d.ts +10 -6
  105. package/dist/state/init.js +38 -9
  106. package/dist/state/report-session-state.d.ts +42 -1
  107. package/dist/state/report-session-state.js +70 -2
  108. package/dist/state/report-versions.d.ts +22 -17
  109. package/dist/state/report-versions.js +48 -35
  110. package/dist/state/snapshot-parse.js +16 -1
  111. package/dist/tasks/data-profile.d.ts +7 -4
  112. package/dist/tasks/data-profile.js +9 -9
  113. package/dist/tools/define-tool.d.ts +32 -0
  114. package/dist/tools/define-tool.js +4 -0
  115. package/dist/tools/detail-resolver.d.ts +7 -2
  116. package/dist/tools/detail-resolver.js +7 -2
  117. package/dist/tools/iterate-report.d.ts +1 -1
  118. package/dist/tools/report/preview-snapshot.js +1 -0
  119. package/dist/tools/report-authoring/authoring-tools.d.ts +15 -6
  120. package/dist/tools/report-authoring/authoring-tools.js +99 -18
  121. package/dist/tools/report-session/derive-table.d.ts +162 -0
  122. package/dist/tools/report-session/derive-table.js +459 -0
  123. package/dist/tools/report-session/examine-page.d.ts +48 -18
  124. package/dist/tools/report-session/examine-page.js +204 -24
  125. package/dist/tools/report-session/index.d.ts +2 -0
  126. package/dist/tools/report-session/index.js +2 -0
  127. package/dist/tools/report-session/list-artifacts.d.ts +97 -0
  128. package/dist/tools/report-session/list-artifacts.js +197 -0
  129. package/dist/tools/report-session/preview-report.d.ts +6 -0
  130. package/dist/tools/report-session/preview-report.js +180 -28
  131. package/dist/tools/report-session/record-version.d.ts +22 -10
  132. package/dist/tools/report-session/record-version.js +99 -14
  133. package/dist/tools/start-report-session.d.ts +115 -0
  134. package/dist/tools/start-report-session.js +179 -0
  135. package/dist/workflows/sandbox-step.d.ts +7 -4
  136. package/dist/workflows/sandbox-step.js +10 -12
  137. package/dist/workspace/paths.d.ts +50 -0
  138. package/dist/workspace/paths.js +63 -0
  139. package/package.json +2 -1
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The tool that starts a report session from a conversation.
3
+ *
4
+ * The tool is the one report path of the conversation agent. It reads the parent
5
+ * thread from the scope of the call, thus the agent cannot name a different
6
+ * conversation. It carries the intent brief of the ask, and the spawn seeds the
7
+ * context of the child with that brief.
8
+ *
9
+ * The order of the checks is fixed. The eyes gate runs first. A composition
10
+ * that names no route to a look is a permanent condition, and the advice is
11
+ * transient state. The advice runs next. At one user turn or less of the parent
12
+ * past the anchor of the newest report child, the tool starts nothing and it
13
+ * names that child. The spawn runs last.
14
+ *
15
+ * Each degraded condition is a typed outcome in the ok channel: a scope with no
16
+ * thread id, a composition with no eyes, the advice, each refusal of the spawn,
17
+ * and a store fault. The tool never throws for one of them.
18
+ */
19
+ import type { Pool } from "pg";
20
+ import { z } from "zod";
21
+ import type { EnsureSessionStateResult } from "../app/report-session-runtime.js";
22
+ import type { ChromeConfig } from "../lib/chrome.js";
23
+ import type { AcquireEyes } from "../lib/eyes.js";
24
+ import type { Logger } from "../lib/logger.js";
25
+ import type { ThreadType } from "../memory/thread-store.js";
26
+ import { type Tool } from "./define-tool.js";
27
+ /**
28
+ * The brief of the ask, plus the one override of the advice.
29
+ *
30
+ * Each brief field is short prose. No field names a path, a dataset, or a
31
+ * format, because the report session reads those from the workspace itself.
32
+ */
33
+ declare const startReportSessionInput: z.ZodObject<{
34
+ objective: z.ZodString;
35
+ audience: z.ZodString;
36
+ angle: z.ZodString;
37
+ exclusions: z.ZodOptional<z.ZodString>;
38
+ openQuestions: z.ZodOptional<z.ZodString>;
39
+ newSessionAnyway: z.ZodOptional<z.ZodBoolean>;
40
+ }, z.core.$strip>;
41
+ export type StartReportSessionInput = z.infer<typeof startReportSessionInput>;
42
+ /**
43
+ * The typed outcome of the tool. Each arm is ok-channel data, thus the tool
44
+ * never throws for a degraded condition.
45
+ *
46
+ * `started` and `existing-session` name a report thread. `refused` names a call
47
+ * whose scope carries no conversation thread. The four refusals of the spawn
48
+ * keep their own names, thus the agent reads one reason from the spawn and from
49
+ * the tool alike. `failed` is a store fault, and it carries a short line.
50
+ */
51
+ export type StartReportSessionResult = {
52
+ outcome: "started";
53
+ threadId: string;
54
+ title: string | null;
55
+ } | {
56
+ outcome: "existing-session";
57
+ threadId: string;
58
+ title: string | null;
59
+ } | {
60
+ outcome: "refused";
61
+ detail: string;
62
+ } | {
63
+ outcome: "no_browser";
64
+ detail: string;
65
+ } | {
66
+ outcome: "parent_not_found";
67
+ } | {
68
+ outcome: "parent_not_a_conversation";
69
+ threadType: ThreadType;
70
+ } | {
71
+ outcome: "empty_parent_transcript";
72
+ } | {
73
+ outcome: "failed";
74
+ detail: string;
75
+ };
76
+ /**
77
+ * The construction deps of the tool.
78
+ *
79
+ * The fields build the spawn operation inside the factory. Thus one singleton
80
+ * tool serves every conversation, and it reads the parent from the scope of each
81
+ * call.
82
+ *
83
+ * Two of the fields name a route to a look at the rendered page: `eyes` and a
84
+ * `chrome` config that names a browser. The tool binds no capture seam. That
85
+ * seam replaces the transport of the eyes tool, and the tool here never looks at
86
+ * a page. A composition that binds a capture seam straight to the spawn still
87
+ * opens the gate of that spawn.
88
+ */
89
+ export interface StartReportSessionToolDeps {
90
+ readonly pool: Pool;
91
+ readonly chrome: ChromeConfig;
92
+ /**
93
+ * The eyes seam of the composition. A bound seam gives a browser for one
94
+ * look, thus the spawn accepts a session with no configured endpoint. The
95
+ * tool passes the seam to the spawn, and it acquires no lease.
96
+ */
97
+ readonly eyes?: AcquireEyes;
98
+ /**
99
+ * The anchor operation of the report session runtime, which the spawn runs
100
+ * after the seed of the child lands. The composition binds it, thus the tool
101
+ * carries no stale value of its own.
102
+ */
103
+ readonly anchorSession?: (threadId: string) => Promise<EnsureSessionStateResult>;
104
+ readonly logger?: Logger;
105
+ }
106
+ /**
107
+ * Make the tool that starts a report session, over one Postgres pool and the
108
+ * eyes of the composition.
109
+ *
110
+ * The factory builds the deps of the spawn one time. The spawn and the gate of
111
+ * the tool read that one value, thus the two gates cannot disagree. The tool
112
+ * holds no per-conversation value.
113
+ */
114
+ export declare function createStartReportSessionTool(deps: StartReportSessionToolDeps): Tool<StartReportSessionInput, StartReportSessionResult>;
115
+ export {};
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The tool that starts a report session from a conversation.
3
+ *
4
+ * The tool is the one report path of the conversation agent. It reads the parent
5
+ * thread from the scope of the call, thus the agent cannot name a different
6
+ * conversation. It carries the intent brief of the ask, and the spawn seeds the
7
+ * context of the child with that brief.
8
+ *
9
+ * The order of the checks is fixed. The eyes gate runs first. A composition
10
+ * that names no route to a look is a permanent condition, and the advice is
11
+ * transient state. The advice runs next. At one user turn or less of the parent
12
+ * past the anchor of the newest report child, the tool starts nothing and it
13
+ * names that child. The spawn runs last.
14
+ *
15
+ * Each degraded condition is a typed outcome in the ok channel: a scope with no
16
+ * thread id, a composition with no eyes, the advice, each refusal of the spawn,
17
+ * and a store fault. The tool never throws for one of them.
18
+ */
19
+ import { ok } from "neverthrow";
20
+ import { z } from "zod";
21
+ import { compositionHasEyes, createReportSessionSpawn } from "../app/spawn-report-session.js";
22
+ import { createNoopLogger } from "../lib/console-logger.js";
23
+ import { describeDbError } from "../lib/db-result.js";
24
+ import { defineTool } from "./define-tool.js";
25
+ /**
26
+ * The character bound of each brief field. The whole brief lands in one durable
27
+ * message row, thus the schema holds no unbounded string out of the store.
28
+ *
29
+ * The description caps the brief at approximately 2000 tokens, and a token is
30
+ * about four characters. Thus the five bounds sum to 8000 characters, and the
31
+ * widest brief that the schema admits still meets the cap that the agent reads.
32
+ *
33
+ * The share of each field follows its role. `audience` names one reader, thus a
34
+ * phrase is enough. `objective` and `angle` carry a sentence or two. Each
35
+ * optional field carries a list of items, thus it takes the largest share.
36
+ */
37
+ const BRIEF_MAX = {
38
+ objective: 1500,
39
+ audience: 200,
40
+ angle: 1500,
41
+ exclusions: 2400,
42
+ openQuestions: 2400,
43
+ };
44
+ /**
45
+ * The brief of the ask, plus the one override of the advice.
46
+ *
47
+ * Each brief field is short prose. No field names a path, a dataset, or a
48
+ * format, because the report session reads those from the workspace itself.
49
+ */
50
+ const startReportSessionInput = z.object({
51
+ objective: z.string().min(1).max(BRIEF_MAX.objective).describe("The question that the report must answer."),
52
+ audience: z.string().min(1).max(BRIEF_MAX.audience).describe("The reader of the report."),
53
+ angle: z.string().min(1).max(BRIEF_MAX.angle).describe("The line that the report takes through the evidence."),
54
+ exclusions: z.string().max(BRIEF_MAX.exclusions).optional().describe("The material that the report must leave out."),
55
+ openQuestions: z.string().max(BRIEF_MAX.openQuestions).optional().describe("The points that the user did not decide yet."),
56
+ newSessionAnyway: z
57
+ .boolean()
58
+ .optional()
59
+ .describe("Set it to true to start a new session when the conversation holds no user turn of new work after the last report session. The default is false."),
60
+ });
61
+ /** The line that a call reads when the scope names no conversation thread. */
62
+ const NO_THREAD_SCOPE_DETAIL = "the scope of the call names no conversation thread, thus the tool cannot start a child session";
63
+ /** The line that a call reads when the thread store refuses the shape of the child. */
64
+ const STORE_REFUSED_DETAIL = "the thread store refused the shape of the child session";
65
+ /**
66
+ * Map a fault of the spawn onto its arm. The switch is exhaustive over the three
67
+ * error unions, thus a new variant fails the build instead of falling into one
68
+ * arm in silence.
69
+ */
70
+ function toOutcome(fault) {
71
+ switch (fault.type) {
72
+ case "no_browser":
73
+ return { outcome: "no_browser", detail: fault.detail };
74
+ case "parent_not_found":
75
+ return { outcome: "parent_not_found" };
76
+ case "parent_not_a_conversation":
77
+ return { outcome: "parent_not_a_conversation", threadType: fault.threadType };
78
+ case "empty_parent_transcript":
79
+ return { outcome: "empty_parent_transcript" };
80
+ case "query_failed":
81
+ case "mutation_failed":
82
+ case "connection_failed":
83
+ case "constraint_violation":
84
+ return { outcome: "failed", detail: describeDbError(fault) };
85
+ case "parent_analysis_mismatch":
86
+ case "parent_anchor_unpaired":
87
+ case "unknown_thread_type":
88
+ return { outcome: "failed", detail: STORE_REFUSED_DETAIL };
89
+ }
90
+ }
91
+ /** The cause of a fault, or `undefined` for a fault that carries none. */
92
+ function causeOf(fault) {
93
+ return "cause" in fault ? fault.cause : undefined;
94
+ }
95
+ /**
96
+ * Make the tool that starts a report session, over one Postgres pool and the
97
+ * eyes of the composition.
98
+ *
99
+ * The factory builds the deps of the spawn one time. The spawn and the gate of
100
+ * the tool read that one value, thus the two gates cannot disagree. The tool
101
+ * holds no per-conversation value.
102
+ */
103
+ export function createStartReportSessionTool(deps) {
104
+ const logger = (deps.logger ?? createNoopLogger()).named("start-report-session");
105
+ const spawnDeps = {
106
+ pool: deps.pool,
107
+ chrome: deps.chrome,
108
+ ...(deps.eyes ? { eyes: deps.eyes } : {}),
109
+ ...(deps.anchorSession ? { anchorSession: deps.anchorSession } : {}),
110
+ ...(deps.logger ? { logger: deps.logger } : {}),
111
+ };
112
+ const spawn = createReportSessionSpawn(spawnDeps);
113
+ // The gate reads the routes of the very spawn below, thus the two answers
114
+ // come from one value. The routes are fixed at construction, thus the gate
115
+ // reads one boolean and never a live probe of the sidecar.
116
+ const eyesAvailable = compositionHasEyes(spawnDeps);
117
+ return defineTool({
118
+ id: "start_report_session",
119
+ description: "Start an interactive report session as a child of this conversation. " +
120
+ "The user composes the report in that chat, with the report agent. " +
121
+ "Give the intent brief: the objective, the audience, and the angle of the report. " +
122
+ "The brief carries intent only. Do not name a path, a dataset, or a format in it, " +
123
+ "because the session reads those from the workspace. " +
124
+ "Keep the whole brief under approximately 2000 tokens. " +
125
+ "When the conversation holds no user turn of new work after the last report session, the tool " +
126
+ "starts nothing and it names that session. The ask that started that session is not new work. " +
127
+ "Then tell the user to continue in that chat.",
128
+ inputSchema: startReportSessionInput,
129
+ executionMode: "inline",
130
+ describeCall: "none",
131
+ execute: async (input, ctx) => {
132
+ const brief = {
133
+ objective: input.objective,
134
+ audience: input.audience,
135
+ angle: input.angle,
136
+ exclusions: input.exclusions,
137
+ openQuestions: input.openQuestions,
138
+ };
139
+ const { scope } = ctx.session;
140
+ if (scope.kind !== "analysis" || scope.threadId === undefined || scope.threadId.length === 0) {
141
+ return ok({ outcome: "refused", detail: NO_THREAD_SCOPE_DETAIL });
142
+ }
143
+ const parentThreadId = scope.threadId;
144
+ // The eyes gate has priority over the advice, and the advice costs two
145
+ // reads. The spawn holds the same gate before each read of its own,
146
+ // thus a blind composition reaches the `no_browser` refusal of the
147
+ // spawn with no read and no write. As a result the tool copies no
148
+ // detail line of the spawn.
149
+ if (eyesAvailable) {
150
+ const delta = await spawn.reportSessionDelta(parentThreadId);
151
+ if (delta.isErr()) {
152
+ logger.warn("the report session delta did not read", {
153
+ parentThreadId,
154
+ reason: delta.error.type,
155
+ ...logger.errorFields(delta.error.cause),
156
+ });
157
+ return ok(toOutcome(delta.error));
158
+ }
159
+ const { newestChild, userTurnsSinceAnchor } = delta.value;
160
+ // The rule admits one turn. A turn appends after its own loop
161
+ // runs, thus the ask that made the child is itself one user turn
162
+ // past the anchor. A rule at zero would never advise again after
163
+ // that turn commits. A second user turn is real work, and a new
164
+ // session can report on it.
165
+ if (input.newSessionAnyway !== true && newestChild !== null && userTurnsSinceAnchor !== null && userTurnsSinceAnchor <= 1) {
166
+ return ok({ outcome: "existing-session", threadId: newestChild.threadId, title: newestChild.title });
167
+ }
168
+ }
169
+ const spawned = await spawn.spawnReportSession(parentThreadId, brief);
170
+ return ok(spawned.match((child) => ({ outcome: "started", threadId: child.threadId, title: child.title }), (fault) => {
171
+ const outcome = toOutcome(fault);
172
+ if (outcome.outcome === "failed") {
173
+ logger.warn("the report session did not start", { parentThreadId, reason: fault.type, ...logger.errorFields(causeOf(fault)) });
174
+ }
175
+ return outcome;
176
+ }));
177
+ },
178
+ });
179
+ }
@@ -29,7 +29,6 @@
29
29
  import type { TokenUsageRollup } from "@inflexa-ai/harness/contracts/usage.js";
30
30
  import type { Pool } from "pg";
31
31
  import type { Logger } from "../lib/logger.js";
32
- import type { ResolveBilling } from "../billing/resolver.js";
33
32
  import type { UsageRecorder } from "../billing/usage-recorder.js";
34
33
  import type { CitationResolver } from "../citations/types.js";
35
34
  import type { AgentDefinition, LoopMessage } from "../loop/types.js";
@@ -233,9 +232,13 @@ export interface SandboxStepDeps {
233
232
  /** Write-side embedder for the post-step vector index. */
234
233
  readonly embedding: EmbeddingProvider;
235
234
  readonly sandboxClient: SandboxClient;
236
- /** Resolves the billing context stamped as the sandbox pod's OpenCost
237
- * labels. Absent in upstream-less (dev/OSS) wiring — pods spawn unlabeled. */
238
- readonly resolveBilling?: ResolveBilling;
235
+ /**
236
+ * Host-supplied labels for this step's sandbox pod, resolved under the
237
+ * step's session. The map is opaque to the harness: it stamps each entry
238
+ * and interprets none of it. Absent in a wiring that attributes nothing —
239
+ * the pod then carries the harness's own labels only.
240
+ */
241
+ readonly resolvePodLabels?: (session: RunSession) => Promise<Record<string, string>>;
239
242
  /**
240
243
  * External artifact registration + sync seam. The harness's post-step pipeline
241
244
  * registers each step's outputs through it (filesystem index in the
@@ -46,8 +46,6 @@ import { createBlockerHolder } from "../tools/sandbox/report-blocker.js";
46
46
  * declare it explicitly.
47
47
  */
48
48
  export const DEFAULT_STEP_TIMEOUT_SECONDS = 3600;
49
- /** Resolved-header key carrying the billing-context id (see `ResolveBilling`). */
50
- const BILLING_CONTEXT_HEADER = "X-Inflexa-Billing-Context";
51
49
  /**
52
50
  * DBOS message topic the child uses to notify the parent that it is
53
51
  * about to self-cancel because of a 402 budget-exceeded error. The
@@ -153,7 +151,7 @@ export async function runSandboxStepBody(input, deps) {
153
151
  const identity = await DBOS.runStep(() => Promise.resolve(mintSandboxIdentity(input.runId)), { name: "sandbox.mint" });
154
152
  // (2b) sandbox.create — spawn (or adopt) the machine under the minted
155
153
  // identity. The handle (secret included) is cached so recovery picks the
156
- // same machine back up without re-provisioning. Billing resolution lives
154
+ // same machine back up without re-provisioning. Pod-label resolution lives
157
155
  // INSIDE this step (not as its own step) so the child's step sequence is
158
156
  // unchanged — in-flight workflows resumed across a deploy replay cleanly.
159
157
  const sandboxMeta = {
@@ -166,20 +164,20 @@ export async function runSandboxStepBody(input, deps) {
166
164
  resources: input.resources,
167
165
  };
168
166
  const sandbox = await DBOS.runStep(async () => {
169
- let billing;
170
- if (deps.resolveBilling) {
167
+ let podLabels;
168
+ if (deps.resolvePodLabels) {
171
169
  try {
172
- const bcId = (await deps.resolveBilling(session))[BILLING_CONTEXT_HEADER];
173
- if (bcId)
174
- billing = { billingContextId: bcId, userId: input.runSession.identity.user };
170
+ podLabels = await deps.resolvePodLabels(session);
175
171
  }
176
172
  catch (err) {
177
- logger.error("[billing] step billing resolution failed", { analysisId: input.analysisId, ...logger.errorFields(err) });
173
+ logger.warn("pod-label resolution failed", { analysisId: input.analysisId, ...logger.errorFields(err) });
178
174
  }
179
- if (!billing)
180
- logger.error("[billing] sandbox spawned without billing labels", { analysisId: input.analysisId });
175
+ // A wired resolver that yields nothing is the one loud case: the
176
+ // host asked for attribution and the pod spawns without it.
177
+ if (!podLabels || Object.keys(podLabels).length === 0)
178
+ logger.warn("sandbox spawned with no pod labels", { analysisId: input.analysisId });
181
179
  }
182
- return deps.sandboxClient.createSandbox({ ...sandboxMeta, billing }, identity);
180
+ return deps.sandboxClient.createSandbox({ ...sandboxMeta, podLabels }, identity);
183
181
  }, { name: "sandbox.create" });
184
182
  // Recovery path: re-check `isAlive` on the persisted ref before continuing.
185
183
  // A classified-dead sandbox triggers a fresh `createSandbox` (task 4.6).
@@ -122,6 +122,34 @@ export declare function stepWritePrefix(args: {
122
122
  readonly runId: string;
123
123
  readonly stepId: string;
124
124
  }): string;
125
+ /**
126
+ * Split a declared sandbox write tail into its segments, and refuse a tail that is
127
+ * not one workspace-relative path.
128
+ *
129
+ * A tail names the one writable mount of a sandbox in place of the step directory.
130
+ * It reaches the mount builders from a caller, thus it takes the same discipline as
131
+ * a `stepId`: each segment passes {@link assertSafeId}, which refuses a separator,
132
+ * a NUL, a shell-hostile char, and a pure-dot segment. An empty tail names the
133
+ * workspace root itself, and an absolute tail names a host location. Both are
134
+ * refused, because a write mount must be a directory inside the tree.
135
+ *
136
+ * The segments come back so a host-path builder joins the validated parts, and it
137
+ * never re-splits the raw text.
138
+ */
139
+ export declare function assertSafeTail(tail: string, label?: string): string[];
140
+ /**
141
+ * Derive the host path of a declared write tail under a workspace root.
142
+ *
143
+ * The counterpart of {@link stepWritePrefix} for a sandbox that declares its own
144
+ * write tail. The tail passes the same validation before it becomes a host
145
+ * directory path, thus a crafted segment cannot widen or escape the mount. This is
146
+ * the one builder of that path: the host makes the directory with it, and the
147
+ * Docker bind source comes from it.
148
+ */
149
+ export declare function tailWritePrefix(args: {
150
+ readonly workspaceRoot: string;
151
+ readonly tail: string;
152
+ }): string;
125
153
  /**
126
154
  * Map a host-side absolute path within an analysis's workspace tree to its
127
155
  * in-sandbox absolute path. The tree is bind-mounted at `/{resourceId}`, so
@@ -152,6 +180,28 @@ export declare function previewDir(previewId: string): string;
152
180
  * Expects names like "v1", "v2", etc. Returns 0 if no versions found.
153
181
  */
154
182
  export declare function latestPreviewVersion(dirNames: string[]): number;
183
+ /**
184
+ * Parent of every report session, workspace-root-relative. Callers that
185
+ * enumerate the sessions join this. Callers that address one join
186
+ * {@link reportSessionDir}. Both spell the segment through this constant, thus
187
+ * the tree has one owner.
188
+ */
189
+ export declare const REPORT_SESSIONS_ROOT = "report-sessions";
190
+ /**
191
+ * Directory of one report session, workspace-root-relative. The page of the
192
+ * session and its staged assets live under this one directory. Thus a host that
193
+ * reclaims the files of a purged session names each directory through this
194
+ * helper. The host restates no layout of the harness.
195
+ */
196
+ export declare function reportSessionDir(threadId: string): string;
197
+ /**
198
+ * Directory of the derived tables of one report session, workspace-root-relative.
199
+ *
200
+ * The derivation writes its output here, and the record prunes an output that the recorded document does
201
+ * not name. Both spell the segment through this helper, thus the write mount and the prune scope cannot
202
+ * disagree.
203
+ */
204
+ export declare function reportSessionDerivedDir(threadId: string): string;
155
205
  /**
156
206
  * True when `value` is a safe single path segment: the `SAFE_ID` charset (word
157
207
  * chars, `.`, `-` — no slash, NUL, or shell-hostile char) AND not a pure-dot
@@ -96,6 +96,42 @@ export function stepWritePrefix(args) {
96
96
  assertSafeId(args.stepId, "stepId");
97
97
  return resolvePath(args.workspaceRoot, "runs", args.runId, args.stepId);
98
98
  }
99
+ /**
100
+ * Split a declared sandbox write tail into its segments, and refuse a tail that is
101
+ * not one workspace-relative path.
102
+ *
103
+ * A tail names the one writable mount of a sandbox in place of the step directory.
104
+ * It reaches the mount builders from a caller, thus it takes the same discipline as
105
+ * a `stepId`: each segment passes {@link assertSafeId}, which refuses a separator,
106
+ * a NUL, a shell-hostile char, and a pure-dot segment. An empty tail names the
107
+ * workspace root itself, and an absolute tail names a host location. Both are
108
+ * refused, because a write mount must be a directory inside the tree.
109
+ *
110
+ * The segments come back so a host-path builder joins the validated parts, and it
111
+ * never re-splits the raw text.
112
+ */
113
+ export function assertSafeTail(tail, label = "writableTail") {
114
+ if (tail.length === 0 || tail.startsWith("/")) {
115
+ throw new Error(`Invalid ${label}: ${tail}`);
116
+ }
117
+ const segments = tail.split("/");
118
+ for (const segment of segments) {
119
+ assertSafeId(segment, label);
120
+ }
121
+ return segments;
122
+ }
123
+ /**
124
+ * Derive the host path of a declared write tail under a workspace root.
125
+ *
126
+ * The counterpart of {@link stepWritePrefix} for a sandbox that declares its own
127
+ * write tail. The tail passes the same validation before it becomes a host
128
+ * directory path, thus a crafted segment cannot widen or escape the mount. This is
129
+ * the one builder of that path: the host makes the directory with it, and the
130
+ * Docker bind source comes from it.
131
+ */
132
+ export function tailWritePrefix(args) {
133
+ return resolvePath(args.workspaceRoot, ...assertSafeTail(args.tail));
134
+ }
99
135
  /**
100
136
  * Map a host-side absolute path within an analysis's workspace tree to its
101
137
  * in-sandbox absolute path. The tree is bind-mounted at `/{resourceId}`, so
@@ -152,6 +188,33 @@ export function latestPreviewVersion(dirNames) {
152
188
  }
153
189
  return max;
154
190
  }
191
+ /**
192
+ * Parent of every report session, workspace-root-relative. Callers that
193
+ * enumerate the sessions join this. Callers that address one join
194
+ * {@link reportSessionDir}. Both spell the segment through this constant, thus
195
+ * the tree has one owner.
196
+ */
197
+ export const REPORT_SESSIONS_ROOT = "report-sessions";
198
+ /**
199
+ * Directory of one report session, workspace-root-relative. The page of the
200
+ * session and its staged assets live under this one directory. Thus a host that
201
+ * reclaims the files of a purged session names each directory through this
202
+ * helper. The host restates no layout of the harness.
203
+ */
204
+ export function reportSessionDir(threadId) {
205
+ assertSafeId(threadId, "threadId");
206
+ return `${REPORT_SESSIONS_ROOT}/${threadId}`;
207
+ }
208
+ /**
209
+ * Directory of the derived tables of one report session, workspace-root-relative.
210
+ *
211
+ * The derivation writes its output here, and the record prunes an output that the recorded document does
212
+ * not name. Both spell the segment through this helper, thus the write mount and the prune scope cannot
213
+ * disagree.
214
+ */
215
+ export function reportSessionDerivedDir(threadId) {
216
+ return `${reportSessionDir(threadId)}/derived`;
217
+ }
155
218
  /**
156
219
  * For an analysis-rooted input (`/{analysisId}/...`), strip the leading
157
220
  * `/{analysisId}/` segment so it can be resolved relative to the analysis
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.19.2",
3
+ "version": "0.21.0",
4
4
  "description": "Host-agnostic bioinformatics agent harness \u2014 hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -64,6 +64,7 @@
64
64
  "@opentelemetry/sdk-trace-base": "^2.9.0",
65
65
  "@opentelemetry/sdk-trace-node": "^2.9.0",
66
66
  "@opentelemetry/semantic-conventions": "^1.43.0",
67
+ "ag-grid-community": "36.1.0",
67
68
  "ai": "^7.0.61",
68
69
  "cheerio": "^1.2.0",
69
70
  "dockerode": "^4.0.12",