@f5-sales-demo/xcsh 19.61.0 → 19.61.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/package.json +10 -9
- package/scripts/api-specs-version.ts +20 -0
- package/scripts/bun-test-guarded.ts +54 -0
- package/scripts/check-bundle.ts +32 -0
- package/scripts/generate-api-spec-index.ts +29 -1
- package/src/browser/chat-handler.ts +23 -1
- package/src/browser/ttft-spans.ts +42 -0
- package/src/commands/manager.ts +18 -4
- package/src/commands/worker.ts +49 -1
- package/src/edit/modes/chunk.ts +1 -1
- package/src/internal-urls/api-spec-resolve.ts +50 -26
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/terraform-index.generated.ts +207 -411
- package/src/system-prompt.ts +18 -2
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/ast-grep.ts +1 -1
- package/src/tools/grep.ts +46 -6
- package/src/tools/renderers.ts +2 -5
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.61.
|
|
4
|
+
"version": "19.61.2",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"check": "biome check . && bun run format-prompts -- --check && bun run check:types",
|
|
36
36
|
"check:types": "bun run generate-build-info && bun run generate-extension-capabilities && bun run generate-api-spec-index && bun run generate-branding-index && bun run generate-terraform-index && tsgo -p tsconfig.json --noEmit",
|
|
37
37
|
"lint": "biome lint .",
|
|
38
|
-
"
|
|
38
|
+
"check:bundle": "bun scripts/check-bundle.ts",
|
|
39
|
+
"test": "bun run generate-build-info && bun run generate-extension-capabilities && bun run generate-api-spec-index && bun run generate-console-catalog && bun run generate-console-field-metadata && bun scripts/bun-test-guarded.ts --max-concurrency 4",
|
|
39
40
|
"fix": "biome check --write --unsafe . && bun run format-prompts && bun run generate-docs-index && bun run generate-api-spec-index && bun run generate-build-info",
|
|
40
41
|
"fmt": "biome format --write . && bun run format-prompts",
|
|
41
42
|
"format-prompts": "bun scripts/format-prompts.ts",
|
|
@@ -55,13 +56,13 @@
|
|
|
55
56
|
"dependencies": {
|
|
56
57
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
57
58
|
"@mozilla/readability": "^0.6",
|
|
58
|
-
"@f5-sales-demo/xcsh-stats": "19.61.
|
|
59
|
-
"@f5-sales-demo/pi-agent-core": "19.61.
|
|
60
|
-
"@f5-sales-demo/pi-ai": "19.61.
|
|
61
|
-
"@f5-sales-demo/pi-natives": "19.61.
|
|
62
|
-
"@f5-sales-demo/pi-resource-management": "19.61.
|
|
63
|
-
"@f5-sales-demo/pi-tui": "19.61.
|
|
64
|
-
"@f5-sales-demo/pi-utils": "19.61.
|
|
59
|
+
"@f5-sales-demo/xcsh-stats": "19.61.2",
|
|
60
|
+
"@f5-sales-demo/pi-agent-core": "19.61.2",
|
|
61
|
+
"@f5-sales-demo/pi-ai": "19.61.2",
|
|
62
|
+
"@f5-sales-demo/pi-natives": "19.61.2",
|
|
63
|
+
"@f5-sales-demo/pi-resource-management": "19.61.2",
|
|
64
|
+
"@f5-sales-demo/pi-tui": "19.61.2",
|
|
65
|
+
"@f5-sales-demo/pi-utils": "19.61.2",
|
|
65
66
|
"@sinclair/typebox": "^0.34",
|
|
66
67
|
"@xterm/headless": "^6.0",
|
|
67
68
|
"ajv": "^8.20",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version helpers for selecting the api-specs-enriched source.
|
|
3
|
+
*
|
|
4
|
+
* Kept in a dedicated module (no side effects) so the freshness logic is unit-testable
|
|
5
|
+
* without importing generate-api-spec-index.ts, which runs the generator on load.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Normalize a release tag (`v2.1.167`) or bare version (`2.1.167`) to a bare version. */
|
|
9
|
+
export function normalizeSpecsTag(tag: string): string {
|
|
10
|
+
return tag.trim().replace(/^v/, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether a local api-specs-enriched checkout matches the latest release tag.
|
|
15
|
+
* A stale checkout must NOT be used, or local/dev builds silently pin to old specs.
|
|
16
|
+
*/
|
|
17
|
+
export function isLocalSpecsCurrent(localVersion: string | undefined, latestTag: string): boolean {
|
|
18
|
+
if (!localVersion) return false;
|
|
19
|
+
return normalizeSpecsTag(localVersion) === normalizeSpecsTag(latestTag);
|
|
20
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guarded `bun test` runner.
|
|
4
|
+
*
|
|
5
|
+
* Runs `bun test <args…>` and enforces one extra invariant: if the run exits 0
|
|
6
|
+
* it MUST have printed a "Ran N tests" summary. A test module that calls
|
|
7
|
+
* `process.exit(0)` terminates the whole runner with a success code BEFORE the
|
|
8
|
+
* summary prints, silently masking every failure that already ran (this made the
|
|
9
|
+
* required CI `test` job a no-op gate — issue #1903). Exit code alone can't catch
|
|
10
|
+
* that regression; the missing summary can.
|
|
11
|
+
*
|
|
12
|
+
* Behaviour:
|
|
13
|
+
* - child exits non-zero → propagate it (real failures already fail the job).
|
|
14
|
+
* - child exits 0 WITH a summary ("Ran N tests" / "No tests found") → pass.
|
|
15
|
+
* - child exits 0 WITHOUT a summary → fail (exit 1): a test aborted the runner.
|
|
16
|
+
*
|
|
17
|
+
* stderr (where bun writes results) is streamed through unchanged so CI logs are
|
|
18
|
+
* identical; we only tee it to scan for the summary line.
|
|
19
|
+
*/
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
|
|
22
|
+
// Use the running bun (process.execPath), not a PATH lookup of "bun", so the
|
|
23
|
+
// guard works in any environment the parent runs in.
|
|
24
|
+
const proc = Bun.spawn([process.execPath, "test", ...args], {
|
|
25
|
+
stdout: "inherit",
|
|
26
|
+
stderr: "pipe",
|
|
27
|
+
env: process.env,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const SUMMARY = /Ran \d+ tests?\b|No tests found/;
|
|
31
|
+
const decoder = new TextDecoder();
|
|
32
|
+
// Keep a small rolling tail so a summary line split across two chunks is still
|
|
33
|
+
// matched, without buffering the entire (potentially large) stderr stream.
|
|
34
|
+
let tail = "";
|
|
35
|
+
|
|
36
|
+
for await (const chunk of proc.stderr as ReadableStream<Uint8Array>) {
|
|
37
|
+
process.stderr.write(chunk); // stream through unchanged
|
|
38
|
+
tail = (tail + decoder.decode(chunk, { stream: true })).slice(-4096);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sawSummary = SUMMARY.test(tail);
|
|
42
|
+
const code = await proc.exited;
|
|
43
|
+
|
|
44
|
+
if (code !== 0) process.exit(code);
|
|
45
|
+
|
|
46
|
+
if (!sawSummary) {
|
|
47
|
+
process.stderr.write(
|
|
48
|
+
'\n\x1b[31mtest-guard: `bun test` exited 0 but printed no "Ran N tests" summary.\x1b[0m\n' +
|
|
49
|
+
"A test module likely called process.exit() and aborted the runner, masking failures (see #1903).\n",
|
|
50
|
+
);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
process.exit(0);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Bundle guard: ensure the CLI entrypoint still bundles.
|
|
4
|
+
*
|
|
5
|
+
* Reproduces the `bun build --compile` module-graph analysis in ~1s so a
|
|
6
|
+
* `require()` into a top-level-await subgraph (or other bundle-breaking graph
|
|
7
|
+
* change) fails fast in a required CI gate — not only in the slower, optional
|
|
8
|
+
* install-methods build. See PRs #1888 → #1892 for the regression this guards.
|
|
9
|
+
*
|
|
10
|
+
* Runs as a standalone `bun` process (not under `bun test`, whose bundler
|
|
11
|
+
* resolver cannot resolve some lazily-imported specifiers). Exits non-zero on
|
|
12
|
+
* any bundle error.
|
|
13
|
+
*/
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
|
|
16
|
+
const cliEntry = path.resolve(import.meta.dir, "../src/cli.ts");
|
|
17
|
+
|
|
18
|
+
const result = await Bun.build({
|
|
19
|
+
entrypoints: [cliEntry],
|
|
20
|
+
target: "bun",
|
|
21
|
+
define: { PI_COMPILED: "true" },
|
|
22
|
+
external: ["mupdf"],
|
|
23
|
+
throw: false,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (!result.success) {
|
|
27
|
+
console.error("Bundle check FAILED — the CLI entrypoint does not bundle:\n");
|
|
28
|
+
for (const log of result.logs) console.error(String(log));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log("Bundle check passed — CLI entrypoint bundles cleanly.");
|
|
@@ -4,6 +4,7 @@ import * as fs from "node:fs";
|
|
|
4
4
|
import * as os from "node:os";
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { $ } from "bun";
|
|
7
|
+
import { isLocalSpecsCurrent } from "./api-specs-version";
|
|
7
8
|
|
|
8
9
|
interface SpecPathOperation {
|
|
9
10
|
operationId?: string;
|
|
@@ -183,15 +184,42 @@ async function downloadFromRelease(): Promise<string> {
|
|
|
183
184
|
return extractDir;
|
|
184
185
|
}
|
|
185
186
|
|
|
187
|
+
function readLocalSpecsVersion(specsDir: string): string | undefined {
|
|
188
|
+
try {
|
|
189
|
+
const index = JSON.parse(fs.readFileSync(path.join(specsDir, "index.json"), "utf-8")) as { version?: string };
|
|
190
|
+
return index.version;
|
|
191
|
+
} catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
186
196
|
async function findSpecsDir(): Promise<string> {
|
|
187
197
|
const envDir = process.env.API_SPECS_DIR;
|
|
188
198
|
if (envDir && fs.existsSync(envDir)) {
|
|
199
|
+
// Explicit override — the caller is responsible for its freshness.
|
|
189
200
|
return envDir;
|
|
190
201
|
}
|
|
191
202
|
|
|
192
203
|
const localCheckout = path.resolve(import.meta.dir, "../../../../api-specs-enriched/docs/specifications/api");
|
|
193
204
|
if (fs.existsSync(localCheckout)) {
|
|
194
|
-
|
|
205
|
+
// Only build from the local checkout when it matches the latest release, so a
|
|
206
|
+
// stale checkout cannot silently pin the build to old specs. If GitHub is
|
|
207
|
+
// unreachable (offline dev), fall back to the local checkout with a warning.
|
|
208
|
+
try {
|
|
209
|
+
const latestTag = await resolveLatestTag();
|
|
210
|
+
const localVersion = readLocalSpecsVersion(localCheckout);
|
|
211
|
+
if (isLocalSpecsCurrent(localVersion, latestTag)) {
|
|
212
|
+
return localCheckout;
|
|
213
|
+
}
|
|
214
|
+
console.warn(
|
|
215
|
+
`Local api-specs-enriched checkout is stale (local ${localVersion ?? "unknown"} != latest ${latestTag}); building against the latest release instead.`,
|
|
216
|
+
);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
console.warn(
|
|
219
|
+
`Could not verify the latest api-specs version (${err instanceof Error ? err.message : err}); using the local checkout.`,
|
|
220
|
+
);
|
|
221
|
+
return localCheckout;
|
|
222
|
+
}
|
|
195
223
|
}
|
|
196
224
|
|
|
197
225
|
return downloadFromRelease();
|
|
@@ -14,12 +14,16 @@ import {
|
|
|
14
14
|
import { CONSOLE_ROUTES } from "./console-routes.generated";
|
|
15
15
|
import type { BridgeServer } from "./extension-bridge";
|
|
16
16
|
import { interpretPageState } from "./page-state-interpreter";
|
|
17
|
+
import { chatSpans } from "./ttft-spans";
|
|
17
18
|
|
|
18
19
|
interface ActiveChat {
|
|
19
20
|
id: string;
|
|
20
21
|
seq: number;
|
|
21
22
|
terminalSent: boolean;
|
|
22
23
|
unsubscribe: () => void;
|
|
24
|
+
entryAt: number;
|
|
25
|
+
promptAt: number | null;
|
|
26
|
+
spanEmitted: boolean;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export class ChatHandler {
|
|
@@ -61,7 +65,15 @@ export class ChatHandler {
|
|
|
61
65
|
this.#activeHistoryHint = req.history_hint;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
const chat: ActiveChat = {
|
|
68
|
+
const chat: ActiveChat = {
|
|
69
|
+
id,
|
|
70
|
+
seq: 0,
|
|
71
|
+
terminalSent: false,
|
|
72
|
+
unsubscribe: () => {},
|
|
73
|
+
entryAt: Date.now(),
|
|
74
|
+
promptAt: null,
|
|
75
|
+
spanEmitted: false,
|
|
76
|
+
};
|
|
65
77
|
this.#activeChats.set(id, chat);
|
|
66
78
|
|
|
67
79
|
const unsubscribe = this.#session.subscribe((event: AgentSessionEvent) => {
|
|
@@ -72,6 +84,7 @@ export class ChatHandler {
|
|
|
72
84
|
const prompt = composeChatPrompt(req.text, req.context, req.mode);
|
|
73
85
|
|
|
74
86
|
try {
|
|
87
|
+
chat.promptAt = Date.now();
|
|
75
88
|
await this.#session.prompt(prompt, { expandPromptTemplates: false, synthetic: false });
|
|
76
89
|
} catch (err: unknown) {
|
|
77
90
|
const message = err instanceof Error ? err.message : "unknown error";
|
|
@@ -121,6 +134,15 @@ export class ChatHandler {
|
|
|
121
134
|
seq: chat.seq++,
|
|
122
135
|
delta: ame.delta,
|
|
123
136
|
} satisfies ChatDelta);
|
|
137
|
+
// TTFT Phase 2: first token out — emit the chat-segment spans once, keyed by
|
|
138
|
+
// the c- turn id. chat.promptAt is set (prompt() was awaited before any event);
|
|
139
|
+
// guard against re-emit on later deltas.
|
|
140
|
+
if (!chat.spanEmitted && chat.promptAt !== null) {
|
|
141
|
+
chat.spanEmitted = true;
|
|
142
|
+
for (const s of chatSpans(chat.id, chat.entryAt, chat.promptAt, Date.now())) {
|
|
143
|
+
this.#server.send(s);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
124
146
|
} else if (ame.type === "error") {
|
|
125
147
|
const errorMsg = ame.error?.errorMessage ?? "LLM error";
|
|
126
148
|
this.#sendTerminal(chat, { type: "chat_error", id: chat.id, error: errorMsg });
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure TTFT span-frame builders for Phase 2. The worker emits these over the bridge
|
|
3
|
+
* WS; the extension records them (proc:'xcsh') and summarizeTtft stitches them into
|
|
4
|
+
* the init->first-token timeline. Chrome-free, so they unit-test in isolation.
|
|
5
|
+
*/
|
|
6
|
+
export interface SpanFrame {
|
|
7
|
+
type: "span";
|
|
8
|
+
stage: string;
|
|
9
|
+
ms: number;
|
|
10
|
+
id?: string;
|
|
11
|
+
sid?: string;
|
|
12
|
+
cold?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const clamp = (ms: number): number => (ms > 0 ? ms : 0);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Decompose one chat turn's route->first-token into two disjoint spans that sum to
|
|
19
|
+
* (firstDeltaAt - entryAt): provider_ttft (prompt issued -> first token) and
|
|
20
|
+
* chat_handler (the xcsh routing/compose/emit overhead = entry -> prompt issued).
|
|
21
|
+
*/
|
|
22
|
+
export function chatSpans(id: string, entryAt: number, promptAt: number, firstDeltaAt: number): SpanFrame[] {
|
|
23
|
+
const providerMs = clamp(firstDeltaAt - promptAt);
|
|
24
|
+
const handlerMs = clamp(firstDeltaAt - entryAt - providerMs);
|
|
25
|
+
return [
|
|
26
|
+
{ type: "span", stage: "provider_ttft", ms: providerMs, id },
|
|
27
|
+
{ type: "span", stage: "chat_handler", ms: handlerMs, id },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Build the per-session cold-start spans, tagged with the session id + authoritative cold. */
|
|
32
|
+
export function coldStartSpans(
|
|
33
|
+
sid: string,
|
|
34
|
+
cold: boolean,
|
|
35
|
+
managerProvisionMs: number,
|
|
36
|
+
workerBootMs: number,
|
|
37
|
+
): SpanFrame[] {
|
|
38
|
+
return [
|
|
39
|
+
{ type: "span", stage: "manager_provision", ms: clamp(managerProvisionMs), sid, cold },
|
|
40
|
+
{ type: "span", stage: "worker_boot", ms: clamp(workerBootMs), sid, cold },
|
|
41
|
+
];
|
|
42
|
+
}
|
package/src/commands/manager.ts
CHANGED
|
@@ -284,11 +284,17 @@ export default class Manager extends Command {
|
|
|
284
284
|
};
|
|
285
285
|
|
|
286
286
|
/** Adopt a warm spare for a provision (bind over IPC). Returns false if none available. */
|
|
287
|
-
const adoptSpare = (msg: { sessionId: string; tenant: string }): boolean => {
|
|
287
|
+
const adoptSpare = (msg: { sessionId: string; tenant: string }, managerProvisionMs: number): boolean => {
|
|
288
288
|
const rec = pool.shift();
|
|
289
289
|
if (!rec) return false;
|
|
290
290
|
// `send` exists because the spare was spawned with an `ipc` handler.
|
|
291
|
-
(rec.proc as { send(m: unknown): void }).send({
|
|
291
|
+
(rec.proc as { send(m: unknown): void }).send({
|
|
292
|
+
type: "bind",
|
|
293
|
+
sessionId: msg.sessionId,
|
|
294
|
+
tenant: msg.tenant,
|
|
295
|
+
provisionMs: managerProvisionMs, // TTFT Phase 2: relayed manager_provision (warm)
|
|
296
|
+
cold: false, // authoritative: warm adopt
|
|
297
|
+
});
|
|
292
298
|
reg.set(msg.sessionId, {
|
|
293
299
|
sessionId: msg.sessionId,
|
|
294
300
|
tenant: msg.tenant,
|
|
@@ -354,7 +360,7 @@ export default class Manager extends Command {
|
|
|
354
360
|
process.exit(0);
|
|
355
361
|
};
|
|
356
362
|
|
|
357
|
-
const spawnWorker = (msg: { sessionId: string; tenant: string }): void => {
|
|
363
|
+
const spawnWorker = (msg: { sessionId: string; tenant: string }, managerProvisionMs: number): void => {
|
|
358
364
|
// Registry-dedupe (pickPort) over only the ports free at the OS level.
|
|
359
365
|
const port = pickPort(reg, range.filter(isPortFree));
|
|
360
366
|
if (port === null) {
|
|
@@ -374,6 +380,12 @@ export default class Manager extends Command {
|
|
|
374
380
|
// spawned tenant key is authoritative (undefined removes the var in Bun).
|
|
375
381
|
XCSH_API_URL: undefined,
|
|
376
382
|
XCSH_API_TOKEN: undefined,
|
|
383
|
+
// TTFT Phase 2: relay cold-start timing to the worker (only it has a WS to
|
|
384
|
+
// the extension). Wall-clock spawn instant + manager_provision ms; COLD=1
|
|
385
|
+
// marks the authoritative cold spawn.
|
|
386
|
+
XCSH_TTFT_SPAWN_AT: String(Date.now()),
|
|
387
|
+
XCSH_TTFT_PROVISION_MS: String(managerProvisionMs),
|
|
388
|
+
XCSH_TTFT_COLD: "1",
|
|
377
389
|
},
|
|
378
390
|
stdout: "ignore",
|
|
379
391
|
stderr: "ignore",
|
|
@@ -417,8 +429,10 @@ export default class Manager extends Command {
|
|
|
417
429
|
}
|
|
418
430
|
return;
|
|
419
431
|
}
|
|
432
|
+
const provisionReceivedAt = Date.now(); // TTFT Phase 2: start of manager_provision
|
|
420
433
|
if (needsProvision(reg, msg.sessionId)) {
|
|
421
|
-
|
|
434
|
+
const managerProvisionMs = Date.now() - provisionReceivedAt;
|
|
435
|
+
if (!adoptSpare(msg, managerProvisionMs)) spawnWorker(msg, managerProvisionMs); // adopt a warm spare, else cold-spawn (fallback)
|
|
422
436
|
}
|
|
423
437
|
const w = reg.get(msg.sessionId);
|
|
424
438
|
if (w) w.lastSeen = Date.now(); // touch on every provision (keep-alive)
|
package/src/commands/worker.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { ChatHandler } from "../browser/chat-handler";
|
|
|
19
19
|
import { startBridgeServer } from "../browser/extension-bridge";
|
|
20
20
|
import { createExtensionBridgeTools, EXTENSION_AGENT_TOOL_NAMES } from "../browser/extension-bridge-tools";
|
|
21
21
|
import { setSharedBridgeServer } from "../browser/provider";
|
|
22
|
+
import { coldStartSpans, type SpanFrame } from "../browser/ttft-spans";
|
|
22
23
|
import { initializeWithSettings } from "../discovery";
|
|
23
24
|
import { createAgentSession } from "../sdk";
|
|
24
25
|
import { activateTenantContext } from "../services/session-context-binding";
|
|
@@ -109,6 +110,12 @@ export default class Worker extends Command {
|
|
|
109
110
|
// Spans only accumulate while recording; nothing prints unless PI_TIMING is set,
|
|
110
111
|
// and each logger.time() returns its wrapped value unchanged — so a normal
|
|
111
112
|
// `xcsh worker` run is behaviorally identical to before.
|
|
113
|
+
// TTFT Phase 2: the manager stamps XCSH_TTFT_SPAWN_AT at Bun.spawn for a cold
|
|
114
|
+
// spawn; worker_boot(cold) = bridge-listening instant - spawn instant (captures
|
|
115
|
+
// fork + runtime init, which logger.startTiming below misses).
|
|
116
|
+
const spawnAtEnv = Number(process.env.XCSH_TTFT_SPAWN_AT);
|
|
117
|
+
const coldSpawn = process.env.XCSH_TTFT_COLD === "1";
|
|
118
|
+
const managerProvisionMsEnv = Number(process.env.XCSH_TTFT_PROVISION_MS);
|
|
112
119
|
logger.startTiming();
|
|
113
120
|
|
|
114
121
|
process.env.XCSH_BROWSER_PROVIDER = "extension";
|
|
@@ -142,15 +149,50 @@ export default class Worker extends Command {
|
|
|
142
149
|
bridge.setSessionInfo(sessionInfoForWorker);
|
|
143
150
|
ContextService.onContextChange(() => bridge.broadcastTenantChanged());
|
|
144
151
|
|
|
152
|
+
// TTFT Phase 2: buffer the per-session cold-start spans and flush them once a
|
|
153
|
+
// client is actually connected. bridge.send() silently no-ops with no client, so
|
|
154
|
+
// the flush is gated on `clientConnected` — a cold-boot flush before the extension
|
|
155
|
+
// connects would otherwise burn the once-latch and lose the spans. Cold path is
|
|
156
|
+
// populated now (env); warm path by the {bind} handler below.
|
|
157
|
+
let coldStartBuffer: SpanFrame[] = [];
|
|
158
|
+
let coldStartSent = false;
|
|
159
|
+
let clientConnected = false;
|
|
160
|
+
const flushColdStart = (): void => {
|
|
161
|
+
if (coldStartSent || !clientConnected || coldStartBuffer.length === 0) return;
|
|
162
|
+
for (const s of coldStartBuffer) bridge.send(s);
|
|
163
|
+
coldStartSent = true;
|
|
164
|
+
};
|
|
165
|
+
// onConnected (raw WS open) is the deliberate flush trigger: no hello hook is exposed
|
|
166
|
+
// today, and the bridge's origin check already gates opens to the extension.
|
|
167
|
+
bridge.onConnected(() => {
|
|
168
|
+
clientConnected = true;
|
|
169
|
+
flushColdStart();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (coldSpawn && process.env.XCSH_SESSION_ID && Number.isFinite(spawnAtEnv)) {
|
|
173
|
+
const workerBootMs = Date.now() - spawnAtEnv; // spawn -> bridge listening
|
|
174
|
+
const mgrMs = Number.isFinite(managerProvisionMsEnv) ? managerProvisionMsEnv : 0;
|
|
175
|
+
coldStartBuffer = coldStartSpans(process.env.XCSH_SESSION_ID, true, mgrMs, workerBootMs);
|
|
176
|
+
// No flush here: at cold boot the extension has not connected yet; onConnected flushes.
|
|
177
|
+
}
|
|
178
|
+
|
|
145
179
|
// Pre-warm pool late-bind: the manager (our parent) sends {bind} over Bun IPC to adopt
|
|
146
180
|
// this spare for a tab. Apply the identity, activate the tenant's context LIVE, then
|
|
147
181
|
// re-announce via broadcastTenantChanged (now carrying the real sessionId).
|
|
148
182
|
process.on("message", (raw: unknown) => {
|
|
149
|
-
const m = raw as {
|
|
183
|
+
const m = raw as {
|
|
184
|
+
type?: unknown;
|
|
185
|
+
sessionId?: unknown;
|
|
186
|
+
tenant?: unknown;
|
|
187
|
+
provisionMs?: unknown;
|
|
188
|
+
cold?: unknown;
|
|
189
|
+
};
|
|
150
190
|
if (m?.type !== "bind" || typeof m.sessionId !== "string" || typeof m.tenant !== "string") return;
|
|
151
191
|
// Capture the narrowed values — TS widens `m.*` back to `unknown` inside the async closure.
|
|
152
192
|
const sessionId = m.sessionId;
|
|
153
193
|
const tenant = m.tenant;
|
|
194
|
+
const bindAt = Date.now(); // TTFT Phase 2: start of warm worker_boot (bind -> bound)
|
|
195
|
+
const relayedProvisionMs = typeof m.provisionMs === "number" ? m.provisionMs : 0;
|
|
154
196
|
setWorkerIdentity(sessionId, tenant);
|
|
155
197
|
void (async () => {
|
|
156
198
|
try {
|
|
@@ -162,6 +204,9 @@ export default class Worker extends Command {
|
|
|
162
204
|
// Ack adoption complete (identity applied + context activated + re-announced).
|
|
163
205
|
// The manager ignores it today; the bench uses it to time adoption latency.
|
|
164
206
|
process.send?.({ type: "bound", sessionId });
|
|
207
|
+
// TTFT Phase 2: warm adopt cold-start spans (worker_boot = bind -> bound).
|
|
208
|
+
coldStartBuffer = coldStartSpans(sessionId, false, relayedProvisionMs, Date.now() - bindAt);
|
|
209
|
+
flushColdStart();
|
|
165
210
|
})();
|
|
166
211
|
});
|
|
167
212
|
|
|
@@ -183,6 +228,9 @@ export default class Worker extends Command {
|
|
|
183
228
|
enableMCP: false,
|
|
184
229
|
enableLsp: false,
|
|
185
230
|
disableExtensionDiscovery: true,
|
|
231
|
+
// TTFT Phase 3: load the hermetic bench stand-in provider when the benchmark
|
|
232
|
+
// sets XCSH_BENCH_EXTENSION (absolute path). Inert for normal workers.
|
|
233
|
+
...(process.env.XCSH_BENCH_EXTENSION ? { additionalExtensionPaths: [process.env.XCSH_BENCH_EXTENSION] } : {}),
|
|
186
234
|
});
|
|
187
235
|
|
|
188
236
|
const chatHandler = new ChatHandler(bridge, session);
|
package/src/edit/modes/chunk.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as nodePath from "node:path";
|
|
3
3
|
import type { AgentToolResult } from "@f5-sales-demo/pi-agent-core";
|
|
4
|
+
import { StringEnum } from "@f5-sales-demo/pi-ai";
|
|
4
5
|
import {
|
|
5
6
|
ChunkAnchorStyle,
|
|
6
7
|
ChunkEditOp,
|
|
@@ -12,7 +13,6 @@ import {
|
|
|
12
13
|
type EditOperation as NativeEditOperation,
|
|
13
14
|
} from "@f5-sales-demo/pi-natives";
|
|
14
15
|
import { $envpos } from "@f5-sales-demo/pi-utils";
|
|
15
|
-
import { StringEnum } from "@f5-sales-demo/xcsh";
|
|
16
16
|
import { type Static, Type } from "@sinclair/typebox";
|
|
17
17
|
import type { BunFile } from "bun";
|
|
18
18
|
import { LRUCache } from "lru-cache";
|
|
@@ -47,29 +47,25 @@ export function createApiSpecResolver(
|
|
|
47
47
|
return {
|
|
48
48
|
async resolve(url: InternalUrl): Promise<InternalResource> {
|
|
49
49
|
const pathname = url.rawPathname ?? url.pathname;
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
if (!domain) {
|
|
53
|
-
return makeResource(url, renderDomainIndex(index));
|
|
54
|
-
}
|
|
50
|
+
const requestedDomain = pathname.replace(/^\//, "").replace(/\/$/, "");
|
|
55
51
|
|
|
56
52
|
// Reserved sub-paths — checked before domain lookup
|
|
57
|
-
if (
|
|
58
|
-
const workflowId =
|
|
53
|
+
if (requestedDomain === "workflows" || requestedDomain.startsWith("workflows/")) {
|
|
54
|
+
const workflowId = requestedDomain.replace(/^workflows\/?/, "");
|
|
59
55
|
return makeResource(url, workflowId ? renderWorkflowDetail(workflowId, index) : renderWorkflowIndex(index));
|
|
60
56
|
}
|
|
61
57
|
|
|
62
|
-
if (
|
|
63
|
-
const errorKey =
|
|
58
|
+
if (requestedDomain === "errors" || requestedDomain.startsWith("errors/")) {
|
|
59
|
+
const errorKey = requestedDomain.replace(/^errors\/?/, "");
|
|
64
60
|
return makeResource(url, errorKey ? renderErrorDetail(errorKey, index) : renderErrorIndex(index));
|
|
65
61
|
}
|
|
66
62
|
|
|
67
|
-
if (
|
|
63
|
+
if (requestedDomain === "glossary" || requestedDomain.startsWith("glossary/")) {
|
|
68
64
|
return makeResource(url, renderGlossary(index));
|
|
69
65
|
}
|
|
70
66
|
|
|
71
|
-
if (
|
|
72
|
-
const resourceKey =
|
|
67
|
+
if (requestedDomain === "validation" || requestedDomain.startsWith("validation/")) {
|
|
68
|
+
const resourceKey = requestedDomain.replace(/^validation\/?/, "");
|
|
73
69
|
return makeResource(
|
|
74
70
|
url,
|
|
75
71
|
resourceKey
|
|
@@ -78,32 +74,55 @@ export function createApiSpecResolver(
|
|
|
78
74
|
);
|
|
79
75
|
}
|
|
80
76
|
|
|
81
|
-
const
|
|
77
|
+
const resourceName = url.searchParams.get("resource");
|
|
78
|
+
|
|
79
|
+
// Resolve the domain entry. When a resource is named against a wrong or
|
|
80
|
+
// omitted domain (e.g. `config?resource=http_loadbalancer`), fall back to
|
|
81
|
+
// the domain that actually owns that resource instead of erroring out.
|
|
82
|
+
let entry = requestedDomain ? index.domains.find(d => d.domain === requestedDomain) : undefined;
|
|
83
|
+
let resolutionNote: string | undefined;
|
|
84
|
+
if (!entry && resourceName) {
|
|
85
|
+
const owner = findDomainForResource(index, resourceName);
|
|
86
|
+
if (owner) {
|
|
87
|
+
entry = owner;
|
|
88
|
+
resolutionNote = requestedDomain
|
|
89
|
+
? `> Note: domain \`${requestedDomain}\` not found; resolved resource \`${resourceName}\` in domain \`${owner.domain}\`.`
|
|
90
|
+
: `> Note: no domain specified; resolved resource \`${resourceName}\` in domain \`${owner.domain}\`.`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
82
94
|
if (!entry) {
|
|
83
|
-
return makeResource(
|
|
95
|
+
return makeResource(
|
|
96
|
+
url,
|
|
97
|
+
requestedDomain ? renderUnknownDomain(requestedDomain, index) : renderDomainIndex(index),
|
|
98
|
+
);
|
|
84
99
|
}
|
|
85
100
|
|
|
101
|
+
const domain = entry.domain;
|
|
102
|
+
const withNote = (content: string): string => (resolutionNote ? `${resolutionNote}\n\n${content}` : content);
|
|
103
|
+
|
|
86
104
|
try {
|
|
87
|
-
const resource = url.searchParams.get("resource");
|
|
88
105
|
const pathFilter = url.searchParams.get("path");
|
|
89
106
|
|
|
90
|
-
if (
|
|
107
|
+
if (resourceName) {
|
|
91
108
|
const crud = url.searchParams.get("crud") === "true";
|
|
92
109
|
const spec = lookup(domain);
|
|
93
|
-
const matchingPaths = filterPathsByResource(spec,
|
|
110
|
+
const matchingPaths = filterPathsByResource(spec, resourceName, entry);
|
|
94
111
|
if (Object.keys(matchingPaths).length === 0) {
|
|
95
|
-
return makeResource(url, renderUnknownResource(
|
|
112
|
+
return makeResource(url, withNote(renderUnknownResource(resourceName, entry, spec)));
|
|
96
113
|
}
|
|
97
114
|
return makeResource(
|
|
98
115
|
url,
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
116
|
+
withNote(
|
|
117
|
+
renderResourceSpec(
|
|
118
|
+
domain,
|
|
119
|
+
resourceName,
|
|
120
|
+
spec,
|
|
121
|
+
entry,
|
|
122
|
+
{ crudOnly: crud },
|
|
123
|
+
enrichments?.[domain],
|
|
124
|
+
validationData,
|
|
125
|
+
),
|
|
107
126
|
),
|
|
108
127
|
);
|
|
109
128
|
}
|
|
@@ -123,6 +142,11 @@ export function createApiSpecResolver(
|
|
|
123
142
|
};
|
|
124
143
|
}
|
|
125
144
|
|
|
145
|
+
/** Find the domain entry that owns a resource by name (for cross-domain resolution). */
|
|
146
|
+
function findDomainForResource(index: ApiSpecIndex, resourceName: string): ApiSpecDomainEntry | undefined {
|
|
147
|
+
return index.domains.find(d => d.resources.some(r => r.name === resourceName));
|
|
148
|
+
}
|
|
149
|
+
|
|
126
150
|
function makeResource(url: InternalUrl, content: string): InternalResource {
|
|
127
151
|
return {
|
|
128
152
|
url: url.href,
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "19.61.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.61.2",
|
|
21
|
+
"commit": "b6923d7302a3f43d8b9600ed4bddd6e26f16fad6",
|
|
22
|
+
"shortCommit": "b6923d7",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.61.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.61.2",
|
|
25
|
+
"commitDate": "2026-07-07T05:10:31Z",
|
|
26
|
+
"buildDate": "2026-07-07T05:33:53.753Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5-sales-demo/xcsh",
|
|
30
30
|
"repoSlug": "f5-sales-demo/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.61.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/b6923d7302a3f43d8b9600ed4bddd6e26f16fad6",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.61.2"
|
|
33
33
|
};
|