Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
7c59ca8
Add progressive workspace affinity
michaelmwu Aug 28, 2026
2ebd3e4
Fail invalid workspace affinity keys terminally
michaelmwu Aug 28, 2026
61a12dd
Resolve affinity context only for opted-in steps
michaelmwu Aug 28, 2026
bf1a9da
Pin reviewed workspace affinity daemon fixes
michaelmwu Aug 28, 2026
d32d4b4
Pin final affinity lifecycle fixes
michaelmwu Aug 28, 2026
bcc5e74
Pin atomic workspace affinity daemon fix
michaelmwu Aug 28, 2026
63cbf68
Pin retryable affinity cleanup fix
michaelmwu Aug 28, 2026
906ef33
Pin opaque affinity key fix
michaelmwu Aug 28, 2026
5f02d21
Preserve opaque workspace affinity keys
michaelmwu Aug 28, 2026
ce3eedd
Complete workspace affinity provider routing
michaelmwu Aug 28, 2026
09978bb
Harden workspace affinity recovery
michaelmwu Aug 28, 2026
0abcc6e
Reject unstable worktrees for workspace affinity
michaelmwu Aug 28, 2026
996c8ec
Pin daemon relationship archive fix
michaelmwu Aug 28, 2026
4207089
Pin daemon affinity startup recovery
michaelmwu Aug 28, 2026
474ab21
Pin daemon resume lease fix
michaelmwu Aug 28, 2026
f2eaf05
Pin widened daemon resume lease
michaelmwu Aug 28, 2026
37588f0
Propagate affinity authority through values
michaelmwu Aug 28, 2026
23edb45
Repin daemon compatibility checks
michaelmwu Aug 28, 2026
5216397
Resolve affinity through named values
michaelmwu Aug 28, 2026
1d221bf
Pin daemon compatibility to latest affinity fix
michaelmwu Aug 28, 2026
43e0581
Pin daemon unarchive affinity fix
michaelmwu Aug 28, 2026
586a82b
Pin daemon affinity restore lease fix
michaelmwu Aug 28, 2026
e33abae
Pin daemon stored-load affinity fix
michaelmwu Aug 28, 2026
6692491
Pin daemon provider import affinity fix
michaelmwu Aug 28, 2026
05d7862
Pin daemon workspace recovery affinity fix
michaelmwu Aug 28, 2026
cafafcc
Pin daemon open-project affinity fix
michaelmwu Aug 28, 2026
9414ebc
Pin daemon workspace race fixes
michaelmwu Aug 29, 2026
49e9c11
Repin affinity E2E daemon revision
michaelmwu Aug 29, 2026
4af46cd
Repin affinity cleanup revision
michaelmwu Aug 29, 2026
40241f5
Merge remote-tracking branch 'origin/main' into workspace-affinity-20…
michaelmwu Aug 31, 2026
09e41f6
Pin repaired Paseo affinity companion
michaelmwu Aug 31, 2026
b9482ea
Preserve daemon session negotiation in Hub E2E proxy
michaelmwu Aug 31, 2026
eef0f54
Grant execution authority in workflow E2E fixtures
michaelmwu Aug 31, 2026
18678bf
Reject unconsumed conversation key values
michaelmwu Sep 1, 2026
8c503f3
Refine workspace affinity validation
michaelmwu Sep 1, 2026
d6d8bae
Merge main and verify affinity across daemon versions
michaelmwu Sep 5, 2026
684716d
Preserve workspace affinity during trigger migration
michaelmwu Sep 5, 2026
06f1a2e
Support workspace affinity in the trigger editor
michaelmwu Sep 6, 2026
f6cf475
Use Linear issue identity for workspace affinity
michaelmwu Sep 6, 2026
c364469
Merge main into workspace affinity branch
michaelmwu Sep 8, 2026
dd4e24f
Integrate latest workspace affinity changes
michaelmwu Sep 8, 2026
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
51 changes: 47 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ concurrency:
permissions:
contents: read

