@anweat/dsh-browser 0.1.15 → 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.
Files changed (162) hide show
  1. package/README.md +272 -75
  2. package/assets/skills/dsh-browser/SKILL.md +120 -0
  3. package/assets/skills/dsh-browser/references/develop.md +48 -0
  4. package/assets/skills/dsh-browser/references/observe.md +91 -0
  5. package/assets/skills/dsh-browser/references/opencli.md +25 -0
  6. package/assets/skills/dsh-browser/references/recipes.md +149 -0
  7. package/assets/skills/dsh-browser/references/userscripts.md +44 -0
  8. package/cordis.patch.yml +4 -0
  9. package/lib/actions/act.d.ts +3 -0
  10. package/lib/actions/act.js +147 -0
  11. package/lib/actions/act.js.map +1 -0
  12. package/lib/actions/automation.d.ts +3 -0
  13. package/lib/actions/automation.js +356 -0
  14. package/lib/actions/automation.js.map +1 -0
  15. package/lib/actions/crawl.d.ts +3 -0
  16. package/lib/actions/crawl.js +28 -0
  17. package/lib/actions/crawl.js.map +1 -0
  18. package/lib/actions/errors.d.ts +53 -0
  19. package/lib/actions/errors.js +178 -0
  20. package/lib/actions/errors.js.map +1 -0
  21. package/lib/actions/index-view.d.ts +42 -0
  22. package/lib/actions/index-view.js +268 -0
  23. package/lib/actions/index-view.js.map +1 -0
  24. package/lib/actions/inspect.d.ts +3 -0
  25. package/lib/actions/inspect.js +35 -0
  26. package/lib/actions/inspect.js.map +1 -0
  27. package/lib/actions/observe.d.ts +3 -0
  28. package/lib/actions/observe.js +121 -0
  29. package/lib/actions/observe.js.map +1 -0
  30. package/lib/actions/opencli.d.ts +3 -0
  31. package/lib/actions/opencli.js +51 -0
  32. package/lib/actions/opencli.js.map +1 -0
  33. package/lib/actions/registry.d.ts +35 -0
  34. package/lib/actions/registry.js +89 -0
  35. package/lib/actions/registry.js.map +1 -0
  36. package/lib/actions/run.d.ts +34 -0
  37. package/lib/actions/run.js +226 -0
  38. package/lib/actions/run.js.map +1 -0
  39. package/lib/actions/runtime.d.ts +3 -0
  40. package/lib/actions/runtime.js +33 -0
  41. package/lib/actions/runtime.js.map +1 -0
  42. package/lib/actions/schema.d.ts +31 -0
  43. package/lib/actions/schema.js +309 -0
  44. package/lib/actions/schema.js.map +1 -0
  45. package/lib/actions/script.d.ts +3 -0
  46. package/lib/actions/script.js +97 -0
  47. package/lib/actions/script.js.map +1 -0
  48. package/lib/actions/shared.d.ts +31 -0
  49. package/lib/actions/shared.js +45 -0
  50. package/lib/actions/shared.js.map +1 -0
  51. package/lib/actions/surface.d.ts +22 -0
  52. package/lib/actions/surface.js +18 -0
  53. package/lib/actions/surface.js.map +1 -0
  54. package/lib/actions/target.d.ts +3 -0
  55. package/lib/actions/target.js +66 -0
  56. package/lib/actions/target.js.map +1 -0
  57. package/lib/actions/types.d.ts +181 -0
  58. package/lib/actions/types.js +48 -0
  59. package/lib/actions/types.js.map +1 -0
  60. package/lib/activation-rules.d.ts +29 -0
  61. package/lib/activation-rules.js +33 -0
  62. package/lib/activation-rules.js.map +1 -0
  63. package/lib/approval-policy.d.ts +40 -2
  64. package/lib/approval-policy.js +184 -97
  65. package/lib/approval-policy.js.map +1 -1
  66. package/lib/automation-assets-rpc.d.ts +8 -2
  67. package/lib/automation-assets-rpc.js +67 -6
  68. package/lib/automation-assets-rpc.js.map +1 -1
  69. package/lib/automation-assets.d.ts +152 -4
  70. package/lib/automation-assets.js +255 -15
  71. package/lib/automation-assets.js.map +1 -1
  72. package/lib/automation-convert.d.ts +23 -0
  73. package/lib/automation-convert.js +110 -0
  74. package/lib/automation-convert.js.map +1 -0
  75. package/lib/automation-development.d.ts +26 -0
  76. package/lib/automation-development.js +47 -0
  77. package/lib/automation-development.js.map +1 -1
  78. package/lib/automation-execution.d.ts +57 -1
  79. package/lib/automation-execution.js +167 -11
  80. package/lib/automation-execution.js.map +1 -1
  81. package/lib/automation-journal.d.ts +92 -0
  82. package/lib/automation-journal.js +449 -0
  83. package/lib/automation-journal.js.map +1 -0
  84. package/lib/automation-v2.d.ts +138 -0
  85. package/lib/automation-v2.js +631 -0
  86. package/lib/automation-v2.js.map +1 -0
  87. package/lib/automation.d.ts +82 -5
  88. package/lib/automation.js +239 -82
  89. package/lib/automation.js.map +1 -1
  90. package/lib/browser-service.d.ts +304 -32
  91. package/lib/browser-service.js +631 -182
  92. package/lib/browser-service.js.map +1 -1
  93. package/lib/client/SettingsCard.js +95 -57
  94. package/lib/client/SettingsCard.js.map +1 -1
  95. package/lib/client/asset-editor.d.ts +55 -0
  96. package/lib/client/asset-editor.js +101 -0
  97. package/lib/client/asset-editor.js.map +1 -0
  98. package/lib/client/automation-assets-client.d.ts +99 -2
  99. package/lib/client/automation-assets-client.js +243 -10
  100. package/lib/client/automation-assets-client.js.map +1 -1
  101. package/lib/client/form.d.ts +48 -4
  102. package/lib/client/form.js +142 -29
  103. package/lib/client/form.js.map +1 -1
  104. package/lib/client/index.d.ts +25 -2
  105. package/lib/client/index.js +5 -1
  106. package/lib/client/index.js.map +1 -1
  107. package/lib/client/locales.d.ts +85 -6
  108. package/lib/client/locales.js +64 -12
  109. package/lib/client/locales.js.map +1 -1
  110. package/lib/client/prompts-form.d.ts +67 -0
  111. package/lib/client/prompts-form.js +140 -0
  112. package/lib/client/prompts-form.js.map +1 -0
  113. package/lib/client/prompts-status-client.d.ts +34 -0
  114. package/lib/client/prompts-status-client.js +53 -0
  115. package/lib/client/prompts-status-client.js.map +1 -0
  116. package/lib/client.js +1914 -329
  117. package/lib/client.js.map +1 -1
  118. package/lib/config.d.ts +50 -1
  119. package/lib/config.js +31 -1
  120. package/lib/config.js.map +1 -1
  121. package/lib/dialogs.d.ts +37 -0
  122. package/lib/dialogs.js +73 -0
  123. package/lib/dialogs.js.map +1 -0
  124. package/lib/freedom.d.ts +23 -7
  125. package/lib/freedom.js +48 -59
  126. package/lib/freedom.js.map +1 -1
  127. package/lib/index.d.ts +4 -4
  128. package/lib/index.js +36 -10
  129. package/lib/index.js.map +1 -1
  130. package/lib/journal.d.ts +109 -0
  131. package/lib/journal.js +238 -0
  132. package/lib/journal.js.map +1 -0
  133. package/lib/locator.d.ts +82 -0
  134. package/lib/locator.js +190 -0
  135. package/lib/locator.js.map +1 -0
  136. package/lib/observe-script.d.ts +27 -0
  137. package/lib/observe-script.js +565 -0
  138. package/lib/observe-script.js.map +1 -0
  139. package/lib/observe.d.ts +179 -0
  140. package/lib/observe.js +368 -0
  141. package/lib/observe.js.map +1 -0
  142. package/lib/policy-keys.d.ts +30 -0
  143. package/lib/policy-keys.js +34 -0
  144. package/lib/policy-keys.js.map +1 -0
  145. package/lib/prompt-limits.d.ts +30 -0
  146. package/lib/prompt-limits.js +31 -0
  147. package/lib/prompt-limits.js.map +1 -0
  148. package/lib/prompts.d.ts +131 -0
  149. package/lib/prompts.js +295 -0
  150. package/lib/prompts.js.map +1 -0
  151. package/lib/skill.d.ts +100 -0
  152. package/lib/skill.js +181 -0
  153. package/lib/skill.js.map +1 -0
  154. package/lib/tool-defs.d.ts +72 -0
  155. package/lib/tool-defs.js +67 -0
  156. package/lib/tool-defs.js.map +1 -0
  157. package/lib/tools.d.ts +19 -4
  158. package/lib/tools.js +124 -922
  159. package/lib/tools.js.map +1 -1
  160. package/package.json +35 -30
  161. package/scripts/dump-prompts.mjs +29 -0
  162. package/scripts/measure-tool-surface.mjs +190 -0
@@ -1,11 +1,58 @@
1
1
  /** Bounded, local automation-asset lifecycle and retrieval. */
2
- import type { BrowserRecipeStep } from './automation.ts';
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
- recipe?: BrowserRecipeStep[];
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
- setStatus(id: string, status: AutomationAssetStatus): AutomationAsset;
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
- noteTestResult(id: string, ok: boolean, url: string): void;
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 {};
@@ -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
- normalizeRecipeForCandidate(asset.recipe ?? []);
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
- setStatus(id, status) {
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
- if (asset.testStatus !== 'passed')
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 = nowIso();
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
- noteTestResult(id, ok, url) {
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
- asset.testStatus = ok ? 'passed' : 'failed';
335
- asset.testMessage = ok ? `Runtime replay passed on ${domain}.` : `Runtime replay failed on ${domain}.`;
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
  }