@pi-unipi/background-tasks 2.6.1
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 +87 -0
- package/extensions/anthropic-attribution.ts +1 -0
- package/extensions/delegate-child.ts +1 -0
- package/extensions/fusion-child.ts +1 -0
- package/package.json +40 -0
- package/src/__tests__/anthropic-attribution.test.ts +195 -0
- package/src/__tests__/config.test.ts +137 -0
- package/src/__tests__/core.test.ts +493 -0
- package/src/__tests__/delegate-artifacts.test.ts +528 -0
- package/src/__tests__/delegate-budget.test.ts +456 -0
- package/src/__tests__/delegate-launch.test.ts +676 -0
- package/src/__tests__/delegate-result-package.test.ts +350 -0
- package/src/__tests__/delegate-seed.test.ts +392 -0
- package/src/__tests__/durable-fs.test.ts +559 -0
- package/src/__tests__/extension-api.test.ts +579 -0
- package/src/__tests__/fusion-artifacts.test.ts +1039 -0
- package/src/__tests__/fusion-budget.test.ts +1356 -0
- package/src/__tests__/fusion-claude-cache.test.ts +320 -0
- package/src/__tests__/fusion-config.test.ts +335 -0
- package/src/__tests__/fusion-context-prompts.test.ts +670 -0
- package/src/__tests__/fusion-evaluation.test.ts +315 -0
- package/src/__tests__/fusion-extraction-equivalence.test.ts +58 -0
- package/src/__tests__/fusion-golden-bytes.test.ts +35 -0
- package/src/__tests__/fusion-high-cardinality.test.ts +192 -0
- package/src/__tests__/fusion-model-selector.test.ts +205 -0
- package/src/__tests__/fusion-orchestrator.test.ts +1194 -0
- package/src/__tests__/fusion-rpc.test.ts +369 -0
- package/src/__tests__/fusion-sdk.test.ts +1226 -0
- package/src/__tests__/fusion-v5-core.test.ts +219 -0
- package/src/__tests__/fusion-validate-orchestrator.test.ts +240 -0
- package/src/__tests__/fusion-web-fetch.test.ts +485 -0
- package/src/__tests__/fusion-workflows.test.ts +59 -0
- package/src/__tests__/helpers/delegate-deterministic-seed.ts +109 -0
- package/src/__tests__/helpers/delegate-seed-subprocess.ts +10 -0
- package/src/__tests__/helpers/fusion-canonical-subprocess.ts +21 -0
- package/src/__tests__/helpers/fusion-canonical.ts +140 -0
- package/src/__tests__/helpers/fusion-fake-pi.ts +279 -0
- package/src/__tests__/helpers/fusion-golden-corpus.ts +500 -0
- package/src/__tests__/helpers/fusion-high-cardinality.ts +140 -0
- package/src/__tests__/helpers/normalize.ts +22 -0
- package/src/__tests__/helpers/pi-hook-contract-evidence.json +18 -0
- package/src/__tests__/pi-launch.test.ts +202 -0
- package/src/__tests__/registry.test.ts +1580 -0
- package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +130 -0
- package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +631 -0
- package/src/__tests__/scripted-provider/delegate-guard-provider.ts +403 -0
- package/src/__tests__/scripted-provider/follow-up.test.ts +448 -0
- package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +132 -0
- package/src/__tests__/scripted-provider/fusion-reason.test.ts +310 -0
- package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +163 -0
- package/src/__tests__/scripted-provider/hook-contract-provider.ts +179 -0
- package/src/__tests__/scripted-provider/hook-probe-a.ts +3 -0
- package/src/__tests__/scripted-provider/hook-probe-b.ts +3 -0
- package/src/__tests__/scripted-provider/hook-probe-extension.ts +126 -0
- package/src/__tests__/scripted-provider/output-recovery-provider.ts +153 -0
- package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +18 -0
- package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +477 -0
- package/src/__tests__/scripted-provider/runtime-guard-probe.ts +28 -0
- package/src/__tests__/scripted-provider/runtime-guard-provider.ts +49 -0
- package/src/__tests__/scripted-provider/scripted-provider-extension.ts +408 -0
- package/src/__tests__/task-manager.test.ts +479 -0
- package/src/__tests__/windows-taskkill.test.ts +161 -0
- package/src/anthropic-attribution-path.ts +21 -0
- package/src/anthropic-attribution.ts +1983 -0
- package/src/attested-pi-run.ts +612 -0
- package/src/child-process.ts +55 -0
- package/src/common.ts +8 -0
- package/src/config.ts +292 -0
- package/src/context-parent-snapshot.ts +142 -0
- package/src/context-token-budget.ts +903 -0
- package/src/context-visible-conversation-v2.ts +551 -0
- package/src/delegate/artifacts.ts +487 -0
- package/src/delegate/budget.ts +415 -0
- package/src/delegate/hook-contract-evidence.json +18 -0
- package/src/delegate/hook-contract.ts +154 -0
- package/src/delegate/launch.ts +497 -0
- package/src/delegate/result-package.ts +459 -0
- package/src/delegate/runner.ts +449 -0
- package/src/delegate/seed.ts +423 -0
- package/src/delegate/types.ts +323 -0
- package/src/delegate-child-extension.ts +978 -0
- package/src/delegate-extension.ts +806 -0
- package/src/durable-fs.ts +386 -0
- package/src/extension-api.ts +548 -0
- package/src/fixtures/delegate-context-incident.json +17 -0
- package/src/fixtures/fusion-golden-bytes.json +310 -0
- package/src/fixtures/fusion-validate-golden-bytes.json +282 -0
- package/src/fusion/artifacts.ts +967 -0
- package/src/fusion/budget.ts +1162 -0
- package/src/fusion/child-protocol.ts +305 -0
- package/src/fusion/claude-cache.ts +207 -0
- package/src/fusion/clean-context.ts +91 -0
- package/src/fusion/config.ts +449 -0
- package/src/fusion/context.ts +265 -0
- package/src/fusion/evaluation.ts +800 -0
- package/src/fusion/orchestrator.ts +1288 -0
- package/src/fusion/output-contract.ts +34 -0
- package/src/fusion/pi-child.ts +2373 -0
- package/src/fusion/prompts.ts +345 -0
- package/src/fusion/result-package.ts +959 -0
- package/src/fusion/source-policy.ts +257 -0
- package/src/fusion/types.ts +1139 -0
- package/src/fusion/web-fetch.ts +1060 -0
- package/src/fusion/workflows.ts +184 -0
- package/src/fusion-child-extension.ts +1052 -0
- package/src/fusion-extension.ts +1293 -0
- package/src/index.ts +295 -0
- package/src/pi-launch.ts +225 -0
- package/src/registry.ts +2424 -0
- package/src/settings-overlay.ts +208 -0
- package/src/task-manager.ts +774 -0
- package/src/tools.ts +530 -0
- package/src/turndown.d.ts +15 -0
- package/src/types.ts +963 -0
- package/src/ui/fusion-model-selector.ts +322 -0
- package/src/windows-taskkill.ts +250 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { mkdir, readFile, realpath, stat } from 'node:fs/promises';
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
6
|
+
import type {
|
|
7
|
+
BackgroundTaskChildProcess,
|
|
8
|
+
BackgroundTaskContext,
|
|
9
|
+
BackgroundTaskSpawn,
|
|
10
|
+
} from './registry.js';
|
|
11
|
+
import { isJsonObject, parseJsonText, type BgTaskSnapshot, type JsonObject } from './types.js';
|
|
12
|
+
import { replaceFileDurable, writeFileDurable } from './durable-fs.js';
|
|
13
|
+
import {
|
|
14
|
+
assertWindowsCommandLineWithinLimit,
|
|
15
|
+
piLaunchArgv,
|
|
16
|
+
resolvePiLaunch,
|
|
17
|
+
type PiLaunchSpec,
|
|
18
|
+
} from './pi-launch.js';
|
|
19
|
+
|
|
20
|
+
export const PI_TASK_ATTESTATION_SCHEMA_VERSION = 'phase2.pi_task_attestation.v1';
|
|
21
|
+
export const ATTESTED_TASK_ID_PATTERN = /^b[0-9a-f]{32}$/;
|
|
22
|
+
|
|
23
|
+
export interface StructuredPiLaunchRequest {
|
|
24
|
+
name: string;
|
|
25
|
+
provider: string;
|
|
26
|
+
model: string;
|
|
27
|
+
prompt: string;
|
|
28
|
+
reportPath: string;
|
|
29
|
+
extraPiArgs?: string[] | undefined;
|
|
30
|
+
thinking?: string | undefined;
|
|
31
|
+
timeoutSeconds?: number | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AttestedTaskPaths {
|
|
35
|
+
outputAbsPath: string;
|
|
36
|
+
metadataAbsPath: string;
|
|
37
|
+
eventsAbsPath: string;
|
|
38
|
+
stderrAbsPath: string;
|
|
39
|
+
wrapperAbsPath: string;
|
|
40
|
+
attestationAbsPath: string;
|
|
41
|
+
outputPath: string;
|
|
42
|
+
metadataPath: string;
|
|
43
|
+
eventsPath: string;
|
|
44
|
+
stderrPath: string;
|
|
45
|
+
wrapperPath: string;
|
|
46
|
+
attestationPath: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface GitAuthoritySnapshot {
|
|
50
|
+
commit: string;
|
|
51
|
+
tree: string;
|
|
52
|
+
clean: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ParsedPiEvents {
|
|
56
|
+
piSessionId: string;
|
|
57
|
+
piCwd: string;
|
|
58
|
+
provider: string;
|
|
59
|
+
model: string;
|
|
60
|
+
providerScopedModelId: string;
|
|
61
|
+
finalStopReason: string;
|
|
62
|
+
tokenUsage: {
|
|
63
|
+
input: number;
|
|
64
|
+
output: number;
|
|
65
|
+
cacheRead: number;
|
|
66
|
+
cacheWrite: number;
|
|
67
|
+
totalTokens: number;
|
|
68
|
+
costTotal?: number | undefined;
|
|
69
|
+
};
|
|
70
|
+
assistantCount: number;
|
|
71
|
+
toolUsage: { total: number; failed: number; byName: Record<string, number> };
|
|
72
|
+
humanTranscript: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AuthObservation {
|
|
76
|
+
apiIdentity: string;
|
|
77
|
+
authClass: string;
|
|
78
|
+
credentialKind: 'oauth';
|
|
79
|
+
routeClass: 'subscription-agent';
|
|
80
|
+
channel: string;
|
|
81
|
+
directApiKey: false;
|
|
82
|
+
selectedModel: Model<Api>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface FinalAttestationInputs {
|
|
86
|
+
task: BgTaskSnapshot;
|
|
87
|
+
paths: AttestedTaskPaths;
|
|
88
|
+
sessionDir: string;
|
|
89
|
+
argv: string[];
|
|
90
|
+
cwdRealpath: string;
|
|
91
|
+
repoRootRealpath: string;
|
|
92
|
+
startAuthority: GitAuthoritySnapshot;
|
|
93
|
+
finishAuthority: GitAuthoritySnapshot;
|
|
94
|
+
parsedEvents: ParsedPiEvents;
|
|
95
|
+
auth: AuthObservation;
|
|
96
|
+
prompt: Buffer;
|
|
97
|
+
reportAbsPath: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function makeAttestedTaskId(): string {
|
|
101
|
+
return `b${randomBytes(16).toString('hex')}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function validateStructuredPiLaunchRequest(input: StructuredPiLaunchRequest): void {
|
|
105
|
+
if (!input.name.trim()) throw new Error('Attested Pi task requires a concise name');
|
|
106
|
+
if (!input.provider.trim()) throw new Error('Attested Pi task requires provider');
|
|
107
|
+
if (!input.model.trim()) throw new Error('Attested Pi task requires model');
|
|
108
|
+
if (!input.prompt) throw new Error('Attested Pi task requires prompt text');
|
|
109
|
+
if (!input.reportPath.trim()) throw new Error('Attested Pi task requires a report path');
|
|
110
|
+
const args = input.extraPiArgs ?? [];
|
|
111
|
+
for (const arg of args) {
|
|
112
|
+
if (arg === '--api-key' || arg.startsWith('--api-key=')) {
|
|
113
|
+
throw new Error('Attested Pi tasks forbid direct --api-key launch arguments');
|
|
114
|
+
}
|
|
115
|
+
if (arg === '--auth-file' || arg.startsWith('--auth-file=')) {
|
|
116
|
+
throw new Error('Attested Pi tasks forbid alternate auth-file launch arguments');
|
|
117
|
+
}
|
|
118
|
+
if (arg === '-p' || arg === '--print' || arg === '--mode' || arg.startsWith('--mode=')) {
|
|
119
|
+
throw new Error('Attested Pi tasks own print/json mode arguments');
|
|
120
|
+
}
|
|
121
|
+
if (
|
|
122
|
+
arg === '--provider' ||
|
|
123
|
+
arg.startsWith('--provider=') ||
|
|
124
|
+
arg === '--model' ||
|
|
125
|
+
arg.startsWith('--model=')
|
|
126
|
+
) {
|
|
127
|
+
throw new Error('Use structured provider/model fields, not duplicate Pi args');
|
|
128
|
+
}
|
|
129
|
+
if (arg === '--thinking' || arg.startsWith('--thinking=')) {
|
|
130
|
+
throw new Error('Use the structured thinking field, not duplicate Pi args');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const ATTESTED_PI_REMOVED_ENV_KEYS = [
|
|
136
|
+
'OPENROUTER_API_KEY',
|
|
137
|
+
'OPENROUTER_BASE_URL',
|
|
138
|
+
'OPENAI_API_KEY',
|
|
139
|
+
'OPENAI_BASE_URL',
|
|
140
|
+
'ANTHROPIC_API_KEY',
|
|
141
|
+
'ANTHROPIC_BASE_URL',
|
|
142
|
+
'PI_API_KEY',
|
|
143
|
+
'PI_API_BASE_URL',
|
|
144
|
+
'PI_AUTH_FILE',
|
|
145
|
+
] as const;
|
|
146
|
+
|
|
147
|
+
export function attestedPiChildEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
148
|
+
const out: NodeJS.ProcessEnv = { ...env };
|
|
149
|
+
for (const key of ATTESTED_PI_REMOVED_ENV_KEYS) Reflect.deleteProperty(out, key);
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function buildAttestedPiArgv(
|
|
154
|
+
input: StructuredPiLaunchRequest,
|
|
155
|
+
attributionExtensionPath?: string,
|
|
156
|
+
): string[] {
|
|
157
|
+
validateStructuredPiLaunchRequest(input);
|
|
158
|
+
const args = ['pi', '--mode', 'json', '--provider', input.provider, '--model', input.model];
|
|
159
|
+
if (input.provider === 'anthropic') {
|
|
160
|
+
if (!attributionExtensionPath?.trim()) {
|
|
161
|
+
throw new Error('Anthropic attested Pi tasks require the package attribution extension');
|
|
162
|
+
}
|
|
163
|
+
args.push('--extension', attributionExtensionPath);
|
|
164
|
+
}
|
|
165
|
+
if (input.thinking?.trim()) args.push('--thinking', input.thinking.trim());
|
|
166
|
+
args.push(...(input.extraPiArgs ?? []), input.prompt);
|
|
167
|
+
return args;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function resolveReportPath(cwd: string, reportPath: string): Promise<string> {
|
|
171
|
+
if (isAbsolute(reportPath))
|
|
172
|
+
throw new Error('Attested Pi report path must be relative to task cwd');
|
|
173
|
+
const resolved = resolve(cwd, reportPath);
|
|
174
|
+
const relativePath = relative(cwd, resolved);
|
|
175
|
+
if (relativePath === '' || relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
|
176
|
+
throw new Error('Attested Pi report path must stay inside task cwd');
|
|
177
|
+
}
|
|
178
|
+
const parts = relativePath.split(sep);
|
|
179
|
+
if (parts[0] === '.git' || (parts[0] === '.pi' && parts[1] === 'tasks')) {
|
|
180
|
+
throw new Error('Attested Pi report path cannot target Git metadata or the fixed task store');
|
|
181
|
+
}
|
|
182
|
+
return resolved;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function gitAuthoritySnapshot(cwd: string): Promise<GitAuthoritySnapshot> {
|
|
186
|
+
const commit = await runGit(cwd, ['rev-parse', 'HEAD']);
|
|
187
|
+
const tree = await runGit(cwd, ['rev-parse', 'HEAD^{tree}']);
|
|
188
|
+
const status = await runGit(cwd, ['status', '--porcelain=v1', '--untracked-files=all']);
|
|
189
|
+
return { commit, tree, clean: status.length === 0 };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function gitRepoRoot(cwd: string): Promise<string> {
|
|
193
|
+
return realpath(await runGit(cwd, ['rev-parse', '--show-toplevel']));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function runGit(cwd: string, args: string[]): Promise<string> {
|
|
197
|
+
return new Promise((resolvePromise, reject) => {
|
|
198
|
+
const child = nodeSpawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
199
|
+
const out: Buffer[] = [];
|
|
200
|
+
const err: Buffer[] = [];
|
|
201
|
+
child.stdout.on('data', (chunk: Buffer) => out.push(chunk));
|
|
202
|
+
child.stderr.on('data', (chunk: Buffer) => err.push(chunk));
|
|
203
|
+
child.on('error', reject);
|
|
204
|
+
child.on('close', (code) => {
|
|
205
|
+
if (code === 0) {
|
|
206
|
+
resolvePromise(Buffer.concat(out).toString('utf8').trim());
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
reject(
|
|
210
|
+
new Error(`git ${args.join(' ')} failed: ${Buffer.concat(err).toString('utf8').trim()}`),
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function observePiOAuth(
|
|
217
|
+
ctx: BackgroundTaskContext,
|
|
218
|
+
provider: string,
|
|
219
|
+
modelId: string,
|
|
220
|
+
): AuthObservation {
|
|
221
|
+
const registry = ctx.modelRegistry;
|
|
222
|
+
const selected = registry.find?.(provider, modelId);
|
|
223
|
+
if (!selected) throw new Error(`Pi model not found in ModelRegistry: ${provider}/${modelId}`);
|
|
224
|
+
if (!registry.isUsingOAuth) throw new Error('ModelRegistry OAuth observation is unavailable');
|
|
225
|
+
if (!registry.isUsingOAuth(selected)) {
|
|
226
|
+
throw new Error(`Attested Pi task requires OAuth credentials for ${provider}/${modelId}`);
|
|
227
|
+
}
|
|
228
|
+
const channel =
|
|
229
|
+
provider === 'openai-codex'
|
|
230
|
+
? 'subscription-codex'
|
|
231
|
+
: provider === 'anthropic'
|
|
232
|
+
? 'subscription-anthropic'
|
|
233
|
+
: undefined;
|
|
234
|
+
const authClass =
|
|
235
|
+
provider === 'openai-codex'
|
|
236
|
+
? 'pi-codex-oauth'
|
|
237
|
+
: provider === 'anthropic'
|
|
238
|
+
? 'pi-anthropic-oauth'
|
|
239
|
+
: undefined;
|
|
240
|
+
if (!channel || !authClass)
|
|
241
|
+
throw new Error(`Unsupported attested Pi OAuth provider: ${provider}`);
|
|
242
|
+
return {
|
|
243
|
+
apiIdentity: selected.api,
|
|
244
|
+
authClass,
|
|
245
|
+
credentialKind: 'oauth',
|
|
246
|
+
routeClass: 'subscription-agent',
|
|
247
|
+
channel,
|
|
248
|
+
directApiKey: false,
|
|
249
|
+
selectedModel: selected,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function readString(record: JsonObject, key: string): string | undefined {
|
|
254
|
+
const value = record[key];
|
|
255
|
+
return typeof value === 'string' ? value : undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function readNumber(record: JsonObject, key: string): number | undefined {
|
|
259
|
+
const value = record[key];
|
|
260
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function nonNegativeInteger(value: unknown): number {
|
|
264
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function normalizeUsage(value: unknown): ParsedPiEvents['tokenUsage'] {
|
|
268
|
+
if (!isJsonObject(value))
|
|
269
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 };
|
|
270
|
+
const input = nonNegativeInteger(value['input']);
|
|
271
|
+
const output = nonNegativeInteger(value['output']);
|
|
272
|
+
const cacheRead = nonNegativeInteger(value['cacheRead']);
|
|
273
|
+
const cacheWrite = nonNegativeInteger(value['cacheWrite']);
|
|
274
|
+
const totalTokens =
|
|
275
|
+
nonNegativeInteger(value['totalTokens']) || input + output + cacheRead + cacheWrite;
|
|
276
|
+
const cost = isJsonObject(value['cost']) ? readNumber(value['cost'], 'total') : undefined;
|
|
277
|
+
const usage: ParsedPiEvents['tokenUsage'] = { input, output, cacheRead, cacheWrite, totalTokens };
|
|
278
|
+
if (cost !== undefined && cost >= 0) usage.costTotal = cost;
|
|
279
|
+
return usage;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function appendUsage(
|
|
283
|
+
target: ParsedPiEvents['tokenUsage'],
|
|
284
|
+
delta: ParsedPiEvents['tokenUsage'],
|
|
285
|
+
): void {
|
|
286
|
+
target.input += delta.input;
|
|
287
|
+
target.output += delta.output;
|
|
288
|
+
target.cacheRead += delta.cacheRead;
|
|
289
|
+
target.cacheWrite += delta.cacheWrite;
|
|
290
|
+
target.totalTokens += delta.totalTokens;
|
|
291
|
+
if (delta.costTotal !== undefined) target.costTotal = (target.costTotal ?? 0) + delta.costTotal;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function textFromAssistantMessage(message: JsonObject): string[] {
|
|
295
|
+
const content = message['content'];
|
|
296
|
+
if (!Array.isArray(content)) return [];
|
|
297
|
+
return content.flatMap((part) => {
|
|
298
|
+
if (!isJsonObject(part)) return [];
|
|
299
|
+
if (part['type'] === 'text' && typeof part['text'] === 'string') return [part['text']];
|
|
300
|
+
return [];
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function countToolCalls(message: JsonObject, tools: ParsedPiEvents['toolUsage']): void {
|
|
305
|
+
const content = message['content'];
|
|
306
|
+
if (!Array.isArray(content)) return;
|
|
307
|
+
for (const part of content) {
|
|
308
|
+
if (!isJsonObject(part) || part['type'] !== 'toolCall') continue;
|
|
309
|
+
const name = typeof part['name'] === 'string' && part['name'] ? part['name'] : 'tool';
|
|
310
|
+
tools.total += 1;
|
|
311
|
+
tools.byName[name] = (tools.byName[name] ?? 0) + 1;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function parsePiJsonEvents(raw: Buffer): ParsedPiEvents {
|
|
316
|
+
const text = raw.toString('utf8');
|
|
317
|
+
if (!text.endsWith('\n')) throw new Error('Pi JSON event stream is not newline-terminated');
|
|
318
|
+
let sessionId: string | undefined;
|
|
319
|
+
let sessionCwd: string | undefined;
|
|
320
|
+
let sessionCount = 0;
|
|
321
|
+
let agentStartCount = 0;
|
|
322
|
+
let agentEndCount = 0;
|
|
323
|
+
let provider: string | undefined;
|
|
324
|
+
let model: string | undefined;
|
|
325
|
+
let finalStopReason: string | undefined;
|
|
326
|
+
let assistantCount = 0;
|
|
327
|
+
const usage: ParsedPiEvents['tokenUsage'] = {
|
|
328
|
+
input: 0,
|
|
329
|
+
output: 0,
|
|
330
|
+
cacheRead: 0,
|
|
331
|
+
cacheWrite: 0,
|
|
332
|
+
totalTokens: 0,
|
|
333
|
+
};
|
|
334
|
+
const tools: ParsedPiEvents['toolUsage'] = { total: 0, failed: 0, byName: {} };
|
|
335
|
+
const transcript: string[] = [];
|
|
336
|
+
for (const line of text.split('\n')) {
|
|
337
|
+
if (!line) continue;
|
|
338
|
+
const parsed = parseJsonText(line);
|
|
339
|
+
if (!isJsonObject(parsed)) throw new Error('Pi JSON event line is not an object');
|
|
340
|
+
const eventType = parsed['type'];
|
|
341
|
+
if (eventType === 'session') {
|
|
342
|
+
sessionCount += 1;
|
|
343
|
+
sessionId = readString(parsed, 'id');
|
|
344
|
+
sessionCwd = readString(parsed, 'cwd');
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (eventType === 'agent_start') agentStartCount += 1;
|
|
348
|
+
if (eventType === 'agent_end') agentEndCount += 1;
|
|
349
|
+
if (eventType === 'tool_execution_start') {
|
|
350
|
+
const name = readString(parsed, 'toolName') ?? readString(parsed, 'tool_name') ?? 'tool';
|
|
351
|
+
tools.total += 1;
|
|
352
|
+
tools.byName[name] = (tools.byName[name] ?? 0) + 1;
|
|
353
|
+
transcript.push(`→ ${name}`);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (eventType === 'tool_execution_end') {
|
|
357
|
+
if (parsed['isError'] === true) {
|
|
358
|
+
tools.failed += 1;
|
|
359
|
+
const name = readString(parsed, 'toolName') ?? readString(parsed, 'tool_name') ?? 'tool';
|
|
360
|
+
transcript.push(`✗ ${name} failed`);
|
|
361
|
+
}
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (eventType !== 'message_end' || !isJsonObject(parsed['message'])) continue;
|
|
365
|
+
const message = parsed['message'];
|
|
366
|
+
if (message['role'] !== 'assistant') continue;
|
|
367
|
+
assistantCount += 1;
|
|
368
|
+
const messageProvider = readString(message, 'provider');
|
|
369
|
+
const messageModel = readString(message, 'model');
|
|
370
|
+
if (!messageProvider || !messageModel) {
|
|
371
|
+
throw new Error('Assistant message lacks provider/model in Pi JSON events');
|
|
372
|
+
}
|
|
373
|
+
if (provider !== undefined && provider !== messageProvider)
|
|
374
|
+
throw new Error('Pi assistant provider changed during task');
|
|
375
|
+
if (model !== undefined && model !== messageModel)
|
|
376
|
+
throw new Error('Pi assistant model changed during task');
|
|
377
|
+
provider = messageProvider;
|
|
378
|
+
model = messageModel;
|
|
379
|
+
appendUsage(usage, normalizeUsage(message['usage']));
|
|
380
|
+
countToolCalls(message, tools);
|
|
381
|
+
transcript.push(...textFromAssistantMessage(message));
|
|
382
|
+
if (message['error'] !== undefined && message['error'] !== null)
|
|
383
|
+
throw new Error('Assistant message reported an error');
|
|
384
|
+
const stopReason = readString(message, 'stopReason');
|
|
385
|
+
if (stopReason) finalStopReason = stopReason;
|
|
386
|
+
}
|
|
387
|
+
if (sessionCount !== 1 || !sessionId || !sessionCwd)
|
|
388
|
+
throw new Error('Pi JSON events must contain exactly one session header');
|
|
389
|
+
if (agentStartCount !== 1) throw new Error('Pi JSON events must contain exactly one agent_start');
|
|
390
|
+
if (agentEndCount !== 1) throw new Error('Pi JSON events must contain exactly one agent_end');
|
|
391
|
+
if (assistantCount < 1 || !provider || !model)
|
|
392
|
+
throw new Error('Pi JSON events contain no assistant message');
|
|
393
|
+
if (finalStopReason !== 'stop')
|
|
394
|
+
throw new Error(`Pi final stop reason is not stop: ${finalStopReason ?? 'missing'}`);
|
|
395
|
+
return {
|
|
396
|
+
piSessionId: sessionId,
|
|
397
|
+
piCwd: sessionCwd,
|
|
398
|
+
provider,
|
|
399
|
+
model,
|
|
400
|
+
providerScopedModelId: `${provider}/${model}`,
|
|
401
|
+
finalStopReason,
|
|
402
|
+
tokenUsage: usage,
|
|
403
|
+
assistantCount,
|
|
404
|
+
toolUsage: tools,
|
|
405
|
+
humanTranscript: transcript.filter((line) => line.trim()).join('\n') + '\n',
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function sha256Buffer(buffer: Buffer): string {
|
|
410
|
+
return `sha256:${createHash('sha256').update(buffer).digest('hex')}`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export async function sha256File(path: string): Promise<{ byteLength: number; sha256: string }> {
|
|
414
|
+
const bytes = await readFile(path);
|
|
415
|
+
return { byteLength: bytes.length, sha256: sha256Buffer(bytes) };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function canonicalJson(value: unknown): string {
|
|
419
|
+
return JSON.stringify(sortJson(value));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function sortJson(value: unknown): unknown {
|
|
423
|
+
if (Array.isArray(value)) return value.map(sortJson);
|
|
424
|
+
if (!isJsonObject(value)) return value;
|
|
425
|
+
return Object.fromEntries(
|
|
426
|
+
Object.keys(value)
|
|
427
|
+
.sort()
|
|
428
|
+
.map((key) => [key, sortJson(value[key])]),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export async function writeFileFsynced(path: string, data: Buffer | string): Promise<void> {
|
|
433
|
+
await mkdir(dirname(path), { recursive: true });
|
|
434
|
+
await writeFileDurable(path, data);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
|
|
438
|
+
await replaceFileDurable(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export async function closeAndFsyncOutputStream(
|
|
442
|
+
stream: NodeJS.WritableStream | undefined,
|
|
443
|
+
): Promise<void> {
|
|
444
|
+
if (!stream) return;
|
|
445
|
+
await new Promise<void>((resolvePromise, reject) => {
|
|
446
|
+
let settled = false;
|
|
447
|
+
const finish = () => {
|
|
448
|
+
if (settled) return;
|
|
449
|
+
settled = true;
|
|
450
|
+
stream.off('error', fail);
|
|
451
|
+
stream.off('close', finish);
|
|
452
|
+
stream.off('finish', finish);
|
|
453
|
+
resolvePromise();
|
|
454
|
+
};
|
|
455
|
+
const fail = (error: Error) => {
|
|
456
|
+
if (settled) return;
|
|
457
|
+
settled = true;
|
|
458
|
+
stream.off('close', finish);
|
|
459
|
+
reject(error);
|
|
460
|
+
};
|
|
461
|
+
stream.once('close', finish);
|
|
462
|
+
stream.once('finish', finish);
|
|
463
|
+
stream.once('error', fail);
|
|
464
|
+
stream.end();
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function spawnAndCapturePi(
|
|
469
|
+
spawnImpl: BackgroundTaskSpawn,
|
|
470
|
+
argv: string[],
|
|
471
|
+
options: SpawnOptions,
|
|
472
|
+
platform: NodeJS.Platform = process.platform,
|
|
473
|
+
launchOverride?: PiLaunchSpec | undefined,
|
|
474
|
+
): { child: BackgroundTaskChildProcess; stdoutChunks: Buffer[]; stderrChunks: Buffer[] } {
|
|
475
|
+
const stdoutChunks: Buffer[] = [];
|
|
476
|
+
const stderrChunks: Buffer[] = [];
|
|
477
|
+
const logicalExecutable = argv[0];
|
|
478
|
+
if (logicalExecutable !== 'pi') throw new Error('Attested Pi argv must start with pi');
|
|
479
|
+
const piArgs = argv.slice(1);
|
|
480
|
+
const launch = launchOverride ?? resolvePiLaunch({ platform });
|
|
481
|
+
assertWindowsCommandLineWithinLimit(launch, piArgs, platform, 'attested-pi-run');
|
|
482
|
+
const child = spawnImpl(launch.executable, piLaunchArgv(launch, piArgs), options);
|
|
483
|
+
child.stdout?.on('data', (chunk: Buffer | string) => {
|
|
484
|
+
stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8'));
|
|
485
|
+
});
|
|
486
|
+
child.stderr?.on('data', (chunk: Buffer | string) => {
|
|
487
|
+
stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8'));
|
|
488
|
+
});
|
|
489
|
+
return { child, stdoutChunks, stderrChunks };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export async function buildPiTaskAttestation(input: FinalAttestationInputs): Promise<JsonObject> {
|
|
493
|
+
if (
|
|
494
|
+
input.startAuthority.commit !== input.finishAuthority.commit ||
|
|
495
|
+
input.startAuthority.tree !== input.finishAuthority.tree
|
|
496
|
+
) {
|
|
497
|
+
throw new Error('Git authority changed during attested Pi task');
|
|
498
|
+
}
|
|
499
|
+
if (!input.startAuthority.clean || !input.finishAuthority.clean) {
|
|
500
|
+
throw new Error('Git worktree must be clean at attested Pi task start and finish');
|
|
501
|
+
}
|
|
502
|
+
if (
|
|
503
|
+
input.parsedEvents.provider !== input.auth.selectedModel.provider ||
|
|
504
|
+
input.parsedEvents.model !== input.auth.selectedModel.id
|
|
505
|
+
) {
|
|
506
|
+
throw new Error('Observed Pi provider/model do not match selected ModelRegistry model');
|
|
507
|
+
}
|
|
508
|
+
const metadata = await sha256File(input.paths.metadataAbsPath);
|
|
509
|
+
const output = await sha256File(input.paths.outputAbsPath);
|
|
510
|
+
const events = await sha256File(input.paths.eventsAbsPath);
|
|
511
|
+
const stderr = await sha256File(input.paths.stderrAbsPath);
|
|
512
|
+
const wrapper = await sha256File(input.paths.wrapperAbsPath);
|
|
513
|
+
const report = await sha256File(input.reportAbsPath);
|
|
514
|
+
const promptHash = sha256Buffer(input.prompt);
|
|
515
|
+
const attestation: { [key: string]: unknown } = {
|
|
516
|
+
schema_version: PI_TASK_ATTESTATION_SCHEMA_VERSION,
|
|
517
|
+
locator: {
|
|
518
|
+
session_dir: input.sessionDir,
|
|
519
|
+
task_id: input.task.id,
|
|
520
|
+
metadata_ref: input.paths.metadataPath,
|
|
521
|
+
output_ref: input.paths.outputPath,
|
|
522
|
+
events_ref: input.paths.eventsPath,
|
|
523
|
+
stderr_ref: input.paths.stderrPath,
|
|
524
|
+
wrapper_ref: input.paths.wrapperPath,
|
|
525
|
+
},
|
|
526
|
+
source_hashes: {
|
|
527
|
+
metadata_sha256: metadata.sha256,
|
|
528
|
+
output_sha256: output.sha256,
|
|
529
|
+
events_sha256: events.sha256,
|
|
530
|
+
stderr_sha256: stderr.sha256,
|
|
531
|
+
wrapper_sha256: wrapper.sha256,
|
|
532
|
+
},
|
|
533
|
+
lifecycle: {
|
|
534
|
+
status: input.task.status,
|
|
535
|
+
is_agent: input.task.isAgent,
|
|
536
|
+
start_time_ms: input.task.startTime,
|
|
537
|
+
end_time_ms: input.task.endTime ?? input.task.startTime,
|
|
538
|
+
exit_code: input.task.exitCode ?? null,
|
|
539
|
+
signal: input.task.signal ?? null,
|
|
540
|
+
bytes_written: input.task.bytesWritten,
|
|
541
|
+
},
|
|
542
|
+
invocation: {
|
|
543
|
+
pi_session_id: input.parsedEvents.piSessionId,
|
|
544
|
+
argv: input.argv,
|
|
545
|
+
cwd_realpath: input.cwdRealpath,
|
|
546
|
+
provider: input.parsedEvents.provider,
|
|
547
|
+
model_id: input.parsedEvents.model,
|
|
548
|
+
provider_scoped_model_id: input.parsedEvents.providerScopedModelId,
|
|
549
|
+
api_identity: input.auth.apiIdentity,
|
|
550
|
+
auth_class: input.auth.authClass,
|
|
551
|
+
credential_kind: input.auth.credentialKind,
|
|
552
|
+
route_class: input.auth.routeClass,
|
|
553
|
+
channel: input.auth.channel,
|
|
554
|
+
direct_api_key: input.auth.directApiKey,
|
|
555
|
+
final_stop_reason: input.parsedEvents.finalStopReason,
|
|
556
|
+
},
|
|
557
|
+
authority: {
|
|
558
|
+
repo_root_realpath: input.repoRootRealpath,
|
|
559
|
+
start_commit_oid: input.startAuthority.commit,
|
|
560
|
+
start_tree_oid: input.startAuthority.tree,
|
|
561
|
+
finish_commit_oid: input.finishAuthority.commit,
|
|
562
|
+
finish_tree_oid: input.finishAuthority.tree,
|
|
563
|
+
start_worktree_clean: input.startAuthority.clean,
|
|
564
|
+
finish_worktree_clean: input.finishAuthority.clean,
|
|
565
|
+
},
|
|
566
|
+
artifacts: {
|
|
567
|
+
prompt: { byte_length: input.prompt.length, sha256: promptHash },
|
|
568
|
+
task_output: { byte_length: output.byteLength, sha256: output.sha256 },
|
|
569
|
+
stderr: { byte_length: stderr.byteLength, sha256: stderr.sha256 },
|
|
570
|
+
transcript: { byte_length: events.byteLength, sha256: events.sha256 },
|
|
571
|
+
report: { byte_length: report.byteLength, sha256: report.sha256 },
|
|
572
|
+
},
|
|
573
|
+
attestation_sha256: '',
|
|
574
|
+
};
|
|
575
|
+
const withoutSelf = { ...attestation, attestation_sha256: undefined };
|
|
576
|
+
Reflect.deleteProperty(withoutSelf, 'attestation_sha256');
|
|
577
|
+
attestation['attestation_sha256'] = sha256Buffer(Buffer.from(canonicalJson(withoutSelf), 'utf8'));
|
|
578
|
+
return attestation;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function makeAttestedTaskPaths(
|
|
582
|
+
runtimeAbs: string,
|
|
583
|
+
runtimeDisplay: string,
|
|
584
|
+
id: string,
|
|
585
|
+
): AttestedTaskPaths {
|
|
586
|
+
return {
|
|
587
|
+
outputAbsPath: join(runtimeAbs, `${id}.output`),
|
|
588
|
+
metadataAbsPath: join(runtimeAbs, `${id}.json`),
|
|
589
|
+
eventsAbsPath: join(runtimeAbs, `${id}.pi-events.jsonl`),
|
|
590
|
+
stderrAbsPath: join(runtimeAbs, `${id}.stderr`),
|
|
591
|
+
wrapperAbsPath: join(runtimeAbs, `${id}.pi-telemetry-wrapper.cjs`),
|
|
592
|
+
attestationAbsPath: join(runtimeAbs, `${id}.attestation.json`),
|
|
593
|
+
outputPath: join(runtimeDisplay, `${id}.output`),
|
|
594
|
+
metadataPath: join(runtimeDisplay, `${id}.json`),
|
|
595
|
+
eventsPath: join(runtimeDisplay, `${id}.pi-events.jsonl`),
|
|
596
|
+
stderrPath: join(runtimeDisplay, `${id}.stderr`),
|
|
597
|
+
wrapperPath: join(runtimeDisplay, `${id}.pi-telemetry-wrapper.cjs`),
|
|
598
|
+
attestationPath: join(runtimeDisplay, `${id}.attestation.json`),
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export function pathInside(parent: string, child: string): boolean {
|
|
603
|
+
const rel = relative(parent, child);
|
|
604
|
+
return (
|
|
605
|
+
rel === '' || (!rel.startsWith('..') && !isAbsolute(rel) && !rel.split(sep).includes('..'))
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export async function assertRegularReadable(path: string): Promise<void> {
|
|
610
|
+
const stats = await stat(path);
|
|
611
|
+
if (!stats.isFile()) throw new Error(`Expected regular file: ${path}`);
|
|
612
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/background-tasks — Shared child-process & context interfaces
|
|
3
|
+
*
|
|
4
|
+
* Consumed by types.ts, registry.ts, attested-pi-run.ts, and tests.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { SpawnOptions } from "node:child_process";
|
|
8
|
+
import type { Writable } from "node:stream";
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
type AnyModel = any;
|
|
11
|
+
|
|
12
|
+
/** Writable stdin side of a spawned child. */
|
|
13
|
+
export type ChildStdin = Writable;
|
|
14
|
+
|
|
15
|
+
/** Readable side of a child output pipe. */
|
|
16
|
+
export interface OutputEventSource {
|
|
17
|
+
on(event: "data", listener: (data: Buffer | string) => void): unknown;
|
|
18
|
+
on(event: "end", listener: () => void): unknown;
|
|
19
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<Buffer>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Minimal child-process surface the registry drives. */
|
|
23
|
+
export interface BackgroundTaskChildProcess {
|
|
24
|
+
pid?: number | undefined;
|
|
25
|
+
stdin?: ChildStdin | null | undefined;
|
|
26
|
+
stdout?: OutputEventSource | null | undefined;
|
|
27
|
+
stderr?: OutputEventSource | null | undefined;
|
|
28
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
29
|
+
on(event: "error", listener: (error: Error) => void): unknown;
|
|
30
|
+
on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type BackgroundTaskSpawnOptions = SpawnOptions;
|
|
34
|
+
|
|
35
|
+
export type BackgroundTaskSpawn = (
|
|
36
|
+
command: string,
|
|
37
|
+
args: string[],
|
|
38
|
+
options?: BackgroundTaskSpawnOptions,
|
|
39
|
+
) => BackgroundTaskChildProcess;
|
|
40
|
+
|
|
41
|
+
/** Model registry surface the registry needs for route resolution. */
|
|
42
|
+
export interface BackgroundTaskModelRegistry {
|
|
43
|
+
getAll(): ReadonlyArray<{ id: unknown; provider: unknown; contextWindow: unknown }>;
|
|
44
|
+
find?(provider: string, modelId: string): AnyModel;
|
|
45
|
+
isUsingOAuth?(model: unknown): boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Extension context slice the registry needs. */
|
|
49
|
+
export interface BackgroundTaskContext {
|
|
50
|
+
cwd: string;
|
|
51
|
+
sessionId?: string | undefined;
|
|
52
|
+
modelRegistry: BackgroundTaskModelRegistry;
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
54
|
+
model?: any;
|
|
55
|
+
}
|