# Replace this marker with the accepted companion commit immediately after the
# companion PR creator commits it. Source-built jobs deliberately fail until it
# is one exact 40-character SHA; mutable refs and repository variables are forbidden.
# Keep the baseline daemon current independently of optional daemon capabilities.
# Both baseline and affinity-capable sources are immutable, separately tested commits.
env:
PASEO_E2E_COMMIT: 76caf0d047bc4437506fbcbbd241da4000769eec
PASEO_E2E_COMMIT: c16d4f14a3ec95485f37aa7fb6201b7c239d9d2a
PASEO_AFFINITY_E2E_COMMIT: bb3a46346c7e781bc963ffb2e0fdbd3f5ed3efa3

jobs:
typecheck:
Expand Down Expand Up @@ -168,6 +168,14 @@ jobs:
run: npx playwright install --with-deps chromium
- name: Run built-app browser tests
run: npm run test:e2e:browser
- name: Attach workspace-affinity editor evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: workspace-affinity-editor
path: e2e/screenshots/triggers/affinity-*.png
if-no-files-found: ignore
retention-days: 14

docker-smoke:
runs-on: ubuntu-latest
Expand All @@ -176,6 +184,41 @@ jobs:
- name: Build and smoke-test production image
run: npm run docker:smoke

workspace-affinity-e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
affinity: [false, true]
env:
PASEO_E2E_WORKTREE: ${{ github.workspace }}/paseo-source
PASEO_E2E_AFFINITY_SUPPORTED: ${{ matrix.affinity }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Require immutable baseline and affinity daemon commits
run: |
[[ "$PASEO_E2E_COMMIT" =~ ^[0-9a-f]{40}$ ]]
[[ "$PASEO_AFFINITY_E2E_COMMIT" =~ ^[0-9a-f]{40}$ ]]
- name: Check out exact Paseo source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: getpaseo/paseo
ref: ${{ matrix.affinity && env.PASEO_AFFINITY_E2E_COMMIT || env.PASEO_E2E_COMMIT }}
path: paseo-source
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
cache: "npm"
- name: Build exact Paseo source
working-directory: paseo-source
run: |
npm ci
npm run build:server
- run: npm ci
- run: npm run build
- name: Verify workspace affinity and legacy fallback
run: npm run test:e2e:hub:affinity

hub-e2e:
runs-on: ubuntu-latest
env:
Expand Down
16 changes: 12 additions & 4 deletions MAINTAINERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,20 @@ npm run build
npm run docker:smoke
npm run test:e2e:browser
npm run test:e2e:hub:source
npm run test:e2e:hub:affinity
```

The source-built browser and Hub suites use the exact Paseo commit in `PASEO_E2E_COMMIT`.
When a Hub change depends on a Paseo protocol or CLI change, update that immutable SHA and
prove the combined contract before merging. Do not replace it with a branch or another mutable
reference.
The source-built browser and Hub suites use the exact baseline Paseo commit in `PASEO_E2E_COMMIT`.
When a Hub change depends on a Paseo protocol or CLI change, update that immutable SHA and prove
the combined contract before merging. Do not replace it with a branch or another mutable reference.

Workspace affinity is optional, so CI also pins `PASEO_AFFINITY_E2E_COMMIT` independently. The
affinity suite runs against both commits: the baseline must complete executions with the existing
fresh-workspace behavior, while the capable daemon must retain, reuse, and restore a workspace
without reusing the agent. Set `PASEO_E2E_WORKTREE` to the built source checkout and
`PASEO_E2E_AFFINITY_SUPPORTED` to `false` or `true` respectively when running that suite locally.
Advance the baseline for unrelated daemon improvements without dropping the capable contract
test or forcing the unmerged affinity companion to carry those changes.

Use the repository formatter through `npm run format` or `npm run format:files`. This repository
uses Oxfmt, not Prettier.
Expand Down
52 changes: 52 additions & 0 deletions docs/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,58 @@ Hub sends each daemon the authored rendered prompt unchanged. Execution tools ar

Daemon environments may author `worktree.newBranch: "trigger-${{ paseo.execution.id }}"` for a stable branch name unique to each agent execution. Hub materializes the execution UUID before persisting or dispatching the launch intent; recovery reuses that fully rendered intent. This is independent of whether a manual, Slack, Discord, GitHub, or Linear trigger selected the reusable environment. No prompt, context, input, value, step output, or provider event namespace is available in environment configuration, and unsupported expressions fail bundle activation at the authored `newBranch` field.

## Workspace affinity
Comment thread
michaelmwu marked this conversation as resolved.

Workspace affinity is an explicit, step-scoped opt-in for a daemon workspace shared by related
trigger arrivals. For example, an agent that replies to Slack threads can retain one workspace per
thread:

```yaml
triggers:
- name: respond-to-slack
on: slack.mention
filters:
from_users: ["U0123456789"]
max_runtime: 2h
steps:
Comment thread
michaelmwu marked this conversation as resolved.
- id: respond
environment: runner
max_runtime: 30m
idle_timeout: 5m
auto_archive: true
workspace_affinity:
key: "slack-thread:${{ paseo.trigger.conversation_key }}"
agent: { provider: codex }
prompt: [{ text: "Respond to the thread." }]
```

`paseo.trigger.conversation_key` is a provider-authenticated identifier, not prompt text. Slack
keys identify the connection, workspace, channel, and root thread; Discord keys identify the
connection, guild, channel, and thread (or its starter message); GitHub keys identify the
connection, repository, issue or pull request, and number. A key may also be a literal custom key,
or be composed from finite declared inputs, values, and outputs. Prompt text, ambient context, the
execution ID, and unbounded values are rejected so an untrusted event cannot select an existing
workspace.

Hub sends only the opaque key and the triggering workflow's `max_runtime` deadline. Daemons that
support workspace affinity acknowledge the request, hash and persist their key-to-workspace
mapping, and reuse an active workspace or restore it if it was archived. Each matching arrival
extends retention to the later workflow deadline. With `auto_archive: true`, a supporting daemon
archives the workspace at that retained deadline—not at `idle_timeout`. `idle_timeout` remains an
in-execution liveness deadline. With `auto_archive: false`, Hub does not request affinity-driven
workspace archiving.

Workspace affinity is a progressive daemon capability. Older daemons safely ignore the optional
request and continue creating and archiving fresh workspaces with their existing behavior; Hub does
not reject those executions or require an immediate daemon upgrade. Exact reuse, retention, and
archived-workspace restoration begin after the daemon is updated to a version that acknowledges
workspace affinity.

Affinity does not serialize matching executions: each gets its own agent in the shared workspace.
All uses of a key must keep the same daemon target, cwd, worktree target, and auto-archive policy;
the daemon rejects a mismatch rather than mixing state. In particular, an execution-ID-derived
worktree branch is incompatible with reuse and is rejected during configuration compilation.

`deliveryKey` is caller-supplied request identity for the existing durable manual-event path. Hub namespaces it by the authenticated organization and resolved project before persistence, so the same caller key can be used independently in different tenants or projects. Existing receipt/run de-duplication applies, but this API does not promise exactly-once execution or guaranteed response replay; retries can still fail or conflict during restart and timing races. A successful representation contains `deliveryKey`, `providerEventReceiptId`, `triggerRunId`, `configuredTriggerName`, and the durable `workflowStatus`.

The self-hosted Scalar reference is served with a restrictive Content Security Policy and does not require external fonts, scripts, telemetry, registries, or proxies.
Expand Down
2 changes: 2 additions & 0 deletions docs/trigger-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ migration finishes before provider events are accepted and is safe to retry afte

- Event type, filters, connection routing, and invocation inputs
- Daemon, working directory, and worktree behavior
- Workspace affinity keys, retention deadlines, and auto-archive policy
- Agent provider configuration and finite agent selection
- The rendered prompt text, including resolved prompt partial content
- Environment variables, GitHub authority, structured output, output grants, and timeouts
Expand All @@ -18,6 +19,7 @@ migration finishes before provider events are accepted and is safe to retry afte
These affect authoring or presentation, not what the active trigger is allowed to do:

- YAML comments, whitespace, key ordering, anchors, and quoting style are regenerated.
String values such as an affinity key retain their exact bytes, including surrounding whitespace.
- Prompt partial boundaries and file names disappear after their resolved content is inlined.
- Shared environment and agent names disappear after their values are inlined.
- A converted one-run workflow uses the internal step ID `run`; the former step ID remains only in
Expand Down
40 changes: 40 additions & 0 deletions docs/workflow-authority.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,46 @@
Workflow authority is authored on an individual step. It is not a trigger option,
agent option, sandbox setting, or Paseo daemon feature.

## Workspace selection

An affinity key can select files left by earlier work, so it is an authority-bearing field.
Both `run.workspace_affinity.key` in a trigger document and `steps[].workspace_affinity.key` in a
legacy bundle use the same compiler validation. The editor preserves the authored key exactly and
never enables affinity by default. Prompt/context text cannot choose an existing workspace.

Keep four identities separate:

| Identity | Owns | Continuity |
| ---------------------- | ------------------------------------------------ | ----------------------------------------- |
| Workspace affinity key | Checkout and files | Related trigger arrivals |
| Hub execution ID | Completion, output grants, deadline, and retries | One step execution |
| External session ID | Provider reply destination and lifecycle signals | For example, one Linear Agent Session |
| Provider agent ID | Provider conversation/history | A new agent per execution in this feature |

Linear's issue UUID is the conversation boundary, scoped by the authenticated connection and
Linear organization. Mutable identifiers, comment IDs, and delivery IDs cannot fragment that
identity. The Agent Sessions integration in [#88](https://github.com/getpaseo/hub/pull/88) can use
the same identity fields, but must add its implemented event names to conversation-key validation
and test multiple sessions on one issue, different issues/connections, and session-scoped replies
and cancellation. This PR does not enable unimplemented session events or resume provider agents.

Hub owns configuration validation, trusted provider identity, and the durable launch intent.
The daemon owns atomic workspace binding, restoration, and safe expiry. Hub's restricted
`hub.execute` permission cannot restore existing workspaces on an older daemon. Requesting general
workspace-management authority to emulate affinity would broaden the operator's permissions and
duplicate daemon-local lifecycle state; do not do that implicitly.

The latest arriving workflow deadline retains the workspace across gaps between executions.
Execution `idle_timeout` cannot represent those gaps because no agent need be running then.
Affinity does not serialize writers or preserve uncommitted files through destructive archival.
Those guarantees need separate, explicit policies; a shared key must not silently enable either.

An old daemon may successfully execute a launch while ignoring affinity. Keep the baseline and
capable-daemon integration tests separate, as described in [MAINTAINERS.md](../MAINTAINERS.md).
The optional `workspaceAffinityApplied` acknowledgement is accepted on the wire but is not yet
retained in execution state. Operator-visible application status remains follow-up work: missing
acknowledgement must be distinguished from a confirmed application, including after reconnect.

## Generic connection values

Step environment values may explicitly request a named value from a configured
Expand Down
7 changes: 6 additions & 1 deletion e2e/helpers/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1637,11 +1637,16 @@ export class PaseoHub {
this.hubCredentials.set(alias, credential);
}

async approveDaemon(alias: string, displayName: string): Promise<string> {
async approveDaemon(
alias: string,
displayName: string,
permissions: readonly string[] = [],
): Promise<string> {
const credential = this.requireHubCredential(alias);
const result = await this.requireSourcePaseo().connectWithCredential(
this.primary.origin,
credential,
permissions,
);
const daemonId = z.string().uuid().parse(result["daemonId"]);
await this.queryDatabase(this.primary, "update daemons set slug = $2 where id = $1", [
Expand Down
15 changes: 15 additions & 0 deletions e2e/helpers/triggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,21 @@ export class OrganizationTriggers {
await this.page.getByLabel("Instructions", { exact: true }).fill(prompt);
}

async changeWorkspaceAffinity(key: string) {
const toggle = this.page.getByRole("button", { name: "Workspace affinity", exact: false });
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click();
await this.page.getByLabel("Affinity key", { exact: true }).fill(key);
await expect(
this.page
.getByRole("alert")
.filter({ hasText: "Workspace sharing, not session continuation" }),
).toContainText("Older daemons ignore affinity and create fresh workspaces.");
}

async expectWorkspaceAffinity(key: string) {
await expect(this.page.getByLabel("Affinity key", { exact: true })).toHaveValue(key);
}

async expectMergeTagsAndAutosizing() {
const instructions = this.page.getByLabel("Instructions", { exact: true });
await instructions.fill("Start finish");
Expand Down
48 changes: 48 additions & 0 deletions e2e/triggers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,54 @@ test("creates a trigger visually, preserves advanced YAML through the form, and
});
});

test("authors, persists, and explicitly disables workspace affinity in the trigger form", async ({
hub,
page,
}) => {
await hub.signUpAs("owner", owner);
await hub.createOrganization("owner", "Acme");
const daemon = await hub.connectProviderDaemon("owner", "Acme");
await hub.seedSlackConnection("owner", "company-slack", "Acme Slack");
const triggers = new OrganizationTriggers(page);
const key = " triage:${{ paseo.trigger.conversation_key }} ";

await triggers.open();
await triggers.startNew();
await triggers.configureSlackMention({
name: "affinity",
connection: "company-slack",
daemon,
cwd: "/workspace/acme",
users: "U123",
agent: "pi/gateway/vendor/model-v1",
mode: "full-access",
thinking: "high",
providerOptions: "",
prompt: "Handle the request.",
});
await triggers.changeWorkspaceAffinity(key);
await triggers.capture(`${SHOTS}/affinity-desktop.png`);
await page.setViewportSize({ width: 390, height: 844 });
await page.getByLabel("Affinity key", { exact: true }).scrollIntoViewIfNeeded();
await triggers.capture(`${SHOTS}/affinity-mobile.png`);
await page.setViewportSize({ width: 1280, height: 720 });
await triggers.save("affinity");
await triggers.openTrigger("affinity");
await triggers.expectWorkspaceAffinity(key);
await triggers.changePrompt("Updated instructions must not reset the workspace key.");
await triggers.save("affinity");
await triggers.openTrigger("affinity");
await triggers.expectWorkspaceAffinity(key);
await triggers.switchToYaml();
await triggers.expectYamlContains("workspace_affinity:", key);
await page.getByRole("radio", { name: "Form", exact: true }).click();
await triggers.changeWorkspaceAffinity("");
await triggers.save("affinity");
await triggers.openTrigger("affinity");
await triggers.switchToYaml();
await expect(page.locator(".cm-content")).not.toContainText("workspace_affinity");
});

for (const scenario of [
{ event: "github.issue_label_added", name: "issue-label" },
{ event: "github.pull_request_label_added", name: "pr-label" },
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"test:scripts": "node --test scripts/release-metadata.node-test.mjs scripts/paseo-workspace.node-test.mjs",
"test:release": "node --test scripts/release-metadata.node-test.mjs",
"test:e2e:hub:source": "RUN_HUB_E2E=1 vitest run src/e2e/hub-foundation.e2e.test.ts src/e2e/hub-attachments.e2e.test.ts --bail=1",
"test:e2e:hub:affinity": "RUN_HUB_AFFINITY_E2E=1 vitest run src/e2e/hub-workspace-affinity.e2e.test.ts --bail=1",
"test:e2e:hub:real-agent": "RUN_HUB_REAL_AGENT_E2E=1 vitest run src/e2e/hub-real-agent.e2e.test.ts --bail=1",
"test:e2e:browser": "npm run build && playwright test",
"format": "oxfmt .",
Expand Down
Loading
Loading