@reticlehq/server 3.8.0 → 3.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +2 -2
- package/dist/command/cli/answers/gate-hook-message.d.ts +2 -0
- package/dist/command/cli/answers/gate-hook-message.js +5 -1
- package/dist/command/cli/auth/cloud-session.d.ts +1 -19
- package/dist/command/cli/auth/device-flow.d.ts +3 -37
- package/dist/command/cli/cli-doctor.js +30 -23
- package/dist/command/cli/cli-flow-commands.js +7 -1
- package/dist/command/cli/cli-parse-serve.d.ts +24 -0
- package/dist/command/cli/cli-parse-serve.js +124 -0
- package/dist/command/cli/cli-parse.d.ts +1 -10
- package/dist/command/cli/cli-parse.js +62 -121
- package/dist/command/cli/cli-usage.js +5 -2
- package/dist/command/cli/cloud-link.d.ts +1 -20
- package/dist/command/cli/doctor/doctor-agent-link.d.ts +23 -0
- package/dist/command/cli/doctor/doctor-agent-link.js +28 -0
- package/dist/command/cli/ports/port-holder.js +6 -1
- package/dist/command/cli/project-name.d.ts +1 -7
- package/dist/command/cli/project-name.js +1 -0
- package/dist/command/daemon/daemon.d.ts +6 -0
- package/dist/command/daemon/daemon.js +52 -2
- package/dist/command/daemon/win32-escape.d.ts +32 -0
- package/dist/command/daemon/win32-escape.js +119 -0
- package/dist/command/setup/bringup/dev-server-files.d.ts +1 -9
- package/dist/command/setup/bringup/dev-server-wait.js +5 -1
- package/dist/command/version/version-skew.d.ts +2 -0
- package/dist/command/version/version-skew.js +7 -3
- package/dist/features/crawl/crawl-tools.js +2 -2
- package/dist/features/exhaust/ledger.d.ts +15 -96
- package/dist/features/exhaust/ledger.js +3 -3
- package/dist/features/license/license.d.ts +2 -14
- package/dist/hooks/hook-commands.js +19 -9
- package/dist/judgement/capsule/capsule-store.d.ts +2 -39
- package/dist/language/flows/annotate-tools.js +1 -1
- package/dist/language/flows/fields/flow-secret-field.js +1 -1
- package/dist/language/flows/flow-expect-grammar.js +14 -6
- package/dist/language/flows/flow-lease.d.ts +27 -0
- package/dist/language/flows/flow-lease.js +59 -0
- package/dist/language/flows/flow-replay-run.d.ts +1 -87
- package/dist/language/flows/flow-replay-run.js +88 -283
- package/dist/language/flows/flow-replayable.d.ts +1 -1
- package/dist/language/flows/flow-start-path.d.ts +108 -0
- package/dist/language/flows/flow-start-path.js +313 -0
- package/dist/language/flows/flow-tools.d.ts +2 -23
- package/dist/language/flows/flow-tools.js +34 -66
- package/dist/language/flows/recording/flake.d.ts +3 -27
- package/dist/language/flows/recording/flake.js +1 -1
- package/dist/language/flows/stores/assertion-tiers-store.js +1 -1
- package/dist/machine/git-tracks.js +1 -0
- package/dist/memory/cloud/cloud-sync.d.ts +2 -24
- package/dist/memory/intent/intent-shard.d.ts +41 -266
- package/dist/memory/journal/envelope-store.js +1 -1
- package/dist/memory/journal/envelope.d.ts +6 -154
- package/dist/memory/journal/on-disk/ambient-file.d.ts +1 -7
- package/dist/memory/journal/on-disk/ambient-file.js +1 -1
- package/dist/memory/journal/self-observation.js +3 -1
- package/dist/memory/journal/session-journal.js +39 -20
- package/dist/portal/input/drive-error.d.ts +6 -0
- package/dist/portal/input/drive-error.js +9 -0
- package/dist/portal/input/real-input-press.d.ts +34 -0
- package/dist/portal/input/real-input-press.js +150 -0
- package/dist/portal/input/real-input.d.ts +6 -23
- package/dist/portal/input/real-input.js +95 -192
- package/dist/portal/input/viewport-tools.d.ts +19 -15
- package/dist/portal/pool/browser-pool.d.ts +5 -55
- package/dist/portal/pool/browser-pool.js +121 -100
- package/dist/portal/pool/pool-lease.d.ts +128 -0
- package/dist/portal/pool/pool-lease.js +49 -0
- package/dist/portal/production/production-activation.d.ts +5 -27
- package/dist/portal/session/control-envelope.d.ts +1 -1
- package/dist/portal/session/zero-install-first-call.d.ts +3 -0
- package/dist/portal/session/zero-install-first-call.js +33 -12
- package/dist/surface/mcp/mcp.d.ts +3 -3
- package/dist/surface/mcp/mcp.js +4 -6
- package/dist/surface/tools/act/act-danger.js +32 -17
- package/dist/surface/tools/act/durable.js +1 -1
- package/dist/surface/tools/act-sequence-tool.js +3 -3
- package/dist/surface/tools/act-tools.js +15 -16
- package/dist/surface/tools/agent/compact-envelope.d.ts +2 -2
- package/dist/surface/tools/coverage-tools.js +1 -1
- package/dist/surface/tools/dynamic-tools.d.ts +8 -0
- package/dist/surface/tools/dynamic-tools.js +36 -16
- package/dist/surface/tools/empty-session-list.d.ts +5 -29
- package/dist/surface/tools/feedback-tools.js +1 -1
- package/dist/surface/tools/gap-novelty.d.ts +1 -1
- package/dist/surface/tools/gaps/capability-absences.js +4 -3
- package/dist/surface/tools/harness-gate.d.ts +1 -1
- package/dist/surface/tools/harness-toolset.js +3 -4
- package/dist/surface/tools/lease-permissions.d.ts +1 -1
- package/dist/surface/tools/lease-readiness.d.ts +23 -1
- package/dist/surface/tools/lease-readiness.js +75 -9
- package/dist/surface/tools/lease-tools.d.ts +1 -52
- package/dist/surface/tools/lease-tools.js +79 -142
- package/dist/surface/tools/observe-tools.js +3 -1
- package/dist/surface/tools/read-tools.js +5 -5
- package/dist/surface/tools/real-input-attempt.js +70 -31
- package/dist/surface/tools/session-ports.d.ts +57 -0
- package/dist/surface/tools/session-ports.js +102 -0
- package/dist/surface/tools/tool-kit.d.ts +5 -3
- package/dist/surface/tools/tools.js +1 -1
- package/dist/telemetry/daemon-telemetry.d.ts +3 -1
- package/dist/telemetry/daemon-telemetry.js +9 -2
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +2 -0
- package/docs/agent-cheatsheet.md +12 -0
- package/docs/cli/gate.mdx +1 -1
- package/docs/cli/verify.mdx +8 -1
- package/docs/first-drive.md +7 -0
- package/docs/multi-agent-testing.md +2 -0
- package/docs/packages/react.mdx +14 -2
- package/docs/packages/vite-plugin.mdx +1 -0
- package/docs/predicates.mdx +6 -0
- package/docs/skill.md +3 -3
- package/docs/troubleshooting.mdx +10 -0
- package/docs/usage.md +1 -1
- package/package.json +8 -9
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay's arrival at a flow's start page: navigate (or reload) before step 1, wait for the session
|
|
3
|
+
* the SDK reconnects as, and say what happened when it does not.
|
|
4
|
+
*/
|
|
5
|
+
import { type CommandResult, type FlowFile, type FlowReplayResult, type ReticleEvent, type StartPageWait } from '@reticlehq/core';
|
|
6
|
+
import type { ArrivalClock } from '../../surface/tools/act/navigation/navigate-arrival.js';
|
|
7
|
+
import type { SessionManager } from '../../portal/session/session-manager.js';
|
|
8
|
+
import type { Session } from '../../portal/session/session.js';
|
|
9
|
+
/** The slice of a session the start-path logic reads: the live URL plus the event buffer. */
|
|
10
|
+
interface StartPathSession {
|
|
11
|
+
url?: string;
|
|
12
|
+
eventsSince(cursor: number): ReticleEvent[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* When a flow records the page its journey started on (`startPath`) and the tab is currently on a
|
|
16
|
+
* different route, step 1 drifts for a reason that has nothing to do with the app regressing — the
|
|
17
|
+
* anchor simply isn't on this page yet. Detect that so the decision says "navigate there first"
|
|
18
|
+
* instead of a mystifying "a step no longer matches". Returns undefined when the routes agree or the
|
|
19
|
+
* current route is unobservable — never a false alarm. Replay normally never gets here on a
|
|
20
|
+
* wrong page (arriveAtStartPath navigates before step 1); this is the fallback when navigation
|
|
21
|
+
* was refused. A page that never reconnected is reported as unverifiable before step 1.
|
|
22
|
+
*/
|
|
23
|
+
export declare function startPathMismatchHint(flow: FlowFile, session: StartPathSession): string | undefined;
|
|
24
|
+
/** What replay learned on its way to step 0. Both fields absent = the tab was left exactly as it was. */
|
|
25
|
+
export interface StartPathArrival {
|
|
26
|
+
/** The successor session replay must continue on. Absent when nothing was navigated, or arrival timed out. */
|
|
27
|
+
session?: Session;
|
|
28
|
+
/** Set when the RESET itself is why step 1 cannot start — the reason a step-1 red is not drift. */
|
|
29
|
+
resetCost?: string;
|
|
30
|
+
/** Set when the tab was sent away and nothing reconnected: the old handle will never answer (#1129). */
|
|
31
|
+
lost?: string;
|
|
32
|
+
/** A live successor arrived on a different route, so replay must not use the dead old handle. */
|
|
33
|
+
redirected?: string;
|
|
34
|
+
/** Set when the page's loading kept replay waiting past the step budget. */
|
|
35
|
+
wait?: StartPageWait;
|
|
36
|
+
}
|
|
37
|
+
/** The replay result, with how long replay waited for its start page when that went past the budget. */
|
|
38
|
+
export declare function withStartPageWait<R extends FlowReplayResult>(result: R, arrival: StartPathArrival): R;
|
|
39
|
+
/**
|
|
40
|
+
* Replay's half of the FlowFile contract: put the tab on the flow's `startPath` before step 1, and
|
|
41
|
+
* RELOAD it if it is already there.
|
|
42
|
+
*
|
|
43
|
+
* The reload is the state contract. Replaying "add a task" twice against a tab nobody reset gives
|
|
44
|
+
* the second run a world the recording never saw, and its assertions then describe the first run
|
|
45
|
+
* rather than the app. Our own `replay-determinism.mjs` has always refreshed before every run — the
|
|
46
|
+
* determinism we measured was never the determinism a user got.
|
|
47
|
+
*
|
|
48
|
+
* `requires` is the declared opt-out, and its first caller. A flow that says it starts from state
|
|
49
|
+
* another flow established is the one flow a page load would destroy, so it is never reset — here
|
|
50
|
+
* or from the wrong page. Silence is not an opt-out: a flow that depends on leftovers without
|
|
51
|
+
* saying so is a flow whose green means nothing on a fresh machine, and CI is a fresh machine.
|
|
52
|
+
*
|
|
53
|
+
* A full-page load tears down the session socket, so the navigation must happen here — before any
|
|
54
|
+
* step runs — and the replay continues on the session the SDK reconnects as (found via the same
|
|
55
|
+
* tombstone rebind that lets `resolve(oldId)` answer after any navigation). Best-effort by design:
|
|
56
|
+
* when the flow carries no startPath, the current route is unobservable, or navigation is refused,
|
|
57
|
+
* replay proceeds on the connected session. A missing successor stops before step 1; a redirect
|
|
58
|
+
* continues on the live successor with a route-specific hint.
|
|
59
|
+
*/
|
|
60
|
+
export declare function arriveAtStartPath(sessions: SessionManager, session: StartPathSession & {
|
|
61
|
+
id: string;
|
|
62
|
+
command(name: string, args?: Record<string, unknown>): Promise<CommandResult>;
|
|
63
|
+
}, flow: FlowFile, timeoutMs?: number, clock?: ArrivalClock,
|
|
64
|
+
/** Whether the tab is still loading. Replay passes one for a pool lease, the only tab it can ask. */
|
|
65
|
+
stillLoading?: () => Promise<boolean>, loadingCapMs?: number): Promise<StartPathArrival>;
|
|
66
|
+
/**
|
|
67
|
+
* Send a tab somewhere and wait for the session that comes back.
|
|
68
|
+
*
|
|
69
|
+
* A navigation is a full page load: the session that issued it DIES and a successor connects with a
|
|
70
|
+
* new id. Every caller therefore has to wait for that successor rather than reuse the handle it
|
|
71
|
+
* already holds, and getting this wrong does not error — it leaves the caller talking to a session
|
|
72
|
+
* that will never answer again.
|
|
73
|
+
*
|
|
74
|
+
* Extracted so there is ONE of these rather than one per caller. `arriveAtStartPath` decides WHETHER
|
|
75
|
+
* to move and this decides how, which is also why the deliberate short-circuits live up there and
|
|
76
|
+
* not in here: a caller that has already decided it must move should not have to argue with them.
|
|
77
|
+
*/
|
|
78
|
+
/**
|
|
79
|
+
* How a navigate ended: target, redirect, refusal (untouched tab), or no successor. `wait` is set
|
|
80
|
+
* when the page's loading earned it time past the budget.
|
|
81
|
+
*/
|
|
82
|
+
export type NavigateOutcome = {
|
|
83
|
+
kind: 'arrived';
|
|
84
|
+
session: Session;
|
|
85
|
+
wait?: StartPageWait;
|
|
86
|
+
} | {
|
|
87
|
+
kind: 'redirected';
|
|
88
|
+
session: Session;
|
|
89
|
+
wait?: StartPageWait;
|
|
90
|
+
} | {
|
|
91
|
+
kind: 'refused';
|
|
92
|
+
} | {
|
|
93
|
+
kind: 'lost';
|
|
94
|
+
waitedMs: number;
|
|
95
|
+
stillLoading: boolean;
|
|
96
|
+
wait?: StartPageWait;
|
|
97
|
+
};
|
|
98
|
+
export declare function navigateAndAwait(sessions: SessionManager, session: {
|
|
99
|
+
id: string;
|
|
100
|
+
/** Optional because a session's URL is, and `carryReticleIdentity` already handles its absence. */
|
|
101
|
+
url?: string;
|
|
102
|
+
command(name: string, args?: Record<string, unknown>): Promise<CommandResult>;
|
|
103
|
+
}, destination: string, expectedPath: string, timeoutMs: number, clock?: ArrivalClock,
|
|
104
|
+
/** True when the destination is the page we are already on — see `arrivedSuccessor`. */
|
|
105
|
+
isReload?: boolean,
|
|
106
|
+
/** Whether the navigated page is still loading; absent when it cannot be asked. */
|
|
107
|
+
stillLoading?: () => Promise<boolean>, loadingCapMs?: number): Promise<NavigateOutcome>;
|
|
108
|
+
export {};
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay's arrival at a flow's start page: navigate (or reload) before step 1, wait for the session
|
|
3
|
+
* the SDK reconnects as, and say what happened when it does not.
|
|
4
|
+
*/
|
|
5
|
+
import { EventType, ReticleCommand, } from '@reticlehq/core';
|
|
6
|
+
import { establishedState, samePath, startPathBudgetMs } from './flow-journey.js';
|
|
7
|
+
import { asRecord } from '@reticlehq/core';
|
|
8
|
+
import { navigablePath, routeOfEvent, routeOfUrl, } from '@reticlehq/engine/question/predicate/predicate-route.js';
|
|
9
|
+
import { carryReticleIdentity } from '../../surface/tools/lease-tools.js';
|
|
10
|
+
import { anchorPrecondition, anchorQueryArgs } from './flow-step-runners.js';
|
|
11
|
+
import { queryRefs } from './replay.js';
|
|
12
|
+
import { isDocumentGoneError } from '../../portal/session/facts/session-replaced.js';
|
|
13
|
+
/**
|
|
14
|
+
* The route the tab is on now: the last observed route.change, falling back to the pathname of the
|
|
15
|
+
* session's own URL. The fallback matters — a tab that hard-loaded its page emits no route event
|
|
16
|
+
* (the route observer only sees pushState/replaceState/popstate), but the HELLO url still names
|
|
17
|
+
* where it sits, and without it a wrong-page replay reported plain drift with no hint at all.
|
|
18
|
+
*/
|
|
19
|
+
function currentPathOf(session) {
|
|
20
|
+
const routes = session.eventsSince(0).filter((e) => e.type === EventType.ROUTE_CHANGE);
|
|
21
|
+
const last = routes.at(-1);
|
|
22
|
+
// pathname + search + hash, because `startPath` is compared against this and must stay NAVIGABLE.
|
|
23
|
+
//
|
|
24
|
+
// Reading the pathname alone made both sides `/` on a hash router — always "same path", so the
|
|
25
|
+
// hint never fired however far the tab had drifted, on the router desktop renderers use by
|
|
26
|
+
// default. Leaving the SEARCH out was the mirror defect: `startPath` keeps its query, so a flow
|
|
27
|
+
// starting at `/admin/events/7?tab=wrap` never matched a tab sitting on exactly that, re-navigated
|
|
28
|
+
// on every replay, and the re-navigation killed the session mid-flow.
|
|
29
|
+
//
|
|
30
|
+
// Comparing without the query instead would have been worse in the direction that matters. The
|
|
31
|
+
// query usually decides what the page renders, so a tab on `?tab=summary` would read as "already
|
|
32
|
+
// at `?tab=wrap`" and the replay would start on the wrong page with nothing saying so. Both sides
|
|
33
|
+
// carry it, which keeps a real difference visible and stops the false one.
|
|
34
|
+
// Reticle's own params dropped, as the recording drops them from `startPath` (#1411).
|
|
35
|
+
const observed = last === undefined ? undefined : routeOfEvent(last);
|
|
36
|
+
if (observed !== undefined) {
|
|
37
|
+
return navigablePath(observed);
|
|
38
|
+
}
|
|
39
|
+
if (session.url === undefined) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const fromUrl = routeOfUrl(session.url);
|
|
43
|
+
return fromUrl === undefined ? undefined : navigablePath(fromUrl);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* When a flow records the page its journey started on (`startPath`) and the tab is currently on a
|
|
47
|
+
* different route, step 1 drifts for a reason that has nothing to do with the app regressing — the
|
|
48
|
+
* anchor simply isn't on this page yet. Detect that so the decision says "navigate there first"
|
|
49
|
+
* instead of a mystifying "a step no longer matches". Returns undefined when the routes agree or the
|
|
50
|
+
* current route is unobservable — never a false alarm. Replay normally never gets here on a
|
|
51
|
+
* wrong page (arriveAtStartPath navigates before step 1); this is the fallback when navigation
|
|
52
|
+
* was refused. A page that never reconnected is reported as unverifiable before step 1.
|
|
53
|
+
*/
|
|
54
|
+
export function startPathMismatchHint(flow, session) {
|
|
55
|
+
const startPath = flow.startPath;
|
|
56
|
+
if (startPath === undefined || 0 === startPath.length) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const current = currentPathOf(session);
|
|
60
|
+
if (current === undefined || samePath(current, startPath)) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
return `this flow's journey starts on ${startPath} but the tab is on ${current} — navigate there (reticle_navigate { url: "${startPath}" }), then replay`;
|
|
64
|
+
}
|
|
65
|
+
/** How often replay looks for the SDK on the start page; how long is `startPathBudgetMs`. */
|
|
66
|
+
const START_PATH_POLL_MS = 100;
|
|
67
|
+
/**
|
|
68
|
+
* How long replay keeps waiting past the step budget while the page says it is still loading
|
|
69
|
+
* (#1705). The bound keeps a page that never finishes from holding the replay forever.
|
|
70
|
+
*/
|
|
71
|
+
const START_PATH_LOADING_CAP_MS = 30_000;
|
|
72
|
+
/** A probe that fails counts as loaded, so it can only end the wait, never stretch it. */
|
|
73
|
+
async function pageStillLoading(probe) {
|
|
74
|
+
try {
|
|
75
|
+
return true === (await probe());
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const REAL_ARRIVAL_CLOCK = {
|
|
82
|
+
now: () => Date.now(),
|
|
83
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* The session `oldId` denotes right now — itself, or the tombstone successor `resolve` rebinds to
|
|
87
|
+
* after a full-document navigation. A different instance proves the SDK reconnected even when the
|
|
88
|
+
* app redirected to another route. Keyed to the navigated tab's own identity on purpose: matching
|
|
89
|
+
* "any session at the target page" could hand replay an unrelated tab already sitting there.
|
|
90
|
+
*/
|
|
91
|
+
function arrivedSuccessor(sessions, oldId, target,
|
|
92
|
+
/** The handle that issued the navigation. */
|
|
93
|
+
issuedBy, isReload) {
|
|
94
|
+
try {
|
|
95
|
+
const candidate = sessions.resolve(oldId);
|
|
96
|
+
// A reload can return the dying handle on the same path. Compare instances, not ids: a leased
|
|
97
|
+
// tab can reconnect under the same id. A redirect counts only on a new instance.
|
|
98
|
+
if (isReload && candidate === issuedBy) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
const path = currentPathOf(candidate);
|
|
102
|
+
if (path === undefined) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const matches = samePath(path, target);
|
|
106
|
+
// A still-connected old tab on the wrong route is not a redirected successor.
|
|
107
|
+
return matches || candidate !== issuedBy ? { session: candidate, matches } : undefined;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Can step 1 start from where the tab already is?
|
|
115
|
+
*
|
|
116
|
+
* A route mismatch is NOT on its own a reason to navigate. The condition worth a page load is that
|
|
117
|
+
* the first anchor is not reachable from here — and a flow whose first anchor lives somewhere
|
|
118
|
+
* persistent (a sidebar, a header, a nav rail) is reachable from every route.
|
|
119
|
+
*
|
|
120
|
+
* Navigating anyway is expensive in the one direction that matters: a full page load tears the
|
|
121
|
+
* session down and brings the app back COLD, which for a flow recorded after signing in is the
|
|
122
|
+
* login screen. Step 1 then reports its anchor missing and blames a file that is completely fine.
|
|
123
|
+
* A best-effort step that can leave the caller WORSE off than skipping it is not best-effort.
|
|
124
|
+
*
|
|
125
|
+
* Unresolvable either way (no anchor we can query, a failed query) reads as "cannot tell", and the
|
|
126
|
+
* navigation goes ahead — the pre-existing behaviour, and the safe direction for a check whose whole
|
|
127
|
+
* job is to avoid making things worse.
|
|
128
|
+
*/
|
|
129
|
+
async function firstStepResolvesHere(session, flow) {
|
|
130
|
+
const first = flow.steps?.[0];
|
|
131
|
+
if (first === undefined) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
const args = anchorQueryArgs(first.anchor);
|
|
135
|
+
if (null === args) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
return 0 < queryRefs(await session.command(ReticleCommand.QUERY, args)).length;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** The replay result, with how long replay waited for its start page when that went past the budget. */
|
|
146
|
+
export function withStartPageWait(result, arrival) {
|
|
147
|
+
return arrival.wait === undefined ? result : { ...result, startPageWait: arrival.wait };
|
|
148
|
+
}
|
|
149
|
+
/** The `wait` an outcome carries, ready to spread into an arrival. */
|
|
150
|
+
function waitOf(outcome) {
|
|
151
|
+
return outcome.wait === undefined ? {} : { wait: outcome.wait };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Replay's half of the FlowFile contract: put the tab on the flow's `startPath` before step 1, and
|
|
155
|
+
* RELOAD it if it is already there.
|
|
156
|
+
*
|
|
157
|
+
* The reload is the state contract. Replaying "add a task" twice against a tab nobody reset gives
|
|
158
|
+
* the second run a world the recording never saw, and its assertions then describe the first run
|
|
159
|
+
* rather than the app. Our own `replay-determinism.mjs` has always refreshed before every run — the
|
|
160
|
+
* determinism we measured was never the determinism a user got.
|
|
161
|
+
*
|
|
162
|
+
* `requires` is the declared opt-out, and its first caller. A flow that says it starts from state
|
|
163
|
+
* another flow established is the one flow a page load would destroy, so it is never reset — here
|
|
164
|
+
* or from the wrong page. Silence is not an opt-out: a flow that depends on leftovers without
|
|
165
|
+
* saying so is a flow whose green means nothing on a fresh machine, and CI is a fresh machine.
|
|
166
|
+
*
|
|
167
|
+
* A full-page load tears down the session socket, so the navigation must happen here — before any
|
|
168
|
+
* step runs — and the replay continues on the session the SDK reconnects as (found via the same
|
|
169
|
+
* tombstone rebind that lets `resolve(oldId)` answer after any navigation). Best-effort by design:
|
|
170
|
+
* when the flow carries no startPath, the current route is unobservable, or navigation is refused,
|
|
171
|
+
* replay proceeds on the connected session. A missing successor stops before step 1; a redirect
|
|
172
|
+
* continues on the live successor with a route-specific hint.
|
|
173
|
+
*/
|
|
174
|
+
export async function arriveAtStartPath(sessions, session, flow, timeoutMs = startPathBudgetMs(flow), clock = REAL_ARRIVAL_CLOCK,
|
|
175
|
+
/** Whether the tab is still loading. Replay passes one for a pool lease, the only tab it can ask. */
|
|
176
|
+
stillLoading, loadingCapMs = START_PATH_LOADING_CAP_MS) {
|
|
177
|
+
const target = flow.startPath;
|
|
178
|
+
if (target === undefined || 0 === target.length) {
|
|
179
|
+
return {};
|
|
180
|
+
}
|
|
181
|
+
const current = currentPathOf(session);
|
|
182
|
+
if (current === undefined) {
|
|
183
|
+
return {};
|
|
184
|
+
}
|
|
185
|
+
// The declared opt-out, both directions. See above.
|
|
186
|
+
if (0 < establishedState(flow).length) {
|
|
187
|
+
return {};
|
|
188
|
+
}
|
|
189
|
+
const here = samePath(current, target);
|
|
190
|
+
// A route mismatch that step 1 can start from anyway is not worth a page load: navigating away
|
|
191
|
+
// from a persistent anchor could only hurt, and did. Arriving is the goal; resetting a tab that is
|
|
192
|
+
// ALREADY here is a different decision, taken above, and this must not override it.
|
|
193
|
+
if (!here && (await firstStepResolvesHere(session, flow))) {
|
|
194
|
+
return {};
|
|
195
|
+
}
|
|
196
|
+
let destination;
|
|
197
|
+
try {
|
|
198
|
+
// startPath is a pathname (a host belongs to the machine, not the journey) — resolve it
|
|
199
|
+
// against the tab's own URL to get something the browser can be sent to. Already HERE, the
|
|
200
|
+
// reset reloads the page the tab is on: `samePath` ignored a query the flow did not record, and
|
|
201
|
+
// navigating to `target` would strip it, so the replay would test a different page than the one
|
|
202
|
+
// it just agreed it was on.
|
|
203
|
+
destination = new URL(here ? current : target, session.url).toString();
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return {}; // no usable base URL — nowhere to navigate from
|
|
207
|
+
}
|
|
208
|
+
// Measured either side of the reset, and only when the reset is a reload: reachable before and
|
|
209
|
+
// gone after is the reload's doing, not the flow file's. Without this, an app holding its session
|
|
210
|
+
// in memory comes back signed out and step 1 blames a component that is completely fine.
|
|
211
|
+
const resolvedBefore = here && (await firstStepResolvesHere(session, flow));
|
|
212
|
+
const outcome = await navigateAndAwait(sessions, session, destination, target, timeoutMs, clock, here, stillLoading, loadingCapMs);
|
|
213
|
+
if ('lost' === outcome.kind) {
|
|
214
|
+
const why = outcome.stillLoading
|
|
215
|
+
? 'The page was still loading when replay stopped waiting: check that the app finishes loading, then replay'
|
|
216
|
+
: 'A slow or backgrounded tab can take longer to come back: bring it to the front and replay';
|
|
217
|
+
return {
|
|
218
|
+
lost: `replay ${here ? 'reloaded' : 'navigated to'} ${target} before step 1 and no page was connected ` +
|
|
219
|
+
`after ${String(outcome.waitedMs)}ms, so the session it was driving is gone and nothing ran. ${why}`,
|
|
220
|
+
...waitOf(outcome),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if ('refused' === outcome.kind) {
|
|
224
|
+
return {};
|
|
225
|
+
}
|
|
226
|
+
if ('redirected' === outcome.kind) {
|
|
227
|
+
const path = currentPathOf(outcome.session) ?? 'another route';
|
|
228
|
+
return {
|
|
229
|
+
session: outcome.session,
|
|
230
|
+
redirected: `replay ${here ? 'reloaded' : 'navigated to'} ${target} before step 1, but the tab reconnected on ${path}. Check the redirect or sign in before replaying`,
|
|
231
|
+
...waitOf(outcome),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const arrived = outcome.session;
|
|
235
|
+
if (!resolvedBefore || (await firstStepResolvesHere(arrived, flow))) {
|
|
236
|
+
return { session: arrived, ...waitOf(outcome) };
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
session: arrived,
|
|
240
|
+
resetCost: resetCostHint(flow, target),
|
|
241
|
+
...waitOf(outcome),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Why step 1 failed after the reset, and the one line that opts the flow out.
|
|
246
|
+
*
|
|
247
|
+
* No tool writes `requires`, so naming the field was advice nobody could act on without guessing its
|
|
248
|
+
* shape. The first anchor IS the precondition: the element step 1 needs, present before it runs.
|
|
249
|
+
*/
|
|
250
|
+
function resetCostHint(flow, target) {
|
|
251
|
+
const first = flow.steps?.[0];
|
|
252
|
+
const needs = first === undefined ? undefined : anchorPrecondition(first.anchor);
|
|
253
|
+
const optOut = needs === undefined
|
|
254
|
+
? 'Declare what it needs (`requires`) in the flow file to opt out of the reset'
|
|
255
|
+
: `Add \`"requires":${JSON.stringify([needs])}\` to the flow file to opt out of the reset`;
|
|
256
|
+
return (`replay reloaded ${target} before step 1 and the first anchor did not come back — this flow ` +
|
|
257
|
+
`starts from state a page load discards. ${optOut}, or record it from a cold page`);
|
|
258
|
+
}
|
|
259
|
+
export async function navigateAndAwait(sessions, session, destination, expectedPath, timeoutMs, clock = REAL_ARRIVAL_CLOCK,
|
|
260
|
+
/** True when the destination is the page we are already on — see `arrivedSuccessor`. */
|
|
261
|
+
isReload = false,
|
|
262
|
+
/** Whether the navigated page is still loading; absent when it cannot be asked. */
|
|
263
|
+
stillLoading, loadingCapMs = START_PATH_LOADING_CAP_MS) {
|
|
264
|
+
// A leased tab is addressed by URL params, so navigating without them would strand the lease.
|
|
265
|
+
const url = carryReticleIdentity(session.url, destination);
|
|
266
|
+
try {
|
|
267
|
+
const outcome = await session.command(ReticleCommand.NAVIGATE, { url });
|
|
268
|
+
if (!outcome.ok || true !== asRecord(outcome.result)['ok']) {
|
|
269
|
+
return { kind: 'refused' };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
// A navigation that unloads the page rejects the very command that asked for it — the transport
|
|
274
|
+
// dies with the document. Reading that as "the navigate failed" skipped the arrival poll below
|
|
275
|
+
// and left replay driving a handle that could never answer again, which is how a flow with a
|
|
276
|
+
// startPath died at step 1 on an app that had loaded perfectly. Anything else is a real refusal.
|
|
277
|
+
if (!isDocumentGoneError(error)) {
|
|
278
|
+
return { kind: 'refused' };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const start = clock.now();
|
|
282
|
+
const deadline = start + timeoutMs;
|
|
283
|
+
const cap = start + Math.max(timeoutMs, loadingCapMs);
|
|
284
|
+
let loading = false;
|
|
285
|
+
let extended = false;
|
|
286
|
+
const extendedWait = () => extended
|
|
287
|
+
? { wait: { waitedMs: clock.now() - start, budgetMs: timeoutMs, stillLoading: loading } }
|
|
288
|
+
: {};
|
|
289
|
+
for (;;) {
|
|
290
|
+
const arrived = arrivedSuccessor(sessions, session.id, expectedPath, session, isReload);
|
|
291
|
+
if (true === arrived?.matches) {
|
|
292
|
+
return { kind: 'arrived', session: arrived.session, ...extendedWait() };
|
|
293
|
+
}
|
|
294
|
+
if (clock.now() >= deadline) {
|
|
295
|
+
// A live redirect is a different result from no SDK reconnection at all.
|
|
296
|
+
if (arrived !== undefined) {
|
|
297
|
+
return { kind: 'redirected', session: arrived.session, ...extendedWait() };
|
|
298
|
+
}
|
|
299
|
+
// Past the budget, only a page that says it is still loading earns more time (#1705).
|
|
300
|
+
loading = stillLoading !== undefined && (await pageStillLoading(stillLoading));
|
|
301
|
+
extended ||= loading;
|
|
302
|
+
if (!loading || clock.now() >= cap) {
|
|
303
|
+
return {
|
|
304
|
+
kind: 'lost',
|
|
305
|
+
waitedMs: clock.now() - start,
|
|
306
|
+
stillLoading: loading,
|
|
307
|
+
...extendedWait(),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
await clock.sleep(START_PATH_POLL_MS);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -1,25 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { ToolDef, ToolDeps } from '../../surface/tools/tool-kit.js';
|
|
1
|
+
import type { ToolDef } from '../../surface/tools/tool-kit.js';
|
|
3
2
|
export { replayNamedFlow } from './flow-replay-run.js';
|
|
4
|
-
|
|
5
|
-
* The URL a leased context should open: the app's ORIGIN, not the live tab's current location.
|
|
6
|
-
*
|
|
7
|
-
* The live tab drifts — every replay navigates it — so using its full URL made each lease start wherever
|
|
8
|
-
* the previous run happened to finish (observed: leases opening /deployments and failing to find the
|
|
9
|
-
* login control that only exists at the root). A lease must be a FRESH visit to a deterministic entry
|
|
10
|
-
* point; flows that need another page carry their own startPath.
|
|
11
|
-
*/
|
|
12
|
-
export declare function leasableAppUrl(deps: ToolDeps, sessionId: string | undefined): string | undefined;
|
|
13
|
-
/**
|
|
14
|
-
* The live session's own URL — what the fixture port must be looked up by.
|
|
15
|
-
*
|
|
16
|
-
* NOT `leasableAppUrl`. That returns an ORIGIN, and the input provider finds a page by comparing
|
|
17
|
-
* full URLs, so an origin matched no page: `suiteFixtureSeed` answered `undefined` on every run and
|
|
18
|
-
* every leased flow booted cold while the code read as if it were seeded. Silent, because every
|
|
19
|
-
* refusal on that path is deliberately silent — a fixture is an optimisation, and a suite must not
|
|
20
|
-
* die when one is unavailable.
|
|
21
|
-
*/
|
|
22
|
-
export declare function sessionUrlOf(deps: ToolDeps, sessionId: string | undefined): string | undefined;
|
|
23
|
-
/** A flow whose leased context never came up is a suite ERROR, never a silent pass. */
|
|
24
|
-
export declare function leaseFailureReplay(name: string, error: string | undefined): FlowReplayResult;
|
|
3
|
+
export { leasableAppUrl, leaseFailureReplay, sessionUrlOf } from './flow-lease.js';
|
|
25
4
|
export declare const FLOW_TOOLS: ToolDef[];
|
|
@@ -9,9 +9,9 @@ import { emptyFlowRefusal } from './empty-flow.js';
|
|
|
9
9
|
import { aliasParam, aliasUsed } from '../../surface/tools/args/alias-args.js';
|
|
10
10
|
import { stillRecording, stillRecordingMessage } from './flow-result.js';
|
|
11
11
|
import { resolveAnnotateTarget } from './annotate-notes/annotate-target.js';
|
|
12
|
-
import { FlowErrorCode, RecordedSaveError,
|
|
12
|
+
import { FlowErrorCode, RecordedSaveError, SuiteIsolation, } from '@reticlehq/core';
|
|
13
13
|
import { recordSuiteFlakes } from './suite/suite-flakes.js';
|
|
14
|
-
import {
|
|
14
|
+
import { ReticleTool } from '@reticlehq/core';
|
|
15
15
|
import { asNumber, asString } from '@reticlehq/core';
|
|
16
16
|
import { log } from '../../log.js';
|
|
17
17
|
import { cloudFetch, syncFlowToCloud, SyncOutcome } from '../../memory/cloud/cloud-sync.js';
|
|
@@ -38,6 +38,8 @@ import { RESULT_FIELD, resultField, suiteResult } from '../../surface/tools/reti
|
|
|
38
38
|
import { replayOneFlow } from './single-replay.js';
|
|
39
39
|
export { replayNamedFlow } from './flow-replay-run.js';
|
|
40
40
|
import { replayAndLearn } from './flow-learning.js';
|
|
41
|
+
import { leasableAppUrl, leaseFailureReplay, sessionUrlOf } from './flow-lease.js';
|
|
42
|
+
export { leasableAppUrl, leaseFailureReplay, sessionUrlOf } from './flow-lease.js';
|
|
41
43
|
/**
|
|
42
44
|
* Best-effort mirror of a just-saved flow to Reticle (only when logged in — both cloud env vars
|
|
43
45
|
* set). Fire-and-forget: resolves the config, POSTs via the platform fetch, and logs the outcome. Any
|
|
@@ -49,8 +51,9 @@ async function syncSavedFlowToCloud(deps, flow, target) {
|
|
|
49
51
|
// `flowsForSession`, and reading the credential from a different tree is how one project's work
|
|
50
52
|
// reaches another project's dashboard.
|
|
51
53
|
const cloud = await resolveProjectCloud(deps.fs, rootForTarget(deps, target), homedir(), process.env);
|
|
52
|
-
if (null === cloud.config || !cloud.policy.flows)
|
|
53
|
-
return;
|
|
54
|
+
if (null === cloud.config || !cloud.policy.flows) {
|
|
55
|
+
return;
|
|
56
|
+
} // not attached / flows disabled → local only
|
|
54
57
|
const result = await syncFlowToCloud(flow, cloud.config, target.projectId, cloudFetch);
|
|
55
58
|
if (result.outcome !== SyncOutcome.SYNCED) {
|
|
56
59
|
log('cloud-flow-sync-failed', { flow: flow.name, status: result.status, error: result.error });
|
|
@@ -58,57 +61,6 @@ async function syncSavedFlowToCloud(deps, flow, target) {
|
|
|
58
61
|
}
|
|
59
62
|
/** A step index no recorded flow reaches; bounds `to` and `at`. */
|
|
60
63
|
const MAX_REPLAY_STEP = 10_000;
|
|
61
|
-
/**
|
|
62
|
-
* The URL a leased context should open: the app's ORIGIN, not the live tab's current location.
|
|
63
|
-
*
|
|
64
|
-
* The live tab drifts — every replay navigates it — so using its full URL made each lease start wherever
|
|
65
|
-
* the previous run happened to finish (observed: leases opening /deployments and failing to find the
|
|
66
|
-
* login control that only exists at the root). A lease must be a FRESH visit to a deterministic entry
|
|
67
|
-
* point; flows that need another page carry their own startPath.
|
|
68
|
-
*/
|
|
69
|
-
export function leasableAppUrl(deps, sessionId) {
|
|
70
|
-
try {
|
|
71
|
-
const session = deps.sessions.resolve(sessionId);
|
|
72
|
-
if (AppRuntime.WEB !== (session.runtime ?? AppRuntime.WEB))
|
|
73
|
-
return undefined; // desktop: no IPC in a lease
|
|
74
|
-
const url = session.url;
|
|
75
|
-
if (typeof url !== 'string' || 0 === url.length)
|
|
76
|
-
return undefined;
|
|
77
|
-
return new URL(url).origin;
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
return undefined; // no live session (or an unparseable URL) → sequential path
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* The live session's own URL — what the fixture port must be looked up by.
|
|
85
|
-
*
|
|
86
|
-
* NOT `leasableAppUrl`. That returns an ORIGIN, and the input provider finds a page by comparing
|
|
87
|
-
* full URLs, so an origin matched no page: `suiteFixtureSeed` answered `undefined` on every run and
|
|
88
|
-
* every leased flow booted cold while the code read as if it were seeded. Silent, because every
|
|
89
|
-
* refusal on that path is deliberately silent — a fixture is an optimisation, and a suite must not
|
|
90
|
-
* die when one is unavailable.
|
|
91
|
-
*/
|
|
92
|
-
export function sessionUrlOf(deps, sessionId) {
|
|
93
|
-
try {
|
|
94
|
-
return deps.sessions.resolve(sessionId).url;
|
|
95
|
-
}
|
|
96
|
-
catch {
|
|
97
|
-
return undefined;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
/** A flow whose leased context never came up is a suite ERROR, never a silent pass. */
|
|
101
|
-
export function leaseFailureReplay(name, error) {
|
|
102
|
-
return {
|
|
103
|
-
name,
|
|
104
|
-
status: ReplayStatus.ERROR,
|
|
105
|
-
steps: [],
|
|
106
|
-
error: {
|
|
107
|
-
code: ReplayStatus.ERROR,
|
|
108
|
-
message: `could not run in a leased context: ${error ?? 'unknown error'}`,
|
|
109
|
-
},
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
64
|
/** Stand-in path for a flow whose name fails the path-segment guard — never a real join. */
|
|
113
65
|
const INVALID_FLOW_NAME_PATH = '(invalid flow name — not a usable path)';
|
|
114
66
|
/**
|
|
@@ -247,8 +199,9 @@ export const FLOW_TOOLS = [
|
|
|
247
199
|
// A zero-step recording is not a flow. Saving it wrote a file the suite reports "unverifiable"
|
|
248
200
|
// forever while telling the agent it had saved a regression test — see empty-flow.
|
|
249
201
|
const emptyRefusal = emptyFlowRefusal(program.steps.length, name);
|
|
250
|
-
if (emptyRefusal !== undefined)
|
|
202
|
+
if (emptyRefusal !== undefined) {
|
|
251
203
|
return Promise.resolve(emptyRefusal);
|
|
204
|
+
}
|
|
252
205
|
const { flows, root } = flowsForSession(deps, target);
|
|
253
206
|
const author = flowAuthor();
|
|
254
207
|
const toSave = {
|
|
@@ -257,8 +210,9 @@ export const FLOW_TOOLS = [
|
|
|
257
210
|
...(author === undefined ? {} : { author }),
|
|
258
211
|
};
|
|
259
212
|
return flows.save(toSave, annotations, projectId).then(async (res) => {
|
|
260
|
-
if (!res.ok)
|
|
213
|
+
if (!res.ok) {
|
|
261
214
|
return { error: flowErrorMessage(res.code, res.detail), code: res.code };
|
|
215
|
+
}
|
|
262
216
|
deps.annotations.clear(name);
|
|
263
217
|
// Grade the saved flow's assertions so the agent learns immediately if it just saved a flow
|
|
264
218
|
// that asserts nothing observable (passes even when the feature is broken).
|
|
@@ -350,8 +304,9 @@ export const FLOW_TOOLS = [
|
|
|
350
304
|
return flows
|
|
351
305
|
.load(asString(aliasParam(args, 'flowName', ['flow'])['flowName']) ?? '', projectId)
|
|
352
306
|
.then((res) => {
|
|
353
|
-
if (!res.ok)
|
|
307
|
+
if (!res.ok) {
|
|
354
308
|
return { error: flowErrorMessage(res.code, res.detail), code: res.code };
|
|
309
|
+
}
|
|
355
310
|
const { name, ...rest } = res.value;
|
|
356
311
|
return { flowName: name, ...rest };
|
|
357
312
|
});
|
|
@@ -382,8 +337,9 @@ export const FLOW_TOOLS = [
|
|
|
382
337
|
return flows
|
|
383
338
|
.remove(asString(aliasParam(args, 'flowName', ['flow'])['flowName']) ?? '', projectId)
|
|
384
339
|
.then((res) => {
|
|
385
|
-
if (!res.ok)
|
|
340
|
+
if (!res.ok) {
|
|
386
341
|
return { error: flowErrorMessage(res.code, res.detail), code: res.code };
|
|
342
|
+
}
|
|
387
343
|
return { deleted: true };
|
|
388
344
|
});
|
|
389
345
|
},
|
|
@@ -399,7 +355,8 @@ export const FLOW_TOOLS = [
|
|
|
399
355
|
'document) — neither pass nor fail, treat as UNKNOWN. ' +
|
|
400
356
|
'An ok replay of a flow asserting no consequence also carries unverifiable:{reason} — do not ' +
|
|
401
357
|
'treat as proof. ' +
|
|
402
|
-
'Early halt carries halted:{atStep, notAttempted}: `steps` is shorter because replay stopped.'
|
|
358
|
+
'Early halt carries halted:{atStep, notAttempted}: `steps` is shorter because replay stopped. ' +
|
|
359
|
+
'A start page that kept loading past the step budget carries startPageWait:{waitedMs, budgetMs, stillLoading}.',
|
|
403
360
|
inputSchema: {
|
|
404
361
|
flow: FLOW_NAME_ALIAS,
|
|
405
362
|
flowName: z
|
|
@@ -461,6 +418,11 @@ export const FLOW_TOOLS = [
|
|
|
461
418
|
.object({ atStep: z.number(), notAttempted: z.number() })
|
|
462
419
|
.optional()
|
|
463
420
|
.describe('Set when the replay STOPPED at a failing step: `steps` is shorter than the flow, and this says where it stopped and how many steps were never attempted. Absent means every step ran.'),
|
|
421
|
+
// Declared for the same reason as `halted`: an undeclared field is stripped.
|
|
422
|
+
startPageWait: z
|
|
423
|
+
.object({ waitedMs: z.number(), budgetMs: z.number(), stillLoading: z.boolean() })
|
|
424
|
+
.optional()
|
|
425
|
+
.describe('Set when replay waited past the step budget because the start page was still loading. stillLoading is what the tab said the last time replay asked. Absent means the wait stayed inside the budget.'),
|
|
464
426
|
// Declared here or a validating profile strips it -- the same way `name` was stripped once.
|
|
465
427
|
// A field the handler sets and the schema omits arrives as nothing, silently.
|
|
466
428
|
/*
|
|
@@ -602,8 +564,9 @@ export const FLOW_TOOLS = [
|
|
|
602
564
|
// verify:server — hand the whole suite to the hosted runner; it records the verification itself.
|
|
603
565
|
const cloud = await resolveProjectCloud(deps.fs, rootForTarget(deps, target), homedir(), process.env);
|
|
604
566
|
const server = await runServerVerify(deps, cloud, sessionId, requested);
|
|
605
|
-
if (server !== null)
|
|
567
|
+
if (server !== null) {
|
|
606
568
|
return server;
|
|
569
|
+
}
|
|
607
570
|
// PARALLEL: flows race the DOM only when they share ONE tab. Given the lease pool, each
|
|
608
571
|
// flow gets its own isolated context, so a large suite finishes in interactive time. Opt-in via
|
|
609
572
|
// `parallel`; a missing prerequisite (no pool, unknown app URL) falls back to the sequential path
|
|
@@ -774,15 +737,17 @@ export const FLOW_TOOLS = [
|
|
|
774
737
|
// The store stamps the project into the file AND routes it to the per-project subdir (a shared
|
|
775
738
|
// daemon serves many apps), so location and content agree from one source of truth.
|
|
776
739
|
const emptyRecorded = emptyFlowRefusal(flow.steps.length, flow.name);
|
|
777
|
-
if (emptyRecorded !== undefined)
|
|
740
|
+
if (emptyRecorded !== undefined) {
|
|
778
741
|
return emptyRecorded;
|
|
742
|
+
}
|
|
779
743
|
// Resolved like every other path now. This save used to go to the daemon's root while the
|
|
780
744
|
// recorded-flow save resolved per session, so where a flow landed depended on which tool wrote
|
|
781
745
|
// it — and the pair that disagreed were the two ways to save the same thing.
|
|
782
746
|
const target = { projectId: session.projectId, origin: originOf(session.url) };
|
|
783
747
|
const res = await flowsForSession(deps, target).flows.saveFlow(flow, session.projectId);
|
|
784
|
-
if (!res.ok)
|
|
748
|
+
if (!res.ok) {
|
|
785
749
|
return { error: flowErrorMessage(res.code, res.detail), code: res.code };
|
|
750
|
+
}
|
|
786
751
|
// If logged in to Reticle, mirror the saved flow to the team's regression suite. Best-effort
|
|
787
752
|
// and non-blocking: the flow is already on disk, so a sync failure never fails the save.
|
|
788
753
|
void syncSavedFlowToCloud(deps, flow, target);
|
|
@@ -841,16 +806,19 @@ async function loadInvoked(flows, flow, projectId) {
|
|
|
841
806
|
const seen = new Set([flow.name]);
|
|
842
807
|
while (queue.length > 0) {
|
|
843
808
|
const current = queue.shift();
|
|
844
|
-
if (current === undefined)
|
|
809
|
+
if (current === undefined) {
|
|
845
810
|
continue;
|
|
811
|
+
}
|
|
846
812
|
for (const step of flattenSteps(current.steps)) {
|
|
847
813
|
const name = step.invoke;
|
|
848
|
-
if (name === undefined || seen.has(name))
|
|
814
|
+
if (name === undefined || seen.has(name)) {
|
|
849
815
|
continue;
|
|
816
|
+
}
|
|
850
817
|
seen.add(name);
|
|
851
818
|
const sub = await flows.load(name, projectId);
|
|
852
|
-
if (!sub.ok || sub.value === undefined)
|
|
819
|
+
if (!sub.ok || sub.value === undefined) {
|
|
853
820
|
continue;
|
|
821
|
+
}
|
|
854
822
|
out.set(name, sub.value);
|
|
855
823
|
queue.push(sub.value);
|
|
856
824
|
}
|