@kontextmind/kxm 0.7.14 → 0.7.16
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/.claude-plugin/marketplace.json +1 -1
- package/docs/configuration.md +1 -2
- package/docs/operator-pi-packages.md +5 -2
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +20 -3
- package/plugins/kxm/dist/core.js +4 -0
- package/plugins/kxm/dist/extension.js +3066 -2
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime.js +263 -70
- package/plugins/kxm/dist/server.js +4 -0
- package/plugins/kxm/dist/vnext-runtime-supervisor.js +259 -70
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/vnext.ts +20 -3
- package/plugins/kxm/src/extension.ts +9 -0
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/providers/antigravity/LICENSE +21 -0
- package/plugins/kxm/src/providers/antigravity/README.md +19 -0
- package/plugins/kxm/src/providers/antigravity/auth/index.ts +17 -0
- package/plugins/kxm/src/providers/antigravity/auth/oauth.ts +449 -0
- package/plugins/kxm/src/providers/antigravity/client/client.ts +562 -0
- package/plugins/kxm/src/providers/antigravity/client/index.ts +1 -0
- package/plugins/kxm/src/providers/antigravity/diagnostics/diagnostics.ts +97 -0
- package/plugins/kxm/src/providers/antigravity/diagnostics/index.ts +1 -0
- package/plugins/kxm/src/providers/antigravity/index.ts +24 -0
- package/plugins/kxm/src/providers/antigravity/models/discovery.ts +155 -0
- package/plugins/kxm/src/providers/antigravity/models/grouping.ts +425 -0
- package/plugins/kxm/src/providers/antigravity/models/index.ts +3 -0
- package/plugins/kxm/src/providers/antigravity/models/models.ts +501 -0
- package/plugins/kxm/src/providers/antigravity/pi-compat.ts +279 -0
- package/plugins/kxm/src/providers/antigravity/register.ts +123 -0
- package/plugins/kxm/src/providers/antigravity/stream/index.ts +1 -0
- package/plugins/kxm/src/providers/antigravity/stream/stream.ts +1461 -0
- package/plugins/kxm/src/providers/antigravity/types/enums.ts +50 -0
- package/plugins/kxm/src/providers/antigravity/types/index.ts +2 -0
- package/plugins/kxm/src/providers/antigravity/types/types.ts +293 -0
- package/plugins/kxm/src/providers/antigravity/usage/index.ts +1 -0
- package/plugins/kxm/src/providers/antigravity/usage/usage.ts +361 -0
- package/plugins/kxm/src/providers/antigravity/utils/http.ts +32 -0
- package/plugins/kxm/src/providers/antigravity/utils/index.ts +3 -0
- package/plugins/kxm/src/providers/antigravity/utils/security.ts +75 -0
- package/plugins/kxm/src/providers/antigravity/utils/util.ts +133 -0
- package/plugins/kxm/src/redact.ts +4 -0
- package/plugins/kxm/src/vnext-engine.ts +183 -3
- package/plugins/kxm/src/vnext-runtime-owner.ts +50 -0
- package/plugins/kxm/src/vnext-runtime-supervisor.ts +83 -58
- package/plugins/kxm/src/vnext-runtime.ts +9 -8
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @ts-nocheck — vendored snapshot; host tsconfig is stricter than upstream.
|
|
2
|
+
import { antigravityEnv } from "./util.ts";
|
|
3
|
+
|
|
4
|
+
const PREWARM_TIMEOUT_MS = 5_000;
|
|
5
|
+
|
|
6
|
+
/** Host fetch. Node 22+ already pools connections; no extra HTTP client dep. */
|
|
7
|
+
export async function antigravityFetch(
|
|
8
|
+
input: string | URL,
|
|
9
|
+
init: RequestInit = {},
|
|
10
|
+
): Promise<Response> {
|
|
11
|
+
return fetch(input, init);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Open the TLS connection when the extension loads so the first message of a session
|
|
16
|
+
* does not pay the handshake either. Best-effort: failures are ignored.
|
|
17
|
+
*/
|
|
18
|
+
export function prewarmConnection(url: string): void {
|
|
19
|
+
if (antigravityEnv("NO_PREWARM") === "1") return;
|
|
20
|
+
if (process.env.NODE_TEST_CONTEXT) return;
|
|
21
|
+
void (async () => {
|
|
22
|
+
try {
|
|
23
|
+
const res = await antigravityFetch(url, {
|
|
24
|
+
method: "HEAD",
|
|
25
|
+
signal: AbortSignal.timeout(PREWARM_TIMEOUT_MS),
|
|
26
|
+
});
|
|
27
|
+
await res.arrayBuffer();
|
|
28
|
+
} catch {
|
|
29
|
+
// Warm-up only; the real request will establish the connection instead.
|
|
30
|
+
}
|
|
31
|
+
})();
|
|
32
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// @ts-nocheck — vendored snapshot; host tsconfig is stricter than upstream.
|
|
2
|
+
import { antigravityEnv } from "./util.ts";
|
|
3
|
+
|
|
4
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
5
|
+
const ALLOWED_API_HOST_SUFFIXES = [".googleapis.com", ".sandbox.googleapis.com"];
|
|
6
|
+
|
|
7
|
+
/** Only loopback binds are allowed so OAuth codes cannot be stolen off-machine. */
|
|
8
|
+
export function resolveCallbackHost(raw = antigravityEnv("CALLBACK_HOST")): string {
|
|
9
|
+
const host = (raw || "127.0.0.1").trim().toLowerCase();
|
|
10
|
+
if (!LOOPBACK_HOSTS.has(host)) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`Unsafe ANTIGRAVITY_CALLBACK_HOST="${host}". Only loopback hosts are allowed: 127.0.0.1, ::1, localhost.`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
return host === "localhost" ? "127.0.0.1" : host;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Prevent token exfiltration via poisoned BASE_URL (SSRF / credential leak). */
|
|
19
|
+
export function assertSafeApiBaseUrl(raw: string): string {
|
|
20
|
+
let url: URL;
|
|
21
|
+
try {
|
|
22
|
+
url = new URL(raw);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(`Invalid ANTIGRAVITY_BASE_URL: ${raw}`);
|
|
25
|
+
}
|
|
26
|
+
if (url.protocol !== "https:") {
|
|
27
|
+
throw new Error(`ANTIGRAVITY_BASE_URL must use https (got ${url.protocol})`);
|
|
28
|
+
}
|
|
29
|
+
if (url.username || url.password) {
|
|
30
|
+
throw new Error("ANTIGRAVITY_BASE_URL must not include credentials");
|
|
31
|
+
}
|
|
32
|
+
const host = url.hostname.toLowerCase();
|
|
33
|
+
const allowed =
|
|
34
|
+
host === "googleapis.com" || ALLOWED_API_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix));
|
|
35
|
+
if (!allowed) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`ANTIGRAVITY_BASE_URL host "${host}" is not allowed. Use a *.googleapis.com endpoint.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
41
|
+
return `${url.origin}${path === "/" ? "" : path}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Redact bearer tokens, refresh tokens, and similar secrets from diagnostics/errors. */
|
|
45
|
+
export function redactSecrets(text: string): string {
|
|
46
|
+
return text
|
|
47
|
+
.replace(/\bya29\.[A-Za-z0-9._~+/-]+=*/g, "[redacted-access-token]")
|
|
48
|
+
.replace(/\b1\/\/[A-Za-z0-9_-]+/g, "[redacted-refresh-token]")
|
|
49
|
+
.replace(/\b1\/[A-Za-z0-9_-]{20,}/g, "[redacted-refresh-token]")
|
|
50
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [redacted]")
|
|
51
|
+
.replace(
|
|
52
|
+
/("?(?:access_token|refresh_token|id_token|token|client_secret|code_verifier|authorization)"?\s*[:=]\s*")[^"]*(")/gi,
|
|
53
|
+
"$1[redacted]$2",
|
|
54
|
+
)
|
|
55
|
+
.replace(
|
|
56
|
+
/("?(?:access_token|refresh_token|id_token|token|client_secret|code_verifier|authorization)"?\s*[:=]\s*)[^\s&,}]+/gi,
|
|
57
|
+
"$1[redacted]",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function maskEmail(email: string | undefined): string | undefined {
|
|
62
|
+
if (!email || typeof email !== "string") return undefined;
|
|
63
|
+
const parts = email.split("@");
|
|
64
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) return "[redacted-email]";
|
|
65
|
+
const name = parts[0];
|
|
66
|
+
const domain = parts[1];
|
|
67
|
+
const lastChar = name.at(-1) || "";
|
|
68
|
+
const maskedName = name.length > 2 ? `${name[0]}***${lastChar}` : `${name[0]}***`;
|
|
69
|
+
return `${maskedName}@${domain}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function safeError(error: unknown): string {
|
|
73
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
74
|
+
return redactSecrets(raw);
|
|
75
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// @ts-nocheck — vendored snapshot; host tsconfig is stricter than upstream.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { getModelEnum } from "../models/models.ts";
|
|
4
|
+
|
|
5
|
+
export function antigravityEnv(name: string): string | undefined {
|
|
6
|
+
return process.env[`ANTIGRAVITY_${name}`] || process.env[`NOAGY_${name}`];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
10
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function asString(value: unknown): string | undefined {
|
|
14
|
+
return typeof value === "string" && value ? value : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sanitizeText(text: unknown): string {
|
|
18
|
+
return String(text ?? "").replace(/[\uD800-\uDFFF]/g, "\uFFFD");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function escapeHtml(text: string): string {
|
|
22
|
+
return text
|
|
23
|
+
.replace(/&/g, "&")
|
|
24
|
+
.replace(/</g, "<")
|
|
25
|
+
.replace(/>/g, ">")
|
|
26
|
+
.replace(/"/g, """)
|
|
27
|
+
.replace(/'/g, "'");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function escapeRegExp(text: string): string {
|
|
31
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function nowRequestId(): string {
|
|
35
|
+
return antigravityRequestEnvelope("unknown", false).requestId;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Deterministic RFC 4122 v5 UUID from seed (survives restarts for the same session seed). */
|
|
39
|
+
export function stableUuid(seed: string): string {
|
|
40
|
+
const bytes = createHash("sha1").update(seed).digest().subarray(0, 16);
|
|
41
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
|
42
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
43
|
+
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
44
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type AntigravityEnvelopeOptions = {
|
|
48
|
+
isClaude?: boolean;
|
|
49
|
+
isNonGemini?: boolean;
|
|
50
|
+
step?: number;
|
|
51
|
+
lastStepIndex?: string;
|
|
52
|
+
requestIndex?: number;
|
|
53
|
+
userTurnIndex?: number;
|
|
54
|
+
trajectoryId?: string;
|
|
55
|
+
conversationId?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const sessionTrajectoryMap = new Map<string, { conversationId: string; trajectoryId: string }>();
|
|
59
|
+
|
|
60
|
+
/** Stable conversationId and trajectoryId within a multi-turn conversation session. */
|
|
61
|
+
export function resolveSessionTrajectory(context?: {
|
|
62
|
+
messages?: Array<{ role?: string; timestamp?: number; content?: unknown }>;
|
|
63
|
+
}): { conversationId: string; trajectoryId: string } {
|
|
64
|
+
const firstMsg = context?.messages?.[0];
|
|
65
|
+
if (!firstMsg) {
|
|
66
|
+
return { conversationId: crypto.randomUUID(), trajectoryId: crypto.randomUUID() };
|
|
67
|
+
}
|
|
68
|
+
const contentSeed =
|
|
69
|
+
typeof firstMsg.content === "string"
|
|
70
|
+
? firstMsg.content.slice(0, 64)
|
|
71
|
+
: Array.isArray(firstMsg.content)
|
|
72
|
+
? JSON.stringify(firstMsg.content[0] ?? "").slice(0, 64)
|
|
73
|
+
: "";
|
|
74
|
+
const seed = `${firstMsg.role || "user"}:${firstMsg.timestamp || ""}:${contentSeed}`;
|
|
75
|
+
let entry = sessionTrajectoryMap.get(seed);
|
|
76
|
+
if (!entry) {
|
|
77
|
+
entry = {
|
|
78
|
+
conversationId: stableUuid(`antigravity:conv:${seed}`),
|
|
79
|
+
trajectoryId: stableUuid(`antigravity:traj:${seed}`),
|
|
80
|
+
};
|
|
81
|
+
sessionTrajectoryMap.set(seed, entry);
|
|
82
|
+
if (sessionTrajectoryMap.size > 64) {
|
|
83
|
+
const oldestKey = sessionTrajectoryMap.keys().next().value;
|
|
84
|
+
if (oldestKey !== undefined) sessionTrajectoryMap.delete(oldestKey);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return entry;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function clearSessionTrajectoryMap(): void {
|
|
91
|
+
sessionTrajectoryMap.clear();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function antigravityRequestEnvelope(
|
|
95
|
+
wireModelId: string,
|
|
96
|
+
optionsOrIsClaude: boolean | AntigravityEnvelopeOptions = false,
|
|
97
|
+
): { requestId: string; sessionId: string; labels: Record<string, string> } {
|
|
98
|
+
const options: AntigravityEnvelopeOptions =
|
|
99
|
+
typeof optionsOrIsClaude === "boolean" ? { isClaude: optionsOrIsClaude } : optionsOrIsClaude;
|
|
100
|
+
|
|
101
|
+
const isClaude = Boolean(options.isClaude);
|
|
102
|
+
const isNonGemini = Boolean(options.isNonGemini || isClaude);
|
|
103
|
+
const step = Math.max(1, options.step ?? 1);
|
|
104
|
+
const lastStepIndex = options.lastStepIndex ?? String(Math.max(0, step - 1));
|
|
105
|
+
const requestIndex = options.requestIndex ?? options.userTurnIndex ?? Math.max(0, step - 1);
|
|
106
|
+
const agentId = options.conversationId || crypto.randomUUID();
|
|
107
|
+
const trajectoryId = options.trajectoryId || crypto.randomUUID();
|
|
108
|
+
const bytes = crypto.getRandomValues(new Uint8Array(8));
|
|
109
|
+
const sessionId = String(new DataView(bytes.buffer, bytes.byteOffset, 8).getBigInt64(0, true));
|
|
110
|
+
|
|
111
|
+
const claudeLabel = isClaude ? "true" : "false";
|
|
112
|
+
const nonGeminiLabel = isNonGemini ? "true" : "false";
|
|
113
|
+
|
|
114
|
+
const labels: Record<string, string> = {
|
|
115
|
+
last_step_index: lastStepIndex,
|
|
116
|
+
request_id: `${trajectoryId}-${requestIndex}`,
|
|
117
|
+
trajectory_id: trajectoryId,
|
|
118
|
+
used_claude: claudeLabel,
|
|
119
|
+
used_claude_conservative: claudeLabel,
|
|
120
|
+
used_non_gemini_model: nonGeminiLabel,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const modelEnum = getModelEnum(wireModelId);
|
|
124
|
+
if (modelEnum) {
|
|
125
|
+
labels.model_enum = modelEnum;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
requestId: `agent/${agentId}/${Date.now()}/${trajectoryId}/${step}`,
|
|
130
|
+
sessionId,
|
|
131
|
+
labels,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -4,6 +4,10 @@ const SECRET_PATTERNS: RegExp[] = [
|
|
|
4
4
|
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
|
5
5
|
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/gi,
|
|
6
6
|
/\bBearer\s+[A-Za-z0-9._~+/=-]+\b/gi,
|
|
7
|
+
/\bya29\.[A-Za-z0-9._~+/-]+=*/g,
|
|
8
|
+
/\b1\/\/[A-Za-z0-9_-]+/g,
|
|
9
|
+
/\b1\/[A-Za-z0-9_-]{20,}/g,
|
|
10
|
+
/("?(?:access_token|refresh_token|id_token)"?\s*[:=]\s*")[^"]*(")/gi,
|
|
7
11
|
/\bKXM_[A-Z0-9_]*(TOKEN|SECRET|KEY)[A-Z0-9_]*=\S+/gi,
|
|
8
12
|
/\b(GITHUB_TOKEN|GH_TOKEN|KXM_AUTH_TOKEN|KXM_WORKFLOW_SIGNAL_SECRET)=\S+/gi,
|
|
9
13
|
/\b[A-Fa-f0-9]{64}\b/g,
|
|
@@ -33,7 +33,9 @@ import { gateRegistryHash } from "./vnext-gate-hash.ts";
|
|
|
33
33
|
import {
|
|
34
34
|
admitVnextRun,
|
|
35
35
|
armVnextGateHold,
|
|
36
|
+
attachVnextDriveSession,
|
|
36
37
|
bindVnextSchedulerPolicy,
|
|
38
|
+
clearVnextDriveSession,
|
|
37
39
|
dropVnextGateStopHook,
|
|
38
40
|
enqueueVnextScheduledRun,
|
|
39
41
|
finishVnextOwnedGate,
|
|
@@ -47,6 +49,7 @@ import {
|
|
|
47
49
|
vnextAttemptControllers,
|
|
48
50
|
vnextGateHold,
|
|
49
51
|
vnextSchedulerPolicy,
|
|
52
|
+
type VnextDriveSession,
|
|
50
53
|
} from "./vnext-runtime-owner.ts";
|
|
51
54
|
import {
|
|
52
55
|
cancelVnextRun,
|
|
@@ -161,6 +164,7 @@ export interface VnextProducerResult {
|
|
|
161
164
|
export interface VnextProducer {
|
|
162
165
|
readonly id: "driver-simulated" | "pi" | "oneshot";
|
|
163
166
|
produce(request: VnextProducerRequest): Promise<VnextProducerResult>;
|
|
167
|
+
close?(): Promise<void>;
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
export interface VnextRunHandoff {
|
|
@@ -412,13 +416,97 @@ function admitPinnedRun(context: VnextRuntimeContext, runId: string): string {
|
|
|
412
416
|
);
|
|
413
417
|
}
|
|
414
418
|
|
|
419
|
+
export interface OpenVnextDriveSessionOptions {
|
|
420
|
+
mode: "simulated" | "live";
|
|
421
|
+
allowLimits?: boolean;
|
|
422
|
+
liveMode?: boolean;
|
|
423
|
+
createProducer: () => VnextProducer;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export interface VnextDriveSessionHandle {
|
|
427
|
+
driveId: string;
|
|
428
|
+
settled: Promise<VnextRunDriveResult>;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function newDriveId(runId: string, token: string, runtimeId: string, monotonicNs: string): string {
|
|
432
|
+
return `drv_${createHash("sha256").update(`${runId}\0${token}\0${runtimeId}\0${monotonicNs}`, "utf8").digest("hex").slice(0, 24)}`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function inflightAttemptId(state: VnextRunState): string | undefined {
|
|
436
|
+
const leftover = unreconciledPanelAttemptId(state);
|
|
437
|
+
if (leftover) return leftover;
|
|
438
|
+
if (state.currentStep?.attemptId) {
|
|
439
|
+
const located = vnextFoldPanelAttempt(state.currentStep, state.currentStep.attemptId);
|
|
440
|
+
const status = located?.attempt.status;
|
|
441
|
+
if (status === "starting" || status === "executing" || status === "settling") return state.currentStep.attemptId;
|
|
442
|
+
}
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function recordExecutingUnrecordedFailure(context: VnextRuntimeContext, runId: string, attemptId: string, stepId: string): void {
|
|
447
|
+
context.eventStore.transaction(() => {
|
|
448
|
+
const run = requireRun(context, runId);
|
|
449
|
+
const now = new Date().toISOString();
|
|
450
|
+
const sequence = context.eventStore.nextSequence(runId);
|
|
451
|
+
const event: VnextRunEvent = {
|
|
452
|
+
...vnextEventBase(context, run, now, vnextMonotonicNs()),
|
|
453
|
+
eventId: newVnextEventId(),
|
|
454
|
+
eventType: "run.status_changed",
|
|
455
|
+
sequence,
|
|
456
|
+
payload: { status: "failed", reason: "executing_unrecorded", stepId },
|
|
457
|
+
};
|
|
458
|
+
context.eventStore.appendEvent(event);
|
|
459
|
+
context.eventStore.settleCapability(attemptId, "revoked");
|
|
460
|
+
const next = foldStoredVnextRun(context, run);
|
|
461
|
+
persistVnextRunState(context, runId, next, sequence);
|
|
462
|
+
context.eventStore.updateRunStatus(runId, "failed", now);
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function recordBareDriveFailure(context: VnextRuntimeContext, runId: string): void {
|
|
467
|
+
if (isVnextRuntimeContextClosed(context)) return;
|
|
468
|
+
try {
|
|
469
|
+
const run = context.eventStore.run(runId);
|
|
470
|
+
if (!run) return;
|
|
471
|
+
const state = foldStoredVnextRun(context, run);
|
|
472
|
+
if (isTerminalRunStatus(state.status) || state.status === "cancelling") return;
|
|
473
|
+
const inflight = inflightAttemptId(state);
|
|
474
|
+
if (inflight) {
|
|
475
|
+
const currentStep = state.currentStep;
|
|
476
|
+
const located = vnextFoldPanelAttempt(currentStep, inflight);
|
|
477
|
+
if (currentStep && currentStep.panel.order.length === 1 && located?.attempt.status === "starting") {
|
|
478
|
+
const plan = rehydrateVnextCompiledPlanFromStore(context.eventStore, run);
|
|
479
|
+
const step = plan.steps[currentStep.stepId];
|
|
480
|
+
if (step?.kind === "gate") return;
|
|
481
|
+
if (step && (step.kind === "agent" || step.kind === "moa") && step.assignments.maximum > 1) return;
|
|
482
|
+
recordExecutingUnrecordedFailure(context, runId, inflight, currentStep.stepId);
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
cancelVnextRun(context, runId, { reason: "drive_error" });
|
|
487
|
+
} catch {
|
|
488
|
+
// The original drive error governs; this is a best-effort bare-running brake.
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function closeDriveProducer(producer: VnextProducer): Promise<void> {
|
|
493
|
+
if (typeof producer.close !== "function") return;
|
|
494
|
+
try {
|
|
495
|
+
await producer.close();
|
|
496
|
+
} catch {
|
|
497
|
+
// Session ownership ends even if producer cleanup fails.
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
415
501
|
export class VnextRunScheduler {
|
|
416
502
|
private readonly context: VnextRuntimeContext;
|
|
503
|
+
private readonly bundle: VnextProjectBundle;
|
|
417
504
|
private readonly configRevision: string;
|
|
418
505
|
|
|
419
|
-
private constructor(context: VnextRuntimeContext,
|
|
506
|
+
private constructor(context: VnextRuntimeContext, bundle: VnextProjectBundle) {
|
|
420
507
|
this.context = context;
|
|
421
|
-
this.
|
|
508
|
+
this.bundle = bundle;
|
|
509
|
+
this.configRevision = bundle.configRevision;
|
|
422
510
|
}
|
|
423
511
|
|
|
424
512
|
static for(context: VnextRuntimeContext, bundle: VnextProjectBundle): VnextRunScheduler {
|
|
@@ -427,7 +515,7 @@ export class VnextRunScheduler {
|
|
|
427
515
|
}
|
|
428
516
|
const limits = vnextProjectAdmissionLimits(bundle);
|
|
429
517
|
bindVnextSchedulerPolicy(context.eventStore.path, limits.maxConcurrentRuns, bundle.configRevision);
|
|
430
|
-
return new VnextRunScheduler(context, bundle
|
|
518
|
+
return new VnextRunScheduler(context, bundle);
|
|
431
519
|
}
|
|
432
520
|
|
|
433
521
|
enqueue(
|
|
@@ -454,6 +542,94 @@ export class VnextRunScheduler {
|
|
|
454
542
|
return Promise.reject(error);
|
|
455
543
|
}
|
|
456
544
|
}
|
|
545
|
+
|
|
546
|
+
openDriveSession(runId: string, options: OpenVnextDriveSessionOptions): Promise<VnextDriveSessionHandle> {
|
|
547
|
+
const policy = vnextSchedulerPolicy(this.context.eventStore.path);
|
|
548
|
+
if (!policy || policy.configRevision !== this.configRevision) {
|
|
549
|
+
return Promise.reject(runtimeError("scheduler_policy_conflict", runId, "scheduler handle does not match the active policy"));
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
const run = requireRun(this.context, runId);
|
|
553
|
+
if (isTerminalRunStatus(run.status) || run.status === "cancelling") {
|
|
554
|
+
return Promise.reject(runtimeError("run_busy", runId, `run ${runId} is already ${run.status}`));
|
|
555
|
+
}
|
|
556
|
+
} catch (error) {
|
|
557
|
+
return Promise.reject(error);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const limits = vnextProjectAdmissionLimits(this.bundle);
|
|
561
|
+
const driveOptions: { allowLimits?: boolean; liveMode?: boolean } = {
|
|
562
|
+
liveMode: options.liveMode ?? options.mode === "live",
|
|
563
|
+
};
|
|
564
|
+
if (options.allowLimits !== undefined) driveOptions.allowLimits = options.allowLimits;
|
|
565
|
+
let opened = false;
|
|
566
|
+
const pending = {
|
|
567
|
+
resolve: (_value: VnextRunDriveResult) => undefined as void,
|
|
568
|
+
reject: (_error: unknown) => undefined as void,
|
|
569
|
+
};
|
|
570
|
+
const settled = new Promise<VnextRunDriveResult>((resolve, reject) => {
|
|
571
|
+
pending.resolve = resolve;
|
|
572
|
+
pending.reject = reject;
|
|
573
|
+
});
|
|
574
|
+
void settled.then(undefined, () => undefined);
|
|
575
|
+
|
|
576
|
+
return new Promise<VnextDriveSessionHandle>((resolveOpen, rejectOpen) => {
|
|
577
|
+
const queued = enqueueVnextScheduledRun(
|
|
578
|
+
this.context.eventStore.path,
|
|
579
|
+
runId,
|
|
580
|
+
this.configRevision,
|
|
581
|
+
limits.maxConcurrentRuns,
|
|
582
|
+
async (token) => {
|
|
583
|
+
let producer: VnextProducer | undefined;
|
|
584
|
+
try {
|
|
585
|
+
producer = options.createProducer();
|
|
586
|
+
requireTrustedProducer(producer);
|
|
587
|
+
const current = requireRun(this.context, runId);
|
|
588
|
+
if (current.status === "created") {
|
|
589
|
+
pinVnextCompiledPlan(this.context, this.bundle, runId);
|
|
590
|
+
}
|
|
591
|
+
const started = startVnextRun(this.context, runId, driveOptions);
|
|
592
|
+
if (started.handoff) {
|
|
593
|
+
const error = runtimeError("run_handoff_required", runId, started.handoff.detail);
|
|
594
|
+
Object.assign(error, { handoff: started.handoff });
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
const driveId = newDriveId(runId, token, this.context.homeRuntimeId, vnextMonotonicNs());
|
|
598
|
+
const session: VnextDriveSession = {
|
|
599
|
+
driveId,
|
|
600
|
+
runId,
|
|
601
|
+
token,
|
|
602
|
+
homeRuntimeId: this.context.homeRuntimeId,
|
|
603
|
+
mode: options.mode,
|
|
604
|
+
openedAt: new Date().toISOString(),
|
|
605
|
+
controller: new AbortController(),
|
|
606
|
+
settled,
|
|
607
|
+
};
|
|
608
|
+
attachVnextDriveSession(this.context.eventStore.path, runId, token, session);
|
|
609
|
+
opened = true;
|
|
610
|
+
resolveOpen({ driveId, settled });
|
|
611
|
+
try {
|
|
612
|
+
const result = await driveAdmitted(this.context, runId, producer, token, driveOptions);
|
|
613
|
+
pending.resolve(result);
|
|
614
|
+
return result;
|
|
615
|
+
} catch (error) {
|
|
616
|
+
recordBareDriveFailure(this.context, runId);
|
|
617
|
+
pending.reject(error);
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
} finally {
|
|
621
|
+
if (producer) await closeDriveProducer(producer);
|
|
622
|
+
clearVnextDriveSession(this.context.eventStore.path, runId, token);
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
) as Promise<VnextRunDriveResult>;
|
|
626
|
+
void queued.then(undefined, (error) => {
|
|
627
|
+
if (!opened) {
|
|
628
|
+
rejectOpen(error);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
}
|
|
457
633
|
}
|
|
458
634
|
|
|
459
635
|
export function verifyVnextAttemptCapability(
|
|
@@ -893,6 +1069,7 @@ export interface VnextPanelDispatchSeams {
|
|
|
893
1069
|
failAppendExecuting?: ((member: VnextPanelMemberHook) => boolean) | undefined;
|
|
894
1070
|
beforeInvoke?: ((member: VnextPanelMemberHook) => void) | undefined;
|
|
895
1071
|
failSettleMember?: ((member: VnextPanelMemberHook) => boolean) | undefined;
|
|
1072
|
+
skipDispatch?: (() => boolean) | undefined;
|
|
896
1073
|
}
|
|
897
1074
|
|
|
898
1075
|
export const vnextPanelDispatchSeams: VnextPanelDispatchSeams = {};
|
|
@@ -993,6 +1170,9 @@ function prepareDispatch(
|
|
|
993
1170
|
const run = requireRun(context, runId);
|
|
994
1171
|
const plan = rehydrateVnextCompiledPlanFromStore(context.eventStore, run);
|
|
995
1172
|
const state = foldStoredVnextRun(context, run);
|
|
1173
|
+
if (vnextPanelDispatchSeams.skipDispatch?.()) {
|
|
1174
|
+
return { kind: "return", state };
|
|
1175
|
+
}
|
|
996
1176
|
if (state.status === "cancelling" && vnextAttemptControllers(context.eventStore.path, runId).length === 0) {
|
|
997
1177
|
return {
|
|
998
1178
|
kind: "return",
|
|
@@ -19,10 +19,22 @@ interface GateHold {
|
|
|
19
19
|
stop?: () => void;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export interface VnextDriveSession {
|
|
23
|
+
driveId: string;
|
|
24
|
+
runId: string;
|
|
25
|
+
token: string;
|
|
26
|
+
homeRuntimeId: string;
|
|
27
|
+
mode: "simulated" | "live";
|
|
28
|
+
openedAt: string;
|
|
29
|
+
controller: AbortController;
|
|
30
|
+
settled: Promise<unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
interface AdmissionRecord {
|
|
23
34
|
token: string;
|
|
24
35
|
configRevision: string;
|
|
25
36
|
gateHold?: GateHold;
|
|
37
|
+
driveSession?: VnextDriveSession;
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
interface QueueItem {
|
|
@@ -270,6 +282,44 @@ export function vnextAdmittedToken(storePath: string, runId: string): string | u
|
|
|
270
282
|
return owners.get(storePath)?.admitted.get(runId)?.token;
|
|
271
283
|
}
|
|
272
284
|
|
|
285
|
+
export function attachVnextDriveSession(
|
|
286
|
+
storePath: string,
|
|
287
|
+
runId: string,
|
|
288
|
+
token: string,
|
|
289
|
+
session: VnextDriveSession,
|
|
290
|
+
): void {
|
|
291
|
+
const owner = record(storePath);
|
|
292
|
+
const current = owner.admitted.get(runId);
|
|
293
|
+
if (!current || current.token !== token) {
|
|
294
|
+
throw runtimeError("run_events_illegal", runId, "drive session requires the exact admitted token");
|
|
295
|
+
}
|
|
296
|
+
if (current.driveSession) {
|
|
297
|
+
throw runtimeError("run_busy", runId, `run ${runId} already has a drive session`);
|
|
298
|
+
}
|
|
299
|
+
current.driveSession = session;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function clearVnextDriveSession(storePath: string, runId: string, token: string): void {
|
|
303
|
+
const owner = owners.get(storePath);
|
|
304
|
+
const current = owner?.admitted.get(runId);
|
|
305
|
+
if (!current || current.token !== token) return;
|
|
306
|
+
delete current.driveSession;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function vnextDriveSession(storePath: string, runId: string): VnextDriveSession | undefined {
|
|
310
|
+
return owners.get(storePath)?.admitted.get(runId)?.driveSession;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function vnextOpenDriveSessions(storePath: string): VnextDriveSession[] {
|
|
314
|
+
const owner = owners.get(storePath);
|
|
315
|
+
if (!owner) return [];
|
|
316
|
+
const sessions: VnextDriveSession[] = [];
|
|
317
|
+
for (const current of owner.admitted.values()) {
|
|
318
|
+
if (current.driveSession) sessions.push(current.driveSession);
|
|
319
|
+
}
|
|
320
|
+
return sessions;
|
|
321
|
+
}
|
|
322
|
+
|
|
273
323
|
export function enqueueVnextScheduledRun(
|
|
274
324
|
storePath: string,
|
|
275
325
|
runId: string,
|