gentle-pi 2.4.0 → 2.6.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/README.md +292 -25
- package/assets/agents/gentle-ai-worker.md +13 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +39 -11
- package/assets/orchestrator.md +5 -5
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +94 -0
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +159 -25
- package/extensions/codegraph-tools.ts +95 -5
- package/extensions/gentle-agents.ts +1337 -0
- package/extensions/gentle-ai.ts +2916 -386
- package/extensions/gentle-shell.ts +650 -0
- package/extensions/gentle-todo.ts +234 -0
- package/extensions/quiet-tools.ts +2 -1
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +315 -0
- package/lib/agents-history.ts +88 -0
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +501 -0
- package/lib/agents-runner.ts +1012 -0
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +914 -0
- package/lib/agents-widget.ts +241 -0
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +143 -25
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +371 -13
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +427 -0
- package/lib/review-candidate-view.ts +150 -48
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +110 -0
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +243 -11
- package/lib/review-last-event-controller.ts +8 -4
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +240 -0
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +184 -0
- package/lib/shell-card.ts +133 -0
- package/lib/shell-changes-view.ts +530 -0
- package/lib/shell-changes.ts +290 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +115 -0
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +297 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +153 -0
- package/package.json +8 -5
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +370 -12
- package/runtime/review-integration-v2.mjs +243 -11
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +154 -0
- package/scripts/build-runtime-modules.mjs +11 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +38 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +8 -2
- package/skills/_shared/review-ledger-contract.md +20 -2
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +205 -0
- package/tests/agents-fake-child.ts +66 -0
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +198 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +959 -0
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +685 -0
- package/tests/agents-widget.test.ts +141 -0
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +325 -5
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +112 -2
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +110 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +2168 -0
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +103 -0
- package/tests/gentle-ai.test.ts +971 -15
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +818 -0
- package/tests/gentle-todo.test.ts +226 -0
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +27 -1
- package/tests/native-review-cli.test.ts +317 -3
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +43 -29
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +243 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +97 -37
- package/tests/rdd-aware-verification-contract.test.ts +226 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +751 -7
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +17 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +884 -7
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +384 -8
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +276 -0
- package/tests/review-last-event-closure.test.ts +112 -3
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +656 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +156 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +139 -0
- package/tests/shell-changes-view.test.ts +609 -0
- package/tests/shell-changes.test.ts +350 -0
- package/tests/shell-prompt.test.ts +140 -0
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +259 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +351 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { isAbsolute, resolve } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
export const REVIEW_SESSION_PERMISSION_REGISTRY_SCHEMA = "gentle-pi.review-session-standing-permission/v1";
|
|
9
|
+
export const REVIEW_SESSION_PERMISSION_REGISTRY_SYMBOL = Symbol.for(REVIEW_SESSION_PERMISSION_REGISTRY_SCHEMA);
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
export interface GitCommandOptions {
|
|
14
|
+
encoding: "utf8";
|
|
15
|
+
timeout: number;
|
|
16
|
+
maxBuffer: number;
|
|
17
|
+
windowsHide?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type GitAsyncRunner = (command: string, args: readonly string[], options: GitCommandOptions) => Promise<{ stdout: string }>;
|
|
21
|
+
export type GitSyncRunner = (command: string, args: readonly string[], options: GitCommandOptions) => string;
|
|
22
|
+
|
|
23
|
+
export interface ReviewSessionManager {
|
|
24
|
+
getSessionId(): unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ReviewSessionContext {
|
|
28
|
+
cwd: string;
|
|
29
|
+
mode?: string;
|
|
30
|
+
hasUI: boolean;
|
|
31
|
+
ui?: { getAllThemes?: () => readonly unknown[] };
|
|
32
|
+
sessionManager: ReviewSessionManager;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ReviewSessionIdentity {
|
|
36
|
+
readonly sessionManager: ReviewSessionManager;
|
|
37
|
+
readonly sessionId: string;
|
|
38
|
+
readonly worktreeRoot: string;
|
|
39
|
+
// A SHA-256 digest of the canonical Git common directory. It is stable for
|
|
40
|
+
// sibling worktrees of one clone without serializing a filesystem path.
|
|
41
|
+
readonly repositoryIdentity: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ReviewSessionPermissionRegistry {
|
|
45
|
+
readonly schema: typeof REVIEW_SESSION_PERMISSION_REGISTRY_SCHEMA;
|
|
46
|
+
readonly permissions: WeakMap<object, Map<string, Set<string>>>;
|
|
47
|
+
readonly revocationEpochs: WeakMap<object, Map<string, number>>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type GlobalRegistry = Record<symbol, unknown>;
|
|
51
|
+
|
|
52
|
+
function registry(): ReviewSessionPermissionRegistry | undefined {
|
|
53
|
+
const globalRegistry = globalThis as GlobalRegistry;
|
|
54
|
+
const current = globalRegistry[REVIEW_SESSION_PERMISSION_REGISTRY_SYMBOL];
|
|
55
|
+
if (current === undefined) {
|
|
56
|
+
const created: ReviewSessionPermissionRegistry = {
|
|
57
|
+
schema: REVIEW_SESSION_PERMISSION_REGISTRY_SCHEMA,
|
|
58
|
+
permissions: new WeakMap(),
|
|
59
|
+
revocationEpochs: new WeakMap(),
|
|
60
|
+
};
|
|
61
|
+
globalRegistry[REVIEW_SESSION_PERMISSION_REGISTRY_SYMBOL] = created;
|
|
62
|
+
return created;
|
|
63
|
+
}
|
|
64
|
+
if (
|
|
65
|
+
typeof current !== "object" ||
|
|
66
|
+
current === null ||
|
|
67
|
+
(current as { schema?: unknown }).schema !== REVIEW_SESSION_PERMISSION_REGISTRY_SCHEMA ||
|
|
68
|
+
!((current as { permissions?: unknown }).permissions instanceof WeakMap) ||
|
|
69
|
+
!((current as { revocationEpochs?: unknown }).revocationEpochs instanceof WeakMap)
|
|
70
|
+
) return undefined;
|
|
71
|
+
return current as ReviewSessionPermissionRegistry;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function exactSessionId(sessionManager: ReviewSessionManager): string | undefined {
|
|
75
|
+
try {
|
|
76
|
+
const value = sessionManager.getSessionId();
|
|
77
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function canonicalRepositoryIdentity(commonDir: string): string {
|
|
84
|
+
return `sha256:${createHash("sha256").update(commonDir).digest("hex")}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validAbsoluteGitPath(value: string): boolean {
|
|
88
|
+
return isAbsolute(value) && value.length > 0 && !value.includes("\n") && !value.includes("\r");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function resolveCanonicalGitWorktreeRoot(
|
|
92
|
+
cwd: string,
|
|
93
|
+
run: GitAsyncRunner = execFileAsync as unknown as GitAsyncRunner,
|
|
94
|
+
): Promise<string | undefined> {
|
|
95
|
+
try {
|
|
96
|
+
const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
|
|
97
|
+
encoding: "utf8",
|
|
98
|
+
timeout: 5_000,
|
|
99
|
+
maxBuffer: 64 * 1024,
|
|
100
|
+
windowsHide: true,
|
|
101
|
+
});
|
|
102
|
+
const output = result.stdout.trim();
|
|
103
|
+
if (!validAbsoluteGitPath(output)) return undefined;
|
|
104
|
+
return await realpath(output);
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Resolves a non-secret, clone-stable identity from Git's canonical common dir. */
|
|
111
|
+
export async function resolveCanonicalGitRepositoryIdentity(
|
|
112
|
+
cwd: string,
|
|
113
|
+
run: GitAsyncRunner = execFileAsync as unknown as GitAsyncRunner,
|
|
114
|
+
): Promise<string | undefined> {
|
|
115
|
+
try {
|
|
116
|
+
const result = await run("git", ["-C", cwd, "rev-parse", "--git-common-dir"], {
|
|
117
|
+
encoding: "utf8",
|
|
118
|
+
timeout: 5_000,
|
|
119
|
+
maxBuffer: 64 * 1024,
|
|
120
|
+
windowsHide: true,
|
|
121
|
+
});
|
|
122
|
+
const output = result.stdout.trim();
|
|
123
|
+
if (output.length === 0 || output.includes("\n") || output.includes("\r")) return undefined;
|
|
124
|
+
return canonicalRepositoryIdentity(await realpath(resolve(cwd, output)));
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The parent AgentRunner binds a child task to this same digest at spawn time. */
|
|
131
|
+
export function resolveCanonicalGitRepositoryIdentitySync(
|
|
132
|
+
cwd: string,
|
|
133
|
+
run: GitSyncRunner = execFileSync as unknown as GitSyncRunner,
|
|
134
|
+
): string | undefined {
|
|
135
|
+
try {
|
|
136
|
+
const output = run("git", ["-C", cwd, "rev-parse", "--git-common-dir"], { encoding: "utf8", timeout: 5_000, maxBuffer: 64 * 1024, windowsHide: true }).trim();
|
|
137
|
+
if (output.length === 0 || output.includes("\n") || output.includes("\r")) return undefined;
|
|
138
|
+
return canonicalRepositoryIdentity(realpathSync(resolve(cwd, output)));
|
|
139
|
+
} catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function hasInteractiveTui(context: ReviewSessionContext): boolean {
|
|
145
|
+
if (context.mode !== undefined) return context.mode === "tui";
|
|
146
|
+
try {
|
|
147
|
+
// Pi 0.85 exposes ctx.mode in the documented SDK surface, while its
|
|
148
|
+
// compatibility runner omits that property. In the latter, the real TUI
|
|
149
|
+
// has themes and RPC's deliberately unsupported TUI surface returns none.
|
|
150
|
+
return (context.ui?.getAllThemes?.().length ?? 0) > 0;
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function captureReviewSessionIdentity(
|
|
157
|
+
context: ReviewSessionContext,
|
|
158
|
+
processEnv: NodeJS.ProcessEnv = process.env,
|
|
159
|
+
resolveRoot: (cwd: string) => Promise<string | undefined> = resolveCanonicalGitWorktreeRoot,
|
|
160
|
+
resolveRepositoryIdentity: (cwd: string) => Promise<string | undefined> = resolveCanonicalGitRepositoryIdentity,
|
|
161
|
+
): Promise<ReviewSessionIdentity | undefined> {
|
|
162
|
+
if (processEnv.GENTLE_PI_AGENTS_CHILD === "1" || context.hasUI !== true || !hasInteractiveTui(context)) return undefined;
|
|
163
|
+
const sessionManager = context.sessionManager;
|
|
164
|
+
if (typeof sessionManager !== "object" || sessionManager === null) return undefined;
|
|
165
|
+
const sessionId = exactSessionId(sessionManager);
|
|
166
|
+
if (sessionId === undefined) return undefined;
|
|
167
|
+
const [worktreeRoot, repositoryIdentity] = await Promise.all([resolveRoot(context.cwd), resolveRepositoryIdentity(context.cwd)]);
|
|
168
|
+
if (worktreeRoot === undefined || repositoryIdentity === undefined) return undefined;
|
|
169
|
+
return { sessionManager, sessionId, worktreeRoot, repositoryIdentity };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function sameReviewSessionIdentity(left: ReviewSessionIdentity, right: ReviewSessionIdentity): boolean {
|
|
173
|
+
return left.sessionManager === right.sessionManager && left.sessionId === right.sessionId && left.repositoryIdentity === right.repositoryIdentity;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function epochFor(state: ReviewSessionPermissionRegistry, sessionManager: object, sessionId: string): number {
|
|
177
|
+
return state.revocationEpochs.get(sessionManager)?.get(sessionId) ?? 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function advanceEpoch(state: ReviewSessionPermissionRegistry, sessionManager: object, sessionId: string): void {
|
|
181
|
+
let sessions = state.revocationEpochs.get(sessionManager);
|
|
182
|
+
if (sessions === undefined) {
|
|
183
|
+
sessions = new Map();
|
|
184
|
+
state.revocationEpochs.set(sessionManager, sessions);
|
|
185
|
+
}
|
|
186
|
+
sessions.set(sessionId, epochFor(state, sessionManager, sessionId) + 1);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function reviewSessionPermissionEpoch(identity: ReviewSessionIdentity): number | undefined {
|
|
190
|
+
const state = registry();
|
|
191
|
+
return state === undefined ? undefined : epochFor(state, identity.sessionManager, identity.sessionId);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function grantReviewSessionPermission(identity: ReviewSessionIdentity, expectedEpoch?: number): boolean {
|
|
195
|
+
const state = registry();
|
|
196
|
+
if (state === undefined || (expectedEpoch !== undefined && epochFor(state, identity.sessionManager, identity.sessionId) !== expectedEpoch)) return false;
|
|
197
|
+
let sessions = state.permissions.get(identity.sessionManager);
|
|
198
|
+
if (sessions === undefined) {
|
|
199
|
+
sessions = new Map();
|
|
200
|
+
state.permissions.set(identity.sessionManager, sessions);
|
|
201
|
+
}
|
|
202
|
+
let roots = sessions.get(identity.sessionId);
|
|
203
|
+
if (roots === undefined) {
|
|
204
|
+
roots = new Set();
|
|
205
|
+
sessions.set(identity.sessionId, roots);
|
|
206
|
+
}
|
|
207
|
+
roots.add(identity.repositoryIdentity);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// A grant belongs to one live session and one Git clone. It follows sibling
|
|
212
|
+
// worktrees through their common directory, never an unrelated repository.
|
|
213
|
+
export function hasReviewSessionPermission(identity: ReviewSessionIdentity): boolean {
|
|
214
|
+
return registry()?.permissions.get(identity.sessionManager)?.get(identity.sessionId)?.has(identity.repositoryIdentity) === true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function revokeReviewSessionPermission(identity: ReviewSessionIdentity): boolean {
|
|
218
|
+
const state = registry();
|
|
219
|
+
if (state === undefined) return false;
|
|
220
|
+
advanceEpoch(state, identity.sessionManager, identity.sessionId);
|
|
221
|
+
const sessions = state.permissions.get(identity.sessionManager);
|
|
222
|
+
const roots = sessions?.get(identity.sessionId);
|
|
223
|
+
if (sessions === undefined || roots === undefined) return false;
|
|
224
|
+
const removed = roots.delete(identity.repositoryIdentity);
|
|
225
|
+
if (roots.size === 0) sessions.delete(identity.sessionId);
|
|
226
|
+
if (sessions.size === 0) state.permissions.delete(identity.sessionManager);
|
|
227
|
+
return removed;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function revokeReviewSessionPermissionsForSession(sessionManager: object, sessionId: string): boolean {
|
|
231
|
+
if (sessionId.length === 0) return false;
|
|
232
|
+
const state = registry();
|
|
233
|
+
if (state === undefined) return false;
|
|
234
|
+
advanceEpoch(state, sessionManager, sessionId);
|
|
235
|
+
const sessions = state.permissions.get(sessionManager);
|
|
236
|
+
if (sessions === undefined) return false;
|
|
237
|
+
const removed = sessions.delete(sessionId);
|
|
238
|
+
if (sessions.size === 0) state.permissions.delete(sessionManager);
|
|
239
|
+
return removed;
|
|
240
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { parseAgentDefinition, type AgentDefinition, type ModelRef } from "./agents-config.ts";
|
|
3
|
+
import type { ChildObservationSnapshot } from "./agents-runner.ts";
|
|
4
|
+
import { FINISHED_STATUSES, type TaskStatus } from "./agents-protocol.ts";
|
|
5
|
+
import { classifyRuntimeModelId, EFFORTS, parseAgentClass, RuntimeMetrics, UNKNOWN_AGENT_CLASS, validRuntimeResponse, type AgentClass, type FinalResponse, type RuntimeMetricBucket } from "./runtime-metrics.ts";
|
|
6
|
+
import { classifyPiCatalogName } from "./runtime-metrics-pi-identity.ts";
|
|
7
|
+
export const CHILD_METRICS_EVENT = "gentle:runtime-metrics:child/v1";
|
|
8
|
+
// Local revocation notification invalidates active observations. Contains only
|
|
9
|
+
// the local session join, never policy output.
|
|
10
|
+
export const CHILD_METRICS_REVOKED = "gentle:runtime-metrics:revoked/v1";
|
|
11
|
+
const missing = { state: "unavailable" } as const;
|
|
12
|
+
const providers = ["anthropic", "openai", "openai-codex", "google", "google-vertex", "amazon-bedrock", "openrouter", "custom", "unknown"];
|
|
13
|
+
const tokenFields = ["input", "output", "cacheRead", "cacheWrite", "reasoning", "totalTokens"] as const;
|
|
14
|
+
/** Recognize only names from this package's fixed assets and the transport's
|
|
15
|
+
* closed agent_class enum. Customized packaged agents retain their schema name;
|
|
16
|
+
* user-defined names never enter telemetry. Exact fingerprints preserve the
|
|
17
|
+
* worker/explore/verify compatibility mapping whose packaged names are prefixed.
|
|
18
|
+
* Model/thinking routing is excluded from fingerprints because installation
|
|
19
|
+
* rewrites it. No installed files are read; this class is not execution/review authority.
|
|
20
|
+
* Only the fixed package catalog is cached; runtime instructions are not retained.
|
|
21
|
+
*/
|
|
22
|
+
let definitions: Array<{ name: string; fingerprint: string; fingerprintClass?: AgentClass }> | undefined;
|
|
23
|
+
const packagedAgentClassAliases = new Map([["sdd-proposal", "sdd-propose"]] as const);
|
|
24
|
+
function fingerprintAgentClassName(name: string): string {
|
|
25
|
+
const compatibilityName = name.startsWith("gentle-ai-") ? name.slice("gentle-ai-".length) : name;
|
|
26
|
+
return packagedAgentClassAliases.get(compatibilityName) ?? compatibilityName;
|
|
27
|
+
}
|
|
28
|
+
function fingerprint(agent: AgentDefinition): string {
|
|
29
|
+
return JSON.stringify([agent.name, agent.description, agent.instructions, agent.tools, agent.mode]);
|
|
30
|
+
}
|
|
31
|
+
export function classifyBuiltinAgent(agent: AgentDefinition): AgentClass {
|
|
32
|
+
try {
|
|
33
|
+
definitions ??= readdirSync(new URL("../assets/agents/", import.meta.url))
|
|
34
|
+
.filter(file => file.endsWith(".md")).map(file => {
|
|
35
|
+
const path = new URL(`../assets/agents/${file}`, import.meta.url);
|
|
36
|
+
const parsed = parseAgentDefinition(readFileSync(path, "utf8"), path.pathname, "global");
|
|
37
|
+
if (!("instructions" in parsed)) throw new Error("Invalid packaged definition");
|
|
38
|
+
return { name: parsed.name, fingerprint: fingerprint(parsed),
|
|
39
|
+
fingerprintClass: parseAgentClass(fingerprintAgentClassName(parsed.name)) };
|
|
40
|
+
});
|
|
41
|
+
const namedClass = parseAgentClass(packagedAgentClassAliases.get(agent.name) ?? agent.name);
|
|
42
|
+
if (namedClass && definitions.some(entry => entry.name === agent.name)) return namedClass;
|
|
43
|
+
return definitions.find(entry => entry.fingerprintClass && entry.fingerprint === fingerprint(agent))?.fingerprintClass ?? UNKNOWN_AGENT_CLASS;
|
|
44
|
+
} catch { return UNKNOWN_AGENT_CLASS; }
|
|
45
|
+
}
|
|
46
|
+
function provider(value: unknown): FinalResponse["provider"] {
|
|
47
|
+
return providers.includes(value as string) ? value as FinalResponse["provider"] : value ? "custom" : "unknown";
|
|
48
|
+
}
|
|
49
|
+
function modelId(namespace: unknown, value: unknown): string {
|
|
50
|
+
if (value === "unknown" || value === undefined) return "unknown";
|
|
51
|
+
return classifyRuntimeModelId(namespace, value, classifyPiCatalogName);
|
|
52
|
+
}
|
|
53
|
+
function effort(value: unknown): FinalResponse["effort"] {
|
|
54
|
+
return EFFORTS.includes(value as FinalResponse["effort"]) ? value as FinalResponse["effort"] : "unavailable";
|
|
55
|
+
}
|
|
56
|
+
export interface LaunchSelection {
|
|
57
|
+
agentClass: AgentClass;
|
|
58
|
+
selectedProvider: FinalResponse["provider"];
|
|
59
|
+
selectedModelId: string;
|
|
60
|
+
selectedEffort: FinalResponse["effort"];
|
|
61
|
+
}
|
|
62
|
+
export interface ChildLaunchBucket extends LaunchSelection {
|
|
63
|
+
evidence: "launch_configuration";
|
|
64
|
+
launches: number;
|
|
65
|
+
}
|
|
66
|
+
export function launchSelection(agent: AgentDefinition, model: ModelRef | undefined, thinking: unknown): LaunchSelection {
|
|
67
|
+
return { agentClass: classifyBuiltinAgent(agent), selectedProvider: provider(model?.provider),
|
|
68
|
+
selectedModelId: modelId(model?.provider, model?.id), selectedEffort: effort(thinking) };
|
|
69
|
+
}
|
|
70
|
+
/** LOCAL event only: these two bounded join IDs must never enter output/export. */
|
|
71
|
+
export interface ChildMetricsEvent {
|
|
72
|
+
schema: typeof CHILD_METRICS_EVENT;
|
|
73
|
+
parentSessionId: string;
|
|
74
|
+
taskId: string;
|
|
75
|
+
launchedAt: number;
|
|
76
|
+
launch: LaunchSelection;
|
|
77
|
+
status: TaskStatus;
|
|
78
|
+
agentSettled: boolean;
|
|
79
|
+
coverage: "final_assistant_messages_only";
|
|
80
|
+
droppedResponses: number;
|
|
81
|
+
responses: FinalResponse[];
|
|
82
|
+
}
|
|
83
|
+
export function childEvent(parentSessionId: string, taskId: string, launch: LaunchSelection, status: TaskStatus,
|
|
84
|
+
snapshot: ChildObservationSnapshot, launchedAt = 0): ChildMetricsEvent | undefined {
|
|
85
|
+
const result: ChildMetricsEvent = { schema: CHILD_METRICS_EVENT, parentSessionId, taskId, launchedAt, launch: { ...launch }, status,
|
|
86
|
+
agentSettled: snapshot.agentSettled, coverage: snapshot.coverage, droppedResponses: snapshot.droppedResponses,
|
|
87
|
+
responses: snapshot.responses.slice(0, 128).map((row, index) => {
|
|
88
|
+
const namespace = row.provider.state === "observed" ? row.provider.value : undefined;
|
|
89
|
+
const name = (value: typeof row.model) => value.state === "observed" ? modelId(namespace, value.value) : undefined;
|
|
90
|
+
return { kind: "final_assistant_response", responseId: String(index), agentClass: launch.agentClass,
|
|
91
|
+
executor: "worker", provider: provider(namespace), modelFamily: "unknown", observedModelId: name(row.model),
|
|
92
|
+
responseModelId: name(row.responseModel), providerThinkingLevel: effort(row.providerThinkingLevel.state === "observed" ? row.providerThinkingLevel.value : undefined),
|
|
93
|
+
selectedProvider: launch.selectedProvider, selectedModelId: launch.selectedModelId, effort: launch.selectedEffort,
|
|
94
|
+
error: row.stopReason === "error" ? "unknown" : row.stopReason === "aborted" ? "aborted" : "none",
|
|
95
|
+
tokens: Object.fromEntries(tokenFields.map(field => [field, { ...row.tokens[field] }])) as FinalResponse["tokens"],
|
|
96
|
+
responseHeadersMs: missing, fullResponseMs: missing };
|
|
97
|
+
}) };
|
|
98
|
+
return validEvent(result) ? result : undefined;
|
|
99
|
+
}
|
|
100
|
+
function validEvent(value: unknown): value is ChildMetricsEvent {
|
|
101
|
+
if (!value || typeof value !== "object") return false;
|
|
102
|
+
const e = value as ChildMetricsEvent;
|
|
103
|
+
const id = (value: unknown) => typeof value === "string" && value.length > 0 && value.length <= 128;
|
|
104
|
+
const l = e.launch;
|
|
105
|
+
return e.schema === CHILD_METRICS_EVENT && id(e.taskId) && id(e.parentSessionId)
|
|
106
|
+
&& Number.isFinite(e.launchedAt) && e.launchedAt >= 0
|
|
107
|
+
&& e.coverage === "final_assistant_messages_only" && typeof e.agentSettled === "boolean"
|
|
108
|
+
&& FINISHED_STATUSES.includes(e.status) && Number.isSafeInteger(e.droppedResponses) && e.droppedResponses >= 0
|
|
109
|
+
&& e.droppedResponses <= Number.MAX_SAFE_INTEGER && !!l && !!parseAgentClass(l.agentClass) && l.agentClass !== "orchestrator"
|
|
110
|
+
&& provider(l.selectedProvider) === l.selectedProvider && modelId(l.selectedProvider, l.selectedModelId) === l.selectedModelId
|
|
111
|
+
&& effort(l.selectedEffort) === l.selectedEffort && Array.isArray(e.responses) && e.responses.length <= 128
|
|
112
|
+
&& e.responses.every(row => validRuntimeResponse(row) && row.agentClass === l.agentClass
|
|
113
|
+
&& row.executor === "worker" && row.effort === l.selectedEffort && row.selectedProvider === l.selectedProvider && row.selectedModelId === l.selectedModelId
|
|
114
|
+
&& provider(row.provider) === row.provider && effort(row.providerThinkingLevel) === row.providerThinkingLevel
|
|
115
|
+
&& (row.observedModelId === undefined || modelId(row.provider, row.observedModelId) === row.observedModelId)
|
|
116
|
+
&& (row.responseModelId === undefined || modelId(row.provider, row.responseModelId) === row.responseModelId)
|
|
117
|
+
&& row.fullResponseMs.state === "unavailable" && row.responseHeadersMs.state === "unavailable");
|
|
118
|
+
}
|
|
119
|
+
/** Copy only validated local fields at the completion callback.
|
|
120
|
+
* Do not clone arbitrary event properties or retain a caller-mutable bus object.
|
|
121
|
+
*/
|
|
122
|
+
export function snapshotChildEvent(value: unknown): ChildMetricsEvent | undefined {
|
|
123
|
+
if (!validEvent(value)) return undefined;
|
|
124
|
+
const l = value.launch;
|
|
125
|
+
return { schema: CHILD_METRICS_EVENT, parentSessionId: value.parentSessionId, taskId: value.taskId, launchedAt: value.launchedAt,
|
|
126
|
+
launch: { agentClass: l.agentClass, selectedProvider: l.selectedProvider, selectedModelId: l.selectedModelId, selectedEffort: l.selectedEffort },
|
|
127
|
+
status: value.status, agentSettled: value.agentSettled, coverage: value.coverage, droppedResponses: value.droppedResponses,
|
|
128
|
+
responses: value.responses.map((row, index) => ({ kind: "final_assistant_response", responseId: String(index),
|
|
129
|
+
agentClass: row.agentClass, executor: "worker", provider: row.provider, observedModelId: row.observedModelId,
|
|
130
|
+
responseModelId: row.responseModelId, providerThinkingLevel: row.providerThinkingLevel, modelFamily: "unknown",
|
|
131
|
+
selectedProvider: l.selectedProvider, selectedModelId: l.selectedModelId, effort: l.selectedEffort, error: row.error,
|
|
132
|
+
tokens: Object.fromEntries(tokenFields.map(field => {
|
|
133
|
+
const t = row.tokens[field];
|
|
134
|
+
return [field, t?.state === "reported" ? { state: "reported", value: t.value } : { state: t?.state ?? "unavailable" }];
|
|
135
|
+
})) as FinalResponse["tokens"], responseHeadersMs: missing, fullResponseMs: missing })) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Export-facing shape: no IDs, paths, task labels, or raw agent/model names.
|
|
139
|
+
* Rankings are descending counts, NOT quality/success comparisons. Native
|
|
140
|
+
* transport is deliberately absent. Response buckets retain selected launch
|
|
141
|
+
* identity and effort separately from effective response evidence.
|
|
142
|
+
*/
|
|
143
|
+
export interface ChildCompositionSnapshot {
|
|
144
|
+
launches: ChildLaunchBucket[];
|
|
145
|
+
responses: RuntimeMetricBucket[];
|
|
146
|
+
settled: number;
|
|
147
|
+
statuses: Record<"completed" | "failed" | "cancelled" | "timed_out", number>;
|
|
148
|
+
droppedResponses: number;
|
|
149
|
+
saturated: boolean;
|
|
150
|
+
}
|
|
151
|
+
export class ChildComposition {
|
|
152
|
+
#seen = new Set<string>();
|
|
153
|
+
#pending = new WeakSet<object>();
|
|
154
|
+
#metrics = new RuntimeMetrics();
|
|
155
|
+
#launches = new Map<string, ChildLaunchBucket>();
|
|
156
|
+
#count = 0;
|
|
157
|
+
#settled = 0;
|
|
158
|
+
#statuses = { completed: 0, failed: 0, cancelled: 0, timed_out: 0 };
|
|
159
|
+
#dropped = 0;
|
|
160
|
+
#saturated = false;
|
|
161
|
+
reserve(value: unknown, session: string): value is ChildMetricsEvent {
|
|
162
|
+
if (!validEvent(value) || value.parentSessionId !== session || this.#seen.has(value.taskId)) return false;
|
|
163
|
+
if (this.#seen.size >= 256) { this.#saturated = true; return false; }
|
|
164
|
+
this.#seen.add(value.taskId);
|
|
165
|
+
this.#pending.add(value);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
record(event: ChildMetricsEvent): void {
|
|
169
|
+
if (!this.#pending.delete(event) || !validEvent(event)) return;
|
|
170
|
+
const key = JSON.stringify(event.launch);
|
|
171
|
+
let bucket = this.#launches.get(key);
|
|
172
|
+
if (!bucket && this.#launches.size < 64) {
|
|
173
|
+
bucket = { ...event.launch, evidence: "launch_configuration", launches: 0 };
|
|
174
|
+
this.#launches.set(key, bucket);
|
|
175
|
+
}
|
|
176
|
+
if (bucket) bucket.launches++;
|
|
177
|
+
else this.#saturated = true;
|
|
178
|
+
this.#settled += Number(event.agentSettled);
|
|
179
|
+
this.#statuses[event.status as keyof ChildCompositionSnapshot["statuses"]]++;
|
|
180
|
+
this.#dropped = Math.min(Number.MAX_SAFE_INTEGER, this.#dropped + event.droppedResponses);
|
|
181
|
+
for (const row of event.responses) {
|
|
182
|
+
if (this.#metrics.record({ ...row, responseId: String(this.#count + 1) }) === "recorded") this.#count++;
|
|
183
|
+
else { this.#dropped = Math.min(Number.MAX_SAFE_INTEGER, this.#dropped + 1); this.#saturated = true; }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
clear(): void {
|
|
187
|
+
this.#metrics = new RuntimeMetrics();
|
|
188
|
+
this.#pending = new WeakSet();
|
|
189
|
+
this.#launches.clear();
|
|
190
|
+
this.#count = this.#settled = this.#dropped = 0;
|
|
191
|
+
this.#statuses = { completed: 0, failed: 0, cancelled: 0, timed_out: 0 };
|
|
192
|
+
// Bounded tombstones survive revocation; denied tasks cannot replay.
|
|
193
|
+
}
|
|
194
|
+
snapshot(): ChildCompositionSnapshot {
|
|
195
|
+
return structuredClone({ launches: [...this.#launches.values()].sort((a, b) => b.launches - a.launches),
|
|
196
|
+
responses: this.#metrics.snapshot().sort((a, b) => b.responses - a.responses),
|
|
197
|
+
settled: this.#settled, statuses: this.#statuses, droppedResponses: this.#dropped, saturated: this.#saturated });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Shared across extension instances, including a cancelled process still exiting.
|
|
2
|
+
let busy = false;
|
|
3
|
+
|
|
4
|
+
/** One accepted attempt, never a queue. Defer resolver/process work beyond the
|
|
5
|
+
* provider callback. Busy events are discarded rather than captured for later.
|
|
6
|
+
* The transport must settle only after its process exits. Print-mode shutdown
|
|
7
|
+
* may join the one accepted attempt for a bounded deadline; it never queues or
|
|
8
|
+
* retries work. Every other disposal remains immediate.
|
|
9
|
+
*/
|
|
10
|
+
export class RuntimeMetricsAttempt {
|
|
11
|
+
#closed = false;
|
|
12
|
+
#scheduled?: ReturnType<typeof setImmediate>;
|
|
13
|
+
#abort?: AbortController;
|
|
14
|
+
#settled?: Promise<void>;
|
|
15
|
+
#release?: () => void;
|
|
16
|
+
offer(work: (signal: AbortSignal) => Promise<unknown>): boolean {
|
|
17
|
+
if (this.#closed || busy) return false;
|
|
18
|
+
busy = true;
|
|
19
|
+
const abort = new AbortController();
|
|
20
|
+
this.#abort = abort;
|
|
21
|
+
let release!: () => void;
|
|
22
|
+
const settled = new Promise<void>(resolve => { release = resolve; });
|
|
23
|
+
this.#settled = settled;
|
|
24
|
+
this.#release = release;
|
|
25
|
+
this.#scheduled = setImmediate(() => {
|
|
26
|
+
this.#scheduled = undefined;
|
|
27
|
+
void (async () => {
|
|
28
|
+
try { if (!abort.signal.aborted) await work(abort.signal); }
|
|
29
|
+
catch { /* An attempt is always best effort; never retry or report. */ }
|
|
30
|
+
finally {
|
|
31
|
+
this.#abort = undefined;
|
|
32
|
+
busy = false;
|
|
33
|
+
if (this.#settled === settled) {
|
|
34
|
+
this.#settled = undefined;
|
|
35
|
+
this.#release = undefined;
|
|
36
|
+
}
|
|
37
|
+
release();
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
});
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
/** Await only the already accepted attempt, up to the caller's deadline. */
|
|
44
|
+
async waitForSettled(timeoutMs: number): Promise<void> {
|
|
45
|
+
const settled = this.#settled;
|
|
46
|
+
if (!settled) return;
|
|
47
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
48
|
+
try {
|
|
49
|
+
await Promise.race([settled, new Promise<void>(resolve => { timer = setTimeout(resolve, timeoutMs); })]);
|
|
50
|
+
} finally {
|
|
51
|
+
if (timer) clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
dispose(): void {
|
|
55
|
+
if (this.#closed) return;
|
|
56
|
+
this.#closed = true;
|
|
57
|
+
if (this.#scheduled) {
|
|
58
|
+
clearImmediate(this.#scheduled);
|
|
59
|
+
this.#scheduled = undefined;
|
|
60
|
+
busy = false;
|
|
61
|
+
this.#settled = undefined;
|
|
62
|
+
this.#release?.();
|
|
63
|
+
this.#release = undefined;
|
|
64
|
+
}
|
|
65
|
+
this.#abort?.abort();
|
|
66
|
+
this.#abort = undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from "node:child_process";
|
|
2
|
+
import { resolveGentleAiBinary } from "./gentle-ai-binary.ts";
|
|
3
|
+
import { runtimeMetricsEnvAllows } from "./runtime-metrics-policy.ts";
|
|
4
|
+
import type { RuntimeMetricBucket } from "./runtime-metrics.ts";
|
|
5
|
+
import type { ChildLaunchBucket } from "./runtime-metrics-children.ts";
|
|
6
|
+
|
|
7
|
+
import schema from "../contracts/telemetry/runtime-aggregate-v1.schema.json" with { type: "json" };
|
|
8
|
+
|
|
9
|
+
export type NativeRuntimeResult = "stored" | "duplicate" | "discarded" | "disabled";
|
|
10
|
+
export const NATIVE_SEND_ACK_SCHEMA = "gentle-ai.telemetry-runtime-send/v1";
|
|
11
|
+
const MAX_METRIC = schema.$defs.count.maximum;
|
|
12
|
+
const count = (value: number) => Number.isSafeInteger(value) && value >= 0 && value <= MAX_METRIC;
|
|
13
|
+
function publicModel(provider: string, id: string) {
|
|
14
|
+
const matches = (rule: { const?: string; enum?: string[] }, value: string) => rule.const === value || rule.enum?.includes(value);
|
|
15
|
+
if (schema.$defs.model.oneOf.some(({ properties }) => matches(properties.provider, provider) && matches(properties.id, id))) return { provider, id };
|
|
16
|
+
const kind = !id || id === "unknown" ? "unknown" : "custom";
|
|
17
|
+
return { provider: kind, id: kind };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Closed field projection of one event, not a session snapshot. Oversized or
|
|
21
|
+
* invalid events discard whole; never split into queued sends or invent IDs.
|
|
22
|
+
*/
|
|
23
|
+
export function encodeNativeRuntimeEvent(source: readonly RuntimeMetricBucket[], launches: readonly ChildLaunchBucket[] = []): string | undefined {
|
|
24
|
+
if (!Array.isArray(source) || !Array.isArray(launches) || source.length < 1
|
|
25
|
+
|| source.length + launches.length > schema.properties.rows.maxItems) return undefined;
|
|
26
|
+
try {
|
|
27
|
+
const rows: Array<Record<string, unknown>> = source.map(row => {
|
|
28
|
+
if (!count(row.responses)) throw new Error("Invalid occurrence coverage");
|
|
29
|
+
const token = (field: keyof RuntimeMetricBucket["tokens"]) => {
|
|
30
|
+
const t = row.tokens[field];
|
|
31
|
+
const coverage = { reported: t.reported, unavailable: t.unavailable, unsupported: t.unsupported, sum: t.sum };
|
|
32
|
+
if (!Object.values(coverage).every(count) || (!coverage.reported && coverage.sum !== 0)) throw new Error("Invalid coverage");
|
|
33
|
+
return coverage;
|
|
34
|
+
};
|
|
35
|
+
const agentClass = schema.$defs.row.properties.agent_class.enum.includes(row.agentClass) ? row.agentClass : "unknown";
|
|
36
|
+
const effort = (value: string) => schema.$defs.effort.enum.includes(value) ? value : "unavailable";
|
|
37
|
+
const response = typeof row.responseModelId === "string" && row.responseModelId !== "unknown" && row.responseModelId !== "";
|
|
38
|
+
const selected = typeof row.selectedModelId === "string" && row.selectedModelId !== "unknown" && row.selectedModelId !== "";
|
|
39
|
+
const model = response ? publicModel(row.provider, row.responseModelId)
|
|
40
|
+
: selected ? publicModel(row.selectedProvider, row.selectedModelId) : { provider: "unknown", id: "unknown" };
|
|
41
|
+
const time = row.fullResponseMs;
|
|
42
|
+
if (!count(time.measured) || !Number.isFinite(time.sum) || time.sum < 0 || time.sum > MAX_METRIC
|
|
43
|
+
|| (!time.measured && time.sum !== 0)) throw new Error("Invalid duration");
|
|
44
|
+
return { model, model_evidence: response ? "response" : selected ? "selected" : "unknown",
|
|
45
|
+
agent_kind: agentClass === "orchestrator" || agentClass === "unknown" ? agentClass : "built_in", agent_class: agentClass,
|
|
46
|
+
selected_effort: effort(row.effort), effective_effort: effort(row.providerThinkingLevel),
|
|
47
|
+
launches: null, responses: row.responses,
|
|
48
|
+
input_tokens: token("input"), output_tokens: token("output"), cache_read_tokens: token("cacheRead"),
|
|
49
|
+
cache_creation_tokens: token("cacheWrite"), reasoning_tokens: token("reasoning"), total_tokens: token("totalTokens"),
|
|
50
|
+
error_category: row.error === "authentication" ? "auth" : ["none", "aborted", "rate_limit"].includes(row.error) ? row.error : "unknown",
|
|
51
|
+
duration: time.measured > 0 ? { kind: "request", measured_count: time.measured, sum_ms: time.sum }
|
|
52
|
+
: { kind: "unavailable", measured_count: 0, sum_ms: null } };
|
|
53
|
+
});
|
|
54
|
+
for (const launch of launches) {
|
|
55
|
+
if (!count(launch.launches) || launch.evidence !== "launch_configuration") return undefined;
|
|
56
|
+
const agentClass = schema.$defs.row.properties.agent_class.enum.includes(launch.agentClass) ? launch.agentClass : "unknown";
|
|
57
|
+
const empty = { reported: 0, unavailable: 0, unsupported: 0, sum: 0 };
|
|
58
|
+
rows.push({ model: publicModel(launch.selectedProvider, launch.selectedModelId), model_evidence: "selected",
|
|
59
|
+
agent_kind: agentClass === "orchestrator" || agentClass === "unknown" ? agentClass : "built_in", agent_class: agentClass,
|
|
60
|
+
selected_effort: schema.$defs.effort.enum.includes(launch.selectedEffort) ? launch.selectedEffort : "unavailable",
|
|
61
|
+
effective_effort: "unavailable", launches: launch.launches, responses: null,
|
|
62
|
+
input_tokens: empty, output_tokens: empty, cache_read_tokens: empty, cache_creation_tokens: empty,
|
|
63
|
+
reasoning_tokens: empty, total_tokens: empty, error_category: "unknown",
|
|
64
|
+
duration: { kind: "unavailable", measured_count: 0, sum_ms: null } });
|
|
65
|
+
}
|
|
66
|
+
const payload = JSON.stringify({ schema: schema.properties.schema.const, registry: schema.properties.registry.const, host: "pi", rows });
|
|
67
|
+
return Buffer.byteLength(payload) <= 16384 ? payload : undefined;
|
|
68
|
+
} catch { return undefined; }
|
|
69
|
+
}
|
|
70
|
+
export interface NativeRuntimeTransportDeps {
|
|
71
|
+
/** Observed launch occurrence accompanying this child completion, not totals. */
|
|
72
|
+
launches?: readonly ChildLaunchBucket[];
|
|
73
|
+
env?: NodeJS.ProcessEnv;
|
|
74
|
+
signal?: AbortSignal;
|
|
75
|
+
current?: () => boolean;
|
|
76
|
+
/** Injected test seams; production uses the verified pin or validated dev override. */
|
|
77
|
+
resolve?: () => string;
|
|
78
|
+
encode?: typeof encodeNativeRuntimeEvent;
|
|
79
|
+
spawn?: (file: string, args: string[], options: SpawnOptionsWithoutStdio) => ChildProcessWithoutNullStreams;
|
|
80
|
+
}
|
|
81
|
+
let busy = false;
|
|
82
|
+
|
|
83
|
+
/** Native owns fresh policy and exactly one POST. No policy/capability probe,
|
|
84
|
+
* disk intake, flush, queue or retry. Call via RuntimeMetricsAttempt so binary
|
|
85
|
+
* verification is outside the provider callback. Cancellation requests a kill;
|
|
86
|
+
* the slot stays occupied until close, never permitting overlapping processes.
|
|
87
|
+
*/
|
|
88
|
+
export async function sendNativeRuntimeEvent(rows: readonly RuntimeMetricBucket[], cwd: string,
|
|
89
|
+
deps: NativeRuntimeTransportDeps = {}): Promise<NativeRuntimeResult> {
|
|
90
|
+
const env = deps.env ?? process.env;
|
|
91
|
+
if (!runtimeMetricsEnvAllows(env)) return "disabled";
|
|
92
|
+
if (busy || deps.signal?.aborted || deps.current?.() === false) return "discarded";
|
|
93
|
+
busy = true;
|
|
94
|
+
try {
|
|
95
|
+
const payload = (deps.encode ?? encodeNativeRuntimeEvent)(rows, deps.launches);
|
|
96
|
+
if (!payload || Buffer.byteLength(payload) > 16384) return "discarded";
|
|
97
|
+
const file = (deps.resolve ?? resolveGentleAiBinary)();
|
|
98
|
+
if (!runtimeMetricsEnvAllows(env)) return "disabled";
|
|
99
|
+
if (deps.signal?.aborted || deps.current?.() === false) return "discarded";
|
|
100
|
+
return await sendProcess(file, cwd, payload, env, deps.spawn ?? spawn, deps.signal);
|
|
101
|
+
} catch { return "discarded"; }
|
|
102
|
+
finally { busy = false; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sendProcess(file: string, cwd: string, payload: string, env: NodeJS.ProcessEnv,
|
|
106
|
+
launch: NonNullable<NativeRuntimeTransportDeps["spawn"]>, signal?: AbortSignal): Promise<NativeRuntimeResult> {
|
|
107
|
+
return new Promise(done => {
|
|
108
|
+
let child: ChildProcessWithoutNullStreams | undefined;
|
|
109
|
+
let output = "";
|
|
110
|
+
let rejected = false;
|
|
111
|
+
let settled = false;
|
|
112
|
+
const ignore = () => {};
|
|
113
|
+
const finish = (result: NativeRuntimeResult) => {
|
|
114
|
+
if (settled) return;
|
|
115
|
+
settled = true;
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
signal?.removeEventListener("abort", cancel);
|
|
118
|
+
output = "";
|
|
119
|
+
if (child) {
|
|
120
|
+
for (const stream of [child.stdin, child.stdout, child.stderr]) {
|
|
121
|
+
stream.on("error", ignore);
|
|
122
|
+
try { stream.destroy(); } catch { /* Best effort cleanup. */ }
|
|
123
|
+
}
|
|
124
|
+
try { child.unref(); } catch { /* No diagnostics. */ }
|
|
125
|
+
}
|
|
126
|
+
done(result);
|
|
127
|
+
};
|
|
128
|
+
const cancel = () => {
|
|
129
|
+
if (rejected || settled) return;
|
|
130
|
+
rejected = true;
|
|
131
|
+
output = "";
|
|
132
|
+
try { child?.kill("SIGKILL"); } catch { /* Keep busy until close. */ }
|
|
133
|
+
};
|
|
134
|
+
const timer = setTimeout(cancel, 1000);
|
|
135
|
+
timer.unref();
|
|
136
|
+
try {
|
|
137
|
+
child = launch(file, ["telemetry", "runtime", "send", "--json"],
|
|
138
|
+
{ cwd, env, shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
139
|
+
child.on("error", cancel);
|
|
140
|
+
for (const stream of [child.stdin, child.stdout, child.stderr]) stream.on("error", cancel);
|
|
141
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
142
|
+
if (rejected || settled) return;
|
|
143
|
+
if (Buffer.byteLength(output) + chunk.length > 2048) { cancel(); return; }
|
|
144
|
+
output += chunk.toString("utf8");
|
|
145
|
+
});
|
|
146
|
+
child.stderr.on("data", cancel); // Do not retain or expose raw errors.
|
|
147
|
+
child.on("close", (code: number | null, exitSignal: NodeJS.Signals | null) => {
|
|
148
|
+
let result: NativeRuntimeResult = "discarded";
|
|
149
|
+
if (!rejected && code === 0 && exitSignal === null) {
|
|
150
|
+
try {
|
|
151
|
+
const value = JSON.parse(output);
|
|
152
|
+
if (Object.keys(value).sort().join(",") === "decision,schema" && value.schema === NATIVE_SEND_ACK_SCHEMA
|
|
153
|
+
&& ["stored", "duplicate", "discarded", "disabled"].includes(value.decision)) result = value.decision;
|
|
154
|
+
} catch { /* Invalid/old acknowledgements discard. */ }
|
|
155
|
+
}
|
|
156
|
+
finish(result);
|
|
157
|
+
});
|
|
158
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
159
|
+
if (signal?.aborted) cancel();
|
|
160
|
+
if (!rejected) child.stdin.end(payload, "utf8");
|
|
161
|
+
} catch {
|
|
162
|
+
if (child) cancel();
|
|
163
|
+
else finish("discarded");
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|