pi-crew 0.2.3 → 0.2.4
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/AGENTS.md +57 -32
- package/CHANGELOG.md +466 -448
- package/LICENSE +21 -21
- package/NOTICE.md +16 -16
- package/README.md +323 -323
- package/docs/FEATURE_INTAKE.md +126 -0
- package/docs/HARNESS.md +86 -0
- package/docs/HARNESS_BACKLOG.md +41 -0
- package/docs/TEST_MATRIX.md +49 -0
- package/docs/actions-reference.md +595 -595
- package/docs/architecture.md +180 -180
- package/docs/code-review-2026-05-11.md +592 -592
- package/docs/commands-reference.md +347 -347
- package/docs/comparison-pi-subagents-vs-pi-crew.md +303 -0
- package/docs/decisions/0001-durable-state.md +41 -0
- package/docs/decisions/0002-child-process-for-async.md +42 -0
- package/docs/decisions/0003-depth-guard.md +36 -0
- package/docs/decisions/0004-execfile-over-exec.md +34 -0
- package/docs/decisions/0005-no-parameter-properties.md +49 -0
- package/docs/decisions/0006-publish-bundled-esm.md +63 -0
- package/docs/decisions/0007-active-run-binary-index.md +54 -0
- package/docs/decisions/0008-child-pi-warm-pool.md +61 -0
- package/docs/decisions/README.md +23 -0
- package/docs/followup-review-round4-2026-05-13.md +107 -0
- package/docs/implementation-plan-top3.md +333 -0
- package/docs/live-mailbox-runtime.md +36 -36
- package/docs/next-upgrade-roadmap.md +808 -808
- package/docs/oh-my-pi-research.md +509 -0
- package/docs/perf/baseline-2026-05.md +113 -0
- package/docs/perf/final-report-2026-05.md +206 -0
- package/docs/perf/sprint-1-report.md +71 -0
- package/docs/perf/sprint-2-report.md +81 -0
- package/docs/perf/sprint-2.5-report.md +53 -0
- package/docs/perf/sprint-3-report.md +36 -0
- package/docs/perf/sprint-4-report.md +47 -0
- package/docs/perf/sprint-5-report.md +51 -0
- package/docs/perf/sprint-6-report.md +94 -0
- package/docs/perf/sprint-7-report.md +74 -0
- package/docs/perf/upgrade-plan-2026-05.md +147 -0
- package/docs/pi-subagents3-deep-analysis.md +508 -0
- package/docs/product/README.md +31 -0
- package/docs/product/platform.md +27 -0
- package/docs/product/runtime-safety.md +37 -0
- package/docs/product/team-run.md +39 -0
- package/docs/product/team-tool.md +37 -0
- package/docs/publishing.md +65 -65
- package/docs/resource-formats.md +134 -134
- package/docs/runtime-analysis-child-vs-live.md +171 -0
- package/docs/runtime-flow.md +148 -148
- package/docs/runtime-migration-in-process-analysis.md +250 -0
- package/docs/stories/README.md +30 -0
- package/docs/stories/backlog.md +36 -0
- package/docs/templates/decision.md +27 -0
- package/docs/templates/story.md +44 -0
- package/docs/templates/validation-report.md +32 -0
- package/docs/usage.md +238 -238
- package/index.ts +7 -6
- package/install.mjs +65 -65
- package/package.json +107 -100
- package/schema.json +222 -222
- package/skills/child-pi-spawning/SKILL.md +213 -0
- package/skills/context-artifact-hygiene/SKILL.md +32 -0
- package/skills/event-log-tracing/SKILL.md +299 -0
- package/skills/git-master/SKILL.md +225 -24
- package/skills/live-agent-lifecycle/SKILL.md +192 -0
- package/skills/mailbox-interactive/SKILL.md +300 -19
- package/skills/model-routing-context/SKILL.md +94 -0
- package/skills/multi-perspective-review/SKILL.md +88 -0
- package/skills/read-only-explorer/SKILL.md +250 -26
- package/skills/safe-bash/SKILL.md +307 -21
- package/skills/verification-before-done/SKILL.md +11 -2
- package/skills/widget-rendering/SKILL.md +258 -0
- package/skills/workspace-isolation/SKILL.md +202 -0
- package/skills/worktree-isolation/SKILL.md +202 -18
- package/src/adapters/claude-adapter.ts +25 -25
- package/src/adapters/codex-adapter.ts +21 -21
- package/src/adapters/cursor-adapter.ts +17 -17
- package/src/adapters/export-util.ts +137 -137
- package/src/adapters/index.ts +15 -15
- package/src/adapters/registry.ts +18 -18
- package/src/adapters/types.ts +23 -23
- package/src/agents/agent-config.ts +38 -38
- package/src/agents/agent-serializer.ts +38 -38
- package/src/agents/discover-agents.ts +121 -118
- package/src/config/config.ts +740 -858
- package/src/config/defaults.ts +96 -96
- package/src/config/drift-detector.ts +211 -211
- package/src/config/markers.ts +327 -327
- package/src/config/resilient-parser.ts +109 -108
- package/src/config/suggestions.ts +74 -74
- package/src/config/types.ts +199 -0
- package/src/extension/async-notifier.ts +123 -89
- package/src/extension/autonomous-policy.ts +169 -169
- package/src/extension/cross-extension-rpc.ts +104 -104
- package/src/extension/help.ts +47 -47
- package/src/extension/import-index.ts +69 -69
- package/src/extension/management.ts +395 -382
- package/src/extension/notification-router.ts +116 -116
- package/src/extension/notification-sink.ts +51 -51
- package/src/extension/project-init.ts +168 -168
- package/src/extension/register.ts +859 -668
- package/src/extension/registration/artifact-cleanup.ts +15 -15
- package/src/extension/registration/command-utils.ts +54 -54
- package/src/extension/registration/commands.ts +559 -452
- package/src/extension/registration/compaction-guard.ts +125 -125
- package/src/extension/registration/subagent-helpers.ts +102 -102
- package/src/extension/registration/subagent-tools.ts +220 -159
- package/src/extension/registration/team-tool.ts +159 -99
- package/src/extension/registration/viewers.ts +29 -0
- package/src/extension/result-watcher.ts +128 -128
- package/src/extension/run-bundle-schema.ts +89 -89
- package/src/extension/run-export.ts +73 -73
- package/src/extension/run-import.ts +84 -84
- package/src/extension/run-index.ts +94 -94
- package/src/extension/run-maintenance.ts +142 -142
- package/src/extension/session-summary.ts +8 -8
- package/src/extension/team-manager-command.ts +96 -96
- package/src/extension/team-recommendation.ts +188 -188
- package/src/extension/team-tool/api.ts +5 -2
- package/src/extension/team-tool/cancel.ts +224 -209
- package/src/extension/team-tool/config-patch.ts +36 -36
- package/src/extension/team-tool/context.ts +60 -60
- package/src/extension/team-tool/doctor.ts +242 -242
- package/src/extension/team-tool/handle-settings.ts +421 -195
- package/src/extension/team-tool/inspect.ts +41 -41
- package/src/extension/team-tool/lifecycle-actions.ts +139 -139
- package/src/extension/team-tool/parallel-dispatch.ts +156 -156
- package/src/extension/team-tool/plan.ts +19 -19
- package/src/extension/team-tool/respond.ts +112 -111
- package/src/extension/team-tool/run.ts +246 -229
- package/src/extension/team-tool/status.ts +110 -110
- package/src/extension/team-tool-types.ts +13 -13
- package/src/extension/team-tool.ts +344 -344
- package/src/extension/tool-result.ts +16 -16
- package/src/extension/validate-resources.ts +77 -77
- package/src/hooks/registry.ts +61 -61
- package/src/hooks/types.ts +40 -40
- package/src/i18n.ts +184 -184
- package/src/observability/correlation.ts +35 -35
- package/src/observability/event-to-metric.ts +68 -68
- package/src/observability/exporters/adapter.ts +30 -30
- package/src/observability/exporters/otlp-exporter.ts +106 -92
- package/src/observability/exporters/prometheus-exporter.ts +54 -54
- package/src/observability/metric-registry.ts +87 -87
- package/src/observability/metric-retention.ts +54 -54
- package/src/observability/metric-sink.ts +81 -56
- package/src/observability/metrics-primitives.ts +167 -167
- package/src/prompt/prompt-runtime.ts +72 -72
- package/src/runtime/adaptive-plan.ts +338 -0
- package/src/runtime/agent-control.ts +169 -169
- package/src/runtime/agent-memory.ts +72 -72
- package/src/runtime/agent-observability.ts +114 -114
- package/src/runtime/async-marker.ts +26 -26
- package/src/runtime/async-runner.ts +153 -153
- package/src/runtime/attention-events.ts +28 -28
- package/src/runtime/auto-resume.ts +100 -100
- package/src/runtime/background-runner.ts +122 -89
- package/src/runtime/cancellation.ts +61 -61
- package/src/runtime/capability-inventory.ts +116 -116
- package/src/runtime/child-pi-pool.ts +68 -0
- package/src/runtime/child-pi.ts +541 -461
- package/src/runtime/code-summary.ts +247 -247
- package/src/runtime/compaction-summary.ts +271 -271
- package/src/runtime/concurrency.ts +58 -58
- package/src/runtime/crash-recovery.ts +317 -301
- package/src/runtime/crew-agent-records.ts +379 -281
- package/src/runtime/crew-agent-runtime.ts +60 -60
- package/src/runtime/cross-extension-rpc.ts +72 -0
- package/src/runtime/custom-tools/irc-tool.ts +201 -201
- package/src/runtime/custom-tools/submit-result-tool.ts +90 -90
- package/src/runtime/deadletter.ts +47 -47
- package/src/runtime/delivery-coordinator.ts +176 -176
- package/src/runtime/delta-conflict.ts +360 -360
- package/src/runtime/diagnostic-export.ts +102 -102
- package/src/runtime/direct-run.ts +35 -35
- package/src/runtime/effectiveness.ts +82 -81
- package/src/runtime/errors/crew-errors.ts +166 -0
- package/src/runtime/event-stream-bridge.ts +92 -92
- package/src/runtime/foreground-control.ts +82 -82
- package/src/runtime/green-contract.ts +46 -46
- package/src/runtime/group-join.ts +234 -106
- package/src/runtime/heartbeat-watcher.ts +145 -124
- package/src/runtime/iteration-hooks.ts +267 -267
- package/src/runtime/live-agent-control.ts +88 -88
- package/src/runtime/live-agent-manager.ts +377 -179
- package/src/runtime/live-control-realtime.ts +36 -36
- package/src/runtime/live-session-runtime.ts +676 -600
- package/src/runtime/loop-gates.ts +129 -129
- package/src/runtime/manifest-cache.ts +263 -263
- package/src/runtime/mcp-proxy.ts +113 -113
- package/src/runtime/metric-parser.ts +40 -40
- package/src/runtime/model-fallback.ts +282 -274
- package/src/runtime/model-resolver.ts +118 -0
- package/src/runtime/output-validator.ts +187 -187
- package/src/runtime/overflow-recovery.ts +175 -175
- package/src/runtime/parallel-research.ts +44 -44
- package/src/runtime/parallel-utils.ts +156 -156
- package/src/runtime/parent-guard.ts +80 -80
- package/src/runtime/phase-progress.ts +217 -217
- package/src/runtime/pi-args.ts +165 -165
- package/src/runtime/pi-json-output.ts +111 -111
- package/src/runtime/pi-spawn.ts +167 -167
- package/src/runtime/policy-engine.ts +79 -79
- package/src/runtime/post-checks.ts +125 -125
- package/src/runtime/post-exit-stdio-guard.ts +86 -86
- package/src/runtime/process-status.ts +97 -73
- package/src/runtime/progress-event-coalescer.ts +43 -43
- package/src/runtime/recovery-recipes.ts +74 -74
- package/src/runtime/retry-executor.ts +81 -81
- package/src/runtime/role-permission.ts +39 -39
- package/src/runtime/run-tracker.ts +99 -0
- package/src/runtime/runtime-policy.ts +21 -0
- package/src/runtime/runtime-resolver.ts +94 -91
- package/src/runtime/scheduler.ts +294 -0
- package/src/runtime/semaphore.ts +131 -131
- package/src/runtime/sensitive-paths.ts +92 -92
- package/src/runtime/session-usage.ts +79 -79
- package/src/runtime/settings-store.ts +103 -0
- package/src/runtime/sidechain-output.ts +29 -29
- package/src/runtime/skill-instructions.ts +222 -222
- package/src/runtime/stale-reconciler.ts +198 -189
- package/src/runtime/streaming-output.ts +47 -0
- package/src/runtime/subagent-manager.ts +404 -400
- package/src/runtime/subprocess-tool-registry.ts +67 -67
- package/src/runtime/task-display.ts +38 -38
- package/src/runtime/task-graph-scheduler.ts +122 -122
- package/src/runtime/task-graph.ts +207 -207
- package/src/runtime/task-output-context.ts +177 -177
- package/src/runtime/task-packet.ts +93 -93
- package/src/runtime/task-quality.ts +207 -207
- package/src/runtime/task-runner/capabilities.ts +78 -78
- package/src/runtime/task-runner/live-executor.ts +131 -113
- package/src/runtime/task-runner/progress.ts +119 -119
- package/src/runtime/task-runner/prompt-builder.ts +139 -139
- package/src/runtime/task-runner/prompt-pipeline.ts +64 -64
- package/src/runtime/task-runner/result-utils.ts +14 -14
- package/src/runtime/task-runner/run-projection.ts +103 -103
- package/src/runtime/task-runner/state-helpers.ts +22 -22
- package/src/runtime/task-runner.ts +469 -459
- package/src/runtime/team-runner.ts +693 -945
- package/src/runtime/usage-tracker.ts +71 -0
- package/src/runtime/worker-heartbeat.ts +21 -21
- package/src/runtime/worker-startup.ts +57 -57
- package/src/runtime/workflow-state.ts +187 -187
- package/src/runtime/yield-handler.ts +190 -190
- package/src/schema/config-schema.ts +172 -168
- package/src/schema/team-tool-schema.ts +126 -126
- package/src/schema/validation-types.ts +151 -148
- package/src/skills/discover-skills.ts +67 -67
- package/src/skills/skill-templates.ts +374 -374
- package/src/state/active-run-registry.ts +227 -191
- package/src/state/artifact-store.ts +130 -129
- package/src/state/atomic-write.ts +262 -195
- package/src/state/blob-store.ts +116 -116
- package/src/state/contracts.ts +111 -111
- package/src/state/event-log-rotation.ts +161 -158
- package/src/state/event-log.ts +383 -303
- package/src/state/event-reconstructor.ts +217 -217
- package/src/state/jsonl-writer.ts +82 -82
- package/src/state/locks.ts +146 -146
- package/src/state/mailbox.ts +446 -405
- package/src/state/state-store.ts +364 -351
- package/src/state/task-claims.ts +44 -44
- package/src/state/types.ts +285 -285
- package/src/state/usage.ts +29 -29
- package/src/subagents/async-entry.ts +1 -1
- package/src/subagents/index.ts +3 -3
- package/src/subagents/live/control.ts +1 -1
- package/src/subagents/live/manager.ts +1 -1
- package/src/subagents/live/realtime.ts +1 -1
- package/src/subagents/live/session-runtime.ts +1 -1
- package/src/subagents/manager.ts +1 -1
- package/src/subagents/spawn.ts +1 -1
- package/src/teams/discover-teams.ts +116 -116
- package/src/teams/team-config.ts +27 -27
- package/src/teams/team-serializer.ts +38 -38
- package/src/types/diff.d.ts +18 -18
- package/src/ui/agent-management-overlay.ts +144 -144
- package/src/ui/crew-widget.ts +487 -370
- package/src/ui/dashboard-panes/agents-pane.ts +109 -28
- package/src/ui/dashboard-panes/cancellation-pane.ts +42 -42
- package/src/ui/dashboard-panes/capability-pane.ts +59 -59
- package/src/ui/dashboard-panes/health-pane.ts +30 -30
- package/src/ui/dashboard-panes/mailbox-pane.ts +35 -35
- package/src/ui/dashboard-panes/progress-pane.ts +30 -30
- package/src/ui/dashboard-panes/transcript-pane.ts +10 -10
- package/src/ui/heartbeat-aggregator.ts +63 -63
- package/src/ui/keybinding-map.ts +97 -94
- package/src/ui/live-conversation-overlay.ts +152 -0
- package/src/ui/live-run-sidebar.ts +180 -180
- package/src/ui/mascot.ts +442 -442
- package/src/ui/overlays/agent-picker-overlay.ts +57 -57
- package/src/ui/overlays/confirm-overlay.ts +58 -58
- package/src/ui/overlays/mailbox-compose-overlay.ts +144 -144
- package/src/ui/overlays/mailbox-compose-preview.ts +63 -63
- package/src/ui/overlays/mailbox-detail-overlay.ts +122 -122
- package/src/ui/pi-ui-compat.ts +57 -57
- package/src/ui/powerbar-publisher.ts +221 -197
- package/src/ui/render-scheduler.ts +216 -143
- package/src/ui/run-action-dispatcher.ts +118 -118
- package/src/ui/run-dashboard.ts +526 -464
- package/src/ui/run-event-bus.ts +208 -208
- package/src/ui/run-snapshot-cache.ts +826 -777
- package/src/ui/settings-overlay.ts +721 -0
- package/src/ui/snapshot-types.ts +86 -70
- package/src/ui/theme-adapter.ts +190 -190
- package/src/ui/tool-progress-formatter.ts +89 -0
- package/src/ui/transcript-cache.ts +94 -94
- package/src/ui/transcript-viewer.ts +335 -335
- package/src/utils/conflict-detect.ts +662 -0
- package/src/utils/file-coalescer.ts +86 -86
- package/src/utils/frontmatter.ts +68 -68
- package/src/utils/fs-watch.ts +88 -31
- package/src/utils/gh-protocol.ts +479 -0
- package/src/utils/ids.ts +17 -17
- package/src/utils/incremental-reader.ts +104 -104
- package/src/utils/internal-error.ts +6 -6
- package/src/utils/names.ts +27 -27
- package/src/utils/paths.ts +102 -63
- package/src/utils/redaction.ts +44 -44
- package/src/utils/safe-paths.ts +47 -47
- package/src/utils/scan-cache.ts +136 -136
- package/src/utils/sse-parser.ts +134 -134
- package/src/utils/task-name-generator.ts +337 -337
- package/src/utils/timings.ts +33 -33
- package/src/utils/visual.ts +243 -198
- package/src/workflows/discover-workflows.ts +139 -139
- package/src/workflows/validate-workflow.ts +40 -40
- package/src/workflows/workflow-config.ts +26 -26
- package/src/workflows/workflow-serializer.ts +32 -32
- package/src/worktree/branch-freshness.ts +45 -45
- package/src/worktree/cleanup.ts +75 -75
- package/src/worktree/worktree-manager.ts +188 -188
- package/teams/default.team.md +12 -12
- package/teams/fast-fix.team.md +11 -11
- package/teams/implementation.team.md +18 -18
- package/teams/parallel-research.team.md +14 -14
- package/teams/research.team.md +11 -11
- package/teams/review.team.md +12 -12
- package/tsconfig.json +19 -19
- package/workflows/default.workflow.md +30 -30
- package/workflows/fast-fix.workflow.md +23 -23
- package/workflows/implementation.workflow.md +43 -43
- package/workflows/parallel-research.workflow.md +46 -46
- package/workflows/research.workflow.md +22 -22
- package/workflows/review.workflow.md +30 -30
- package/skills/task-packet/SKILL.md +0 -28
- package/skills/verify-evidence/SKILL.md +0 -27
|
@@ -1,195 +1,262 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
import * as path from "node:path";
|
|
3
|
-
import { logInternalError } from "../utils/internal-error.ts";
|
|
4
|
-
import { sleepSync } from "../utils/sleep.ts";
|
|
5
|
-
|
|
6
|
-
const RETRYABLE_RENAME_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Symlink-safe file write guard (caveman-inspired).
|
|
10
|
-
* Returns true if the path is safe to write, false if it's a symlink or
|
|
11
|
-
* inside a symlinked directory owned by another user.
|
|
12
|
-
*/
|
|
13
|
-
function isSymlinkSafePath(filePath: string): boolean {
|
|
14
|
-
try {
|
|
15
|
-
const dir = path.dirname(filePath);
|
|
16
|
-
// Check if parent directory is a symlink
|
|
17
|
-
try {
|
|
18
|
-
const dirStat = fs.lstatSync(dir);
|
|
19
|
-
if (dirStat.isSymbolicLink()) {
|
|
20
|
-
// Resolve and verify ownership on Unix
|
|
21
|
-
const realDir = fs.realpathSync(dir);
|
|
22
|
-
const realStat = fs.statSync(realDir);
|
|
23
|
-
if (!realStat.isDirectory()) return false;
|
|
24
|
-
if (typeof process.getuid === "function" && realStat.uid !== process.getuid()) return false;
|
|
25
|
-
}
|
|
26
|
-
} catch {
|
|
27
|
-
// Directory doesn't exist yet — that's OK, mkdirSync will create it
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Check if target file itself is a symlink
|
|
31
|
-
try {
|
|
32
|
-
const fileStat = fs.lstatSync(filePath);
|
|
33
|
-
if (fileStat.isSymbolicLink()) return false;
|
|
34
|
-
} catch {
|
|
35
|
-
// File doesn't exist yet — that's OK
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return true;
|
|
39
|
-
} catch {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
function sleep(ms: number): Promise<void> {
|
|
47
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function isRetryableRenameError(error: unknown): boolean {
|
|
51
|
-
return Boolean(error && typeof error === "object" && "code" in error && RETRYABLE_RENAME_CODES.has(String((error as NodeJS.ErrnoException).code)));
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function renameWithRetry(tempPath: string, filePath: string, retries =
|
|
55
|
-
let lastError: unknown;
|
|
56
|
-
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
57
|
-
try {
|
|
58
|
-
rename(tempPath, filePath);
|
|
59
|
-
return;
|
|
60
|
-
} catch (error) {
|
|
61
|
-
lastError = error;
|
|
62
|
-
if (!isRetryableRenameError(error) || attempt === retries) break;
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
fs.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
4
|
+
import { sleepSync } from "../utils/sleep.ts";
|
|
5
|
+
|
|
6
|
+
const RETRYABLE_RENAME_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Symlink-safe file write guard (caveman-inspired).
|
|
10
|
+
* Returns true if the path is safe to write, false if it's a symlink or
|
|
11
|
+
* inside a symlinked directory owned by another user.
|
|
12
|
+
*/
|
|
13
|
+
function isSymlinkSafePath(filePath: string): boolean {
|
|
14
|
+
try {
|
|
15
|
+
const dir = path.dirname(filePath);
|
|
16
|
+
// Check if parent directory is a symlink
|
|
17
|
+
try {
|
|
18
|
+
const dirStat = fs.lstatSync(dir);
|
|
19
|
+
if (dirStat.isSymbolicLink()) {
|
|
20
|
+
// Resolve and verify ownership on Unix
|
|
21
|
+
const realDir = fs.realpathSync(dir);
|
|
22
|
+
const realStat = fs.statSync(realDir);
|
|
23
|
+
if (!realStat.isDirectory()) return false;
|
|
24
|
+
if (typeof process.getuid === "function" && realStat.uid !== process.getuid()) return false;
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// Directory doesn't exist yet — that's OK, mkdirSync will create it
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Check if target file itself is a symlink
|
|
31
|
+
try {
|
|
32
|
+
const fileStat = fs.lstatSync(filePath);
|
|
33
|
+
if (fileStat.isSymbolicLink()) return false;
|
|
34
|
+
} catch {
|
|
35
|
+
// File doesn't exist yet — that's OK
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
function sleep(ms: number): Promise<void> {
|
|
47
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isRetryableRenameError(error: unknown): boolean {
|
|
51
|
+
return Boolean(error && typeof error === "object" && "code" in error && RETRYABLE_RENAME_CODES.has(String((error as NodeJS.ErrnoException).code)));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function renameWithRetry(tempPath: string, filePath: string, retries = 8, rename: (oldPath: string, newPath: string) => void = fs.renameSync): void {
|
|
55
|
+
let lastError: unknown;
|
|
56
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
57
|
+
try {
|
|
58
|
+
rename(tempPath, filePath);
|
|
59
|
+
return;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
lastError = error;
|
|
62
|
+
if (!isRetryableRenameError(error) || attempt === retries) break;
|
|
63
|
+
// 3.4: exponential backoff with ±20% jitter, capped at 500ms.
|
|
64
|
+
// Without jitter, multiple processes contending on the same file
|
|
65
|
+
// retry in lockstep and starve each other.
|
|
66
|
+
const base = Math.min(500, 10 * 2 ** attempt);
|
|
67
|
+
const jitter = base * 0.2 * (Math.random() * 2 - 1);
|
|
68
|
+
sleepSync(Math.max(1, Math.round(base + jitter)));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
throw lastError;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Test alias for renameWithRetry. */
|
|
75
|
+
export const __test__renameWithRetry = renameWithRetry;
|
|
76
|
+
|
|
77
|
+
export async function renameWithRetryAsync(tempPath: string, filePath: string, retries = 8, rename: (oldPath: string, newPath: string) => Promise<void> = (source, destination) => fs.promises.rename(source, destination)): Promise<void> {
|
|
78
|
+
let lastError: unknown;
|
|
79
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
80
|
+
try {
|
|
81
|
+
await rename(tempPath, filePath);
|
|
82
|
+
return;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
lastError = error;
|
|
85
|
+
if (!isRetryableRenameError(error) || attempt === retries) break;
|
|
86
|
+
// 3.4: same jitter as renameWithRetry.
|
|
87
|
+
const base = Math.min(500, 10 * 2 ** attempt);
|
|
88
|
+
const jitter = base * 0.2 * (Math.random() * 2 - 1);
|
|
89
|
+
await sleep(Math.max(1, Math.round(base + jitter)));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw lastError;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Test alias for renameWithRetryAsync. */
|
|
96
|
+
export const __test__renameWithRetryAsync = renameWithRetryAsync;
|
|
97
|
+
|
|
98
|
+
export function atomicWriteFile(filePath: string, content: string): void {
|
|
99
|
+
if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
|
|
100
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
101
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
102
|
+
// Write temp with restrictive permissions
|
|
103
|
+
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
|
|
104
|
+
try {
|
|
105
|
+
const fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o644);
|
|
106
|
+
// Post-open verification: on Windows O_NOFOLLOW is 0, so verify FD is a regular file
|
|
107
|
+
const openedStat = fs.fstatSync(fd);
|
|
108
|
+
if (!openedStat.isFile()) {
|
|
109
|
+
fs.closeSync(fd);
|
|
110
|
+
throw new Error(`Refusing to write: opened path is not a regular file: ${tempPath}`);
|
|
111
|
+
}
|
|
112
|
+
fs.writeSync(fd, content, undefined, "utf-8");
|
|
113
|
+
fs.closeSync(fd);
|
|
114
|
+
try {
|
|
115
|
+
renameWithRetry(tempPath, filePath);
|
|
116
|
+
} catch (renameError) {
|
|
117
|
+
// Fallback: if rename fails (Windows EPERM/EBUSY), try direct write.
|
|
118
|
+
// This is less atomic but avoids data loss when concurrent writers contend.
|
|
119
|
+
try {
|
|
120
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
121
|
+
} catch {
|
|
122
|
+
throw renameError;
|
|
123
|
+
}
|
|
124
|
+
try { fs.rmSync(tempPath, { force: true }); } catch { /* best-effort */ }
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
let matches = false;
|
|
128
|
+
try {
|
|
129
|
+
const existing = fs.readFileSync(filePath, "utf-8");
|
|
130
|
+
matches = existing === content;
|
|
131
|
+
} catch {
|
|
132
|
+
/* ignore */
|
|
133
|
+
}
|
|
134
|
+
if (matches) {
|
|
135
|
+
try { fs.rmSync(tempPath, { force: true }); } catch { /* best-effort */ }
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
fs.rmSync(tempPath, { force: true });
|
|
140
|
+
} catch (cleanupError) {
|
|
141
|
+
logInternalError("atomic-write.cleanup", cleanupError, `tempPath=${tempPath}`);
|
|
142
|
+
}
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
export async function atomicWriteFileAsync(filePath: string, content: string): Promise<void> {
|
|
149
|
+
if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
|
|
150
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
151
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
152
|
+
try {
|
|
153
|
+
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
|
|
154
|
+
const fd = await fs.promises.open(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o644);
|
|
155
|
+
// Post-open verification: on Windows O_NOFOLLOW is 0, so verify FD is a regular file
|
|
156
|
+
const openedStat = await fd.stat();
|
|
157
|
+
if (!openedStat.isFile()) {
|
|
158
|
+
await fd.close();
|
|
159
|
+
throw new Error(`Refusing to write: opened path is not a regular file: ${tempPath}`);
|
|
160
|
+
}
|
|
161
|
+
await fd.writeFile(content, "utf-8");
|
|
162
|
+
await fd.close();
|
|
163
|
+
try {
|
|
164
|
+
await renameWithRetryAsync(tempPath, filePath);
|
|
165
|
+
} catch (renameError) {
|
|
166
|
+
let matches = false;
|
|
167
|
+
try {
|
|
168
|
+
const existing = await fs.promises.readFile(filePath, "utf-8");
|
|
169
|
+
matches = existing === content;
|
|
170
|
+
} catch {
|
|
171
|
+
/* ignore */
|
|
172
|
+
}
|
|
173
|
+
if (matches) {
|
|
174
|
+
try {
|
|
175
|
+
await fs.promises.rm(tempPath, { force: true });
|
|
176
|
+
} catch (cleanupError) {
|
|
177
|
+
logInternalError("atomic-write.cleanupAsync", cleanupError, `tempPath=${tempPath}`);
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
throw renameError;
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
try {
|
|
185
|
+
await fs.promises.rm(tempPath, { force: true });
|
|
186
|
+
} catch (cleanupError) {
|
|
187
|
+
logInternalError("atomic-write.cleanupAsync", cleanupError, `tempPath=${tempPath}`);
|
|
188
|
+
}
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
export function atomicWriteJson<T>(filePath: string, value: T): void {
|
|
195
|
+
atomicWriteFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function atomicWriteJsonAsync<T>(filePath: string, value: T): Promise<void> {
|
|
199
|
+
await atomicWriteFileAsync(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 2.1 — atomic-write coalescer. Buffer the latest payload per filePath and
|
|
203
|
+
// flush after `coalesceMs` ms (default 50). Multiple writes to the same
|
|
204
|
+
// path within the window collapse to one disk write (last value wins),
|
|
205
|
+
// which is exactly the semantic that team-runner.ts merge loops need for
|
|
206
|
+
// `saveRunTasks` and similar high-frequency state-store paths.
|
|
207
|
+
//
|
|
208
|
+
// Caveat: a `readJsonFile` call between buffer and flush sees the previous
|
|
209
|
+
// on-disk content. Callers that need read-after-write within the window
|
|
210
|
+
// must invoke `flushPendingAtomicWrites()` first (or pass through
|
|
211
|
+
// `atomicWriteJson` which flushes synchronously).
|
|
212
|
+
//
|
|
213
|
+
// Auto-flush hooks: process exit / SIGTERM / SIGINT, plus an exposed
|
|
214
|
+
// `flushPendingAtomicWrites()` for cleanupRuntime.
|
|
215
|
+
interface CoalescedAtomicWrite {
|
|
216
|
+
content: string;
|
|
217
|
+
timer: ReturnType<typeof setTimeout>;
|
|
218
|
+
}
|
|
219
|
+
const pendingAtomicWrites = new Map<string, CoalescedAtomicWrite>();
|
|
220
|
+
const DEFAULT_ATOMIC_COALESCE_MS = 50;
|
|
221
|
+
|
|
222
|
+
export function atomicWriteJsonCoalesced<T>(filePath: string, value: T, coalesceMs = DEFAULT_ATOMIC_COALESCE_MS): void {
|
|
223
|
+
const content = `${JSON.stringify(value, null, 2)}\n`;
|
|
224
|
+
const previous = pendingAtomicWrites.get(filePath);
|
|
225
|
+
if (previous) clearTimeout(previous.timer);
|
|
226
|
+
const timer = setTimeout(() => flushOnePendingAtomicWrite(filePath), coalesceMs);
|
|
227
|
+
timer.unref();
|
|
228
|
+
pendingAtomicWrites.set(filePath, { content, timer });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function flushOnePendingAtomicWrite(filePath: string): void {
|
|
232
|
+
const entry = pendingAtomicWrites.get(filePath);
|
|
233
|
+
if (!entry) return;
|
|
234
|
+
pendingAtomicWrites.delete(filePath);
|
|
235
|
+
clearTimeout(entry.timer);
|
|
236
|
+
try {
|
|
237
|
+
atomicWriteFile(filePath, entry.content);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
logInternalError("atomic-write.coalesced-flush", error, filePath);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Flush every queued coalesced write synchronously. Safe to call any time. */
|
|
244
|
+
export function flushPendingAtomicWrites(): void {
|
|
245
|
+
for (const filePath of [...pendingAtomicWrites.keys()]) flushOnePendingAtomicWrite(filePath);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
process.on("exit", () => flushPendingAtomicWrites());
|
|
249
|
+
process.on("SIGTERM", () => flushPendingAtomicWrites());
|
|
250
|
+
process.on("SIGINT", () => flushPendingAtomicWrites());
|
|
251
|
+
|
|
252
|
+
export function readJsonFile<T>(filePath: string): T | undefined {
|
|
253
|
+
try {
|
|
254
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
|
255
|
+
} catch (err) {
|
|
256
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
257
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
258
|
+
logInternalError("readJsonFile", err, `filePath=${filePath}`);
|
|
259
|
+
}
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
}
|