@anweat/dsh-browser 0.1.17 → 0.2.0-rc.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/README.md +238 -71
- package/assets/skills/dsh-browser/SKILL.md +120 -0
- package/assets/skills/dsh-browser/references/develop.md +48 -0
- package/assets/skills/dsh-browser/references/observe.md +91 -0
- package/assets/skills/dsh-browser/references/opencli.md +25 -0
- package/assets/skills/dsh-browser/references/recipes.md +149 -0
- package/assets/skills/dsh-browser/references/userscripts.md +44 -0
- package/cordis.patch.yml +4 -0
- package/lib/actions/act.d.ts +3 -0
- package/lib/actions/act.js +147 -0
- package/lib/actions/act.js.map +1 -0
- package/lib/actions/automation.d.ts +3 -0
- package/lib/actions/automation.js +356 -0
- package/lib/actions/automation.js.map +1 -0
- package/lib/actions/crawl.d.ts +3 -0
- package/lib/actions/crawl.js +28 -0
- package/lib/actions/crawl.js.map +1 -0
- package/lib/actions/errors.d.ts +53 -0
- package/lib/actions/errors.js +178 -0
- package/lib/actions/errors.js.map +1 -0
- package/lib/actions/index-view.d.ts +42 -0
- package/lib/actions/index-view.js +268 -0
- package/lib/actions/index-view.js.map +1 -0
- package/lib/actions/inspect.d.ts +3 -0
- package/lib/actions/inspect.js +35 -0
- package/lib/actions/inspect.js.map +1 -0
- package/lib/actions/observe.d.ts +3 -0
- package/lib/actions/observe.js +121 -0
- package/lib/actions/observe.js.map +1 -0
- package/lib/actions/opencli.d.ts +3 -0
- package/lib/actions/opencli.js +51 -0
- package/lib/actions/opencli.js.map +1 -0
- package/lib/actions/registry.d.ts +35 -0
- package/lib/actions/registry.js +89 -0
- package/lib/actions/registry.js.map +1 -0
- package/lib/actions/run.d.ts +34 -0
- package/lib/actions/run.js +226 -0
- package/lib/actions/run.js.map +1 -0
- package/lib/actions/runtime.d.ts +3 -0
- package/lib/actions/runtime.js +33 -0
- package/lib/actions/runtime.js.map +1 -0
- package/lib/actions/schema.d.ts +31 -0
- package/lib/actions/schema.js +309 -0
- package/lib/actions/schema.js.map +1 -0
- package/lib/actions/script.d.ts +3 -0
- package/lib/actions/script.js +97 -0
- package/lib/actions/script.js.map +1 -0
- package/lib/actions/shared.d.ts +31 -0
- package/lib/actions/shared.js +45 -0
- package/lib/actions/shared.js.map +1 -0
- package/lib/actions/surface.d.ts +22 -0
- package/lib/actions/surface.js +18 -0
- package/lib/actions/surface.js.map +1 -0
- package/lib/actions/target.d.ts +3 -0
- package/lib/actions/target.js +66 -0
- package/lib/actions/target.js.map +1 -0
- package/lib/actions/types.d.ts +181 -0
- package/lib/actions/types.js +48 -0
- package/lib/actions/types.js.map +1 -0
- package/lib/activation-rules.d.ts +29 -0
- package/lib/activation-rules.js +33 -0
- package/lib/activation-rules.js.map +1 -0
- package/lib/approval-policy.d.ts +40 -2
- package/lib/approval-policy.js +184 -97
- package/lib/approval-policy.js.map +1 -1
- package/lib/automation-assets-rpc.d.ts +8 -2
- package/lib/automation-assets-rpc.js +67 -6
- package/lib/automation-assets-rpc.js.map +1 -1
- package/lib/automation-assets.d.ts +152 -4
- package/lib/automation-assets.js +255 -15
- package/lib/automation-assets.js.map +1 -1
- package/lib/automation-convert.d.ts +23 -0
- package/lib/automation-convert.js +110 -0
- package/lib/automation-convert.js.map +1 -0
- package/lib/automation-development.d.ts +26 -0
- package/lib/automation-development.js +47 -0
- package/lib/automation-development.js.map +1 -1
- package/lib/automation-execution.d.ts +57 -1
- package/lib/automation-execution.js +167 -11
- package/lib/automation-execution.js.map +1 -1
- package/lib/automation-journal.d.ts +92 -0
- package/lib/automation-journal.js +449 -0
- package/lib/automation-journal.js.map +1 -0
- package/lib/automation-v2.d.ts +138 -0
- package/lib/automation-v2.js +631 -0
- package/lib/automation-v2.js.map +1 -0
- package/lib/automation.d.ts +82 -5
- package/lib/automation.js +239 -82
- package/lib/automation.js.map +1 -1
- package/lib/browser-service.d.ts +304 -32
- package/lib/browser-service.js +631 -182
- package/lib/browser-service.js.map +1 -1
- package/lib/client/SettingsCard.js +95 -57
- package/lib/client/SettingsCard.js.map +1 -1
- package/lib/client/asset-editor.d.ts +55 -0
- package/lib/client/asset-editor.js +101 -0
- package/lib/client/asset-editor.js.map +1 -0
- package/lib/client/automation-assets-client.d.ts +99 -2
- package/lib/client/automation-assets-client.js +243 -10
- package/lib/client/automation-assets-client.js.map +1 -1
- package/lib/client/form.d.ts +48 -4
- package/lib/client/form.js +142 -29
- package/lib/client/form.js.map +1 -1
- package/lib/client/index.d.ts +25 -2
- package/lib/client/index.js +5 -1
- package/lib/client/index.js.map +1 -1
- package/lib/client/locales.d.ts +85 -6
- package/lib/client/locales.js +64 -12
- package/lib/client/locales.js.map +1 -1
- package/lib/client/prompts-form.d.ts +67 -0
- package/lib/client/prompts-form.js +140 -0
- package/lib/client/prompts-form.js.map +1 -0
- package/lib/client/prompts-status-client.d.ts +34 -0
- package/lib/client/prompts-status-client.js +53 -0
- package/lib/client/prompts-status-client.js.map +1 -0
- package/lib/client.js +1914 -329
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +50 -1
- package/lib/config.js +31 -1
- package/lib/config.js.map +1 -1
- package/lib/dialogs.d.ts +37 -0
- package/lib/dialogs.js +73 -0
- package/lib/dialogs.js.map +1 -0
- package/lib/freedom.d.ts +23 -7
- package/lib/freedom.js +48 -59
- package/lib/freedom.js.map +1 -1
- package/lib/index.d.ts +4 -4
- package/lib/index.js +36 -10
- package/lib/index.js.map +1 -1
- package/lib/journal.d.ts +109 -0
- package/lib/journal.js +238 -0
- package/lib/journal.js.map +1 -0
- package/lib/locator.d.ts +82 -0
- package/lib/locator.js +190 -0
- package/lib/locator.js.map +1 -0
- package/lib/observe-script.d.ts +27 -0
- package/lib/observe-script.js +565 -0
- package/lib/observe-script.js.map +1 -0
- package/lib/observe.d.ts +179 -0
- package/lib/observe.js +368 -0
- package/lib/observe.js.map +1 -0
- package/lib/policy-keys.d.ts +30 -0
- package/lib/policy-keys.js +34 -0
- package/lib/policy-keys.js.map +1 -0
- package/lib/prompt-limits.d.ts +30 -0
- package/lib/prompt-limits.js +31 -0
- package/lib/prompt-limits.js.map +1 -0
- package/lib/prompts.d.ts +131 -0
- package/lib/prompts.js +295 -0
- package/lib/prompts.js.map +1 -0
- package/lib/skill.d.ts +100 -0
- package/lib/skill.js +181 -0
- package/lib/skill.js.map +1 -0
- package/lib/tool-defs.d.ts +72 -0
- package/lib/tool-defs.js +67 -0
- package/lib/tool-defs.js.map +1 -0
- package/lib/tools.d.ts +19 -4
- package/lib/tools.js +124 -922
- package/lib/tools.js.map +1 -1
- package/package.json +5 -1
- package/scripts/dump-prompts.mjs +29 -0
- package/scripts/measure-tool-surface.mjs +190 -0
|
@@ -1,11 +1,58 @@
|
|
|
1
1
|
/** Bounded, local automation-asset lifecycle and retrieval. */
|
|
2
|
-
import type
|
|
2
|
+
import { type AnyRecipeStep, type BrowserRecipeStep, type RecipeExecutionStatus, type RecipeValidationStatus } from './automation.ts';
|
|
3
|
+
import { type InputSpec, type OutputSpec, type PendingDisambiguation, type Postcondition } from './automation-v2.ts';
|
|
3
4
|
export declare const ASSET_PERSISTENCE_MODES: readonly ["off", "manual", "suggest", "auto-draft"];
|
|
4
5
|
export type AssetPersistenceMode = typeof ASSET_PERSISTENCE_MODES[number];
|
|
5
6
|
export declare const ASSET_ACTIVATION_MODES: readonly ["manual", "auto-tested"];
|
|
6
7
|
export type AssetActivationMode = typeof ASSET_ACTIVATION_MODES[number];
|
|
7
8
|
export type AutomationAssetKind = 'recipe' | 'userscript';
|
|
8
9
|
export type AutomationAssetStatus = 'draft' | 'active' | 'archived';
|
|
10
|
+
/**
|
|
11
|
+
* How strongly the last passing test confirmed the result. `verified`: a recipe
|
|
12
|
+
* with assert steps ran and every one held. `legacy-unverified`: the steps ran
|
|
13
|
+
* without raising, but nothing checked the outcome (recipes without an assert
|
|
14
|
+
* step, UserScripts, and every asset saved before B2). A missing value on stored
|
|
15
|
+
* data means `legacy-unverified`.
|
|
16
|
+
*/
|
|
17
|
+
export type EvidenceLevel = 'verified' | 'legacy-unverified';
|
|
18
|
+
/** Recipe/UserScript test credentials kept per asset unless `maxTestCredentials` says otherwise. */
|
|
19
|
+
export declare const DEFAULT_TEST_CREDENTIALS = 5;
|
|
20
|
+
/**
|
|
21
|
+
* What one runtime test proved, bound to the exact content it ran against. Activation trusts
|
|
22
|
+
* only a credential whose revision and contentHash match the asset as it is now.
|
|
23
|
+
* Inputs are kept as a digest, never as values.
|
|
24
|
+
*/
|
|
25
|
+
export interface TestCredential {
|
|
26
|
+
revision: number;
|
|
27
|
+
/** {@link computeContentHash} of the content that was tested. */
|
|
28
|
+
contentHash: string;
|
|
29
|
+
/** sha256 of the typed inputSchema (or of the declared input names for v1 and UserScripts). */
|
|
30
|
+
inputSchemaHash: string;
|
|
31
|
+
schemaVersion: 1 | 2;
|
|
32
|
+
testedAt: string;
|
|
33
|
+
/** Truncated sha256 of the canonical inputs the test ran with. */
|
|
34
|
+
inputsDigest: string;
|
|
35
|
+
executionStatus: RecipeExecutionStatus;
|
|
36
|
+
validationStatus: RecipeValidationStatus;
|
|
37
|
+
evidenceLevel: EvidenceLevel;
|
|
38
|
+
/** The test counted as passed: it completed, nothing failed, and (v2) something verified the result. */
|
|
39
|
+
passed: boolean;
|
|
40
|
+
/** Synthesized when data written before B4 was loaded: `testStatus` was `passed` and nothing else is known. */
|
|
41
|
+
legacy?: true;
|
|
42
|
+
/** A test with several input sets (each in a fresh context): one entry per set that ran. Digests only. */
|
|
43
|
+
inputSets?: {
|
|
44
|
+
index: number;
|
|
45
|
+
inputsDigest: string;
|
|
46
|
+
passed: boolean;
|
|
47
|
+
executionStatus: RecipeExecutionStatus;
|
|
48
|
+
validationStatus: RecipeValidationStatus;
|
|
49
|
+
outputsDigest: string;
|
|
50
|
+
}[];
|
|
51
|
+
/** How many sets the test was asked to run (more than `inputSets.length` when a failing set stopped it). */
|
|
52
|
+
plannedSets?: number;
|
|
53
|
+
/** Cautions on a passing test, e.g. `PARAMETERIZATION_SUSPECT`: different inputs gave identical outputs. */
|
|
54
|
+
warnings?: string[];
|
|
55
|
+
}
|
|
9
56
|
export interface AutomationAssetPolicyInput {
|
|
10
57
|
enabled?: boolean;
|
|
11
58
|
directory?: string;
|
|
@@ -24,6 +71,13 @@ export interface AutomationAssetPolicyInput {
|
|
|
24
71
|
catalogTokenBudget?: number;
|
|
25
72
|
modelDevelopmentEnabled?: boolean;
|
|
26
73
|
maxModelDraftWritesPerSession?: number;
|
|
74
|
+
/** How many test credentials each asset keeps (the most recent ones). Default 5. */
|
|
75
|
+
maxTestCredentials?: number;
|
|
76
|
+
/**
|
|
77
|
+
* How many input sets the passing test of an asset that declares inputs must have covered before it can be activated
|
|
78
|
+
* (1 to 5, default 2). Assets without inputs are not affected.
|
|
79
|
+
*/
|
|
80
|
+
minInputSetsForActivation?: number;
|
|
27
81
|
}
|
|
28
82
|
export interface AutomationAssetPolicy {
|
|
29
83
|
enabled: boolean;
|
|
@@ -43,6 +97,8 @@ export interface AutomationAssetPolicy {
|
|
|
43
97
|
catalogTokenBudget: number;
|
|
44
98
|
modelDevelopmentEnabled: boolean;
|
|
45
99
|
maxModelDraftWritesPerSession: number;
|
|
100
|
+
maxTestCredentials: number;
|
|
101
|
+
minInputSetsForActivation: number;
|
|
46
102
|
}
|
|
47
103
|
export interface AutomationCandidate {
|
|
48
104
|
id: string;
|
|
@@ -67,11 +123,45 @@ export interface AutomationAsset {
|
|
|
67
123
|
domains: string[];
|
|
68
124
|
tags: string[];
|
|
69
125
|
inputNames: string[];
|
|
70
|
-
|
|
126
|
+
/** v1 steps (`selector`, first match) or, when `schemaVersion` is 2, {@link BrowserRecipeStepV2}. */
|
|
127
|
+
recipe?: AnyRecipeStep[];
|
|
71
128
|
source?: string;
|
|
129
|
+
/** Recipe schema. Absent means 1: `.first()` locating and the v1 fill rule, unchanged. */
|
|
130
|
+
schemaVersion?: 1 | 2;
|
|
131
|
+
/** v2: typed inputs. When present they are validated and converted at run time. */
|
|
132
|
+
inputSchema?: InputSpec[];
|
|
133
|
+
/** v2: named, typed outputs of `extract` steps with `as`. */
|
|
134
|
+
outputSchema?: OutputSpec[];
|
|
135
|
+
/** v2: conditions that must hold after the steps for the result to count as verified. */
|
|
136
|
+
postconditions?: Postcondition[];
|
|
137
|
+
/** v2: recorded only; not enforced yet. */
|
|
138
|
+
requiredCapabilities?: string[];
|
|
139
|
+
/** v2 converted from v1: steps that still take the first match and should be made unique. Derived from the recipe on save. */
|
|
140
|
+
pendingDisambiguation?: PendingDisambiguation[];
|
|
141
|
+
/** Set when this draft was converted from another asset; the source is never modified. */
|
|
142
|
+
sourceAssetId?: string;
|
|
143
|
+
sourceRevision?: number;
|
|
144
|
+
/**
|
|
145
|
+
* Set on a draft built from an exploration journal (`draft_from_journal`). `unmapped` counts the journaled
|
|
146
|
+
* actions that could not become steps; while it is above zero the draft is a half-finished copy of the
|
|
147
|
+
* exploration and cannot be activated. Saving the draft again with an explicit recipe replaces it and drops this.
|
|
148
|
+
*/
|
|
149
|
+
origin?: {
|
|
150
|
+
kind: 'journal';
|
|
151
|
+
fromSeq: number;
|
|
152
|
+
toSeq: number;
|
|
153
|
+
unmapped: number;
|
|
154
|
+
};
|
|
155
|
+
/** Bumped by every save, never reused. */
|
|
72
156
|
revision: number;
|
|
157
|
+
/** sha256 over the recipe or source, schemaVersion, inputSchema, outputSchema, postconditions and domains. Derived; recomputed on load. */
|
|
158
|
+
contentHash?: string;
|
|
159
|
+
/** The most recent runtime tests, oldest first. Activation checks the one bound to the current revision. */
|
|
160
|
+
testCredentials?: TestCredential[];
|
|
73
161
|
testStatus: 'untested' | 'passed' | 'failed';
|
|
74
162
|
testMessage?: string;
|
|
163
|
+
/** Optional on stored data; absent means `legacy-unverified`. Set only by a passing runtime test. */
|
|
164
|
+
evidenceLevel?: EvidenceLevel;
|
|
75
165
|
successCount: number;
|
|
76
166
|
failureCount: number;
|
|
77
167
|
createdAt: string;
|
|
@@ -87,6 +177,10 @@ export interface AutomationAssetSummary {
|
|
|
87
177
|
domains: string[];
|
|
88
178
|
tags: string[];
|
|
89
179
|
inputNames: string[];
|
|
180
|
+
/** Only present for schema v2 assets. */
|
|
181
|
+
schemaVersion?: 2;
|
|
182
|
+
/** v2 assets: the typed inputs a caller must pass to automation.run. */
|
|
183
|
+
inputSchema?: InputSpec[];
|
|
90
184
|
revision: number;
|
|
91
185
|
testStatus: AutomationAsset['testStatus'];
|
|
92
186
|
successCount: number;
|
|
@@ -113,8 +207,38 @@ export interface AutomationAssetSnapshot {
|
|
|
113
207
|
}
|
|
114
208
|
export declare function defaultAutomationAssetDirectory(): string;
|
|
115
209
|
export declare function resolveAutomationAssetPolicy(input?: AutomationAssetPolicyInput): AutomationAssetPolicy;
|
|
210
|
+
type HashedContent = Pick<AutomationAsset, 'kind' | 'recipe' | 'source' | 'schemaVersion' | 'inputSchema' | 'outputSchema' | 'postconditions' | 'domains'>;
|
|
211
|
+
/**
|
|
212
|
+
* What a test vouches for: the steps or source, the schema version and the three v2 contracts, and the
|
|
213
|
+
* domains it may run on. Name, description, tags and counters are not content. Domains are a set.
|
|
214
|
+
*/
|
|
215
|
+
export declare function computeContentHash(asset: HashedContent): string;
|
|
216
|
+
/** A digest of the inputs a test ran with. The values themselves are never stored. */
|
|
217
|
+
export declare function digestInputs(inputs: unknown): string;
|
|
218
|
+
/** Why an activation request was refused; the message says what to do. */
|
|
219
|
+
export type ActivationRefusal = 'expected-revision-required' | 'revision-mismatch' | 'not-tested' | 'test-failed' | 'content-changed' | 'no-domain' | 'limit-reached' | 'incomplete-draft' | 'insufficient-input-sets';
|
|
220
|
+
export declare class ActivationRefusedError extends Error {
|
|
221
|
+
readonly reason: ActivationRefusal;
|
|
222
|
+
constructor(reason: ActivationRefusal, message: string);
|
|
223
|
+
}
|
|
116
224
|
/** Remove concrete form values and non-semantic output from a successful recipe. */
|
|
117
225
|
export declare function normalizeRecipeForCandidate(steps: BrowserRecipeStep[]): BrowserRecipeStep[];
|
|
226
|
+
/** What a test reports besides pass/fail, for the credential it leaves. */
|
|
227
|
+
export interface TestDetails {
|
|
228
|
+
/** The inputs the test ran with; only their digest is kept. */
|
|
229
|
+
inputs?: unknown;
|
|
230
|
+
executionStatus?: RecipeExecutionStatus;
|
|
231
|
+
validationStatus?: RecipeValidationStatus;
|
|
232
|
+
inputSchemaHash?: string;
|
|
233
|
+
/** The revision and content that were tested; defaults to the asset's current ones. */
|
|
234
|
+
tested?: {
|
|
235
|
+
revision: number;
|
|
236
|
+
contentHash: string;
|
|
237
|
+
};
|
|
238
|
+
inputSets?: NonNullable<TestCredential['inputSets']>;
|
|
239
|
+
plannedSets?: number;
|
|
240
|
+
warnings?: string[];
|
|
241
|
+
}
|
|
118
242
|
export declare class AutomationAssetStore {
|
|
119
243
|
readonly policy: AutomationAssetPolicy;
|
|
120
244
|
private readonly statePath;
|
|
@@ -126,14 +250,38 @@ export declare class AutomationAssetStore {
|
|
|
126
250
|
summarizeCandidate(id: string): AutomationAsset;
|
|
127
251
|
dismissCandidate(id: string): void;
|
|
128
252
|
saveDraft(input: Partial<AutomationAsset> & Pick<AutomationAsset, 'kind' | 'name'>): AutomationAsset;
|
|
253
|
+
/** Validate the v2 steps and asset-level fields of a draft about to be saved. Throws RecipeValidationError. */
|
|
254
|
+
private checkV2;
|
|
129
255
|
validate(id: string): AutomationAsset;
|
|
130
|
-
|
|
256
|
+
/**
|
|
257
|
+
* Copy an asset into a NEW draft that remembers where it came from (`sourceAssetId`, `sourceRevision`).
|
|
258
|
+
* This is how an active asset is repaired: active assets cannot be edited, the copy can, and the source
|
|
259
|
+
* keeps running until the copy is tested and activated (which then archives the source).
|
|
260
|
+
*/
|
|
261
|
+
fork(id: string): AutomationAsset;
|
|
262
|
+
/**
|
|
263
|
+
* Change an asset's status. Activation is the guarded one: the request must name the revision the caller
|
|
264
|
+
* looked at (`expectedRevision`), and that revision, as it is now, needs a passed test credential bound to
|
|
265
|
+
* its exact content. A draft that was forked from (or converted from) an active asset replaces it: the
|
|
266
|
+
* source is archived in the same write, so the repaired asset never runs next to the one it fixes.
|
|
267
|
+
*/
|
|
268
|
+
setStatus(id: string, status: AutomationAssetStatus, options?: {
|
|
269
|
+
expectedRevision?: number;
|
|
270
|
+
}): AutomationAsset;
|
|
271
|
+
/** Throws {@link ActivationRefusedError} unless `asset` may become active; returns the active asset it replaces, if any. */
|
|
272
|
+
private checkActivation;
|
|
131
273
|
search(query: string, domain?: string, status?: AutomationAssetStatus | 'all', kind?: AutomationAssetKind): AutomationAssetSummary[];
|
|
132
274
|
noteRun(id: string, ok: boolean): void;
|
|
133
|
-
|
|
275
|
+
/**
|
|
276
|
+
* Record one runtime test as a credential bound to the content that ran. The asset's own `testStatus`
|
|
277
|
+
* moves only if that content is still the asset's current content: a test that finishes after a newer
|
|
278
|
+
* save leaves a credential for the old revision and changes nothing else.
|
|
279
|
+
*/
|
|
280
|
+
noteTestResult(id: string, ok: boolean, url: string, evidenceLevel?: EvidenceLevel, failureReason?: string, details?: TestDetails): void;
|
|
134
281
|
assertTarget(asset: AutomationAsset, url: string): void;
|
|
135
282
|
private requireAsset;
|
|
136
283
|
private prune;
|
|
137
284
|
private read;
|
|
138
285
|
private write;
|
|
139
286
|
}
|
|
287
|
+
export {};
|
package/lib/automation-assets.js
CHANGED
|
@@ -3,9 +3,15 @@ import crypto from 'node:crypto';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { validateRecipeEnums } from "./automation.js";
|
|
7
|
+
import { RecipeValidationError } from "./actions/errors.js";
|
|
8
|
+
import { normalizeInputSchema, normalizeOutputSchema, normalizePostconditions, normalizeRequiredCapabilities, pendingDisambiguation, placeholderNames, validateRecipeV2, hostInDomains, } from "./automation-v2.js";
|
|
6
9
|
import { validateUserscript } from "./scripts.js";
|
|
10
|
+
import { INPUT_SET_MAX, inputSetShortfall } from "./activation-rules.js";
|
|
7
11
|
export const ASSET_PERSISTENCE_MODES = ['off', 'manual', 'suggest', 'auto-draft'];
|
|
8
12
|
export const ASSET_ACTIVATION_MODES = ['manual', 'auto-tested'];
|
|
13
|
+
/** Recipe/UserScript test credentials kept per asset unless `maxTestCredentials` says otherwise. */
|
|
14
|
+
export const DEFAULT_TEST_CREDENTIALS = 5;
|
|
9
15
|
const DEFAULT_STATE = { version: 1, candidates: [], assets: [] };
|
|
10
16
|
function boundedInteger(value, fallback, min, max) {
|
|
11
17
|
return typeof value === 'number' && Number.isInteger(value) ? Math.min(Math.max(value, min), max) : fallback;
|
|
@@ -42,11 +48,52 @@ export function resolveAutomationAssetPolicy(input = {}) {
|
|
|
42
48
|
catalogTokenBudget: boundedInteger(input.catalogTokenBudget, 800, 100, 4_000),
|
|
43
49
|
modelDevelopmentEnabled: input.modelDevelopmentEnabled ?? true,
|
|
44
50
|
maxModelDraftWritesPerSession: boundedInteger(input.maxModelDraftWritesPerSession, 3, 1, 20),
|
|
51
|
+
maxTestCredentials: boundedInteger(input.maxTestCredentials, DEFAULT_TEST_CREDENTIALS, 1, 20),
|
|
52
|
+
minInputSetsForActivation: boundedInteger(input.minInputSetsForActivation, 2, 1, 5),
|
|
45
53
|
};
|
|
46
54
|
}
|
|
47
55
|
function nowIso(now = Date.now()) { return new Date(now).toISOString(); }
|
|
48
56
|
function uid() { return crypto.randomUUID(); }
|
|
49
57
|
function hash(value) { return crypto.createHash('sha256').update(value).digest('hex'); }
|
|
58
|
+
/** JSON with sorted keys and no undefined members: the same value always gives the same text. */
|
|
59
|
+
function canonicalJson(value) {
|
|
60
|
+
if (Array.isArray(value))
|
|
61
|
+
return '[' + value.map(entry => canonicalJson(entry)).join(',') + ']';
|
|
62
|
+
if (value && typeof value === 'object') {
|
|
63
|
+
const record = value;
|
|
64
|
+
return '{' + Object.keys(record).filter(key => record[key] !== undefined).sort().map(key => JSON.stringify(key) + ':' + canonicalJson(record[key])).join(',') + '}';
|
|
65
|
+
}
|
|
66
|
+
return JSON.stringify(value) ?? 'null';
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* What a test vouches for: the steps or source, the schema version and the three v2 contracts, and the
|
|
70
|
+
* domains it may run on. Name, description, tags and counters are not content. Domains are a set.
|
|
71
|
+
*/
|
|
72
|
+
export function computeContentHash(asset) {
|
|
73
|
+
return hash(canonicalJson({
|
|
74
|
+
...asset.kind === 'userscript' ? { source: asset.source ?? '' } : { recipe: asset.recipe ?? [] },
|
|
75
|
+
schemaVersion: asset.schemaVersion ?? 1,
|
|
76
|
+
inputSchema: asset.inputSchema ?? null,
|
|
77
|
+
outputSchema: asset.outputSchema ?? null,
|
|
78
|
+
postconditions: asset.postconditions ?? null,
|
|
79
|
+
domains: [...asset.domains].sort(),
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
function computeInputSchemaHash(asset) {
|
|
83
|
+
return hash(canonicalJson(asset.inputSchema ?? { inputNames: [...asset.inputNames].sort() }));
|
|
84
|
+
}
|
|
85
|
+
/** A digest of the inputs a test ran with. The values themselves are never stored. */
|
|
86
|
+
export function digestInputs(inputs) {
|
|
87
|
+
return hash(canonicalJson(inputs ?? {})).slice(0, 16);
|
|
88
|
+
}
|
|
89
|
+
export class ActivationRefusedError extends Error {
|
|
90
|
+
reason;
|
|
91
|
+
constructor(reason, message) {
|
|
92
|
+
super(message);
|
|
93
|
+
this.reason = reason;
|
|
94
|
+
this.name = 'ActivationRefusedError';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
50
97
|
function safeDomain(url) {
|
|
51
98
|
const parsed = new URL(url);
|
|
52
99
|
if (!['http:', 'https:'].includes(parsed.protocol))
|
|
@@ -99,7 +146,7 @@ function candidateFingerprint(domain, steps) {
|
|
|
99
146
|
}
|
|
100
147
|
function summary(asset) {
|
|
101
148
|
const { id, kind, status, name, description, domains, tags, inputNames, revision, testStatus, successCount, failureCount, updatedAt, lastRunAt } = asset;
|
|
102
|
-
return { id, kind, status, name, description, domains: [...domains], tags: [...tags], inputNames: [...inputNames], revision, testStatus, successCount, failureCount, updatedAt, ...lastRunAt ? { lastRunAt } : {} };
|
|
149
|
+
return { id, kind, status, name, description, domains: [...domains], tags: [...tags], inputNames: [...inputNames], ...asset.schemaVersion === 2 ? { schemaVersion: 2, ...asset.inputSchema ? { inputSchema: structuredClone(asset.inputSchema) } : {} } : {}, revision, testStatus, successCount, failureCount, updatedAt, ...lastRunAt ? { lastRunAt } : {} };
|
|
103
150
|
}
|
|
104
151
|
function persistedState(value) {
|
|
105
152
|
if (!value || typeof value !== 'object')
|
|
@@ -121,11 +168,33 @@ function persistedState(value) {
|
|
|
121
168
|
|| !Array.isArray(asset.tags) || asset.tags.length > 20 || !Array.isArray(asset.inputNames) || asset.inputNames.length > 20
|
|
122
169
|
|| (asset.kind === 'recipe' && (!Array.isArray(asset.recipe) || asset.recipe.length < 1 || asset.recipe.length > 25))
|
|
123
170
|
|| (asset.kind === 'userscript' && (typeof asset.source !== 'string' || Buffer.byteLength(asset.source, 'utf8') > 64 * 1024))
|
|
171
|
+
|| (asset.schemaVersion !== undefined && ![1, 2].includes(asset.schemaVersion))
|
|
124
172
|
|| !Number.isInteger(asset.revision) || !Number.isFinite(Date.parse(asset.createdAt)) || !Number.isFinite(Date.parse(asset.updatedAt)));
|
|
125
173
|
if (malformedCandidate || malformedAsset)
|
|
126
174
|
throw new Error('persisted asset entries are malformed');
|
|
175
|
+
const malformedCredentials = state.assets.some(asset => asset.testCredentials !== undefined
|
|
176
|
+
&& (!Array.isArray(asset.testCredentials) || asset.testCredentials.some(entry => !entry || typeof entry !== 'object' || !Number.isInteger(entry.revision) || typeof entry.contentHash !== 'string')));
|
|
177
|
+
if (malformedCredentials)
|
|
178
|
+
throw new Error('persisted test credentials are malformed');
|
|
179
|
+
for (const asset of state.assets)
|
|
180
|
+
upgradeLegacyAsset(asset);
|
|
127
181
|
return { version: 1, candidates: state.candidates, assets: state.assets };
|
|
128
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Fill in what data written before B4 lacks, without touching any existing field: the content hash is
|
|
185
|
+
* derived, and a `testStatus` of `passed` becomes a legacy credential bound to the current revision.
|
|
186
|
+
*/
|
|
187
|
+
function upgradeLegacyAsset(asset) {
|
|
188
|
+
asset.contentHash = computeContentHash(asset);
|
|
189
|
+
if (asset.testStatus === 'passed' && !asset.testCredentials?.some(entry => entry.revision === asset.revision)) {
|
|
190
|
+
const legacy = {
|
|
191
|
+
revision: asset.revision, contentHash: asset.contentHash, inputSchemaHash: computeInputSchemaHash(asset), schemaVersion: asset.schemaVersion ?? 1,
|
|
192
|
+
testedAt: asset.updatedAt, inputsDigest: 'legacy', executionStatus: 'completed',
|
|
193
|
+
validationStatus: asset.evidenceLevel === 'verified' ? 'passed' : 'not_checked', evidenceLevel: asset.evidenceLevel ?? 'legacy-unverified', passed: true, legacy: true,
|
|
194
|
+
};
|
|
195
|
+
asset.testCredentials = [...asset.testCredentials ?? [], legacy];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
129
198
|
export class AutomationAssetStore {
|
|
130
199
|
policy;
|
|
131
200
|
statePath;
|
|
@@ -207,6 +276,7 @@ export class AutomationAssetStore {
|
|
|
207
276
|
domains: [candidate.domain], tags: [`candidate:${candidate.id}`], inputNames: recipeInputNames(candidate.steps), recipe: structuredClone(candidate.steps), revision: 1,
|
|
208
277
|
testStatus: 'untested', successCount: 0, failureCount: 0, createdAt: timestamp, updatedAt: timestamp,
|
|
209
278
|
};
|
|
279
|
+
asset.contentHash = computeContentHash(asset);
|
|
210
280
|
this.state.assets.push(asset);
|
|
211
281
|
candidate.dismissedAt = timestamp;
|
|
212
282
|
this.write();
|
|
@@ -232,23 +302,52 @@ export class AutomationAssetStore {
|
|
|
232
302
|
if (!name)
|
|
233
303
|
throw new Error('automation asset name is required');
|
|
234
304
|
const kind = input.kind;
|
|
305
|
+
const schemaVersion = input.schemaVersion ?? existing?.schemaVersion ?? 1;
|
|
306
|
+
if (schemaVersion !== 1 && schemaVersion !== 2)
|
|
307
|
+
throw new RecipeValidationError('schemaVersion must be 1 or 2');
|
|
235
308
|
const domains = [...new Set((input.domains ?? []).map(value => cap(String(value).toLowerCase(), 255)).filter(Boolean))].slice(0, 20);
|
|
236
309
|
const tags = [...new Set((input.tags ?? []).map(value => cap(String(value), 40)).filter(Boolean))].slice(0, 20);
|
|
237
310
|
const declaredInputNames = [...new Set((input.inputNames ?? []).map(value => cap(String(value), 40)).filter(value => /^[a-zA-Z][\w-]*$/.test(value)))].slice(0, 20);
|
|
238
311
|
if (kind === 'recipe' && (!Array.isArray(input.recipe) || input.recipe.length < 1 || input.recipe.length > 25))
|
|
239
312
|
throw new Error('recipe asset requires 1 to 25 steps');
|
|
313
|
+
const hasV2Fields = [input.inputSchema, input.outputSchema, input.postconditions, input.requiredCapabilities].some(value => value !== undefined);
|
|
314
|
+
if (kind === 'userscript' && (schemaVersion === 2 || hasV2Fields))
|
|
315
|
+
throw new RecipeValidationError('schemaVersion 2 and inputSchema/outputSchema/postconditions/requiredCapabilities apply to recipe assets only');
|
|
316
|
+
if (kind === 'recipe' && schemaVersion === 1 && hasV2Fields)
|
|
317
|
+
throw new RecipeValidationError('inputSchema, outputSchema, postconditions and requiredCapabilities need schemaVersion 2');
|
|
318
|
+
// Unknown extract modes and wait conditions never reach storage; assets already stored are not re-checked.
|
|
319
|
+
if (kind === 'recipe' && schemaVersion === 1)
|
|
320
|
+
validateRecipeEnums(input.recipe);
|
|
321
|
+
const v2 = kind === 'recipe' && schemaVersion === 2 ? this.checkV2(input, domains) : undefined;
|
|
240
322
|
if (kind === 'userscript') {
|
|
241
323
|
const validation = validateUserscript(String(input.source ?? ''));
|
|
242
324
|
if (!validation.valid)
|
|
243
325
|
throw new Error('userscript is invalid: ' + validation.errors.join('; '));
|
|
244
326
|
}
|
|
245
|
-
const inputNames = kind === 'recipe' ? recipeInputNames(input.recipe) : declaredInputNames;
|
|
327
|
+
const inputNames = v2 ? v2.inputNames : kind === 'recipe' ? recipeInputNames(input.recipe) : declaredInputNames;
|
|
328
|
+
const sourceAssetId = input.sourceAssetId ?? existing?.sourceAssetId;
|
|
329
|
+
const sourceRevision = input.sourceRevision ?? existing?.sourceRevision;
|
|
330
|
+
if (sourceAssetId !== undefined && (typeof sourceAssetId !== 'string' || sourceAssetId.length > 80 || !Number.isInteger(sourceRevision)))
|
|
331
|
+
throw new RecipeValidationError('sourceAssetId needs a string id and an integer sourceRevision');
|
|
246
332
|
const asset = {
|
|
247
333
|
id: existing?.id ?? uid(), kind, status: 'draft', name, description: cap(String(input.description ?? ''), 500), domains, tags, inputNames,
|
|
248
334
|
...kind === 'recipe' ? { recipe: structuredClone(input.recipe) } : { source: String(input.source) },
|
|
335
|
+
...v2 ? {
|
|
336
|
+
schemaVersion: 2,
|
|
337
|
+
...v2.inputSchema ? { inputSchema: v2.inputSchema } : {},
|
|
338
|
+
...v2.outputSchema ? { outputSchema: v2.outputSchema } : {},
|
|
339
|
+
...v2.postconditions ? { postconditions: v2.postconditions } : {},
|
|
340
|
+
...v2.requiredCapabilities ? { requiredCapabilities: v2.requiredCapabilities } : {},
|
|
341
|
+
...v2.pending.length ? { pendingDisambiguation: v2.pending } : {},
|
|
342
|
+
} : {},
|
|
343
|
+
...sourceAssetId !== undefined ? { sourceAssetId, sourceRevision: sourceRevision } : {},
|
|
344
|
+
...input.origin ? { origin: structuredClone(input.origin) } : {},
|
|
249
345
|
revision: (existing?.revision ?? 0) + 1, testStatus: 'untested', successCount: existing?.successCount ?? 0, failureCount: existing?.failureCount ?? 0,
|
|
346
|
+
// Earlier credentials stay as history; each is bound to its own revision and never vouches for this one.
|
|
347
|
+
...existing?.testCredentials?.length ? { testCredentials: existing.testCredentials.slice(-this.policy.maxTestCredentials) } : {},
|
|
250
348
|
createdAt: existing?.createdAt ?? timestamp, updatedAt: timestamp, ...existing?.lastRunAt ? { lastRunAt: existing.lastRunAt } : {},
|
|
251
349
|
};
|
|
350
|
+
asset.contentHash = computeContentHash(asset);
|
|
252
351
|
if (existing)
|
|
253
352
|
this.state.assets[this.state.assets.indexOf(existing)] = asset;
|
|
254
353
|
else
|
|
@@ -256,11 +355,60 @@ export class AutomationAssetStore {
|
|
|
256
355
|
this.write();
|
|
257
356
|
return structuredClone(asset);
|
|
258
357
|
}
|
|
358
|
+
/** Validate the v2 steps and asset-level fields of a draft about to be saved. Throws RecipeValidationError. */
|
|
359
|
+
checkV2(input, domains) {
|
|
360
|
+
try {
|
|
361
|
+
const steps = input.recipe;
|
|
362
|
+
validateRecipeV2(steps);
|
|
363
|
+
const gotos = steps.filter(step => step.type === 'goto');
|
|
364
|
+
if (gotos.length && domains.length === 0)
|
|
365
|
+
throw new Error('a recipe with goto steps must declare domains: goto may only go to the asset\'s domains');
|
|
366
|
+
for (const step of gotos) {
|
|
367
|
+
if (/\{\{/.test(step.url))
|
|
368
|
+
continue; // a placeholder host is checked when the recipe runs
|
|
369
|
+
const host = new URL(step.url).hostname;
|
|
370
|
+
if (!hostInDomains(host, domains))
|
|
371
|
+
throw new Error(`goto ${host} is not allowed on this asset: it is outside its domains (${domains.join(', ')})`);
|
|
372
|
+
}
|
|
373
|
+
const inputSchema = input.inputSchema !== undefined ? normalizeInputSchema(input.inputSchema) : undefined;
|
|
374
|
+
const outputSchema = input.outputSchema !== undefined ? normalizeOutputSchema(input.outputSchema, steps) : undefined;
|
|
375
|
+
const postconditions = input.postconditions !== undefined ? normalizePostconditions(input.postconditions, steps) : undefined;
|
|
376
|
+
const requiredCapabilities = input.requiredCapabilities !== undefined ? normalizeRequiredCapabilities(input.requiredCapabilities) : undefined;
|
|
377
|
+
const used = placeholderNames(steps, postconditions ?? []);
|
|
378
|
+
if (inputSchema) {
|
|
379
|
+
const declared = new Set(inputSchema.map(spec => spec.name));
|
|
380
|
+
const undeclared = used.filter(name => !declared.has(name));
|
|
381
|
+
if (undeclared.length)
|
|
382
|
+
throw new Error(`the recipe uses {{${undeclared.join('}}, {{')}}} but inputSchema does not declare it`);
|
|
383
|
+
}
|
|
384
|
+
else if (used.length > 20)
|
|
385
|
+
throw new Error('recipe exceeds 20 reusable inputs');
|
|
386
|
+
return {
|
|
387
|
+
inputNames: inputSchema ? inputSchema.map(spec => spec.name) : used.slice(0, 20),
|
|
388
|
+
...inputSchema ? { inputSchema } : {}, ...outputSchema ? { outputSchema } : {}, ...postconditions ? { postconditions } : {},
|
|
389
|
+
...requiredCapabilities ? { requiredCapabilities } : {},
|
|
390
|
+
pending: pendingDisambiguation(steps),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
throw error instanceof RecipeValidationError ? error : new RecipeValidationError(error instanceof Error ? error.message : String(error));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
259
397
|
validate(id) {
|
|
260
398
|
const asset = this.requireAsset(id);
|
|
261
399
|
let message = 'Recipe structure is valid; runtime replay is still required.';
|
|
262
|
-
if (asset.kind === 'recipe')
|
|
263
|
-
|
|
400
|
+
if (asset.kind === 'recipe' && asset.schemaVersion === 2) {
|
|
401
|
+
this.checkV2(asset, asset.domains);
|
|
402
|
+
const hasAssert = asset.recipe.some(step => step.type === 'assert');
|
|
403
|
+
const notes = [];
|
|
404
|
+
if (asset.pendingDisambiguation?.length)
|
|
405
|
+
notes.push(`${asset.pendingDisambiguation.length} step(s) still take the first match (explicitFirst): make their locators unique`);
|
|
406
|
+
if (!hasAssert && !asset.postconditions?.length)
|
|
407
|
+
notes.push('no assert step or postcondition: a test cannot pass until one checks the result');
|
|
408
|
+
message = 'Recipe v2 structure is valid; runtime replay is still required.' + (notes.length ? ' ' + notes.join('; ') + '.' : '');
|
|
409
|
+
}
|
|
410
|
+
else if (asset.kind === 'recipe')
|
|
411
|
+
normalizeRecipeForCandidate((asset.recipe ?? []));
|
|
264
412
|
else {
|
|
265
413
|
const validation = validateUserscript(asset.source ?? '');
|
|
266
414
|
if (!validation.valid)
|
|
@@ -269,23 +417,88 @@ export class AutomationAssetStore {
|
|
|
269
417
|
}
|
|
270
418
|
return { ...structuredClone(asset), testMessage: message };
|
|
271
419
|
}
|
|
272
|
-
|
|
420
|
+
/**
|
|
421
|
+
* Copy an asset into a NEW draft that remembers where it came from (`sourceAssetId`, `sourceRevision`).
|
|
422
|
+
* This is how an active asset is repaired: active assets cannot be edited, the copy can, and the source
|
|
423
|
+
* keeps running until the copy is tested and activated (which then archives the source).
|
|
424
|
+
*/
|
|
425
|
+
fork(id) {
|
|
426
|
+
if (!this.policy.enabled || this.policy.persistenceMode === 'off')
|
|
427
|
+
throw new Error('automation asset persistence is disabled');
|
|
428
|
+
const source = this.requireAsset(id);
|
|
429
|
+
if (this.state.assets.filter(item => item.status === 'draft').length >= this.policy.maxDrafts)
|
|
430
|
+
throw new Error('automation draft limit reached');
|
|
431
|
+
const timestamp = nowIso();
|
|
432
|
+
const copy = structuredClone(source);
|
|
433
|
+
const draft = {
|
|
434
|
+
id: uid(), kind: copy.kind, status: 'draft', name: copy.name, description: copy.description, domains: copy.domains, tags: copy.tags, inputNames: copy.inputNames,
|
|
435
|
+
...copy.recipe ? { recipe: copy.recipe } : {}, ...copy.source !== undefined ? { source: copy.source } : {},
|
|
436
|
+
...copy.schemaVersion ? { schemaVersion: copy.schemaVersion } : {},
|
|
437
|
+
...copy.inputSchema ? { inputSchema: copy.inputSchema } : {}, ...copy.outputSchema ? { outputSchema: copy.outputSchema } : {},
|
|
438
|
+
...copy.postconditions ? { postconditions: copy.postconditions } : {}, ...copy.requiredCapabilities ? { requiredCapabilities: copy.requiredCapabilities } : {},
|
|
439
|
+
...copy.pendingDisambiguation ? { pendingDisambiguation: copy.pendingDisambiguation } : {},
|
|
440
|
+
sourceAssetId: source.id, sourceRevision: source.revision,
|
|
441
|
+
revision: 1, testStatus: 'untested', successCount: 0, failureCount: 0, createdAt: timestamp, updatedAt: timestamp,
|
|
442
|
+
};
|
|
443
|
+
draft.contentHash = computeContentHash(draft);
|
|
444
|
+
this.state.assets.push(draft);
|
|
445
|
+
this.write();
|
|
446
|
+
return structuredClone(draft);
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Change an asset's status. Activation is the guarded one: the request must name the revision the caller
|
|
450
|
+
* looked at (`expectedRevision`), and that revision, as it is now, needs a passed test credential bound to
|
|
451
|
+
* its exact content. A draft that was forked from (or converted from) an active asset replaces it: the
|
|
452
|
+
* source is archived in the same write, so the repaired asset never runs next to the one it fixes.
|
|
453
|
+
*/
|
|
454
|
+
setStatus(id, status, options = {}) {
|
|
273
455
|
const asset = this.requireAsset(id);
|
|
274
456
|
if (!['draft', 'active', 'archived'].includes(status))
|
|
275
457
|
throw new Error('invalid automation asset status');
|
|
458
|
+
let replaced;
|
|
276
459
|
if (status === 'active') {
|
|
277
|
-
|
|
278
|
-
throw new Error('automation asset must pass testing before activation');
|
|
279
|
-
if (asset.domains.length < 1)
|
|
280
|
-
throw new Error('automation asset must declare at least one domain before activation');
|
|
281
|
-
if (this.state.assets.filter(item => item.status === 'active' && item.id !== id).length >= this.policy.maxActiveAssets)
|
|
282
|
-
throw new Error('active automation asset limit reached');
|
|
460
|
+
replaced = this.checkActivation(asset, options.expectedRevision);
|
|
283
461
|
}
|
|
462
|
+
const timestamp = nowIso();
|
|
284
463
|
asset.status = status;
|
|
285
|
-
asset.updatedAt =
|
|
464
|
+
asset.updatedAt = timestamp;
|
|
465
|
+
if (replaced) {
|
|
466
|
+
replaced.status = 'archived';
|
|
467
|
+
replaced.updatedAt = timestamp;
|
|
468
|
+
}
|
|
286
469
|
this.write();
|
|
287
470
|
return structuredClone(asset);
|
|
288
471
|
}
|
|
472
|
+
/** Throws {@link ActivationRefusedError} unless `asset` may become active; returns the active asset it replaces, if any. */
|
|
473
|
+
checkActivation(asset, expectedRevision) {
|
|
474
|
+
if (!Number.isInteger(expectedRevision))
|
|
475
|
+
throw new ActivationRefusedError('expected-revision-required', 'activation requires expectedRevision: the revision you looked at and tested');
|
|
476
|
+
if (asset.revision !== expectedRevision) {
|
|
477
|
+
throw new ActivationRefusedError('revision-mismatch', `activation refused: expectedRevision ${expectedRevision} is not the current revision ${asset.revision}; the asset changed after you looked at it. Reload it, test that revision, then activate it`);
|
|
478
|
+
}
|
|
479
|
+
const credentials = (asset.testCredentials ?? []).filter(entry => entry.revision === asset.revision);
|
|
480
|
+
const latest = credentials.at(-1);
|
|
481
|
+
if (!latest)
|
|
482
|
+
throw new ActivationRefusedError('not-tested', `automation asset must pass testing before activation: revision ${asset.revision} has no test credential`);
|
|
483
|
+
if (!latest.passed || asset.testStatus !== 'passed')
|
|
484
|
+
throw new ActivationRefusedError('test-failed', `automation asset must pass testing before activation: the latest test of revision ${asset.revision} did not pass`);
|
|
485
|
+
if (latest.contentHash !== computeContentHash(asset)) {
|
|
486
|
+
throw new ActivationRefusedError('content-changed', `automation asset must pass testing before activation: the passed test of revision ${asset.revision} covered different content than the asset has now; save and test it again`);
|
|
487
|
+
}
|
|
488
|
+
const shortfall = inputSetShortfall(asset, latest, this.policy.minInputSetsForActivation);
|
|
489
|
+
if (shortfall) {
|
|
490
|
+
throw new ActivationRefusedError('insufficient-input-sets', `automation asset declares ${shortfall.declared} input(s), so its passing test must cover at least ${shortfall.required} different input sets (automationAssets.minInputSetsForActivation), but the test of revision ${asset.revision} covered ${shortfall.covered}. A recipe written against one input can hard-code it. Test again with ${shortfall.required} to ${INPUT_SET_MAX} different input objects (automation.develop test with inputSets, or a JSON array in the panel's test inputs)`);
|
|
491
|
+
}
|
|
492
|
+
if (asset.origin?.unmapped)
|
|
493
|
+
throw new ActivationRefusedError('incomplete-draft', `automation draft is incomplete: ${asset.origin.unmapped} action(s) of the exploration it was built from (journal seq ${asset.origin.fromSeq}-${asset.origin.toSeq}) could not become steps. Finish the recipe yourself and save it, test it, then activate`);
|
|
494
|
+
if (asset.domains.length < 1)
|
|
495
|
+
throw new ActivationRefusedError('no-domain', 'automation asset must declare at least one domain before activation');
|
|
496
|
+
const replaced = asset.sourceAssetId ? this.state.assets.find(item => item.id === asset.sourceAssetId && item.id !== asset.id && item.status === 'active') : undefined;
|
|
497
|
+
if (this.state.assets.filter(item => item.status === 'active' && item.id !== asset.id && item.id !== replaced?.id).length >= this.policy.maxActiveAssets) {
|
|
498
|
+
throw new ActivationRefusedError('limit-reached', 'active automation asset limit reached');
|
|
499
|
+
}
|
|
500
|
+
return replaced;
|
|
501
|
+
}
|
|
289
502
|
search(query, domain, status = 'active', kind) {
|
|
290
503
|
if (!query.trim())
|
|
291
504
|
throw new Error('automation search requires explicit keywords');
|
|
@@ -326,13 +539,40 @@ export class AutomationAssetStore {
|
|
|
326
539
|
asset.updatedAt = asset.lastRunAt;
|
|
327
540
|
this.write();
|
|
328
541
|
}
|
|
329
|
-
|
|
542
|
+
/**
|
|
543
|
+
* Record one runtime test as a credential bound to the content that ran. The asset's own `testStatus`
|
|
544
|
+
* moves only if that content is still the asset's current content: a test that finishes after a newer
|
|
545
|
+
* save leaves a credential for the old revision and changes nothing else.
|
|
546
|
+
*/
|
|
547
|
+
noteTestResult(id, ok, url, evidenceLevel = 'legacy-unverified', failureReason, details = {}) {
|
|
330
548
|
const asset = this.requireAsset(id);
|
|
331
549
|
if (asset.status !== 'draft')
|
|
332
550
|
throw new Error('only draft automation assets can record test results');
|
|
333
551
|
const domain = safeDomain(url);
|
|
334
|
-
|
|
335
|
-
|
|
552
|
+
const currentHash = computeContentHash(asset);
|
|
553
|
+
const tested = details.tested ?? { revision: asset.revision, contentHash: currentHash };
|
|
554
|
+
const credential = {
|
|
555
|
+
revision: tested.revision, contentHash: tested.contentHash, inputSchemaHash: details.inputSchemaHash ?? computeInputSchemaHash(asset), schemaVersion: asset.schemaVersion ?? 1,
|
|
556
|
+
testedAt: nowIso(), inputsDigest: digestInputs(details.inputs),
|
|
557
|
+
executionStatus: details.executionStatus ?? (ok ? 'completed' : 'failed'),
|
|
558
|
+
validationStatus: details.validationStatus ?? (ok && evidenceLevel === 'verified' ? 'passed' : 'not_checked'),
|
|
559
|
+
evidenceLevel: ok ? evidenceLevel : 'legacy-unverified', passed: ok,
|
|
560
|
+
...details.inputSets ? { inputSets: details.inputSets, plannedSets: details.plannedSets ?? details.inputSets.length } : {},
|
|
561
|
+
...details.warnings?.length ? { warnings: details.warnings } : {},
|
|
562
|
+
};
|
|
563
|
+
asset.testCredentials = [...asset.testCredentials ?? [], credential].slice(-this.policy.maxTestCredentials);
|
|
564
|
+
if (tested.revision === asset.revision && tested.contentHash === currentHash) {
|
|
565
|
+
asset.testStatus = ok ? 'passed' : 'failed';
|
|
566
|
+
if (ok)
|
|
567
|
+
asset.evidenceLevel = evidenceLevel;
|
|
568
|
+
else
|
|
569
|
+
delete asset.evidenceLevel;
|
|
570
|
+
asset.testMessage = !ok ? (failureReason ?? `Runtime replay failed on ${domain}.`)
|
|
571
|
+
: evidenceLevel === 'verified' ? `Runtime replay passed on ${domain}; every assert step held.`
|
|
572
|
+
: `Runtime replay passed on ${domain}; no assert step checked the result (legacy-unverified).`;
|
|
573
|
+
if (ok && details.inputSets)
|
|
574
|
+
asset.testMessage += ` ${details.inputSets.length} input sets, each in a fresh context.${details.warnings?.length ? ' ' + details.warnings.join(', ') + ': different inputs gave identical outputs.' : ''}`;
|
|
575
|
+
}
|
|
336
576
|
asset.updatedAt = nowIso();
|
|
337
577
|
this.write();
|
|
338
578
|
}
|