@akira-tl/forgerelay 0.8.8 → 0.8.10
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 +32 -0
- package/README.md +10 -18
- package/capabilities/code-intelligence/GUIDE.md +7 -3
- package/capabilities/host-integration/GUIDE.md +2 -2
- package/capabilities/subagents/GUIDE.md +9 -0
- package/capabilities/{workspace-tasks → workspace/workspace-tasks}/GUIDE.md +6 -0
- package/dist/activity/{audit-store.js → history/audit-store.js} +129 -21
- package/dist/activity/history/bash-output-store.js +250 -0
- package/dist/activity/{host-turn-store.js → history/host-turn-store.js} +4 -1
- package/dist/activity/{query-service.js → history/query-service.js} +13 -7
- package/dist/activity/{lifecycle.js → runtime/lifecycle.js} +1 -1
- package/dist/activity/{mcp-query-tools.js → runtime/mcp-query-tools.js} +3 -3
- package/dist/activity/storage/history.js +12 -0
- package/dist/activity/storage/paths.js +56 -0
- package/dist/activity/storage/segmented-log.js +149 -0
- package/dist/cli/init.js +180 -0
- package/dist/cli/setup-support.js +181 -0
- package/dist/cli.js +23 -216
- package/dist/lsp/code-intelligence.js +18 -4
- package/dist/lsp/runtime/diagnostic-snapshots.js +52 -1
- package/dist/lsp/runtime/managed-language-servers.js +172 -0
- package/dist/lsp/runtime/manager.js +37 -2
- package/dist/lsp/runtime/process-launch.js +3 -0
- package/dist/lsp/test-support/server-fixture.js +14 -11
- package/dist/mcp/filesystem/filesystem-tools.js +245 -0
- package/dist/mcp/hooks/command-runner.js +79 -0
- package/dist/{hook-cli.js → mcp/hooks/hook-cli.js} +1 -1
- package/dist/{hooks.js → mcp/hooks/hooks.js} +3 -80
- package/dist/{oauth-provider.js → mcp/oauth/oauth-provider.js} +1 -1
- package/dist/{oauth-store.js → mcp/oauth/oauth-store.js} +5 -4
- package/dist/{operations → mcp/operations}/native-bulk-mutations.js +2 -2
- package/dist/mcp/panel/app.js +143 -0
- package/dist/{mcp-app-template.js → mcp/panel/mcp-app-template.js} +2 -38
- package/dist/{process-platform.js → mcp/process/process-platform.js} +21 -9
- package/dist/{process-sessions.js → mcp/process/process-sessions.js} +3 -3
- package/dist/mcp/process/runtime.js +257 -0
- package/dist/mcp/process/tools.js +346 -0
- package/dist/mcp/server/core/activity-support.js +70 -0
- package/dist/{capabilities.js → mcp/server/core/capabilities.js} +5 -3
- package/dist/{capability-registry.js → mcp/server/core/capability-registry.js} +9 -4
- package/dist/mcp/server/core/capability-support.js +90 -0
- package/dist/mcp/server/core/schemas.js +216 -0
- package/dist/mcp/server/core/tool-support.js +210 -0
- package/dist/mcp/server/core/version.js +9 -0
- package/dist/mcp/server/operations/runtime/filesystem-tools.js +455 -0
- package/dist/mcp/server/operations/runtime/mutation-diagnostics.js +54 -0
- package/dist/mcp/server/operations/runtime/operation-runtime.js +524 -0
- package/dist/mcp/server/transport/http-server.js +301 -0
- package/dist/mcp/server/transport/http-support.js +43 -0
- package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +242 -0
- package/dist/mcp/server/workspace/runtime/workspace-open-schema.js +187 -0
- package/dist/mcp/server/workspace/runtime/workspace-open.js +555 -0
- package/dist/mcp/server/workspace/runtime/workspace-tools.js +601 -0
- package/dist/mcp/server-instructions.js +1 -1
- package/dist/{config.js → runtime/config/config.js} +4 -3
- package/dist/{user-config.js → runtime/config/user-config.js} +14 -63
- package/dist/{db → runtime/state/db}/client.js +1 -1
- package/dist/{db → runtime/state/db}/migrations.js +34 -3
- package/dist/runtime/state/lock/file-lock.js +130 -0
- package/dist/runtime/testing/server-fixture.js +190 -0
- package/dist/server.js +133 -4189
- package/dist/subagents/providers/adapters/claude.js +86 -52
- package/dist/subagents/providers/adapters/codex.js +103 -39
- package/dist/subagents/providers/adapters/opencode.js +114 -84
- package/dist/subagents/providers/adapters/pi.js +59 -8
- package/dist/subagents/providers/availability.js +15 -30
- package/dist/subagents/providers/runtime/external-command.js +56 -0
- package/dist/subagents/sessions/capability.js +1 -1
- package/dist/subagents/sessions/execution.js +1 -1
- package/dist/subagents/sessions/mcp/activity-audit-test-support.js +30 -0
- package/dist/subagents/sessions/store.js +1 -1
- package/dist/ui/.vite/manifest.json +342 -410
- package/dist/ui/activity-panel-app.html +2 -4
- package/dist/ui/assets/activity-panel-app-7Xe0alhm.css +1 -0
- package/dist/ui/assets/activity-panel-app-Sl1Jro8v.js +115 -0
- package/dist/ui/assets/angular-html-B_yn91wW.js +1 -0
- package/dist/ui/assets/{angular-ts-Bxh4-O1-.js → angular-ts-DSgtloKz.js} +1 -1
- package/dist/ui/assets/{apl-BmYHKQ6m.js → apl-NatWtF8X.js} +1 -1
- package/dist/ui/assets/{astro-C51vqDKq.js → astro-BYFEVBuN.js} +1 -1
- package/dist/ui/assets/{blade-CdhVsmwq.js → blade-DoO7yvMR.js} +1 -1
- package/dist/ui/assets/c-Rl2hMCcg.js +1 -0
- package/dist/ui/assets/{cobol-Cf6azoN_.js → cobol-ByC5mWiB.js} +1 -1
- package/dist/ui/assets/{coffee-vVdGzlmt.js → coffee-Dez77Qov.js} +1 -1
- package/dist/ui/assets/cpp-DtaAQT9h.js +1 -0
- package/dist/ui/assets/{crystal-5UZy6KIX.js → crystal-Bgmbf8ec.js} +1 -1
- package/dist/ui/assets/css-C1tjBtVN.js +1 -0
- package/dist/ui/assets/{edge-CESgo4ia.js → edge-DeH6u8Wg.js} +1 -1
- package/dist/ui/assets/{elixir-9FKfw4cJ.js → elixir-_H3KnmWT.js} +1 -1
- package/dist/ui/assets/{elm-Dv7A0aLa.js → elm-CsCe3U28.js} +1 -1
- package/dist/ui/assets/{erb-DHFmDvM0.js → erb-CbM-L2g8.js} +1 -1
- package/dist/ui/assets/{git-rebase-utbU6CzV.js → git-rebase-D0s5pDh9.js} +1 -1
- package/dist/ui/assets/{glimmer-js-t2kiCkyB.js → glimmer-js-BfOToq_A.js} +1 -1
- package/dist/ui/assets/{glimmer-ts-F0Jv5okV.js → glimmer-ts-CCUOkL2K.js} +1 -1
- package/dist/ui/assets/glsl-DH4ec9dE.js +1 -0
- package/dist/ui/assets/graphql-DilAMBoI.js +1 -0
- package/dist/ui/assets/{hack-8yjdbaSA.js → hack-BDkNDZlF.js} +1 -1
- package/dist/ui/assets/haml-D_QBp6K7.js +1 -0
- package/dist/ui/assets/{handlebars-BBFEg4Lo.js → handlebars-DePUrTpC.js} +1 -1
- package/dist/ui/assets/heavy-payload-DOwZpO6n.js +290 -0
- package/dist/ui/assets/html-By4dNnM4.js +1 -0
- package/dist/ui/assets/{html-derivative-DJAQcZyc.js → html-derivative-DDw9Ef4Q.js} +1 -1
- package/dist/ui/assets/{http-Dg5LSYRK.js → http-B0QxWc9M.js} +1 -1
- package/dist/ui/assets/{hurl-Y5uYzMZR.js → hurl-vMQ4lda1.js} +1 -1
- package/dist/ui/assets/java-DYRe2lWG.js +1 -0
- package/dist/ui/assets/javascript-CxyGqwcL.js +1 -0
- package/dist/ui/assets/{jinja-Bt30h8Ik.js → jinja-JUwV-fZt.js} +1 -1
- package/dist/ui/assets/{jison-D5uZ1Nyo.js → jison-7ZqCEvS8.js} +1 -1
- package/dist/ui/assets/json-dbltgiiA.js +1 -0
- package/dist/ui/assets/jsx-oDopT7YQ.js +1 -0
- package/dist/ui/assets/{julia-76hRaaGz.js → julia-CkKiNSav.js} +1 -1
- package/dist/ui/assets/{just-CDE7W0vW.js → just-DuqCAKJ3.js} +1 -1
- package/dist/ui/assets/{latex-CVO2Byab.js → latex-sudcRDSn.js} +1 -1
- package/dist/ui/assets/{liquid-CA-AS3EX.js → liquid-Di-08Js7.js} +1 -1
- package/dist/ui/assets/lua-YlqdMoV0.js +1 -0
- package/dist/ui/assets/{marko-_BWrPfny.js → marko-CJRNyJDb.js} +1 -1
- package/dist/ui/assets/{mdc-PyJv85eS.js → mdc-t12CzZHs.js} +1 -1
- package/dist/ui/assets/{nginx-DAWVw8yu.js → nginx-57-b4XwT.js} +1 -1
- package/dist/ui/assets/{nim-CKE1dzpU.js → nim-BzrswYQa.js} +1 -1
- package/dist/ui/assets/{perl-BBhWva19.js → perl-D9JR_iIh.js} +1 -1
- package/dist/ui/assets/{php-ChIeaAoF.js → php-Py2gFHil.js} +1 -1
- package/dist/ui/assets/{pug-CMgI5afi.js → pug-PzLLy0h1.js} +1 -1
- package/dist/ui/assets/{qml-CjxTkcFH.js → qml-By8fwgV4.js} +1 -1
- package/dist/ui/assets/r-CcezTbLq.js +1 -0
- package/dist/ui/assets/{razor-i4DbHr8p.js → razor-dG-vdhL_.js} +1 -1
- package/dist/ui/assets/regexp-BlsQ-N_8.js +1 -0
- package/dist/ui/assets/{rst-oy_97Kl4.js → rst-C2awDCat.js} +1 -1
- package/dist/ui/assets/{ruby-C5zYZIyl.js → ruby-zA2W_49d.js} +1 -1
- package/dist/ui/assets/{sas-BnMEh2S3.js → sas-D49bhyaN.js} +1 -1
- package/dist/ui/assets/scss-BQb-cp01.js +1 -0
- package/dist/ui/assets/shellscript-Dga4H_Ni.js +1 -0
- package/dist/ui/assets/{shellsession-BpE5o7_3.js → shellsession-n3-5CqcF.js} +1 -1
- package/dist/ui/assets/{soy-COesmhTY.js → soy-XYBOf_BA.js} +1 -1
- package/dist/ui/assets/sql-ET4u5Cpl.js +1 -0
- package/dist/ui/assets/{stata-CGQEuSZq.js → stata-CLHcJHO_.js} +1 -1
- package/dist/ui/assets/{surrealql-Bz4JbU-9.js → surrealql-BdjQ60T4.js} +1 -1
- package/dist/ui/assets/{svelte-1jM2ejXj.js → svelte-B2RPtKJe.js} +1 -1
- package/dist/ui/assets/{templ-C-VwELcp.js → templ-CrByPW_W.js} +1 -1
- package/dist/ui/assets/{tex-t1rUiQCm.js → tex-DZBkg363.js} +1 -1
- package/dist/ui/assets/{ts-tags-Bewoa6pS.js → ts-tags-DJR3Gphb.js} +1 -1
- package/dist/ui/assets/tsx-CajzROyy.js +1 -0
- package/dist/ui/assets/{twig-DVg-vELl.js → twig-BZjk5Zda.js} +1 -1
- package/dist/ui/assets/typescript-B-EWToLj.js +1 -0
- package/dist/ui/assets/{vue-wPQ7UlMy.js → vue-D_rHCCr8.js} +1 -1
- package/dist/ui/assets/{vue-html-BHtJ8geX.js → vue-html-CC4WlvVt.js} +1 -1
- package/dist/ui/assets/{vue-vine-CNSGAWwc.js → vue-vine-BgNMVgpk.js} +1 -1
- package/dist/ui/assets/xml-BI86-WbH.js +1 -0
- package/dist/ui/assets/{xsl-3_vJ27aE.js → xsl-DXeLZV4J.js} +1 -1
- package/dist/ui/assets/yaml-60_PFlWa.js +1 -0
- package/dist/workspaces/bootstrap.js +72 -0
- package/dist/workspaces/context.js +337 -0
- package/dist/workspaces/conversation-test-support.js +86 -0
- package/dist/{git-worktrees.js → workspaces/git/git-worktrees.js} +1 -1
- package/dist/workspaces/inventory.js +131 -0
- package/dist/workspaces/paths.js +55 -0
- package/dist/{remote-auth.js → workspaces/relay/auth/remote-auth.js} +2 -2
- package/dist/workspaces/relay/result-support.js +111 -0
- package/dist/workspaces/relay/tests/test-support.js +203 -0
- package/dist/{remote-mcp-connection-pool.js → workspaces/relay/transport/remote-mcp-connection-pool.js} +1 -1
- package/dist/{remote-transport.js → workspaces/relay/transport/remote-transport.js} +30 -3
- package/dist/workspaces/relay/types.js +1 -0
- package/dist/{remote-workspace-relay.js → workspaces/relay/workspace-relay.js} +23 -172
- package/dist/workspaces/resources/resource-monitor.js +377 -0
- package/dist/workspaces/resources/skills.js +283 -0
- package/dist/{review-checkpoints.js → workspaces/review/review-checkpoints.js} +2 -2
- package/dist/workspaces/sessions.js +460 -0
- package/dist/{workspace-store.js → workspaces/state/workspace-store.js} +2 -2
- package/dist/workspaces.js +77 -993
- package/docs/chatgpt-coding-workflow.md +7 -14
- package/docs/configuration.md +26 -34
- package/docs/gotchas.md +0 -30
- package/docs/roadmap.md +6 -10
- package/docs/security.md +1 -5
- package/docs/setup.md +0 -11
- package/package.json +6 -9
- package/scripts/ci/architecture.mjs +109 -0
- package/scripts/ci/verify.mjs +5 -0
- package/scripts/debug/accept/bootstrap.mjs +326 -0
- package/scripts/debug/accept/harness.mjs +76 -0
- package/scripts/debug/accept/support.mjs +432 -0
- package/scripts/debug/accept.mjs +43 -806
- package/scripts/debug/relay-accept/support.mjs +135 -0
- package/scripts/debug/relay-accept.mjs +26 -115
- package/scripts/debug/traffic/support.mjs +167 -0
- package/scripts/debug/traffic/traffic-audit.mjs +4 -161
- package/scripts/{lsp-interop-support.test.mjs → lsp/interop-support.test.mjs} +1 -1
- package/scripts/{lsp-interop.mjs → lsp/interop.mjs} +3 -3
- package/scripts/release/publish.mjs +12 -9
- package/scripts/release/release-gate.test.mjs +3 -1
- package/scripts/release/release-version.test.mjs +13 -1
- package/scripts/release-parity.mjs +1 -1
- package/scripts/release-proof.mjs +15 -0
- package/scripts/release-proof.test.mjs +27 -1
- package/scripts/release-version.mjs +16 -1
- package/scripts/wiki/sync.mjs +21 -10
- package/dist/activity/bash-output-store.js +0 -127
- package/dist/pi-tools.js +0 -110
- package/dist/skills.js +0 -127
- package/dist/ui/assets/activity-panel-app-7AckaxIR.js +0 -2
- package/dist/ui/assets/angular-html-BkgDQl-G.js +0 -1
- package/dist/ui/assets/c-IBzBpxAO.js +0 -1
- package/dist/ui/assets/chunk-EyZ2wyi3.js +0 -1
- package/dist/ui/assets/cpp-CVHeO-Jk.js +0 -1
- package/dist/ui/assets/css-DEfus9J5.js +0 -1
- package/dist/ui/assets/glsl-_ooCjaeB.js +0 -1
- package/dist/ui/assets/graphql-B9LqP-cY.js +0 -1
- package/dist/ui/assets/haml-BU5EoZBn.js +0 -1
- package/dist/ui/assets/heavy-payload-Bol1_mcs.js +0 -4
- package/dist/ui/assets/html-D-C6YZk4.js +0 -1
- package/dist/ui/assets/java-ppHvycj5.js +0 -1
- package/dist/ui/assets/javascript-DDTjKLlQ.js +0 -1
- package/dist/ui/assets/json-Cw91r8h1.js +0 -1
- package/dist/ui/assets/jsx-04X2FT_1.js +0 -1
- package/dist/ui/assets/lua-CiyBp52Y.js +0 -1
- package/dist/ui/assets/r-DfFga4si.js +0 -1
- package/dist/ui/assets/regexp-iC9_nbSI.js +0 -1
- package/dist/ui/assets/review-payload-BqJp0c5e.js +0 -1
- package/dist/ui/assets/scrollbar-CvE-I-jG.js +0 -287
- package/dist/ui/assets/scss-BfZXsAWQ.js +0 -1
- package/dist/ui/assets/shellscript-BC2srNaB.js +0 -1
- package/dist/ui/assets/sql-Ccv_yeUm.js +0 -1
- package/dist/ui/assets/tsx-CTXzrBkN.js +0 -1
- package/dist/ui/assets/typescript--ElHoFkw.js +0 -1
- package/dist/ui/assets/workspace-app-BLW7p6IZ.js +0 -114
- package/dist/ui/assets/workspace-app-BjNqR0en.js +0 -1
- package/dist/ui/assets/workspace-app-CmaYU4DW.js +0 -3
- package/dist/ui/assets/workspace-app-D2bJ5fjt.css +0 -1
- package/dist/ui/assets/workspace-lifecycle-app-BVbI2Ilf.js +0 -1
- package/dist/ui/assets/xml-JnmX6vyS.js +0 -1
- package/dist/ui/assets/yaml-OAcZkP-w.js +0 -1
- package/dist/ui/workspace-app.html +0 -18
- package/dist/ui/workspace-lifecycle-app.html +0 -18
- package/docs/agent-profile-schema.md +0 -114
- package/docs/artifact-exchange.md +0 -65
- package/docs/versioning.md +0 -205
- package/skills/subagent-delegation/SKILL.md +0 -132
- /package/capabilities/{managed-worktrees → workspace/managed-worktrees}/GUIDE.md +0 -0
- /package/dist/activity/{process-output-audit.js → runtime/process-output-audit.js} +0 -0
- /package/dist/{artifact-error.js → mcp/artifacts/artifact-error.js} +0 -0
- /package/dist/{artifact-tools.js → mcp/artifacts/artifact-tools.js} +0 -0
- /package/dist/{incoming-artifacts.js → mcp/artifacts/incoming-artifacts.js} +0 -0
- /package/dist/{advertised-files.js → mcp/filesystem/advertised-files.js} +0 -0
- /package/dist/{apply-patch.js → mcp/filesystem/apply-patch.js} +0 -0
- /package/dist/{file-mutations.js → mcp/filesystem/file-mutations.js} +0 -0
- /package/dist/{roots.js → mcp/filesystem/roots.js} +0 -0
- /package/dist/{oauth → mcp/oauth}/public-url.js +0 -0
- /package/dist/{oauth → mcp/oauth}/router.js +0 -0
- /package/dist/{operations → mcp/operations}/batch/executor.js +0 -0
- /package/dist/{operations → mcp/operations}/batch/scheduler.js +0 -0
- /package/dist/{operations → mcp/operations}/batch/types.js +0 -0
- /package/dist/{operations → mcp/operations}/bulk-mutation.js +0 -0
- /package/dist/{operations → mcp/operations}/bulk-read.js +0 -0
- /package/dist/{operations → mcp/operations}/core-operation-executor.js +0 -0
- /package/dist/{request-meta.js → mcp/request-meta.js} +0 -0
- /package/dist/{mcp-sessions.js → mcp/server/transport/mcp-sessions.js} +0 -0
- /package/dist/{server-shutdown.js → mcp/server/transport/server-shutdown.js} +0 -0
- /package/dist/{logger.js → runtime/logging/logger.js} +0 -0
- /package/dist/{db → runtime/state/db}/schema.js +0 -0
- /package/dist/{composite-activity.js → workspaces/composite/composite-activity.js} +0 -0
- /package/dist/{composite-workspaces.js → workspaces/composite/composite-workspaces.js} +0 -0
- /package/dist/{git.js → workspaces/git/git.js} +0 -0
- /package/dist/{workspace-presentation.js → workspaces/presentation/workspace-presentation.js} +0 -0
- /package/dist/{workspace-task-reminders.js → workspaces/tasks/workspace-task-reminders.js} +0 -0
- /package/dist/{workspace-tasks.js → workspaces/tasks/workspace-tasks.js} +0 -0
- /package/scripts/{lsp-interop-support.mjs → lsp/interop-support.mjs} +0 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { openDatabase } from "../../runtime/state/db/client.js";
|
|
3
|
+
import { SegmentedLogStore } from "../storage/segmented-log.js";
|
|
4
|
+
import { bashOutputLogPrefix, bashOutputMetadataPrefix, stateRelativePath } from "../storage/paths.js";
|
|
5
|
+
const DEFAULT_FLUSH_BYTES = 256 * 1024;
|
|
6
|
+
const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
|
7
|
+
export class BashOutputStore {
|
|
8
|
+
database;
|
|
9
|
+
now;
|
|
10
|
+
nextOutputId;
|
|
11
|
+
stateDir;
|
|
12
|
+
logs;
|
|
13
|
+
pending = new Map();
|
|
14
|
+
flushBytes;
|
|
15
|
+
flushTimer;
|
|
16
|
+
constructor(stateDir, options = {}) {
|
|
17
|
+
this.database = openDatabase(stateDir);
|
|
18
|
+
this.now = options.now ?? (() => new Date());
|
|
19
|
+
this.nextOutputId = options.outputId ?? (() => `out_${randomUUID().replaceAll("-", "")}`);
|
|
20
|
+
this.stateDir = stateDir;
|
|
21
|
+
this.logs = new SegmentedLogStore(stateDir);
|
|
22
|
+
this.flushBytes = positiveInteger(options.flushBytes, DEFAULT_FLUSH_BYTES, "Bash output flushBytes");
|
|
23
|
+
const flushIntervalMs = positiveInteger(options.flushIntervalMs, DEFAULT_FLUSH_INTERVAL_MS, "Bash output flushIntervalMs");
|
|
24
|
+
this.migrateLegacyStreams();
|
|
25
|
+
this.migrateLegacyMetadata();
|
|
26
|
+
this.flushTimer = setInterval(() => this.flushPending(), flushIntervalMs);
|
|
27
|
+
this.flushTimer.unref();
|
|
28
|
+
}
|
|
29
|
+
begin(input) {
|
|
30
|
+
const outputId = this.nextOutputId();
|
|
31
|
+
const startedAt = this.now().toISOString();
|
|
32
|
+
const workspace = { id: input.workspaceId, root: input.workspaceRoot, mode: "checkout" };
|
|
33
|
+
const logPrefix = stateRelativePath(this.stateDir, bashOutputLogPrefix(this.stateDir, workspace, outputId));
|
|
34
|
+
const commandRef = this.logs.append(bashOutputMetadataPrefix(this.stateDir, workspace, outputId, "command"), Buffer.from(input.command, "utf8"));
|
|
35
|
+
this.database.sqlite.prepare(`insert into bash_output_streams (
|
|
36
|
+
id, activity_id, turn_id, conversation_scope_id, process_id,
|
|
37
|
+
workspace_id, workspace_root, command, tty, status, timed_out, started_at,
|
|
38
|
+
log_file, output_bytes, command_file, command_offset, command_length
|
|
39
|
+
) values (?, ?, ?, ?, ?, ?, ?, '', ?, 'running', 0, ?, ?, 0, ?, ?, ?)`).run(outputId, input.activityId, input.turnId, input.conversationScopeId ?? null, input.processId, input.workspaceId, input.workspaceRoot, input.tty ? 1 : 0, startedAt, logPrefix, commandRef.prefix, commandRef.offset, commandRef.length);
|
|
40
|
+
return outputId;
|
|
41
|
+
}
|
|
42
|
+
append(outputId, _channel, data) {
|
|
43
|
+
const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
|
|
44
|
+
if (bytes.length === 0)
|
|
45
|
+
return;
|
|
46
|
+
const pending = this.pending.get(outputId) ?? { buffers: [], bytes: 0 };
|
|
47
|
+
pending.buffers.push(bytes);
|
|
48
|
+
pending.bytes += bytes.length;
|
|
49
|
+
this.pending.set(outputId, pending);
|
|
50
|
+
if (pending.bytes >= this.flushBytes)
|
|
51
|
+
this.flushOutput(outputId);
|
|
52
|
+
}
|
|
53
|
+
markReturned(outputId) {
|
|
54
|
+
this.flushOutput(outputId);
|
|
55
|
+
this.database.sqlite.prepare("update bash_output_streams set returned = 1 where id = ?").run(outputId);
|
|
56
|
+
}
|
|
57
|
+
claimCompletion(outputId) {
|
|
58
|
+
this.flushOutput(outputId);
|
|
59
|
+
const claimedAt = this.now().toISOString();
|
|
60
|
+
const claimed = this.database.sqlite.prepare(`update bash_output_streams
|
|
61
|
+
set completion_claimed_at = ?
|
|
62
|
+
where id = ? and returned = 1 and status != 'running' and completion_claimed_at is null`).run(claimedAt, outputId);
|
|
63
|
+
return claimed.changes === 1 ? this.readMetadata(outputId) : undefined;
|
|
64
|
+
}
|
|
65
|
+
finish(outputId, input) {
|
|
66
|
+
this.flushOutput(outputId);
|
|
67
|
+
const row = this.requireStream(outputId);
|
|
68
|
+
const status = input.error || input.timedOut || input.signal || (input.exitCode !== undefined && input.exitCode !== 0)
|
|
69
|
+
? "failed"
|
|
70
|
+
: "done";
|
|
71
|
+
const errorRef = input.error
|
|
72
|
+
? this.logs.append(bashOutputMetadataPrefix(this.stateDir, { id: row.workspace_id, root: row.workspace_root, mode: "checkout" }, outputId, "error"), Buffer.from(input.error, "utf8"))
|
|
73
|
+
: undefined;
|
|
74
|
+
this.database.sqlite.prepare(`update bash_output_streams
|
|
75
|
+
set status = ?, exit_code = ?, signal = ?, timed_out = ?, error = null, finished_at = ?,
|
|
76
|
+
error_file = ?, error_offset = ?, error_length = ?
|
|
77
|
+
where id = ?`).run(status, input.exitCode ?? null, input.signal ?? null, input.timedOut ? 1 : 0, this.now().toISOString(), errorRef?.prefix ?? null, errorRef?.offset ?? null, errorRef?.length ?? null, outputId);
|
|
78
|
+
}
|
|
79
|
+
read(outputId) {
|
|
80
|
+
const metadata = this.readMetadata(outputId);
|
|
81
|
+
if (!metadata)
|
|
82
|
+
return undefined;
|
|
83
|
+
const row = this.requireStream(outputId);
|
|
84
|
+
const prefix = this.ensureFileBacked(row);
|
|
85
|
+
const output = this.logs.readRange(prefix, 0, metadata.outputBytes).toString("utf8");
|
|
86
|
+
return { ...metadata, output };
|
|
87
|
+
}
|
|
88
|
+
readSince(outputId, cursor = 0) {
|
|
89
|
+
const metadata = this.readMetadata(outputId);
|
|
90
|
+
if (!metadata)
|
|
91
|
+
return undefined;
|
|
92
|
+
if (!Number.isSafeInteger(cursor) || cursor < 0) {
|
|
93
|
+
throw new Error(`Bash output cursor must be a non-negative integer: ${cursor}.`);
|
|
94
|
+
}
|
|
95
|
+
const start = Math.min(cursor, metadata.outputBytes);
|
|
96
|
+
const row = this.requireStream(outputId);
|
|
97
|
+
const prefix = this.ensureFileBacked(row);
|
|
98
|
+
const output = this.logs.readRange(prefix, start, metadata.outputBytes).toString("utf8");
|
|
99
|
+
return { ...metadata, output, cursor: metadata.outputBytes };
|
|
100
|
+
}
|
|
101
|
+
readMetadata(outputId) {
|
|
102
|
+
this.flushOutput(outputId);
|
|
103
|
+
const row = this.readStream(outputId);
|
|
104
|
+
if (!row)
|
|
105
|
+
return undefined;
|
|
106
|
+
this.ensureFileBacked(row);
|
|
107
|
+
const refreshed = this.requireStream(outputId);
|
|
108
|
+
return this.rowToMetadata(refreshed);
|
|
109
|
+
}
|
|
110
|
+
deleteWorkspace(workspaceId) {
|
|
111
|
+
const rows = this.database.sqlite.prepare("select id from bash_output_streams where workspace_id = ?").all(workspaceId);
|
|
112
|
+
for (const row of rows) {
|
|
113
|
+
this.flushOutput(row.id);
|
|
114
|
+
this.pending.delete(row.id);
|
|
115
|
+
}
|
|
116
|
+
this.database.sqlite.prepare("delete from bash_output_streams where workspace_id = ?").run(workspaceId);
|
|
117
|
+
}
|
|
118
|
+
close() {
|
|
119
|
+
clearInterval(this.flushTimer);
|
|
120
|
+
this.flushPending();
|
|
121
|
+
this.database.close();
|
|
122
|
+
}
|
|
123
|
+
flushPending() {
|
|
124
|
+
for (const outputId of [...this.pending.keys()])
|
|
125
|
+
this.flushOutput(outputId);
|
|
126
|
+
}
|
|
127
|
+
flushOutput(outputId) {
|
|
128
|
+
const pending = this.pending.get(outputId);
|
|
129
|
+
if (!pending || pending.bytes === 0)
|
|
130
|
+
return;
|
|
131
|
+
const row = this.requireStream(outputId);
|
|
132
|
+
const prefix = this.ensureFileBacked(row);
|
|
133
|
+
const onDiskBytes = this.logs.logicalLength(prefix);
|
|
134
|
+
if (onDiskBytes < row.output_bytes) {
|
|
135
|
+
throw new Error(`Bash output ${outputId} metadata references ${row.output_bytes} bytes but only ${onDiskBytes} are on disk.`);
|
|
136
|
+
}
|
|
137
|
+
const data = Buffer.concat(pending.buffers, pending.bytes);
|
|
138
|
+
// File first, index second. If SQLite updating fails after the write, the
|
|
139
|
+
// next access reconciles output_bytes from the append-only log rather than
|
|
140
|
+
// re-emitting the already-written payload.
|
|
141
|
+
this.logs.appendAt(prefix, onDiskBytes, data);
|
|
142
|
+
this.pending.delete(outputId);
|
|
143
|
+
this.database.sqlite.prepare("update bash_output_streams set output_bytes = ? where id = ?").run(onDiskBytes + data.length, outputId);
|
|
144
|
+
}
|
|
145
|
+
ensureFileBacked(row) {
|
|
146
|
+
let prefix = row.log_file;
|
|
147
|
+
if (!prefix) {
|
|
148
|
+
prefix = stateRelativePath(this.stateDir, bashOutputLogPrefix(this.stateDir, { id: row.workspace_id, root: row.workspace_root, mode: "checkout" }, row.id));
|
|
149
|
+
let logicalOffset = this.logs.logicalLength(prefix);
|
|
150
|
+
const legacyRows = this.database.sqlite.prepare(`select data from bash_output_chunks
|
|
151
|
+
where output_id = ?
|
|
152
|
+
order by sequence asc`);
|
|
153
|
+
for (const legacy of legacyRows.iterate(row.id)) {
|
|
154
|
+
this.logs.appendAt(prefix, logicalOffset, legacy.data);
|
|
155
|
+
logicalOffset += legacy.data.length;
|
|
156
|
+
}
|
|
157
|
+
this.database.sqlite.transaction(() => {
|
|
158
|
+
this.database.sqlite.prepare("update bash_output_streams set log_file = ?, output_bytes = ? where id = ?").run(prefix, logicalOffset, row.id);
|
|
159
|
+
this.database.sqlite.prepare("delete from bash_output_chunks where output_id = ?").run(row.id);
|
|
160
|
+
}).immediate();
|
|
161
|
+
row.log_file = prefix;
|
|
162
|
+
row.output_bytes = logicalOffset;
|
|
163
|
+
return prefix;
|
|
164
|
+
}
|
|
165
|
+
const onDiskBytes = this.logs.logicalLength(prefix);
|
|
166
|
+
if (onDiskBytes < row.output_bytes) {
|
|
167
|
+
throw new Error(`Bash output ${row.id} index is ahead of its segmented log (${row.output_bytes} > ${onDiskBytes}).`);
|
|
168
|
+
}
|
|
169
|
+
if (onDiskBytes > row.output_bytes) {
|
|
170
|
+
this.database.sqlite.prepare("update bash_output_streams set output_bytes = ? where id = ?").run(onDiskBytes, row.id);
|
|
171
|
+
row.output_bytes = onDiskBytes;
|
|
172
|
+
}
|
|
173
|
+
return prefix;
|
|
174
|
+
}
|
|
175
|
+
migrateLegacyStreams() {
|
|
176
|
+
const rows = this.database.sqlite.prepare("select * from bash_output_streams where log_file is null order by started_at asc");
|
|
177
|
+
for (const row of rows.iterate()) {
|
|
178
|
+
this.ensureFileBacked(row);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
migrateLegacyMetadata() {
|
|
182
|
+
const rows = this.database.sqlite.prepare(`select * from bash_output_streams
|
|
183
|
+
where command_file is null or (error is not null and error_file is null)
|
|
184
|
+
order by started_at asc`);
|
|
185
|
+
const update = this.database.sqlite.prepare(`update bash_output_streams
|
|
186
|
+
set command = '', error = null,
|
|
187
|
+
command_file = ?, command_offset = ?, command_length = ?,
|
|
188
|
+
error_file = ?, error_offset = ?, error_length = ?
|
|
189
|
+
where id = ?`);
|
|
190
|
+
for (const row of rows.iterate()) {
|
|
191
|
+
const workspace = { id: row.workspace_id, root: row.workspace_root, mode: "checkout" };
|
|
192
|
+
const commandRef = row.command_file
|
|
193
|
+
? undefined
|
|
194
|
+
: this.logs.append(bashOutputMetadataPrefix(this.stateDir, workspace, row.id, "command"), Buffer.from(row.command, "utf8"));
|
|
195
|
+
const errorRef = row.error && !row.error_file
|
|
196
|
+
? this.logs.append(bashOutputMetadataPrefix(this.stateDir, workspace, row.id, "error"), Buffer.from(row.error, "utf8"))
|
|
197
|
+
: undefined;
|
|
198
|
+
update.run(commandRef?.prefix ?? row.command_file, commandRef?.offset ?? row.command_offset, commandRef?.length ?? row.command_length, errorRef?.prefix ?? row.error_file, errorRef?.offset ?? row.error_offset, errorRef?.length ?? row.error_length, row.id);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
rowToMetadata(stream) {
|
|
202
|
+
const command = this.readMetadataField(stream.command_file, stream.command_offset, stream.command_length, stream.command);
|
|
203
|
+
const error = this.readMetadataField(stream.error_file, stream.error_offset, stream.error_length, stream.error ?? undefined);
|
|
204
|
+
return {
|
|
205
|
+
outputId: stream.id,
|
|
206
|
+
activityId: stream.activity_id,
|
|
207
|
+
turnId: stream.turn_id,
|
|
208
|
+
...(stream.conversation_scope_id ? { conversationScopeId: stream.conversation_scope_id } : {}),
|
|
209
|
+
processId: stream.process_id,
|
|
210
|
+
workspaceId: stream.workspace_id,
|
|
211
|
+
workspaceRoot: stream.workspace_root,
|
|
212
|
+
command,
|
|
213
|
+
tty: stream.tty === 1,
|
|
214
|
+
status: isBashOutputStatus(stream.status) ? stream.status : "failed",
|
|
215
|
+
...(stream.exit_code !== null ? { exitCode: stream.exit_code } : {}),
|
|
216
|
+
...(stream.signal ? { signal: stream.signal } : {}),
|
|
217
|
+
timedOut: stream.timed_out === 1,
|
|
218
|
+
...(error ? { error } : {}),
|
|
219
|
+
returned: stream.returned === 1,
|
|
220
|
+
outputBytes: stream.output_bytes,
|
|
221
|
+
startedAt: stream.started_at,
|
|
222
|
+
...(stream.finished_at ? { finishedAt: stream.finished_at } : {}),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
readMetadataField(file, offset, length, fallback) {
|
|
226
|
+
if (file !== null && offset !== null && length !== null) {
|
|
227
|
+
return this.logs.read({ prefix: file, offset, length }).toString("utf8");
|
|
228
|
+
}
|
|
229
|
+
return fallback ?? "";
|
|
230
|
+
}
|
|
231
|
+
readStream(outputId) {
|
|
232
|
+
return this.database.sqlite.prepare("select * from bash_output_streams where id = ?").get(outputId);
|
|
233
|
+
}
|
|
234
|
+
requireStream(outputId) {
|
|
235
|
+
const row = this.readStream(outputId);
|
|
236
|
+
if (!row)
|
|
237
|
+
throw new Error(`Unknown Bash output: ${outputId}.`);
|
|
238
|
+
return row;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function isBashOutputStatus(value) {
|
|
242
|
+
return value === "running" || value === "done" || value === "failed";
|
|
243
|
+
}
|
|
244
|
+
function positiveInteger(value, fallback, label) {
|
|
245
|
+
const resolved = value ?? fallback;
|
|
246
|
+
if (!Number.isSafeInteger(resolved) || resolved <= 0) {
|
|
247
|
+
throw new Error(`${label} must be a positive integer.`);
|
|
248
|
+
}
|
|
249
|
+
return resolved;
|
|
250
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { openDatabase } from "
|
|
2
|
+
import { openDatabase } from "../../runtime/state/db/client.js";
|
|
3
3
|
export class HostTurnStore {
|
|
4
4
|
database;
|
|
5
5
|
now;
|
|
@@ -34,6 +34,9 @@ export class HostTurnStore {
|
|
|
34
34
|
limit 1`).get(conversationScopeId, workspaceId);
|
|
35
35
|
return row ? rowToTurn(row) : undefined;
|
|
36
36
|
}
|
|
37
|
+
deleteWorkspace(workspaceId) {
|
|
38
|
+
this.database.sqlite.prepare("delete from activity_host_turns where workspace_id = ?").run(workspaceId);
|
|
39
|
+
}
|
|
37
40
|
close() {
|
|
38
41
|
this.database.close();
|
|
39
42
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deleteWorkspaceActivityHistory } from "../storage/history.js";
|
|
1
2
|
export class ActivityQueryService {
|
|
2
3
|
turns;
|
|
3
4
|
audit;
|
|
@@ -7,6 +8,15 @@ export class ActivityQueryService {
|
|
|
7
8
|
this.audit = audit;
|
|
8
9
|
this.outputs = outputs;
|
|
9
10
|
}
|
|
11
|
+
deleteWorkspaceHistory(stateDir, workspaceId) {
|
|
12
|
+
deleteWorkspaceActivityHistory({
|
|
13
|
+
stateDir,
|
|
14
|
+
workspaceId,
|
|
15
|
+
audit: this.audit,
|
|
16
|
+
outputs: this.outputs,
|
|
17
|
+
turns: this.turns,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
10
20
|
beginTurn(conversationScopeId, workspaceId) {
|
|
11
21
|
const turn = this.turns.begin(conversationScopeId, workspaceId);
|
|
12
22
|
return this.state(turn.turnId);
|
|
@@ -73,7 +83,7 @@ export class ActivityQueryService {
|
|
|
73
83
|
}
|
|
74
84
|
bashOutput(turnId, outputId, cursor = 0) {
|
|
75
85
|
this.requireTurn(turnId);
|
|
76
|
-
const output = this.outputs.
|
|
86
|
+
const output = this.outputs.readSince(outputId, cursor);
|
|
77
87
|
if (!output)
|
|
78
88
|
throw new Error(`Unknown Bash output: ${outputId}.`);
|
|
79
89
|
const activities = this.audit.listActivitiesByTurn(turnId);
|
|
@@ -81,17 +91,13 @@ export class ActivityQueryService {
|
|
|
81
91
|
if (!visible) {
|
|
82
92
|
throw new Error(`Bash output ${outputId} is not part of Host Turn ${turnId}.`);
|
|
83
93
|
}
|
|
84
|
-
const latestCursor = output.chunks.at(-1)?.sequence ?? 0;
|
|
85
94
|
return {
|
|
86
95
|
outputId: output.outputId,
|
|
87
96
|
activityId: output.activityId,
|
|
88
97
|
processId: output.processId,
|
|
89
98
|
command: output.command,
|
|
90
|
-
output: output.
|
|
91
|
-
|
|
92
|
-
.map((chunk) => chunk.data)
|
|
93
|
-
.join(""),
|
|
94
|
-
cursor: latestCursor,
|
|
99
|
+
output: output.output,
|
|
100
|
+
cursor: output.cursor,
|
|
95
101
|
status: output.status,
|
|
96
102
|
...(output.exitCode !== undefined ? { exitCode: output.exitCode } : {}),
|
|
97
103
|
...(output.signal !== undefined ? { signal: output.signal } : {}),
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
|
|
2
2
|
import * as z from "zod/v4";
|
|
3
|
-
import { logEvent, transportSessionIdPrefix } from "
|
|
4
|
-
import { hostConversationScopeId } from "
|
|
5
|
-
import { ACTIVITY_PANEL_DEFAULT_EXPANDED_META_KEY, ACTIVITY_PANEL_WORKSPACE_META_KEY, } from "
|
|
3
|
+
import { logEvent, transportSessionIdPrefix } from "../../runtime/logging/logger.js";
|
|
4
|
+
import { hostConversationScopeId } from "../../mcp/request-meta.js";
|
|
5
|
+
import { ACTIVITY_PANEL_DEFAULT_EXPANDED_META_KEY, ACTIVITY_PANEL_WORKSPACE_META_KEY, } from "../ui/contract.js";
|
|
6
6
|
const READ_ONLY_ANNOTATIONS = {
|
|
7
7
|
readOnlyHint: true,
|
|
8
8
|
destructiveHint: false,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { removeWorkspaceActivityStorage } from "./paths.js";
|
|
2
|
+
/**
|
|
3
|
+
* User-requested Workspace deletion is the retention boundary for durable
|
|
4
|
+
* Activity history by default. Index rows are removed before the backing files
|
|
5
|
+
* so a crash cannot leave SQLite pointing at already-deleted payload shards.
|
|
6
|
+
*/
|
|
7
|
+
export function deleteWorkspaceActivityHistory(input) {
|
|
8
|
+
input.outputs.deleteWorkspace(input.workspaceId);
|
|
9
|
+
input.audit.deleteWorkspace(input.workspaceId);
|
|
10
|
+
input.turns.deleteWorkspace(input.workspaceId);
|
|
11
|
+
removeWorkspaceActivityStorage(input.stateDir, input.workspaceId);
|
|
12
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
const SAFE_WORKSPACE_ID = /^(?:ws|rws|cws)_[a-z0-9]+$/i;
|
|
5
|
+
export function activityWorkspaceStorageKey(workspace) {
|
|
6
|
+
if (workspace.id && SAFE_WORKSPACE_ID.test(workspace.id))
|
|
7
|
+
return workspace.id;
|
|
8
|
+
return `root_${createHash("sha256").update(`${workspace.mode}\0${resolve(workspace.root)}`).digest("hex").slice(0, 20)}`;
|
|
9
|
+
}
|
|
10
|
+
export function activityWorkspaceDirectory(stateDir, workspace) {
|
|
11
|
+
return join(stateDir, "workspaces", activityWorkspaceStorageKey(workspace), "activity");
|
|
12
|
+
}
|
|
13
|
+
export function activityEventLogPrefix(stateDir, workspace) {
|
|
14
|
+
return join(activityWorkspaceDirectory(stateDir, workspace), "events");
|
|
15
|
+
}
|
|
16
|
+
export function bashOutputDirectory(stateDir, workspace) {
|
|
17
|
+
return join(activityWorkspaceDirectory(stateDir, workspace), "bash");
|
|
18
|
+
}
|
|
19
|
+
export function bashOutputLogPrefix(stateDir, workspace, outputId) {
|
|
20
|
+
return join(bashOutputDirectory(stateDir, workspace), outputId);
|
|
21
|
+
}
|
|
22
|
+
export function bashOutputMetadataPrefix(stateDir, workspace, outputId, field) {
|
|
23
|
+
return join(bashOutputDirectory(stateDir, workspace), `${outputId}-${field}`);
|
|
24
|
+
}
|
|
25
|
+
export function removeWorkspaceActivityStorage(stateDir, workspaceId) {
|
|
26
|
+
if (!SAFE_WORKSPACE_ID.test(workspaceId)) {
|
|
27
|
+
throw new Error(`Invalid Workspace id for Activity storage cleanup: ${workspaceId}`);
|
|
28
|
+
}
|
|
29
|
+
rmSync(join(stateDir, "workspaces", workspaceId, "activity"), {
|
|
30
|
+
recursive: true,
|
|
31
|
+
force: true,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export function ensurePrivateDirectory(path) {
|
|
35
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
36
|
+
}
|
|
37
|
+
export function stateRelativePath(stateDir, path) {
|
|
38
|
+
const relationship = relative(resolve(stateDir), resolve(path));
|
|
39
|
+
if (relationship === "" ||
|
|
40
|
+
relationship === ".." ||
|
|
41
|
+
relationship.startsWith(`..${sep}`)) {
|
|
42
|
+
throw new Error(`Activity log path is outside ForgeRelay state directory: ${path}`);
|
|
43
|
+
}
|
|
44
|
+
return relationship.split(sep).join("/");
|
|
45
|
+
}
|
|
46
|
+
export function resolveStateRelativePath(stateDir, path) {
|
|
47
|
+
const absolute = resolve(stateDir, path);
|
|
48
|
+
const relationship = relative(resolve(stateDir), absolute);
|
|
49
|
+
if (relationship === ".." || relationship.startsWith(`..${sep}`)) {
|
|
50
|
+
throw new Error(`Activity log reference escapes ForgeRelay state directory: ${path}`);
|
|
51
|
+
}
|
|
52
|
+
return absolute;
|
|
53
|
+
}
|
|
54
|
+
export function ensureParentPrivate(path) {
|
|
55
|
+
ensurePrivateDirectory(dirname(path));
|
|
56
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { closeSync, constants as fsConstants, fstatSync, openSync, readSync, readdirSync, statSync, writeSync, } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { ensureParentPrivate, resolveStateRelativePath, stateRelativePath } from "./paths.js";
|
|
4
|
+
/**
|
|
5
|
+
* Segment files are deliberately small enough to inspect, copy, prune, and
|
|
6
|
+
* recover independently. The logical stream may be arbitrarily large; no
|
|
7
|
+
* individual on-disk activity/output file should grow without bound.
|
|
8
|
+
*/
|
|
9
|
+
export const ACTIVITY_LOG_SEGMENT_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
export class SegmentedLogStore {
|
|
11
|
+
stateDir;
|
|
12
|
+
segmentBytes;
|
|
13
|
+
appendOffsets = new Map();
|
|
14
|
+
constructor(stateDir, segmentBytes = ACTIVITY_LOG_SEGMENT_BYTES) {
|
|
15
|
+
this.stateDir = stateDir;
|
|
16
|
+
this.segmentBytes = segmentBytes;
|
|
17
|
+
}
|
|
18
|
+
append(prefixPath, data) {
|
|
19
|
+
const prefix = stateRelativePath(this.stateDir, prefixPath);
|
|
20
|
+
const currentOffset = this.appendOffsets.get(prefix) ?? this.discoverLength(prefixPath);
|
|
21
|
+
this.writeAt(prefixPath, currentOffset, data);
|
|
22
|
+
this.appendOffsets.set(prefix, currentOffset + data.byteLength);
|
|
23
|
+
return { prefix, offset: currentOffset, length: data.byteLength };
|
|
24
|
+
}
|
|
25
|
+
appendAt(prefix, logicalOffset, data) {
|
|
26
|
+
const prefixPath = resolveStateRelativePath(this.stateDir, prefix);
|
|
27
|
+
this.writeAt(prefixPath, logicalOffset, data);
|
|
28
|
+
const next = logicalOffset + data.byteLength;
|
|
29
|
+
const known = this.appendOffsets.get(prefix);
|
|
30
|
+
if (known === undefined || next > known)
|
|
31
|
+
this.appendOffsets.set(prefix, next);
|
|
32
|
+
}
|
|
33
|
+
read(reference) {
|
|
34
|
+
return this.readRange(reference.prefix, reference.offset, reference.offset + reference.length);
|
|
35
|
+
}
|
|
36
|
+
readRange(prefix, start, end) {
|
|
37
|
+
if (!Number.isSafeInteger(start) || start < 0 || !Number.isSafeInteger(end) || end < start) {
|
|
38
|
+
throw new Error(`Invalid segmented log range: ${start}..${end}.`);
|
|
39
|
+
}
|
|
40
|
+
if (start === end)
|
|
41
|
+
return Buffer.alloc(0);
|
|
42
|
+
const prefixPath = resolveStateRelativePath(this.stateDir, prefix);
|
|
43
|
+
const result = Buffer.allocUnsafe(end - start);
|
|
44
|
+
let logicalOffset = start;
|
|
45
|
+
let resultOffset = 0;
|
|
46
|
+
while (logicalOffset < end) {
|
|
47
|
+
const segmentIndex = Math.floor(logicalOffset / this.segmentBytes);
|
|
48
|
+
const segmentOffset = logicalOffset % this.segmentBytes;
|
|
49
|
+
const available = Math.min(end - logicalOffset, this.segmentBytes - segmentOffset);
|
|
50
|
+
const path = segmentPath(prefixPath, segmentIndex);
|
|
51
|
+
const fd = openSync(path, fsConstants.O_RDONLY);
|
|
52
|
+
try {
|
|
53
|
+
const bytesRead = readSync(fd, result, resultOffset, available, segmentOffset);
|
|
54
|
+
if (bytesRead !== available) {
|
|
55
|
+
throw new Error(`Segmented log ${path} ended before logical offset ${logicalOffset + available}.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
closeSync(fd);
|
|
60
|
+
}
|
|
61
|
+
logicalOffset += available;
|
|
62
|
+
resultOffset += available;
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
logicalLength(prefix) {
|
|
67
|
+
const known = this.appendOffsets.get(prefix);
|
|
68
|
+
if (known !== undefined)
|
|
69
|
+
return known;
|
|
70
|
+
const prefixPath = resolveStateRelativePath(this.stateDir, prefix);
|
|
71
|
+
const length = this.discoverLength(prefixPath);
|
|
72
|
+
this.appendOffsets.set(prefix, length);
|
|
73
|
+
return length;
|
|
74
|
+
}
|
|
75
|
+
writeAt(prefixPath, logicalOffset, data) {
|
|
76
|
+
if (!Number.isSafeInteger(logicalOffset) || logicalOffset < 0) {
|
|
77
|
+
throw new Error(`Invalid segmented log offset: ${logicalOffset}.`);
|
|
78
|
+
}
|
|
79
|
+
if (data.byteLength === 0)
|
|
80
|
+
return;
|
|
81
|
+
ensureParentPrivate(prefixPath);
|
|
82
|
+
const bytes = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
83
|
+
let sourceOffset = 0;
|
|
84
|
+
let current = logicalOffset;
|
|
85
|
+
while (sourceOffset < bytes.length) {
|
|
86
|
+
const segmentIndex = Math.floor(current / this.segmentBytes);
|
|
87
|
+
const segmentOffset = current % this.segmentBytes;
|
|
88
|
+
const writable = Math.min(bytes.length - sourceOffset, this.segmentBytes - segmentOffset);
|
|
89
|
+
const path = segmentPath(prefixPath, segmentIndex);
|
|
90
|
+
const fd = openSync(path, fsConstants.O_CREAT | fsConstants.O_WRONLY, 0o600);
|
|
91
|
+
try {
|
|
92
|
+
const stats = fstatSync(fd);
|
|
93
|
+
if (stats.size !== segmentOffset) {
|
|
94
|
+
throw new Error(`Segmented log append position mismatch for ${path}: expected ${segmentOffset}, found ${stats.size}.`);
|
|
95
|
+
}
|
|
96
|
+
const written = writeSync(fd, bytes, sourceOffset, writable, segmentOffset);
|
|
97
|
+
if (written !== writable) {
|
|
98
|
+
throw new Error(`Short write while appending segmented log ${path}.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
closeSync(fd);
|
|
103
|
+
}
|
|
104
|
+
sourceOffset += writable;
|
|
105
|
+
current += writable;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
discoverLength(prefixPath) {
|
|
109
|
+
const directory = dirname(prefixPath);
|
|
110
|
+
const stem = basename(prefixPath);
|
|
111
|
+
let files;
|
|
112
|
+
try {
|
|
113
|
+
files = readdirSync(directory);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error.code === "ENOENT")
|
|
117
|
+
return 0;
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
const pattern = new RegExp(`^${escapeRegExp(stem)}\\.(\\d{6})\\.log$`);
|
|
121
|
+
let maxIndex = -1;
|
|
122
|
+
let maxSize = 0;
|
|
123
|
+
for (const file of files) {
|
|
124
|
+
const match = pattern.exec(file);
|
|
125
|
+
if (!match)
|
|
126
|
+
continue;
|
|
127
|
+
const index = Number(match[1]);
|
|
128
|
+
const size = statSync(join(directory, file)).size;
|
|
129
|
+
if (size > this.segmentBytes) {
|
|
130
|
+
throw new Error(`Segmented log shard exceeds ${this.segmentBytes} bytes: ${join(directory, file)}.`);
|
|
131
|
+
}
|
|
132
|
+
if (index > maxIndex) {
|
|
133
|
+
maxIndex = index;
|
|
134
|
+
maxSize = size;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (maxIndex < 0)
|
|
138
|
+
return 0;
|
|
139
|
+
if (maxSize < this.segmentBytes)
|
|
140
|
+
return maxIndex * this.segmentBytes + maxSize;
|
|
141
|
+
return (maxIndex + 1) * this.segmentBytes;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export function segmentPath(prefixPath, index) {
|
|
145
|
+
return `${prefixPath}.${String(index).padStart(6, "0")}.log`;
|
|
146
|
+
}
|
|
147
|
+
function escapeRegExp(value) {
|
|
148
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
149
|
+
}
|