@inflexa-ai/harness 0.19.0 → 0.19.2
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/dist/agents/report-session-agent.d.ts +32 -9
- package/dist/agents/report-session-agent.js +22 -6
- package/dist/app/report-session-runtime.js +26 -0
- package/dist/app/spawn-report-session.d.ts +32 -5
- package/dist/app/spawn-report-session.js +19 -0
- package/dist/auth/local-run-authorizer.d.ts +3 -2
- package/dist/auth/local-run-authorizer.js +4 -2
- package/dist/config/environment-stores.d.ts +12 -7
- package/dist/config/resource-limits.d.ts +6 -10
- package/dist/config/resource-limits.js +6 -44
- package/dist/contracts/report-reference.d.ts +4 -0
- package/dist/contracts/report-reference.js +3 -0
- package/dist/execution/run-authorizer.d.ts +18 -0
- package/dist/execution/run-canceler.d.ts +67 -0
- package/dist/execution/run-canceler.js +130 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +6 -1
- package/dist/lib/chrome.d.ts +14 -0
- package/dist/lib/chrome.js +7 -0
- package/dist/lib/page-capture.d.ts +47 -0
- package/dist/lib/page-capture.js +83 -0
- package/dist/loop/run-agent.js +104 -15
- package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
- package/dist/memory/ai-sdk-message-storage.js +9 -4
- package/dist/memory/count-tokens.js +34 -5
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +21 -0
- package/dist/providers/ai-sdk.d.ts +81 -3
- package/dist/providers/ai-sdk.js +91 -10
- package/dist/providers/types.d.ts +17 -0
- package/dist/report-model/assert-rules.d.ts +44 -0
- package/dist/report-model/assert-rules.js +97 -0
- package/dist/report-model/draft-hash.d.ts +14 -0
- package/dist/report-model/draft-hash.js +36 -0
- package/dist/report-model/fixture-resolver.js +2 -71
- package/dist/report-model/production-resolver.d.ts +124 -0
- package/dist/report-model/production-resolver.js +801 -0
- package/dist/report-model/reference-resolver.d.ts +6 -0
- package/dist/report-model/validate.js +5 -0
- package/dist/report-render/assets.d.ts +42 -0
- package/dist/report-render/assets.js +61 -0
- package/dist/report-render/chart.js +31 -10
- package/dist/report-render/design.d.ts +120 -0
- package/dist/report-render/design.js +808 -0
- package/dist/report-render/page.d.ts +51 -111
- package/dist/report-render/page.js +158 -268
- package/dist/report-render/render.js +34 -11
- package/dist/report-render/views/chart-view.d.ts +6 -3
- package/dist/report-render/views/chart-view.js +10 -5
- package/dist/report-render/views/page-view.d.ts +19 -8
- package/dist/report-render/views/page-view.js +31 -11
- package/dist/report-render/views/prose.d.ts +1 -0
- package/dist/report-render/views/prose.js +7 -13
- package/dist/report-render/views/references-view.js +5 -5
- package/dist/report-render/views/values.d.ts +15 -6
- package/dist/report-render/views/values.js +29 -10
- package/dist/runtime/assemble.d.ts +38 -4
- package/dist/runtime/assemble.js +43 -3
- package/dist/sandbox/k8s-client.js +18 -0
- package/dist/sandbox/mount-plan.d.ts +7 -0
- package/dist/sandbox/mount-plan.js +7 -2
- package/dist/sandbox/types.d.ts +6 -0
- package/dist/state/analyses.d.ts +3 -26
- package/dist/state/analyses.js +6 -64
- package/dist/state/index.d.ts +2 -2
- package/dist/state/index.js +2 -2
- package/dist/state/init.js +23 -10
- package/dist/state/report-session-state.d.ts +27 -0
- package/dist/state/report-session-state.js +29 -2
- package/dist/state/runs.d.ts +10 -0
- package/dist/state/runs.js +21 -0
- package/dist/tasks/data-profile.d.ts +4 -0
- package/dist/tasks/data-profile.js +14 -0
- package/dist/tasks/extract-values-script.d.ts +64 -0
- package/dist/tasks/extract-values-script.js +180 -0
- package/dist/tasks/extract-values.d.ts +124 -0
- package/dist/tasks/extract-values.js +193 -0
- package/dist/tools/approval/gateway.d.ts +8 -1
- package/dist/tools/approval/gateway.js +9 -2
- package/dist/tools/approval/queries.d.ts +9 -2
- package/dist/tools/approval/queries.js +12 -4
- package/dist/tools/define-tool.d.ts +29 -0
- package/dist/tools/define-tool.js +26 -0
- package/dist/tools/report/preview-snapshot.d.ts +1 -1
- package/dist/tools/report/preview-snapshot.js +11 -50
- package/dist/tools/report-authoring/authoring-tools.d.ts +54 -4
- package/dist/tools/report-authoring/authoring-tools.js +1 -1
- package/dist/tools/report-session/examine-page.d.ts +90 -0
- package/dist/tools/report-session/examine-page.js +136 -0
- package/dist/tools/report-session/index.d.ts +3 -1
- package/dist/tools/report-session/index.js +3 -1
- package/dist/tools/report-session/preview-report.d.ts +31 -6
- package/dist/tools/report-session/preview-report.js +63 -16
- package/dist/tools/report-session/record-version.d.ts +102 -0
- package/dist/tools/report-session/record-version.js +139 -0
- package/dist/tools/sandbox/list-available-packages.js +2 -1
- package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
- package/dist/tools/sandbox/list-available-refs.js +13 -6
- package/dist/workflows/sandbox-step.d.ts +4 -0
- package/dist/workflows/sandbox-step.js +21 -2
- package/package.json +14 -8
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fixed extraction script and its wire protocol.
|
|
3
|
+
*
|
|
4
|
+
* The extraction arm runs this one Python script in the sandbox. The script reads one JSON input: a list
|
|
5
|
+
* of path, format, and hash triples. For each triple the script hashes the file bytes with a streamed
|
|
6
|
+
* sha256 before any read. If the on-disk hash does not match the pinned hash, the script refuses the file
|
|
7
|
+
* as a typed error. If the hash matches, the script reads the file from the read-only analysis mount, and
|
|
8
|
+
* it parses the file by the carried format. A parquet file uses pyarrow. The script emits one JSON map:
|
|
9
|
+
* each path to its rows, or to a typed error. The script decides nothing else. It applies no filter and no
|
|
10
|
+
* projection.
|
|
11
|
+
*
|
|
12
|
+
* The host decides the format one time, and the request carries it. Thus the script derives no reader from
|
|
13
|
+
* an extension, and it refuses a request that names no format. The JSON arm obeys the strict host shape, an
|
|
14
|
+
* array of flat objects, thus a shape that the host refuses is a refusal here too and it never comes back
|
|
15
|
+
* as a table that pandas inferred.
|
|
16
|
+
*
|
|
17
|
+
* The harness holds the script as a string constant, thus the package ships no separate asset file. The
|
|
18
|
+
* script reaches the container as the argument of `python3 -c`. The input reaches the container as one
|
|
19
|
+
* environment variable.
|
|
20
|
+
*/
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
/**
|
|
23
|
+
* The environment variable that carries the script input. The workflow body sets it to the JSON list of
|
|
24
|
+
* path, format, and hash triples. The script reads it back one time.
|
|
25
|
+
*/
|
|
26
|
+
export const EXTRACTION_INPUT_ENV = "CORTEX_EXTRACTION_REQUESTS";
|
|
27
|
+
/**
|
|
28
|
+
* One artifact row, as plain cells. A text-backed format holds a numeric column as a string, thus a cell
|
|
29
|
+
* is a string or a number.
|
|
30
|
+
*/
|
|
31
|
+
const ExtractionRowSchema = z.record(z.string(), z.union([z.string(), z.number()]));
|
|
32
|
+
/**
|
|
33
|
+
* The outcome for one path. A clean read gives the rows. A per-file read fault gives a typed error, and
|
|
34
|
+
* the script still emits the map. The arm keeps the rows and drops the error, thus a failed path reads as
|
|
35
|
+
* an unread artifact downstream.
|
|
36
|
+
*/
|
|
37
|
+
const ExtractOutcomeSchema = z.union([
|
|
38
|
+
z.object({ rows: z.array(ExtractionRowSchema) }),
|
|
39
|
+
z.object({ error: z.object({ type: z.string(), message: z.string() }) }),
|
|
40
|
+
]);
|
|
41
|
+
/** The script output: each requested path to its outcome. */
|
|
42
|
+
export const ExtractValuesResultSchema = z.record(z.string(), ExtractOutcomeSchema);
|
|
43
|
+
/**
|
|
44
|
+
* The one extraction script. The workflow body runs it as the argument of `python3 -c`. The script reads
|
|
45
|
+
* the request list from `CORTEX_EXTRACTION_REQUESTS`, and it emits the value map to stdout.
|
|
46
|
+
*
|
|
47
|
+
* The Python comments obey the same Simplified Technical English rule as the harness comments.
|
|
48
|
+
*/
|
|
49
|
+
export const EXTRACTION_SCRIPT = `import hashlib
|
|
50
|
+
import json
|
|
51
|
+
import math
|
|
52
|
+
import os
|
|
53
|
+
import sys
|
|
54
|
+
|
|
55
|
+
import pandas as pd
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sha256_of(path):
|
|
59
|
+
# The host pins the exact bytes with a streamed sha256, in the "sha256:<hex>" form. Mirror that form
|
|
60
|
+
# here, and stream the file in blocks, thus a large file never loads fully into memory.
|
|
61
|
+
digest = hashlib.sha256()
|
|
62
|
+
with open(path, "rb") as handle:
|
|
63
|
+
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
|
64
|
+
digest.update(block)
|
|
65
|
+
return "sha256:" + digest.hexdigest()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def coerce_cell(value):
|
|
69
|
+
# A null cell becomes None, thus the record drops the key.
|
|
70
|
+
if value is None:
|
|
71
|
+
return None
|
|
72
|
+
# A numpy scalar becomes a plain Python scalar.
|
|
73
|
+
if hasattr(value, "item"):
|
|
74
|
+
try:
|
|
75
|
+
value = value.item()
|
|
76
|
+
except (ValueError, TypeError):
|
|
77
|
+
return str(value)
|
|
78
|
+
if isinstance(value, bool):
|
|
79
|
+
return "true" if value else "false"
|
|
80
|
+
if isinstance(value, float):
|
|
81
|
+
if math.isnan(value) or math.isinf(value):
|
|
82
|
+
return None
|
|
83
|
+
return value
|
|
84
|
+
if isinstance(value, int):
|
|
85
|
+
return value
|
|
86
|
+
if isinstance(value, str):
|
|
87
|
+
return value
|
|
88
|
+
return str(value)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def read_frame(path, fmt):
|
|
92
|
+
# pyarrow reads the parquet columnar format.
|
|
93
|
+
if fmt == "parquet":
|
|
94
|
+
return pd.read_parquet(path, engine="pyarrow")
|
|
95
|
+
if fmt == "tsv":
|
|
96
|
+
# keep_default_na=False keeps a literal cell such as "NA" or an empty string as its text, and the
|
|
97
|
+
# host parser keeps it too. A default read turns each into NaN, and the row builder drops the key.
|
|
98
|
+
return pd.read_csv(path, sep="\\t", dtype=str, keep_default_na=False)
|
|
99
|
+
if fmt == "csv":
|
|
100
|
+
# keep_default_na=False keeps a literal cell such as "NA" or an empty string as its text, thus a
|
|
101
|
+
# text cell survives and the row keeps the key.
|
|
102
|
+
return pd.read_csv(path, dtype=str, keep_default_na=False)
|
|
103
|
+
# The host decides the format for both arms. A request with no decided format is a protocol fault, thus
|
|
104
|
+
# the file refuses here and no reader guesses at it.
|
|
105
|
+
raise ValueError("the request names no supported format: " + str(fmt))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def json_rows(path):
|
|
109
|
+
# The host reads a JSON table as an array of flat objects, and it refuses any other shape. Obey the
|
|
110
|
+
# same shape here. An inference pass would turn a shape that the host refuses into a different table.
|
|
111
|
+
with open(path, "rb") as handle:
|
|
112
|
+
value = json.load(handle)
|
|
113
|
+
if not isinstance(value, list):
|
|
114
|
+
raise ValueError("the JSON top-level value is not an array")
|
|
115
|
+
rows = []
|
|
116
|
+
for item in value:
|
|
117
|
+
if not isinstance(item, dict):
|
|
118
|
+
raise ValueError("a JSON array item is not an object")
|
|
119
|
+
row = {}
|
|
120
|
+
for key, cell in item.items():
|
|
121
|
+
if isinstance(cell, (dict, list)):
|
|
122
|
+
raise ValueError("a JSON cell holds a nested value")
|
|
123
|
+
coerced = coerce_cell(cell)
|
|
124
|
+
if coerced is not None:
|
|
125
|
+
row[str(key)] = coerced
|
|
126
|
+
rows.append(row)
|
|
127
|
+
return rows
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def rows_of(frame):
|
|
131
|
+
# A null cell becomes None before the record build, thus a missing cell drops.
|
|
132
|
+
clean = frame.astype(object).where(pd.notnull(frame), None)
|
|
133
|
+
records = clean.to_dict(orient="records")
|
|
134
|
+
rows = []
|
|
135
|
+
for record in records:
|
|
136
|
+
row = {}
|
|
137
|
+
for key, value in record.items():
|
|
138
|
+
cell = coerce_cell(value)
|
|
139
|
+
if cell is not None:
|
|
140
|
+
row[str(key)] = cell
|
|
141
|
+
rows.append(row)
|
|
142
|
+
return rows
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def rows_for(path, fmt):
|
|
146
|
+
# The JSON arm reads the strict host shape itself. Each other format reads through pandas.
|
|
147
|
+
if fmt == "json":
|
|
148
|
+
return json_rows(path)
|
|
149
|
+
return rows_of(read_frame(path, fmt))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main():
|
|
153
|
+
raw = os.environ.get("${EXTRACTION_INPUT_ENV}", "[]")
|
|
154
|
+
requests = json.loads(raw)
|
|
155
|
+
out = {}
|
|
156
|
+
for request in requests:
|
|
157
|
+
path = request["path"]
|
|
158
|
+
fmt = request.get("format")
|
|
159
|
+
expected = request["hash"]
|
|
160
|
+
try:
|
|
161
|
+
# Hash the file before any read. A file that drifted from the pin never reaches the reader, thus
|
|
162
|
+
# the extraction never gives back drifted bytes for the gate to match against.
|
|
163
|
+
actual = sha256_of(path)
|
|
164
|
+
if actual != expected:
|
|
165
|
+
out[path] = {
|
|
166
|
+
"error": {
|
|
167
|
+
"type": "hash-mismatch",
|
|
168
|
+
"message": f"the pinned hash {expected} does not match the on-disk hash {actual}",
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
continue
|
|
172
|
+
out[path] = {"rows": rows_for(path, fmt)}
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
# A per-file read fault lands as a typed error, and the map still holds each other path.
|
|
175
|
+
out[path] = {"error": {"type": "read-fault", "message": str(exc)}}
|
|
176
|
+
json.dump(out, sys.stdout)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
main()
|
|
180
|
+
`;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The extraction workflow on the profile rails, and the `ExtractionArm` realization over it.
|
|
3
|
+
*
|
|
4
|
+
* The report resolver falls through to this arm for an over-cap file or a host parse fault. The arm runs one
|
|
5
|
+
* fixed extraction script in an ephemeral sandbox, and it gives back the rows that the script read. No agent
|
|
6
|
+
* loop runs anywhere in this path. A file whose extension names no tabular format never arrives here,
|
|
7
|
+
* because the host decides the format and it refuses the file before the fall-through.
|
|
8
|
+
*
|
|
9
|
+
* The shape matches the data profile (`src/tasks/data-profile.ts`). The authorization mints at the async
|
|
10
|
+
* edge (`triggerExtractValues`) and rides in the workflow input. The body never mints, and it revokes the
|
|
11
|
+
* authorization on every terminal path. The container is ephemeral, and it goes away with the work, thus no
|
|
12
|
+
* standing machine exists.
|
|
13
|
+
*/
|
|
14
|
+
import type { AuthContext, RunSession } from "../auth/types.js";
|
|
15
|
+
import type { RunAuthorizer } from "../execution/run-authorizer.js";
|
|
16
|
+
import type { Logger } from "../lib/logger.js";
|
|
17
|
+
import type { ExtractionArm, ExtractionArtifact, ExtractionRequest } from "../report-model/production-resolver.js";
|
|
18
|
+
import type { SandboxClient } from "../sandbox/client.js";
|
|
19
|
+
import type { ExecResult, SubmitExecBody } from "../sandbox/types.js";
|
|
20
|
+
import { type ExtractValuesResult } from "./extract-values-script.js";
|
|
21
|
+
export type { ExtractValuesResult } from "./extract-values-script.js";
|
|
22
|
+
/**
|
|
23
|
+
* The body's construction-time deps, closed over at registration. The extraction pass shares the profile's
|
|
24
|
+
* sandbox and authorization rails, thus it draws the same three seams and nothing else.
|
|
25
|
+
*/
|
|
26
|
+
export interface ExtractValuesDeps {
|
|
27
|
+
/** Operational logging seam. An absent logger falls back to a no-op. */
|
|
28
|
+
readonly logger?: Logger;
|
|
29
|
+
readonly sandboxClient: SandboxClient;
|
|
30
|
+
readonly runAuthorizer: RunAuthorizer;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The workflow input. It is JSON-serializable, thus DBOS persists it as the workflow's input row. The
|
|
34
|
+
* `RunSession` carries the run authorization. The body reads it and never mints.
|
|
35
|
+
*/
|
|
36
|
+
export interface ExtractValuesWorkflowInput {
|
|
37
|
+
readonly analysisId: string;
|
|
38
|
+
readonly runSession: RunSession;
|
|
39
|
+
/** The fall-through artifacts of one document pass. One submission covers every request. */
|
|
40
|
+
readonly requests: readonly ExtractionRequest[];
|
|
41
|
+
/**
|
|
42
|
+
* True when this workflow owns the run-authorization lifecycle, thus the body revokes on every terminal
|
|
43
|
+
* path. An input persisted before this field existed reads as absent, and the body defaults it to true,
|
|
44
|
+
* which matches the prior owned behavior.
|
|
45
|
+
*/
|
|
46
|
+
readonly ownsMandate?: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Build the sandbox exec for one extraction pass. The command runs the fixed script through `python3 -c`.
|
|
50
|
+
* The working directory is the analysis mount, thus a relative request path reads the correct file. The
|
|
51
|
+
* request list rides in one environment variable.
|
|
52
|
+
*
|
|
53
|
+
* Each request carries its pinned hash. Thus the script can hash the file first and refuse a file that
|
|
54
|
+
* drifted from the pin, before the read gives back drifted bytes.
|
|
55
|
+
*
|
|
56
|
+
* Each request also carries the format that the host decided. The script obeys that format, thus this arm
|
|
57
|
+
* derives no reader from an extension and the two arms can never read one file two ways.
|
|
58
|
+
*
|
|
59
|
+
* The function is pure, thus a test asserts the command shape without a sandbox.
|
|
60
|
+
*/
|
|
61
|
+
export declare function buildExtractionExec(analysisId: string, requests: readonly ExtractionRequest[], execId: string): SubmitExecBody;
|
|
62
|
+
/**
|
|
63
|
+
* Parse the script output from the exec result. A synthetic failure, a timeout, and a non-zero exit each
|
|
64
|
+
* throw, because each means the script did not emit its map. A stdout that is not the map shape throws too.
|
|
65
|
+
*
|
|
66
|
+
* The function is pure, thus a test asserts the parse without a sandbox. The throw crosses the workflow
|
|
67
|
+
* boundary, and the arm turns it into a rejected promise.
|
|
68
|
+
*/
|
|
69
|
+
export declare function parseExtractionOutput(result: ExecResult): ExtractValuesResult;
|
|
70
|
+
/**
|
|
71
|
+
* The body, extracted so a test drives it without a registered workflow. It creates the ephemeral
|
|
72
|
+
* container, submits one exec, awaits the result durably, and gives the value map back. It revokes the run
|
|
73
|
+
* authorization on every terminal path, and it tears the container down on both paths.
|
|
74
|
+
*/
|
|
75
|
+
export declare function runExtractValuesBody(input: ExtractValuesWorkflowInput, deps: ExtractValuesDeps): Promise<ExtractValuesResult>;
|
|
76
|
+
/**
|
|
77
|
+
* Register the extraction workflow with DBOS. It gives back the registered callable, thus a trigger
|
|
78
|
+
* dispatches with `DBOS.startWorkflow`.
|
|
79
|
+
*/
|
|
80
|
+
export declare function registerExtractValuesWorkflow(deps: ExtractValuesDeps): (input: ExtractValuesWorkflowInput) => Promise<ExtractValuesResult>;
|
|
81
|
+
/** The per-attempt workflow id. A fresh nonce keeps each pass under a distinct id. */
|
|
82
|
+
export declare function extractValuesWorkflowId(analysisId: string, nonce: string): string;
|
|
83
|
+
/**
|
|
84
|
+
* The route-side deps for the trigger: the run authorizer, and the registered workflow callable. The body's
|
|
85
|
+
* construction-time deps are closed over at registration, thus the trigger never holds them.
|
|
86
|
+
*/
|
|
87
|
+
export interface ExtractValuesTriggerDeps {
|
|
88
|
+
readonly runAuthorizer: RunAuthorizer;
|
|
89
|
+
readonly workflow: (input: ExtractValuesWorkflowInput) => Promise<ExtractValuesResult>;
|
|
90
|
+
}
|
|
91
|
+
/** The identity and the requests for one extraction pass. */
|
|
92
|
+
export interface ExtractValuesTriggerParams {
|
|
93
|
+
readonly auth: AuthContext;
|
|
94
|
+
readonly analysisId: string;
|
|
95
|
+
readonly requests: readonly ExtractionRequest[];
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The async edge. It authorizes through the `RunAuthorizer`, starts the workflow, and awaits the outcome.
|
|
99
|
+
* The body owns the revoke, thus the trigger holds no lifecycle. A workflow fault rejects the returned
|
|
100
|
+
* promise.
|
|
101
|
+
*/
|
|
102
|
+
export declare function triggerExtractValues(deps: ExtractValuesTriggerDeps, params: ExtractValuesTriggerParams): Promise<ExtractValuesResult>;
|
|
103
|
+
/** Runs one extraction pass for the bound analysis, and gives the raw value map back. */
|
|
104
|
+
export type ExtractionRunner = (requests: readonly ExtractionRequest[]) => Promise<ExtractValuesResult>;
|
|
105
|
+
/**
|
|
106
|
+
* Bind the trigger to one analysis and one auth context. The result runs one pass with every request in it.
|
|
107
|
+
*/
|
|
108
|
+
export declare function bindExtractionTrigger(deps: ExtractValuesTriggerDeps, binding: {
|
|
109
|
+
readonly auth: AuthContext;
|
|
110
|
+
readonly analysisId: string;
|
|
111
|
+
}): ExtractionRunner;
|
|
112
|
+
/**
|
|
113
|
+
* Turn the raw script output into the arm result. A path with rows enters the map. A path with an error
|
|
114
|
+
* drops out, thus the reference at that path reads as an unread artifact, which the seam contract expects.
|
|
115
|
+
*
|
|
116
|
+
* The function is pure, thus a test asserts the mapping without a sandbox.
|
|
117
|
+
*/
|
|
118
|
+
export declare function extractionArtifactsFromResult(result: ExtractValuesResult): Map<string, ExtractionArtifact>;
|
|
119
|
+
/**
|
|
120
|
+
* Make the extraction arm over a runner. One `extract` call makes one run with every request in it, and it
|
|
121
|
+
* gives the value map. A run fault rejects the promise, and the resolver turns the rejection into a failed
|
|
122
|
+
* reference.
|
|
123
|
+
*/
|
|
124
|
+
export declare function createExtractionArm(run: ExtractionRunner): ExtractionArm;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The extraction workflow on the profile rails, and the `ExtractionArm` realization over it.
|
|
3
|
+
*
|
|
4
|
+
* The report resolver falls through to this arm for an over-cap file or a host parse fault. The arm runs one
|
|
5
|
+
* fixed extraction script in an ephemeral sandbox, and it gives back the rows that the script read. No agent
|
|
6
|
+
* loop runs anywhere in this path. A file whose extension names no tabular format never arrives here,
|
|
7
|
+
* because the host decides the format and it refuses the file before the fall-through.
|
|
8
|
+
*
|
|
9
|
+
* The shape matches the data profile (`src/tasks/data-profile.ts`). The authorization mints at the async
|
|
10
|
+
* edge (`triggerExtractValues`) and rides in the workflow input. The body never mints, and it revokes the
|
|
11
|
+
* authorization on every terminal path. The container is ephemeral, and it goes away with the work, thus no
|
|
12
|
+
* standing machine exists.
|
|
13
|
+
*/
|
|
14
|
+
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { createNoopLogger } from "../lib/console-logger.js";
|
|
17
|
+
import { generateExecutionId } from "../sandbox/execution-id.js";
|
|
18
|
+
import { mintSandboxIdentity } from "../sandbox/identity.js";
|
|
19
|
+
import { EXTRACTION_INPUT_ENV, EXTRACTION_SCRIPT, ExtractValuesResultSchema } from "./extract-values-script.js";
|
|
20
|
+
/** The synthetic run id and step id of the extraction pass. Both are constants shared by every analysis. */
|
|
21
|
+
const EXTRACT_VALUES_RUN_LITERAL = "extract-values";
|
|
22
|
+
const EXTRACT_VALUES_STEP_LITERAL = "extract";
|
|
23
|
+
/** The provenance agent id. No agent loop runs, thus this names the extraction pass, not a model agent. */
|
|
24
|
+
const EXTRACT_VALUES_AGENT_ID = "value-extractor";
|
|
25
|
+
/** The exec budget for one extraction pass. It matches the profile budget. */
|
|
26
|
+
const EXTRACTION_DEADLINE_MS = 300_000;
|
|
27
|
+
/**
|
|
28
|
+
* The container size for one extraction pass. The fall-through reads a file that is over the host cap, thus
|
|
29
|
+
* the container needs headroom to load the file into pandas. The size is fixed, because the seam request
|
|
30
|
+
* carries no file size. A later change can size it from the batch.
|
|
31
|
+
*/
|
|
32
|
+
const EXTRACTION_RESOURCES = { cpu: 2, memoryGb: 8 };
|
|
33
|
+
/** The awaitExec callback. The extraction pass reports no live activity, thus the callback drops each event. */
|
|
34
|
+
const noopEmit = () => { };
|
|
35
|
+
/**
|
|
36
|
+
* Build the sandbox exec for one extraction pass. The command runs the fixed script through `python3 -c`.
|
|
37
|
+
* The working directory is the analysis mount, thus a relative request path reads the correct file. The
|
|
38
|
+
* request list rides in one environment variable.
|
|
39
|
+
*
|
|
40
|
+
* Each request carries its pinned hash. Thus the script can hash the file first and refuse a file that
|
|
41
|
+
* drifted from the pin, before the read gives back drifted bytes.
|
|
42
|
+
*
|
|
43
|
+
* Each request also carries the format that the host decided. The script obeys that format, thus this arm
|
|
44
|
+
* derives no reader from an extension and the two arms can never read one file two ways.
|
|
45
|
+
*
|
|
46
|
+
* The function is pure, thus a test asserts the command shape without a sandbox.
|
|
47
|
+
*/
|
|
48
|
+
export function buildExtractionExec(analysisId, requests, execId) {
|
|
49
|
+
const scriptRequests = requests.map((request) => ({ path: request.path, format: request.format, hash: request.hash }));
|
|
50
|
+
return {
|
|
51
|
+
command: ["python3", "-c", EXTRACTION_SCRIPT],
|
|
52
|
+
execId,
|
|
53
|
+
cwd: `/${analysisId}`,
|
|
54
|
+
env: { [EXTRACTION_INPUT_ENV]: JSON.stringify(scriptRequests) },
|
|
55
|
+
timeoutSeconds: Math.floor(EXTRACTION_DEADLINE_MS / 1000),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Parse the script output from the exec result. A synthetic failure, a timeout, and a non-zero exit each
|
|
60
|
+
* throw, because each means the script did not emit its map. A stdout that is not the map shape throws too.
|
|
61
|
+
*
|
|
62
|
+
* The function is pure, thus a test asserts the parse without a sandbox. The throw crosses the workflow
|
|
63
|
+
* boundary, and the arm turns it into a rejected promise.
|
|
64
|
+
*/
|
|
65
|
+
export function parseExtractionOutput(result) {
|
|
66
|
+
if (result.syntheticFailure !== undefined) {
|
|
67
|
+
throw new Error(`the extraction sandbox failed: ${result.syntheticFailure.reason}`);
|
|
68
|
+
}
|
|
69
|
+
if (result.timedOut) {
|
|
70
|
+
throw new Error("the extraction sandbox reached the deadline");
|
|
71
|
+
}
|
|
72
|
+
if (result.exitCode !== 0) {
|
|
73
|
+
throw new Error(`the extraction script exited with code ${String(result.exitCode)}`);
|
|
74
|
+
}
|
|
75
|
+
let parsed;
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(result.stdout);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw new Error("the extraction script did not emit valid JSON");
|
|
81
|
+
}
|
|
82
|
+
return ExtractValuesResultSchema.parse(parsed);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The body, extracted so a test drives it without a registered workflow. It creates the ephemeral
|
|
86
|
+
* container, submits one exec, awaits the result durably, and gives the value map back. It revokes the run
|
|
87
|
+
* authorization on every terminal path, and it tears the container down on both paths.
|
|
88
|
+
*/
|
|
89
|
+
export async function runExtractValuesBody(input, deps) {
|
|
90
|
+
const logger = (deps.logger ?? createNoopLogger()).named("extract-values").with({ analysisId: input.analysisId });
|
|
91
|
+
const { analysisId, runSession, requests, ownsMandate = true } = input; // oss-core-managed-ok
|
|
92
|
+
const authorization = { runSession, ownsMandate }; // oss-core-managed-ok
|
|
93
|
+
try {
|
|
94
|
+
const executionId = generateExecutionId(EXTRACT_VALUES_AGENT_ID);
|
|
95
|
+
const workflowId = DBOS.workflowID ?? `${EXTRACT_VALUES_RUN_LITERAL}:${executionId}`;
|
|
96
|
+
// The container mounts the analysis tree read-only. The extraction pass only reads, thus it needs
|
|
97
|
+
// no writable step mount.
|
|
98
|
+
const sandbox = await deps.sandboxClient.createSandbox({
|
|
99
|
+
runId: EXTRACT_VALUES_RUN_LITERAL,
|
|
100
|
+
stepId: EXTRACT_VALUES_STEP_LITERAL,
|
|
101
|
+
analysisId,
|
|
102
|
+
childWorkflowId: workflowId,
|
|
103
|
+
resources: EXTRACTION_RESOURCES,
|
|
104
|
+
readOnly: true,
|
|
105
|
+
}, mintSandboxIdentity(EXTRACT_VALUES_RUN_LITERAL));
|
|
106
|
+
try {
|
|
107
|
+
// A checkpointed clock, not `Date.now()`: the await gates on this absolute deadline, and a
|
|
108
|
+
// wall-clock deadline that grew on replay would shift which loop iteration crosses it.
|
|
109
|
+
const deadlineAbs = (await DBOS.now()) + EXTRACTION_DEADLINE_MS;
|
|
110
|
+
await deps.sandboxClient.submitExec(sandbox, buildExtractionExec(analysisId, requests, executionId));
|
|
111
|
+
const result = await deps.sandboxClient.awaitExec(sandbox, executionId, noopEmit, deadlineAbs);
|
|
112
|
+
const map = parseExtractionOutput(result);
|
|
113
|
+
await deps.runAuthorizer.revoke(authorization, "extract-values-completed");
|
|
114
|
+
return map;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
try {
|
|
118
|
+
await deps.sandboxClient.teardown(sandbox);
|
|
119
|
+
}
|
|
120
|
+
catch (teardownErr) {
|
|
121
|
+
logger.warn("teardown failed (non-fatal)", { executionId, ...logger.errorFields(teardownErr) });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
logger.error("extraction failed", logger.errorFields(err));
|
|
127
|
+
await deps.runAuthorizer.revoke(authorization, "extract-values-failed");
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Register the extraction workflow with DBOS. It gives back the registered callable, thus a trigger
|
|
133
|
+
* dispatches with `DBOS.startWorkflow`.
|
|
134
|
+
*/
|
|
135
|
+
export function registerExtractValuesWorkflow(deps) {
|
|
136
|
+
return DBOS.registerWorkflow((input) => runExtractValuesBody(input, deps), { name: "extract-values" });
|
|
137
|
+
}
|
|
138
|
+
/** The per-attempt workflow id. A fresh nonce keeps each pass under a distinct id. */
|
|
139
|
+
export function extractValuesWorkflowId(analysisId, nonce) {
|
|
140
|
+
return `extract-values:${analysisId}:${nonce}`;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The async edge. It authorizes through the `RunAuthorizer`, starts the workflow, and awaits the outcome.
|
|
144
|
+
* The body owns the revoke, thus the trigger holds no lifecycle. A workflow fault rejects the returned
|
|
145
|
+
* promise.
|
|
146
|
+
*/
|
|
147
|
+
export async function triggerExtractValues(deps, params) {
|
|
148
|
+
const { auth, analysisId, requests } = params;
|
|
149
|
+
const { runSession, ownsMandate } = await deps.runAuthorizer.authorize({
|
|
150
|
+
// oss-core-managed-ok
|
|
151
|
+
auth,
|
|
152
|
+
scope: { kind: "analysis", analysisId },
|
|
153
|
+
provenance: { agentId: EXTRACT_VALUES_AGENT_ID, callPath: [EXTRACT_VALUES_AGENT_ID] },
|
|
154
|
+
frame: { runId: EXTRACT_VALUES_RUN_LITERAL, stepId: EXTRACT_VALUES_STEP_LITERAL },
|
|
155
|
+
});
|
|
156
|
+
const handle = (await DBOS.startWorkflow(deps.workflow, {
|
|
157
|
+
workflowID: extractValuesWorkflowId(analysisId, randomUUID()),
|
|
158
|
+
})({ analysisId, runSession, requests: [...requests], ownsMandate })); // oss-core-managed-ok
|
|
159
|
+
return handle.getResult();
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Bind the trigger to one analysis and one auth context. The result runs one pass with every request in it.
|
|
163
|
+
*/
|
|
164
|
+
export function bindExtractionTrigger(deps, binding) {
|
|
165
|
+
return (requests) => triggerExtractValues(deps, { auth: binding.auth, analysisId: binding.analysisId, requests });
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Turn the raw script output into the arm result. A path with rows enters the map. A path with an error
|
|
169
|
+
* drops out, thus the reference at that path reads as an unread artifact, which the seam contract expects.
|
|
170
|
+
*
|
|
171
|
+
* The function is pure, thus a test asserts the mapping without a sandbox.
|
|
172
|
+
*/
|
|
173
|
+
export function extractionArtifactsFromResult(result) {
|
|
174
|
+
const out = new Map();
|
|
175
|
+
for (const [path, outcome] of Object.entries(result)) {
|
|
176
|
+
if ("rows" in outcome) {
|
|
177
|
+
out.set(path, { rows: outcome.rows });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Make the extraction arm over a runner. One `extract` call makes one run with every request in it, and it
|
|
184
|
+
* gives the value map. A run fault rejects the promise, and the resolver turns the rejection into a failed
|
|
185
|
+
* reference.
|
|
186
|
+
*/
|
|
187
|
+
export function createExtractionArm(run) {
|
|
188
|
+
return {
|
|
189
|
+
async extract(requests) {
|
|
190
|
+
return extractionArtifactsFromResult(await run(requests));
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -34,6 +34,13 @@ export interface AskGateway {
|
|
|
34
34
|
ask(request: AskRequest, ctx: AskContext): Promise<AskApproval>;
|
|
35
35
|
answer(id: string, reply: AskReply): Promise<AnswerOutcome>;
|
|
36
36
|
pending(): Promise<PendingAsk[]>;
|
|
37
|
-
|
|
37
|
+
/** Expire pending asks older than `maxAgeMs` (default {@link DEFAULT_SWEEP_MAX_AGE_MS}). */
|
|
38
|
+
sweepExpired(maxAgeMs?: number): Promise<number>;
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Pending rows older than this are treated as orphans of a dead process. Far
|
|
42
|
+
* beyond any plausible live turn, so a boot sweep during a rolling deployment
|
|
43
|
+
* cannot expire an ask another pod is still polling.
|
|
44
|
+
*/
|
|
45
|
+
export declare const DEFAULT_SWEEP_MAX_AGE_MS: number;
|
|
39
46
|
export declare function createAskGateway(deps: AskGatewayDeps): AskGateway;
|
|
@@ -15,6 +15,12 @@ import { answerAsk, insertGrantedAsk, insertPendingAsk, markAborted, selectAskRe
|
|
|
15
15
|
import { uuidv7 } from "./uuidv7.js";
|
|
16
16
|
/** Fixed poll cadence — imperceptible against human decision speed, low chatter. */
|
|
17
17
|
const POLL_INTERVAL_MS = 200;
|
|
18
|
+
/**
|
|
19
|
+
* Pending rows older than this are treated as orphans of a dead process. Far
|
|
20
|
+
* beyond any plausible live turn, so a boot sweep during a rolling deployment
|
|
21
|
+
* cannot expire an ask another pod is still polling.
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_SWEEP_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
18
24
|
export function createAskGateway(deps) {
|
|
19
25
|
const { pool } = deps;
|
|
20
26
|
async function ask(request, ctx) {
|
|
@@ -74,8 +80,9 @@ export function createAskGateway(deps) {
|
|
|
74
80
|
async function pending() {
|
|
75
81
|
return unwrapOrThrow(await selectPending(pool));
|
|
76
82
|
}
|
|
77
|
-
async function sweepExpired() {
|
|
78
|
-
|
|
83
|
+
async function sweepExpired(maxAgeMs = DEFAULT_SWEEP_MAX_AGE_MS) {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
return unwrapOrThrow(await sweepExpiredRows(pool, new Date(now).toISOString(), new Date(now - maxAgeMs).toISOString()));
|
|
79
86
|
}
|
|
80
87
|
return { ask, answer, pending, sweepExpired };
|
|
81
88
|
}
|
|
@@ -63,8 +63,15 @@ export declare function selectAskResolution(querier: Querier, id: string): Resul
|
|
|
63
63
|
export declare function markAborted(querier: Querier, id: string, resolvedAt: string): ResultAsync<void, DbError>;
|
|
64
64
|
/** Enumerate every unresolved ask, oldest first. */
|
|
65
65
|
export declare function selectPending(querier: Querier): ResultAsync<PendingAsk[], DbError>;
|
|
66
|
-
/**
|
|
67
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Sweep `pending` rows created before `olderThan` to `expired`, returning the
|
|
68
|
+
* count swept. The age predicate is what keeps a boot sweep from expiring an ask
|
|
69
|
+
* another live pod is still polling — only rows old enough to be orphans of a
|
|
70
|
+
* dead process are touched. Both bounds compare as text: every `created_at` is a
|
|
71
|
+
* fixed-width UTC ISO string, so lexicographic order is chronological (the same
|
|
72
|
+
* property `selectPending`'s ORDER BY relies on).
|
|
73
|
+
*/
|
|
74
|
+
export declare function sweepExpired(querier: Querier, expiredAt: string, olderThan: string): ResultAsync<number, DbError>;
|
|
68
75
|
/**
|
|
69
76
|
* Record a decision against a still-`pending` row and, for an `always` reply,
|
|
70
77
|
* write the standing grant — both in one transaction, so a crash between them can
|
|
@@ -100,12 +100,20 @@ export function selectPending(querier) {
|
|
|
100
100
|
}));
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
|
-
/**
|
|
104
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Sweep `pending` rows created before `olderThan` to `expired`, returning the
|
|
105
|
+
* count swept. The age predicate is what keeps a boot sweep from expiring an ask
|
|
106
|
+
* another live pod is still polling — only rows old enough to be orphans of a
|
|
107
|
+
* dead process are touched. Both bounds compare as text: every `created_at` is a
|
|
108
|
+
* fixed-width UTC ISO string, so lexicographic order is chronological (the same
|
|
109
|
+
* property `selectPending`'s ORDER BY relies on).
|
|
110
|
+
*/
|
|
111
|
+
export function sweepExpired(querier, expiredAt, olderThan) {
|
|
105
112
|
return tryMutation("asks.sweepExpired", async () => {
|
|
106
113
|
const result = await querier.query({
|
|
107
|
-
text: `UPDATE cortex_asks SET status = 'expired', resolved_at = $1
|
|
108
|
-
|
|
114
|
+
text: `UPDATE cortex_asks SET status = 'expired', resolved_at = $1
|
|
115
|
+
WHERE status = 'pending' AND created_at < $2`,
|
|
116
|
+
values: [expiredAt, olderThan],
|
|
109
117
|
});
|
|
110
118
|
return result.rowCount ?? 0;
|
|
111
119
|
});
|
|
@@ -38,6 +38,35 @@ export interface ToolError {
|
|
|
38
38
|
export type ToolExecutionMode = "step" | "workflow" | "inline";
|
|
39
39
|
/** Runtime guard — does a `Result`'s error value carry the `ToolError` shape? */
|
|
40
40
|
export declare function isToolError(value: unknown): value is ToolError;
|
|
41
|
+
/**
|
|
42
|
+
* The picture that a tool ok value carries beside its JSON data. `base64` holds
|
|
43
|
+
* the bytes, and `mediaType` names the IANA type, for example "image/png". The
|
|
44
|
+
* loop splits the picture out of the JSON text, and it rides the tool result as
|
|
45
|
+
* an image content block. Thus the model sees the picture, and the JSON text
|
|
46
|
+
* holds no bytes.
|
|
47
|
+
*/
|
|
48
|
+
export interface ToolResultImage {
|
|
49
|
+
readonly base64: string;
|
|
50
|
+
readonly mediaType: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The reserved key that carries a picture on a tool ok value. It is a symbol,
|
|
54
|
+
* thus it never collides with a data field. `JSON.stringify` also omits a symbol
|
|
55
|
+
* key, thus the bytes never reach the JSON text by accident.
|
|
56
|
+
*/
|
|
57
|
+
export declare const toolResultImageKey: unique symbol;
|
|
58
|
+
/** A tool ok value that can carry a picture under the reserved key. */
|
|
59
|
+
export type WithToolResultImage<T> = T & {
|
|
60
|
+
readonly [toolResultImageKey]?: ToolResultImage;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Attach a picture to a tool ok value. The value keeps its own fields, and the
|
|
64
|
+
* picture rides under the reserved key. The loop reads the key, and it moves the
|
|
65
|
+
* bytes into an image content block on the tool result.
|
|
66
|
+
*/
|
|
67
|
+
export declare function withToolResultImage<T extends object>(value: T, image: ToolResultImage): WithToolResultImage<T>;
|
|
68
|
+
/** Read the picture that a tool ok value carries, or undefined when it carries none. */
|
|
69
|
+
export declare function readToolResultImage(value: unknown): ToolResultImage | undefined;
|
|
41
70
|
/**
|
|
42
71
|
* The request-scoped values passed to every tool's `execute`. No injected
|
|
43
72
|
* dependencies (see the harness-durable-runtime spec) — invocation identity,
|
|
@@ -20,6 +20,32 @@ import { z } from "zod";
|
|
|
20
20
|
export function isToolError(value) {
|
|
21
21
|
return typeof value === "object" && value !== null && typeof value.error === "string" && typeof value.retryable === "boolean";
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* The reserved key that carries a picture on a tool ok value. It is a symbol,
|
|
25
|
+
* thus it never collides with a data field. `JSON.stringify` also omits a symbol
|
|
26
|
+
* key, thus the bytes never reach the JSON text by accident.
|
|
27
|
+
*/
|
|
28
|
+
export const toolResultImageKey = Symbol("toolResultImage");
|
|
29
|
+
/**
|
|
30
|
+
* Attach a picture to a tool ok value. The value keeps its own fields, and the
|
|
31
|
+
* picture rides under the reserved key. The loop reads the key, and it moves the
|
|
32
|
+
* bytes into an image content block on the tool result.
|
|
33
|
+
*/
|
|
34
|
+
export function withToolResultImage(value, image) {
|
|
35
|
+
return { ...value, [toolResultImageKey]: image };
|
|
36
|
+
}
|
|
37
|
+
/** Read the picture that a tool ok value carries, or undefined when it carries none. */
|
|
38
|
+
export function readToolResultImage(value) {
|
|
39
|
+
if (typeof value !== "object" || value === null)
|
|
40
|
+
return undefined;
|
|
41
|
+
const image = value[toolResultImageKey];
|
|
42
|
+
if (typeof image !== "object" || image === null)
|
|
43
|
+
return undefined;
|
|
44
|
+
const { base64, mediaType } = image;
|
|
45
|
+
if (typeof base64 !== "string" || typeof mediaType !== "string")
|
|
46
|
+
return undefined;
|
|
47
|
+
return { base64, mediaType };
|
|
48
|
+
}
|
|
23
49
|
/**
|
|
24
50
|
* Package a `Tool`. The emitted JSON Schema must have a top-level
|
|
25
51
|
* `"type": "object"` — model tool calling rejects anything else. A `z.discriminatedUnion`
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* has to be able to see without reading the model transcript.
|
|
18
18
|
*/
|
|
19
19
|
import { type Tool } from "../define-tool.js";
|
|
20
|
-
import {
|
|
20
|
+
import type { ChromeConfig } from "../../lib/chrome.js";
|
|
21
21
|
import type { Logger } from "../../lib/logger.js";
|
|
22
22
|
import { type PreviewPublisher } from "./preview-publisher.js";
|
|
23
23
|
import type { PreviewUrlCell } from "./mint-preview-url.js";
|