pi-agent-browser-native 0.3.0 → 0.5.0
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/CHANGELOG.md +127 -0
- package/README.md +63 -20
- package/dist/extensions/agent-browser/index.js +787 -105
- package/dist/extensions/agent-browser/lib/argv-descriptor.js +35 -3
- package/dist/extensions/agent-browser/lib/argv-grammar.js +44 -2
- package/dist/extensions/agent-browser/lib/batch-lifecycle.js +71 -0
- package/dist/extensions/agent-browser/lib/command-policy.js +1 -1
- package/dist/extensions/agent-browser/lib/command-taxonomy.js +35 -2
- package/dist/extensions/agent-browser/lib/input-modes/job.js +61 -4
- package/dist/extensions/agent-browser/lib/input-modes/lookups.js +2 -2
- package/dist/extensions/agent-browser/lib/input-modes/params.js +22 -23
- package/dist/extensions/agent-browser/lib/input-modes/script.js +462 -0
- package/dist/extensions/agent-browser/lib/input-modes/semantic-action.js +51 -12
- package/dist/extensions/agent-browser/lib/launch-scoped-flags.js +8 -0
- package/dist/extensions/agent-browser/lib/managed-session-policy-lock.js +3 -1
- package/dist/extensions/agent-browser/lib/managed-session-restore.js +26 -36
- package/dist/extensions/agent-browser/lib/managed-session-snapshots.js +2 -4
- package/dist/extensions/agent-browser/lib/managed-session-state-policy.js +47 -29
- package/dist/extensions/agent-browser/lib/managed-session-storage.js +50 -24
- package/dist/extensions/agent-browser/lib/orchestration/batch-stdin.js +26 -5
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/artifact-paths.js +110 -30
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.js +2 -1
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +26 -25
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +17 -3
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/index.js +2 -1
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/managed-session-daemon-policy.js +6 -4
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +129 -32
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +191 -55
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state.js +57 -29
- package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +15 -11
- package/dist/extensions/agent-browser/lib/orchestration/input-plan.js +36 -18
- package/dist/extensions/agent-browser/lib/orchestration/script-mode.js +299 -0
- package/dist/extensions/agent-browser/lib/pi-tool-rendering.js +32 -10
- package/dist/extensions/agent-browser/lib/playbook.js +18 -15
- package/dist/extensions/agent-browser/lib/process-environment.js +14 -0
- package/dist/extensions/agent-browser/lib/process-identity.js +4 -4
- package/dist/extensions/agent-browser/lib/process.js +131 -41
- package/dist/extensions/agent-browser/lib/recording-reservations.js +183 -0
- package/dist/extensions/agent-browser/lib/results/action-recommendations.js +62 -5
- package/dist/extensions/agent-browser/lib/results/artifact-manifest.js +62 -4
- package/dist/extensions/agent-browser/lib/results/categories.js +6 -1
- package/dist/extensions/agent-browser/lib/results/next-actions.js +19 -5
- package/dist/extensions/agent-browser/lib/results/presentation/artifacts.js +85 -38
- package/dist/extensions/agent-browser/lib/results/presentation/batch.js +66 -11
- package/dist/extensions/agent-browser/lib/results/presentation/common.js +18 -0
- package/dist/extensions/agent-browser/lib/results/presentation/diagnostics.js +7 -1
- package/dist/extensions/agent-browser/lib/results/presentation/errors.js +2 -1
- package/dist/extensions/agent-browser/lib/results/presentation/navigation.js +26 -11
- package/dist/extensions/agent-browser/lib/results/presentation/registry.js +58 -13
- package/dist/extensions/agent-browser/lib/results/presentation/semantic-action.js +1 -10
- package/dist/extensions/agent-browser/lib/results/presentation.js +6 -3
- package/dist/extensions/agent-browser/lib/results/recovery-actions.js +2 -0
- package/dist/extensions/agent-browser/lib/results/selector-recovery.js +51 -8
- package/dist/extensions/agent-browser/lib/results/snapshot-high-value-controls.js +13 -7
- package/dist/extensions/agent-browser/lib/runtime.js +116 -39
- package/dist/extensions/agent-browser/lib/session-page-state.js +62 -10
- package/dist/extensions/agent-browser/lib/upstream-version.js +14 -0
- package/dist/extensions/agent-browser/script-worker.js +169 -0
- package/dist/scripts/agent-browser-target.mjs +3 -0
- package/docs/ARCHITECTURE.md +40 -21
- package/docs/COMMAND_REFERENCE.md +90 -35
- package/docs/RELEASE.md +3 -3
- package/docs/REQUIREMENTS.md +4 -2
- package/docs/SUPPORT_MATRIX.md +26 -19
- package/docs/TOOL_CONTRACT.md +93 -52
- package/package.json +3 -1
- package/platform-smoke.config.mjs +2 -2
- package/scripts/agent-browser-capability-baseline.mjs +24 -6
- package/scripts/agent-browser-target.mjs +3 -0
- package/scripts/build.mjs +41 -0
- package/scripts/doctor.mjs +7 -6
- package/scripts/platform-smoke/browser-dogfood-windows.ps1 +9 -3
- package/scripts/platform-smoke/targets.mjs +12 -6
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getAgentBrowserSessionIdentityKey } from "../argv-grammar.js";
|
|
1
2
|
import { isRecord } from "../parsing.js";
|
|
2
3
|
export function isPendingRecordingCommand(command, subcommand, kind) {
|
|
3
4
|
return command === "record" && (subcommand === "start" || subcommand === "restart") && kind === "video";
|
|
@@ -32,6 +33,16 @@ function isManifestEntry(value) {
|
|
|
32
33
|
return false;
|
|
33
34
|
if (typeof value.kind !== "string" || value.kind.trim().length === 0)
|
|
34
35
|
return false;
|
|
36
|
+
for (const key of ["absolutePath", "command", "cwd", "extension", "mediaType", "namespace", "requestedPath", "session", "subcommand"]) {
|
|
37
|
+
if (value[key] !== undefined && typeof value[key] !== "string")
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (value.evictedAtMs !== undefined && (typeof value.evictedAtMs !== "number" || !Number.isFinite(value.evictedAtMs)))
|
|
41
|
+
return false;
|
|
42
|
+
if (value.exists !== undefined && typeof value.exists !== "boolean")
|
|
43
|
+
return false;
|
|
44
|
+
if (value.sizeBytes !== undefined && (typeof value.sizeBytes !== "number" || !Number.isFinite(value.sizeBytes) || value.sizeBytes < 0))
|
|
45
|
+
return false;
|
|
35
46
|
return true;
|
|
36
47
|
}
|
|
37
48
|
export function isSessionArtifactManifest(value) {
|
|
@@ -73,7 +84,34 @@ export function formatSessionArtifactRetentionSummary(manifest) {
|
|
|
73
84
|
return `Session artifacts: ${parts.join(", ")} (${manifest.entries.length}/${manifest.maxEntries} recent).`;
|
|
74
85
|
}
|
|
75
86
|
export function getSessionArtifactManifestEntryKey(entry) {
|
|
76
|
-
|
|
87
|
+
const pathKey = entry.storageScope === "explicit-path" && entry.absolutePath ? `${entry.storageScope}:${entry.absolutePath}` : `${entry.storageScope}:${entry.path}`;
|
|
88
|
+
const recordingSessionKey = entry.command === "record" && entry.kind === "video" && entry.session
|
|
89
|
+
? getAgentBrowserSessionIdentityKey(entry.session, entry.namespace)
|
|
90
|
+
: undefined;
|
|
91
|
+
return recordingSessionKey ? `${pathKey}\0${recordingSessionKey}` : pathKey;
|
|
92
|
+
}
|
|
93
|
+
export function retirePendingRecordingManifestEntries(manifest, sessionName, namespace, nowMs = Date.now()) {
|
|
94
|
+
let changed = false;
|
|
95
|
+
const sessionKey = sessionName ? getAgentBrowserSessionIdentityKey(sessionName, namespace) : undefined;
|
|
96
|
+
const entries = manifest.entries.map((entry) => {
|
|
97
|
+
if (!sessionKey
|
|
98
|
+
|| !entry.session
|
|
99
|
+
|| getAgentBrowserSessionIdentityKey(entry.session, entry.namespace) !== sessionKey
|
|
100
|
+
|| entry.kind !== "video"
|
|
101
|
+
|| !isPendingRecordingCommand(entry.command, entry.subcommand, entry.kind))
|
|
102
|
+
return entry;
|
|
103
|
+
changed = true;
|
|
104
|
+
return { ...entry, retentionState: "missing", subcommand: "close-abandoned" };
|
|
105
|
+
});
|
|
106
|
+
if (!changed)
|
|
107
|
+
return manifest;
|
|
108
|
+
return {
|
|
109
|
+
...manifest,
|
|
110
|
+
entries,
|
|
111
|
+
evictedCount: entries.filter((entry) => entry.retentionState === "evicted").length,
|
|
112
|
+
liveCount: entries.filter((entry) => entry.retentionState === "live").length,
|
|
113
|
+
updatedAtMs: nowMs,
|
|
114
|
+
};
|
|
77
115
|
}
|
|
78
116
|
export function mergeSessionArtifactManifest(options) {
|
|
79
117
|
const nowMs = options.nowMs ?? Date.now();
|
|
@@ -82,13 +120,31 @@ export function mergeSessionArtifactManifest(options) {
|
|
|
82
120
|
for (const entry of options.base?.entries ?? []) {
|
|
83
121
|
byPath.set(getSessionArtifactManifestEntryKey(entry), entry);
|
|
84
122
|
}
|
|
85
|
-
|
|
123
|
+
const orderedEntries = (options.entries ?? [])
|
|
124
|
+
.map((entry, index) => ({ entry, index }))
|
|
125
|
+
.sort((left, right) => left.entry.createdAtMs - right.entry.createdAtMs || left.index - right.index)
|
|
126
|
+
.map(({ entry }) => entry);
|
|
127
|
+
for (const entry of orderedEntries) {
|
|
86
128
|
const key = getSessionArtifactManifestEntryKey(entry);
|
|
129
|
+
if (entry.command === "record" && entry.kind === "video") {
|
|
130
|
+
const entrySessionKey = entry.session ? getAgentBrowserSessionIdentityKey(entry.session, entry.namespace) : undefined;
|
|
131
|
+
for (const [candidateKey, candidate] of byPath) {
|
|
132
|
+
const sameRecordingSession = entrySessionKey === undefined
|
|
133
|
+
? candidate.session === undefined
|
|
134
|
+
: candidate.session !== undefined && getAgentBrowserSessionIdentityKey(candidate.session, candidate.namespace) === entrySessionKey;
|
|
135
|
+
if (candidateKey !== key
|
|
136
|
+
&& sameRecordingSession
|
|
137
|
+
&& candidate.kind === "video"
|
|
138
|
+
&& isPendingRecordingCommand(candidate.command, candidate.subcommand, candidate.kind)) {
|
|
139
|
+
byPath.delete(candidateKey);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
87
143
|
const existing = byPath.get(key);
|
|
88
144
|
byPath.set(key, {
|
|
89
145
|
...existing,
|
|
90
146
|
...entry,
|
|
91
|
-
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
|
|
147
|
+
createdAtMs: entry.command === "record" && entry.kind === "video" ? entry.createdAtMs : existing?.createdAtMs ?? entry.createdAtMs,
|
|
92
148
|
evictedAtMs: entry.retentionState === "evicted" ? (entry.evictedAtMs ?? nowMs) : entry.evictedAtMs,
|
|
93
149
|
});
|
|
94
150
|
}
|
|
@@ -98,7 +154,9 @@ export function mergeSessionArtifactManifest(options) {
|
|
|
98
154
|
.sort((left, right) => {
|
|
99
155
|
const leftTime = left.evictedAtMs ?? left.createdAtMs;
|
|
100
156
|
const rightTime = right.evictedAtMs ?? right.createdAtMs;
|
|
101
|
-
return rightTime - leftTime
|
|
157
|
+
return rightTime - leftTime
|
|
158
|
+
|| Number(isPendingRecordingCommand(right.command, right.subcommand, right.kind)) - Number(isPendingRecordingCommand(left.command, left.subcommand, left.kind))
|
|
159
|
+
|| left.path.localeCompare(right.path);
|
|
102
160
|
})
|
|
103
161
|
.slice(0, maxEntries);
|
|
104
162
|
return {
|
|
@@ -5,6 +5,8 @@ function hasUnverifiedFileArtifact(artifacts) {
|
|
|
5
5
|
export function classifyAgentBrowserSuccessCategory(options) {
|
|
6
6
|
if (options.inspection)
|
|
7
7
|
return "inspection";
|
|
8
|
+
if ((options.artifacts ?? []).some(isPendingRecordingArtifact))
|
|
9
|
+
return "artifact-pending";
|
|
8
10
|
if ((options.artifacts ?? []).length > 0)
|
|
9
11
|
return hasUnverifiedFileArtifact(options.artifacts) ? "artifact-unverified" : "artifact-saved";
|
|
10
12
|
if (options.savedFile)
|
|
@@ -19,6 +21,9 @@ export function classifyAgentBrowserFailureCategory(options) {
|
|
|
19
21
|
// missed control named "Confirmation required" still gets selector recovery.
|
|
20
22
|
if (options.confirmationRequired)
|
|
21
23
|
return "confirmation-required";
|
|
24
|
+
// Canonical upstream prefix first: lastUrl can contain aborted / policy-blocked / about:blank.
|
|
25
|
+
if (/\btab_gone:/i.test(text))
|
|
26
|
+
return "tab-gone";
|
|
22
27
|
// Upstream 0.32.4+ locator misses keep detail and may echo getByRole/getByText or Names seen lists.
|
|
23
28
|
// Evaluate before text-derived timeout/confirmation so accessible-name substrings cannot suppress recovery.
|
|
24
29
|
const isUpstreamLocatorMiss = /\bNo element found:\s*(?:getBy[A-Za-z]+|role=|text=|label=|placeholder=|alt=|title=|testid=)/i.test(text) ||
|
|
@@ -48,7 +53,7 @@ export function classifyAgentBrowserFailureCategory(options) {
|
|
|
48
53
|
return "policy-blocked";
|
|
49
54
|
if (/cleanup failed|cleanup.*partial|partial cleanup|remaining resources/i.test(text))
|
|
50
55
|
return "cleanup-failed";
|
|
51
|
-
if (options.validationError)
|
|
56
|
+
if (options.validationError || /Agent-browser Unix socket path would be|Agent-browser socket storage .* is unusable/i.test(text))
|
|
52
57
|
return "validation-error";
|
|
53
58
|
if (options.tabDrift || /could not re-select the intended tab|about:blank|selected tab looks wrong|tab drift|tab.*wrong/i.test(text))
|
|
54
59
|
return "tab-drift";
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
export function withOptionalNamespaceArgs(namespace, args) {
|
|
2
|
-
return namespace && args[0] !== "--namespace" ? ["--namespace", namespace, ...args] : args;
|
|
2
|
+
return namespace !== undefined && args[0] !== "--namespace" ? ["--namespace", namespace, ...args] : args;
|
|
3
3
|
}
|
|
4
4
|
export function withOptionalSessionArgs(sessionName, args) {
|
|
5
|
-
if (!sessionName || args[0] === "--session")
|
|
5
|
+
if (!sessionName || args[0] === "--session" || (args[0] === "--namespace" && args[2] === "--session"))
|
|
6
6
|
return args;
|
|
7
|
-
if (args[0] === "--namespace" && args
|
|
7
|
+
if (args[0] === "--namespace" && args.length >= 2)
|
|
8
8
|
return [args[0], args[1], "--session", sessionName, ...args.slice(2)];
|
|
9
9
|
return ["--session", sessionName, ...args];
|
|
10
10
|
}
|
|
11
11
|
export function applyNamespaceToNextActions(actions, namespace) {
|
|
12
|
-
if (
|
|
12
|
+
if (namespace === undefined || !actions)
|
|
13
13
|
return actions;
|
|
14
14
|
return actions.map((action) => {
|
|
15
15
|
const args = action.params?.args;
|
|
@@ -19,6 +19,18 @@ export function applyNamespaceToNextActions(actions, namespace) {
|
|
|
19
19
|
return networkSourceLookup ? { ...action, params: { ...action.params, networkSourceLookup: { ...networkSourceLookup, namespace } } } : action;
|
|
20
20
|
});
|
|
21
21
|
}
|
|
22
|
+
export function applySessionToNextActions(actions, sessionName) {
|
|
23
|
+
if (!sessionName || !actions)
|
|
24
|
+
return actions;
|
|
25
|
+
return actions.map((action) => {
|
|
26
|
+
// Fresh-session actions deliberately target a new session; the planner ignores sessionMode when an
|
|
27
|
+
// explicit --session is present, so prefixing one here would silently downgrade them to reuse.
|
|
28
|
+
if (action.params?.sessionMode === "fresh")
|
|
29
|
+
return action;
|
|
30
|
+
const args = action.params?.args;
|
|
31
|
+
return args ? { ...action, params: { ...action.params, args: withOptionalSessionArgs(sessionName, args) } } : action;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
22
34
|
export function buildNextToolAction(options) {
|
|
23
35
|
return {
|
|
24
36
|
id: options.id,
|
|
@@ -48,7 +60,9 @@ export function isStandaloneSnapshotNextAction(action) {
|
|
|
48
60
|
const args = action.params?.args;
|
|
49
61
|
if (!args || action.params?.stdin)
|
|
50
62
|
return false;
|
|
51
|
-
|
|
63
|
+
let commandIndex = args[0] === "--namespace" ? 2 : 0;
|
|
64
|
+
if (args[commandIndex] === "--session")
|
|
65
|
+
commandIndex += 2;
|
|
52
66
|
return args[commandIndex] === "snapshot";
|
|
53
67
|
}
|
|
54
68
|
export function alignPageChangeSummaryNextActionIds(summary, nextActions) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { extname, resolve } from "node:path";
|
|
3
|
+
import { getAgentBrowserSessionIdentityKey } from "../../argv-grammar.js";
|
|
3
4
|
import { isRecord, parsePositiveInteger } from "../../parsing.js";
|
|
4
|
-
import { formatSessionArtifactRetentionSummary, isPendingRecordingArtifact, isPendingRecordingCommand, mergeSessionArtifactManifest, } from "../artifact-manifest.js";
|
|
5
|
+
import { formatSessionArtifactRetentionSummary, getSessionArtifactManifestEntryKey, isPendingRecordingArtifact, isPendingRecordingCommand, mergeSessionArtifactManifest, } from "../artifact-manifest.js";
|
|
5
6
|
import { classifyAgentBrowserSuccessCategory } from "../categories.js";
|
|
6
7
|
const IMAGE_EXTENSION_TO_MIME_TYPE = {
|
|
7
8
|
".gif": "image/gif",
|
|
@@ -12,6 +13,7 @@ const IMAGE_EXTENSION_TO_MIME_TYPE = {
|
|
|
12
13
|
};
|
|
13
14
|
const INLINE_IMAGE_MAX_BYTES_ENV = "PI_AGENT_BROWSER_INLINE_IMAGE_MAX_BYTES";
|
|
14
15
|
const DEFAULT_INLINE_IMAGE_MAX_BYTES = 5 * 1_024 * 1_024;
|
|
16
|
+
const ARTIFACT_MTIME_TOLERANCE_MS = 2_000;
|
|
15
17
|
function getImageMimeType(filePath) {
|
|
16
18
|
const extension = extname(filePath).toLowerCase();
|
|
17
19
|
return IMAGE_EXTENSION_TO_MIME_TYPE[extension];
|
|
@@ -36,14 +38,11 @@ function appendPresentationNotice(presentation, message) {
|
|
|
36
38
|
function shouldAppendArtifactRetentionNotice(entries) {
|
|
37
39
|
return entries.some((entry) => entry.retentionState === "evicted" || entry.storageScope !== "explicit-path");
|
|
38
40
|
}
|
|
39
|
-
function getManifestEntryKey(entry) {
|
|
40
|
-
return entry.storageScope === "explicit-path" && entry.absolutePath ? `${entry.storageScope}:${entry.absolutePath}` : `${entry.storageScope}:${entry.path}`;
|
|
41
|
-
}
|
|
42
41
|
export function manifestHasNewNoticeWorthyEntries(base, current) {
|
|
43
42
|
if (!current)
|
|
44
43
|
return false;
|
|
45
|
-
const baseKeys = new Set((base?.entries ?? []).map(
|
|
46
|
-
return current.entries.some((entry) => !baseKeys.has(
|
|
44
|
+
const baseKeys = new Set((base?.entries ?? []).map(getSessionArtifactManifestEntryKey));
|
|
45
|
+
return current.entries.some((entry) => !baseKeys.has(getSessionArtifactManifestEntryKey(entry)) && (entry.retentionState === "evicted" || entry.storageScope !== "explicit-path"));
|
|
47
46
|
}
|
|
48
47
|
export function applyArtifactManifest(presentation, baseManifest, entries) {
|
|
49
48
|
if (entries.length === 0)
|
|
@@ -86,6 +85,9 @@ const ARTIFACT_EXTENSION_TO_MEDIA_TYPE = {
|
|
|
86
85
|
".zip": "application/zip",
|
|
87
86
|
...IMAGE_EXTENSION_TO_MIME_TYPE,
|
|
88
87
|
};
|
|
88
|
+
function isDownloadWaitSubcommand(subcommand) {
|
|
89
|
+
return subcommand === "--download" || subcommand === "-d";
|
|
90
|
+
}
|
|
89
91
|
function getArtifactKind(commandInfo) {
|
|
90
92
|
if (commandInfo.command === "screenshot")
|
|
91
93
|
return "image";
|
|
@@ -95,7 +97,7 @@ function getArtifactKind(commandInfo) {
|
|
|
95
97
|
return "pdf";
|
|
96
98
|
if (commandInfo.command === "download")
|
|
97
99
|
return "download";
|
|
98
|
-
if (commandInfo.command === "wait" && commandInfo.subcommand
|
|
100
|
+
if (commandInfo.command === "wait" && isDownloadWaitSubcommand(commandInfo.subcommand))
|
|
99
101
|
return "download";
|
|
100
102
|
if (commandInfo.command === "state" && commandInfo.subcommand === "save")
|
|
101
103
|
return "file";
|
|
@@ -135,6 +137,11 @@ function extractPathStrings(data) {
|
|
|
135
137
|
}
|
|
136
138
|
return [...new Set(paths)];
|
|
137
139
|
}
|
|
140
|
+
function artifactMtimeIsOutsideCommandWindow(updatedAtMs, minUpdatedAtMs, maxUpdatedAtMs) {
|
|
141
|
+
return minUpdatedAtMs !== undefined
|
|
142
|
+
&& (updatedAtMs < minUpdatedAtMs - ARTIFACT_MTIME_TOLERANCE_MS
|
|
143
|
+
|| (maxUpdatedAtMs !== undefined && updatedAtMs > maxUpdatedAtMs + ARTIFACT_MTIME_TOLERANCE_MS));
|
|
144
|
+
}
|
|
138
145
|
async function buildFileArtifactMetadata(options) {
|
|
139
146
|
const kind = getArtifactKind(options.commandInfo);
|
|
140
147
|
if (!kind) {
|
|
@@ -146,11 +153,16 @@ async function buildFileArtifactMetadata(options) {
|
|
|
146
153
|
const pendingRecording = isPendingRecordingCommand(options.commandInfo.command, options.commandInfo.subcommand, kind);
|
|
147
154
|
let exists;
|
|
148
155
|
let sizeBytes;
|
|
156
|
+
let stale = false;
|
|
157
|
+
let updatedAtMs;
|
|
149
158
|
if (!pendingRecording) {
|
|
150
159
|
try {
|
|
151
160
|
const fileStats = await stat(absolutePath);
|
|
152
161
|
exists = true;
|
|
153
162
|
sizeBytes = fileStats.size;
|
|
163
|
+
updatedAtMs = fileStats.mtimeMs;
|
|
164
|
+
const commandCreatesArtifact = !(options.commandInfo.command === "wait" && isDownloadWaitSubcommand(options.commandInfo.subcommand));
|
|
165
|
+
stale = commandCreatesArtifact && artifactMtimeIsOutsideCommandWindow(updatedAtMs, options.artifactMinUpdatedAtMs, options.artifactMaxUpdatedAtMs);
|
|
154
166
|
}
|
|
155
167
|
catch {
|
|
156
168
|
exists = false;
|
|
@@ -165,31 +177,33 @@ async function buildFileArtifactMetadata(options) {
|
|
|
165
177
|
extension,
|
|
166
178
|
kind,
|
|
167
179
|
mediaType: extension ? ARTIFACT_EXTENSION_TO_MEDIA_TYPE[extension] : undefined,
|
|
180
|
+
namespace: options.namespace,
|
|
168
181
|
path: displayPath,
|
|
169
182
|
recordingState: pendingRecording ? "openRecording" : undefined,
|
|
170
183
|
requestedPath: options.artifactRequest?.path,
|
|
171
184
|
session: options.sessionName,
|
|
172
185
|
sizeBytes,
|
|
173
|
-
status:
|
|
186
|
+
status: pendingRecording ? "pending" : exists === false ? "missing" : stale ? "stale" : options.artifactRequest?.status ?? "saved",
|
|
174
187
|
subcommand: options.commandInfo.subcommand,
|
|
175
188
|
tempPath: options.artifactRequest?.tempPath,
|
|
189
|
+
updatedAtMs,
|
|
176
190
|
willExistOnStop: pendingRecording ? true : undefined,
|
|
177
191
|
};
|
|
178
192
|
}
|
|
179
193
|
async function buildPreviousRestartRecordingArtifact(options) {
|
|
180
194
|
if (options.commandInfo.command !== "record" || options.commandInfo.subcommand !== "restart")
|
|
181
195
|
return undefined;
|
|
196
|
+
const sessionKey = options.sessionName ? getAgentBrowserSessionIdentityKey(options.sessionName, options.namespace) : undefined;
|
|
182
197
|
const previousRecording = options.artifactManifest?.entries.find((entry) => (entry.command === "record" &&
|
|
183
198
|
(entry.subcommand === "start" || entry.subcommand === "restart") &&
|
|
184
199
|
entry.kind === "video" &&
|
|
185
|
-
(!
|
|
186
|
-
!options.currentPaths.has(entry.path) &&
|
|
187
|
-
(!entry.absolutePath || !options.currentPaths.has(entry.absolutePath))));
|
|
200
|
+
(!sessionKey || (entry.session && getAgentBrowserSessionIdentityKey(entry.session, entry.namespace) === sessionKey))));
|
|
188
201
|
if (!previousRecording)
|
|
189
202
|
return undefined;
|
|
190
203
|
const absolutePath = previousRecording.absolutePath ?? resolve(options.cwd, previousRecording.path);
|
|
191
204
|
try {
|
|
192
205
|
const fileStats = await stat(absolutePath);
|
|
206
|
+
const stale = artifactMtimeIsOutsideCommandWindow(fileStats.mtimeMs, options.artifactMinUpdatedAtMs, options.artifactMaxUpdatedAtMs);
|
|
193
207
|
return {
|
|
194
208
|
absolutePath,
|
|
195
209
|
artifactType: "video",
|
|
@@ -199,23 +213,39 @@ async function buildPreviousRestartRecordingArtifact(options) {
|
|
|
199
213
|
extension: previousRecording.extension ?? (extname(absolutePath).toLowerCase() || undefined),
|
|
200
214
|
kind: "video",
|
|
201
215
|
mediaType: previousRecording.mediaType,
|
|
216
|
+
namespace: previousRecording.namespace ?? options.namespace,
|
|
202
217
|
path: previousRecording.path,
|
|
203
218
|
requestedPath: previousRecording.requestedPath,
|
|
204
219
|
session: previousRecording.session ?? options.sessionName,
|
|
205
220
|
sizeBytes: fileStats.size,
|
|
206
|
-
status: "saved",
|
|
221
|
+
status: stale ? "stale" : "saved",
|
|
207
222
|
subcommand: "restart-previous",
|
|
223
|
+
updatedAtMs: fileStats.mtimeMs,
|
|
208
224
|
};
|
|
209
225
|
}
|
|
210
226
|
catch {
|
|
211
|
-
return
|
|
227
|
+
return {
|
|
228
|
+
absolutePath,
|
|
229
|
+
artifactType: "video",
|
|
230
|
+
command: "record",
|
|
231
|
+
cwd: previousRecording.cwd ?? options.cwd,
|
|
232
|
+
exists: false,
|
|
233
|
+
extension: previousRecording.extension ?? (extname(absolutePath).toLowerCase() || undefined),
|
|
234
|
+
kind: "video",
|
|
235
|
+
mediaType: previousRecording.mediaType,
|
|
236
|
+
namespace: previousRecording.namespace ?? options.namespace,
|
|
237
|
+
path: previousRecording.path,
|
|
238
|
+
requestedPath: previousRecording.requestedPath,
|
|
239
|
+
session: previousRecording.session ?? options.sessionName,
|
|
240
|
+
status: "missing",
|
|
241
|
+
subcommand: "restart-previous",
|
|
242
|
+
};
|
|
212
243
|
}
|
|
213
244
|
}
|
|
214
245
|
export async function extractFileArtifacts(options) {
|
|
215
246
|
const candidates = extractPathStrings(options.data);
|
|
216
247
|
const currentArtifacts = (await Promise.all(candidates.map((path) => buildFileArtifactMetadata({ ...options, path })))).filter((artifact) => artifact !== undefined);
|
|
217
|
-
const
|
|
218
|
-
const previousRestartRecordingArtifact = await buildPreviousRestartRecordingArtifact({ artifactManifest: options.artifactManifest, commandInfo: options.commandInfo, currentPaths, cwd: options.cwd, sessionName: options.sessionName });
|
|
248
|
+
const previousRestartRecordingArtifact = await buildPreviousRestartRecordingArtifact({ artifactManifest: options.artifactManifest, artifactMaxUpdatedAtMs: options.artifactMaxUpdatedAtMs, artifactMinUpdatedAtMs: options.artifactMinUpdatedAtMs, commandInfo: options.commandInfo, cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName });
|
|
219
249
|
return previousRestartRecordingArtifact ? [previousRestartRecordingArtifact, ...currentArtifacts] : currentArtifacts;
|
|
220
250
|
}
|
|
221
251
|
export function buildManifestEntriesForFileArtifacts(artifacts, nowMs = Date.now()) {
|
|
@@ -228,9 +258,10 @@ export function buildManifestEntriesForFileArtifacts(artifacts, nowMs = Date.now
|
|
|
228
258
|
extension: artifact.extension,
|
|
229
259
|
kind: artifact.kind,
|
|
230
260
|
mediaType: artifact.mediaType,
|
|
261
|
+
namespace: artifact.namespace,
|
|
231
262
|
path: artifact.path,
|
|
232
263
|
requestedPath: artifact.requestedPath,
|
|
233
|
-
retentionState: artifact.exists === false ? "missing" : "live",
|
|
264
|
+
retentionState: artifact.exists === false || artifact.status === "stale" ? "missing" : "live",
|
|
234
265
|
session: artifact.session,
|
|
235
266
|
sizeBytes: artifact.sizeBytes,
|
|
236
267
|
storageScope: "explicit-path",
|
|
@@ -238,6 +269,9 @@ export function buildManifestEntriesForFileArtifacts(artifacts, nowMs = Date.now
|
|
|
238
269
|
}));
|
|
239
270
|
}
|
|
240
271
|
export function isManifestFileArtifact(artifact) {
|
|
272
|
+
if (artifact.status === "stale") {
|
|
273
|
+
return artifact.kind === "video" && artifact.command === "record" && artifact.subcommand === "restart-previous";
|
|
274
|
+
}
|
|
241
275
|
return artifact.kind === "video" && artifact.command === "record" ? true : !isPendingRecordingArtifact(artifact);
|
|
242
276
|
}
|
|
243
277
|
function getArtifactVerificationEntry(artifact) {
|
|
@@ -259,20 +293,24 @@ function getArtifactVerificationEntry(artifact) {
|
|
|
259
293
|
willExistOnStop: artifact.willExistOnStop ?? true,
|
|
260
294
|
};
|
|
261
295
|
}
|
|
262
|
-
const state = artifact.
|
|
263
|
-
? "
|
|
264
|
-
: artifact.exists ===
|
|
265
|
-
? "
|
|
266
|
-
:
|
|
296
|
+
const state = artifact.status === "stale"
|
|
297
|
+
? "unverified"
|
|
298
|
+
: artifact.exists === true
|
|
299
|
+
? "verified"
|
|
300
|
+
: artifact.exists === false
|
|
301
|
+
? "missing"
|
|
302
|
+
: "unverified";
|
|
267
303
|
return {
|
|
268
304
|
absolutePath: artifact.absolutePath,
|
|
269
305
|
exists: artifact.exists,
|
|
270
306
|
kind: artifact.kind,
|
|
271
|
-
limitation:
|
|
272
|
-
? "The
|
|
273
|
-
: state === "
|
|
274
|
-
? "The wrapper
|
|
275
|
-
:
|
|
307
|
+
limitation: artifact.status === "stale"
|
|
308
|
+
? "The reported path's modification time fell outside this command's bounded artifact window. Treat the artifact as stale until regenerated."
|
|
309
|
+
: state === "missing"
|
|
310
|
+
? "The wrapper did not find the reported artifact at absolutePath. Treat the path as unverified until recovered or regenerated."
|
|
311
|
+
: state === "unverified"
|
|
312
|
+
? "The wrapper could not prove local filesystem existence for this artifact."
|
|
313
|
+
: undefined,
|
|
276
314
|
mediaType: artifact.mediaType,
|
|
277
315
|
path: artifact.path,
|
|
278
316
|
requestedPath: artifact.requestedPath,
|
|
@@ -281,6 +319,7 @@ function getArtifactVerificationEntry(artifact) {
|
|
|
281
319
|
state,
|
|
282
320
|
status: artifact.status,
|
|
283
321
|
storageScope: "explicit-path",
|
|
322
|
+
updatedAtMs: artifact.updatedAtMs,
|
|
284
323
|
};
|
|
285
324
|
}
|
|
286
325
|
function getManifestVerificationEntry(entry) {
|
|
@@ -335,17 +374,19 @@ export function buildArtifactVerificationSummary(artifacts, manifest, manifestPa
|
|
|
335
374
|
};
|
|
336
375
|
}
|
|
337
376
|
export function hasMissingFileArtifact(artifacts) {
|
|
338
|
-
return (artifacts ?? []).some((artifact) => !isPendingRecordingArtifact(artifact) && artifact.exists === false);
|
|
377
|
+
return (artifacts ?? []).some((artifact) => !isPendingRecordingArtifact(artifact) && (artifact.exists === false || artifact.status === "stale"));
|
|
339
378
|
}
|
|
340
379
|
export function formatMissingArtifactFailureText(artifacts) {
|
|
341
|
-
const
|
|
342
|
-
if (
|
|
380
|
+
const failedArtifacts = (artifacts ?? []).filter((artifact) => !isPendingRecordingArtifact(artifact) && (artifact.exists === false || artifact.status === "stale"));
|
|
381
|
+
if (failedArtifacts.length === 0)
|
|
343
382
|
return undefined;
|
|
344
|
-
if (
|
|
345
|
-
const artifact =
|
|
346
|
-
return
|
|
383
|
+
if (failedArtifacts.length === 1) {
|
|
384
|
+
const artifact = failedArtifacts[0];
|
|
385
|
+
return artifact.status === "stale"
|
|
386
|
+
? `Artifact verification failed: requested ${artifact.kind} path ${artifact.absolutePath} has a modification time outside the current command window.`
|
|
387
|
+
: `Artifact verification failed: requested ${artifact.kind} was not found at ${artifact.absolutePath}.`;
|
|
347
388
|
}
|
|
348
|
-
return `Artifact verification failed: ${
|
|
389
|
+
return `Artifact verification failed: ${failedArtifacts.length} requested artifacts were missing or stale.`;
|
|
349
390
|
}
|
|
350
391
|
export function classifyPresentationSuccessCategory(options) {
|
|
351
392
|
if ((options.artifactVerification?.missingCount ?? 0) > 0 || (options.artifactVerification?.unverifiedCount ?? 0) > 0) {
|
|
@@ -357,9 +398,9 @@ function formatArtifactLabel(artifact) {
|
|
|
357
398
|
switch (artifact.kind) {
|
|
358
399
|
case "download":
|
|
359
400
|
if (artifact.exists !== true) {
|
|
360
|
-
return artifact.command === "wait" && artifact.subcommand
|
|
401
|
+
return artifact.command === "wait" && isDownloadWaitSubcommand(artifact.subcommand) ? "Download event reported; file not verified" : "Download reported; file not verified";
|
|
361
402
|
}
|
|
362
|
-
return artifact.command === "wait" && artifact.subcommand
|
|
403
|
+
return artifact.command === "wait" && isDownloadWaitSubcommand(artifact.subcommand) ? "Download saved and verified" : "Downloaded file verified";
|
|
363
404
|
case "file":
|
|
364
405
|
return artifact.command === "state" ? "State file" : "Saved file";
|
|
365
406
|
case "har":
|
|
@@ -375,11 +416,16 @@ function formatArtifactLabel(artifact) {
|
|
|
375
416
|
case "trace":
|
|
376
417
|
return "Saved trace";
|
|
377
418
|
case "video":
|
|
378
|
-
if (artifact.command === "record" && artifact.subcommand === "restart-previous")
|
|
419
|
+
if (artifact.command === "record" && artifact.subcommand === "restart-previous") {
|
|
420
|
+
if (artifact.status === "stale")
|
|
421
|
+
return "Previous recording stale";
|
|
422
|
+
if (artifact.exists === false)
|
|
423
|
+
return "Previous recording missing";
|
|
379
424
|
return "Previous recording saved";
|
|
425
|
+
}
|
|
380
426
|
if (!isPendingRecordingArtifact(artifact))
|
|
381
427
|
return "Saved recording";
|
|
382
|
-
return artifact.subcommand === "restart" ? "Recording restarted; output will be written on stop" : "Recording started; output will be written on stop";
|
|
428
|
+
return artifact.subcommand === "restart" ? "Recording restarted; output will be written on stop" : "Recording started in a fresh active page; output will be written on stop";
|
|
383
429
|
}
|
|
384
430
|
}
|
|
385
431
|
export function formatArtifactSummary(artifacts) {
|
|
@@ -409,6 +455,7 @@ export function formatArtifactMetadataLines(artifacts) {
|
|
|
409
455
|
`Status: ${artifact.status ?? "pending"}`,
|
|
410
456
|
`Recording state: ${artifact.recordingState ?? "openRecording"}`,
|
|
411
457
|
`Will exist on stop: ${artifact.willExistOnStop !== false}`,
|
|
458
|
+
artifact.subcommand === "start" ? "Page state: record start uses a fresh active page for video capture; prior in-page DOM and JavaScript state does not carry over. Take a fresh snapshot before continuing." : undefined,
|
|
412
459
|
artifact.session ? `Session: ${artifact.session}` : undefined,
|
|
413
460
|
artifact.cwd ? `CWD: ${artifact.cwd}` : undefined,
|
|
414
461
|
`Machine data: details.artifacts[${index}]`,
|
|
@@ -433,7 +480,7 @@ export function formatArtifactMetadataLines(artifacts) {
|
|
|
433
480
|
});
|
|
434
481
|
}
|
|
435
482
|
function isDownloadWaitCommand(commandInfo) {
|
|
436
|
-
return commandInfo.command === "wait" && commandInfo.subcommand
|
|
483
|
+
return commandInfo.command === "wait" && isDownloadWaitSubcommand(commandInfo.subcommand);
|
|
437
484
|
}
|
|
438
485
|
function extractSavedFilePath(data) {
|
|
439
486
|
return typeof data.path === "string" && data.path.trim().length > 0 ? data.path : undefined;
|