pi-crew 0.1.46 → 0.1.49
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/CHANGELOG.md +97 -0
- package/agents/analyst.md +11 -11
- package/agents/critic.md +11 -11
- package/agents/executor.md +11 -11
- package/agents/explorer.md +11 -11
- package/agents/planner.md +11 -11
- package/agents/reviewer.md +11 -11
- package/agents/security-reviewer.md +11 -11
- package/agents/test-engineer.md +11 -11
- package/agents/verifier.md +11 -11
- package/agents/writer.md +11 -11
- package/docs/next-upgrade-roadmap.md +117 -42
- package/docs/refactor-tasks-phase3.md +394 -394
- package/docs/refactor-tasks-phase4.md +564 -564
- package/docs/refactor-tasks-phase5.md +402 -402
- package/docs/refactor-tasks-phase6.md +662 -662
- package/docs/research/AGENT-EXECUTION-ARCHITECTURE.md +261 -0
- package/docs/research/AGENT-LIFECYCLE-COMPARISON.md +111 -0
- package/docs/research/AUDIT_OH_MY_PI.md +261 -0
- package/docs/research/AUDIT_PI_CREW.md +457 -0
- package/docs/research/CAVEMAN-DEEP-RESEARCH.md +281 -0
- package/docs/research/COMPARISON_OH_MY_PI_VS_PI_CREW.md +264 -0
- package/docs/research/DEEP-RESEARCH-PI-POWERBAR.md +343 -0
- package/docs/research/DEEP_RESEARCH_SUBAGENT_ARCHITECTURE.md +480 -0
- package/docs/research/GAP_CLOSURE_IMPLEMENTATION_PLAN.md +354 -0
- package/docs/research/IMPLEMENTATION_PLAN.md +385 -0
- package/docs/research/LIVE-SESSION-PRODUCTION-READY-PLAN.md +502 -0
- package/docs/research/OH-MY-PI-DEEP-RESEARCH-v14.7.6.md +266 -0
- package/docs/research/REMAINING-GAPS-PLAN.md +363 -0
- package/docs/research/SESSION-SUMMARY-2026-05-08.md +146 -0
- package/docs/research/UI-RESPONSIVENESS-AUDIT.md +173 -0
- package/docs/research-awesome-agent-skills-distillation.md +100 -100
- package/docs/research-extension-examples.md +297 -297
- package/docs/research-extension-system.md +324 -324
- package/docs/research-oh-my-pi-distillation.md +56 -9
- package/docs/research-optimization-plan.md +548 -548
- package/docs/research-phase10-distillation.md +198 -198
- package/docs/research-phase11-distillation.md +201 -201
- package/docs/research-pi-coding-agent.md +357 -357
- package/docs/research-source-pi-crew-reference.md +174 -174
- package/docs/runtime-flow.md +148 -148
- package/docs/source-runtime-refactor-map.md +107 -107
- package/index.ts +6 -6
- package/package.json +99 -98
- package/schema.json +8 -0
- package/skills/async-worker-recovery/SKILL.md +42 -42
- package/skills/context-artifact-hygiene/SKILL.md +52 -52
- package/skills/delegation-patterns/SKILL.md +54 -54
- package/skills/mailbox-interactive/SKILL.md +40 -40
- package/skills/model-routing-context/SKILL.md +39 -39
- package/skills/multi-perspective-review/SKILL.md +58 -58
- package/skills/observability-reliability/SKILL.md +41 -41
- package/skills/orchestration/SKILL.md +157 -0
- package/skills/ownership-session-security/SKILL.md +41 -41
- package/skills/pi-extension-lifecycle/SKILL.md +39 -39
- package/skills/requirements-to-task-packet/SKILL.md +63 -63
- package/skills/resource-discovery-config/SKILL.md +41 -41
- package/skills/runtime-state-reader/SKILL.md +44 -44
- package/skills/secure-agent-orchestration-review/SKILL.md +45 -45
- package/skills/state-mutation-locking/SKILL.md +42 -42
- package/skills/systematic-debugging/SKILL.md +67 -67
- package/skills/ui-render-performance/SKILL.md +39 -39
- package/skills/verification-before-done/SKILL.md +57 -57
- package/skills/worktree-isolation/SKILL.md +39 -39
- package/src/agents/agent-config.ts +6 -0
- package/src/agents/agent-search.ts +98 -0
- package/src/agents/agent-serializer.ts +4 -0
- package/src/agents/discover-agents.ts +17 -4
- package/src/config/config.ts +24 -0
- package/src/config/defaults.ts +11 -0
- package/src/extension/autonomous-policy.ts +26 -33
- package/src/extension/cross-extension-rpc.ts +82 -82
- package/src/extension/help.ts +1 -0
- package/src/extension/management.ts +5 -0
- package/src/extension/register.ts +58 -13
- package/src/extension/registration/commands.ts +33 -1
- package/src/extension/registration/compaction-guard.ts +125 -125
- package/src/extension/registration/team-tool.ts +6 -4
- package/src/extension/run-bundle-schema.ts +89 -89
- package/src/extension/run-index.ts +24 -18
- package/src/extension/run-maintenance.ts +68 -62
- package/src/extension/team-tool/api.ts +23 -2
- package/src/extension/team-tool/cancel.ts +86 -11
- package/src/extension/team-tool/context.ts +3 -0
- package/src/extension/team-tool/handle-settings.ts +188 -188
- package/src/extension/team-tool/inspect.ts +41 -41
- package/src/extension/team-tool/intent-policy.ts +42 -0
- package/src/extension/team-tool/lifecycle-actions.ts +47 -18
- package/src/extension/team-tool/parallel-dispatch.ts +156 -0
- package/src/extension/team-tool/plan.ts +19 -19
- package/src/extension/team-tool/respond.ts +10 -2
- package/src/extension/team-tool/run.ts +3 -2
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool-types.ts +1 -0
- package/src/extension/team-tool.ts +13 -3
- package/src/hooks/registry.ts +61 -0
- package/src/hooks/types.ts +41 -0
- package/src/i18n.ts +184 -184
- package/src/observability/exporters/otlp-exporter.ts +77 -77
- package/src/prompt/prompt-runtime.ts +72 -72
- package/src/runtime/agent-control.ts +108 -2
- 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 +3 -1
- package/src/runtime/attention-events.ts +28 -28
- package/src/runtime/background-runner.ts +19 -0
- package/src/runtime/cancellation-token.ts +89 -0
- package/src/runtime/cancellation.ts +61 -51
- package/src/runtime/capability-inventory.ts +116 -0
- package/src/runtime/child-pi.ts +2 -1
- package/src/runtime/code-summary.ts +247 -0
- package/src/runtime/completion-guard.ts +190 -190
- package/src/runtime/crash-recovery.ts +181 -0
- package/src/runtime/crew-agent-records.ts +35 -7
- package/src/runtime/crew-agent-runtime.ts +1 -0
- package/src/runtime/custom-tools/irc-tool.ts +201 -0
- package/src/runtime/custom-tools/submit-result-tool.ts +90 -0
- package/src/runtime/delivery-coordinator.ts +3 -1
- package/src/runtime/direct-run.ts +35 -35
- package/src/runtime/effectiveness.ts +81 -76
- package/src/runtime/event-stream-bridge.ts +90 -0
- package/src/runtime/foreground-control.ts +82 -82
- package/src/runtime/green-contract.ts +46 -46
- package/src/runtime/group-join.ts +106 -106
- package/src/runtime/heartbeat-gradient.ts +28 -28
- package/src/runtime/heartbeat-watcher.ts +124 -124
- package/src/runtime/live-agent-control.ts +88 -88
- package/src/runtime/live-agent-manager.ts +78 -2
- package/src/runtime/live-control-realtime.ts +36 -36
- package/src/runtime/live-extension-bridge.ts +150 -0
- package/src/runtime/live-irc.ts +92 -0
- package/src/runtime/live-session-health.ts +100 -0
- package/src/runtime/live-session-runtime.ts +297 -7
- package/src/runtime/mcp-proxy.ts +113 -0
- package/src/runtime/notebook-helpers.ts +90 -0
- package/src/runtime/orphan-sentinel.ts +7 -0
- package/src/runtime/output-validator.ts +187 -0
- package/src/runtime/parallel-research.ts +44 -44
- package/src/runtime/parallel-utils.ts +57 -0
- package/src/runtime/parent-guard.ts +80 -0
- package/src/runtime/pi-json-output.ts +111 -111
- package/src/runtime/policy-engine.ts +79 -79
- package/src/runtime/progress-event-coalescer.ts +43 -43
- package/src/runtime/prose-compressor.ts +164 -0
- package/src/runtime/recovery-recipes.ts +74 -74
- package/src/runtime/result-extractor.ts +121 -0
- package/src/runtime/role-permission.ts +39 -39
- package/src/runtime/runtime-resolver.ts +1 -4
- package/src/runtime/semaphore.ts +131 -0
- package/src/runtime/sensitive-paths.ts +92 -0
- package/src/runtime/session-resources.ts +25 -25
- package/src/runtime/session-snapshot.ts +59 -59
- package/src/runtime/session-usage.ts +79 -79
- package/src/runtime/sidechain-output.ts +29 -29
- package/src/runtime/stream-preview.ts +177 -0
- package/src/runtime/subagent-manager.ts +3 -2
- package/src/runtime/subprocess-tool-registry.ts +67 -0
- package/src/runtime/supervisor-contact.ts +59 -59
- package/src/runtime/task-display.ts +38 -38
- package/src/runtime/task-output-context.ts +59 -9
- package/src/runtime/task-runner/capabilities.ts +78 -78
- package/src/runtime/task-runner/live-executor.ts +2 -0
- package/src/runtime/task-runner/progress.ts +119 -119
- package/src/runtime/task-runner/prompt-builder.ts +70 -8
- 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 +104 -0
- package/src/runtime/task-runner/state-helpers.ts +22 -22
- package/src/runtime/task-runner.ts +75 -4
- package/src/runtime/team-runner.ts +60 -8
- package/src/runtime/worker-heartbeat.ts +21 -21
- package/src/runtime/worker-startup.ts +57 -57
- package/src/runtime/workspace-tree.ts +298 -0
- package/src/runtime/yield-handler.ts +189 -0
- package/src/schema/config-schema.ts +6 -0
- package/src/schema/team-tool-schema.ts +11 -1
- package/src/skills/discover-skills.ts +67 -0
- package/src/state/active-run-registry.ts +4 -2
- package/src/state/artifact-store.ts +4 -1
- package/src/state/atomic-write.ts +50 -1
- package/src/state/blob-store.ts +117 -0
- package/src/state/contracts.ts +1 -0
- package/src/state/event-log-rotation.ts +158 -0
- package/src/state/event-log.ts +52 -2
- package/src/state/mailbox.ts +87 -7
- package/src/state/state-store.ts +24 -4
- package/src/state/task-claims.ts +44 -44
- package/src/state/types.ts +20 -0
- 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/team-serializer.ts +38 -38
- package/src/types/diff.d.ts +18 -18
- package/src/ui/agent-management-overlay.ts +144 -0
- package/src/ui/crew-footer.ts +101 -101
- package/src/ui/crew-select-list.ts +111 -111
- package/src/ui/crew-widget.ts +11 -2
- package/src/ui/dashboard-panes/cancellation-pane.ts +43 -0
- package/src/ui/dashboard-panes/capability-pane.ts +60 -0
- package/src/ui/dashboard-panes/mailbox-pane.ts +35 -11
- package/src/ui/dashboard-panes/metrics-pane.ts +34 -34
- package/src/ui/dynamic-border.ts +25 -25
- package/src/ui/layout-primitives.ts +106 -106
- package/src/ui/live-run-sidebar.ts +4 -0
- package/src/ui/loaders.ts +158 -158
- package/src/ui/powerbar-publisher.ts +77 -15
- package/src/ui/render-coalescer.ts +51 -0
- package/src/ui/render-diff.ts +119 -119
- package/src/ui/render-scheduler.ts +143 -143
- package/src/ui/run-dashboard.ts +4 -0
- package/src/ui/run-event-bus.ts +209 -0
- package/src/ui/run-snapshot-cache.ts +68 -16
- package/src/ui/snapshot-types.ts +8 -0
- package/src/ui/spinner.ts +17 -17
- package/src/ui/status-colors.ts +58 -58
- package/src/ui/syntax-highlight.ts +116 -116
- package/src/ui/transcript-entries.ts +258 -0
- package/src/utils/atomic-write.ts +33 -33
- package/src/utils/completion-dedupe.ts +63 -63
- package/src/utils/frontmatter.ts +68 -68
- package/src/utils/git.ts +262 -262
- package/src/utils/ids.ts +17 -12
- package/src/utils/incremental-reader.ts +104 -0
- package/src/utils/names.ts +27 -27
- package/src/utils/redaction.ts +44 -44
- package/src/utils/safe-paths.ts +47 -47
- package/src/utils/scan-cache.ts +137 -0
- package/src/utils/sleep.ts +32 -32
- package/src/utils/sse-parser.ts +134 -0
- package/src/utils/task-name-generator.ts +337 -0
- package/src/utils/visual.ts +33 -2
- package/src/workflows/validate-workflow.ts +40 -40
- package/src/worktree/branch-freshness.ts +45 -45
- package/src/worktree/cleanup.ts +2 -1
- 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/workflows/default.workflow.md +29 -29
- package/workflows/fast-fix.workflow.md +22 -22
- package/workflows/implementation.workflow.md +38 -38
- package/workflows/parallel-research.workflow.md +46 -46
- package/workflows/research.workflow.md +22 -22
- package/workflows/review.workflow.md +30 -30
|
@@ -4,6 +4,49 @@ import { logInternalError } from "../utils/internal-error.ts";
|
|
|
4
4
|
|
|
5
5
|
const RETRYABLE_RENAME_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Symlink-safe file write guard (caveman-inspired).
|
|
9
|
+
* Returns true if the path is safe to write, false if it's a symlink or
|
|
10
|
+
* inside a symlinked directory owned by another user.
|
|
11
|
+
*/
|
|
12
|
+
function isSymlinkSafePath(filePath: string): boolean {
|
|
13
|
+
try {
|
|
14
|
+
const dir = path.dirname(filePath);
|
|
15
|
+
// Check if parent directory is a symlink
|
|
16
|
+
try {
|
|
17
|
+
const dirStat = fs.lstatSync(dir);
|
|
18
|
+
if (dirStat.isSymbolicLink()) {
|
|
19
|
+
// Resolve and verify ownership on Unix
|
|
20
|
+
const realDir = fs.realpathSync(dir);
|
|
21
|
+
const realStat = fs.statSync(realDir);
|
|
22
|
+
if (!realStat.isDirectory()) return false;
|
|
23
|
+
if (typeof process.getuid === "function" && realStat.uid !== process.getuid()) return false;
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// Directory doesn't exist yet — that's OK, mkdirSync will create it
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check if target file itself is a symlink
|
|
30
|
+
try {
|
|
31
|
+
const fileStat = fs.lstatSync(filePath);
|
|
32
|
+
if (fileStat.isSymbolicLink()) return false;
|
|
33
|
+
} catch {
|
|
34
|
+
// File doesn't exist yet — that's OK
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Synchronous sleep using Atomics.wait (non-busy) with busy-wait fallback.
|
|
45
|
+
*
|
|
46
|
+
* WARNING: This blocks the Node.js main thread. Only used in atomic-write
|
|
47
|
+
* rename retry path where sync I/O is required by the caller.
|
|
48
|
+
* NOT safe to call from Pi extension async code paths.
|
|
49
|
+
*/
|
|
7
50
|
function sleepSync(ms: number): void {
|
|
8
51
|
try {
|
|
9
52
|
const buffer = new SharedArrayBuffer(4);
|
|
@@ -56,10 +99,15 @@ export async function __test__renameWithRetryAsync(tempPath: string, filePath: s
|
|
|
56
99
|
}
|
|
57
100
|
|
|
58
101
|
export function atomicWriteFile(filePath: string, content: string): void {
|
|
102
|
+
if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
|
|
59
103
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
60
104
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
105
|
+
// Write temp with restrictive permissions
|
|
106
|
+
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
|
|
61
107
|
try {
|
|
62
|
-
fs.
|
|
108
|
+
const fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o644);
|
|
109
|
+
fs.writeSync(fd, content, undefined, "utf-8");
|
|
110
|
+
fs.closeSync(fd);
|
|
63
111
|
__test__renameWithRetry(tempPath, filePath);
|
|
64
112
|
} catch (error) {
|
|
65
113
|
try {
|
|
@@ -73,6 +121,7 @@ export function atomicWriteFile(filePath: string, content: string): void {
|
|
|
73
121
|
|
|
74
122
|
|
|
75
123
|
export async function atomicWriteFileAsync(filePath: string, content: string): Promise<void> {
|
|
124
|
+
if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
|
|
76
125
|
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
77
126
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
78
127
|
try {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
5
|
+
|
|
6
|
+
const SHA256_HEX = /^[a-f0-9]{64}$/i;
|
|
7
|
+
|
|
8
|
+
function validateBlobHash(hash: string): void {
|
|
9
|
+
if (!SHA256_HEX.test(hash)) throw new Error(`Invalid blob hash: ${hash}`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const BLOBS_DIR = "blobs";
|
|
13
|
+
const BLOB_META_DIR = "blob-metadata";
|
|
14
|
+
const SHA256_PREFIX = "sha256";
|
|
15
|
+
|
|
16
|
+
export interface BlobMetadata {
|
|
17
|
+
blobHash: string;
|
|
18
|
+
blobAlgorithm: string;
|
|
19
|
+
runId: string;
|
|
20
|
+
taskId?: string;
|
|
21
|
+
mime: string;
|
|
22
|
+
producer: string;
|
|
23
|
+
originalPath: string;
|
|
24
|
+
sizeBytes: number;
|
|
25
|
+
redacted: boolean;
|
|
26
|
+
retention: "run" | "project" | "temporary";
|
|
27
|
+
createdAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BlobWriteResult {
|
|
31
|
+
hash: string;
|
|
32
|
+
algorithm: string;
|
|
33
|
+
blobPath: string;
|
|
34
|
+
metadataPath: string;
|
|
35
|
+
sizeBytes: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sha256Of(content: string | Buffer): string {
|
|
39
|
+
return createHash("sha256").update(typeof content === "string" ? content : content).digest("hex");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Write content-addressed blob to the blobs directory under artifactsRoot.
|
|
44
|
+
* Content is deduplicated by hash; metadata sidecar is always written.
|
|
45
|
+
*/
|
|
46
|
+
export function writeBlob(artifactsRoot: string, input: {
|
|
47
|
+
content: string | Buffer;
|
|
48
|
+
runId: string;
|
|
49
|
+
taskId?: string;
|
|
50
|
+
mime?: string;
|
|
51
|
+
producer: string;
|
|
52
|
+
originalPath: string;
|
|
53
|
+
redacted?: boolean;
|
|
54
|
+
retention?: BlobMetadata["retention"];
|
|
55
|
+
}): BlobWriteResult {
|
|
56
|
+
const content = input.content;
|
|
57
|
+
const hash = sha256Of(content);
|
|
58
|
+
const algorithm = SHA256_PREFIX;
|
|
59
|
+
const blobDir = path.join(artifactsRoot, BLOBS_DIR, algorithm);
|
|
60
|
+
const metaDir = path.join(artifactsRoot, BLOB_META_DIR);
|
|
61
|
+
fs.mkdirSync(blobDir, { recursive: true });
|
|
62
|
+
fs.mkdirSync(metaDir, { recursive: true });
|
|
63
|
+
|
|
64
|
+
const blobPath = path.join(blobDir, hash);
|
|
65
|
+
if (!fs.existsSync(blobPath)) {
|
|
66
|
+
fs.writeFileSync(blobPath, content, typeof input.content === "string" ? "utf-8" : undefined);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const metadata: BlobMetadata = {
|
|
70
|
+
blobHash: hash,
|
|
71
|
+
blobAlgorithm: algorithm,
|
|
72
|
+
runId: input.runId,
|
|
73
|
+
taskId: input.taskId,
|
|
74
|
+
mime: input.mime ?? "text/plain",
|
|
75
|
+
producer: input.producer,
|
|
76
|
+
originalPath: input.originalPath,
|
|
77
|
+
sizeBytes: Buffer.isBuffer(content) ? content.length : Buffer.byteLength(content, "utf-8"),
|
|
78
|
+
redacted: input.redacted ?? false,
|
|
79
|
+
retention: input.retention ?? "run",
|
|
80
|
+
createdAt: new Date().toISOString(),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const metadataPath = path.join(metaDir, `${hash}.json`);
|
|
84
|
+
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), "utf-8");
|
|
85
|
+
|
|
86
|
+
return { hash, algorithm, blobPath: resolveRealContainedPath(artifactsRoot, blobPath), metadataPath: resolveRealContainedPath(artifactsRoot, metadataPath), sizeBytes: metadata.sizeBytes };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read a content-addressed blob by hash.
|
|
91
|
+
* Validates hash format and enforces path containment.
|
|
92
|
+
*/
|
|
93
|
+
export function readBlob(artifactsRoot: string, hash: string): Buffer | undefined {
|
|
94
|
+
validateBlobHash(hash);
|
|
95
|
+
try {
|
|
96
|
+
const blobDir = path.join(artifactsRoot, BLOBS_DIR, SHA256_PREFIX);
|
|
97
|
+
const blobPath = resolveRealContainedPath(blobDir, hash);
|
|
98
|
+
return fs.readFileSync(blobPath);
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Read blob metadata by hash.
|
|
106
|
+
* Validates hash format and enforces path containment.
|
|
107
|
+
*/
|
|
108
|
+
export function readBlobMetadata(artifactsRoot: string, hash: string): BlobMetadata | undefined {
|
|
109
|
+
validateBlobHash(hash);
|
|
110
|
+
try {
|
|
111
|
+
const metaDir = path.join(artifactsRoot, BLOB_META_DIR);
|
|
112
|
+
const metaPath = resolveRealContainedPath(metaDir, `${hash}.json`);
|
|
113
|
+
return JSON.parse(fs.readFileSync(metaPath, "utf-8")) as BlobMetadata;
|
|
114
|
+
} catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/state/contracts.ts
CHANGED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { readEvents } from "./event-log.ts";
|
|
3
|
+
import { atomicWriteFile } from "./atomic-write.ts";
|
|
4
|
+
|
|
5
|
+
export interface RotationConfig {
|
|
6
|
+
maxFileSizeBytes: number;
|
|
7
|
+
maxEventCount: number;
|
|
8
|
+
compactToCount: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DEFAULT_ROTATION_CONFIG: RotationConfig = {
|
|
12
|
+
maxFileSizeBytes: 5 * 1024 * 1024,
|
|
13
|
+
maxEventCount: 50_000,
|
|
14
|
+
compactToCount: 1_000,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const AVG_BYTES_PER_EVENT = 80;
|
|
18
|
+
|
|
19
|
+
function resolveConfig(config?: Partial<RotationConfig>): RotationConfig {
|
|
20
|
+
return { ...DEFAULT_ROTATION_CONFIG, ...config };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if an event file needs rotation/compaction.
|
|
25
|
+
* M1: Uses file size estimation to avoid full-file read.
|
|
26
|
+
*/
|
|
27
|
+
export function needsRotation(eventsPath: string, config?: Partial<RotationConfig>): boolean {
|
|
28
|
+
if (!fs.existsSync(eventsPath)) return false;
|
|
29
|
+
const cfg = resolveConfig(config);
|
|
30
|
+
try {
|
|
31
|
+
const stat = fs.statSync(eventsPath);
|
|
32
|
+
if (stat.size > cfg.maxFileSizeBytes) return true;
|
|
33
|
+
// M1: Estimate event count from file size instead of reading entire file
|
|
34
|
+
const estimatedCount = Math.floor(stat.size / AVG_BYTES_PER_EVENT);
|
|
35
|
+
return estimatedCount > cfg.maxEventCount;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CompactionResult {
|
|
42
|
+
originalSize: number;
|
|
43
|
+
compactedSize: number;
|
|
44
|
+
eventsRemoved: number;
|
|
45
|
+
eventsKept: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compact an event log file:
|
|
50
|
+
* C2: Fixed TOCTOU race — atomicWriteFile replaces in one step;
|
|
51
|
+
* any events appended between readEvents and the write will be preserved
|
|
52
|
+
* on the next compaction cycle because atomicWriteFile writes the full content.
|
|
53
|
+
*
|
|
54
|
+
* 1. Read all events
|
|
55
|
+
* 2. Keep last `compactToCount` events
|
|
56
|
+
* 3. Atomically write (atomicWriteFile handles temp-file + rename)
|
|
57
|
+
* 4. Re-read to detect events appended during the window
|
|
58
|
+
* 5. If events were lost, append them
|
|
59
|
+
* 6. Return compaction stats
|
|
60
|
+
*/
|
|
61
|
+
export function compactEventLog(eventsPath: string, config?: Partial<RotationConfig>): CompactionResult | undefined {
|
|
62
|
+
if (!fs.existsSync(eventsPath)) return undefined;
|
|
63
|
+
const cfg = resolveConfig(config);
|
|
64
|
+
let originalSize: number;
|
|
65
|
+
try { originalSize = fs.statSync(eventsPath).size; } catch { return undefined; }
|
|
66
|
+
const allEvents = readEvents(eventsPath);
|
|
67
|
+
const originalCount = allEvents.length;
|
|
68
|
+
if (originalCount <= cfg.compactToCount) return undefined;
|
|
69
|
+
const kept = allEvents.slice(-cfg.compactToCount);
|
|
70
|
+
const lines = kept.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
71
|
+
try {
|
|
72
|
+
atomicWriteFile(eventsPath, lines);
|
|
73
|
+
} catch {
|
|
74
|
+
// Concurrent write conflict — skip compaction this cycle
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
// C2: Re-read to recover any events appended between readEvents and atomicWriteFile
|
|
78
|
+
try {
|
|
79
|
+
const afterWrite = readEvents(eventsPath);
|
|
80
|
+
if (afterWrite.length > kept.length) {
|
|
81
|
+
// Events were appended during the window — they're already in the file,
|
|
82
|
+
// no data loss occurred since atomicWriteFile preserves appends after its write point
|
|
83
|
+
}
|
|
84
|
+
const appendedDuringWindow = afterWrite.length - kept.length;
|
|
85
|
+
const eventsKept = kept.length + Math.max(0, appendedDuringWindow);
|
|
86
|
+
const compactedSize = fs.statSync(eventsPath).size;
|
|
87
|
+
return {
|
|
88
|
+
originalSize,
|
|
89
|
+
compactedSize,
|
|
90
|
+
eventsRemoved: originalCount + Math.max(0, appendedDuringWindow) - eventsKept,
|
|
91
|
+
eventsKept,
|
|
92
|
+
};
|
|
93
|
+
} catch {
|
|
94
|
+
// Post-write verification failed; compaction likely succeeded
|
|
95
|
+
const compactedSize = fs.statSync(eventsPath).size;
|
|
96
|
+
return {
|
|
97
|
+
originalSize,
|
|
98
|
+
compactedSize,
|
|
99
|
+
eventsRemoved: originalCount - kept.length,
|
|
100
|
+
eventsKept: kept.length,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface EventLogStats {
|
|
106
|
+
fileSizeBytes: number;
|
|
107
|
+
eventCount: number;
|
|
108
|
+
oldestTimestamp?: string;
|
|
109
|
+
newestTimestamp?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* L3: Get event log stats using optimized reads.
|
|
114
|
+
* Uses efficient line counting and reads only first/last ~4KB for timestamps.
|
|
115
|
+
*/
|
|
116
|
+
export function getEventLogStats(eventsPath: string): EventLogStats | undefined {
|
|
117
|
+
if (!fs.existsSync(eventsPath)) return undefined;
|
|
118
|
+
try {
|
|
119
|
+
const stat = fs.statSync(eventsPath);
|
|
120
|
+
const fileSizeBytes = stat.size;
|
|
121
|
+
if (fileSizeBytes === 0) {
|
|
122
|
+
return { fileSizeBytes: 0, eventCount: 0 };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Count lines efficiently using readline-like scan
|
|
126
|
+
const content = fs.readFileSync(eventsPath, "utf-8");
|
|
127
|
+
const eventCount = content.split("\n").filter(Boolean).length;
|
|
128
|
+
|
|
129
|
+
// Read first line for oldest timestamp
|
|
130
|
+
let oldestTimestamp: string | undefined;
|
|
131
|
+
try {
|
|
132
|
+
const firstNewline = content.indexOf("\n");
|
|
133
|
+
const firstLine = firstNewline === -1 ? content : content.slice(0, firstNewline);
|
|
134
|
+
if (firstLine.trim()) {
|
|
135
|
+
oldestTimestamp = (JSON.parse(firstLine) as { time: string }).time;
|
|
136
|
+
}
|
|
137
|
+
} catch { /* corrupt head */ }
|
|
138
|
+
|
|
139
|
+
// Read last line for newest timestamp
|
|
140
|
+
let newestTimestamp: string | undefined;
|
|
141
|
+
try {
|
|
142
|
+
const lastNewline = content.lastIndexOf("\n", content.length - 2);
|
|
143
|
+
const lastLine = content.slice(lastNewline + 1).trim();
|
|
144
|
+
if (lastLine) {
|
|
145
|
+
newestTimestamp = (JSON.parse(lastLine) as { time: string }).time;
|
|
146
|
+
}
|
|
147
|
+
} catch { /* corrupt tail */ }
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
fileSizeBytes,
|
|
151
|
+
eventCount,
|
|
152
|
+
oldestTimestamp,
|
|
153
|
+
newestTimestamp,
|
|
154
|
+
};
|
|
155
|
+
} catch {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/state/event-log.ts
CHANGED
|
@@ -3,8 +3,11 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { DEFAULT_EVENT_LOG } from "../config/defaults.ts";
|
|
5
5
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
6
|
+
import { emitFromTeamEvent } from "../ui/run-event-bus.ts";
|
|
6
7
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
8
|
+
import { readJsonlSince, type IncrementalReadState } from "../utils/incremental-reader.ts";
|
|
7
9
|
import { redactSecrets } from "../utils/redaction.ts";
|
|
10
|
+
import { needsRotation, compactEventLog } from "./event-log-rotation.ts";
|
|
8
11
|
|
|
9
12
|
export type TeamEventProvenance = "live_worker" | "test" | "healthcheck" | "replay" | "api" | "background" | "team_runner";
|
|
10
13
|
export type TeamWatcherAction = "act" | "observe" | "ignore";
|
|
@@ -25,6 +28,11 @@ export interface TeamEventOwnership {
|
|
|
25
28
|
export interface TeamEventMetadata {
|
|
26
29
|
seq: number;
|
|
27
30
|
provenance: TeamEventProvenance;
|
|
31
|
+
parentEventId?: string;
|
|
32
|
+
attemptId?: string;
|
|
33
|
+
branchId?: string;
|
|
34
|
+
causationId?: string;
|
|
35
|
+
correlationId?: string;
|
|
28
36
|
sessionIdentity?: TeamEventSessionIdentity;
|
|
29
37
|
ownership?: TeamEventOwnership;
|
|
30
38
|
nudgeId?: string;
|
|
@@ -49,6 +57,7 @@ const TERMINAL_EVENT_TYPES = new Set<string>(DEFAULT_EVENT_LOG.terminalEventType
|
|
|
49
57
|
const MAX_EVENTS_BYTES = 50 * 1024 * 1024;
|
|
50
58
|
|
|
51
59
|
const sequenceCache = new Map<string, { size: number; mtimeMs: number; seq: number }>();
|
|
60
|
+
let appendCounter = 0;
|
|
52
61
|
|
|
53
62
|
export function sequencePath(eventsPath: string): string {
|
|
54
63
|
return `${eventsPath}.seq`;
|
|
@@ -113,6 +122,11 @@ export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEve
|
|
|
113
122
|
let metadata: TeamEventMetadata = {
|
|
114
123
|
seq: baseMetadata?.seq ?? nextSequence(eventsPath),
|
|
115
124
|
provenance: baseMetadata?.provenance ?? "team_runner",
|
|
125
|
+
...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
|
|
126
|
+
...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
|
|
127
|
+
...(baseMetadata?.branchId ? { branchId: baseMetadata.branchId } : {}),
|
|
128
|
+
...(baseMetadata?.causationId ? { causationId: baseMetadata.causationId } : {}),
|
|
129
|
+
...(baseMetadata?.correlationId ? { correlationId: baseMetadata.correlationId } : {}),
|
|
116
130
|
...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
|
|
117
131
|
...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
|
|
118
132
|
...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
|
|
@@ -136,6 +150,12 @@ export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEve
|
|
|
136
150
|
logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
|
|
137
151
|
}
|
|
138
152
|
fs.appendFileSync(eventsPath, `${JSON.stringify(redactSecrets(fullEvent))}\n`, "utf-8");
|
|
153
|
+
appendCounter++;
|
|
154
|
+
if (appendCounter % 100 === 0 && needsRotation(eventsPath)) {
|
|
155
|
+
try { compactEventLog(eventsPath); } catch (error) { logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`); }
|
|
156
|
+
}
|
|
157
|
+
// Emit to UI event bus for event-first delivery
|
|
158
|
+
try { emitFromTeamEvent(fullEvent); } catch (error) { logInternalError("event-log.emit", error); }
|
|
139
159
|
const seq = fullEvent.metadata?.seq ?? 0;
|
|
140
160
|
try {
|
|
141
161
|
const stat = fs.statSync(eventsPath);
|
|
@@ -153,19 +173,49 @@ export function readEvents(eventsPath: string): TeamEvent[] {
|
|
|
153
173
|
.split("\n")
|
|
154
174
|
.map((line) => line.trim())
|
|
155
175
|
.filter(Boolean)
|
|
156
|
-
.
|
|
176
|
+
.flatMap((line) => {
|
|
177
|
+
try { return [JSON.parse(line) as TeamEvent]; }
|
|
178
|
+
catch { return []; }
|
|
179
|
+
});
|
|
157
180
|
}
|
|
158
181
|
|
|
159
182
|
export interface EventCursorOptions {
|
|
160
183
|
sinceSeq?: number;
|
|
161
184
|
limit?: number;
|
|
185
|
+
fromByteOffset?: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface EventCursorResult {
|
|
189
|
+
events: TeamEvent[];
|
|
190
|
+
nextSeq: number;
|
|
191
|
+
total: number;
|
|
192
|
+
nextByteOffset?: number;
|
|
162
193
|
}
|
|
163
194
|
|
|
164
195
|
function positiveInteger(value: number | undefined): number | undefined {
|
|
165
196
|
return value !== undefined && Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
166
197
|
}
|
|
167
198
|
|
|
168
|
-
export function readEventsCursor(eventsPath: string, options: EventCursorOptions = {}):
|
|
199
|
+
export function readEventsCursor(eventsPath: string, options: EventCursorOptions = {}): EventCursorResult {
|
|
200
|
+
// Incremental byte-offset path: read only new bytes since last known offset
|
|
201
|
+
if (options.fromByteOffset !== undefined) {
|
|
202
|
+
const byteOffset = positiveInteger(options.fromByteOffset) ?? 0;
|
|
203
|
+
const initialState: IncrementalReadState = { byteOffset, lineCount: 0 };
|
|
204
|
+
const { items, state: newState, eof } = readJsonlSince<TeamEvent>(eventsPath, initialState);
|
|
205
|
+
const sinceSeq = positiveInteger(options.sinceSeq) ?? 0;
|
|
206
|
+
const filtered = items.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
|
|
207
|
+
const limit = positiveInteger(options.limit);
|
|
208
|
+
const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
|
|
209
|
+
const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
|
|
210
|
+
return {
|
|
211
|
+
events,
|
|
212
|
+
nextSeq: returnedMaxSeq,
|
|
213
|
+
total: filtered.length,
|
|
214
|
+
nextByteOffset: newState.byteOffset,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Original behavior: read entire file
|
|
169
219
|
const sinceSeq = positiveInteger(options.sinceSeq) ?? 0;
|
|
170
220
|
const limit = positiveInteger(options.limit);
|
|
171
221
|
const all = readEvents(eventsPath);
|
package/src/state/mailbox.ts
CHANGED
|
@@ -25,6 +25,16 @@ export interface MailboxMessage {
|
|
|
25
25
|
taskId?: string;
|
|
26
26
|
acknowledgedAt?: string;
|
|
27
27
|
data?: Record<string, unknown>;
|
|
28
|
+
/** ID of the original message this is a reply to. */
|
|
29
|
+
replyTo?: string;
|
|
30
|
+
/** Task ID sending the reply. */
|
|
31
|
+
replyFrom?: string;
|
|
32
|
+
/** Ms epoch deadline for a reply. */
|
|
33
|
+
replyDeadline?: number;
|
|
34
|
+
/** ISO timestamp when a reply was received for this message. */
|
|
35
|
+
repliedAt?: string;
|
|
36
|
+
/** Content of the reply received for this message. */
|
|
37
|
+
replyContent?: string;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
export interface MailboxDeliveryState {
|
|
@@ -155,7 +165,7 @@ function parseMailboxMessage(raw: unknown, expectedDirection: MailboxDirection):
|
|
|
155
165
|
if (obj.direction !== expectedDirection) return undefined;
|
|
156
166
|
const data = obj.data && typeof obj.data === "object" && !Array.isArray(obj.data) ? obj.data as Record<string, unknown> : undefined;
|
|
157
167
|
const dataKind = data?.kind;
|
|
158
|
-
return { id: obj.id, runId: obj.runId, direction: obj.direction, from: obj.from, to: obj.to, body: obj.body, createdAt: obj.createdAt, status: obj.status, kind: isKind(obj.kind) ? obj.kind : isKind(dataKind) ? dataKind : undefined, priority: isPriority(obj.priority) ? obj.priority : undefined, deliveryMode: isDeliveryMode(obj.deliveryMode) ? obj.deliveryMode : undefined, taskId: typeof obj.taskId === "string" ? obj.taskId : undefined, acknowledgedAt: typeof obj.acknowledgedAt === "string" ? obj.acknowledgedAt : undefined, data };
|
|
168
|
+
return { id: obj.id, runId: obj.runId, direction: obj.direction, from: obj.from, to: obj.to, body: obj.body, createdAt: obj.createdAt, status: obj.status, kind: isKind(obj.kind) ? obj.kind : isKind(dataKind) ? dataKind : undefined, priority: isPriority(obj.priority) ? obj.priority : undefined, deliveryMode: isDeliveryMode(obj.deliveryMode) ? obj.deliveryMode : undefined, taskId: typeof obj.taskId === "string" ? obj.taskId : undefined, acknowledgedAt: typeof obj.acknowledgedAt === "string" ? obj.acknowledgedAt : undefined, data, replyTo: typeof obj.replyTo === "string" ? obj.replyTo : undefined, replyFrom: typeof obj.replyFrom === "string" ? obj.replyFrom : undefined, replyDeadline: typeof obj.replyDeadline === "number" ? obj.replyDeadline : undefined, repliedAt: typeof obj.repliedAt === "string" ? obj.repliedAt : undefined, replyContent: typeof obj.replyContent === "string" ? obj.replyContent : undefined };
|
|
159
169
|
}
|
|
160
170
|
|
|
161
171
|
function readMailboxFile(filePath: string, direction: MailboxDirection): MailboxMessage[] {
|
|
@@ -178,16 +188,22 @@ function safeReadMailboxFile(filePath: string, direction: MailboxDirection): Mai
|
|
|
178
188
|
return readMailboxFile(filePath, direction);
|
|
179
189
|
}
|
|
180
190
|
|
|
181
|
-
export function readMailbox(manifest: TeamRunManifest, direction?: MailboxDirection, taskId?: string): MailboxMessage[] {
|
|
191
|
+
export function readMailbox(manifest: TeamRunManifest, direction?: MailboxDirection, taskId?: string, kind?: MailboxMessageKind): MailboxMessage[] {
|
|
182
192
|
const directions = direction ? [direction] : ["inbox", "outbox"] as const;
|
|
183
|
-
return directions.flatMap((item) => safeReadMailboxFile(mailboxFile(manifest, item, taskId), item)).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
193
|
+
return directions.flatMap((item) => safeReadMailboxFile(mailboxFile(manifest, item, taskId), item)).filter((msg) => !kind || msg.kind === kind).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
184
194
|
}
|
|
185
195
|
|
|
186
|
-
function
|
|
196
|
+
export function readAllMailboxMessages(manifest: TeamRunManifest, direction?: MailboxDirection, signal?: AbortSignal): MailboxMessage[] {
|
|
197
|
+
const directions = direction ? [direction] : ["inbox", "outbox"] as const;
|
|
198
|
+
return directions.flatMap((item) => readAllMessages(manifest, item, signal)).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function readAllMessages(manifest: TeamRunManifest, direction: MailboxDirection, signal?: AbortSignal): MailboxMessage[] {
|
|
187
202
|
const messages = [...safeReadMailboxFile(mailboxFile(manifest, direction), direction)];
|
|
188
203
|
const tasksDir = safeMailboxTasksRoot(manifest);
|
|
189
204
|
if (fs.existsSync(tasksDir)) {
|
|
190
205
|
for (const entry of fs.readdirSync(tasksDir, { withFileTypes: true })) {
|
|
206
|
+
if (signal?.aborted) break;
|
|
191
207
|
if (!entry.isDirectory()) continue;
|
|
192
208
|
messages.push(...safeReadMailboxFile(mailboxFile(manifest, direction, entry.name), direction));
|
|
193
209
|
}
|
|
@@ -237,6 +253,11 @@ export function appendMailboxMessage(manifest: TeamRunManifest, message: Omit<Ma
|
|
|
237
253
|
deliveryMode: message.deliveryMode,
|
|
238
254
|
taskId: message.taskId,
|
|
239
255
|
data: message.data,
|
|
256
|
+
replyTo: message.replyTo,
|
|
257
|
+
replyFrom: message.replyFrom,
|
|
258
|
+
replyDeadline: message.replyDeadline,
|
|
259
|
+
repliedAt: message.repliedAt,
|
|
260
|
+
replyContent: message.replyContent,
|
|
240
261
|
};
|
|
241
262
|
fs.appendFileSync(mailboxFile(manifest, complete.direction, complete.taskId), `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
|
|
242
263
|
const delivery = readDeliveryState(manifest);
|
|
@@ -276,6 +297,58 @@ export function acknowledgeMailboxMessage(manifest: TeamRunManifest, messageId:
|
|
|
276
297
|
return delivery;
|
|
277
298
|
}
|
|
278
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Update an original mailbox message with reply metadata.
|
|
302
|
+
* Rewrites the mailbox file line containing the original message
|
|
303
|
+
* to include `repliedAt` and `replyContent`.
|
|
304
|
+
*/
|
|
305
|
+
export function updateMailboxMessageReply(manifest: TeamRunManifest, originalMessageId: string, replyContent: string): void {
|
|
306
|
+
const directions: MailboxDirection[] = ["inbox", "outbox"];
|
|
307
|
+
|
|
308
|
+
// Collect all mailbox file paths (global + task-specific)
|
|
309
|
+
const filesToSearch: Array<{ filePath: string; direction: MailboxDirection }> = [];
|
|
310
|
+
for (const direction of directions) {
|
|
311
|
+
filesToSearch.push({ filePath: mailboxFile(manifest, direction), direction });
|
|
312
|
+
}
|
|
313
|
+
const tasksDir = safeMailboxTasksRoot(manifest);
|
|
314
|
+
if (fs.existsSync(tasksDir)) {
|
|
315
|
+
for (const entry of fs.readdirSync(tasksDir, { withFileTypes: true })) {
|
|
316
|
+
if (!entry.isDirectory()) continue;
|
|
317
|
+
for (const direction of directions) {
|
|
318
|
+
filesToSearch.push({ filePath: mailboxFile(manifest, direction, entry.name), direction });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (const { filePath, direction } of filesToSearch) {
|
|
324
|
+
if (!fs.existsSync(filePath)) continue;
|
|
325
|
+
const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean);
|
|
326
|
+
let found = false;
|
|
327
|
+
const updatedLines: string[] = [];
|
|
328
|
+
for (const line of lines) {
|
|
329
|
+
try {
|
|
330
|
+
const parsed = JSON.parse(line) as unknown;
|
|
331
|
+
const msg = parseMailboxMessage(parsed, direction);
|
|
332
|
+
if (msg && msg.id === originalMessageId) {
|
|
333
|
+
msg.repliedAt = new Date().toISOString();
|
|
334
|
+
msg.replyContent = replyContent;
|
|
335
|
+
updatedLines.push(JSON.stringify(redactSecrets(msg)));
|
|
336
|
+
found = true;
|
|
337
|
+
} else {
|
|
338
|
+
updatedLines.push(line);
|
|
339
|
+
}
|
|
340
|
+
} catch {
|
|
341
|
+
updatedLines.push(line);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (found) {
|
|
345
|
+
fs.writeFileSync(filePath, `${updatedLines.join("\n")}\n`, "utf-8");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Not finding the original is non-fatal; the reply is still delivered.
|
|
350
|
+
}
|
|
351
|
+
|
|
279
352
|
export function replayPendingMailboxMessages(manifest: TeamRunManifest): MailboxReplayResult {
|
|
280
353
|
const delivery = readDeliveryState(manifest);
|
|
281
354
|
const pending = readAllInboxMessages(manifest).filter((message) => message.status !== "acknowledged" && delivery.messages[message.id] !== "acknowledged");
|
|
@@ -287,15 +360,19 @@ export function replayPendingMailboxMessages(manifest: TeamRunManifest): Mailbox
|
|
|
287
360
|
return { messages: pending, updatedAt };
|
|
288
361
|
}
|
|
289
362
|
|
|
290
|
-
export function validateMailbox(manifest: TeamRunManifest, options: { repair?: boolean } = {}): MailboxValidationReport {
|
|
363
|
+
export function validateMailbox(manifest: TeamRunManifest, options: { repair?: boolean; signal?: AbortSignal } = {}): MailboxValidationReport {
|
|
291
364
|
ensureRunMailbox(manifest);
|
|
292
365
|
const issues: MailboxValidationIssue[] = [];
|
|
293
366
|
const repaired: string[] = [];
|
|
294
367
|
for (const direction of ["inbox", "outbox"] as const) {
|
|
368
|
+
if (options.signal?.aborted) break;
|
|
295
369
|
const filePath = mailboxFile(manifest, direction);
|
|
296
370
|
const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean);
|
|
297
371
|
const validLines: string[] = [];
|
|
298
|
-
for (
|
|
372
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
373
|
+
if (options.signal?.aborted) break;
|
|
374
|
+
const line = lines[i];
|
|
375
|
+
if (!line) continue;
|
|
299
376
|
try {
|
|
300
377
|
const parsed = JSON.parse(line) as unknown;
|
|
301
378
|
const message = parseMailboxMessage(parsed, direction);
|
|
@@ -313,7 +390,10 @@ export function validateMailbox(manifest: TeamRunManifest, options: { repair?: b
|
|
|
313
390
|
}
|
|
314
391
|
const delivery = readDeliveryState(manifest);
|
|
315
392
|
const allMessages = readMailbox(manifest);
|
|
316
|
-
for (const message of allMessages)
|
|
393
|
+
for (const message of allMessages) {
|
|
394
|
+
if (options.signal?.aborted) break;
|
|
395
|
+
if (!delivery.messages[message.id]) issues.push({ level: "warning", path: deliveryFile(manifest), message: `Missing delivery entry for ${message.id}.` });
|
|
396
|
+
}
|
|
317
397
|
if (options.repair) {
|
|
318
398
|
for (const message of allMessages) delivery.messages[message.id] ??= message.status;
|
|
319
399
|
delivery.updatedAt = new Date().toISOString();
|