diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c245f17..3a56f2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,7 @@ jobs: - run: pnpm verify:imported-avatar-current-inputs - run: pnpm avatar:verify:compatibility - run: pnpm check:core + - run: node --test tooling/release/workstream.test.mjs push-checks: needs: [scope, docs, core] diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 237512d..0efe355 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -96,8 +96,8 @@ jobs: run: | $root=Join-Path $env:RUNNER_TEMP 'world-packages' $env:AIW_SMOKE_NO_SYSTEM_GIT='1' - ./tooling/release/install-smoke.ps1 -Installer "$root\AgentIntersect-World-0.15.0-rc.2-windows.2-windows-x64-setup.exe" -SmokeTest "$env:GITHUB_WORKSPACE\tooling\release\launch.test.mjs" -Receipt "$root\windows-smoke.json" - $file=Get-Item "$root\AgentIntersect-World-0.15.0-rc.2-windows.2-windows-x64-setup.exe" + ./tooling/release/install-smoke.ps1 -Installer "$root\AgentIntersect-World-0.15.0-rc.2-windows.3-windows-x64-setup.exe" -SmokeTest "$env:GITHUB_WORKSPACE\tooling\release\launch.test.mjs" -Receipt "$root\windows-smoke.json" + $file=Get-Item "$root\AgentIntersect-World-0.15.0-rc.2-windows.3-windows-x64-setup.exe" ((Get-FileHash $file.FullName -Algorithm SHA256).Hash.ToLower()+' '+$file.Name) | Set-Content "$root\WINDOWS-SHA256SUMS" -Encoding utf8NoBOM - uses: actions/upload-artifact@v7 with: diff --git a/apps/local-server/src/workstream-routes.ts b/apps/local-server/src/workstream-routes.ts index 2167662..4aa2dd5 100644 --- a/apps/local-server/src/workstream-routes.ts +++ b/apps/local-server/src/workstream-routes.ts @@ -2,6 +2,7 @@ import { MAX_PROMPT_BYTES } from "@agentintersect-world/agent-session-protocol"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { PreviewManagerService } from "./preview-manager-service.js"; import { WorkstreamSourceError } from "./workstream-start-source.js"; +import { WorktreeAuthorityError } from "./worktree-authority.js"; import { WorkstreamService, @@ -85,6 +86,19 @@ function fail( reply: FastifyReply, envelope: RouteEnvelope, ) { + if (error instanceof WorktreeAuthorityError) { + return reply + .code(error.code === "unavailable" ? 503 : 409) + .send( + envelope.failure( + request, + error.code === "unavailable" ? "unavailable" : "conflict", + error.code === "git-failed" + ? "Git could not complete the Workstream operation. Check the selected branch, commit and worktree permissions." + : error.message, + ), + ); + } if (error instanceof WorkstreamSourceError) error = new WorkstreamServiceError("validation", error.message); if (!(error instanceof WorkstreamServiceError)) throw error; diff --git a/apps/local-server/src/workstream-service.ts b/apps/local-server/src/workstream-service.ts index 8509213..ec14d58 100644 --- a/apps/local-server/src/workstream-service.ts +++ b/apps/local-server/src/workstream-service.ts @@ -372,7 +372,7 @@ function optionalRead(path: string): Promise { } async function syncFile(path: string): Promise { - const handle = await open(path, "r"); + const handle = await open(path, "r+"); try { await handle.sync(); } finally { diff --git a/apps/local-server/src/worktree-authority.ts b/apps/local-server/src/worktree-authority.ts index fc85166..fcb7d24 100644 --- a/apps/local-server/src/worktree-authority.ts +++ b/apps/local-server/src/worktree-authority.ts @@ -718,7 +718,10 @@ export class WorktreeAuthority { ); return { ...boundary, - relativePath: relative(boundary.allowedParent, input.worktreePath), + relativePath: relative( + boundary.allowedParent, + join(parent, basename(input.worktreePath)), + ), }; } @@ -833,7 +836,11 @@ export class WorktreeAuthority { registered.exitCode !== 0 || !registered.stdout .split(/\r?\n/) - .some((line) => line === `worktree ${input.worktreePath}`) + .some( + (line) => + line.startsWith("worktree ") && + resolve(line.slice("worktree ".length)) === input.worktreePath, + ) ) throw new WorktreeAuthorityError( "git-refused", diff --git a/apps/local-server/test/workstream-api.test.ts b/apps/local-server/test/workstream-api.test.ts index c606f7d..f9013d3 100644 --- a/apps/local-server/test/workstream-api.test.ts +++ b/apps/local-server/test/workstream-api.test.ts @@ -75,6 +75,30 @@ afterEach(async () => { }); describe("Workstream API", () => { + it("reports a Git worktree refusal instead of an opaque server error", async () => { + const server = await fixture(); + try { + const response = await server.inject({ + method: "POST", + url: "/workstreams", + payload: { + requestId: "request-refused", + correlationId: "correlation-refused", + title: "Start from missing commit", + task: "Start from missing commit", + startPoint: "a".repeat(40), + repository, + agent, + }, + }); + expect(response.statusCode).toBe(409); + expect(response.json().error).toMatchObject({ code: "conflict" }); + expect(response.json().error.message).not.toBe("Internal server error"); + expect(response.json().error.message).toMatch(/commit/i); + } finally { + await server.close(); + } + }); it("creates, reads, and idempotently cancels one owned workstream", async () => { const server = await fixture(); const createBody = { diff --git a/docs/internal/WINDOWS_BUNDLED_GIT_CANDIDATE.md b/docs/internal/WINDOWS_BUNDLED_GIT_CANDIDATE.md index af13878..aea7651 100644 --- a/docs/internal/WINDOWS_BUNDLED_GIT_CANDIDATE.md +++ b/docs/internal/WINDOWS_BUNDLED_GIT_CANDIDATE.md @@ -1,5 +1,13 @@ # Windows installer: bundled Git and proportional icon +## Current correction — native Workstream lifecycle + +Aaron authorized the native Workstream fix, source-first PR/green CI/merge, and affected installer rebuilds (Discord 1557886044173766749). Reproduction on shipped Windows Node/Git showed a registered worktree rejected because Git reports forward slashes. The native lifecycle regression also exposed short-name versus real-path relative receipts and Windows read-only-handle fsync EPERM during cancellation. Normalize registration paths, derive relative receipts from the validated canonical parent, and open owned persistence files read/write before fsync. Keep all existing ownership checks and propagate bounded authority errors without raw Git stderr. + +The native packaged API regression must create, restore and cancel against real Git using disposable state; synthetic agent references do not claim real harness acceptance. Historical RED and corrected local GREEN are separate from final clean-package proof. Next Windows setup: `0.15.0-rc.2-windows.3`, numeric `0.15.0.5`; AppId/destination/state unchanged. Rebuild Windows ZIP/setup and Linux DEB/tar only after merged-main CI; then repin the npm bootstrap through a separate source-first PR against those fixed archive bytes. Private delivery only, no publication/provider/TEST45495 changes. Never automatically delete branches/worktrees left by earlier failed user attempts. + +## Previous candidates (historical) + Authorized by Aaron (Discord 1557792944902373429): rebuild private Windows setup, install over the prior candidate, preserve state, include private Portable Git so project creation works without system Git, and derive the installer/shortcut icon from the supplied agentintersect_appicon.svg without distortion. Base: merged main 3c4b358. Scope: Windows packaging/launcher, icon generation, focused package tests and native isolated proof. Keep AppId/default directory/state paths unchanged. No machine/user PATH or global Git config writes; do not invent commit identity. Provider changes, public uploads/releases and TEST45495 mutation remain excluded. diff --git a/tooling/release/README.md b/tooling/release/README.md index eb0eb5e..f249042 100644 --- a/tooling/release/README.md +++ b/tooling/release/README.md @@ -12,7 +12,7 @@ Linux packages are not for musl/Alpine or ARM. For headless/remote sessions use ## Windows x64 -Run `AgentIntersect-World-0.15.0-rc.2-windows.2-windows-x64-setup.exe`. Close World’s console before installing over the earlier copy; keep the same destination. The installer keeps its original AppId and state location. It bundles private Git for World and child processes without changing Windows PATH or your Git identity/configuration. First launch initializes this private Git tree. This is a per-user installer: a Start Menu shortcut and registered uninstaller are created. The installer is **unsigned**, so Windows SmartScreen or antivirus may warn. Verify its published SHA-256 checksum and source before running; do not assume a warning means the file was signed or vetted. No administrator access or PATH change is requested. Close the console with `quit` + Enter or Ctrl+C. +Run `AgentIntersect-World-0.15.0-rc.2-windows.3-windows-x64-setup.exe`. Close World’s console before installing over the earlier copy; keep the same destination. The installer keeps its original AppId and state location. It bundles private Git for World and child processes without changing Windows PATH or your Git identity/configuration. First launch initializes this private Git tree. This is a per-user installer: a Start Menu shortcut and registered uninstaller are created. The installer is **unsigned**, so Windows SmartScreen or antivirus may warn. Verify its published SHA-256 checksum and source before running; do not assume a warning means the file was signed or vetted. No administrator access or PATH change is requested. Close the console with `quit` + Enter or Ctrl+C. ## State and integrations diff --git a/tooling/release/build.py b/tooling/release/build.py index 4a579d3..14c5b5d 100644 --- a/tooling/release/build.py +++ b/tooling/release/build.py @@ -289,7 +289,7 @@ def main(): "platform": platform, "runtime": provenance, "git": git_provenance, - "windowsInstallerVersion": "0.15.0-rc.2-windows.2" if platform == "windows-x64" else None, + "windowsInstallerVersion": "0.15.0-rc.2-windows.3" if platform == "windows-x64" else None, "deploymentTool": "pnpm12.4.2", "signed": False, }, diff --git a/tooling/release/windows.iss b/tooling/release/windows.iss index 9a04eda..fa8bccf 100644 --- a/tooling/release/windows.iss +++ b/tooling/release/windows.iss @@ -5,7 +5,7 @@ #error OutputPath is required #endif #ifndef ReleaseVersion - #define ReleaseVersion "0.15.0-rc.2-windows.2" + #define ReleaseVersion "0.15.0-rc.2-windows.3" #endif [Setup] AppId=AgentIntersectWorld @@ -26,9 +26,9 @@ LicenseFile={#PackageRoot}\LICENSE InfoBeforeFile=installer-notice.txt OutputDir={#OutputPath} OutputBaseFilename=AgentIntersect-World-{#ReleaseVersion}-windows-x64-setup -VersionInfoVersion=0.15.0.4 +VersionInfoVersion=0.15.0.5 VersionInfoProductName=AgentIntersect World -VersionInfoProductVersion=0.15.0.4 +VersionInfoProductVersion=0.15.0.5 VersionInfoProductTextVersion={#ReleaseVersion} Compression=lzma2/normal SolidCompression=yes diff --git a/tooling/release/workstream.test.mjs b/tooling/release/workstream.test.mjs new file mode 100644 index 0000000..68ced5c --- /dev/null +++ b/tooling/release/workstream.test.mjs @@ -0,0 +1,127 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const load = (name) => + import(pathToFileURL(resolve(`apps/local-server/dist/${name}.js`)).href); + +test( + "packaged Workstream API creates, restores and cancels a registered worktree", + { timeout: 60000 }, + async () => { + const root = await mkdtemp(join(tmpdir(), "aiw-workstream-package-")); + const repositoryRoot = join(root, "sample repo"); + const worktreeParent = join(root, "worktrees"); + const previousPath = process.env.PATH; + if (process.platform === "win32") + process.env.PATH = `${resolve("runtime/git/cmd")};${previousPath}`; + const git = (...args) => + execFileSync("git", ["-C", repositoryRoot, ...args], { + encoding: "utf8", + env: { + ...process.env, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: join(root, "empty.gitconfig"), + }, + }); + let server; + let service; + let authority; + try { + await mkdir(repositoryRoot); + await mkdir(worktreeParent); + await writeFile(join(root, "empty.gitconfig"), ""); + git("init", "--initial-branch=master"); + git("config", "user.name", "Disposable Workstream Test"); + git("config", "user.email", "test@example.invalid"); + await writeFile( + join(repositoryRoot, "README.md"), + "Native package fixture\n", + ); + git("add", "README.md"); + git("commit", "-m", "fixture"); + const { WorktreeAuthority } = await load("worktree-authority"); + const { WorkstreamService } = await load("workstream-service"); + const { createLocalServer } = await load("server"); + const repository = { + repositoryId: "repo-package", + revision: "repo-rev-1", + }; + const agent = { + agentId: "fixture-agent", + nativeSessionId: "fixture-session", + rootNativeSessionId: "fixture-session", + revision: "agent-rev-1", + }; + authority = new WorktreeAuthority({ + approvedRepositoryRoot: repositoryRoot, + allowedWorktreeParent: worktreeParent, + }); + service = new WorkstreamService({ + directory: join(root, "store"), + worktreeAuthority: authority, + currentRepository: () => repository, + connectedAgent: () => agent, + evidenceReader: { read: async () => [] }, + id: () => "workstream-package", + }); + server = createLocalServer({ workstreamService: service }); + const response = await server.inject({ + method: "POST", + url: "/workstreams", + payload: { + requestId: "create-package", + correlationId: "create-package", + title: "Create native workstream", + task: "Create native workstream", + startPoint: "HEAD", + prIntent: "local", + repository, + agent, + }, + }); + assert.equal(response.statusCode, 201, response.body); + const workstream = response.json().data.workstream; + assert.equal(workstream.status, "working"); + assert.equal( + (await authority.restore(workstream.authority)).state, + "current", + ); + const cancelled = await server.inject({ + method: "POST", + url: "/workstreams/workstream-package/cancel", + payload: { + requestId: "cancel-package", + correlationId: "cancel-package", + expectedRevision: workstream.revision, + repository, + agent, + }, + }); + assert.equal(cancelled.statusCode, 200, cancelled.body); + assert.equal(cancelled.json().data.workstream.worktreeState, "removed"); + assert.equal( + git("worktree", "list", "--porcelain") + .split(/\r?\n/) + .filter((line) => line.startsWith("worktree ")).length, + 1, + ); + } finally { + await server?.close(); + await service?.dispose(); + await authority?.dispose(); + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + await rm(root, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 200, + }); + } + }, +);