@oh-my-pi/pi-coding-agent 15.8.3 → 15.9.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/CHANGELOG.md +74 -31
- package/dist/types/capability/skill.d.ts +7 -0
- package/dist/types/config/settings-schema.d.ts +36 -22
- package/dist/types/debug/protocol-probe.d.ts +38 -0
- package/dist/types/debug/terminal-info.d.ts +34 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
- package/dist/types/extensibility/extensions/types.d.ts +11 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mcp/transports/stdio.d.ts +16 -7
- package/dist/types/modes/components/assistant-message.d.ts +3 -2
- package/dist/types/modes/components/hook-selector.d.ts +11 -0
- package/dist/types/modes/components/todo-reminder.d.ts +1 -1
- package/dist/types/modes/components/transcript-container.d.ts +36 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +20 -1
- package/dist/types/plan-mode/plan-handoff.d.ts +20 -0
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/indexed-session-storage.d.ts +59 -0
- package/dist/types/session/redis-session-storage.d.ts +12 -85
- package/dist/types/session/session-manager.d.ts +20 -0
- package/dist/types/session/session-storage.d.ts +5 -7
- package/dist/types/session/sql-session-storage.d.ts +16 -85
- package/dist/types/task/executor.d.ts +9 -0
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/telemetry-export.d.ts +19 -0
- package/dist/types/tiny/compiled-runtime.d.ts +35 -0
- package/dist/types/tools/ask.d.ts +1 -0
- package/dist/types/tools/index.d.ts +4 -2
- package/dist/types/tools/path-utils.d.ts +1 -1
- package/dist/types/tools/search.d.ts +2 -2
- package/dist/types/tools/{todo-write.d.ts → todo.d.ts} +18 -18
- package/dist/types/utils/session-color.d.ts +7 -2
- package/dist/types/web/search/index.d.ts +1 -1
- package/dist/types/web/search/provider.d.ts +2 -2
- package/dist/types/web/search/types.d.ts +65 -1
- package/package.json +15 -9
- package/scripts/build-binary.ts +12 -0
- package/src/capability/skill.ts +7 -0
- package/src/cli/args.ts +1 -1
- package/src/config/settings-schema.ts +22 -55
- package/src/debug/index.ts +67 -1
- package/src/debug/protocol-probe.ts +267 -0
- package/src/debug/terminal-info.ts +127 -0
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +3 -3
- package/src/extensibility/custom-tools/types.ts +1 -1
- package/src/extensibility/extensions/types.ts +11 -0
- package/src/extensibility/shared-events.ts +1 -1
- package/src/extensibility/skills.ts +3 -3
- package/src/index.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/main.ts +12 -0
- package/src/mcp/transports/stdio.ts +37 -12
- package/src/modes/acp/acp-event-mapper.ts +7 -7
- package/src/modes/components/assistant-message.ts +3 -2
- package/src/modes/components/hook-selector.ts +149 -14
- package/src/modes/components/session-selector.ts +36 -5
- package/src/modes/components/status-line/segments.ts +2 -1
- package/src/modes/components/status-line.ts +1 -1
- package/src/modes/components/tips.txt +1 -1
- package/src/modes/components/todo-reminder.ts +1 -1
- package/src/modes/components/tool-execution.ts +9 -4
- package/src/modes/components/transcript-container.ts +91 -0
- package/src/modes/controllers/event-controller.ts +12 -7
- package/src/modes/controllers/extension-ui-controller.ts +3 -0
- package/src/modes/controllers/todo-command-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +18 -6
- package/src/modes/print-mode.ts +5 -0
- package/src/modes/rpc/rpc-types.ts +1 -1
- package/src/modes/theme/theme.ts +48 -8
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/plan-mode/plan-handoff.ts +37 -0
- package/src/priority.json +4 -0
- package/src/prompts/goals/goal-continuation.md +1 -1
- package/src/prompts/system/eager-todo.md +3 -3
- package/src/prompts/system/orchestrate-notice.md +5 -5
- package/src/prompts/system/plan-mode-approved.md +14 -17
- package/src/prompts/system/plan-mode-reference.md +3 -6
- package/src/prompts/system/subagent-system-prompt.md +11 -0
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/prompts/tools/browser.md +5 -2
- package/src/prompts/tools/search.md +1 -1
- package/src/prompts/tools/task.md +3 -1
- package/src/prompts/tools/{todo-write.md → todo.md} +5 -5
- package/src/sdk.ts +1 -0
- package/src/session/agent-session.ts +14 -14
- package/src/session/indexed-session-storage.ts +430 -0
- package/src/session/redis-session-storage.ts +66 -377
- package/src/session/session-manager.ts +102 -22
- package/src/session/session-storage.ts +148 -68
- package/src/session/sql-session-storage.ts +131 -382
- package/src/slash-commands/helpers/todo.ts +2 -2
- package/src/task/executor.ts +9 -1
- package/src/task/index.ts +51 -1
- package/src/telemetry-export.ts +126 -0
- package/src/tiny/compiled-runtime.ts +179 -0
- package/src/tiny/worker.ts +24 -2
- package/src/tools/ask.ts +133 -87
- package/src/tools/fetch.ts +17 -4
- package/src/tools/find.ts +2 -2
- package/src/tools/index.ts +6 -4
- package/src/tools/path-utils.ts +16 -7
- package/src/tools/renderers.ts +2 -2
- package/src/tools/search.ts +6 -3
- package/src/tools/{todo-write.ts → todo.ts} +32 -35
- package/src/utils/session-color.ts +39 -14
- package/src/web/search/index.ts +1 -1
- package/src/web/search/provider.ts +18 -34
- package/src/web/search/types.ts +73 -32
|
@@ -38,6 +38,8 @@ Subagents have no conversation history. Every fact, file path, and direction the
|
|
|
38
38
|
- Pass large payloads via `local://<path>` URIs, not inline. {{#if contextEnabled}} (other than the context){{/if}}
|
|
39
39
|
{{#if contextEnabled}}- Put shared constraints in `context` once; do not duplicate across assignments.{{/if}}
|
|
40
40
|
- Prefer agents that investigate **and** edit in one pass; only spin a read-only discovery step when affected files are genuinely unknown.
|
|
41
|
+
- **Read-only agents**: Agents tagged READ-ONLY (e.g. `explore`) have no edit/write/command tools. NEVER hand them an assignment that requires changing files or running commands — they cannot do it and the turn is wasted. Use them to investigate and report back; do the edits yourself or delegate to a writing agent (`task`, `oracle`, `designer`).
|
|
42
|
+
- **No reasoning offload**: NEVER offload reasoning, analysis, design, or decision-making to `quick_task` or `explore` — they run minimal-effort / small models for mechanical lookups and data collection only. Keep judgment and synthesis in your own context; delegate hard thinking to `task`, `plan`, or `oracle`.
|
|
41
43
|
</rules>
|
|
42
44
|
|
|
43
45
|
<parallelization>
|
|
@@ -71,7 +73,7 @@ Parallel when tasks touch disjoint files or are independent refactors/tests.
|
|
|
71
73
|
Agent spawning is disabled for this context.
|
|
72
74
|
{{else}}
|
|
73
75
|
{{#list agents join="\n"}}
|
|
74
|
-
# {{name}}
|
|
76
|
+
# {{name}}{{#if readOnly}} — READ-ONLY (no edit/write/exec tools){{/if}}
|
|
75
77
|
{{description}}
|
|
76
78
|
{{/list}}
|
|
77
79
|
{{/if}}
|
|
@@ -50,9 +50,9 @@ Allowed `op` values are only `init`, `start`, `done`, `drop`, `rm`, `append`, an
|
|
|
50
50
|
</examples>
|
|
51
51
|
|
|
52
52
|
<critical>
|
|
53
|
-
When the user hands you a multi-step plan — a phased todo, a numbered or bulleted checklist, or "N bugs/items/tasks" to work through:
|
|
54
|
-
- You MUST `init` the list with EVERY item as its own task before doing the work.
|
|
55
|
-
- Enumerate all of them;
|
|
56
|
-
- NEVER summarize the plan into fewer tasks, sample "the important ones", drop items, or rely on memory to track the rest.
|
|
53
|
+
When the user hands you a multi-step plan — a phased todo, a numbered or bulleted checklist, or "N bugs/items/tasks" to work through:
|
|
54
|
+
- You MUST `init` the list with EVERY item as its own task before doing the work.
|
|
55
|
+
- Enumerate all of them;
|
|
56
|
+
- NEVER summarize the plan into fewer tasks, sample "the important ones", drop items, or rely on memory to track the rest.
|
|
57
57
|
The entire point is to remember every one.
|
|
58
|
-
</critical>
|
|
58
|
+
</critical>
|
package/src/sdk.ts
CHANGED
|
@@ -1259,6 +1259,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1259
1259
|
getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
|
|
1260
1260
|
getActiveModelString,
|
|
1261
1261
|
getPlanModeState: () => session?.getPlanModeState(),
|
|
1262
|
+
getPlanReferencePath: () => session?.getPlanReferencePath() ?? "local://PLAN.md",
|
|
1262
1263
|
getGoalModeState: () => session?.getGoalModeState(),
|
|
1263
1264
|
getGoalRuntime: () => session?.goalRuntime,
|
|
1264
1265
|
getUsageStatistics: () => sessionManager.getUsageStatistics(),
|
|
@@ -203,7 +203,7 @@ import type { CheckpointState } from "../tools/checkpoint";
|
|
|
203
203
|
import { outputMeta } from "../tools/output-meta";
|
|
204
204
|
import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
|
|
205
205
|
import { isAutoQaEnabled } from "../tools/report-tool-issue";
|
|
206
|
-
import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo
|
|
206
|
+
import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo";
|
|
207
207
|
import { ToolAbortError, ToolError } from "../tools/tool-errors";
|
|
208
208
|
import { clampTimeout } from "../tools/tool-timeouts";
|
|
209
209
|
import { parseCommandArgs } from "../utils/command-args";
|
|
@@ -1807,21 +1807,21 @@ export class AgentSession {
|
|
|
1807
1807
|
if (toolName === "edit" && details?.path) {
|
|
1808
1808
|
this.#invalidateFileCacheForPath(details.path);
|
|
1809
1809
|
}
|
|
1810
|
-
if (toolName === "
|
|
1810
|
+
if (toolName === "todo" && !isError && Array.isArray(details?.phases)) {
|
|
1811
1811
|
this.setTodoPhases(details.phases);
|
|
1812
1812
|
}
|
|
1813
|
-
if (toolName === "
|
|
1813
|
+
if (toolName === "todo" && isError) {
|
|
1814
1814
|
const errorText = content?.find(part => part.type === "text")?.text;
|
|
1815
1815
|
const reminderText = [
|
|
1816
1816
|
"<system-reminder>",
|
|
1817
|
-
"
|
|
1818
|
-
errorText ? `Failure: ${errorText}` : "Failure:
|
|
1819
|
-
"Fix the todo payload and call
|
|
1817
|
+
"todo failed, so todo progress is not visible to the user.",
|
|
1818
|
+
errorText ? `Failure: ${errorText}` : "Failure: todo returned an error.",
|
|
1819
|
+
"Fix the todo payload and call todo again before continuing.",
|
|
1820
1820
|
"</system-reminder>",
|
|
1821
1821
|
].join("\n");
|
|
1822
1822
|
await this.sendCustomMessage(
|
|
1823
1823
|
{
|
|
1824
|
-
customType: "todo-
|
|
1824
|
+
customType: "todo-error-reminder",
|
|
1825
1825
|
content: reminderText,
|
|
1826
1826
|
display: false,
|
|
1827
1827
|
details: { toolName, errorText },
|
|
@@ -4994,7 +4994,7 @@ export class AgentSession {
|
|
|
4994
4994
|
// splice mutated canonical `#todoPhases` between tool calls, so the model
|
|
4995
4995
|
// observed phase totals shrinking ("5 → 4") after marking tasks done. The
|
|
4996
4996
|
// `tasks.todoClearDelay` setting is now inert; completed tasks survive
|
|
4997
|
-
// until the next explicit `
|
|
4997
|
+
// until the next explicit `todo` call removes them via `rm`/`drop`.
|
|
4998
4998
|
|
|
4999
4999
|
/**
|
|
5000
5000
|
* Abort current operation and wait for agent to become idle.
|
|
@@ -6553,16 +6553,16 @@ export class AgentSession {
|
|
|
6553
6553
|
return undefined;
|
|
6554
6554
|
}
|
|
6555
6555
|
|
|
6556
|
-
if (!this.#toolRegistry.has("
|
|
6557
|
-
logger.warn("Eager todo enforcement skipped because
|
|
6556
|
+
if (!this.#toolRegistry.has("todo")) {
|
|
6557
|
+
logger.warn("Eager todo enforcement skipped because todo is unavailable", {
|
|
6558
6558
|
activeToolNames: this.agent.state.tools.map(tool => tool.name),
|
|
6559
6559
|
});
|
|
6560
6560
|
return undefined;
|
|
6561
6561
|
}
|
|
6562
6562
|
|
|
6563
|
-
const
|
|
6564
|
-
if (!
|
|
6565
|
-
logger.warn("Eager todo enforcement skipped because the current model does not support forcing
|
|
6563
|
+
const todoToolChoice = buildNamedToolChoice("todo", this.model);
|
|
6564
|
+
if (!todoToolChoice) {
|
|
6565
|
+
logger.warn("Eager todo enforcement skipped because the current model does not support forcing todo", {
|
|
6566
6566
|
modelApi: this.model?.api,
|
|
6567
6567
|
modelId: this.model?.id,
|
|
6568
6568
|
});
|
|
@@ -6580,7 +6580,7 @@ export class AgentSession {
|
|
|
6580
6580
|
attribution: "agent",
|
|
6581
6581
|
timestamp: Date.now(),
|
|
6582
6582
|
},
|
|
6583
|
-
toolChoice:
|
|
6583
|
+
toolChoice: todoToolChoice,
|
|
6584
6584
|
};
|
|
6585
6585
|
}
|
|
6586
6586
|
/**
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { toError } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from "./session-storage";
|
|
3
|
+
|
|
4
|
+
export interface SessionStorageIndexEntry {
|
|
5
|
+
path: string;
|
|
6
|
+
size: number;
|
|
7
|
+
mtimeMs: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SessionStorageBackend {
|
|
11
|
+
init(): Promise<void>;
|
|
12
|
+
loadIndex(): Promise<Iterable<SessionStorageIndexEntry>>;
|
|
13
|
+
readFull(path: string): Promise<string | null>;
|
|
14
|
+
readSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]>;
|
|
15
|
+
writeFull(path: string, content: string, mtimeMs: number): Promise<void>;
|
|
16
|
+
append(path: string, line: string, mtimeMs: number): Promise<void>;
|
|
17
|
+
truncate(path: string, mtimeMs: number): Promise<void>;
|
|
18
|
+
remove(paths: string[]): Promise<void>;
|
|
19
|
+
move(src: string, dst: string, mtimeMs: number): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface IndexEntry {
|
|
23
|
+
size: number;
|
|
24
|
+
mtimeMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface EnqueueOptions {
|
|
28
|
+
trackDrain: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const RESOLVED = Promise.resolve();
|
|
32
|
+
|
|
33
|
+
function enoent(p: string): NodeJS.ErrnoException {
|
|
34
|
+
const err = new Error(`ENOENT: no such file, '${p}'`) as NodeJS.ErrnoException;
|
|
35
|
+
err.code = "ENOENT";
|
|
36
|
+
err.errno = -2;
|
|
37
|
+
err.path = p;
|
|
38
|
+
err.syscall = "open";
|
|
39
|
+
return err;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function matchesGlob(name: string, pattern: string): boolean {
|
|
43
|
+
if (pattern === "*") return true;
|
|
44
|
+
if (pattern.startsWith("*.")) return name.endsWith(pattern.slice(1));
|
|
45
|
+
return name === pattern;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function byteLength(text: string): number {
|
|
49
|
+
return Buffer.byteLength(text, "utf-8");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeByteLimit(maxBytes: number): number {
|
|
53
|
+
if (!(maxBytes > 0)) return 0;
|
|
54
|
+
return Math.trunc(maxBytes);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function uniquePaths(paths: readonly string[]): string[] {
|
|
58
|
+
const out: string[] = [];
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
for (const path of paths) {
|
|
61
|
+
if (seen.has(path)) continue;
|
|
62
|
+
seen.add(path);
|
|
63
|
+
out.push(path);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class IndexedSessionStorage implements SessionStorage {
|
|
69
|
+
readonly #backend: SessionStorageBackend;
|
|
70
|
+
readonly #index = new Map<string, IndexEntry>();
|
|
71
|
+
readonly #writers = new Set<IndexedSessionStorageWriter>();
|
|
72
|
+
readonly #pathTails = new Map<string, Promise<void>>();
|
|
73
|
+
readonly #pathPending = new Map<string, Promise<void>>();
|
|
74
|
+
readonly #drainPending = new Set<Promise<void>>();
|
|
75
|
+
#nextMtimeMs = 0;
|
|
76
|
+
#firstDrainError: Error | undefined;
|
|
77
|
+
|
|
78
|
+
constructor(backend: SessionStorageBackend) {
|
|
79
|
+
this.#backend = backend;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async initialize(): Promise<void> {
|
|
83
|
+
await this.#backend.init();
|
|
84
|
+
await this.refresh();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async refresh(): Promise<void> {
|
|
88
|
+
await this.drain();
|
|
89
|
+
const rows = await this.#backend.loadIndex();
|
|
90
|
+
this.#index.clear();
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
this.#setIndex(row.path, row.size, row.mtimeMs);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async drain(): Promise<void> {
|
|
97
|
+
while (this.#drainPending.size > 0) {
|
|
98
|
+
await Promise.allSettled(this.#drainPending);
|
|
99
|
+
}
|
|
100
|
+
const error = this.#firstDrainError;
|
|
101
|
+
this.#firstDrainError = undefined;
|
|
102
|
+
if (error) throw error;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
ensureDirSync(_dir: string): void {
|
|
106
|
+
// Indexed backends are flat: directories are derived from key prefixes.
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
existsSync(path: string): boolean {
|
|
110
|
+
return this.#index.has(path);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
writeTextSync(path: string, content: string): void {
|
|
114
|
+
const mtimeMs = this.#allocMtimeMs();
|
|
115
|
+
this.#setIndex(path, byteLength(content), mtimeMs);
|
|
116
|
+
this.#enqueuePath(path, () => this.#backend.writeFull(path, content, mtimeMs), { trackDrain: true });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
statSync(path: string): SessionStorageStat {
|
|
120
|
+
const entry = this.#index.get(path);
|
|
121
|
+
if (!entry) throw enoent(path);
|
|
122
|
+
return {
|
|
123
|
+
size: entry.size,
|
|
124
|
+
mtimeMs: entry.mtimeMs,
|
|
125
|
+
mtime: new Date(entry.mtimeMs),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
listFilesSync(dir: string, pattern: string): string[] {
|
|
130
|
+
const prefix = dir.endsWith("/") ? dir : `${dir}/`;
|
|
131
|
+
const out: string[] = [];
|
|
132
|
+
for (const path of this.#index.keys()) {
|
|
133
|
+
if (!path.startsWith(prefix)) continue;
|
|
134
|
+
const name = path.slice(prefix.length);
|
|
135
|
+
if (name.includes("/") || name.includes("\\")) continue;
|
|
136
|
+
if (!matchesGlob(name, pattern)) continue;
|
|
137
|
+
out.push(path);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
exists(path: string): Promise<boolean> {
|
|
143
|
+
return Promise.resolve(this.existsSync(path));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async readText(path: string): Promise<string> {
|
|
147
|
+
if (!this.#index.has(path)) throw enoent(path);
|
|
148
|
+
await this.#awaitPath(path);
|
|
149
|
+
const content = await this.#backend.readFull(path);
|
|
150
|
+
if (content === null) throw enoent(path);
|
|
151
|
+
return content;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async readTextSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]> {
|
|
155
|
+
if (!this.#index.has(path)) throw enoent(path);
|
|
156
|
+
const prefixLimit = normalizeByteLimit(prefixBytes);
|
|
157
|
+
const suffixLimit = normalizeByteLimit(suffixBytes);
|
|
158
|
+
if (prefixLimit === 0 && suffixLimit === 0) return ["", ""];
|
|
159
|
+
await this.#awaitPath(path);
|
|
160
|
+
return this.#backend.readSlices(path, prefixLimit, suffixLimit);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async writeText(path: string, content: string): Promise<void> {
|
|
164
|
+
await this.#awaitPath(path);
|
|
165
|
+
const previous = this.#index.get(path);
|
|
166
|
+
const mtimeMs = this.#allocMtimeMs();
|
|
167
|
+
this.#setIndex(path, byteLength(content), mtimeMs);
|
|
168
|
+
try {
|
|
169
|
+
await this.#enqueuePath(path, () => this.#backend.writeFull(path, content, mtimeMs), { trackDrain: false });
|
|
170
|
+
} catch (err) {
|
|
171
|
+
this.#restoreIndex(path, previous);
|
|
172
|
+
throw toError(err);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async rename(src: string, dst: string): Promise<void> {
|
|
177
|
+
await this.#awaitPath(src);
|
|
178
|
+
await this.#awaitPath(dst);
|
|
179
|
+
const entry = this.#index.get(src);
|
|
180
|
+
if (!entry) throw enoent(src);
|
|
181
|
+
const dstPrevious = this.#index.get(dst);
|
|
182
|
+
this.#index.delete(src);
|
|
183
|
+
this.#index.set(dst, { ...entry });
|
|
184
|
+
try {
|
|
185
|
+
await this.#enqueuePaths([src, dst], () => this.#backend.move(src, dst, entry.mtimeMs), { trackDrain: false });
|
|
186
|
+
} catch (err) {
|
|
187
|
+
this.#index.delete(dst);
|
|
188
|
+
this.#restoreIndex(dst, dstPrevious);
|
|
189
|
+
this.#index.set(src, entry);
|
|
190
|
+
throw toError(err);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async unlink(path: string): Promise<void> {
|
|
195
|
+
await this.#awaitPath(path);
|
|
196
|
+
const previous = this.#index.get(path);
|
|
197
|
+
if (!previous) throw enoent(path);
|
|
198
|
+
this.#index.delete(path);
|
|
199
|
+
try {
|
|
200
|
+
await this.#enqueuePath(path, () => this.#backend.remove([path]), { trackDrain: false });
|
|
201
|
+
} catch (err) {
|
|
202
|
+
this.#index.set(path, previous);
|
|
203
|
+
throw toError(err);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async deleteSessionWithArtifacts(sessionPath: string): Promise<void> {
|
|
208
|
+
await this.#awaitPath(sessionPath);
|
|
209
|
+
const sessionEntry = this.#index.get(sessionPath);
|
|
210
|
+
if (!sessionEntry) throw enoent(sessionPath);
|
|
211
|
+
|
|
212
|
+
const artifactsDir = sessionPath.slice(0, -6);
|
|
213
|
+
const prefix = artifactsDir.endsWith("/") ? artifactsDir : `${artifactsDir}/`;
|
|
214
|
+
const paths = [sessionPath];
|
|
215
|
+
for (const key of this.#index.keys()) {
|
|
216
|
+
if (key.startsWith(prefix)) paths.push(key);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const path of paths) await this.#awaitPath(path);
|
|
220
|
+
|
|
221
|
+
const previous = new Map<string, IndexEntry>();
|
|
222
|
+
for (const path of paths) {
|
|
223
|
+
const entry = this.#index.get(path);
|
|
224
|
+
if (entry) previous.set(path, entry);
|
|
225
|
+
this.#index.delete(path);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
await this.#enqueuePaths(paths, () => this.#backend.remove(paths), { trackDrain: false });
|
|
230
|
+
} catch (err) {
|
|
231
|
+
for (const [path, entry] of previous) this.#index.set(path, entry);
|
|
232
|
+
throw toError(err);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
|
|
237
|
+
const writer = new IndexedSessionStorageWriter(this, path, options);
|
|
238
|
+
this.#writers.add(writer);
|
|
239
|
+
return writer;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_writerClosed(writer: IndexedSessionStorageWriter): void {
|
|
243
|
+
this.#writers.delete(writer);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
_truncateForWriter(path: string): number {
|
|
247
|
+
const mtimeMs = this.#allocMtimeMs();
|
|
248
|
+
this.#setIndex(path, 0, mtimeMs);
|
|
249
|
+
return mtimeMs;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
_queueTruncate(path: string, mtimeMs: number, getError?: () => Error | undefined): Promise<void> {
|
|
253
|
+
return this.#enqueuePath(
|
|
254
|
+
path,
|
|
255
|
+
async () => {
|
|
256
|
+
const error = getError?.();
|
|
257
|
+
if (error) throw error;
|
|
258
|
+
await this.#backend.truncate(path, mtimeMs);
|
|
259
|
+
},
|
|
260
|
+
{ trackDrain: true },
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
_appendForWriter(path: string, line: string): number {
|
|
265
|
+
const mtimeMs = this.#allocMtimeMs();
|
|
266
|
+
const existing = this.#index.get(path);
|
|
267
|
+
const size = (existing?.size ?? 0) + byteLength(line);
|
|
268
|
+
this.#setIndex(path, size, mtimeMs);
|
|
269
|
+
return mtimeMs;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_queueAppend(path: string, line: string, mtimeMs: number, getError?: () => Error | undefined): Promise<void> {
|
|
273
|
+
return this.#enqueuePath(
|
|
274
|
+
path,
|
|
275
|
+
async () => {
|
|
276
|
+
const error = getError?.();
|
|
277
|
+
if (error) throw error;
|
|
278
|
+
await this.#backend.append(path, line, mtimeMs);
|
|
279
|
+
},
|
|
280
|
+
{ trackDrain: true },
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#restoreIndex(path: string, entry: IndexEntry | undefined): void {
|
|
285
|
+
if (entry) {
|
|
286
|
+
this.#index.set(path, entry);
|
|
287
|
+
} else {
|
|
288
|
+
this.#index.delete(path);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#setIndex(path: string, size: number, mtimeMs: number): void {
|
|
293
|
+
this.#index.set(path, { size, mtimeMs });
|
|
294
|
+
if (mtimeMs > this.#nextMtimeMs) this.#nextMtimeMs = mtimeMs;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#allocMtimeMs(): number {
|
|
298
|
+
const now = Date.now();
|
|
299
|
+
const next = now > this.#nextMtimeMs ? now : this.#nextMtimeMs + 1;
|
|
300
|
+
this.#nextMtimeMs = next;
|
|
301
|
+
return next;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
#enqueuePath(path: string, task: () => Promise<void>, options: EnqueueOptions): Promise<void> {
|
|
305
|
+
return this.#enqueuePaths([path], task, options);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#enqueuePaths(paths: readonly string[], task: () => Promise<void>, options: EnqueueOptions): Promise<void> {
|
|
309
|
+
const unique = uniquePaths(paths);
|
|
310
|
+
const previous = unique.map(path => this.#pathTails.get(path) ?? RESOLVED);
|
|
311
|
+
const operation = Promise.all(previous).then(task);
|
|
312
|
+
const tracked = operation.catch(err => {
|
|
313
|
+
const error = toError(err);
|
|
314
|
+
if (options.trackDrain && !this.#firstDrainError) this.#firstDrainError = error;
|
|
315
|
+
throw error;
|
|
316
|
+
});
|
|
317
|
+
const tail = tracked.catch(() => {});
|
|
318
|
+
for (const path of unique) {
|
|
319
|
+
this.#pathTails.set(path, tail);
|
|
320
|
+
this.#pathPending.set(path, tracked);
|
|
321
|
+
}
|
|
322
|
+
tail.finally(() => {
|
|
323
|
+
for (const path of unique) {
|
|
324
|
+
if (this.#pathTails.get(path) === tail) this.#pathTails.delete(path);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
tracked
|
|
328
|
+
.finally(() => {
|
|
329
|
+
for (const path of unique) {
|
|
330
|
+
if (this.#pathPending.get(path) === tracked) this.#pathPending.delete(path);
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
.catch(() => {});
|
|
334
|
+
tracked.catch(() => {});
|
|
335
|
+
if (options.trackDrain) {
|
|
336
|
+
this.#drainPending.add(tracked);
|
|
337
|
+
tracked
|
|
338
|
+
.finally(() => {
|
|
339
|
+
this.#drainPending.delete(tracked);
|
|
340
|
+
})
|
|
341
|
+
.catch(() => {});
|
|
342
|
+
}
|
|
343
|
+
return tracked;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#awaitPath(path: string): Promise<void> {
|
|
347
|
+
return this.#pathPending.get(path) ?? RESOLVED;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
class IndexedSessionStorageWriter implements SessionStorageWriter {
|
|
352
|
+
#storage: IndexedSessionStorage;
|
|
353
|
+
#path: string;
|
|
354
|
+
#closed = false;
|
|
355
|
+
#error: Error | undefined;
|
|
356
|
+
#onError: ((err: Error) => void) | undefined;
|
|
357
|
+
#pendingChain: Promise<void> = Promise.resolve();
|
|
358
|
+
|
|
359
|
+
constructor(
|
|
360
|
+
storage: IndexedSessionStorage,
|
|
361
|
+
path: string,
|
|
362
|
+
options?: { flags?: "a" | "w"; onError?: (err: Error) => void },
|
|
363
|
+
) {
|
|
364
|
+
this.#storage = storage;
|
|
365
|
+
this.#path = path;
|
|
366
|
+
this.#onError = options?.onError;
|
|
367
|
+
if ((options?.flags ?? "a") === "w") {
|
|
368
|
+
const mtimeMs = storage._truncateForWriter(path);
|
|
369
|
+
this.#trackPromise(storage._queueTruncate(path, mtimeMs, () => this.#error));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
#recordError(err: unknown): Error {
|
|
374
|
+
const error = toError(err);
|
|
375
|
+
if (!this.#error) this.#error = error;
|
|
376
|
+
this.#onError?.(error);
|
|
377
|
+
return error;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#trackPromise(promise: Promise<void>): Promise<void> {
|
|
381
|
+
const next = this.#pendingChain.then(async () => {
|
|
382
|
+
if (this.#error) throw this.#error;
|
|
383
|
+
try {
|
|
384
|
+
await promise;
|
|
385
|
+
} catch (err) {
|
|
386
|
+
throw this.#recordError(err);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
this.#pendingChain = next.catch(() => {});
|
|
390
|
+
return next;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
writeLineSync(line: string): void {
|
|
394
|
+
if (this.#closed) throw new Error("Writer closed");
|
|
395
|
+
if (this.#error) throw this.#error;
|
|
396
|
+
const mtimeMs = this.#storage._appendForWriter(this.#path, line);
|
|
397
|
+
this.#trackPromise(this.#storage._queueAppend(this.#path, line, mtimeMs, () => this.#error));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async writeLine(line: string): Promise<void> {
|
|
401
|
+
if (this.#closed) throw new Error("Writer closed");
|
|
402
|
+
if (this.#error) throw this.#error;
|
|
403
|
+
const mtimeMs = this.#storage._appendForWriter(this.#path, line);
|
|
404
|
+
await this.#trackPromise(this.#storage._queueAppend(this.#path, line, mtimeMs, () => this.#error));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async flush(): Promise<void> {
|
|
408
|
+
if (this.#error) throw this.#error;
|
|
409
|
+
await this.#pendingChain;
|
|
410
|
+
if (this.#error) throw this.#error;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async fsync(): Promise<void> {
|
|
414
|
+
await this.flush();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async close(): Promise<void> {
|
|
418
|
+
if (this.#closed) return;
|
|
419
|
+
this.#closed = true;
|
|
420
|
+
try {
|
|
421
|
+
await this.flush();
|
|
422
|
+
} finally {
|
|
423
|
+
this.#storage._writerClosed(this);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
getError(): Error | undefined {
|
|
428
|
+
return this.#error;
|
|
429
|
+
}
|
|
430
|
+
}
|