codecartographer-pi 0.19.5 → 0.20.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/.codecarto/GUIDE.md +1 -1
- package/.codecarto/broadside/SKILL.md +14 -0
- package/.codecarto/broadside/config.yaml +18 -0
- package/.codecarto/templates/gitignore +55 -0
- package/.codecarto/workflow/VALIDATE.md +2 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +10 -6
- package/dist/core/amendment.js +28 -23
- package/dist/core/broadside.d.ts +72 -2
- package/dist/core/broadside.js +351 -68
- package/dist/core/completion.js +95 -26
- package/dist/core/dashboard-writer.d.ts +8 -0
- package/dist/core/dashboard-writer.js +159 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/library.js +115 -107
- package/dist/core/orchestrator-config.d.ts +32 -7
- package/dist/core/orchestrator-config.js +124 -44
- package/dist/core/pipeline.d.ts +37 -0
- package/dist/core/pipeline.js +80 -10
- package/dist/core/prompts.d.ts +20 -0
- package/dist/core/prompts.js +43 -10
- package/dist/core/secrets.d.ts +16 -0
- package/dist/core/secrets.js +98 -0
- package/dist/core/status.d.ts +30 -2
- package/dist/core/status.js +54 -8
- package/dist/core/synthesis.js +5 -2
- package/dist/core/usage.d.ts +8 -0
- package/dist/core/usage.js +35 -7
- package/dist/core/utils.d.ts +32 -5
- package/dist/core/utils.js +81 -19
- package/dist/core/workspace.d.ts +99 -18
- package/dist/core/workspace.js +275 -36
- package/dist/core/yaml.js +173 -14
- package/dist/extensions/codecarto/agent-rewriter.js +21 -14
- package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
- package/dist/extensions/codecarto/agent-runner.js +27 -9
- package/dist/extensions/codecarto/agent-state.d.ts +0 -2
- package/dist/extensions/codecarto/auto-runner.js +10 -6
- package/dist/extensions/codecarto/dashboard-narrator.js +12 -8
- package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
- package/dist/extensions/codecarto/dashboard-writer.js +5 -157
- package/dist/extensions/codecarto/index.js +73 -21
- package/dist/extensions/codecarto/phase-compaction.js +7 -7
- package/dist/mcp-server/server.js +111 -50
- package/package.json +3 -2
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Secret redaction for text that leaves the machine (#252).
|
|
2
|
+
//
|
|
3
|
+
// Broad-Side uploads repository content to a batch API as-is; the only thing
|
|
4
|
+
// keeping a `.env` out of a batch was that no language glob matched it. This
|
|
5
|
+
// module is the content-level counterpart: files that exist to hold secrets
|
|
6
|
+
// are skipped by name, and high-confidence secret shapes inside any other
|
|
7
|
+
// file are replaced with `[REDACTED:<kind>]` before the text is sent. The
|
|
8
|
+
// marker keeps the *presence* of a credential visible to the security lens —
|
|
9
|
+
// `password = "[REDACTED:credential-assignment]"` is still a hardcoded
|
|
10
|
+
// credential to flag — while the value stays home.
|
|
11
|
+
//
|
|
12
|
+
// The patterns are deliberately the well-known, low-false-positive ones. A
|
|
13
|
+
// generic entropy scan would redact hashes, ids, and base64 blobs that the
|
|
14
|
+
// lenses need to read, and the point of the pass is to make an accidental
|
|
15
|
+
// upload harmless, not to replace a secret scanner in CI.
|
|
16
|
+
/** A file whose purpose is to hold credentials; never uploaded, whatever a lens's globs say. */
|
|
17
|
+
const SECRET_FILE_PATTERNS = [
|
|
18
|
+
/^\.env(?:\..*)?$/i, // .env, .env.local, .env.production — templates included; they hold values more often than not
|
|
19
|
+
/\.(?:pem|key|p12|pfx|jks|keystore|asc|gpg|ppk)$/i,
|
|
20
|
+
/^id_(?:rsa|dsa|ecdsa|ed25519)(?:\..*)?$/, // private keys and their .pub siblings; the public half is noise anyway
|
|
21
|
+
/^(?:\.npmrc|\.pypirc|\.netrc|_netrc|\.htpasswd|\.git-credentials|\.pgpass|\.my\.cnf|\.boto)$/i,
|
|
22
|
+
// credentials.json, secrets.yaml, secret.env — data files by that name.
|
|
23
|
+
// Not `secrets.go` or `credentials.py`: source that *handles* secrets is
|
|
24
|
+
// exactly what the security lens should read, so only data extensions
|
|
25
|
+
// (or none) count here.
|
|
26
|
+
/^(?:credentials?|secrets?)(?:\.(?:json|ya?ml|toml|ini|txt|cfg|conf|properties|env|local))?$/i,
|
|
27
|
+
/\.secret$/i,
|
|
28
|
+
/\.tfvars(?:\.json)?$/i,
|
|
29
|
+
/^service[-_]?account.*\.json$/i,
|
|
30
|
+
];
|
|
31
|
+
/** Whether a repo-relative path names a file that exists to hold secrets. */
|
|
32
|
+
export function isSecretFile(relPath) {
|
|
33
|
+
const base = relPath.slice(relPath.lastIndexOf("/") + 1);
|
|
34
|
+
return SECRET_FILE_PATTERNS.some((pattern) => pattern.test(base));
|
|
35
|
+
}
|
|
36
|
+
const SECRET_PATTERNS = [
|
|
37
|
+
{ kind: "private-key", pattern: /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY(?: BLOCK)?-----/g },
|
|
38
|
+
{ kind: "aws-access-key-id", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
|
|
39
|
+
{ kind: "github-token", pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})\b/g },
|
|
40
|
+
// OpenAI, OpenRouter (sk-or-v1-…), and Anthropic (sk-ant-…) keys share the prefix.
|
|
41
|
+
{ kind: "sk-api-key", pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
|
|
42
|
+
{ kind: "stripe-key", pattern: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
|
|
43
|
+
{ kind: "slack-token", pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
44
|
+
{ kind: "google-api-key", pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
45
|
+
{ kind: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
46
|
+
// `password: "hunter2hunter2"`, `API_KEY = 'abcdefgh…'`: the key and the
|
|
47
|
+
// quotes stay, the value goes. Eight characters or more, so `password: "x"`
|
|
48
|
+
// in a test fixture is left alone.
|
|
49
|
+
// The key may carry a prefix (`DB_PASSWORD`, `stripe-secret-key`), and a
|
|
50
|
+
// value that is already a marker is left alone, which is what makes the
|
|
51
|
+
// pass idempotent.
|
|
52
|
+
{
|
|
53
|
+
kind: "credential-assignment",
|
|
54
|
+
pattern: /\b((?:[A-Za-z0-9]+[_-])*(?:api[_-]?key|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token|bearer[_-]?token|password|passwd|pwd|secret|token)\s*[:=]\s*)(["'`])(?!\[REDACTED:)([^"'`\r\n]{8,})\2/gi,
|
|
55
|
+
rebuild: (marker, prefix, quote) => `${prefix}${quote}${marker}${quote}`,
|
|
56
|
+
},
|
|
57
|
+
// The password in `scheme://user:password@host`.
|
|
58
|
+
{
|
|
59
|
+
kind: "url-credential",
|
|
60
|
+
pattern: /\b([a-z][a-z0-9+.-]*:\/\/[^\s/:@"']+:)(?!\[REDACTED:)([^\s/@"']{3,})(@)/gi,
|
|
61
|
+
rebuild: (marker, head, _password, at) => `${head}${marker}${at}`,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
/**
|
|
65
|
+
* Replace every high-confidence secret in `text` with `[REDACTED:<kind>]`.
|
|
66
|
+
* Idempotent: a marker contains nothing any pattern matches.
|
|
67
|
+
*/
|
|
68
|
+
export function redactSecrets(text) {
|
|
69
|
+
let out = text;
|
|
70
|
+
let count = 0;
|
|
71
|
+
const kinds = {};
|
|
72
|
+
for (const { kind, pattern, rebuild } of SECRET_PATTERNS) {
|
|
73
|
+
out = out.replace(pattern, (...match) => {
|
|
74
|
+
count++;
|
|
75
|
+
kinds[kind] = (kinds[kind] ?? 0) + 1;
|
|
76
|
+
const marker = `[REDACTED:${kind}]`;
|
|
77
|
+
if (!rebuild)
|
|
78
|
+
return marker;
|
|
79
|
+
// replace() passes [whole, ...groups, offset, input]; hand over the groups.
|
|
80
|
+
const groups = match.slice(1, match.length - 2).map((value) => (typeof value === "string" ? value : ""));
|
|
81
|
+
return rebuild(marker, ...groups);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return { text: out, count, kinds };
|
|
85
|
+
}
|
|
86
|
+
/** One line for a submit report: what the pass did, or nothing when it did nothing. */
|
|
87
|
+
export function describeRedactions(values, files, skipped) {
|
|
88
|
+
const parts = [];
|
|
89
|
+
if (values > 0)
|
|
90
|
+
parts.push(`redacted ${values} secret-like value(s) in ${files} file(s)`);
|
|
91
|
+
if (skipped.length > 0) {
|
|
92
|
+
const shown = skipped.slice(0, 3).join(", ");
|
|
93
|
+
parts.push(`skipped ${skipped.length} secret-bearing file(s) by name (${shown}${skipped.length > 3 ? ", …" : ""})`);
|
|
94
|
+
}
|
|
95
|
+
if (parts.length === 0)
|
|
96
|
+
return null;
|
|
97
|
+
return `Before upload: ${parts.join("; ")}.`;
|
|
98
|
+
}
|
package/dist/core/status.d.ts
CHANGED
|
@@ -16,6 +16,14 @@ export declare function autoAssignIds(entries: OpenQuestionEntry[], prefix: stri
|
|
|
16
16
|
export declare function ensurePostPipelineArray(value: unknown): PostPipelineEntry[];
|
|
17
17
|
export declare function ensurePhaseRecord(value: unknown): Record<string, StatusPhase>;
|
|
18
18
|
export declare function createEmptyStatus(projectName: string, pipelinePath: string, pipeline: PipelineFile): NormalizedStatus;
|
|
19
|
+
/**
|
|
20
|
+
* The one next_actions line for a phase the engine says is eligible. Init,
|
|
21
|
+
* completion, and a pipeline switch all spell it this way.
|
|
22
|
+
*/
|
|
23
|
+
export declare function beginPhaseAction(phase: {
|
|
24
|
+
id: string;
|
|
25
|
+
primary_output?: string;
|
|
26
|
+
}): string;
|
|
19
27
|
/**
|
|
20
28
|
* Route the terminal boundary to the post-pipeline surfaces (issue #114). The
|
|
21
29
|
* moment every phase completes is exactly when skills, amendments, publishing,
|
|
@@ -36,6 +44,26 @@ export declare function parseHandoff(value: unknown): PhaseHandoff;
|
|
|
36
44
|
export declare function ensureProposedConventionArray(value: unknown): ProposedConventionEntry[];
|
|
37
45
|
export declare function loadHandoffFile(phaseId: string, workspaceDir: string): Promise<PhaseHandoff | null>;
|
|
38
46
|
export declare function applyHandoff(status: NormalizedStatus, handoff: PhaseHandoff): NormalizedStatus;
|
|
39
|
-
|
|
47
|
+
/** What {@link acquireLock} hands back: a release that only ever removes its own lock. */
|
|
48
|
+
export interface LockHandle {
|
|
40
49
|
release: () => Promise<void>;
|
|
41
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Set when acquiring meant breaking a lock older than {@link STALE_LOCK_MS}:
|
|
52
|
+
* the previous holder as its lock file recorded it, for callers that log.
|
|
53
|
+
*/
|
|
54
|
+
brokeStale?: {
|
|
55
|
+
pid: number | null;
|
|
56
|
+
since: string | null;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Take the O_EXCL lock at `lockPath`, waiting up to {@link LOCK_TIMEOUT_MS}
|
|
61
|
+
* and breaking a lock older than {@link STALE_LOCK_MS}.
|
|
62
|
+
*
|
|
63
|
+
* The lock file records `pid`, timestamp, and a per-acquisition token, and
|
|
64
|
+
* release removes the file only while it still carries that token. Without
|
|
65
|
+
* the token, release removed whoever's lock was there: after a stale break
|
|
66
|
+
* the previous holder's release deleted the new holder's lock, and a third
|
|
67
|
+
* writer walked straight in (#227).
|
|
68
|
+
*/
|
|
69
|
+
export declare function acquireLock(lockPath: string): Promise<LockHandle>;
|
package/dist/core/status.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Status normalization, atomic writes, and file-lock primitives. Pure
|
|
2
2
|
// framework logic shared by every wrapper.
|
|
3
|
-
import {
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { open, readFile, rm, stat } from "node:fs/promises";
|
|
4
5
|
import { basename, join } from "node:path";
|
|
5
6
|
import { pathExists, sleep } from "./utils.js";
|
|
6
7
|
import { loadYamlFile } from "./yaml.js";
|
|
@@ -154,12 +155,17 @@ export function createEmptyStatus(projectName, pipelinePath, pipeline) {
|
|
|
154
155
|
last_updated: "",
|
|
155
156
|
schema_version: 1,
|
|
156
157
|
phases,
|
|
157
|
-
next_actions: firstPhaseConfig
|
|
158
|
-
? [`Begin ${firstPhase} phase by producing ${firstPhaseConfig.primary_output}`]
|
|
159
|
-
: ["Begin the first pending phase."],
|
|
158
|
+
next_actions: [beginPhaseAction(firstPhaseConfig ?? { id: firstPhase })],
|
|
160
159
|
post_pipeline: [],
|
|
161
160
|
};
|
|
162
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* The one next_actions line for a phase the engine says is eligible. Init,
|
|
164
|
+
* completion, and a pipeline switch all spell it this way.
|
|
165
|
+
*/
|
|
166
|
+
export function beginPhaseAction(phase) {
|
|
167
|
+
return `Begin ${phase.id} phase by producing ${phase.primary_output ?? `findings/${phase.id}/`}`;
|
|
168
|
+
}
|
|
163
169
|
/**
|
|
164
170
|
* Spell a tool for both executable surfaces — the shape the scaffold
|
|
165
171
|
* staleness notice adopted (#177). next_actions is canonical state rendered
|
|
@@ -397,13 +403,25 @@ export function applyHandoff(status, handoff) {
|
|
|
397
403
|
status.post_pipeline = [...legacyPostPipeline, ...postPipeline.values()];
|
|
398
404
|
return status;
|
|
399
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* Take the O_EXCL lock at `lockPath`, waiting up to {@link LOCK_TIMEOUT_MS}
|
|
408
|
+
* and breaking a lock older than {@link STALE_LOCK_MS}.
|
|
409
|
+
*
|
|
410
|
+
* The lock file records `pid`, timestamp, and a per-acquisition token, and
|
|
411
|
+
* release removes the file only while it still carries that token. Without
|
|
412
|
+
* the token, release removed whoever's lock was there: after a stale break
|
|
413
|
+
* the previous holder's release deleted the new holder's lock, and a third
|
|
414
|
+
* writer walked straight in (#227).
|
|
415
|
+
*/
|
|
400
416
|
export async function acquireLock(lockPath) {
|
|
401
417
|
const startedAt = Date.now();
|
|
418
|
+
const token = `${process.pid}.${randomBytes(8).toString("hex")}`;
|
|
419
|
+
let brokeStale;
|
|
402
420
|
while (true) {
|
|
403
421
|
try {
|
|
404
422
|
const handle = await open(lockPath, "wx");
|
|
405
423
|
try {
|
|
406
|
-
await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`, "utf8");
|
|
424
|
+
await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n${token}\n`, "utf8");
|
|
407
425
|
}
|
|
408
426
|
catch (error) {
|
|
409
427
|
// A non-EEXIST write failure must not leak the descriptor the
|
|
@@ -413,9 +431,8 @@ export async function acquireLock(lockPath) {
|
|
|
413
431
|
}
|
|
414
432
|
await handle.close();
|
|
415
433
|
return {
|
|
416
|
-
release:
|
|
417
|
-
|
|
418
|
-
},
|
|
434
|
+
release: () => releaseOwnedLock(lockPath, token),
|
|
435
|
+
...(brokeStale && { brokeStale }),
|
|
419
436
|
};
|
|
420
437
|
}
|
|
421
438
|
catch (error) {
|
|
@@ -425,6 +442,7 @@ export async function acquireLock(lockPath) {
|
|
|
425
442
|
try {
|
|
426
443
|
const lockStat = await stat(lockPath);
|
|
427
444
|
if (Date.now() - lockStat.mtimeMs > STALE_LOCK_MS) {
|
|
445
|
+
brokeStale = await describeLockHolder(lockPath);
|
|
428
446
|
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
429
447
|
continue;
|
|
430
448
|
}
|
|
@@ -439,3 +457,31 @@ export async function acquireLock(lockPath) {
|
|
|
439
457
|
}
|
|
440
458
|
}
|
|
441
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* Remove the lock at `lockPath` only if it is still ours. A lock that vanished
|
|
462
|
+
* (someone broke it as stale) or that now carries another holder's token is
|
|
463
|
+
* left alone; one whose content cannot be read is left to go stale rather
|
|
464
|
+
* than removed unverified.
|
|
465
|
+
*/
|
|
466
|
+
async function releaseOwnedLock(lockPath, token) {
|
|
467
|
+
let content;
|
|
468
|
+
try {
|
|
469
|
+
content = await readFile(lockPath, "utf8");
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (content.split(/\r?\n/)[2] !== token)
|
|
475
|
+
return;
|
|
476
|
+
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
477
|
+
}
|
|
478
|
+
async function describeLockHolder(lockPath) {
|
|
479
|
+
try {
|
|
480
|
+
const [pidLine, sinceLine] = (await readFile(lockPath, "utf8")).split(/\r?\n/);
|
|
481
|
+
const pid = Number.parseInt(pidLine ?? "", 10);
|
|
482
|
+
return { pid: Number.isFinite(pid) ? pid : null, since: sinceLine?.trim() || null };
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
return { pid: null, since: null };
|
|
486
|
+
}
|
|
487
|
+
}
|
package/dist/core/synthesis.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { discoverLibrary, listEntries } from "./library.js";
|
|
7
|
-
import { loadCodecartoConfig } from "./orchestrator-config.js";
|
|
7
|
+
import { describeConfigProblems, loadCodecartoConfig } from "./orchestrator-config.js";
|
|
8
8
|
import { pathExists } from "./utils.js";
|
|
9
9
|
export const SYNTHESIS_PROPOSAL_PATH = "findings/goal-synthesis/proposal.md";
|
|
10
10
|
export const SYNTHESIS_VISION_INPUT_PATH = "inputs/vision.md";
|
|
@@ -88,7 +88,10 @@ export async function runPhasePreflight(state, phase) {
|
|
|
88
88
|
if (checks.has("requires-library")) {
|
|
89
89
|
const config = await loadCodecartoConfig(state.workspaceDir);
|
|
90
90
|
if (!config.library.path) {
|
|
91
|
-
|
|
91
|
+
// When a config file was dropped, that — not a missing key — is the
|
|
92
|
+
// likeliest reason there is no path; say so ahead of the example.
|
|
93
|
+
const problems = config.problems.length > 0 ? `${describeConfigProblems(config).join("\n")}\n` : "";
|
|
94
|
+
throw new PhasePreflightError(phase.id, `${problems}no library.path is configured. Create a library directory with a .codecarto-library marker file, then set library.path in ~/.codecarto/config.yaml or .codecarto/workflow/config.yaml. Example config:\n library:\n path: ~/codecarto-library\n publish_confirm: true`);
|
|
92
95
|
}
|
|
93
96
|
const marker = await discoverLibrary(config.library.path);
|
|
94
97
|
if (!marker) {
|
package/dist/core/usage.d.ts
CHANGED
|
@@ -44,6 +44,14 @@ export interface UsageTotals {
|
|
|
44
44
|
compactions: CompactionTelemetry;
|
|
45
45
|
}
|
|
46
46
|
export declare function loadUsage(workspaceDir: string): Promise<UsageFile>;
|
|
47
|
+
/**
|
|
48
|
+
* Append one run. The read-modify-write runs under the file's lock and lands
|
|
49
|
+
* through an atomic write, so concurrent appends (a Pi runner and an MCP host
|
|
50
|
+
* on one workspace, two phases finishing together) each keep their record
|
|
51
|
+
* (#226, #238). A log that exists but does not parse is never rewritten: the
|
|
52
|
+
* lenient {@link loadUsage} is for display, and appending over its empty
|
|
53
|
+
* fallback destroyed every record the file still held.
|
|
54
|
+
*/
|
|
47
55
|
export declare function appendUsageRun(workspaceDir: string, run: UsageRun): Promise<void>;
|
|
48
56
|
export declare function computeTotals(file: UsageFile): UsageTotals;
|
|
49
57
|
export declare function computePerPhaseTotals(file: UsageFile): Map<string, UsageTotals>;
|
package/dist/core/usage.js
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
// running, and phases run sequentially against this file, so a plain
|
|
8
8
|
// read-modify-write is safe enough. If parallel-phase dispatch ever ships,
|
|
9
9
|
// switch this to atomic-rename (see core/workspace.ts for the pattern).
|
|
10
|
-
import { readFile
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
-
import {
|
|
12
|
+
import { acquireLock } from "./status.js";
|
|
13
|
+
import { atomicWriteFile, pathExists } from "./utils.js";
|
|
13
14
|
import { parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
|
|
14
15
|
export const USAGE_RELATIVE_PATH = "workflow/.usage.local.yaml";
|
|
15
16
|
const SCHEMA_VERSION = 1;
|
|
@@ -29,13 +30,40 @@ export async function loadUsage(workspaceDir) {
|
|
|
29
30
|
return emptyUsage();
|
|
30
31
|
}
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Append one run. The read-modify-write runs under the file's lock and lands
|
|
35
|
+
* through an atomic write, so concurrent appends (a Pi runner and an MCP host
|
|
36
|
+
* on one workspace, two phases finishing together) each keep their record
|
|
37
|
+
* (#226, #238). A log that exists but does not parse is never rewritten: the
|
|
38
|
+
* lenient {@link loadUsage} is for display, and appending over its empty
|
|
39
|
+
* fallback destroyed every record the file still held.
|
|
40
|
+
*/
|
|
32
41
|
export async function appendUsageRun(workspaceDir, run) {
|
|
33
|
-
const current = await loadUsage(workspaceDir);
|
|
34
|
-
current.runs.push(run);
|
|
35
42
|
const path = join(workspaceDir, USAGE_RELATIVE_PATH);
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
const lock = await acquireLock(`${path}.lock`);
|
|
44
|
+
try {
|
|
45
|
+
const current = await loadUsageForAppend(path);
|
|
46
|
+
current.runs.push(run);
|
|
47
|
+
await atomicWriteFile(path, `${stringifySimpleYaml(current)}\n`);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await lock.release();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function loadUsageForAppend(path) {
|
|
54
|
+
if (!(await pathExists(path)))
|
|
55
|
+
return emptyUsage();
|
|
56
|
+
const raw = await readFile(path, "utf8");
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = parseSimpleYaml(raw);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
63
|
+
throw new Error(`Refusing to append to ${USAGE_RELATIVE_PATH}: the existing file does not parse (${reason}). ` +
|
|
64
|
+
"Its records are still in it — move the file aside to start a new log.");
|
|
65
|
+
}
|
|
66
|
+
return normalize(parsed);
|
|
39
67
|
}
|
|
40
68
|
export function computeTotals(file) {
|
|
41
69
|
const totals = emptyTotals();
|
package/dist/core/utils.d.ts
CHANGED
|
@@ -1,15 +1,42 @@
|
|
|
1
1
|
export declare function sleep(ms: number): Promise<void>;
|
|
2
2
|
export declare function pathExists(path: string): Promise<boolean>;
|
|
3
|
+
/**
|
|
4
|
+
* A temp-file suffix that is unique within and across processes: pid, a
|
|
5
|
+
* per-process sequence number, and random bytes. `<pid>.<Date.now()>` alone
|
|
6
|
+
* collides whenever two writers hit one target inside a millisecond, and the
|
|
7
|
+
* loser's rename then either fails with ENOENT or clobbers the winner (#226).
|
|
8
|
+
*/
|
|
9
|
+
export declare function uniqueTempSuffix(): string;
|
|
10
|
+
/**
|
|
11
|
+
* Write `content` to `path` atomically: a uniquely named sibling temp file,
|
|
12
|
+
* then a rename over the target. Readers see the old bytes or the new bytes,
|
|
13
|
+
* never a truncated file. On failure the temp file is removed best-effort and
|
|
14
|
+
* the error propagates. Every framework file that is rewritten in place goes
|
|
15
|
+
* through this so no caller hand-rolls the temp name.
|
|
16
|
+
*/
|
|
17
|
+
export declare function atomicWriteFile(path: string, content: string): Promise<void>;
|
|
3
18
|
export declare function canonicalPath(path: string): Promise<string>;
|
|
4
19
|
export declare function normalizeForComparison(path: string): string;
|
|
5
20
|
export declare function isWithinPath(path: string, root: string): boolean;
|
|
6
21
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
22
|
+
* Resolve a path the way the kernel will when something is written to it:
|
|
23
|
+
* every component that already exists is followed through symlinks
|
|
24
|
+
* (`realpath`), and the not-yet-existing tail is appended lexically. A `..`
|
|
25
|
+
* is applied to the *resolved* prefix, not the spelled one, because
|
|
26
|
+
* `link/..` means the link target's parent on disk. A relative `path` is
|
|
27
|
+
* taken against `base` without normalisation for the same reason.
|
|
10
28
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
29
|
+
* `realpath` alone throws for a file that does not exist yet, and a lexical
|
|
30
|
+
* fallback let `.codecarto/link/new.md` through when `link` was a symlink to
|
|
31
|
+
* somewhere outside the workspace — the file landed outside (#223).
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveExistingPrefix(path: string, base?: string): Promise<string>;
|
|
34
|
+
/**
|
|
35
|
+
* Symlink-aware version of isWithinPath for paths that may not exist yet:
|
|
36
|
+
* the existing prefix of `path` is resolved through symlinks
|
|
37
|
+
* ({@link resolveExistingPrefix}), the root through `realpath`, and the two
|
|
38
|
+
* are compared lexically. A symlinked ancestor that points outside the root
|
|
39
|
+
* fails whether or not the target file exists.
|
|
13
40
|
*/
|
|
14
41
|
export declare function isWithinPathResolved(path: string, root: string): Promise<boolean>;
|
|
15
42
|
export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
package/dist/core/utils.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// General-purpose helpers used by yaml/status/prompts and by wrapper-specific
|
|
2
2
|
// path-boundary enforcement (Pi tool interception, MCP cwd validation).
|
|
3
|
-
import {
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { access, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
4
5
|
import { constants } from "node:fs";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
|
-
import { join, normalize, resolve } from "node:path";
|
|
7
|
-
import { realpath } from "node:fs/promises";
|
|
7
|
+
import { dirname, isAbsolute, join, normalize, parse, resolve, sep } from "node:path";
|
|
8
8
|
export function sleep(ms) {
|
|
9
9
|
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
10
10
|
}
|
|
@@ -17,6 +17,35 @@ export async function pathExists(path) {
|
|
|
17
17
|
return false;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
let tempSequence = 0;
|
|
21
|
+
/**
|
|
22
|
+
* A temp-file suffix that is unique within and across processes: pid, a
|
|
23
|
+
* per-process sequence number, and random bytes. `<pid>.<Date.now()>` alone
|
|
24
|
+
* collides whenever two writers hit one target inside a millisecond, and the
|
|
25
|
+
* loser's rename then either fails with ENOENT or clobbers the winner (#226).
|
|
26
|
+
*/
|
|
27
|
+
export function uniqueTempSuffix() {
|
|
28
|
+
tempSequence = (tempSequence + 1) % 0x7fffffff;
|
|
29
|
+
return `${process.pid}.${tempSequence}.${randomBytes(4).toString("hex")}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Write `content` to `path` atomically: a uniquely named sibling temp file,
|
|
33
|
+
* then a rename over the target. Readers see the old bytes or the new bytes,
|
|
34
|
+
* never a truncated file. On failure the temp file is removed best-effort and
|
|
35
|
+
* the error propagates. Every framework file that is rewritten in place goes
|
|
36
|
+
* through this so no caller hand-rolls the temp name.
|
|
37
|
+
*/
|
|
38
|
+
export async function atomicWriteFile(path, content) {
|
|
39
|
+
const tempPath = `${path}.${uniqueTempSuffix()}.tmp`;
|
|
40
|
+
try {
|
|
41
|
+
await writeFile(tempPath, content, "utf8");
|
|
42
|
+
await rename(tempPath, path);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
await rm(tempPath, { force: true }).catch(() => undefined);
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
20
49
|
export async function canonicalPath(path) {
|
|
21
50
|
try {
|
|
22
51
|
return await realpath(path);
|
|
@@ -43,25 +72,58 @@ export function isWithinPath(path, root) {
|
|
|
43
72
|
return normalizedPath.startsWith(prefix);
|
|
44
73
|
}
|
|
45
74
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
75
|
+
* Resolve a path the way the kernel will when something is written to it:
|
|
76
|
+
* every component that already exists is followed through symlinks
|
|
77
|
+
* (`realpath`), and the not-yet-existing tail is appended lexically. A `..`
|
|
78
|
+
* is applied to the *resolved* prefix, not the spelled one, because
|
|
79
|
+
* `link/..` means the link target's parent on disk. A relative `path` is
|
|
80
|
+
* taken against `base` without normalisation for the same reason.
|
|
49
81
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
82
|
+
* `realpath` alone throws for a file that does not exist yet, and a lexical
|
|
83
|
+
* fallback let `.codecarto/link/new.md` through when `link` was a symlink to
|
|
84
|
+
* somewhere outside the workspace — the file landed outside (#223).
|
|
52
85
|
*/
|
|
53
|
-
export async function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
86
|
+
export async function resolveExistingPrefix(path, base = process.cwd()) {
|
|
87
|
+
const raw = isAbsolute(path) ? path : `${base}${sep}${path}`;
|
|
88
|
+
const { root } = parse(raw);
|
|
89
|
+
const segments = raw
|
|
90
|
+
.slice(root.length)
|
|
91
|
+
.split(/[\\/]+/)
|
|
92
|
+
.filter((segment) => segment !== "" && segment !== ".");
|
|
93
|
+
let current = await canonicalPath(root || sep);
|
|
94
|
+
const tail = [];
|
|
95
|
+
for (const segment of segments) {
|
|
96
|
+
if (segment === "..") {
|
|
97
|
+
if (tail.length > 0)
|
|
98
|
+
tail.pop();
|
|
99
|
+
else
|
|
100
|
+
current = dirname(current);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (tail.length > 0) {
|
|
104
|
+
// Once one component is missing, nothing below it can exist either.
|
|
105
|
+
tail.push(segment);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
current = await realpath(join(current, segment));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
tail.push(segment);
|
|
113
|
+
}
|
|
64
114
|
}
|
|
115
|
+
return tail.length === 0 ? current : join(current, ...tail);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Symlink-aware version of isWithinPath for paths that may not exist yet:
|
|
119
|
+
* the existing prefix of `path` is resolved through symlinks
|
|
120
|
+
* ({@link resolveExistingPrefix}), the root through `realpath`, and the two
|
|
121
|
+
* are compared lexically. A symlinked ancestor that points outside the root
|
|
122
|
+
* fails whether or not the target file exists.
|
|
123
|
+
*/
|
|
124
|
+
export async function isWithinPathResolved(path, root) {
|
|
125
|
+
const [resolvedPath, resolvedRoot] = await Promise.all([resolveExistingPrefix(path), canonicalPath(root)]);
|
|
126
|
+
return isWithinPath(resolvedPath, resolvedRoot);
|
|
65
127
|
}
|
|
66
128
|
export function isPlainObject(value) {
|
|
67
129
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|