Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions apps/local-server/src/workstream-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion apps/local-server/src/workstream-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ function optionalRead(path: string): Promise<string | null> {
}

async function syncFile(path: string): Promise<void> {
const handle = await open(path, "r");
const handle = await open(path, "r+");
try {
await handle.sync();
} finally {
Expand Down
11 changes: 9 additions & 2 deletions apps/local-server/src/worktree-authority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,10 @@ export class WorktreeAuthority {
);
return {
...boundary,
relativePath: relative(boundary.allowedParent, input.worktreePath),
relativePath: relative(
boundary.allowedParent,
join(parent, basename(input.worktreePath)),
),
};
}

Expand Down Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions apps/local-server/test/workstream-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
8 changes: 8 additions & 0 deletions docs/internal/WINDOWS_BUNDLED_GIT_CANDIDATE.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion tooling/release/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion tooling/release/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
6 changes: 3 additions & 3 deletions tooling/release/windows.iss
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
127 changes: 127 additions & 0 deletions tooling/release/workstream.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
});
}
},
);
Loading