@inflexa-ai/harness 0.44.0 → 0.45.1
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/conversation-agent.d.ts +10 -0
- package/dist/agents/conversation-agent.js +19 -1
- package/dist/agents/sandbox/shared.d.ts +23 -2
- package/dist/agents/sandbox/shared.js +51 -18
- package/dist/agents/sandbox/translational-safety-agent.js +26 -0
- package/dist/agents/sandbox/types.d.ts +24 -1
- package/dist/agents/sandbox-catalog.d.ts +14 -0
- package/dist/agents/sandbox-catalog.js +19 -0
- package/dist/app/approve-plan.d.ts +51 -0
- package/dist/app/approve-plan.js +84 -0
- package/dist/app/chat-turn.js +6 -3
- package/dist/app/data-profile-view.d.ts +1 -1
- package/dist/app/run-activity.js +4 -6
- package/dist/config/environment-stores.d.ts +4 -2
- package/dist/contracts/chat-events.d.ts +14 -2
- package/dist/contracts/chat-events.js +1 -1
- package/dist/contracts/chat-frame.d.ts +7 -0
- package/dist/contracts/chat-frame.js +24 -6
- package/dist/contracts/chat-parts.d.ts +73 -2
- package/dist/contracts/chat-parts.js +3 -1
- package/dist/contracts/index.d.ts +4 -2
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/part-registry.js +3 -0
- package/dist/contracts/schemas/chat-events.d.ts +38 -0
- package/dist/contracts/schemas/chat-events.js +8 -0
- package/dist/contracts/schemas/chat-parts.d.ts +239 -15
- package/dist/contracts/schemas/chat-parts.js +80 -12
- package/dist/contracts/schemas/index.d.ts +2 -2
- package/dist/contracts/schemas/index.js +2 -2
- package/dist/contracts/subagent-activity.d.ts +12 -0
- package/dist/contracts/subagent-activity.js +76 -0
- package/dist/execution/artifact-metadata.js +1 -3
- package/dist/execution/dbos-workflow-purger.d.ts +2 -0
- package/dist/execution/dbos-workflow-purger.js +21 -14
- package/dist/execution/run-canceler.d.ts +33 -11
- package/dist/execution/run-canceler.js +130 -28
- package/dist/execution/run-end-record.d.ts +30 -0
- package/dist/execution/run-end-record.js +27 -0
- package/dist/execution/run-event-parts.d.ts +6 -5
- package/dist/execution/run-event-parts.js +6 -5
- package/dist/execution/run-event-stream.d.ts +18 -6
- package/dist/execution/run-event-stream.js +67 -17
- package/dist/execution/run-synthesis.d.ts +21 -0
- package/dist/execution/run-synthesis.js +77 -3
- package/dist/index.d.ts +28 -10
- package/dist/index.js +26 -2
- package/dist/lib/hooks.d.ts +1 -1
- package/dist/lib/metrics.d.ts +2 -2
- package/dist/loop/call-guard.d.ts +50 -0
- package/dist/loop/call-guard.js +90 -0
- package/dist/loop/compaction.d.ts +5 -0
- package/dist/loop/compaction.js +17 -0
- package/dist/loop/run-agent.d.ts +9 -0
- package/dist/loop/run-agent.js +16 -1
- package/dist/loop/run-to-terminal.js +4 -1
- package/dist/loop/types.d.ts +8 -0
- package/dist/memory/card-builders.d.ts +11 -0
- package/dist/memory/card-builders.js +35 -21
- package/dist/memory/conversation-display-recorder.d.ts +2 -2
- package/dist/memory/conversation-display-recorder.js +38 -4
- package/dist/memory/conversation-display-storage.d.ts +9 -2
- package/dist/memory/conversation-display-storage.js +18 -3
- package/dist/memory/thread-history.d.ts +42 -8
- package/dist/memory/thread-history.js +164 -45
- package/dist/memory/thread-store.d.ts +13 -0
- package/dist/memory/thread-store.js +15 -1
- package/dist/prompts/briefing.d.ts +57 -3
- package/dist/prompts/briefing.js +129 -2
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +3 -1
- package/dist/prompts/planner.js +13 -2
- package/dist/provenance/seam.d.ts +39 -20
- package/dist/provenance/seam.js +7 -5
- package/dist/providers/ai-sdk.js +93 -11
- package/dist/providers/types.d.ts +6 -0
- package/dist/reference-data/catalog.js +141 -2
- package/dist/report-model/authoring-grammar.d.ts +26 -26
- package/dist/report-model/pin-snapshot.d.ts +1 -1
- package/dist/report-model/pin-snapshot.js +2 -50
- package/dist/runtime/assemble.d.ts +8 -1
- package/dist/runtime/assemble.js +11 -0
- package/dist/runtime/boot.d.ts +3 -0
- package/dist/runtime/boot.js +10 -1
- package/dist/sandbox/image-packages.d.ts +26 -4
- package/dist/sandbox/image-packages.js +18 -4
- package/dist/sandbox/sandbox-step-translate.d.ts +23 -0
- package/dist/sandbox/sandbox-step-translate.js +24 -0
- package/dist/schemas/artifact-manifest.d.ts +1 -1
- package/dist/schemas/artifact-manifest.js +11 -9
- package/dist/schemas/data-profile-schemas.d.ts +12 -12
- package/dist/schemas/plan-schemas.d.ts +75 -21
- package/dist/schemas/plan-schemas.js +1 -1
- package/dist/schemas/run-synthesis.d.ts +2 -2
- package/dist/schemas/workflow-state.d.ts +113 -0
- package/dist/schemas/workflow-state.js +54 -0
- package/dist/state/analyses-metrics.d.ts +79 -7
- package/dist/state/analyses-metrics.js +141 -6
- package/dist/state/artifacts.d.ts +66 -3
- package/dist/state/artifacts.js +85 -7
- package/dist/state/data-profile.d.ts +4 -2
- package/dist/state/data-profile.js +7 -5
- package/dist/state/index.d.ts +9 -8
- package/dist/state/index.js +5 -5
- package/dist/state/migrations/20261012120000_index_the_runs_by_start.d.ts +8 -0
- package/dist/state/migrations/20261012120000_index_the_runs_by_start.js +12 -0
- package/dist/state/migrations/20261013120000_index_the_ended_runs_and_the_recent_threads.d.ts +8 -0
- package/dist/state/migrations/20261013120000_index_the_ended_runs_and_the_recent_threads.js +13 -0
- package/dist/state/migrations/20261013180000_narrow_the_active_run_index.d.ts +8 -0
- package/dist/state/migrations/20261013180000_narrow_the_active_run_index.js +13 -0
- package/dist/state/migrations/20261014120000_index_the_plans_by_parent_and_the_runs_by_plan.d.ts +8 -0
- package/dist/state/migrations/20261014120000_index_the_plans_by_parent_and_the_runs_by_plan.js +13 -0
- package/dist/state/migrations/index.js +8 -0
- package/dist/state/plans.d.ts +21 -0
- package/dist/state/plans.js +25 -0
- package/dist/state/runs.d.ts +79 -14
- package/dist/state/runs.js +150 -33
- package/dist/state/schema.d.ts +2 -0
- package/dist/state/step-executions.d.ts +9 -0
- package/dist/state/step-executions.js +22 -0
- package/dist/tasks/data-profile-activity.d.ts +7 -1
- package/dist/tasks/data-profile-activity.js +18 -9
- package/dist/tasks/data-profile.d.ts +15 -1
- package/dist/tasks/data-profile.js +16 -11
- package/dist/tools/ad-hoc-router.d.ts +2 -0
- package/dist/tools/ad-hoc-router.js +18 -2
- package/dist/tools/approval/gateway.js +28 -5
- package/dist/tools/capability-data.d.ts +36 -0
- package/dist/tools/capability-data.js +73 -0
- package/dist/tools/display/show-user.d.ts +1 -1
- package/dist/tools/execute-analysis.d.ts +80 -1
- package/dist/tools/execute-analysis.js +352 -110
- package/dist/tools/knowledge/check.d.ts +60 -0
- package/dist/tools/knowledge/check.js +92 -0
- package/dist/tools/knowledge/client.d.ts +382 -0
- package/dist/tools/knowledge/client.js +302 -0
- package/dist/tools/knowledge/environment.d.ts +78 -0
- package/dist/tools/knowledge/environment.js +105 -0
- package/dist/tools/knowledge/index.d.ts +49 -0
- package/dist/tools/knowledge/index.js +70 -0
- package/dist/tools/knowledge/local-slots.d.ts +62 -0
- package/dist/tools/knowledge/local-slots.js +177 -0
- package/dist/tools/knowledge/pin.d.ts +14 -0
- package/dist/tools/knowledge/pin.js +17 -0
- package/dist/tools/knowledge/recommend.d.ts +92 -0
- package/dist/tools/knowledge/recommend.js +98 -0
- package/dist/tools/knowledge/situation.d.ts +143 -0
- package/dist/tools/knowledge/situation.js +164 -0
- package/dist/tools/knowledge/skeleton.d.ts +69 -0
- package/dist/tools/knowledge/skeleton.js +202 -0
- package/dist/tools/knowledge/template.d.ts +137 -0
- package/dist/tools/knowledge/template.js +267 -0
- package/dist/tools/research/generate-plan.d.ts +23 -0
- package/dist/tools/research/generate-plan.js +203 -12
- package/dist/tools/research/guarded-address.d.ts +20 -0
- package/dist/tools/research/guarded-address.js +148 -0
- package/dist/tools/research/inspect-data-profile.d.ts +1 -1
- package/dist/tools/research/inspect-run.d.ts +1 -1
- package/dist/tools/research/inspect-run.js +7 -7
- package/dist/tools/research/searxng-engine.d.ts +40 -0
- package/dist/tools/research/searxng-engine.js +120 -0
- package/dist/tools/research/web-fetch.d.ts +124 -0
- package/dist/tools/research/web-fetch.js +627 -0
- package/dist/tools/research/web-search.d.ts +75 -0
- package/dist/tools/research/web-search.js +112 -0
- package/dist/tools/sandbox/list-available-packages.d.ts +7 -4
- package/dist/tools/sandbox/list-available-packages.js +12 -4
- package/dist/tools/sandbox/list-available-refs.d.ts +21 -0
- package/dist/tools/sandbox/list-available-refs.js +60 -3
- package/dist/tools/sandbox/report-blocker.js +5 -1
- package/dist/tools/terminal-text.d.ts +11 -0
- package/dist/tools/terminal-text.js +19 -0
- package/dist/tools/workspace/decision-record.d.ts +81 -0
- package/dist/tools/workspace/decision-record.js +117 -0
- package/dist/tools/workspace/edit-file.d.ts +3 -1
- package/dist/tools/workspace/edit-file.js +20 -2
- package/dist/tools/workspace/execute-command.d.ts +17 -0
- package/dist/tools/workspace/execute-command.js +28 -1
- package/dist/tools/workspace/index.d.ts +1 -0
- package/dist/tools/workspace/index.js +1 -0
- package/dist/tools/workspace/mutator.d.ts +1 -1
- package/dist/workflows/execute-analysis.d.ts +62 -21
- package/dist/workflows/execute-analysis.js +307 -79
- package/dist/workflows/plan/graph.d.ts +16 -0
- package/dist/workflows/plan/graph.js +36 -0
- package/dist/workflows/sandbox-step.d.ts +12 -0
- package/dist/workflows/sandbox-step.js +14 -12
- package/package.json +5 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `web_search` — the web step of the conversation agent's research.
|
|
3
|
+
*
|
|
4
|
+
* The tool queries a `WebSearchEngine` the embedder injects at the composition
|
|
5
|
+
* root; the SearXNG realization ships beside it in `searxng-engine.ts`. The
|
|
6
|
+
* tool owns the input contract and the search semantics the spec pins — the
|
|
7
|
+
* domain-filter terms and the host re-filter, the queries merge, the bound —
|
|
8
|
+
* and every engine failure rides the ok channel as a data-status variant,
|
|
9
|
+
* never as an error of the turn.
|
|
10
|
+
*/
|
|
11
|
+
import { type Result } from "neverthrow";
|
|
12
|
+
export interface WebSearchAnswer {
|
|
13
|
+
readonly answer?: string;
|
|
14
|
+
readonly url?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface WebSearchResult {
|
|
17
|
+
readonly title: string;
|
|
18
|
+
readonly url: string;
|
|
19
|
+
readonly snippet?: string;
|
|
20
|
+
}
|
|
21
|
+
export type WebSearchOutput = {
|
|
22
|
+
readonly status: "ok";
|
|
23
|
+
readonly answers: readonly WebSearchAnswer[];
|
|
24
|
+
readonly results: readonly WebSearchResult[];
|
|
25
|
+
} | {
|
|
26
|
+
readonly status: "http_error";
|
|
27
|
+
readonly httpStatus: number;
|
|
28
|
+
readonly text: string;
|
|
29
|
+
} | {
|
|
30
|
+
readonly status: "retryable_status";
|
|
31
|
+
readonly httpStatus: number;
|
|
32
|
+
readonly text: string;
|
|
33
|
+
} | {
|
|
34
|
+
readonly status: "schema_mismatch";
|
|
35
|
+
readonly text: string;
|
|
36
|
+
} | {
|
|
37
|
+
readonly status: "not_json";
|
|
38
|
+
readonly text: string;
|
|
39
|
+
readonly detail?: string;
|
|
40
|
+
};
|
|
41
|
+
/** One query with its terms already composed: the engine adds transport, not semantics. */
|
|
42
|
+
export interface WebSearchRequest {
|
|
43
|
+
/** The final query text, `site:` / `-site:` terms included. */
|
|
44
|
+
readonly query: string;
|
|
45
|
+
readonly recencyFilter?: "day" | "week" | "month" | "year";
|
|
46
|
+
}
|
|
47
|
+
/** What one query returned: the mapped answers and the mapped result rows. */
|
|
48
|
+
export type WebSearchPage = {
|
|
49
|
+
readonly answers: readonly WebSearchAnswer[];
|
|
50
|
+
readonly results: readonly WebSearchResult[];
|
|
51
|
+
};
|
|
52
|
+
/** The error side of the engine result: the data-status variants the tool reports as normal results. */
|
|
53
|
+
export type WebSearchEngineError = Exclude<WebSearchOutput, {
|
|
54
|
+
readonly status: "ok";
|
|
55
|
+
}>;
|
|
56
|
+
/**
|
|
57
|
+
* The injected web-search engine: an embedder supplies one realization, and the
|
|
58
|
+
* conversation agent attaches `web_search` iff it did. `search` gets the final
|
|
59
|
+
* query terms and the optional recency filter, and resolves to the page or to
|
|
60
|
+
* the error variant that names the failure.
|
|
61
|
+
*/
|
|
62
|
+
export interface WebSearchEngine {
|
|
63
|
+
search(request: WebSearchRequest): Promise<Result<WebSearchPage, WebSearchEngineError>>;
|
|
64
|
+
}
|
|
65
|
+
export interface WebSearchToolDeps {
|
|
66
|
+
/** The web-search engine the embedder injected. */
|
|
67
|
+
readonly engine: WebSearchEngine;
|
|
68
|
+
}
|
|
69
|
+
export declare function createWebSearchTool(deps: WebSearchToolDeps): import("../define-tool.js").Tool<{
|
|
70
|
+
numResults: number;
|
|
71
|
+
query?: string | undefined;
|
|
72
|
+
queries?: string[] | undefined;
|
|
73
|
+
recencyFilter?: "year" | "day" | "week" | "month" | undefined;
|
|
74
|
+
domainFilter?: string | undefined;
|
|
75
|
+
}, WebSearchOutput>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `web_search` — the web step of the conversation agent's research.
|
|
3
|
+
*
|
|
4
|
+
* The tool queries a `WebSearchEngine` the embedder injects at the composition
|
|
5
|
+
* root; the SearXNG realization ships beside it in `searxng-engine.ts`. The
|
|
6
|
+
* tool owns the input contract and the search semantics the spec pins — the
|
|
7
|
+
* domain-filter terms and the host re-filter, the queries merge, the bound —
|
|
8
|
+
* and every engine failure rides the ok channel as a data-status variant,
|
|
9
|
+
* never as an error of the turn.
|
|
10
|
+
*/
|
|
11
|
+
import { ok } from "neverthrow";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { defineTool } from "../define-tool.js";
|
|
14
|
+
function parseDomainFilter(raw) {
|
|
15
|
+
const trimmed = (raw ?? "").trim().toLowerCase();
|
|
16
|
+
if (trimmed === "" || trimmed === "-")
|
|
17
|
+
return null;
|
|
18
|
+
if (trimmed.startsWith("-")) {
|
|
19
|
+
const domain = trimmed.slice(1).trim();
|
|
20
|
+
return domain === "" ? null : { include: false, domain };
|
|
21
|
+
}
|
|
22
|
+
return { include: true, domain: trimmed };
|
|
23
|
+
}
|
|
24
|
+
function queryTerms(query, filter) {
|
|
25
|
+
if (!filter)
|
|
26
|
+
return query;
|
|
27
|
+
return `${query} ${filter.include ? "" : "-"}site:${filter.domain}`;
|
|
28
|
+
}
|
|
29
|
+
/** A domain matches a host and each subdomain of the domain. */
|
|
30
|
+
function hostMatchesDomain(host, domain) {
|
|
31
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
32
|
+
}
|
|
33
|
+
function hostOf(url) {
|
|
34
|
+
try {
|
|
35
|
+
return new URL(url).hostname;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Filter the merged rows by host again, because an engine can ignore `site:`. */
|
|
42
|
+
function applyDomainFilter(results, filter) {
|
|
43
|
+
if (!filter)
|
|
44
|
+
return [...results];
|
|
45
|
+
return results.filter((result) => {
|
|
46
|
+
const host = hostOf(result.url);
|
|
47
|
+
if (host === null)
|
|
48
|
+
return !filter.include;
|
|
49
|
+
const matches = hostMatchesDomain(host, filter.domain);
|
|
50
|
+
return filter.include ? matches : !matches;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export function createWebSearchTool(deps) {
|
|
54
|
+
return defineTool({
|
|
55
|
+
id: "web_search",
|
|
56
|
+
description: "Search the web for pages: titles, URLs, and short snippets, plus any direct answers the engines returned. " +
|
|
57
|
+
"Give `query` for one search, or `queries` (at most 5) for one pass whose answers and results merge into a single " +
|
|
58
|
+
"list of at most `numResults` results, duplicates by URL removed. `domainFilter` restricts the search to one domain " +
|
|
59
|
+
'and a leading "-" excludes it, for example "-pinterest.com". `recencyFilter` keeps only results from the last day, ' +
|
|
60
|
+
"week, month, or year. Read one of the returned pages with `web_fetch`.\n" +
|
|
61
|
+
"ABSENCE IS NORMAL: an empty results array means no page matched, and a status of http_error, retryable_status, " +
|
|
62
|
+
"schema_mismatch, or not_json names a search that did not answer. Report either one and continue, do not retry unchanged.",
|
|
63
|
+
inputSchema: z
|
|
64
|
+
.object({
|
|
65
|
+
query: z.string().min(1).optional().describe("One free-text search, for example a method name with its domain."),
|
|
66
|
+
queries: z
|
|
67
|
+
.array(z.string().min(1))
|
|
68
|
+
.min(1)
|
|
69
|
+
.max(5)
|
|
70
|
+
.optional()
|
|
71
|
+
.describe("Up to 5 searches that run in one pass. Answers and results merge into one bounded list."),
|
|
72
|
+
numResults: z.number().int().min(1).max(20).default(5).describe("Maximum merged results (1–20, default 5)."),
|
|
73
|
+
recencyFilter: z.enum(["day", "week", "month", "year"]).optional().describe("Keep only results from this recency."),
|
|
74
|
+
domainFilter: z
|
|
75
|
+
.string()
|
|
76
|
+
.optional()
|
|
77
|
+
.describe('One domain the search stays in, for example "nih.gov". A leading "-" excludes the domain instead.'),
|
|
78
|
+
})
|
|
79
|
+
.refine((input) => (input.query !== undefined) !== (input.queries !== undefined), {
|
|
80
|
+
message: "Give exactly one of `query` or `queries`.",
|
|
81
|
+
}),
|
|
82
|
+
describeCall: "none",
|
|
83
|
+
execute: async ({ query, queries, numResults, recencyFilter, domainFilter }) => {
|
|
84
|
+
const filter = parseDomainFilter(domainFilter);
|
|
85
|
+
// The input refine guarantees exactly one of `query` and `queries`.
|
|
86
|
+
const queryList = queries ?? [query];
|
|
87
|
+
const requests = queryList.map((raw) => deps.engine.search({ query: queryTerms(raw, filter), ...(recencyFilter ? { recencyFilter } : {}) }));
|
|
88
|
+
const responses = [];
|
|
89
|
+
for (const response of await Promise.all(requests)) {
|
|
90
|
+
// An engine failure is the data-status variant the tool reports, not a turn error.
|
|
91
|
+
if (response.isErr())
|
|
92
|
+
return ok(response.error);
|
|
93
|
+
responses.push(response.value);
|
|
94
|
+
}
|
|
95
|
+
const answers = [];
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
const results = [];
|
|
98
|
+
for (const response of responses) {
|
|
99
|
+
answers.push(...response.answers);
|
|
100
|
+
for (const result of applyDomainFilter(response.results, filter)) {
|
|
101
|
+
if (results.length >= numResults)
|
|
102
|
+
break;
|
|
103
|
+
if (seen.has(result.url))
|
|
104
|
+
continue;
|
|
105
|
+
seen.add(result.url);
|
|
106
|
+
results.push(result);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return ok({ status: "ok", answers, results });
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -41,13 +41,16 @@ export declare function trackOfLockSubtree(subtree: string): Track | undefined;
|
|
|
41
41
|
*/
|
|
42
42
|
export declare function lockSections(lock: FarmLock): Section[];
|
|
43
43
|
/**
|
|
44
|
-
* Group the image record into its
|
|
45
|
-
*
|
|
46
|
-
* with nothing under it.
|
|
44
|
+
* Group the image record into its sections, in the order of the record. An
|
|
45
|
+
* empty or absent track yields no section, so the report never carries a
|
|
46
|
+
* heading with nothing under it.
|
|
47
47
|
*
|
|
48
48
|
* A `system_tools` row renders its `executable` name where the record gives
|
|
49
49
|
* one, because an agent invokes the binary rather than the conda package
|
|
50
|
-
* (the manifest `binaries:` map holds the pairs that differ).
|
|
50
|
+
* (the manifest `binaries:` map holds the pairs that differ). The `r_base`
|
|
51
|
+
* rows are the R track: a `names` lookup of `survival` or `MASS` resolves
|
|
52
|
+
* there, with the version the runtime ships, and the `language: "r"` filter
|
|
53
|
+
* lists them beside the farm packages.
|
|
51
54
|
*/
|
|
52
55
|
export declare function imageSections(record: ImagePackages): Section[];
|
|
53
56
|
/** One package entry of a section, with the store identity where the source records it. */
|
|
@@ -53,6 +53,8 @@ const DEFAULT_IMAGE_PACKAGES_FILE = `${LIBS_CONTAINER_PATH}/${IMAGE_PACKAGES_FIL
|
|
|
53
53
|
*/
|
|
54
54
|
const CLI_TITLE = "System tools (CLI)";
|
|
55
55
|
const NODE_TITLE = "Node (npm)";
|
|
56
|
+
/** The R packages that ship with the R runtime of the image: the base and the recommended packages. */
|
|
57
|
+
const R_BASE_TITLE = "R (ships with R)";
|
|
56
58
|
/** The section title of each known lock track. An unknown track titles itself. */
|
|
57
59
|
const TRACK_TITLES = {
|
|
58
60
|
python: "Python (pip)",
|
|
@@ -101,16 +103,22 @@ export function lockSections(lock) {
|
|
|
101
103
|
});
|
|
102
104
|
}
|
|
103
105
|
/**
|
|
104
|
-
* Group the image record into its
|
|
105
|
-
*
|
|
106
|
-
* with nothing under it.
|
|
106
|
+
* Group the image record into its sections, in the order of the record. An
|
|
107
|
+
* empty or absent track yields no section, so the report never carries a
|
|
108
|
+
* heading with nothing under it.
|
|
107
109
|
*
|
|
108
110
|
* A `system_tools` row renders its `executable` name where the record gives
|
|
109
111
|
* one, because an agent invokes the binary rather than the conda package
|
|
110
|
-
* (the manifest `binaries:` map holds the pairs that differ).
|
|
112
|
+
* (the manifest `binaries:` map holds the pairs that differ). The `r_base`
|
|
113
|
+
* rows are the R track: a `names` lookup of `survival` or `MASS` resolves
|
|
114
|
+
* there, with the version the runtime ships, and the `language: "r"` filter
|
|
115
|
+
* lists them beside the farm packages.
|
|
111
116
|
*/
|
|
112
117
|
export function imageSections(record) {
|
|
113
118
|
const sections = [];
|
|
119
|
+
if (record.r_base !== undefined && record.r_base.length > 0) {
|
|
120
|
+
sections.push({ title: R_BASE_TITLE, track: "r", packages: record.r_base.map((pkg) => ({ name: pkg.name, version: pkg.version })) });
|
|
121
|
+
}
|
|
114
122
|
if (record.system_tools.length > 0) {
|
|
115
123
|
sections.push({
|
|
116
124
|
title: CLI_TITLE,
|
|
@@ -90,7 +90,28 @@ interface RawScan {
|
|
|
90
90
|
readonly receipts?: unknown[];
|
|
91
91
|
readonly legacyEntries?: unknown[];
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* The dataset ids the install receipts of the store report, for a caller that must know whether a
|
|
95
|
+
* declared dataset is present BEFORE a run starts.
|
|
96
|
+
*
|
|
97
|
+
* `undefined` means the store itself could not be answered for: an unreadable root, a `.inflexa`
|
|
98
|
+
* that crosses a symlink, or a receipts directory that exists and cannot be listed. That is NOT an
|
|
99
|
+
* empty install, and a caller must never collapse the two — every dataset would read as absent, and
|
|
100
|
+
* a launch would be refused over data that is sitting in the store.
|
|
101
|
+
*
|
|
102
|
+
* An empty set is a real answer: the store is readable and it holds no receipt.
|
|
103
|
+
*/
|
|
104
|
+
export declare function readInstalledDatasetIds(deps: ListAvailableRefsDeps): Promise<ReadonlySet<string> | undefined>;
|
|
93
105
|
/** Create reference discovery over the host-visible reference store. */
|
|
106
|
+
/**
|
|
107
|
+
* The whole enriched inventory of the store, for a host-side join: which
|
|
108
|
+
* collection a procedure names is present, and where. No envelope bound,
|
|
109
|
+
* because the caller keeps the join and drops the listing.
|
|
110
|
+
*/
|
|
111
|
+
export declare function readReferenceInventory(storeRoot: string): Promise<{
|
|
112
|
+
readonly available: boolean;
|
|
113
|
+
readonly entries: readonly ReferenceInventoryEntry[];
|
|
114
|
+
}>;
|
|
94
115
|
export declare function createListAvailableRefsTool(deps: ListAvailableRefsDeps): import("../define-tool.js").Tool<{
|
|
95
116
|
path?: string | undefined;
|
|
96
117
|
category?: string | undefined;
|
|
@@ -205,6 +205,54 @@ async function readReceipts(root) {
|
|
|
205
205
|
}
|
|
206
206
|
return receipts;
|
|
207
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* The dataset ids the install receipts of the store report, for a caller that must know whether a
|
|
210
|
+
* declared dataset is present BEFORE a run starts.
|
|
211
|
+
*
|
|
212
|
+
* `undefined` means the store itself could not be answered for: an unreadable root, a `.inflexa`
|
|
213
|
+
* that crosses a symlink, or a receipts directory that exists and cannot be listed. That is NOT an
|
|
214
|
+
* empty install, and a caller must never collapse the two — every dataset would read as absent, and
|
|
215
|
+
* a launch would be refused over data that is sitting in the store.
|
|
216
|
+
*
|
|
217
|
+
* An empty set is a real answer: the store is readable and it holds no receipt.
|
|
218
|
+
*/
|
|
219
|
+
export async function readInstalledDatasetIds(deps) {
|
|
220
|
+
const storeRoot = deps.refStorePath ?? REFS_ROOT;
|
|
221
|
+
try {
|
|
222
|
+
await lstat(storeRoot);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
if (await crossesSymlink(storeRoot, ".inflexa"))
|
|
228
|
+
return undefined;
|
|
229
|
+
const receiptsDirectory = hostJoin(storeRoot, ".inflexa", "receipts");
|
|
230
|
+
let names;
|
|
231
|
+
try {
|
|
232
|
+
names = (await readdir(receiptsDirectory)).slice(0, MAX_RECEIPT_FILES);
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
// An absent directory is a readable store with nothing installed. Any other failure is the
|
|
236
|
+
// store declining to answer, which is a different fact and gets the honest `undefined`.
|
|
237
|
+
if (error.code === "ENOENT")
|
|
238
|
+
return new Set();
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
const ids = new Set();
|
|
242
|
+
for (const name of names) {
|
|
243
|
+
// Per-file, so one malformed receipt costs only its own dataset. A shared try would drop
|
|
244
|
+
// every receipt after the bad one and read as a store that holds almost nothing.
|
|
245
|
+
try {
|
|
246
|
+
const receipt = parseReferenceInstallReceipt(JSON.parse(await readFile(hostJoin(receiptsDirectory, name), "utf-8")));
|
|
247
|
+
if (receipt)
|
|
248
|
+
ids.add(receipt.datasetId);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return ids;
|
|
255
|
+
}
|
|
208
256
|
/** Labels from a `registry.json` an externally-provisioned store may carry. */
|
|
209
257
|
async function readLegacyEntries(root) {
|
|
210
258
|
const legacyEntries = [];
|
|
@@ -465,8 +513,8 @@ function collectCategories(entries) {
|
|
|
465
513
|
}
|
|
466
514
|
function renderContent(path, scan, entries) {
|
|
467
515
|
if (scan.state === "unavailable")
|
|
468
|
-
return ("No reference store is provisioned. Nothing in the analysis environment can fetch reference data
|
|
469
|
-
"
|
|
516
|
+
return ("No reference store is provisioned for this session. Nothing in the analysis environment can fetch reference data, and a later call " +
|
|
517
|
+
"in this run gives the same answer. Plan with the data at hand, or ask for the reference by what it is.");
|
|
470
518
|
if (scan.state === "not_found")
|
|
471
519
|
return `Reference path does not exist: ${path}`;
|
|
472
520
|
if (scan.state === "not_a_directory")
|
|
@@ -499,6 +547,15 @@ function renderContent(path, scan, entries) {
|
|
|
499
547
|
return lines.join("\n");
|
|
500
548
|
}
|
|
501
549
|
/** Create reference discovery over the host-visible reference store. */
|
|
550
|
+
/**
|
|
551
|
+
* The whole enriched inventory of the store, for a host-side join: which
|
|
552
|
+
* collection a procedure names is present, and where. No envelope bound,
|
|
553
|
+
* because the caller keeps the join and drops the listing.
|
|
554
|
+
*/
|
|
555
|
+
export async function readReferenceInventory(storeRoot) {
|
|
556
|
+
const raw = await scanStore(storeRoot, ".", true);
|
|
557
|
+
return { available: raw.state !== "unavailable", entries: enrichEntries(raw.entries, raw) };
|
|
558
|
+
}
|
|
502
559
|
export function createListAvailableRefsTool(deps) {
|
|
503
560
|
const storeRoot = deps.refStorePath ?? REFS_ROOT;
|
|
504
561
|
return defineTool({
|
|
@@ -516,7 +573,7 @@ export function createListAvailableRefsTool(deps) {
|
|
|
516
573
|
"pass a group label as `query`, because `category` inspects a top-level directory by name and a label that is not a directory reads as `not_found`; " +
|
|
517
574
|
"`limit` caps the entries returned. The response carries `returned`, `total`, and `hasMore`, so truncation is never silent. " +
|
|
518
575
|
"An empty result means the dataset is absent right now — not that it is unobtainable. Say so; never guess a path, and never silently substitute a different dataset. " +
|
|
519
|
-
"The store is provisioned by the host and is NOT frozen: datasets can be added through the host's own provisioning path, and whatever is added shows up
|
|
576
|
+
"The store is provisioned by the host and is NOT frozen: datasets can be added through the host's own provisioning path between runs, and whatever is added shows up in a later run. Within one run the listing does not change, thus one call per question is enough.",
|
|
520
577
|
inputSchema: ListAvailableRefsInputSchema,
|
|
521
578
|
// `execute` resolves `path ?? category` for the subtree it inspects, and
|
|
522
579
|
// it ignores `category` when `path` is present. `query` is an ADDITIVE
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
* runs in durable steps, so it reads the blocker back from the transcript
|
|
19
19
|
* (`recordedBlocker`) on replay, not only from its cell.
|
|
20
20
|
*/
|
|
21
|
-
import { ok } from "neverthrow";
|
|
21
|
+
import { err, ok } from "neverthrow";
|
|
22
22
|
import { z } from "zod";
|
|
23
23
|
import { repairToolInput } from "../../lib/zod-issues.js";
|
|
24
24
|
import { toolOutcomeForOutputType } from "../../loop/tool-outcome.js";
|
|
25
|
+
import { degenerateTerminalText } from "../terminal-text.js";
|
|
25
26
|
import { defineTool } from "../define-tool.js";
|
|
26
27
|
export const REPORT_BLOCKER_TOOL_ID = "report_blocker";
|
|
27
28
|
export function createBlockerHolder() {
|
|
@@ -46,6 +47,9 @@ export function createReportBlockerToolFor(deps) {
|
|
|
46
47
|
inputSchema: ReportBlockerInputSchema,
|
|
47
48
|
describeCall: "none",
|
|
48
49
|
execute: async (input) => {
|
|
50
|
+
const degenerate = degenerateTerminalText(input.reason);
|
|
51
|
+
if (degenerate)
|
|
52
|
+
return err({ error: `${degenerate} Give the real reason, or continue the work.`, retryable: true });
|
|
49
53
|
deps.record({ kind: "blocker", reason: input.reason });
|
|
50
54
|
return ok({
|
|
51
55
|
recorded: true,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The text of a terminal call: a clarification question or a blocker reason.
|
|
3
|
+
*
|
|
4
|
+
* A terminal call whose text is a placeholder ends a run with nothing. A small
|
|
5
|
+
* model emits such a call when it drafts several calls at once and fills the
|
|
6
|
+
* text later, or never. The guard names the fault, the tool refuses the call,
|
|
7
|
+
* and the loop continues with the real work.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MIN_TERMINAL_TEXT = 4;
|
|
10
|
+
/** The reason a text cannot end a run, or undefined when it can. */
|
|
11
|
+
export declare function degenerateTerminalText(text: string): string | undefined;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The text of a terminal call: a clarification question or a blocker reason.
|
|
3
|
+
*
|
|
4
|
+
* A terminal call whose text is a placeholder ends a run with nothing. A small
|
|
5
|
+
* model emits such a call when it drafts several calls at once and fills the
|
|
6
|
+
* text later, or never. The guard names the fault, the tool refuses the call,
|
|
7
|
+
* and the loop continues with the real work.
|
|
8
|
+
*/
|
|
9
|
+
const PLACEHOLDER_TEXT = /^\s*(?:placeholder|todo|tbd|skip|n\/?a|none|null|test|x+|\.+|-+)\b/i;
|
|
10
|
+
export const MIN_TERMINAL_TEXT = 4;
|
|
11
|
+
/** The reason a text cannot end a run, or undefined when it can. */
|
|
12
|
+
export function degenerateTerminalText(text) {
|
|
13
|
+
const trimmed = text.trim();
|
|
14
|
+
if (trimmed.length < MIN_TERMINAL_TEXT)
|
|
15
|
+
return `The text has ${trimmed.length} characters, and a real question or reason has at least ${MIN_TERMINAL_TEXT}.`;
|
|
16
|
+
if (PLACEHOLDER_TEXT.test(trimmed) || /\bplaceholder\b/i.test(trimmed))
|
|
17
|
+
return "The text is a placeholder.";
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision record of a rendered script, as the file tools keep it true
|
|
3
|
+
* after the render. `knowledge_template` writes the record beside the
|
|
4
|
+
* script with the digest of the bytes it wrote. A later change of the
|
|
5
|
+
* script through `edit_file` appends an entry to `unvetted_edits` with the
|
|
6
|
+
* digest after the change, and `execute_command` compares the script it is
|
|
7
|
+
* about to run with the last recorded digest, thus a change by any other
|
|
8
|
+
* path is recorded before the script runs. The record then connects the
|
|
9
|
+
* cited decision to the bytes that ran, and an empty `unvetted_edits` is a
|
|
10
|
+
* fact and not an assumption.
|
|
11
|
+
*/
|
|
12
|
+
import type { AgentSession } from "../../auth/types.js";
|
|
13
|
+
import type { WorkspaceFilesystem } from "../../workspace/filesystem.js";
|
|
14
|
+
import type { MutateToolName, WorkspaceMutator, WriteRecord } from "./mutator.js";
|
|
15
|
+
/**
|
|
16
|
+
* The path of the decision record of one rendered script inside the step:
|
|
17
|
+
* `output/decision_record_<script stem>.json`. One record per render, thus a
|
|
18
|
+
* step that renders two scripts keeps both records.
|
|
19
|
+
*/
|
|
20
|
+
export declare function decisionRecordPath(scriptFile: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* The record path of a script path, or `undefined` when the path is not a
|
|
23
|
+
* script under `scripts/`. A relative `scripts/x.R` gives the relative
|
|
24
|
+
* record path, and an absolute `/<analysisId>/.../scripts/x.R` gives the
|
|
25
|
+
* absolute one, beside it in the same step.
|
|
26
|
+
*/
|
|
27
|
+
export declare function decisionRecordPathFor(scriptPath: string): string | undefined;
|
|
28
|
+
export declare function scriptSha256(content: string | Uint8Array): string;
|
|
29
|
+
/** One change of a script after its render, as the record keeps it. */
|
|
30
|
+
export interface UnvettedEdit {
|
|
31
|
+
readonly path: string;
|
|
32
|
+
readonly note: string;
|
|
33
|
+
/** The digest of the script after the change. */
|
|
34
|
+
readonly sha256: string;
|
|
35
|
+
}
|
|
36
|
+
/** The digest the record expects of the script now: the last recorded change, else the bytes the render wrote. */
|
|
37
|
+
export declare function expectedScriptSha256(record: Readonly<Record<string, unknown>>): string | undefined;
|
|
38
|
+
export interface NoteScriptChangeArgs {
|
|
39
|
+
readonly filesystem: WorkspaceFilesystem;
|
|
40
|
+
readonly mutator: WorkspaceMutator;
|
|
41
|
+
readonly session: AgentSession;
|
|
42
|
+
/** Absolute host base for a relative path; the analysis root when absent. */
|
|
43
|
+
readonly workingDir?: string;
|
|
44
|
+
readonly invocationId: string;
|
|
45
|
+
/** The tool that made the change, for the provenance of the record write. */
|
|
46
|
+
readonly toolName: MutateToolName;
|
|
47
|
+
/** The script path as the tool received it, relative or absolute. */
|
|
48
|
+
readonly scriptPath: string;
|
|
49
|
+
readonly note: string;
|
|
50
|
+
/** The digest of the script after the change. */
|
|
51
|
+
readonly sha256: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The outcome of a change note. `noted` carries the record of the write,
|
|
55
|
+
* thus the calling tool carries it as its call record, and its fold hands it
|
|
56
|
+
* to `recordWrite`.
|
|
57
|
+
*/
|
|
58
|
+
export type ScriptChangeNote = {
|
|
59
|
+
readonly status: "noted";
|
|
60
|
+
readonly write: WriteRecord;
|
|
61
|
+
} | {
|
|
62
|
+
readonly status: "no_record" | "refused";
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Append a change to the record of a script. `no_record` when the path is
|
|
66
|
+
* not a script under `scripts/`, or when no record exists for it, which is
|
|
67
|
+
* the state of every script an agent wrote itself. `refused` when the
|
|
68
|
+
* mutator refused the write of the record, for example a record of another
|
|
69
|
+
* step, outside the writable prefix of this one.
|
|
70
|
+
*/
|
|
71
|
+
export declare function noteScriptChange(args: NoteScriptChangeArgs): Promise<ScriptChangeNote>;
|
|
72
|
+
export type ReconcileScriptRecordArgs = Omit<NoteScriptChangeArgs, "note" | "sha256">;
|
|
73
|
+
/**
|
|
74
|
+
* Compare a script with the digest its record expects, before the script
|
|
75
|
+
* runs. A difference is a change that no tool of the host recorded, and it
|
|
76
|
+
* is noted with the digest of the bytes that are about to run. `unchanged`
|
|
77
|
+
* when the digests agree, `no_record` when the script has no record.
|
|
78
|
+
*/
|
|
79
|
+
export declare function reconcileScriptRecord(args: ReconcileScriptRecordArgs): Promise<ScriptChangeNote | {
|
|
80
|
+
readonly status: "unchanged";
|
|
81
|
+
}>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision record of a rendered script, as the file tools keep it true
|
|
3
|
+
* after the render. `knowledge_template` writes the record beside the
|
|
4
|
+
* script with the digest of the bytes it wrote. A later change of the
|
|
5
|
+
* script through `edit_file` appends an entry to `unvetted_edits` with the
|
|
6
|
+
* digest after the change, and `execute_command` compares the script it is
|
|
7
|
+
* about to run with the last recorded digest, thus a change by any other
|
|
8
|
+
* path is recorded before the script runs. The record then connects the
|
|
9
|
+
* cited decision to the bytes that ran, and an empty `unvetted_edits` is a
|
|
10
|
+
* fact and not an assumption.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { posix as posixPath } from "node:path";
|
|
14
|
+
import { unwrapOrThrow } from "../../lib/result.js";
|
|
15
|
+
/**
|
|
16
|
+
* The path of the decision record of one rendered script inside the step:
|
|
17
|
+
* `output/decision_record_<script stem>.json`. One record per render, thus a
|
|
18
|
+
* step that renders two scripts keeps both records.
|
|
19
|
+
*/
|
|
20
|
+
export function decisionRecordPath(scriptFile) {
|
|
21
|
+
const stem = scriptFile.replace(/\.[A-Za-z0-9]+$/, "");
|
|
22
|
+
return `output/decision_record_${stem}.json`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The record path of a script path, or `undefined` when the path is not a
|
|
26
|
+
* script under `scripts/`. A relative `scripts/x.R` gives the relative
|
|
27
|
+
* record path, and an absolute `/<analysisId>/.../scripts/x.R` gives the
|
|
28
|
+
* absolute one, beside it in the same step.
|
|
29
|
+
*/
|
|
30
|
+
export function decisionRecordPathFor(scriptPath) {
|
|
31
|
+
const dir = posixPath.dirname(scriptPath);
|
|
32
|
+
if (posixPath.basename(dir) !== "scripts")
|
|
33
|
+
return undefined;
|
|
34
|
+
const record = decisionRecordPath(posixPath.basename(scriptPath));
|
|
35
|
+
return dir === "scripts" ? record : posixPath.join(posixPath.dirname(dir), record);
|
|
36
|
+
}
|
|
37
|
+
export function scriptSha256(content) {
|
|
38
|
+
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
39
|
+
}
|
|
40
|
+
/** The digest the record expects of the script now: the last recorded change, else the bytes the render wrote. */
|
|
41
|
+
export function expectedScriptSha256(record) {
|
|
42
|
+
const edits = Array.isArray(record.unvetted_edits) ? record.unvetted_edits : [];
|
|
43
|
+
const last = edits[edits.length - 1]?.sha256;
|
|
44
|
+
if (typeof last === "string")
|
|
45
|
+
return last;
|
|
46
|
+
return typeof record.written_sha256 === "string" ? record.written_sha256 : undefined;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Append a change to the record of a script. `no_record` when the path is
|
|
50
|
+
* not a script under `scripts/`, or when no record exists for it, which is
|
|
51
|
+
* the state of every script an agent wrote itself. `refused` when the
|
|
52
|
+
* mutator refused the write of the record, for example a record of another
|
|
53
|
+
* step, outside the writable prefix of this one.
|
|
54
|
+
*/
|
|
55
|
+
export async function noteScriptChange(args) {
|
|
56
|
+
const recordPath = decisionRecordPathFor(args.scriptPath);
|
|
57
|
+
if (!recordPath)
|
|
58
|
+
return { status: "no_record" };
|
|
59
|
+
const read = unwrapOrThrow(await args.filesystem.readFile({ session: args.session, path: recordPath, ...(args.workingDir !== undefined ? { workingDir: args.workingDir } : {}) }));
|
|
60
|
+
if (read.kind !== "ok")
|
|
61
|
+
return { status: "no_record" };
|
|
62
|
+
let record;
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(read.content.toString("utf8"));
|
|
65
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
66
|
+
return { status: "no_record" };
|
|
67
|
+
record = parsed;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return { status: "no_record" };
|
|
71
|
+
}
|
|
72
|
+
const edits = Array.isArray(record.unvetted_edits) ? record.unvetted_edits : [];
|
|
73
|
+
const entry = { path: args.scriptPath, note: args.note, sha256: args.sha256 };
|
|
74
|
+
const next = { ...record, unvetted_edits: [...edits, entry] };
|
|
75
|
+
const written = await args.mutator.writeFile({
|
|
76
|
+
path: recordPath,
|
|
77
|
+
content: `${JSON.stringify(next, null, 2)}\n`,
|
|
78
|
+
toolName: args.toolName,
|
|
79
|
+
invocationId: args.invocationId,
|
|
80
|
+
session: args.session,
|
|
81
|
+
});
|
|
82
|
+
return written.status === "ok" ? { status: "noted", write: written.record } : { status: "refused" };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Compare a script with the digest its record expects, before the script
|
|
86
|
+
* runs. A difference is a change that no tool of the host recorded, and it
|
|
87
|
+
* is noted with the digest of the bytes that are about to run. `unchanged`
|
|
88
|
+
* when the digests agree, `no_record` when the script has no record.
|
|
89
|
+
*/
|
|
90
|
+
export async function reconcileScriptRecord(args) {
|
|
91
|
+
const recordPath = decisionRecordPathFor(args.scriptPath);
|
|
92
|
+
if (!recordPath)
|
|
93
|
+
return { status: "no_record" };
|
|
94
|
+
const base = args.workingDir !== undefined ? { workingDir: args.workingDir } : {};
|
|
95
|
+
const record = unwrapOrThrow(await args.filesystem.readFile({ session: args.session, path: recordPath, ...base }));
|
|
96
|
+
if (record.kind !== "ok")
|
|
97
|
+
return { status: "no_record" };
|
|
98
|
+
let expected;
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(record.content.toString("utf8"));
|
|
101
|
+
if (parsed === null || typeof parsed !== "object")
|
|
102
|
+
return { status: "no_record" };
|
|
103
|
+
expected = expectedScriptSha256(parsed);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return { status: "no_record" };
|
|
107
|
+
}
|
|
108
|
+
if (expected === undefined)
|
|
109
|
+
return { status: "no_record" };
|
|
110
|
+
const script = unwrapOrThrow(await args.filesystem.readFile({ session: args.session, path: args.scriptPath, ...base }));
|
|
111
|
+
if (script.kind !== "ok")
|
|
112
|
+
return { status: "no_record" };
|
|
113
|
+
const actual = scriptSha256(script.content);
|
|
114
|
+
if (actual === expected)
|
|
115
|
+
return { status: "unchanged" };
|
|
116
|
+
return noteScriptChange({ ...args, note: "the script changed by a path no file tool recorded, before it ran", sha256: actual });
|
|
117
|
+
}
|
|
@@ -51,7 +51,9 @@ export type EditFileResult = {
|
|
|
51
51
|
readonly bytesWritten: number;
|
|
52
52
|
/** Regex mode only: 1-based line numbers where the matches started. */
|
|
53
53
|
readonly lines?: readonly number[];
|
|
54
|
-
|
|
54
|
+
/** True when the file is a rendered script and its decision record now lists this edit. */
|
|
55
|
+
readonly decision_record_noted?: true;
|
|
56
|
+
}, readonly WriteRecord[]>;
|
|
55
57
|
export interface EditFileDeps {
|
|
56
58
|
readonly mutator: WorkspaceMutator;
|
|
57
59
|
/** Read seam used to fetch current file content for search/replace. */
|